diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 54cf66ea75b..60f40115b27 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -8251,7 +8251,7 @@ }, { "kind": "ui-localized-call", - "line": 110, + "line": 114, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Unable to Export Transcript", "surface": "apple", @@ -8259,7 +8259,7 @@ }, { "kind": "ui-call", - "line": 114, + "line": 118, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "OK", "surface": "apple", @@ -8267,7 +8267,7 @@ }, { "kind": "ui-call", - "line": 118, + "line": 122, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "OpenClaw could not prepare the Markdown file.", "surface": "apple", @@ -8275,7 +8275,7 @@ }, { "kind": "ui-localized-call", - "line": 138, + "line": 143, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "What would you like to work on?", "surface": "apple", @@ -8283,7 +8283,7 @@ }, { "kind": "ui-call", - "line": 149, + "line": 154, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "The session attaches once the gateway is ready.", "surface": "apple", @@ -8291,7 +8291,7 @@ }, { "kind": "ui-modifier", - "line": 238, + "line": 253, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Opens Settings / Gateway", "surface": "apple", @@ -8299,7 +8299,7 @@ }, { "kind": "ui-call", - "line": 263, + "line": 278, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "New Chat", "surface": "apple", @@ -8307,7 +8307,7 @@ }, { "kind": "ui-call", - "line": 276, + "line": 291, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "New Chat in Worktree", "surface": "apple", @@ -8315,7 +8315,7 @@ }, { "kind": "ui-call", - "line": 291, + "line": 306, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Export Transcript", "surface": "apple", @@ -8323,7 +8323,7 @@ }, { "kind": "ui-modifier", - "line": 301, + "line": 316, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Chat actions", "surface": "apple", @@ -8331,7 +8331,7 @@ }, { "kind": "conditional-branch", - "line": 354, + "line": 369, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Connected", "surface": "apple", @@ -8339,31 +8339,39 @@ }, { "kind": "conditional-branch", - "line": 354, + "line": 369, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Unavailable", "surface": "apple", "id": "native.apple.04934af85e3b9d6e" }, { - "kind": "conditional-branch", - "line": 365, - "path": "apps/ios/Sources/Design/ChatProTab.swift", - "source": "Connect to a gateway", - "surface": "apple", - "id": "native.apple.889fe76e02b8ad5d" - }, - { - "kind": "conditional-branch", - "line": 365, + "kind": "ui-localized-call", + "line": 381, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Message \\(self.agentDisplayName)...", "surface": "apple", - "id": "native.apple.e8173c7d24457256" + "id": "native.apple.4dcedefea4b49a1c" }, { "kind": "ui-localized-call", - "line": 419, + "line": 384, + "path": "apps/ios/Sources/Design/ChatProTab.swift", + "source": "Message \\(self.agentDisplayName); sends when connected", + "surface": "apple", + "id": "native.apple.84767ec18054524b" + }, + { + "kind": "ui-localized-call", + "line": 386, + "path": "apps/ios/Sources/Design/ChatProTab.swift", + "source": "Connect to a gateway", + "surface": "apple", + "id": "native.apple.11b20bef7e51ab69" + }, + { + "kind": "ui-localized-call", + "line": 454, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Check OpenClaw status", "surface": "apple", @@ -8371,7 +8379,7 @@ }, { "kind": "ui-localized-call", - "line": 420, + "line": 455, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Summarize the current OpenClaw status and tell me what needs attention.", "surface": "apple", @@ -8379,7 +8387,7 @@ }, { "kind": "ui-localized-call", - "line": 423, + "line": 458, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "What can I control here?", "surface": "apple", @@ -8387,7 +8395,7 @@ }, { "kind": "ui-localized-call", - "line": 424, + "line": 459, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Show me which phone controls and device capabilities are available right now.", "surface": "apple", @@ -8395,7 +8403,7 @@ }, { "kind": "ui-localized-call", - "line": 427, + "line": 462, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Help me start voice chat", "surface": "apple", @@ -8403,7 +8411,7 @@ }, { "kind": "ui-localized-call", - "line": 428, + "line": 463, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Help me start a realtime voice session from this phone.", "surface": "apple", @@ -12443,7 +12451,7 @@ }, { "kind": "conditional-branch", - "line": 2757, + "line": 2840, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Action required", "surface": "apple", @@ -12451,7 +12459,7 @@ }, { "kind": "conditional-branch", - "line": 2757, + "line": 2840, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Approval needed", "surface": "apple", @@ -12459,7 +12467,7 @@ }, { "kind": "conditional-branch", - "line": 3315, + "line": 3398, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connecting…", "surface": "apple", @@ -12467,7 +12475,7 @@ }, { "kind": "conditional-branch", - "line": 3315, + "line": 3398, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Reconnecting…", "surface": "apple", @@ -12475,7 +12483,7 @@ }, { "kind": "conditional-branch", - "line": 3319, + "line": 3402, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connecting...", "surface": "apple", @@ -12483,7 +12491,7 @@ }, { "kind": "conditional-branch", - "line": 3319, + "line": 3402, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Reconnecting...", "surface": "apple", @@ -12491,7 +12499,7 @@ }, { "kind": "conditional-branch", - "line": 3610, + "line": 3694, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connected", "surface": "apple", @@ -12499,7 +12507,7 @@ }, { "kind": "conditional-branch", - "line": 3610, + "line": 3694, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Offline", "surface": "apple", @@ -21523,7 +21531,7 @@ }, { "kind": "ui-call", - "line": 197, + "line": 198, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Thinking", "surface": "apple", @@ -21531,7 +21539,7 @@ }, { "kind": "ui-call", - "line": 218, + "line": 219, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Model", "surface": "apple", @@ -21539,7 +21547,7 @@ }, { "kind": "ui-call", - "line": 235, + "line": 236, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Pinned", "surface": "apple", @@ -21547,7 +21555,7 @@ }, { "kind": "ui-call", - "line": 243, + "line": 244, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Recent", "surface": "apple", @@ -21555,7 +21563,7 @@ }, { "kind": "ui-call", - "line": 251, + "line": 252, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Models", "surface": "apple", @@ -21563,7 +21571,7 @@ }, { "kind": "conditional-branch", - "line": 280, + "line": 281, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Pin model", "surface": "apple", @@ -21571,7 +21579,7 @@ }, { "kind": "conditional-branch", - "line": 280, + "line": 281, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Unpin model", "surface": "apple", @@ -21579,7 +21587,7 @@ }, { "kind": "ui-call", - "line": 284, + "line": 285, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Session", "surface": "apple", @@ -21587,7 +21595,7 @@ }, { "kind": "ui-modifier", - "line": 348, + "line": 349, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Add Image", "surface": "apple", @@ -21595,7 +21603,7 @@ }, { "kind": "ui-modifier", - "line": 349, + "line": 350, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Attachments", "surface": "apple", @@ -21603,7 +21611,7 @@ }, { "kind": "conditional-branch", - "line": 519, + "line": 520, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Talk", "surface": "apple", @@ -21611,7 +21619,7 @@ }, { "kind": "conditional-branch", - "line": 565, + "line": 566, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Start realtime chat", "surface": "apple", @@ -21619,7 +21627,7 @@ }, { "kind": "conditional-branch", - "line": 565, + "line": 566, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Stop realtime chat", "surface": "apple", @@ -21627,7 +21635,7 @@ }, { "kind": "conditional-branch", - "line": 598, + "line": 599, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Start", "surface": "apple", @@ -21635,7 +21643,7 @@ }, { "kind": "conditional-branch", - "line": 598, + "line": 599, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Stop", "surface": "apple", @@ -21643,7 +21651,7 @@ }, { "kind": "ui-call", - "line": 708, + "line": 709, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Loading commands", "surface": "apple", @@ -21651,7 +21659,7 @@ }, { "kind": "ui-call", - "line": 717, + "line": 718, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Commands unavailable", "surface": "apple", @@ -21659,7 +21667,7 @@ }, { "kind": "ui-call", - "line": 726, + "line": 727, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Retry", "surface": "apple", @@ -21667,7 +21675,7 @@ }, { "kind": "ui-call", - "line": 735, + "line": 736, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "No matching commands", "surface": "apple", @@ -21675,7 +21683,7 @@ }, { "kind": "ui-modifier", - "line": 867, + "line": 868, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Stop response", "surface": "apple", @@ -21683,7 +21691,7 @@ }, { "kind": "ui-modifier", - "line": 892, + "line": 893, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Send message", "surface": "apple", @@ -21691,7 +21699,7 @@ }, { "kind": "ui-modifier", - "line": 907, + "line": 908, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Refresh", "surface": "apple", @@ -21699,7 +21707,7 @@ }, { "kind": "conditional-branch", - "line": 1015, + "line": 1018, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Connecting...", "surface": "apple", @@ -21707,7 +21715,7 @@ }, { "kind": "conditional-branch", - "line": 1015, + "line": 1018, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Gateway connected", "surface": "apple", @@ -21765,13 +21773,29 @@ "kind": "conditional-branch", "line": 644, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", + "source": "Confirming…", + "surface": "apple", + "id": "native.apple.38722ea25417ad4f" + }, + { + "kind": "conditional-branch", + "line": 646, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", + "source": "Delivery unknown", + "surface": "apple", + "id": "native.apple.78682085b14232f8" + }, + { + "kind": "conditional-branch", + "line": 648, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Not sent", "surface": "apple", "id": "native.apple.2888a268bfa49e9e" }, { "kind": "conditional-branch", - "line": 662, + "line": 670, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Queued, sends when reconnected", "surface": "apple", @@ -21779,7 +21803,7 @@ }, { "kind": "conditional-branch", - "line": 664, + "line": 672, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Sending", "surface": "apple", @@ -21787,7 +21811,23 @@ }, { "kind": "conditional-branch", - "line": 666, + "line": 674, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", + "source": "Sent, waiting for chat history confirmation", + "surface": "apple", + "id": "native.apple.bfe7aab77abe9a2c" + }, + { + "kind": "conditional-branch", + "line": 676, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", + "source": "Delivery unconfirmed, touch and hold to retry or delete", + "surface": "apple", + "id": "native.apple.7eb6eca416d33bb4" + }, + { + "kind": "conditional-branch", + "line": 678, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Not sent, touch and hold to retry or delete", "surface": "apple", @@ -21795,7 +21835,7 @@ }, { "kind": "ui-call", - "line": 761, + "line": 773, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Running tools…", "surface": "apple", @@ -21803,7 +21843,7 @@ }, { "kind": "ui-call", - "line": 769, + "line": 781, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "\\(display.emoji) \\(display.label)", "surface": "apple", @@ -21819,7 +21859,7 @@ }, { "kind": "ui-call", - "line": 416, + "line": 420, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Retry Send", "surface": "apple", @@ -21827,7 +21867,7 @@ }, { "kind": "ui-call", - "line": 431, + "line": 434, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Delete", "surface": "apple", @@ -21835,7 +21875,7 @@ }, { "kind": "ui-call", - "line": 485, + "line": 488, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Jump to latest", "surface": "apple", @@ -21843,7 +21883,7 @@ }, { "kind": "ui-modifier", - "line": 496, + "line": 499, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Jump to latest reply", "surface": "apple", @@ -21851,7 +21891,7 @@ }, { "kind": "ui-named-argument", - "line": 516, + "line": 519, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Refresh", "surface": "apple", @@ -21859,7 +21899,7 @@ }, { "kind": "ui-call", - "line": 941, + "line": 944, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Loading chat", "surface": "apple", @@ -21867,7 +21907,7 @@ }, { "kind": "ui-modifier", - "line": 1021, + "line": 1024, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Dismiss", "surface": "apple", @@ -21881,6 +21921,22 @@ "surface": "apple", "id": "native.apple.82c2287cd78da56f" }, + { + "kind": "conditional-branch", + "line": 1350, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift", + "source": "delivery unconfirmed", + "surface": "apple", + "id": "native.apple.2b45abdc56b2caed" + }, + { + "kind": "conditional-branch", + "line": 1350, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift", + "source": "queued after route change", + "surface": "apple", + "id": "native.apple.722f1f90b97e8e45" + }, { "kind": "conditional-branch", "line": 235, diff --git a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift index 1de18d6ac04..a05dc88e585 100644 --- a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift +++ b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift @@ -10,6 +10,11 @@ struct IOSGatewayChatTransport: OpenClawChatTransport { static let compactionRequestTimeoutSeconds = 0 private let gateway: GatewayNodeSession private let globalAgentId: String? + private let outboxGatewayID: String? + + var outboxRequiresSessionRoutingContract: Bool { + true + } private struct CreateSessionParams: Codable { var key: String @@ -87,6 +92,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport { private struct ChatSendParams: Codable { var sessionKey: String var agentId: String? + var expectedSessionRoutingContract: String? var message: String var thinking: String var attachments: [OpenClawChatAttachmentPayload]? @@ -126,10 +132,73 @@ struct IOSGatewayChatTransport: OpenClawChatTransport { } } - init(gateway: GatewayNodeSession, globalAgentId: String? = nil) { + init( + gateway: GatewayNodeSession, + globalAgentId: String? = nil, + outboxGatewayID: String? = nil) + { self.gateway = gateway let normalized = globalAgentId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() self.globalAgentId = normalized?.isEmpty == false ? normalized : nil + let normalizedGatewayID = outboxGatewayID?.trimmingCharacters(in: .whitespacesAndNewlines) + self.outboxGatewayID = normalizedGatewayID?.isEmpty == false ? normalizedGatewayID : nil + } + + func acquireOutboxRouteLease() async -> OpenClawChatTransportRouteLeaseResult { + guard let outboxGatewayID, + let route = await gateway.currentRoute(ifGatewayID: outboxGatewayID) + else { return .unavailable(reason: nil) } + guard let supportsRoutingContract = await gateway.supportsServerCapability( + .chatSendRoutingContract, + ifCurrentRoute: route) + else { return .unavailable(reason: nil) } + guard supportsRoutingContract else { + return .unavailable(reason: OpenClawChatTransportUpgradeMessage.routingContract) + } + let transport = self + guard let routingContract = try? await transport.sessionRoutingContract(ifCurrentRoute: route) + else { return .unavailable(reason: nil) } + return .available(OpenClawChatTransportRouteLease( + sendTargetedMessage: { sessionKey, agentID, message, thinking, idempotencyKey, attachments in + try await transport.sendMessage( + sessionKey: sessionKey, + agentID: agentID, + expectedSessionRoutingContract: routingContract, + message: message, + thinking: thinking, + idempotencyKey: idempotencyKey, + attachments: attachments, + ifCurrentRoute: route, + distinguishPreDispatchRouteChange: true) + }, + requestTargetedHistory: { sessionKey, agentID in + try await transport.requestHistory( + sessionKey: sessionKey, + agentID: agentID, + ifCurrentRoute: route) + }, + sessionRoutingContract: routingContract)) + } + + private func sessionRoutingContract( + ifCurrentRoute route: GatewayNodeSessionRoute) async throws -> String + { + let data = try await gateway.request( + method: "agents.list", + paramsJSON: "{}", + timeoutSeconds: 15, + ifCurrentRoute: route) + return try Self.decodeSessionRoutingContract(data) + } + + static func decodeSessionRoutingContract(_ data: Data) throws -> String { + let result = try JSONDecoder().decode(AgentsListResult.self, from: data) + guard let contract = OpenClawChatSessionRoutingContract.make( + scope: result.scope.value as? String, + mainKey: result.mainkey, + defaultAgentID: result.defaultid) + else { throw CancellationError() } + return contract } static func agentWaitRequestTimeoutSeconds(timeoutMs: Int) -> Int { @@ -173,6 +242,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport { static func makeChatSendParamsJSON( sessionKey: String, agentId: String? = nil, + expectedSessionRoutingContract: String? = nil, message: String, thinking: String, idempotencyKey: String, @@ -181,6 +251,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport { let params = ChatSendParams( sessionKey: sessionKey, agentId: agentId, + expectedSessionRoutingContract: expectedSessionRoutingContract, message: message, thinking: thinking, attachments: attachments.isEmpty ? nil : attachments, @@ -304,27 +375,71 @@ struct IOSGatewayChatTransport: OpenClawChatTransport { : nil } + struct SessionTarget: Equatable { + var sessionKey: String + var agentID: String? + } + + static func sessionTarget( + for rawSessionKey: String, + selectedAgentID: String?, + overrideAgentID: String? = nil) -> SessionTarget + { + let sessionKey = rawSessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + let selected = selectedAgentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let override = overrideAgentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if self.agentID(fromSessionKey: sessionKey) != nil { + return SessionTarget(sessionKey: sessionKey, agentID: override) + } + if sessionKey.lowercased().hasPrefix("agent:") { + return SessionTarget(sessionKey: sessionKey, agentID: nil) + } + if sessionKey.lowercased() == "unknown" { + return SessionTarget(sessionKey: sessionKey, agentID: nil) + } + let targetAgentID = override ?? (selected?.isEmpty == false ? selected : nil) + if sessionKey.lowercased() == "global" { + return SessionTarget(sessionKey: sessionKey, agentID: targetAgentID) + } + if let targetAgentID { + return SessionTarget(sessionKey: "agent:\(targetAgentID):\(sessionKey)", agentID: nil) + } + return SessionTarget(sessionKey: sessionKey, agentID: nil) + } + + private func sessionTarget( + for sessionKey: String, + overrideAgentID: String? = nil) -> SessionTarget + { + Self.sessionTarget( + for: sessionKey, + selectedAgentID: self.globalAgentId, + overrideAgentID: overrideAgentID) + } + func createSession( key: String, label: String?, parentSessionKey: String?, worktree: Bool?) async throws -> OpenClawChatCreateSessionResponse { + let target = self.sessionTarget(for: key) + let parentTarget = parentSessionKey.map { self.sessionTarget(for: $0) } let json = try Self.makeCreateSessionParamsJSON( - key: key, - agentId: Self.agentID(fromSessionKey: key) ?? - parentSessionKey.flatMap { self.selectedGlobalAgentId(for: $0) }, + key: target.sessionKey, + agentId: target.agentID ?? parentTarget?.agentID, label: label, - parentSessionKey: parentSessionKey, + parentSessionKey: parentTarget?.sessionKey, worktree: worktree) let res = try await self.gateway.request(method: "sessions.create", paramsJSON: json, timeoutSeconds: 15) return try JSONDecoder().decode(OpenClawChatCreateSessionResponse.self, from: res) } func abortRun(sessionKey: String, runId: String) async throws { + let target = self.sessionTarget(for: sessionKey) let json = try Self.makeRunParamsJSON( - sessionKey: sessionKey, - agentId: self.selectedGlobalAgentId(for: sessionKey), + sessionKey: target.sessionKey, + agentId: target.agentID, runId: runId) _ = try await self.gateway.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10) } @@ -348,9 +463,10 @@ struct IOSGatewayChatTransport: OpenClawChatTransport { } func setSessionModel(sessionKey: String, model: String?) async throws { + let target = self.sessionTarget(for: sessionKey) let json = try Self.makeSessionPatchModelParamsJSON( - sessionKey: sessionKey, - agentId: self.selectedGlobalAgentId(for: sessionKey), + sessionKey: target.sessionKey, + agentId: target.agentID, model: model) _ = try await self.gateway.request(method: "sessions.patch", paramsJSON: json, timeoutSeconds: 15) } @@ -395,9 +511,8 @@ struct IOSGatewayChatTransport: OpenClawChatTransport { var key: String var agentId: String? } - let data = try JSONEncoder().encode(Params( - key: sessionKey, - agentId: self.selectedGlobalAgentId(for: sessionKey))) + let target = self.sessionTarget(for: sessionKey) + let data = try JSONEncoder().encode(Params(key: target.sessionKey, agentId: target.agentID)) let json = String(data: data, encoding: .utf8) _ = try await self.gateway.request( method: "sessions.messages.subscribe", @@ -406,17 +521,15 @@ struct IOSGatewayChatTransport: OpenClawChatTransport { } func resetSession(sessionKey: String) async throws { - let json = try Self.makeSessionKeyParamsJSON( - sessionKey, - agentId: self.selectedGlobalAgentId(for: sessionKey)) + let target = self.sessionTarget(for: sessionKey) + let json = try Self.makeSessionKeyParamsJSON(target.sessionKey, agentId: target.agentID) _ = try await self.gateway.request(method: "sessions.reset", paramsJSON: json, timeoutSeconds: 10) } func compactSession(sessionKey: String) async throws { - let json = try Self.makeSessionKeyParamsJSON( - sessionKey, - agentId: self.selectedGlobalAgentId(for: sessionKey)) - let response = try await self.gateway.request( + let target = self.sessionTarget(for: sessionKey) + let json = try Self.makeSessionKeyParamsJSON(target.sessionKey, agentId: target.agentID) + let response = try await gateway.request( method: "sessions.compact", paramsJSON: json, timeoutSeconds: Self.compactionRequestTimeoutSeconds) @@ -424,17 +537,19 @@ struct IOSGatewayChatTransport: OpenClawChatTransport { } func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { - try await self.requestHistory(sessionKey: sessionKey, ifCurrentRoute: nil) + try await self.requestHistory(sessionKey: sessionKey, agentID: nil, ifCurrentRoute: nil) } func requestHistory( sessionKey: String, + agentID: String? = nil, ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?) async throws -> OpenClawChatHistoryPayload { + let target = self.sessionTarget(for: sessionKey, overrideAgentID: agentID) let json = try Self.makeHistoryParamsJSON( - sessionKey: sessionKey, - agentId: self.selectedGlobalAgentId(for: sessionKey)) - let res = try await self.gateway.request( + sessionKey: target.sessionKey, + agentId: target.agentID) + let res = try await gateway.request( method: "chat.history", paramsJSON: json, timeoutSeconds: 15, @@ -449,8 +564,8 @@ struct IOSGatewayChatTransport: OpenClawChatTransport { func listCommands(sessionKey: String) async throws -> [OpenClawChatCommandChoice] { let json = try Self.makeCommandsListParamsJSON( sessionKey: sessionKey, - agentId: self.selectedGlobalAgentId(for: sessionKey)) - let res = try await self.gateway.request(method: "commands.list", paramsJSON: json, timeoutSeconds: 15) + agentId: Self.agentID(fromSessionKey: sessionKey) ?? self.globalAgentId) + let res = try await gateway.request(method: "commands.list", paramsJSON: json, timeoutSeconds: 15) let decoded = try JSONDecoder().decode(CommandsListResult.self, from: res) return decoded.commands.map(Self.mapCommandChoice) } @@ -464,6 +579,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport { { try await self.sendMessage( sessionKey: sessionKey, + agentID: nil, message: message, thinking: thinking, idempotencyKey: idempotencyKey, @@ -473,35 +589,83 @@ struct IOSGatewayChatTransport: OpenClawChatTransport { func sendMessage( sessionKey: String, + agentID: String?, + expectedSessionRoutingContract: String?, + message: String, + thinking: String, + idempotencyKey: String, + attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse + { + let route: GatewayNodeSessionRoute? = if let outboxGatewayID { + await self.gateway.currentRoute(ifGatewayID: outboxGatewayID) + } else { + await self.gateway.currentRoute() + } + guard let route, + let supportsRoutingContract = await gateway.supportsServerCapability( + .chatSendRoutingContract, + ifCurrentRoute: route) + else { throw OpenClawChatTransportSendError.notDispatched } + // Durable replay requires the atomic server guard and is blocked in + // acquireOutboxRouteLease. Keep ordinary live chat compatible with + // older gateways by retaining the captured route but omitting the + // unsupported request field. + let guardedContract = OpenClawChatSessionRoutingContract.expectedValue( + expectedSessionRoutingContract, + serverSupportsGuard: supportsRoutingContract) + return try await self.sendMessage( + sessionKey: sessionKey, + agentID: agentID, + expectedSessionRoutingContract: guardedContract, + message: message, + thinking: thinking, + idempotencyKey: idempotencyKey, + attachments: attachments, + ifCurrentRoute: route, + distinguishPreDispatchRouteChange: true) + } + + func sendMessage( + sessionKey: String, + agentID: String? = nil, + expectedSessionRoutingContract: String? = nil, message: String, thinking: String, idempotencyKey: String, attachments: [OpenClawChatAttachmentPayload], - ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?) async throws -> OpenClawChatSendResponse + ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?, + distinguishPreDispatchRouteChange: Bool = false) async throws -> OpenClawChatSendResponse { + let target = self.sessionTarget(for: sessionKey, overrideAgentID: agentID) let startLogMessage = - "chat.send start sessionKey=\(sessionKey) " + "chat.send start sessionKey=\(target.sessionKey) " + "len=\(message.count) attachments=\(attachments.count)" Self.logger.info( "\(startLogMessage, privacy: .public)") GatewayDiagnostics.log(startLogMessage) let json = try Self.makeChatSendParamsJSON( - sessionKey: sessionKey, - agentId: self.selectedGlobalAgentId(for: sessionKey), + sessionKey: target.sessionKey, + agentId: target.agentID, + expectedSessionRoutingContract: expectedSessionRoutingContract, message: message, thinking: thinking, idempotencyKey: idempotencyKey, attachments: attachments) do { - let res = try await self.gateway.request( + let res = try await gateway.request( method: "chat.send", paramsJSON: json, timeoutSeconds: 35, - ifCurrentRoute: expectedRoute) + ifCurrentRoute: expectedRoute, + distinguishPreDispatchRouteChange: distinguishPreDispatchRouteChange) let decoded = try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res) Self.logger.info("chat.send ok runId=\(decoded.runId, privacy: .public)") GatewayDiagnostics.log("chat.send ok runId=\(decoded.runId) status=\(decoded.status)") return decoded + } catch is GatewayNodeSessionRequestError { + Self.logger.info("chat.send skipped because the captured route changed before dispatch") + GatewayDiagnostics.log("chat.send skipped before dispatch: route changed") + throw OpenClawChatTransportSendError.notDispatched } catch { Self.logger.error("chat.send failed \(error.localizedDescription, privacy: .public)") GatewayDiagnostics.log("chat.send failed error=\(error.localizedDescription)") @@ -559,7 +723,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport { let json = try Self.makeAgentWaitParamsJSON(runId: runId, timeoutMs: timeoutMs) let requestTimeoutSeconds = Self.agentWaitRequestTimeoutSeconds(timeoutMs: timeoutMs) GatewayDiagnostics.log("agent.wait start runId=\(runId)") - let res = try await self.gateway.request( + let res = try await gateway.request( method: "agent.wait", paramsJSON: json, timeoutSeconds: requestTimeoutSeconds, @@ -580,7 +744,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport { func requestHealth(timeoutMs: Int) async throws -> Bool { let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0))) - let res = try await self.gateway.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds) + let res = try await gateway.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds) return (try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: res))?.ok ?? true } diff --git a/apps/ios/Sources/Design/ChatProTab.swift b/apps/ios/Sources/Design/ChatProTab.swift index 8e9286b6574..73e1792b050 100644 --- a/apps/ios/Sources/Design/ChatProTab.swift +++ b/apps/ios/Sources/Design/ChatProTab.swift @@ -10,13 +10,13 @@ struct ChatProTab: View { @Environment(NodeAppModel.self) private var appModel @State private var viewModel: OpenClawChatViewModel? - @State private var viewModelTransportModeID = "" + @State private var viewModelOwnerID = "" @State private var transcriptShareItem: TranscriptShareItem? @State private var showsTranscriptExportError = false // Transport can start unscoped while the UI uses its "main" fallback. - // Track the real agent separately so gateway metadata always rebuilds it. + // Track the real agent so gateway metadata replaces the captured transport. @State private var viewModelTransportAgentID = "" - @State private var viewModelAgentID = "" + @State private var viewModelRoutingContract = "" let headerLeadingAction: OpenClawSidebarHeaderAction? let headerTitle: String? let showsAgentBadge: Bool @@ -50,12 +50,13 @@ struct ChatProTab: View { } } .task { + await self.appModel.restoreChatSessionRoutingIdentityIfNeeded() self.syncChatViewModel() } .onChange(of: self.appModel.chatSessionKey) { _, _ in self.syncChatViewModel() } - .onChange(of: self.appModel.chatViewModelIdentityID) { _, _ in + .onChange(of: self.appModel.chatViewModelOwnerID) { _, _ in self.syncChatViewModel() } .onChange(of: self.appModel.chatAgentId) { _, _ in @@ -64,6 +65,9 @@ struct ChatProTab: View { .onChange(of: self.appModel.gatewayDefaultAgentId) { _, _ in self.syncChatViewModel() } + .onChange(of: self.appModel.chatSessionRoutingContract) { _, _ in + self.syncChatViewModel() + } .onChange(of: self.appModel.isAppleReviewDemoModeEnabled) { _, _ in self.syncChatViewModel() self.viewModel?.refresh() @@ -133,7 +137,8 @@ struct ChatProTab: View { assistantAvatarTint: OpenClawBrand.accent, showsAssistantAvatars: false, composerChrome: .clean, - isComposerEnabled: self.gatewayConnected, + isComposerEnabled: self.gatewayConnected || self.canQueueOffline, + isAttachmentInputEnabled: self.gatewayConnected, messagePlaceholder: self.messagePlaceholder, emptyAssistantIntro: String(localized: "What would you like to work on?"), emptyAssistantPrompts: Self.emptyAssistantPrompts, @@ -166,38 +171,48 @@ struct ChatProTab: View { let sessionKey = self.appModel.chatSessionKey // Includes the cache gateway identity so switching paired gateways // rebuilds the view model even while the transport mode stays the same. - let transportModeID = self.appModel.chatViewModelIdentityID - let transportAgentID = Self.transportAgentID(self.appModel.chatAgentId) - let agentID = self.activeAgentID + let ownerID = self.appModel.chatViewModelOwnerID + let deliveryAgentID = self.appModel.chatDeliveryAgentId + let transportAgentID = Self.transportAgentID(deliveryAgentID) + let routingContract = self.appModel.chatSessionRoutingContract ?? "" guard let viewModel else { - self.viewModelTransportModeID = transportModeID + self.viewModelOwnerID = ownerID self.viewModelTransportAgentID = transportAgentID - self.viewModelAgentID = agentID - self.viewModel = self.makeChatViewModel(sessionKey: sessionKey, activeAgentId: agentID) + self.viewModelRoutingContract = routingContract + self.viewModel = self.makeChatViewModel(sessionKey: sessionKey) return } - if self.viewModelTransportModeID != transportModeID || - self.viewModelTransportAgentID != transportAgentID || - self.viewModelAgentID != agentID + if Self.requiresViewModelRebuild( + currentOwnerID: self.viewModelOwnerID, + nextOwnerID: ownerID, + currentTransportAgentID: self.viewModelTransportAgentID, + nextTransportAgentID: transportAgentID) { - self.viewModelTransportModeID = transportModeID + self.viewModelOwnerID = ownerID self.viewModelTransportAgentID = transportAgentID - self.viewModelAgentID = agentID - self.viewModel = self.makeChatViewModel(sessionKey: sessionKey, activeAgentId: agentID) + self.viewModelRoutingContract = routingContract + self.viewModel = self.makeChatViewModel(sessionKey: sessionKey) return } + if self.viewModelRoutingContract != routingContract { + self.viewModelRoutingContract = routingContract + viewModel.syncSessionRoutingContract(self.appModel.chatSessionRoutingContract) + } guard viewModel.sessionKey != sessionKey else { return } viewModel.syncSession(to: sessionKey) } - private func makeChatViewModel(sessionKey: String, activeAgentId: String? = nil) -> OpenClawChatViewModel { + private func makeChatViewModel(sessionKey: String) -> OpenClawChatViewModel { // One store instance backs both seams so the transcript cache and the // offline outbox share a single SQLite connection. let offlineStore = self.appModel.makeChatOfflineStore() return OpenClawChatViewModel( sessionKey: sessionKey, - transport: self.appModel.makeChatTransport(), - activeAgentId: activeAgentId, + // Bind durable rows and their transport lease to the exact same + // gateway owner even if app state switches between these calls. + transport: self.appModel.makeChatTransport(outboxGatewayID: offlineStore?.gatewayID), + activeAgentId: self.appModel.chatDeliveryAgentId, + sessionRoutingContract: self.appModel.chatSessionRoutingContract, transcriptCache: offlineStore, outbox: offlineStore, onSessionChanged: { sessionKey in @@ -362,7 +377,18 @@ struct ChatProTab: View { } private var messagePlaceholder: String { - self.gatewayConnected ? "Message \(self.agentDisplayName)..." : "Connect to a gateway" + if self.gatewayConnected { + return String(localized: "Message \(self.agentDisplayName)...") + } + if self.canQueueOffline { + return String(localized: "Message \(self.agentDisplayName); sends when connected") + } + return String(localized: "Connect to a gateway") + } + + private var canQueueOffline: Bool { + self.viewModel?.supportsOfflineTextOutbox == true && + self.appModel.hasVerifiedChatOfflineRoutingIdentity } private var headerDisplayTitle: String { @@ -413,6 +439,15 @@ struct ChatProTab: View { value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" } + nonisolated static func requiresViewModelRebuild( + currentOwnerID: String, + nextOwnerID: String, + currentTransportAgentID: String, + nextTransportAgentID: String) -> Bool + { + currentOwnerID != nextOwnerID || currentTransportAgentID != nextTransportAgentID + } + nonisolated static let emptyAssistantPrompts: [OpenClawChatView.StarterPrompt] = [ OpenClawChatView.StarterPrompt( id: "summarize-status", diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index d873007ef5a..c112b6c0d70 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -210,6 +210,7 @@ final class NodeAppModel { } private var mainSessionBaseKey: String = "main" + private var gatewaySessionScope: String? private var focusedChatSessionKey: String? // Two-part unread guard mirroring Android: the opened key survives read // confirmations so later unread episodes on the same open chat re-acknowledge; @@ -329,7 +330,7 @@ final class NodeAppModel { return self.isOperatorGatewayConnected ? "operator" : "offline" } - func makeChatTransport() -> any OpenClawChatTransport { + func makeChatTransport(outboxGatewayID: String? = nil) -> any OpenClawChatTransport { if self.isScreenshotFixtureModeEnabled { return LocalFixtureChatTransport(fixture: .appScreenshots) } @@ -338,7 +339,8 @@ final class NodeAppModel { } return IOSGatewayChatTransport( gateway: self.operatorSession, - globalAgentId: self.chatAgentId) + globalAgentId: self.chatDeliveryAgentId, + outboxGatewayID: outboxGatewayID) } /// Gateway identity the transcript cache is scoped to: the active @@ -349,6 +351,7 @@ final class NodeAppModel { var chatTranscriptCacheGatewayID: String? { guard !self.isLocalGatewayFixtureEnabled else { return nil } let stableID = self.activeGatewayConnectConfig?.effectiveStableID + ?? self.connectedGatewayID ?? GatewaySettingsStore.loadLastGatewayConnection()?.stableID guard let stableID, !stableID.isEmpty else { return nil } return stableID @@ -362,6 +365,14 @@ final class NodeAppModel { "\(self.chatTransportModeID)|\(self.chatTranscriptCacheGatewayID ?? "")|\(self.chatTranscriptCacheGeneration)" } + /// Stable owner key for the long-lived chat view model. Connectivity still + /// changes `chatViewModelIdentityID` for session-list refreshes, but must + /// not rebuild Chat and discard an offline draft on the same gateway. + var chatViewModelOwnerID: String { + let modeID = self.isLocalGatewayFixtureEnabled ? self.chatTransportModeID : "gateway" + return "\(modeID)|\(self.chatTranscriptCacheGatewayID ?? "")|\(self.chatTranscriptCacheGeneration)" + } + private var chatTranscriptCacheGeneration = 0 /// Offline transcript cache plus durable command outbox, both scoped to @@ -379,6 +390,28 @@ final class NodeAppModel { return cache } + var hasVerifiedChatOfflineRoutingIdentity: Bool { + self.chatTranscriptCacheGatewayID != nil && + self.chatDeliveryAgentId != nil && + self.chatSessionRoutingContract != nil + } + + func restoreChatSessionRoutingIdentityIfNeeded() async { + guard !self.isLocalGatewayFixtureEnabled, + self.chatSessionRoutingContract == nil, + let store = self.makeChatOfflineStore(), + let identity = await store.loadSessionRoutingIdentity(), + self.chatTranscriptCacheGatewayID == store.gatewayID, + self.chatSessionRoutingContract == nil + else { return } + self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: store.gatewayID) + self.gatewaySessionScope = identity.scope + self.mainSessionBaseKey = identity.mainSessionKey + self.gatewayDefaultAgentId = identity.defaultAgentID + self.talkMode.updateMainSessionKey(self.mainSessionKey) + self.homeCanvasRevision &+= 1 + } + func loadCachedChatSessions() async -> [OpenClawChatSessionEntry] { guard let cache = self.makeChatOfflineStore() else { return [] } return await cache.loadSessions() @@ -1006,14 +1039,23 @@ final class NodeAppModel { private func refreshBrandingFromGateway(shouldApply: () -> Bool = { true }) async { do { - let res = try await operatorGateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8) + guard let sourceGatewayID = self.chatTranscriptCacheGatewayID, + let sourceRoute = await operatorGateway.currentRoute(ifGatewayID: sourceGatewayID) + else { return } + let res = try await operatorGateway.request( + method: "config.get", + paramsJSON: "{}", + timeoutSeconds: 8, + ifCurrentRoute: sourceRoute) guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return } guard let config = json["config"] as? [String: Any] else { return } let session = config["session"] as? [String: Any] let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String) - guard shouldApply() else { return } + let scope = (session?["scope"] as? String) ?? "per-sender" + guard shouldApply(), self.chatTranscriptCacheGatewayID == sourceGatewayID else { return } await MainActor.run { self.mainSessionBaseKey = mainKey + self.gatewaySessionScope = scope self.talkMode.updateMainSessionKey(self.mainSessionKey) self.homeCanvasRevision &+= 1 } @@ -1030,12 +1072,26 @@ final class NodeAppModel { private func refreshAgentsFromGateway(shouldApply: () -> Bool = { true }) async { do { - let res = try await operatorGateway.request(method: "agents.list", paramsJSON: "{}", timeoutSeconds: 8) + guard let sourceGatewayID = self.chatTranscriptCacheGatewayID, + let sourceStore = self.makeChatOfflineStore(), + sourceStore.gatewayID == sourceGatewayID, + let sourceRoute = await operatorGateway.currentRoute(ifGatewayID: sourceGatewayID) + else { return } + let res = try await operatorGateway.request( + method: "agents.list", + paramsJSON: "{}", + timeoutSeconds: 8, + ifCurrentRoute: sourceRoute) let decoded = try JSONDecoder().decode(AgentsListResult.self, from: res) - guard shouldApply() else { return } + let routingIdentity = OpenClawChatSessionRoutingIdentity( + scope: decoded.scope.value as? String, + mainSessionKey: decoded.mainkey, + defaultAgentID: decoded.defaultid) + guard shouldApply(), self.chatTranscriptCacheGatewayID == sourceGatewayID else { return } await MainActor.run { self.gatewayDefaultAgentId = decoded.defaultid self.gatewayAgents = decoded.agents + self.gatewaySessionScope = decoded.scope.value as? String self.applyMainSessionKey(decoded.mainkey) let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) @@ -1046,6 +1102,9 @@ final class NodeAppModel { self.talkMode.updateMainSessionKey(self.mainSessionKey) self.homeCanvasRevision &+= 1 } + if let routingIdentity { + await sourceStore.storeSessionRoutingIdentity(routingIdentity) + } } catch { // Best-effort only. } @@ -2344,6 +2403,26 @@ extension NodeAppModel { return self.selectedOrDefaultAgentId } + /// Verified routing owner for sends. Unlike `chatAgentId`, this has no + /// display fallback: a cold offline start must wait for persisted or + /// gateway-provided ownership before it can queue durable work. + var chatDeliveryAgentId: String? { + if let sessionAgentId = SessionKey.agentId(from: chatSessionKey) { + return sessionAgentId.lowercased() + } + let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if !selected.isEmpty { return selected.lowercased() } + let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return defaultId.isEmpty ? nil : defaultId.lowercased() + } + + var chatSessionRoutingContract: String? { + OpenClawChatSessionRoutingContract.make( + scope: self.gatewaySessionScope, + mainKey: self.mainSessionBaseKey, + defaultAgentID: self.gatewayDefaultAgentId) + } + var chatAgentName: String { self.agentDisplayName(for: self.chatAgentId, fallback: "Main") } @@ -2636,7 +2715,6 @@ extension NodeAppModel { self.gatewayConnected = false setOperatorConnected(false) self.talkMode.updateGatewayConnected(false) - self.mainSessionBaseKey = "main" self.talkMode.updateMainSessionKey(self.mainSessionKey) ShareGatewayRelaySettings.clearConfig() showLocalCanvasOnDisconnect() @@ -2684,12 +2762,17 @@ extension NodeAppModel { self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask = nil LiveActivityManager.shared.endActivity(reason: "new_gateway_connect") + self.mainSessionBaseKey = "main" + self.gatewaySessionScope = nil self.gatewayDefaultAgentId = nil self.gatewayAgents = [] self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID) self.focusedChatSessionKey = nil self.homeCanvasRevision &+= 1 self.apnsLastRegisteredTokenHex = nil + Task { [weak self] in + await self?.restoreChatSessionRoutingIdentityIfNeeded() + } } private func clearGatewayConnectionProblem() { @@ -3476,7 +3559,8 @@ extension NodeAppModel { self.gatewayConnected = false self.setOperatorConnected(false) self.talkMode.updateGatewayConnected(false) - self.mainSessionBaseKey = "main" + // Retain the last verified routing contract for offline + // capture; reconnect compares it with the live gateway before replay. self.talkMode.updateMainSessionKey(self.mainSessionKey) self.showLocalCanvasOnDisconnect() } @@ -3682,6 +3766,7 @@ extension NodeAppModel { self.talkMode.setEnabled(false) self.talkMode.statusText = "Demo mode only" self.mainSessionBaseKey = "main" + self.gatewaySessionScope = "per-sender" self.selectedAgentId = nil self.gatewayDefaultAgentId = "main" self.gatewayAgents = AppleReviewDemoMode.agents @@ -3723,6 +3808,7 @@ extension NodeAppModel { self.setOperatorConnected(true) self.hasOperatorAdminScope = true self.mainSessionBaseKey = "main" + self.gatewaySessionScope = "per-sender" self.selectedAgentId = nil self.gatewayDefaultAgentId = "main" self.gatewayAgents = ScreenshotFixtureMode.agents @@ -3887,7 +3973,7 @@ extension NodeAppModel { let routeGeneration = self.gatewayRouteGeneration guard let gatewayStableID = self.connectedGatewayID, - let nodeRoute = await nodeGateway.currentRoute(), + let nodeRoute = await self.nodeGateway.currentRoute(), shouldContinue(), self.isCurrentGatewayRoute(generation: routeGeneration, stableID: gatewayStableID) else { return } diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift index 7f32a860618..a215f896417 100644 --- a/apps/ios/Tests/GatewayConnectionControllerTests.swift +++ b/apps/ios/Tests/GatewayConnectionControllerTests.swift @@ -5,6 +5,20 @@ import UIKit @testable import OpenClawKit @Suite(.serialized) struct GatewayConnectionControllerTests { + @Test @MainActor func `chat owner survives reconnect while session refresh identity changes`() { + let appModel = NodeAppModel() + let disconnectedOwner = appModel.chatViewModelOwnerID + let disconnectedRefreshIdentity = appModel.chatViewModelIdentityID + + appModel._test_setOperatorConnected(true) + #expect(appModel.chatViewModelOwnerID == disconnectedOwner) + #expect(appModel.chatViewModelIdentityID != disconnectedRefreshIdentity) + + appModel._test_setOperatorConnected(false) + #expect(appModel.chatViewModelOwnerID == disconnectedOwner) + #expect(appModel.chatViewModelIdentityID == disconnectedRefreshIdentity) + } + @Test @MainActor func `resolved display name sets default when missing`() { let defaults = UserDefaults.standard let displayKey = "node.displayName" diff --git a/apps/ios/Tests/IOSGatewayChatTransportTests.swift b/apps/ios/Tests/IOSGatewayChatTransportTests.swift index 0968e9fef1c..83da41ca543 100644 --- a/apps/ios/Tests/IOSGatewayChatTransportTests.swift +++ b/apps/ios/Tests/IOSGatewayChatTransportTests.swift @@ -39,8 +39,44 @@ struct IOSGatewayChatTransportTests { #expect(completion.completed) } + @Test func `routing contract decodes gateway main semantics`() throws { + let data = Data(#"{"defaultId":"Ops","mainKey":"Work","scope":"global","agents":[]}"#.utf8) + #expect(try IOSGatewayChatTransport.decodeSessionRoutingContract(data) == "global|work|ops") + } + + @Test func `live routing guard permits an identity still loading`() { + #expect(OpenClawChatSessionRoutingContract.expectedValue( + nil, + serverSupportsGuard: true) == nil) + #expect(OpenClawChatSessionRoutingContract.expectedValue( + " per-sender|main|reviewer ", + serverSupportsGuard: true) == "per-sender|main|reviewer") + #expect(OpenClawChatSessionRoutingContract.expectedValue( + "per-sender|main|reviewer", + serverSupportsGuard: false) == nil) + } + + @Test func `routing contract round trips a delimited legacy main key`() throws { + let contract = try #require(OpenClawChatSessionRoutingContract.make( + scope: "per-sender", + mainKey: "team|primary", + defaultAgentID: "main")) + let components = try #require(OpenClawChatSessionRoutingContract.parse(contract)) + #expect(components.scope == "per-sender") + #expect(components.mainKey == "team|primary") + #expect(components.defaultAgentID == "main") + } + + @Test func `hello advertises guarded chat send capability`() throws { + let data = Data( + #"{"type":"hello-ok","protocol":4,"server":{"version":"test","connId":"test"},"features":{"methods":[],"events":[],"capabilities":["chat-send-routing-contract"]},"snapshot":{"presence":[],"health":{},"stateVersion":{"presence":0,"health":0},"uptimeMs":0},"auth":{},"policy":{}}"# + .utf8) + let hello = try JSONDecoder().decode(HelloOk.self, from: data) + #expect(hello.supportsServerCapability(.chatSendRoutingContract)) + } + @Test func `list sessions params include global sessions but not unknown`() throws { - let params = try self.object(from: IOSGatewayChatTransport.makeListSessionsParamsJSON(limit: 12)) + let params = try object(from: IOSGatewayChatTransport.makeListSessionsParamsJSON(limit: 12)) #expect(params["includeGlobal"] as? Bool == true) #expect(params["includeUnknown"] as? Bool == false) #expect(params["limit"] as? Int == 12) @@ -135,14 +171,14 @@ struct IOSGatewayChatTransportTests { } @Test func `commands list params request text scope with args`() throws { - let params = try self.object(from: IOSGatewayChatTransport.makeCommandsListParamsJSON()) + let params = try object(from: IOSGatewayChatTransport.makeCommandsListParamsJSON()) #expect(params["scope"] as? String == "text") #expect(params["includeArgs"] as? Bool == true) #expect(params["agentId"] == nil) } @Test func `commands list params include agent for agent scoped session`() throws { - let params = try self.object( + let params = try object( from: IOSGatewayChatTransport.makeCommandsListParamsJSON(sessionKey: "agent:reviewer:ios-main")) #expect(params["scope"] as? String == "text") #expect(params["includeArgs"] as? Bool == true) @@ -150,7 +186,7 @@ struct IOSGatewayChatTransportTests { } @Test func `commands list params use explicit agent for selected global session`() throws { - let params = try self.object( + let params = try object( from: IOSGatewayChatTransport.makeCommandsListParamsJSON( sessionKey: "global", agentId: "reviewer")) @@ -158,7 +194,7 @@ struct IOSGatewayChatTransportTests { } @Test func `create session params include selected global agent`() throws { - let params = try self.object( + let params = try object( from: IOSGatewayChatTransport.makeCreateSessionParamsJSON( key: "agent:reviewer:ios-new", agentId: "reviewer", @@ -172,7 +208,7 @@ struct IOSGatewayChatTransportTests { } @Test func `chat send params omit empty attachments and keep session fields`() throws { - let params = try self.object( + let params = try object( from: IOSGatewayChatTransport.makeChatSendParamsJSON( sessionKey: "agent:main", message: "hello", @@ -188,16 +224,41 @@ struct IOSGatewayChatTransportTests { } @Test func `chat send params include selected global agent`() throws { - let params = try self.object( + let params = try object( from: IOSGatewayChatTransport.makeChatSendParamsJSON( sessionKey: "global", agentId: "reviewer", + expectedSessionRoutingContract: "per-sender|main|reviewer", message: "hello", thinking: "low", idempotencyKey: "send-1", attachments: [])) #expect(params["sessionKey"] as? String == "global") #expect(params["agentId"] as? String == "reviewer") + #expect(params["expectedSessionRoutingContract"] as? String == "per-sender|main|reviewer") + } + + @Test func `unscoped live routes use the selected agent`() { + #expect(IOSGatewayChatTransport.sessionTarget( + for: "Matrix:Channel:!MixedRoom:example.org", + selectedAgentID: " Reviewer ") == .init( + sessionKey: "agent:reviewer:Matrix:Channel:!MixedRoom:example.org", + agentID: nil)) + #expect(IOSGatewayChatTransport.sessionTarget( + for: "main", + selectedAgentID: "main") == .init(sessionKey: "agent:main:main", agentID: nil)) + #expect(IOSGatewayChatTransport.sessionTarget( + for: "agent:ops:main", + selectedAgentID: "reviewer") == .init(sessionKey: "agent:ops:main", agentID: nil)) + #expect(IOSGatewayChatTransport.sessionTarget( + for: "global", + selectedAgentID: "reviewer") == .init(sessionKey: "global", agentID: "reviewer")) + #expect(IOSGatewayChatTransport.sessionTarget( + for: "unknown", + selectedAgentID: "reviewer") == .init(sessionKey: "unknown", agentID: nil)) + #expect(IOSGatewayChatTransport.sessionTarget( + for: "agent::main", + selectedAgentID: "reviewer") == .init(sessionKey: "agent::main", agentID: nil)) } @Test func `requests fail fast when gateway not connected`() async { @@ -219,6 +280,22 @@ struct IOSGatewayChatTransportTests { Issue.record("Expected sendMessage to throw when gateway not connected") } catch {} + do { + _ = try await transport.sendMessage( + sessionKey: "node-test", + agentID: "main", + expectedSessionRoutingContract: "per-sender|main|main", + message: "hello", + thinking: "low", + idempotencyKey: "guarded-idempotency", + attachments: []) + Issue.record("Expected guarded sendMessage to fail before dispatch") + } catch is OpenClawChatTransportSendError { + // Expected: a missing route never reached chat.send. + } catch { + Issue.record("Expected a typed pre-dispatch failure, got \(error)") + } + do { _ = try await transport.requestHealth(timeoutMs: 250) Issue.record("Expected requestHealth to throw when gateway not connected") diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index f25575c1ee6..cde3afefc90 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -344,6 +344,21 @@ private actor WatchSnapshotSendGate { @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`() { diff --git a/apps/ios/Tests/RootTabsPresentationTests.swift b/apps/ios/Tests/RootTabsPresentationTests.swift index ff41a2088d0..a2bb8a8d8af 100644 --- a/apps/ios/Tests/RootTabsPresentationTests.swift +++ b/apps/ios/Tests/RootTabsPresentationTests.swift @@ -338,6 +338,24 @@ struct RootTabsPresentationTests { #expect(ChatProTab.transportAgentID(" Main ") == "main") } + @Test func `chat view model rebuilds only when its transport owner changes`() { + #expect(!ChatProTab.requiresViewModelRebuild( + currentOwnerID: "gateway-a", + nextOwnerID: "gateway-a", + currentTransportAgentID: "main", + nextTransportAgentID: "main")) + #expect(ChatProTab.requiresViewModelRebuild( + currentOwnerID: "gateway-a", + nextOwnerID: "gateway-b", + currentTransportAgentID: "main", + nextTransportAgentID: "main")) + #expect(ChatProTab.requiresViewModelRebuild( + currentOwnerID: "gateway-a", + nextOwnerID: "gateway-a", + currentTransportAgentID: "main", + nextTransportAgentID: "work")) + } + @Test func `agent routes can open gateway settings from header pill`() { let standalone = AgentProTab() let routed = AgentProTab( diff --git a/apps/ios/Tests/RootTabsSourceGuardTests.swift b/apps/ios/Tests/RootTabsSourceGuardTests.swift index 78d6bc8dc46..927b6cd77fe 100644 --- a/apps/ios/Tests/RootTabsSourceGuardTests.swift +++ b/apps/ios/Tests/RootTabsSourceGuardTests.swift @@ -298,10 +298,12 @@ struct RootTabsSourceGuardTests { #expect(!aboutDestination.contains("SettingsDetailRow(\"Version\"")) #expect(!aboutDestination.contains("SettingsDetailRow(\"Platform\"")) #expect(!aboutDestination.contains("SettingsDetailRow(\"Model\"")) - #expect(diagnosticsDestination.contains("SettingsDetailRow(\"Device\", value: DeviceInfoHelper.deviceFamily())")) + #expect(diagnosticsDestination + .contains("SettingsDetailRow(\"Device\", value: DeviceInfoHelper.deviceFamily())")) #expect(diagnosticsDestination .contains("SettingsDetailRow(\"Platform\", value: DeviceInfoHelper.platformStringForDisplay())")) - #expect(diagnosticsDestination.contains("SettingsDetailRow(\"Model\", value: DeviceInfoHelper.modelIdentifier())")) + #expect(diagnosticsDestination + .contains("SettingsDetailRow(\"Model\", value: DeviceInfoHelper.modelIdentifier())")) } @Test func `routed headers use shared adaptive layout`() throws { @@ -630,7 +632,8 @@ struct RootTabsSourceGuardTests { #expect(activitySource.contains("struct IPadActivityScreen: View")) #expect(activitySource.contains("self.appModel.makeChatTransport()")) #expect(appModelSource.contains("return IOSGatewayChatTransport(")) - #expect(appModelSource.contains("globalAgentId: self.chatAgentId")) + #expect(appModelSource.contains("globalAgentId: self.chatDeliveryAgentId")) + #expect(!appModelSource.contains("defaultAgentId: self.gatewayDefaultAgentId")) #expect(activitySource.contains("IPadSidebarScreenChrome(")) #expect(!taskSource.contains("struct IPadActivityScreen")) #expect(!taskSource.contains("import OpenClawChatUI")) @@ -1237,10 +1240,13 @@ struct RootTabsSourceGuardTests { let appModelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8) let transportSource = try String(contentsOf: Self.iOSGatewayChatTransportSourceURL(), encoding: .utf8) - #expect(chatSource.matches(of: /self\.appModel\.makeChatTransport\(\)/).count == 2) - #expect(chatSource.contains("self.viewModelTransportAgentID != transportAgentID")) + #expect(chatSource.contains( + "self.appModel.makeChatTransport(outboxGatewayID: offlineStore?.gatewayID)")) + #expect(chatSource.contains("activeAgentId: self.appModel.chatDeliveryAgentId")) + #expect(chatSource.contains("Self.requiresViewModelRebuild(")) + #expect(chatSource.contains("viewModel.syncSessionRoutingContract")) #expect(appModelSource.contains("return IOSGatewayChatTransport(")) - #expect(appModelSource.contains("globalAgentId: self.chatAgentId")) + #expect(appModelSource.contains("globalAgentId: self.chatDeliveryAgentId")) #expect(appModelSource.contains("ifCurrentRoute: operatorRoute")) #expect(transportSource.matches(of: /ifCurrentRoute: expectedRoute/).count == 3) #expect(channelsSource.contains("\"clickclack\": SettingsChannelFallbackMetadata")) diff --git a/apps/macos/Sources/OpenClaw/ChatTranscriptCacheSupport.swift b/apps/macos/Sources/OpenClaw/ChatTranscriptCacheSupport.swift index 51da10e46cf..a6888f722e0 100644 --- a/apps/macos/Sources/OpenClaw/ChatTranscriptCacheSupport.swift +++ b/apps/macos/Sources/OpenClaw/ChatTranscriptCacheSupport.swift @@ -9,6 +9,16 @@ import OpenClawChatUI /// classes (the iOS store applies `completeUntilFirstUserAuthentication`); /// at-rest protection here is the per-user container permissions plus FileVault. enum MacChatTranscriptCache { + struct Context { + let store: OpenClawChatSQLiteTranscriptCache + let routingIdentity: OpenClawChatSessionRoutingIdentity? + } + + /// Every chat window for one gateway must share the same outbox actor. + /// Otherwise a newly opened window can mistake another live window's + /// claimed send for crash residue during its first recovery pass. + @MainActor private static var storesByGatewayID: [String: OpenClawChatSQLiteTranscriptCache] = [:] + /// Stable identity of the gateway this app talks to, derivable offline /// (the cache pre-paints before any connection is up). Keys must not /// collide across gateways: @@ -73,7 +83,8 @@ enum MacChatTranscriptCache { /// resolved, so foreign rows can never leak into another gateway's scope. /// Concrete return type: the same SQLite store also backs the offline /// command outbox, and callers wire both protocol facets from one instance. - static func make() -> OpenClawChatSQLiteTranscriptCache? { + @MainActor + static func currentGatewayID() -> String? { let root = OpenClawConfigFile.loadDict() let mode = ConnectionModeResolver.resolve(root: root).mode let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root) @@ -85,19 +96,45 @@ enum MacChatTranscriptCache { let sshRemotePort = RemotePortTunnel.resolveRemotePortOverride( defaultRemotePort: defaultRemotePort, for: sshHost) ?? defaultRemotePort - let gatewayID = self.gatewayID( + return self.gatewayID( mode: mode, localStateDir: OpenClawConfigFile.stateDirURL(), remoteTransport: resolution.transport, directURL: resolution.directURL, sshTarget: sshTarget, sshRemotePort: sshRemotePort) - guard let gatewayID else { return nil } + } + + @MainActor + static func make() -> OpenClawChatSQLiteTranscriptCache? { + self.makeContext()?.store + } + + /// Loads the small process-stable routing fact before Chat constructs its + /// view model, so cache partitioning and offline sends never bootstrap + /// against a nil agent. + @MainActor + static func makeContext() -> Context? { + guard let gatewayID = currentGatewayID() else { return nil } guard let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } let databaseURL = base.appendingPathComponent("OpenClaw/chat-cache.sqlite", isDirectory: false) - return OpenClawChatSQLiteTranscriptCache(databaseURL: databaseURL, gatewayID: gatewayID) + return Context( + store: self.store(databaseURL: databaseURL, gatewayID: gatewayID), + routingIdentity: OpenClawChatSQLiteTranscriptCache.loadSessionRoutingIdentity( + databaseURL: databaseURL, + gatewayID: gatewayID)) + } + + @MainActor + static func store(databaseURL: URL, gatewayID: String) -> OpenClawChatSQLiteTranscriptCache { + if let store = storesByGatewayID[gatewayID] { + return store + } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: databaseURL, gatewayID: gatewayID) + self.storesByGatewayID[gatewayID] = store + return store } } diff --git a/apps/macos/Sources/OpenClaw/GatewayConnection.swift b/apps/macos/Sources/OpenClaw/GatewayConnection.swift index c438f3df816..03e0f37f23b 100644 --- a/apps/macos/Sources/OpenClaw/GatewayConnection.swift +++ b/apps/macos/Sources/OpenClaw/GatewayConnection.swift @@ -53,6 +53,22 @@ actor GatewayConnection { typealias Config = (url: URL, token: String?, password: String?) + struct Route { + fileprivate let generation: UInt64 + fileprivate let url: URL + fileprivate let token: String? + fileprivate let password: String? + + fileprivate func matches(_ config: Config) -> Bool { + self.url == config.url && self.token == config.token && self.password == config.password + } + } + + struct SessionRoutingIdentity: Equatable { + let defaultAgentID: String + let contract: String + } + enum Method: String { case agent case status @@ -108,6 +124,7 @@ actor GatewayConnection { private var configuredURL: URL? private var configuredToken: String? private var configuredPassword: String? + private var routeGeneration: UInt64 = 0 private var subscribers: [UUID: AsyncStream.Continuation] = [:] private var lastSnapshot: HelloOk? @@ -166,8 +183,8 @@ actor GatewayConnection { timeoutMs: Double? = nil, retryTransportFailures: Bool = true) async throws -> Data { - let cfg = try await self.configProvider() - await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) + let cfg = try await configProvider() + await configure(url: cfg.url, token: cfg.token, password: cfg.password) guard let client else { throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway not configured"]) } @@ -233,8 +250,8 @@ actor GatewayConnection { for delayMs in [150, 400, 900] { try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) do { - let cfg = try await self.configProvider() - await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) + let cfg = try await configProvider() + await configure(url: cfg.url, token: cfg.token, password: cfg.password) guard let client = self.client else { throw NSError( domain: "Gateway", @@ -254,6 +271,31 @@ actor GatewayConnection { } } + /// Route-bound requests never reconfigure or retry on another endpoint. + /// Durable outbox rows must remain owned by the gateway that created them. + func request( + method: String, + params: [String: AnyCodable]?, + timeoutMs: Double? = nil, + ifCurrentRoute route: Route, + distinguishPreDispatchRouteChange: Bool = false) async throws -> Data + { + let cfg = try await configProvider() + guard route.generation == self.routeGeneration, + route.matches(cfg), + self.configuredURL == route.url, + self.configuredToken == route.token, + self.configuredPassword == route.password, + let client + else { + if distinguishPreDispatchRouteChange { + throw OpenClawChatTransportSendError.notDispatched + } + throw CancellationError() + } + return try await client.request(method: method, params: params, timeoutMs: timeoutMs) + } + func requestRaw( method: Method, params: [String: AnyCodable]? = nil, @@ -275,7 +317,7 @@ actor GatewayConnection { params: [String: AnyCodable]? = nil, timeoutMs: Double? = nil) async throws -> T { - let data = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs) + let data = try await requestRaw(method: method, params: params, timeoutMs: timeoutMs) do { return try self.decoder.decode(T.self, from: data) } catch { @@ -293,8 +335,57 @@ actor GatewayConnection { /// Ensure the underlying socket is configured (and replaced if config changed). func refresh() async throws { - let cfg = try await self.configProvider() - await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) + let cfg = try await configProvider() + await configure(url: cfg.url, token: cfg.token, password: cfg.password) + } + + func captureRoute() async -> Route? { + do { + let cfg = try await configProvider() + await configure(url: cfg.url, token: cfg.token, password: cfg.password) + guard self.client != nil else { return nil } + return Route( + generation: self.routeGeneration, + url: cfg.url, + token: cfg.token, + password: cfg.password) + } catch { + return nil + } + } + + func supportsServerCapability( + _ capability: GatewayServerCapability, + ifCurrentRoute route: Route) async -> Bool? + { + guard let cfg = try? await configProvider(), + route.generation == self.routeGeneration, + route.matches(cfg), + configuredURL == route.url, + configuredToken == route.token, + configuredPassword == route.password, + let snapshot = lastSnapshot + else { return nil } + return snapshot.supportsServerCapability(capability) + } + + func sessionRoutingIdentity(ifCurrentRoute route: Route) async throws -> SessionRoutingIdentity { + let data = try await request( + method: "agents.list", + params: [:], + timeoutMs: 15000, + ifCurrentRoute: route) + return try Self.decodeSessionRoutingIdentity(data) + } + + static func decodeSessionRoutingIdentity(_ data: Data) throws -> SessionRoutingIdentity { + let result = try JSONDecoder().decode(AgentsListResult.self, from: data) + guard let contract = OpenClawChatSessionRoutingContract.make( + scope: result.scope.value as? String, + mainKey: result.mainkey, + defaultAgentID: result.defaultid) + else { throw CancellationError() } + return SessionRoutingIdentity(defaultAgentID: result.defaultid, contract: contract) } func authSource() async -> GatewayAuthSource? { @@ -303,17 +394,18 @@ actor GatewayConnection { } func shutdown() async { + self.routeGeneration &+= 1 if let client { await client.shutdown() } - self.client = nil + client = nil self.configuredURL = nil self.configuredToken = nil self.lastSnapshot = nil } func canvasPluginSurfaceUrl() async -> String? { - guard let snapshot = self.lastSnapshot else { return nil } + guard let snapshot = lastSnapshot else { return nil } let raw = snapshot.pluginsurfaceurls?["canvas"]?.value as? String let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? nil : trimmed @@ -325,7 +417,7 @@ actor GatewayConnection { { return token } - if let deviceToken = self.lastSnapshot?.auth["deviceToken"]?.value as? String { + if let deviceToken = lastSnapshot?.auth["deviceToken"]?.value as? String { let trimmed = deviceToken.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed @@ -347,26 +439,20 @@ actor GatewayConnection { } func cachedMainSessionKey() -> String? { - guard let snapshot = self.lastSnapshot else { return nil } + guard let snapshot = lastSnapshot else { return nil } let trimmed = self.sessionDefaultString(snapshot.snapshot.sessiondefaults, key: "mainSessionKey") return trimmed.isEmpty ? nil : trimmed } - func cachedDefaultAgentId() -> String? { - guard let snapshot = self.lastSnapshot else { return nil } - let trimmed = self.sessionDefaultString(snapshot.snapshot.sessiondefaults, key: "defaultAgentId") - return trimmed.isEmpty ? nil : trimmed - } - func cachedGatewayVersion() -> String? { - guard let snapshot = self.lastSnapshot else { return nil } + guard let snapshot = lastSnapshot else { return nil } let raw = snapshot.server["version"]?.value as? String let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? nil : trimmed } func snapshotPaths() -> (configPath: String?, stateDir: String?) { - guard let snapshot = self.lastSnapshot else { return (nil, nil) } + guard let snapshot = lastSnapshot else { return (nil, nil) } let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines) let stateDir = snapshot.snapshot.statedir?.trimmingCharacters(in: .whitespacesAndNewlines) return ( @@ -396,7 +482,7 @@ actor GatewayConnection { private func broadcast(_ push: GatewayPush) { if case let .snapshot(snapshot) = push { self.lastSnapshot = snapshot - if let mainSessionKey = self.cachedMainSessionKey() { + if let mainSessionKey = cachedMainSessionKey() { Task { @MainActor in WorkActivityStore.shared.setMainSessionKey(mainSessionKey) } @@ -410,7 +496,7 @@ actor GatewayConnection { private func canonicalizeSessionKey(_ raw: String) -> String { let trimmed = raw.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) guard !trimmed.isEmpty else { return trimmed } - guard let defaults = self.lastSnapshot?.snapshot.sessiondefaults else { return trimmed } + guard let defaults = lastSnapshot?.snapshot.sessiondefaults else { return trimmed } let mainSessionKey = self.sessionDefaultString(defaults, key: "mainSessionKey") guard !mainSessionKey.isEmpty else { return trimmed } let mainKey = self.sessionDefaultString(defaults, key: "mainKey") @@ -426,16 +512,19 @@ actor GatewayConnection { } private func configure(url: URL, token: String?, password: String?) async { - if self.client != nil, self.configuredURL == url, self.configuredToken == token, + if client != nil, self.configuredURL == url, self.configuredToken == token, self.configuredPassword == password { return } + // Invalidate captured routes before suspension so no reentrant caller + // can continue an old-gateway outbox flush during replacement. + self.routeGeneration &+= 1 if let client { await client.shutdown() } self.lastSnapshot = nil - self.client = GatewayChannelActor( + client = GatewayChannelActor( url: url, token: token, password: password, @@ -483,11 +572,11 @@ extension GatewayConnection { } func mainSessionKey(timeoutMs: Double = 15000) async -> String { - if let cached = self.cachedMainSessionKey() { + if let cached = cachedMainSessionKey() { return cached } do { - let data = try await self.requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs) + let data = try await requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs) return try Self.mainSessionKey(fromConfigGetData: data) } catch { return "main" @@ -579,13 +668,13 @@ extension GatewayConnection { // MARK: - Health func healthSnapshot(timeoutMs: Double? = nil) async throws -> HealthSnapshot { - let data = try await self.requestRaw(method: .health, timeoutMs: timeoutMs) + let data = try await requestRaw(method: .health, timeoutMs: timeoutMs) if let snap = decodeHealthSnapshot(from: data) { return snap } throw GatewayDecodingError(method: Method.health.rawValue, message: "failed to decode health snapshot") } func healthOK(timeoutMs: Int = 8000) async throws -> Bool { - let data = try await self.requestRaw(method: .health, timeoutMs: Double(timeoutMs)) + let data = try await requestRaw(method: .health, timeoutMs: Double(timeoutMs)) return (try? self.decoder.decode(OpenClawGatewayHealthOK.self, from: data))?.ok ?? true } @@ -657,28 +746,42 @@ extension GatewayConnection { func chatHistory( sessionKey: String, + agentID: String? = nil, limit: Int? = nil, maxChars: Int? = nil, - timeoutMs: Int? = nil) async throws -> OpenClawChatHistoryPayload + timeoutMs: Int? = nil, + ifCurrentRoute route: Route? = nil) async throws -> OpenClawChatHistoryPayload { let resolvedKey = self.canonicalizeSessionKey(sessionKey) var params: [String: AnyCodable] = ["sessionKey": AnyCodable(resolvedKey)] + if let agentID = agentID?.trimmingCharacters(in: .whitespacesAndNewlines), !agentID.isEmpty { + params["agentId"] = AnyCodable(agentID) + } if let limit { params["limit"] = AnyCodable(limit) } if let maxChars { params["maxChars"] = AnyCodable(maxChars) } let timeout = timeoutMs.map { Double($0) } - return try await self.requestDecoded( - method: .chatHistory, - params: params, - timeoutMs: timeout) + if let route { + let data = try await request( + method: Method.chatHistory.rawValue, + params: params, + timeoutMs: timeout, + ifCurrentRoute: route) + return try self.decoder.decode(OpenClawChatHistoryPayload.self, from: data) + } + return try await self.requestDecoded(method: .chatHistory, params: params, timeoutMs: timeout) } func chatSend( sessionKey: String, + agentID: String? = nil, + expectedSessionRoutingContract: String? = nil, message: String, thinking: String?, idempotencyKey: String, attachments: [OpenClawChatAttachmentPayload], - timeoutMs: Int = 30000) async throws -> OpenClawChatSendResponse + timeoutMs: Int = 30000, + ifCurrentRoute route: Route? = nil, + distinguishPreDispatchRouteChange: Bool = false) async throws -> OpenClawChatSendResponse { let resolvedKey = self.canonicalizeSessionKey(sessionKey) var params: [String: AnyCodable] = [ @@ -687,6 +790,15 @@ extension GatewayConnection { "idempotencyKey": AnyCodable(idempotencyKey), "timeoutMs": AnyCodable(timeoutMs), ] + if let agentID = agentID?.trimmingCharacters(in: .whitespacesAndNewlines), !agentID.isEmpty { + params["agentId"] = AnyCodable(agentID) + } + if let expectedSessionRoutingContract = expectedSessionRoutingContract? + .trimmingCharacters(in: .whitespacesAndNewlines), + !expectedSessionRoutingContract.isEmpty + { + params["expectedSessionRoutingContract"] = AnyCodable(expectedSessionRoutingContract) + } if let thinking = thinking?.trimmingCharacters(in: .whitespacesAndNewlines), !thinking.isEmpty { @@ -705,16 +817,22 @@ extension GatewayConnection { params["attachments"] = AnyCodable(encoded) } - return try await self.requestDecoded( - method: .chatSend, - params: params, - timeoutMs: Double(timeoutMs)) + if let route { + let data = try await request( + method: Method.chatSend.rawValue, + params: params, + timeoutMs: Double(timeoutMs), + ifCurrentRoute: route, + distinguishPreDispatchRouteChange: distinguishPreDispatchRouteChange) + return try self.decoder.decode(OpenClawChatSendResponse.self, from: data) + } + return try await self.requestDecoded(method: .chatSend, params: params, timeoutMs: Double(timeoutMs)) } func chatAbort(sessionKey: String, runId: String) async throws -> Bool { let resolvedKey = self.canonicalizeSessionKey(sessionKey) struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? } - let res: AbortResponse = try await self.requestDecoded( + let res: AbortResponse = try await requestDecoded( method: .chatAbort, params: ["sessionKey": AnyCodable(resolvedKey), "runId": AnyCodable(runId)]) return res.aborted ?? false @@ -730,7 +848,7 @@ extension GatewayConnection { func voiceWakeGetTriggers() async throws -> [String] { struct VoiceWakePayload: Decodable { let triggers: [String] } - let payload: VoiceWakePayload = try await self.requestDecoded(method: .voicewakeGet) + let payload: VoiceWakePayload = try await requestDecoded(method: .voicewakeGet) return payload.triggers } @@ -792,14 +910,14 @@ extension GatewayConnection { } func cronList(includeDisabled: Bool = true) async throws -> [CronJob] { - let data = try await self.requestRaw( + let data = try await requestRaw( method: .cronList, params: ["includeDisabled": AnyCodable(includeDisabled)]) return try Self.decodeCronListResponse(data) } func cronRuns(jobId: String, limit: Int = 200) async throws -> [CronRunLogEntry] { - let data = try await self.requestRaw( + let data = try await requestRaw( method: .cronRuns, params: ["id": AnyCodable(jobId), "limit": AnyCodable(limit)]) return try Self.decodeCronRunsResponse(data) diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift index d80146482fe..bfa95d3187d 100644 --- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift +++ b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift @@ -18,6 +18,16 @@ private enum WebChatSwiftUILayout { } struct MacGatewayChatTransport: OpenClawChatTransport { + private let outboxGatewayID: String? + + init(outboxGatewayID: String? = nil) { + self.outboxGatewayID = outboxGatewayID + } + + var outboxRequiresSessionRoutingContract: Bool { + true + } + func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey) } @@ -119,6 +129,89 @@ struct MacGatewayChatTransport: OpenClawChatTransport { attachments: attachments) } + func sendMessage( + sessionKey: String, + agentID: String?, + expectedSessionRoutingContract: String?, + message: String, + thinking: String, + idempotencyKey: String, + attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse + { + if let outboxGatewayID { + try await Self.requireGateway(outboxGatewayID) + } + guard let route = await GatewayConnection.shared.captureRoute(), + let supportsRoutingContract = await GatewayConnection.shared.supportsServerCapability( + .chatSendRoutingContract, + ifCurrentRoute: route) + else { throw OpenClawChatTransportSendError.notDispatched } + // Outbox replay is capability-gated in acquireOutboxRouteLease. A + // live send keeps its captured route on older gateways and omits the + // unsupported atomic routing field. + let guardedContract = OpenClawChatSessionRoutingContract.expectedValue( + expectedSessionRoutingContract, + serverSupportsGuard: supportsRoutingContract) + return try await GatewayConnection.shared.chatSend( + sessionKey: sessionKey, + agentID: agentID, + expectedSessionRoutingContract: guardedContract, + message: message, + thinking: thinking, + idempotencyKey: idempotencyKey, + attachments: attachments, + ifCurrentRoute: route, + distinguishPreDispatchRouteChange: true) + } + + func acquireOutboxRouteLease() async -> OpenClawChatTransportRouteLeaseResult { + guard let outboxGatewayID else { return .unavailable(reason: nil) } + let currentGatewayID = await MainActor.run { MacChatTranscriptCache.currentGatewayID() } + guard currentGatewayID == outboxGatewayID, + let route = await GatewayConnection.shared.captureRoute() + else { return .unavailable(reason: nil) } + guard let supportsRoutingContract = await GatewayConnection.shared.supportsServerCapability( + .chatSendRoutingContract, + ifCurrentRoute: route) + else { return .unavailable(reason: nil) } + guard supportsRoutingContract else { + return .unavailable(reason: OpenClawChatTransportUpgradeMessage.routingContract) + } + guard let routingIdentity = try? await GatewayConnection.shared.sessionRoutingIdentity( + ifCurrentRoute: route) + else { return .unavailable(reason: nil) } + let routingContract = routingIdentity.contract + return .available(OpenClawChatTransportRouteLease( + sendTargetedMessage: { sessionKey, agentID, message, thinking, idempotencyKey, attachments in + try await Self.requireGateway(outboxGatewayID) + return try await GatewayConnection.shared.chatSend( + sessionKey: sessionKey, + agentID: agentID, + expectedSessionRoutingContract: routingContract, + message: message, + thinking: thinking, + idempotencyKey: idempotencyKey, + attachments: attachments, + ifCurrentRoute: route, + distinguishPreDispatchRouteChange: true) + }, + requestTargetedHistory: { sessionKey, agentID in + try await Self.requireGateway(outboxGatewayID) + return try await GatewayConnection.shared.chatHistory( + sessionKey: sessionKey, + agentID: agentID, + ifCurrentRoute: route) + }, + sessionRoutingContract: routingContract)) + } + + private static func requireGateway(_ gatewayID: String) async throws { + let currentGatewayID = await MainActor.run { MacChatTranscriptCache.currentGatewayID() } + guard currentGatewayID == gatewayID else { + throw OpenClawChatTransportSendError.notDispatched + } + } + func requestHealth(timeoutMs: Int) async throws -> Bool { try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs) } @@ -255,11 +348,14 @@ final class WebChatSwiftUIWindowController { // so binding the cache identity at construction stays correct. One // store instance backs both the transcript cache and the offline // command outbox. - let store = MacChatTranscriptCache.make() + let context = MacChatTranscriptCache.makeContext() + let store = context?.store self.init( sessionKey: sessionKey, presentation: presentation, - transport: MacGatewayChatTransport(), + transport: MacGatewayChatTransport(outboxGatewayID: store?.gatewayID), + initialActiveAgentID: context?.routingIdentity?.defaultAgentID, + initialSessionRoutingContract: context?.routingIdentity?.contract, transcriptCache: store, outbox: store) } @@ -268,6 +364,8 @@ final class WebChatSwiftUIWindowController { sessionKey: String, presentation: WebChatPresentation, transport: any OpenClawChatTransport, + initialActiveAgentID: String? = nil, + initialSessionRoutingContract: String? = nil, transcriptCache: (any OpenClawChatTranscriptCache)? = nil, outbox: (any OpenClawChatCommandOutbox)? = nil) { @@ -276,6 +374,8 @@ final class WebChatSwiftUIWindowController { let vm = OpenClawChatViewModel( sessionKey: sessionKey, transport: transport, + activeAgentId: initialActiveAgentID, + sessionRoutingContract: initialSessionRoutingContract, transcriptCache: transcriptCache, outbox: outbox, initialThinkingLevel: Self.persistedThinkingLevel(), @@ -287,8 +387,25 @@ final class WebChatSwiftUIWindowController { for await push in pushes { guard let vm else { return } guard case .snapshot = push else { continue } - let activeAgentId = await GatewayConnection.shared.cachedDefaultAgentId() - vm.syncActiveAgentId(activeAgentId) + let route = await GatewayConnection.shared.captureRoute() + let routingIdentity: GatewayConnection.SessionRoutingIdentity? = if let route { + try? await GatewayConnection.shared.sessionRoutingIdentity( + ifCurrentRoute: route) + } else { + nil + } + if let routingIdentity { + if let store = transcriptCache as? OpenClawChatSQLiteTranscriptCache, + store.gatewayID == MacChatTranscriptCache.currentGatewayID(), + let persistedIdentity = OpenClawChatSessionRoutingIdentity( + contract: routingIdentity.contract) + { + await store.storeSessionRoutingIdentity(persistedIdentity) + } + vm.syncDeliveryIdentity( + activeAgentId: routingIdentity.defaultAgentID, + sessionRoutingContract: routingIdentity.contract) + } } } let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex) diff --git a/apps/macos/Tests/OpenClawIPCTests/ChatTranscriptCacheIdentityTests.swift b/apps/macos/Tests/OpenClawIPCTests/ChatTranscriptCacheIdentityTests.swift index 7c0a604303b..0f19c223de3 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ChatTranscriptCacheIdentityTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ChatTranscriptCacheIdentityTests.swift @@ -16,6 +16,16 @@ struct ChatTranscriptCacheIdentityTests { #expect(id == nil) } + @Test @MainActor func `windows share one outbox owner per gateway`() { + let databaseURL = FileManager.default.temporaryDirectory + .appendingPathComponent("openclaw-cache-owner-\(UUID().uuidString).sqlite") + let gatewayID = "gw-shared-\(UUID().uuidString)" + let first = MacChatTranscriptCache.store(databaseURL: databaseURL, gatewayID: gatewayID) + let second = MacChatTranscriptCache.store(databaseURL: databaseURL, gatewayID: gatewayID) + + #expect(first === second) + } + @Test func `local mode keys on state dir so profiles never collide`() { let defaultProfile = MacChatTranscriptCache.gatewayID( mode: .local, diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift index 93dfe72227f..8912c7da17a 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift @@ -1,4 +1,5 @@ import Foundation +import OpenClawChatUI import OpenClawKit import os import Testing @@ -58,7 +59,7 @@ struct GatewayConnectionTests { @Test func `request reuses single web socket for same config`() async throws { let session = self.makeSession() - let (conn, _) = try self.makeConnection(session: session) + let (conn, _) = try makeConnection(session: session) _ = try await conn.request(method: "status", params: nil) #expect(session.snapshotMakeCount() == 1) @@ -70,7 +71,7 @@ struct GatewayConnectionTests { @Test func `request reconfigures and cancels on token change`() async throws { let session = self.makeSession() - let (conn, cfg) = try self.makeConnection(session: session, token: "a") + let (conn, cfg) = try makeConnection(session: session, token: "a") _ = try await conn.request(method: "status", params: nil) #expect(session.snapshotMakeCount() == 1) @@ -81,9 +82,38 @@ struct GatewayConnectionTests { #expect(session.snapshotCancelCount() == 1) } + @Test func `captured route cancels instead of reconfiguring on token change`() async throws { + let session = self.makeSession() + let (conn, cfg) = try makeConnection(session: session, token: "a") + + _ = try await conn.request(method: "status", params: nil) + let route = try #require(await conn.captureRoute()) + cfg.setToken("b") + + do { + _ = try await conn.request( + method: "status", + params: nil, + ifCurrentRoute: route) + Issue.record("expected stale route cancellation") + } catch is CancellationError {} + + do { + _ = try await conn.request( + method: "status", + params: nil, + ifCurrentRoute: route, + distinguishPreDispatchRouteChange: true) + Issue.record("expected typed stale route rejection") + } catch is OpenClawChatTransportSendError {} + + #expect(session.snapshotMakeCount() == 1) + #expect(session.snapshotCancelCount() == 0) + } + @Test func `concurrent requests still use single web socket`() async throws { let session = self.makeSession(helloDelayMs: 150) - let (conn, _) = try self.makeConnection(session: session) + let (conn, _) = try makeConnection(session: session) async let r1: Data = conn.request(method: "status", params: nil) async let r2: Data = conn.request(method: "status", params: nil) @@ -101,7 +131,7 @@ struct GatewayConnectionTests { } }) }) - let (conn, _) = try self.makeConnection(session: session) + let (conn, _) = try makeConnection(session: session) do { _ = try await conn.request( @@ -118,7 +148,7 @@ struct GatewayConnectionTests { @Test func `subscribe replays latest snapshot`() async throws { let session = self.makeSession() - let (conn, _) = try self.makeConnection(session: session) + let (conn, _) = try makeConnection(session: session) _ = try await conn.request(method: "status", params: nil) @@ -135,7 +165,7 @@ struct GatewayConnectionTests { @Test func `subscribe emits seq gap before event`() async throws { let session = self.makeSession() - let (conn, _) = try self.makeConnection(session: session) + let (conn, _) = try makeConnection(session: session) let stream = await conn.subscribe(bufferingNewest: 10) var iterator = stream.makeAsyncIterator() diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift index d0fdb041fde..779cb86b19e 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift @@ -29,11 +29,12 @@ private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable { {"type":"event","event":"connect.challenge","payload":{"nonce":"test-nonce"}} """) } - if let request = self.latestUnrespondedRequest() { + if let request = latestUnrespondedRequest() { self.respondedRequestIds.insert(request.id) if request.method == "connect" { return .string(""" - {"type":"res","id":"\(request.id)","ok":true,"payload":{"type":"hello","protocol":3,"server":{},"features":{},"snapshot":{"presence":[],"health":{},"stateVersion":{"presence":0,"health":0},"uptimeMs":0},"auth":{},"policy":{}}} + {"type":"res","id":"\(request + .id)","ok":true,"payload":{"type":"hello","protocol":3,"server":{},"features":{},"snapshot":{"presence":[],"health":{},"stateVersion":{"presence":0,"health":0},"uptimeMs":0},"auth":{},"policy":{}}} """) } return .string(""" @@ -181,7 +182,7 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes #expect(params?["voiceWakeTrigger"] as? String == "") } - @Test func `chat send omits thinking when inheriting session default`() async throws { + @Test func `chat send carries routing precondition and omits inherited thinking`() async throws { let recorder = WebSocketMessageRecorder() let session = GatewayTestWebSocketSession(taskFactory: { GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in @@ -202,6 +203,7 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes _ = try await connection.chatSend( sessionKey: "main", + expectedSessionRoutingContract: "per-sender|main|main", message: "hello", thinking: nil, idempotencyKey: "chat-1", @@ -226,6 +228,15 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes let json = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any] let params = json?["params"] as? [String: Any] #expect(params?["thinking"] == nil) + #expect(params?["expectedSessionRoutingContract"] as? String == "per-sender|main|main") + } + + @Test func `routing identity decodes agent and contract from one response`() throws { + let data = Data(#"{"defaultId":"Work","mainKey":"Primary","scope":"global","agents":[]}"#.utf8) + let identity = try GatewayConnection.decodeSessionRoutingIdentity(data) + + #expect(identity.defaultAgentID == "Work") + #expect(identity.contract == "global|primary|work") } private static func messageData(_ message: URLSessionWebSocketTask.Message) -> Data? { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift index b5fdcba72f2..e766d6e0216 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift @@ -84,6 +84,7 @@ struct OpenClawChatComposer: View { let assistantAvatarTint: Color? let composerChrome: OpenClawChatView.ComposerChrome let isComposerEnabled: Bool + let isAttachmentInputEnabled: Bool let messagePlaceholder: String? let talkControl: OpenClawChatTalkControl? @@ -314,7 +315,7 @@ struct OpenClawChatComposer: View { .accessibilityIdentifier("chat-attachment-picker") .buttonStyle(.plain) .controlSize(.small) - .disabled(!self.isComposerEnabled) + .disabled(!self.isAttachmentInputEnabled) } else { Button { self.pickFilesMac() @@ -325,7 +326,7 @@ struct OpenClawChatComposer: View { .accessibilityLabel("Attachments") .buttonStyle(.bordered) .controlSize(.small) - .disabled(!self.isComposerEnabled) + .disabled(!self.isAttachmentInputEnabled) } #else if self.composerChrome == .clean { @@ -337,7 +338,7 @@ struct OpenClawChatComposer: View { .accessibilityIdentifier("chat-attachment-picker") .buttonStyle(.plain) .controlSize(.small) - .disabled(!self.isComposerEnabled) + .disabled(!self.isAttachmentInputEnabled) .onChange(of: self.pickerItems) { _, newItems in Task { await self.loadPhotosPickerItems(newItems) } } @@ -349,7 +350,7 @@ struct OpenClawChatComposer: View { .accessibilityLabel("Attachments") .buttonStyle(.bordered) .controlSize(.small) - .disabled(!self.isComposerEnabled) + .disabled(!self.isAttachmentInputEnabled) .onChange(of: self.pickerItems) { _, newItems in Task { await self.loadPhotosPickerItems(newItems) } } @@ -643,7 +644,7 @@ struct OpenClawChatComposer: View { self.sendDraftIfEnabled() }, onPasteImageAttachment: { data, fileName, mimeType in - guard self.isComposerEnabled else { return } + guard self.isAttachmentInputEnabled else { return } self.viewModel.addImageAttachment(data: data, fileName: fileName, mimeType: mimeType) }) .frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight) @@ -1008,7 +1009,9 @@ struct OpenClawChatComposer: View { } private var canSendMessage: Bool { - self.isComposerEnabled && self.viewModel.canSend + self.isComposerEnabled + && self.viewModel.canSend + && (self.isAttachmentInputEnabled || self.viewModel.attachments.isEmpty) } private var connectionStatusText: String { @@ -1026,7 +1029,7 @@ struct OpenClawChatComposer: View { #if os(macOS) private func pickFilesMac() { - guard self.isComposerEnabled else { return } + guard self.isAttachmentInputEnabled else { return } let panel = NSOpenPanel() panel.title = "Select image attachments" panel.allowsMultipleSelection = true @@ -1039,7 +1042,7 @@ struct OpenClawChatComposer: View { } private func handleDrop(_ providers: [NSItemProvider]) -> Bool { - guard self.isComposerEnabled else { return false } + guard self.isAttachmentInputEnabled else { return false } let fileProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) } guard !fileProviders.isEmpty else { return false } for item in fileProviders { @@ -1056,7 +1059,7 @@ struct OpenClawChatComposer: View { } #else private func loadPhotosPickerItems(_ items: [PhotosPickerItem]) async { - guard self.isComposerEnabled else { + guard self.isAttachmentInputEnabled else { self.pickerItems = [] return } @@ -1077,7 +1080,7 @@ struct OpenClawChatComposer: View { #endif private func sendDraftIfEnabled() { - guard self.isComposerEnabled else { return } + guard self.canSendMessage else { return } self.viewModel.send() } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift index 89d67af885b..4e4f15063cb 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift @@ -641,6 +641,10 @@ struct ChatOutboxStatusLabel: View { "Queued" case .sending: "Sending…" + case .confirming: + "Confirming…" + case let .failed(reason) where reason == OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError: + "Delivery unknown" case .failed: "Not sent" } @@ -652,6 +656,10 @@ struct ChatOutboxStatusLabel: View { "clock" case .sending: "arrow.up.circle" + case .confirming: + "checkmark.circle" + case let .failed(reason) where reason == OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError: + "questionmark.circle" case .failed: "exclamationmark.circle" } @@ -663,6 +671,10 @@ struct ChatOutboxStatusLabel: View { "Queued, sends when reconnected" case .sending: "Sending" + case .confirming: + "Sent, waiting for chat history confirmation" + case let .failed(reason) where reason == OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError: + "Delivery unconfirmed, touch and hold to retry or delete" case .failed: "Not sent, touch and hold to retry or delete" } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift index 4466fb67a15..81a7b3bf662 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift @@ -7,6 +7,78 @@ import UIKit private let cacheLogger = Logger(subsystem: "ai.openclaw", category: "OpenClawChatTranscriptCache") +private final class OutboxChangeHub: @unchecked Sendable { + private let lock = NSLock() + private var continuations: [UUID: AsyncStream.Continuation] = [:] + + func stream() -> AsyncStream { + let id = UUID() + let pair = AsyncStream.makeStream() + self.lock.lock() + self.continuations[id] = pair.continuation + self.lock.unlock() + pair.continuation.onTermination = { [weak self] _ in + self?.remove(id) + } + return pair.stream + } + + func yield(_ change: OpenClawChatOutboxChange) { + self.lock.lock() + let continuations = Array(self.continuations.values) + self.lock.unlock() + for continuation in continuations { + continuation.yield(change) + } + } + + func finish() { + self.lock.lock() + let continuations = Array(self.continuations.values) + self.continuations.removeAll() + self.lock.unlock() + for continuation in continuations { + continuation.finish() + } + } + + private func remove(_ id: UUID) { + self.lock.lock() + self.continuations.removeValue(forKey: id) + self.lock.unlock() + } +} + +/// Canonical gateway evidence must beat a user cancellation synchronously; +/// actor hops would leave a window where an already-delivered row is scrubbed. +private final class CanonicalMessageProofHub: @unchecked Sendable { + private static let maxKeys = 512 + private let lock = NSLock() + private var keys: [String] = [] + + func observe(_ observed: Set) { + guard !observed.isEmpty else { return } + self.lock.lock() + for key in observed.sorted() { + self.keys.removeAll(where: { $0 == key }) + self.keys.append(key) + } + if self.keys.count > Self.maxKeys { + self.keys.removeFirst(self.keys.count - Self.maxKeys) + } + self.lock.unlock() + } + + /// Serializes the final cancellation decision and SQLite commit with + /// synchronous canonical observation. Evidence recorded before this lock + /// wins; evidence after it observes a completed cancellation. + func withProofDecision(for key: String, _ body: (Bool) -> T) -> T { + self.lock.lock() + defer { self.lock.unlock() } + return body(self.keys.contains(key)) + } +} + /// Read-only offline cache seam for chat sessions and transcripts. /// /// The cache only pre-paints cold opens and covers offline browsing; connected @@ -16,8 +88,74 @@ private let cacheLogger = Logger(subsystem: "ai.openclaw", category: "OpenClawCh public protocol OpenClawChatTranscriptCache: Sendable { func loadSessions() async -> [OpenClawChatSessionEntry] func loadTranscript(sessionKey: String) async -> [OpenClawChatMessage] + func loadTranscript(sessionKey: String, agentID: String?) async -> [OpenClawChatMessage] func storeSessions(_ sessions: [OpenClawChatSessionEntry]) async func storeTranscript(sessionKey: String, messages: [OpenClawChatMessage]) async + func storeTranscript(sessionKey: String, agentID: String?, messages: [OpenClawChatMessage]) async + /// Canonical gateway rows can prove that an ambiguously delivered local + /// command landed after cancellation and must override local suppression. + func storeCanonicalTranscript( + sessionKey: String, + messages: [OpenClawChatMessage], + canonicalMessageIdempotencyKeys: Set) async + func storeCanonicalTranscript( + sessionKey: String, + agentID: String?, + messages: [OpenClawChatMessage], + canonicalMessageIdempotencyKeys: Set) async + /// Synchronous observation closes the session.message -> cancellation + /// race before asynchronous SQLite confirmation starts. + func observeCanonicalMessageIdempotencyKeys(_ keys: Set) +} + +extension OpenClawChatTranscriptCache { + public func loadTranscript(sessionKey: String, agentID: String?) async -> [OpenClawChatMessage] { + guard agentID == nil else { return [] } + return await self.loadTranscript(sessionKey: sessionKey) + } + + public func storeTranscript( + sessionKey: String, + agentID: String?, + messages: [OpenClawChatMessage]) async + { + guard agentID == nil else { return } + await self.storeTranscript(sessionKey: sessionKey, messages: messages) + } + + public func storeCanonicalTranscript( + sessionKey: String, + messages: [OpenClawChatMessage], + canonicalMessageIdempotencyKeys _: Set) async + { + await self.storeTranscript(sessionKey: sessionKey, messages: messages) + } + + public func storeCanonicalTranscript( + sessionKey: String, + agentID: String?, + messages: [OpenClawChatMessage], + canonicalMessageIdempotencyKeys: Set) async + { + guard agentID == nil else { return } + await self.storeCanonicalTranscript( + sessionKey: sessionKey, + messages: messages, + canonicalMessageIdempotencyKeys: canonicalMessageIdempotencyKeys) + } + + public func observeCanonicalMessageIdempotencyKeys(_: Set) {} +} + +/// Optional atomic merge seam for cache owners that also provide a durable +/// outbox. Keeping this separate preserves source compatibility for read-only +/// transcript-cache conformers. +public protocol OpenClawChatCanonicalTranscriptMerging: OpenClawChatTranscriptCache { + func mergeCanonicalTranscriptMessage( + sessionKey: String, + agentID: String?, + message: OpenClawChatMessage, + canonicalMessageIdempotencyKey: String) async } /// One durable queued chat command (text only in v1). `id` is the client UUID @@ -27,14 +165,27 @@ public protocol OpenClawChatTranscriptCache: Sendable { /// Naming mirrors the watch-side `QueuedCommand` shape (WatchChatCoordinator) /// so the two queues can merge into one owner later. public struct OpenClawChatOutboxCommand: Hashable, Sendable, Identifiable { + static let legacyUnboundRoutingContract = "legacy-unbound" + public enum Status: String, Sendable { case queued case sending + case awaitingConfirmation = "awaiting_confirmation" case failed } public let id: String + /// Presentation/cache key captured when the user queued the command. public let sessionKey: String + /// Canonical transport key captured at enqueue time. This must never be + /// re-resolved from a mutable main/default alias during reconnect. + public let deliverySessionKey: String + /// Gateway main-routing contract (scope, main key, default agent) captured + /// with the command. A changed contract must fail closed before replay. + public let routingContract: String? + /// Durable routing owner, required for the literal `global` session and + /// retained for ownership checks on canonical agent-scoped keys. + public let agentID: String? public let text: String /// Thinking level captured when the command was queued, so a later flush /// never borrows the setting of whichever session is visible then. @@ -48,6 +199,9 @@ public struct OpenClawChatOutboxCommand: Hashable, Sendable, Identifiable { public init( id: String, sessionKey: String, + deliverySessionKey: String? = nil, + routingContract: String? = nil, + agentID: String? = nil, text: String, thinking: String, createdAt: Double, @@ -57,6 +211,15 @@ public struct OpenClawChatOutboxCommand: Hashable, Sendable, Identifiable { { self.id = id self.sessionKey = sessionKey + if let deliverySessionKey { + self.deliverySessionKey = deliverySessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + } else { + self.deliverySessionKey = sessionKey + } + let normalizedRoutingContract = routingContract?.trimmingCharacters(in: .whitespacesAndNewlines) + self.routingContract = normalizedRoutingContract?.isEmpty == false ? normalizedRoutingContract : nil + let normalizedAgentID = agentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + self.agentID = normalizedAgentID?.isEmpty == false ? normalizedAgentID : nil self.text = text self.thinking = thinking self.createdAt = createdAt @@ -66,6 +229,18 @@ public struct OpenClawChatOutboxCommand: Hashable, Sendable, Identifiable { } } +public enum OpenClawChatOutboxUpdateResult: Equatable, Sendable { + case updated + case confirmed + case missing + case unavailable +} + +public enum OpenClawChatOutboxChange: Equatable, Sendable { + case canceled(id: String) + case confirmed(id: String) +} + /// Durable offline outbox for chat commands, scoped to one gateway identity /// exactly like the transcript cache. Implementations persist queued sends so /// they survive app restarts and flush on reconnect. @@ -74,26 +249,69 @@ public protocol OpenClawChatCommandOutbox: Sendable { /// is unavailable; callers surface that instead of dropping text silently. func enqueueCommand(_ command: OpenClawChatOutboxCommand) async -> Bool /// Gateway-scoped rows in `createdAt` order. Applies the staleness gate: - /// queued rows older than `outboxCommandMaxAge` become failed("expired") - /// so reconnect never sends stale commands silently. + /// old queued or unconfirmed rows become failed so reconnect never sends + /// stale or ambiguously delivered commands silently. func loadCommands() async -> [OpenClawChatOutboxCommand] - /// Crash safety: rows stuck in 'sending' from a previous process revert - /// to 'queued'; the idempotency key makes the re-send safe. Returns false - /// when the store was unreachable (for example Complete file protection - /// while the device is locked) so callers can retry recovery later. + /// Availability-aware read used by the FIFO restoration gate. Nil means + /// storage was not readable, not that the queue was empty. + func loadCommandsIfAvailable() async -> [OpenClawChatOutboxCommand]? + /// Crash safety: rows stuck in 'sending' from a previous process become + /// failed once per store lifetime. Delivery is ambiguous after a crash, + /// so only explicit user retry may replay them; acknowledged rows stay + /// awaiting canonical history confirmation. + /// Returns false while storage is unavailable so callers can retry later. @discardableResult func recoverInterruptedSends() async -> Bool - /// Claims a row for sending. Returns false when the row no longer exists - /// (deleted mid-flush), so the flush skips it instead of sending stale text. - @discardableResult - func markCommandSending(id: String) async -> Bool + /// Atomically claims the oldest queued row when no other row is sending. + /// Nil means another flusher owns the queue or no deliverable row remains. + func claimNextCommand() async -> OpenClawChatOutboxCommand? func markCommandQueued(id: String, retryCount: Int, lastError: String?) async - func markCommandFailed(id: String, retryCount: Int, lastError: String?) async - /// Explicit user retry: reset attempts and refresh `createdAt` so an - /// expired row can send again (retry is new intent, so it also moves the - /// command to the queue tail rather than replaying its old position). - func markCommandRetried(id: String) async - func deleteCommand(id: String) async + func markCommandAwaitingConfirmation(id: String) async -> OpenClawChatOutboxUpdateResult + /// Result-bearing terminal transition for callers that must stop their + /// FIFO when durable storage is unavailable. + func markCommandFailedIfPresent( + id: String, + retryCount: Int, + lastError: String?) async -> OpenClawChatOutboxUpdateResult + /// Result-bearing retry used to adopt an unowned legacy alias into the + /// canonical target explicitly selected by the user. + func markCommandRetriedIfPresent( + id: String, + agentID: String?, + deliverySessionKey: String, + routingContract: String) async -> OpenClawChatOutboxUpdateResult + /// User cancellation succeeds only before a sender claims the row. The + /// status predicate is the cross-view-model cancellation boundary. + func cancelCommand(id: String) async -> OpenClawChatOutboxUpdateResult + /// Canonical gateway history may complete any row, including a sending + /// row whose request ACK was lost. + func confirmCommand(id: String) async -> OpenClawChatOutboxUpdateResult + /// Cross-view-model invalidation. + func changes() -> AsyncStream +} + +public struct OpenClawChatSessionRoutingIdentity: Equatable, Sendable { + public let scope: String + public let mainSessionKey: String + public let defaultAgentID: String + public let contract: String + + public init?(contract: String?) { + guard let components = OpenClawChatSessionRoutingContract.parse(contract) else { return nil } + self.scope = components.scope + self.mainSessionKey = components.mainKey + self.defaultAgentID = components.defaultAgentID + self.contract = "\(components.scope)|\(components.mainKey)|\(components.defaultAgentID)" + } + + public init?(scope: String?, mainSessionKey: String?, defaultAgentID: String?) { + guard let contract = OpenClawChatSessionRoutingContract.make( + scope: scope, + mainKey: mainSessionKey, + defaultAgentID: defaultAgentID) + else { return nil } + self.init(contract: contract) + } } /// SQLite-backed transcript cache for one gateway identity. Owners should use @@ -101,11 +319,13 @@ public protocol OpenClawChatCommandOutbox: Sendable { /// cached transcript bytes without disturbing other paired gateways; queries /// are additionally scoped by `gatewayID` as a defensive belt. /// -/// The cache is disposable: any open, schema, or decode mismatch drops the -/// affected state and rebuilds silently. There are no migrations. The command -/// outbox shares this database; a drop also clears queued commands, which is -/// acceptable because a queue that predates a schema change is stale anyway. -public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, OpenClawChatCommandOutbox { +/// Transcript rows are disposable, but the command outbox is persistent user +/// state. Schema upgrades migrate the shared database; unknown or corrupt +/// existing schemas fail closed without deleting queued commands. +public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, + OpenClawChatCanonicalTranscriptMerging, + OpenClawChatCommandOutbox +{ /// Bounds keep the cache small: enough for a recently-used session picker /// and a full first screen of transcript, not a durable archive. public static let maxCachedSessions = 50 @@ -117,8 +337,48 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, Ope public static let outboxCommandMaxAge: TimeInterval = 48 * 60 * 60 /// Machine-readable `lastError` set by the staleness gate. public static let outboxExpiredError = "expired" - /// v2 adds the outbox_commands table; older shapes drop-and-rebuild. - static let schemaVersion: Int32 = 2 + public static let outboxUnconfirmedError = "delivery_unconfirmed" + public static let outboxUnknownTargetError = "delivery_target_unknown" + public static let outboxChangedTargetError = "delivery_target_changed" + // v2 adds the durable outbox; v3 adds delivery ownership; v4 binds + // replay to the main-routing contract; v5 persists that verified identity. + static let schemaVersion: Int32 = 5 + private static let createOutboxTableSQL = """ + CREATE TABLE IF NOT EXISTS outbox_commands( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_uuid TEXT NOT NULL UNIQUE, + gateway_id TEXT NOT NULL, + session_key TEXT NOT NULL, + delivery_session_key TEXT NOT NULL DEFAULT '', + routing_contract TEXT NOT NULL DEFAULT '', + agent_id TEXT NOT NULL DEFAULT '', + text TEXT NOT NULL, + thinking TEXT NOT NULL DEFAULT '', + created_at REAL NOT NULL, + status TEXT NOT NULL, + retry_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '' + ) + """ + private static let createTranscriptTableSQL = """ + CREATE TABLE IF NOT EXISTS cached_transcripts( + gateway_id TEXT NOT NULL, + session_key TEXT NOT NULL, + agent_id TEXT NOT NULL DEFAULT '', + payload TEXT NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY(gateway_id, session_key, agent_id) + ) + """ + private static let createRoutingIdentityTableSQL = """ + CREATE TABLE IF NOT EXISTS gateway_routing_identity( + gateway_id TEXT NOT NULL PRIMARY KEY, + scope TEXT NOT NULL, + main_session_key TEXT NOT NULL, + default_agent_id TEXT NOT NULL, + updated_at REAL NOT NULL + ) + """ /// Owns the raw sqlite handle so it closes on release without needing an /// isolated actor deinit (OpaquePointer is not Sendable). @@ -135,11 +395,17 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, Ope } private let databaseURL: URL - private let gatewayID: String + public nonisolated let gatewayID: String private var db: Connection? private var isRetired = false - /// After a failed drop-and-rebuild the cache becomes a no-op instead of - /// erroring the chat surface; a fresh launch retries from scratch. + private var hasRecoveredInterruptedSends = false + /// Process-lifetime tombstones reject stale transcript snapshots from an + /// overlapping view model after its queued bubble was canceled durably. + private var canceledMessageKeysBySession: [String: [String]] = [:] + private nonisolated let outboxChangeHub = OutboxChangeHub() + private nonisolated let canonicalMessageProofHub = CanonicalMessageProofHub() + /// Existing database failures preserve persistent outbox bytes and make + /// this store a no-op; an explicit owner purge remains the recovery path. private var isBroken = false public init(databaseURL: URL, gatewayID: String) { @@ -147,6 +413,40 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, Ope self.gatewayID = gatewayID } + /// Startup-only synchronous read for UI owners that must seed routing + /// before constructing a view model. Runtime writes stay actor-isolated. + public nonisolated static func loadSessionRoutingIdentity( + databaseURL: URL, + gatewayID: String) -> OpenClawChatSessionRoutingIdentity? + { + var db: OpaquePointer? + guard sqlite3_open_v2(databaseURL.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK, + let db + else { + sqlite3_close_v2(db) + return nil + } + defer { sqlite3_close_v2(db) } + var statement: OpaquePointer? + let sql = """ + SELECT scope, main_session_key, default_agent_id + FROM gateway_routing_identity WHERE gateway_id = ?1 + """ + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return nil } + defer { sqlite3_finalize(statement) } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + guard sqlite3_bind_text(statement, 1, gatewayID, -1, transient) == SQLITE_OK, + sqlite3_step(statement) == SQLITE_ROW, + let scope = sqlite3_column_text(statement, 0), + let mainSessionKey = sqlite3_column_text(statement, 1), + let defaultAgentID = sqlite3_column_text(statement, 2) + else { return nil } + return OpenClawChatSessionRoutingIdentity( + scope: String(cString: scope), + mainSessionKey: String(cString: mainSessionKey), + defaultAgentID: String(cString: defaultAgentID)) + } + /// Startup-only cleanup, before any cache actor can own an open handle. public static func removeDatabaseFiles(at databaseURL: URL) { let fm = FileManager.default @@ -160,11 +460,11 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, Ope public func loadSessions() async -> [OpenClawChatSessionEntry] { guard !self.isRetired else { return [] } - guard let db = await self.handle() else { return [] } - guard let payload = self.selectPayload( + guard let db = await handle() else { return [] } + guard let payload = selectPayload( db, sql: "SELECT payload FROM cached_sessions WHERE gateway_id = ?1", - bindings: [self.gatewayID]) + bindings: [gatewayID]) else { return [] } @@ -180,12 +480,28 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, Ope } public func loadTranscript(sessionKey: String) async -> [OpenClawChatMessage] { + await self.loadTranscript(sessionKey: sessionKey, agentID: nil) + } + + public func loadTranscript(sessionKey: String, agentID: String?) async -> [OpenClawChatMessage] { guard !self.isRetired else { return [] } - guard let db = await self.handle() else { return [] } - guard let payload = self.selectPayload( + guard let db = await handle() else { return [] } + return self.readTranscript(db, sessionKey: sessionKey, agentID: agentID) + } + + private func readTranscript( + _ db: OpaquePointer, + sessionKey: String, + agentID: String?) -> [OpenClawChatMessage] + { + let normalizedAgentID = Self.normalizedAgentID(agentID) + guard let payload = selectPayload( db, - sql: "SELECT payload FROM cached_transcripts WHERE gateway_id = ?1 AND session_key = ?2", - bindings: [self.gatewayID, sessionKey]) + sql: """ + SELECT payload FROM cached_transcripts + WHERE gateway_id = ?1 AND session_key = ?2 AND agent_id = ?3 + """, + bindings: [gatewayID, sessionKey, normalizedAgentID]) else { return [] } @@ -195,8 +511,11 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, Ope else { self.execute( db, - sql: "DELETE FROM cached_transcripts WHERE gateway_id = ?1 AND session_key = ?2", - bindings: [self.gatewayID, sessionKey]) + sql: """ + DELETE FROM cached_transcripts + WHERE gateway_id = ?1 AND session_key = ?2 AND agent_id = ?3 + """, + bindings: [self.gatewayID, sessionKey, normalizedAgentID]) return [] } return decoded @@ -204,7 +523,7 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, Ope public func storeSessions(_ sessions: [OpenClawChatSessionEntry]) async { guard !self.isRetired else { return } - guard let db = await self.handle() else { return } + guard let db = await handle() else { return } let bounded = Self.boundedSessions(sessions) guard !bounded.isEmpty else { self.execute(db, sql: "DELETE FROM cached_sessions WHERE gateway_id = ?1", bindings: [self.gatewayID]) @@ -220,34 +539,171 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, Ope bindings: [self.gatewayID, payload, Date().timeIntervalSince1970]) } + public func loadSessionRoutingIdentity() async -> OpenClawChatSessionRoutingIdentity? { + guard !self.isRetired, let db = await handle() else { return nil } + var statement: OpaquePointer? + let sql = """ + SELECT scope, main_session_key, default_agent_id + FROM gateway_routing_identity WHERE gateway_id = ?1 + """ + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return nil } + defer { sqlite3_finalize(statement) } + guard self.bind(statement, bindings: [self.gatewayID]), + sqlite3_step(statement) == SQLITE_ROW, + let scope = sqlite3_column_text(statement, 0), + let mainSessionKey = sqlite3_column_text(statement, 1), + let defaultAgentID = sqlite3_column_text(statement, 2) + else { return nil } + return OpenClawChatSessionRoutingIdentity( + scope: String(cString: scope), + mainSessionKey: String(cString: mainSessionKey), + defaultAgentID: String(cString: defaultAgentID)) + } + + public func storeSessionRoutingIdentity(_ identity: OpenClawChatSessionRoutingIdentity) async { + guard !self.isRetired, let db = await handle() else { return } + self.execute( + db, + sql: """ + INSERT OR REPLACE INTO gateway_routing_identity( + gateway_id, scope, main_session_key, default_agent_id, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5) + """, + bindings: [ + self.gatewayID, + identity.scope, + identity.mainSessionKey, + identity.defaultAgentID, + Date().timeIntervalSince1970, + ]) + } + public func storeTranscript(sessionKey: String, messages: [OpenClawChatMessage]) async { + await self.storeTranscript(sessionKey: sessionKey, agentID: nil, messages: messages) + } + + public func storeTranscript( + sessionKey: String, + agentID: String?, + messages: [OpenClawChatMessage]) async + { + await self.writeTranscript(sessionKey: sessionKey, agentID: agentID, messages: messages) + } + + public func storeCanonicalTranscript( + sessionKey: String, + messages: [OpenClawChatMessage], + canonicalMessageIdempotencyKeys: Set) async + { + await self.storeCanonicalTranscript( + sessionKey: sessionKey, + agentID: nil, + messages: messages, + canonicalMessageIdempotencyKeys: canonicalMessageIdempotencyKeys) + } + + public func storeCanonicalTranscript( + sessionKey: String, + agentID: String?, + messages: [OpenClawChatMessage], + canonicalMessageIdempotencyKeys: Set) async + { + self.observeCanonicalMessageIdempotencyKeys(canonicalMessageIdempotencyKeys) + if !canonicalMessageIdempotencyKeys.isEmpty, + var canceledKeys = canceledMessageKeysBySession[sessionKey] + { + canceledKeys.removeAll(where: canonicalMessageIdempotencyKeys.contains) + self.canceledMessageKeysBySession[sessionKey] = canceledKeys.isEmpty ? nil : canceledKeys + } + await self.writeTranscript(sessionKey: sessionKey, agentID: agentID, messages: messages) + } + + public func mergeCanonicalTranscriptMessage( + sessionKey: String, + agentID: String?, + message: OpenClawChatMessage, + canonicalMessageIdempotencyKey: String) async + { + self.observeCanonicalMessageIdempotencyKeys([canonicalMessageIdempotencyKey]) + guard !self.isRetired, let db = await handle() else { return } + + if var canceledKeys = canceledMessageKeysBySession[sessionKey] { + canceledKeys.removeAll(where: { $0 == canonicalMessageIdempotencyKey }) + self.canceledMessageKeysBySession[sessionKey] = canceledKeys.isEmpty ? nil : canceledKeys + } + + // Keep the read, merge, and write in one actor turn. macOS shares this + // cache across windows, so a caller-side read/modify/write can erase a + // newer snapshot written by another view model. + var cached = self.readTranscript(db, sessionKey: sessionKey, agentID: agentID) + if let index = cached.firstIndex(where: { + $0.idempotencyKey?.trimmingCharacters(in: .whitespacesAndNewlines) == + canonicalMessageIdempotencyKey + }) { + cached[index] = message + } else if let timestamp = message.timestamp, + let index = cached.firstIndex(where: { ($0.timestamp ?? .greatestFiniteMagnitude) > timestamp }) + { + cached.insert(message, at: index) + } else { + cached.append(message) + } + self.writeTranscript(db, sessionKey: sessionKey, agentID: agentID, messages: cached) + } + + public nonisolated func observeCanonicalMessageIdempotencyKeys(_ keys: Set) { + self.canonicalMessageProofHub.observe(keys) + } + + private func writeTranscript( + sessionKey: String, + agentID: String?, + messages: [OpenClawChatMessage]) async + { guard !self.isRetired else { return } - guard let db = await self.handle() else { return } - let bounded = Self.cacheableMessages(messages) + guard let db = await handle() else { return } + self.writeTranscript(db, sessionKey: sessionKey, agentID: agentID, messages: messages) + } + + private func writeTranscript( + _ db: OpaquePointer, + sessionKey: String, + agentID: String?, + messages: [OpenClawChatMessage]) + { + let normalizedAgentID = Self.normalizedAgentID(agentID) + let canceledKeys = self.canceledMessageKeysBySession[sessionKey] ?? [] + let bounded = Self.cacheableMessages(messages).filter { message in + guard let key = message.idempotencyKey else { return true } + return !canceledKeys.contains(key) + } guard !bounded.isEmpty else { // An emptied live transcript must also empty the cache, or the next // cold open would ghost-paint messages the gateway no longer has. self.execute( db, - sql: "DELETE FROM cached_transcripts WHERE gateway_id = ?1 AND session_key = ?2", - bindings: [self.gatewayID, sessionKey]) + sql: """ + DELETE FROM cached_transcripts + WHERE gateway_id = ?1 AND session_key = ?2 AND agent_id = ?3 + """, + bindings: [self.gatewayID, sessionKey, normalizedAgentID]) return } guard let payload = Self.encodeJSON(bounded) else { return } self.execute( db, sql: """ - INSERT OR REPLACE INTO cached_transcripts(gateway_id, session_key, payload, updated_at) - VALUES (?1, ?2, ?3, ?4) + INSERT OR REPLACE INTO cached_transcripts(gateway_id, session_key, agent_id, payload, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5) """, - bindings: [self.gatewayID, sessionKey, payload, Date().timeIntervalSince1970]) + bindings: [self.gatewayID, sessionKey, normalizedAgentID, payload, Date().timeIntervalSince1970]) // rowid tie-breaks equal timestamps: INSERT OR REPLACE mints a fresh // rowid, so the most recently written transcript always survives. self.execute( db, sql: """ - DELETE FROM cached_transcripts WHERE gateway_id = ?1 AND session_key NOT IN ( - SELECT session_key FROM cached_transcripts WHERE gateway_id = ?1 + DELETE FROM cached_transcripts WHERE gateway_id = ?1 AND rowid NOT IN ( + SELECT rowid FROM cached_transcripts WHERE gateway_id = ?1 ORDER BY updated_at DESC, rowid DESC LIMIT \(Self.maxCachedTranscripts) ) """, @@ -259,16 +715,22 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, Ope // no-op. Closing the handle lets the owner delete the whole cache file. self.isRetired = true self.db = nil + self.outboxChangeHub.finish() } // MARK: - OpenClawChatCommandOutbox + public nonisolated func changes() -> AsyncStream { + self.outboxChangeHub.stream() + } + public func enqueueCommand(_ command: OpenClawChatOutboxCommand) async -> Bool { - guard !self.isRetired, let db = await self.handle() else { return false } - let count = self.selectInt( + guard !self.isRetired, let db = await handle() else { return false } + guard let count = selectInt( db, sql: "SELECT COUNT(*) FROM outbox_commands WHERE gateway_id = ?1", - bindings: [self.gatewayID]) ?? 0 + bindings: [gatewayID]) + else { return false } // Bound the queue per gateway across all statuses so failed rows also // count: the user must clear them before queueing more. guard count < Self.maxQueuedCommands else { return false } @@ -276,131 +738,334 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, Ope db, sql: """ INSERT INTO outbox_commands( - client_uuid, gateway_id, session_key, text, thinking, created_at, status, retry_count, last_error - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, '') + client_uuid, gateway_id, session_key, delivery_session_key, routing_contract, + agent_id, text, thinking, created_at, status, retry_count, last_error + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) """, bindings: [ command.id, self.gatewayID, command.sessionKey, + command.deliverySessionKey, + command.routingContract ?? "", + command.agentID ?? "", command.text, command.thinking, command.createdAt, command.status.rawValue, command.retryCount, + command.lastError ?? "", ]) } public func loadCommands() async -> [OpenClawChatOutboxCommand] { - guard !self.isRetired, let db = await self.handle() else { return [] } - // Staleness gate: a command queued 48h ago is more likely wrong than - // wanted; surface it as failed("expired") instead of sending it. - self.execute( - db, - sql: """ - UPDATE outbox_commands SET status = 'failed', last_error = ?3 - WHERE gateway_id = ?1 AND status = 'queued' AND created_at < ?2 - """, - bindings: [ - self.gatewayID, - Date().timeIntervalSince1970 - Self.outboxCommandMaxAge, - Self.outboxExpiredError, - ]) + await self.loadCommandsIfAvailable() ?? [] + } - var statement: OpaquePointer? - let sql = """ - SELECT client_uuid, session_key, text, thinking, created_at, status, retry_count, last_error - FROM outbox_commands WHERE gateway_id = ?1 - ORDER BY created_at ASC, id ASC - """ - guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return [] } - defer { sqlite3_finalize(statement) } - guard self.bind(statement, bindings: [self.gatewayID]) else { return [] } - - var commands: [OpenClawChatOutboxCommand] = [] - while sqlite3_step(statement) == SQLITE_ROW { - guard let id = sqlite3_column_text(statement, 0), - let sessionKey = sqlite3_column_text(statement, 1), - let text = sqlite3_column_text(statement, 2) - else { continue } - let thinking = sqlite3_column_text(statement, 3).map { String(cString: $0) } ?? "" - let statusRaw = sqlite3_column_text(statement, 5).map { String(cString: $0) } ?? "" - let lastError = sqlite3_column_text(statement, 7).map { String(cString: $0) } ?? "" - commands.append( - OpenClawChatOutboxCommand( - id: String(cString: id), - sessionKey: String(cString: sessionKey), - text: String(cString: text), - thinking: thinking, - createdAt: sqlite3_column_double(statement, 4), - // Unknown status means a foreign writer; treating it as - // queued is safe because the idempotency key dedupes. - status: OpenClawChatOutboxCommand.Status(rawValue: statusRaw) ?? .queued, - retryCount: Int(sqlite3_column_int64(statement, 6)), - lastError: lastError.isEmpty ? nil : lastError)) - } - return commands + public func loadCommandsIfAvailable() async -> [OpenClawChatOutboxCommand]? { + guard !self.isRetired, let db = await handle() else { return nil } + guard self.applyOutboxStaleness(db) else { return nil } + return self.readCommands(db) } @discardableResult public func recoverInterruptedSends() async -> Bool { - guard !self.isRetired, let db = await self.handle() else { return false } - return self.execute( + guard !self.isRetired else { return false } + if self.hasRecoveredInterruptedSends { return true } + guard let db = await handle() else { return false } + let recovered = self.execute( db, - sql: "UPDATE outbox_commands SET status = 'queued' WHERE gateway_id = ?1 AND status = 'sending'", - bindings: [self.gatewayID]) + sql: """ + UPDATE outbox_commands SET status = 'failed', last_error = ?2 + WHERE gateway_id = ?1 AND status = 'sending' + """, + bindings: [self.gatewayID, Self.outboxUnconfirmedError]) + if recovered { + self.hasRecoveredInterruptedSends = true + } + return recovered } - @discardableResult - public func markCommandSending(id: String) async -> Bool { - guard !self.isRetired, let db = await self.handle() else { return false } + public func claimNextCommand() async -> OpenClawChatOutboxCommand? { + guard !self.isRetired, let db = await handle() else { return nil } + guard self.execute(db, sql: "BEGIN IMMEDIATE", bindings: []) else { return nil } + var committed = false + defer { + if !committed { + _ = self.execute(db, sql: "ROLLBACK", bindings: []) + } + } + guard self.applyOutboxStaleness(db) else { return nil } + guard let activeClaimCount = selectInt( + db, + sql: "SELECT COUNT(*) FROM outbox_commands WHERE gateway_id = ?1 AND status = 'sending'", + bindings: [gatewayID]) + else { return nil } + let hasActiveClaim = activeClaimCount > 0 + guard !hasActiveClaim else { + committed = self.execute(db, sql: "COMMIT", bindings: []) + return nil + } + guard let commands = readCommands(db) else { return nil } + guard var next = commands.first(where: { $0.status == .queued }) else { + committed = self.execute(db, sql: "COMMIT", bindings: []) + return nil + } let updated = self.execute( db, - sql: "UPDATE outbox_commands SET status = 'sending' WHERE gateway_id = ?1 AND client_uuid = ?2", - bindings: [self.gatewayID, id]) - // Zero changed rows means the command was deleted while this claim - // was queued; the caller must not send it. - return updated && sqlite3_changes(db) > 0 + sql: """ + UPDATE outbox_commands SET status = 'sending' + WHERE gateway_id = ?1 AND client_uuid = ?2 AND status = 'queued' + """, + bindings: [self.gatewayID, next.id]) && sqlite3_changes(db) > 0 + guard updated else { return nil } + committed = self.execute(db, sql: "COMMIT", bindings: []) + guard committed else { return nil } + next.status = .sending + return next } public func markCommandQueued(id: String, retryCount: Int, lastError: String?) async { await self.updateCommandStatus(id: id, status: "queued", retryCount: retryCount, lastError: lastError) } - public func markCommandFailed(id: String, retryCount: Int, lastError: String?) async { - await self.updateCommandStatus(id: id, status: "failed", retryCount: retryCount, lastError: lastError) - } - - public func markCommandRetried(id: String) async { - guard !self.isRetired, let db = await self.handle() else { return } - // Fresh createdAt: without it the staleness gate would immediately - // re-expire a retried row that sat offline past the 48h bound. - self.execute( + public func markCommandAwaitingConfirmation(id: String) async -> OpenClawChatOutboxUpdateResult { + guard !self.isRetired, let db = await handle() else { + self.hasRecoveredInterruptedSends = false + return .unavailable + } + let updated = self.execute( db, sql: """ - UPDATE outbox_commands SET status = 'queued', retry_count = 0, last_error = '', created_at = ?3 - WHERE gateway_id = ?1 AND client_uuid = ?2 + UPDATE outbox_commands SET status = 'awaiting_confirmation', retry_count = 0, last_error = '' + WHERE gateway_id = ?1 AND client_uuid = ?2 AND status = 'sending' """, - bindings: [self.gatewayID, id, Date().timeIntervalSince1970]) + bindings: [self.gatewayID, id]) + guard updated else { + // The claimed row may still be `sending`; a later healthy pass + // must fail it closed within this store lifetime. + self.hasRecoveredInterruptedSends = false + return .unavailable + } + return sqlite3_changes(db) > 0 ? .updated : .missing } - public func deleteCommand(id: String) async { - guard !self.isRetired, let db = await self.handle() else { return } - self.execute( + public func markCommandFailedIfPresent( + id: String, + retryCount: Int, + lastError: String?) async -> OpenClawChatOutboxUpdateResult + { + guard !self.isRetired, let db = await handle() else { + self.hasRecoveredInterruptedSends = false + return .unavailable + } + let updated = self.execute( + db, + sql: """ + UPDATE outbox_commands SET status = 'failed', retry_count = ?3, last_error = ?4 + WHERE gateway_id = ?1 AND client_uuid = ?2 AND status = 'sending' + """, + bindings: [self.gatewayID, id, retryCount, lastError ?? ""]) + guard updated else { + // The caller must stop this flush pass until storage is healthy; + // otherwise a durable `sending` row can block the FIFO silently. + self.hasRecoveredInterruptedSends = false + return .unavailable + } + return sqlite3_changes(db) > 0 ? .updated : .missing + } + + public func markCommandRetriedIfPresent( + id: String, + agentID: String?, + deliverySessionKey: String, + routingContract: String) async -> OpenClawChatOutboxUpdateResult + { + guard !self.isRetired, let db = await handle() else { return .unavailable } + let normalizedAgentID = agentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + let normalizedDeliverySessionKey = deliverySessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedRoutingContract = routingContract.trimmingCharacters(in: .whitespacesAndNewlines) + let allowsUntargetedAgent = normalizedRoutingContract == OpenClawChatOutboxCommand + .legacyUnboundRoutingContract || normalizedDeliverySessionKey.lowercased() == "unknown" + guard !normalizedAgentID.isEmpty || allowsUntargetedAgent, + !normalizedDeliverySessionKey.isEmpty, + !normalizedRoutingContract.isEmpty + else { return .unavailable } + guard self.execute(db, sql: "BEGIN IMMEDIATE", bindings: []) else { return .unavailable } + var committed = false + defer { + if !committed { + _ = self.execute(db, sql: "ROLLBACK", bindings: []) + } + } + let previousTarget = self.lookupOutboxTarget(db, id: id) + let previousSessionKey: String + let previousAgentID: String + switch previousTarget { + case let .value(sessionKey, agentID): + previousSessionKey = sessionKey + previousAgentID = agentID + case .missing: + committed = self.execute(db, sql: "COMMIT", bindings: []) + return committed ? .missing : .unavailable + case .unavailable: + return .unavailable + } + guard self.execute( + db, + sql: """ + UPDATE outbox_commands + SET status = 'queued', retry_count = 0, last_error = '', created_at = ?3, + agent_id = ?4, delivery_session_key = ?5, routing_contract = ?6 + WHERE gateway_id = ?1 AND client_uuid = ?2 AND status = 'failed' + """, + bindings: [ + self.gatewayID, + id, + Date().timeIntervalSince1970, + normalizedAgentID, + normalizedDeliverySessionKey, + normalizedRoutingContract, + ]) + else { return .unavailable } + guard sqlite3_changes(db) > 0 else { + committed = self.execute(db, sql: "COMMIT", bindings: []) + return committed ? .missing : .unavailable + } + // Retargeting adopts a new cache owner. Remove the optimistic row + // from the previous partition before the command becomes sendable. + if Self.normalizedAgentID(previousAgentID) != normalizedAgentID, + !self.removeCachedMessage( + db, + sessionKey: previousSessionKey, + agentID: Self.transcriptCacheAgentID( + sessionKey: previousSessionKey, + agentID: previousAgentID), + idempotencyKey: "\(id):user") + { + return .unavailable + } + committed = self.execute(db, sql: "COMMIT", bindings: []) + return committed ? .updated : .unavailable + } + + public func cancelCommand(id: String) async -> OpenClawChatOutboxUpdateResult { + guard !self.isRetired, let db = await handle() else { return .unavailable } + guard self.execute(db, sql: "BEGIN IMMEDIATE", bindings: []) else { return .unavailable } + var committed = false + defer { + if !committed { + _ = self.execute(db, sql: "ROLLBACK", bindings: []) + } + } + let messageKey = "\(id):user" + let targetLookup = self.lookupOutboxTarget(db, id: id) + let sessionKey: String + let agentID: String + switch targetLookup { + case let .value(foundSessionKey, foundAgentID): + sessionKey = foundSessionKey + agentID = foundAgentID + case .missing: + return self.canonicalMessageProofHub.withProofDecision(for: messageKey) { isProven in + committed = self.execute(db, sql: "COMMIT", bindings: []) + guard committed else { return .unavailable } + return isProven ? .confirmed : .missing + } + case .unavailable: + return .unavailable + } + guard self.execute( + db, + sql: """ + DELETE FROM outbox_commands + WHERE gateway_id = ?1 AND client_uuid = ?2 AND status IN ('queued', 'failed') + """, + bindings: [self.gatewayID, id]) + else { return .unavailable } + guard sqlite3_changes(db) > 0 else { return .unavailable } + let result = self.canonicalMessageProofHub.withProofDecision(for: messageKey) { isProven in + if isProven { + committed = self.execute(db, sql: "COMMIT", bindings: []) + return committed ? OpenClawChatOutboxUpdateResult.confirmed : .unavailable + } + guard self.removeCachedMessage( + db, + sessionKey: sessionKey, + agentID: Self.transcriptCacheAgentID( + sessionKey: sessionKey, + agentID: agentID), + idempotencyKey: messageKey) + else { return .unavailable } + committed = self.execute(db, sql: "COMMIT", bindings: []) + return committed ? .updated : .unavailable + } + if result == .confirmed { + self.emitOutboxChange(.confirmed(id: id)) + return .confirmed + } + guard result == .updated else { return .unavailable } + Self.rememberMessageKey( + messageKey, + sessionKey: sessionKey, + storage: &self.canceledMessageKeysBySession) + self.emitOutboxChange(.canceled(id: id)) + return .updated + } + + public func confirmCommand(id: String) async -> OpenClawChatOutboxUpdateResult { + guard !self.isRetired, let db = await handle() else { return .unavailable } + guard self.execute( db, sql: "DELETE FROM outbox_commands WHERE gateway_id = ?1 AND client_uuid = ?2", bindings: [self.gatewayID, id]) + else { return .unavailable } + guard sqlite3_changes(db) > 0 else { return .missing } + self.emitOutboxChange(.confirmed(id: id)) + return .updated + } + + private func emitOutboxChange(_ change: OpenClawChatOutboxChange) { + self.outboxChangeHub.yield(change) } private func updateCommandStatus(id: String, status: String, retryCount: Int, lastError: String?) async { - guard !self.isRetired, let db = await self.handle() else { return } - self.execute( + guard !self.isRetired, let db = await handle() else { + self.hasRecoveredInterruptedSends = false + return + } + let updated = self.execute( db, sql: """ UPDATE outbox_commands SET status = ?3, retry_count = ?4, last_error = ?5 WHERE gateway_id = ?1 AND client_uuid = ?2 """, bindings: [self.gatewayID, id, status, retryCount, lastError ?? ""]) + if !updated { + self.hasRecoveredInterruptedSends = false + } + } + + private func applyOutboxStaleness(_ db: OpaquePointer) -> Bool { + // Old queued work is no longer safe to send automatically. Likewise, + // an acknowledged row that never appeared in canonical history needs + // an explicit user decision rather than a potentially duplicate replay. + self.execute( + db, + sql: """ + UPDATE outbox_commands + SET status = 'failed', + last_error = CASE WHEN status = 'awaiting_confirmation' THEN ?4 ELSE ?3 END + WHERE gateway_id = ?1 + AND status IN ('queued', 'awaiting_confirmation') + AND created_at < ?2 + """, + bindings: [ + self.gatewayID, + Date().timeIntervalSince1970 - Self.outboxCommandMaxAge, + Self.outboxExpiredError, + Self.outboxUnconfirmedError, + ]) } // MARK: - Cached shapes @@ -443,11 +1108,103 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, Ope .prefix(self.maxCachedSessions)) } + private static func normalizedAgentID(_ agentID: String?) -> String { + agentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + } + + private static func transcriptCacheAgentID(sessionKey: String, agentID: String) -> String { + let parts = sessionKey + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: ":", omittingEmptySubsequences: false) + // Canonical agent keys already own their cache partition. Aliases + // need the separate agent dimension to prevent cross-agent repaint. + if parts.count >= 3, parts[0].lowercased() == "agent", !parts[1].isEmpty, !parts[2].isEmpty { + return "" + } + return self.normalizedAgentID(agentID) + } + private static func encodeJSON(_ value: some Encodable) -> String? { guard let data = try? JSONEncoder().encode(value) else { return nil } return String(bytes: data, encoding: .utf8) } + private static func rememberMessageKey( + _ key: String, + sessionKey: String, + storage: inout [String: [String]]) + { + var keys = storage[sessionKey] ?? [] + keys.removeAll(where: { $0 == key }) + keys.append(key) + if keys.count > Self.maxCachedMessagesPerSession { + keys.removeFirst(keys.count - Self.maxCachedMessagesPerSession) + } + storage[sessionKey] = keys + if storage.count > Self.maxCachedTranscripts, + let evictedSession = storage.keys.first(where: { $0 != sessionKey }) + { + storage.removeValue(forKey: evictedSession) + } + } + + /// Runs inside the outbox cancellation transaction, so a process exit can + /// never leave a deleted command behind as an ordinary cached sent row. + private func removeCachedMessage( + _ db: OpaquePointer, + sessionKey: String, + agentID: String, + idempotencyKey: String) -> Bool + { + let transcriptLookup = self.lookupPayload( + db, + sql: """ + SELECT payload FROM cached_transcripts + WHERE gateway_id = ?1 AND session_key = ?2 AND agent_id = ?3 + """, + bindings: [self.gatewayID, sessionKey, agentID]) + let payload: String + switch transcriptLookup { + case let .value(value): + payload = value + case .missing: + return true + case .unavailable: + return false + } + guard let decoded = try? JSONDecoder().decode( + [OpenClawChatMessage].self, + from: Data(payload.utf8)) + else { + return self.execute( + db, + sql: """ + DELETE FROM cached_transcripts + WHERE gateway_id = ?1 AND session_key = ?2 AND agent_id = ?3 + """, + bindings: [self.gatewayID, sessionKey, agentID]) + } + let filtered = decoded.filter { $0.idempotencyKey != idempotencyKey } + guard filtered.count != decoded.count else { return true } + guard !filtered.isEmpty else { + return self.execute( + db, + sql: """ + DELETE FROM cached_transcripts + WHERE gateway_id = ?1 AND session_key = ?2 AND agent_id = ?3 + """, + bindings: [self.gatewayID, sessionKey, agentID]) + } + guard let filteredPayload = Self.encodeJSON(filtered) else { return false } + return self.execute( + db, + sql: """ + UPDATE cached_transcripts SET payload = ?4, updated_at = ?5 + WHERE gateway_id = ?1 AND session_key = ?2 AND agent_id = ?3 + """, + bindings: [self.gatewayID, sessionKey, agentID, filteredPayload, Date().timeIntervalSince1970]) + } + // MARK: - Connection lifecycle private func handle() async -> OpaquePointer? { @@ -459,18 +1216,27 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, Ope // locked. Treat that as a temporary miss, never as corruption. guard await self.isProtectedDataAvailable(), !self.isRetired else { return nil } #endif - if let opened = self.openConnection() { - self.db = Connection(raw: opened) + let databaseExisted = FileManager.default.fileExists(atPath: self.databaseURL.path) + if let opened = openConnection() { + db = Connection(raw: opened) return opened } #if os(iOS) guard await self.isProtectedDataAvailable(), !self.isRetired else { return nil } #endif - // Cache is disposable: on any open/schema failure drop the file - // (and SQLite sidecars) and rebuild once, silently. + if databaseExisted { + // The shared file may contain unsent user text. Preserve it for a + // future compatible build or explicit owner purge instead of + // turning a cache repair into silent outbox data loss. + cacheLogger.error("chat offline store unavailable; preserving existing database") + self.isBroken = true + return nil + } + // A failed first create cannot contain user state; remove the partial + // file and retry once so transient bootstrap failures self-heal. self.removeDatabaseFiles() - if let reopened = self.openConnection() { - self.db = Connection(raw: reopened) + if let reopened = openConnection() { + db = Connection(raw: reopened) return reopened } cacheLogger.error("chat transcript cache unavailable; continuing without offline cache") @@ -500,7 +1266,7 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, Ope sqlite3_close_v2(opened) return nil } - guard let version = self.readUserVersion(opened) else { + guard let version = readUserVersion(opened) else { sqlite3_close_v2(opened) return nil } @@ -509,8 +1275,29 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, Ope sqlite3_close_v2(opened) return nil } + } else if version == 1 { + guard self.migrateSchemaFromV1(opened) else { + sqlite3_close_v2(opened) + return nil + } + } else if version == 2 { + guard self.migrateSchemaFromV2(opened) else { + sqlite3_close_v2(opened) + return nil + } + } else if version == 3 { + guard self.migrateSchemaFromV3(opened) else { + sqlite3_close_v2(opened) + return nil + } + } else if version == 4 { + guard self.migrateSchemaFromV4(opened) else { + sqlite3_close_v2(opened) + return nil + } } else if version != Self.schemaVersion { - // Unknown schema: no migrations by design, force drop-and-rebuild. + // Unknown schemas may contain outbox rows from a newer build. + // The caller preserves the file and fails closed. sqlite3_close_v2(opened) return nil } @@ -542,31 +1329,9 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, Ope updated_at REAL NOT NULL ) """, - """ - CREATE TABLE IF NOT EXISTS cached_transcripts( - gateway_id TEXT NOT NULL, - session_key TEXT NOT NULL, - payload TEXT NOT NULL, - updated_at REAL NOT NULL, - PRIMARY KEY(gateway_id, session_key) - ) - """, - // last_error uses '' for "none" so every column binds non-null. - // rowid `id` breaks created_at ties so flush order stays stable. - """ - CREATE TABLE IF NOT EXISTS outbox_commands( - id INTEGER PRIMARY KEY AUTOINCREMENT, - client_uuid TEXT NOT NULL UNIQUE, - gateway_id TEXT NOT NULL, - session_key TEXT NOT NULL, - text TEXT NOT NULL, - thinking TEXT NOT NULL DEFAULT '', - created_at REAL NOT NULL, - status TEXT NOT NULL, - retry_count INTEGER NOT NULL DEFAULT 0, - last_error TEXT NOT NULL DEFAULT '' - ) - """, + Self.createTranscriptTableSQL, + Self.createOutboxTableSQL, + Self.createRoutingIdentityTableSQL, "PRAGMA user_version = \(Self.schemaVersion)", ] for sql in statements { @@ -575,6 +1340,173 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, Ope return true } + private func migrateSchemaFromV1(_ db: OpaquePointer) -> Bool { + guard sqlite3_exec(db, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK else { return false } + var committed = false + defer { + if !committed { + sqlite3_exec(db, "ROLLBACK", nil, nil, nil) + } + } + guard self.migrateTranscriptTableToV3(db), + sqlite3_exec(db, Self.createOutboxTableSQL, nil, nil, nil) == SQLITE_OK, + sqlite3_exec(db, Self.createRoutingIdentityTableSQL, nil, nil, nil) == SQLITE_OK, + sqlite3_exec(db, "PRAGMA user_version = \(Self.schemaVersion)", nil, nil, nil) == SQLITE_OK, + sqlite3_exec(db, "COMMIT", nil, nil, nil) == SQLITE_OK + else { + return false + } + committed = true + return true + } + + private func migrateSchemaFromV2(_ db: OpaquePointer) -> Bool { + guard sqlite3_exec(db, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK else { return false } + var committed = false + defer { + if !committed { + sqlite3_exec(db, "ROLLBACK", nil, nil, nil) + } + } + // A v2 non-canonical alias has no durable agent owner. Park it for + // explicit retry; the current default agent may have changed. Agent + // keys already carry enough ownership to migrate without replay risk. + guard self.migrateTranscriptTableToV3(db), + sqlite3_exec( + db, + "ALTER TABLE outbox_commands ADD COLUMN agent_id TEXT NOT NULL DEFAULT ''", + nil, + nil, + nil) == SQLITE_OK, + sqlite3_exec( + db, + "ALTER TABLE outbox_commands ADD COLUMN delivery_session_key TEXT NOT NULL DEFAULT ''", + nil, + nil, + nil) == SQLITE_OK, + sqlite3_exec( + db, + "ALTER TABLE outbox_commands ADD COLUMN routing_contract TEXT NOT NULL DEFAULT ''", + nil, + nil, + nil) == SQLITE_OK, + self.execute( + db, + sql: """ + UPDATE outbox_commands + SET status = 'failed', + last_error = CASE + WHEN status IN ('sending', 'awaiting_confirmation') OR last_error = ?2 + THEN ?2 + ELSE ?1 + END, + agent_id = '', + delivery_session_key = '', routing_contract = '' + """, + bindings: [Self.outboxUnknownTargetError, Self.outboxUnconfirmedError]), + sqlite3_exec(db, Self.createRoutingIdentityTableSQL, nil, nil, nil) == SQLITE_OK, + sqlite3_exec(db, "PRAGMA user_version = \(Self.schemaVersion)", nil, nil, nil) == SQLITE_OK, + sqlite3_exec(db, "COMMIT", nil, nil, nil) == SQLITE_OK + else { + return false + } + committed = true + return true + } + + private func migrateSchemaFromV3(_ db: OpaquePointer) -> Bool { + guard sqlite3_exec(db, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK else { return false } + var committed = false + defer { + if !committed { + sqlite3_exec(db, "ROLLBACK", nil, nil, nil) + } + } + guard sqlite3_exec( + db, + "ALTER TABLE outbox_commands ADD COLUMN routing_contract TEXT NOT NULL DEFAULT ''", + nil, + nil, + nil) == SQLITE_OK, + self.execute( + db, + sql: """ + UPDATE outbox_commands + SET status = 'failed', + last_error = CASE + WHEN status IN ('sending', 'awaiting_confirmation') OR last_error = ?2 + THEN ?2 + ELSE ?1 + END, + agent_id = '', + delivery_session_key = '', routing_contract = '' + """, + bindings: [Self.outboxUnknownTargetError, Self.outboxUnconfirmedError]), + sqlite3_exec(db, Self.createRoutingIdentityTableSQL, nil, nil, nil) == SQLITE_OK, + sqlite3_exec(db, "PRAGMA user_version = \(Self.schemaVersion)", nil, nil, nil) == SQLITE_OK, + sqlite3_exec(db, "COMMIT", nil, nil, nil) == SQLITE_OK + else { return false } + committed = true + return true + } + + private func migrateSchemaFromV4(_ db: OpaquePointer) -> Bool { + guard sqlite3_exec(db, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK else { return false } + var committed = false + defer { + if !committed { + sqlite3_exec(db, "ROLLBACK", nil, nil, nil) + } + } + guard sqlite3_exec(db, Self.createRoutingIdentityTableSQL, nil, nil, nil) == SQLITE_OK, + sqlite3_exec(db, "PRAGMA user_version = \(Self.schemaVersion)", nil, nil, nil) == SQLITE_OK, + sqlite3_exec(db, "COMMIT", nil, nil, nil) == SQLITE_OK + else { return false } + committed = true + return true + } + + private func migrateTranscriptTableToV3(_ db: OpaquePointer) -> Bool { + let hadAgentID = self.table(db, hasColumn: "agent_id", tableName: "cached_transcripts") + guard sqlite3_exec( + db, + "ALTER TABLE cached_transcripts RENAME TO cached_transcripts_pre_v3", + nil, + nil, + nil) == SQLITE_OK, + sqlite3_exec(db, Self.createTranscriptTableSQL, nil, nil, nil) == SQLITE_OK + else { return false } + let copySQL = hadAgentID + ? """ + INSERT INTO cached_transcripts(gateway_id, session_key, agent_id, payload, updated_at) + SELECT gateway_id, session_key, agent_id, payload, updated_at + FROM cached_transcripts_pre_v3 + """ + : """ + INSERT INTO cached_transcripts(gateway_id, session_key, agent_id, payload, updated_at) + SELECT gateway_id, session_key, '', payload, updated_at + FROM cached_transcripts_pre_v3 + WHERE lower(trim(session_key)) GLOB 'agent:*:*' + """ + guard sqlite3_exec(db, copySQL, nil, nil, nil) == SQLITE_OK, + sqlite3_exec(db, "DROP TABLE cached_transcripts_pre_v3", nil, nil, nil) == SQLITE_OK + else { return false } + return true + } + + private func table(_ db: OpaquePointer, hasColumn columnName: String, tableName: String) -> Bool { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, "PRAGMA table_info(\(tableName))", -1, &statement, nil) == SQLITE_OK else { + return false + } + defer { sqlite3_finalize(statement) } + while sqlite3_step(statement) == SQLITE_ROW { + guard let name = sqlite3_column_text(statement, 1) else { continue } + if String(cString: name) == columnName { return true } + } + return false + } + // MARK: - Statement helpers @discardableResult @@ -598,14 +1530,107 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, Ope return Int(sqlite3_column_int64(statement, 0)) } - private func selectPayload(_ db: OpaquePointer, sql: String, bindings: [Any]) -> String? { + private func readCommands(_ db: OpaquePointer) -> [OpenClawChatOutboxCommand]? { var statement: OpaquePointer? + let sql = """ + SELECT client_uuid, session_key, delivery_session_key, routing_contract, agent_id, + text, thinking, created_at, status, retry_count, last_error + FROM outbox_commands WHERE gateway_id = ?1 + ORDER BY created_at ASC, id ASC + """ guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return nil } defer { sqlite3_finalize(statement) } - guard self.bind(statement, bindings: bindings) else { return nil } - guard sqlite3_step(statement) == SQLITE_ROW else { return nil } - guard let text = sqlite3_column_text(statement, 0) else { return nil } - return String(cString: text) + guard self.bind(statement, bindings: [self.gatewayID]) else { return nil } + + var commands: [OpenClawChatOutboxCommand] = [] + var step = sqlite3_step(statement) + while step == SQLITE_ROW { + if let id = sqlite3_column_text(statement, 0), + let sessionKey = sqlite3_column_text(statement, 1), + let text = sqlite3_column_text(statement, 5) + { + let deliverySessionKey = sqlite3_column_text(statement, 2).map { String(cString: $0) } ?? "" + let routingContract = sqlite3_column_text(statement, 3).map { String(cString: $0) } + let agentID = sqlite3_column_text(statement, 4).map { String(cString: $0) } + let thinking = sqlite3_column_text(statement, 6).map { String(cString: $0) } ?? "" + let statusRaw = sqlite3_column_text(statement, 8).map { String(cString: $0) } ?? "" + let lastError = sqlite3_column_text(statement, 10).map { String(cString: $0) } ?? "" + if let status = OpenClawChatOutboxCommand.Status(rawValue: statusRaw) { + commands.append( + OpenClawChatOutboxCommand( + id: String(cString: id), + sessionKey: String(cString: sessionKey), + deliverySessionKey: deliverySessionKey, + routingContract: routingContract, + agentID: agentID, + text: String(cString: text), + thinking: thinking, + createdAt: sqlite3_column_double(statement, 7), + status: status, + retryCount: Int(sqlite3_column_int64(statement, 9)), + lastError: lastError.isEmpty ? nil : lastError)) + } + } + step = sqlite3_step(statement) + } + guard step == SQLITE_DONE else { return nil } + return commands + } + + private enum PayloadLookup { + case value(String) + case missing + case unavailable + } + + private enum OutboxTargetLookup { + case value(sessionKey: String, agentID: String) + case missing + case unavailable + } + + private func lookupOutboxTarget(_ db: OpaquePointer, id: String) -> OutboxTargetLookup { + var statement: OpaquePointer? + let sql = """ + SELECT session_key, agent_id FROM outbox_commands + WHERE gateway_id = ?1 AND client_uuid = ?2 AND status IN ('queued', 'failed') + """ + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return .unavailable } + defer { sqlite3_finalize(statement) } + guard self.bind(statement, bindings: [self.gatewayID, id]) else { return .unavailable } + switch sqlite3_step(statement) { + case SQLITE_ROW: + guard let sessionKey = sqlite3_column_text(statement, 0) else { return .unavailable } + let agentID = sqlite3_column_text(statement, 1).map { String(cString: $0) } ?? "" + return .value(sessionKey: String(cString: sessionKey), agentID: agentID) + case SQLITE_DONE: + return .missing + default: + return .unavailable + } + } + + private func lookupPayload(_ db: OpaquePointer, sql: String, bindings: [Any]) -> PayloadLookup { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return .unavailable } + defer { sqlite3_finalize(statement) } + guard self.bind(statement, bindings: bindings) else { return .unavailable } + switch sqlite3_step(statement) { + case SQLITE_ROW: + guard let text = sqlite3_column_text(statement, 0) else { return .unavailable } + return .value(String(cString: text)) + case SQLITE_DONE: + return .missing + default: + return .unavailable + } + } + + private func selectPayload(_ db: OpaquePointer, sql: String, bindings: [Any]) -> String? { + guard case let .value(payload) = lookupPayload(db, sql: sql, bindings: bindings) else { + return nil + } + return payload } private func bind(_ statement: OpaquePointer?, bindings: [Any]) -> Bool { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift index e9f4fb42f42..ad0a67ea4f5 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift @@ -9,6 +9,97 @@ public enum OpenClawChatTransportEvent: Sendable { case seqGap } +/// One immutable transport route used by an entire outbox flush. Route-aware +/// transports bind both sends and confirmation reads to the same connection; +/// a gateway switch then cancels the old work instead of retargeting it. +public struct OpenClawChatTransportRouteLease: Sendable { + public typealias SendMessage = @Sendable ( + _ sessionKey: String, + _ message: String, + _ thinking: String, + _ idempotencyKey: String, + _ attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse + public typealias RequestHistory = @Sendable (String) async throws -> OpenClawChatHistoryPayload + public typealias SendTargetedMessage = @Sendable ( + _ sessionKey: String, + _ agentID: String?, + _ message: String, + _ thinking: String, + _ idempotencyKey: String, + _ attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse + public typealias RequestTargetedHistory = @Sendable ( + _ sessionKey: String, + _ agentID: String?) async throws -> OpenClawChatHistoryPayload + + private let sendTargetedMessageImpl: SendTargetedMessage + private let requestTargetedHistoryImpl: RequestTargetedHistory + public let sessionRoutingContract: String? + + public init( + sendMessage: @escaping SendMessage, + requestHistory: @escaping RequestHistory, + sessionRoutingContract: String? = nil) + { + self.sessionRoutingContract = sessionRoutingContract + self.sendTargetedMessageImpl = { sessionKey, _, message, thinking, idempotencyKey, attachments in + try await sendMessage(sessionKey, message, thinking, idempotencyKey, attachments) + } + self.requestTargetedHistoryImpl = { sessionKey, _ in + try await requestHistory(sessionKey) + } + } + + public init( + sendTargetedMessage: @escaping SendTargetedMessage, + requestTargetedHistory: @escaping RequestTargetedHistory, + sessionRoutingContract: String? = nil) + { + self.sessionRoutingContract = sessionRoutingContract + self.sendTargetedMessageImpl = sendTargetedMessage + self.requestTargetedHistoryImpl = requestTargetedHistory + } + + public func sendMessage( + sessionKey: String, + agentID: String? = nil, + message: String, + thinking: String, + idempotencyKey: String, + attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse + { + try await self.sendTargetedMessageImpl( + sessionKey, + agentID, + message, + thinking, + idempotencyKey, + attachments) + } + + public func requestHistory( + sessionKey: String, + agentID: String? = nil) async throws -> OpenClawChatHistoryPayload + { + try await self.requestTargetedHistoryImpl(sessionKey, agentID) + } +} + +public enum OpenClawChatTransportRouteLeaseResult: Sendable { + case available(OpenClawChatTransportRouteLease) + case unavailable(reason: String?) +} + +/// The transport rejected a send before it reached its request channel. This +/// is the only failure class safe for automatic outbox retry. +public enum OpenClawChatTransportSendError: Error, Sendable { + case notDispatched +} + +public enum OpenClawChatTransportUpgradeMessage { + public static let routingContract = + "Update the gateway before sending queued messages. This version requires safe delivery routing." +} + public protocol OpenClawChatTransport: Sendable { func createSession( key: String, @@ -26,6 +117,19 @@ public protocol OpenClawChatTransport: Sendable { thinking: String, idempotencyKey: String, attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse + func sendMessage( + sessionKey: String, + agentID: String?, + expectedSessionRoutingContract: String?, + message: String, + thinking: String, + idempotencyKey: String, + attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse + + /// Captures the current route for a durable outbox flush. Implementations + /// backed by a mutable gateway must override this with route-checked calls. + func acquireOutboxRouteLease() async -> OpenClawChatTransportRouteLeaseResult + var outboxRequiresSessionRoutingContract: Bool { get } func abortRun(sessionKey: String, runId: String) async throws func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse @@ -52,6 +156,43 @@ public protocol OpenClawChatTransport: Sendable { } extension OpenClawChatTransport { + public var outboxRequiresSessionRoutingContract: Bool { + false + } + + public func acquireOutboxRouteLease() async -> OpenClawChatTransportRouteLeaseResult { + let transport = self + return .available(OpenClawChatTransportRouteLease( + sendMessage: { sessionKey, message, thinking, idempotencyKey, attachments in + try await transport.sendMessage( + sessionKey: sessionKey, + message: message, + thinking: thinking, + idempotencyKey: idempotencyKey, + attachments: attachments) + }, + requestHistory: { sessionKey in + try await transport.requestHistory(sessionKey: sessionKey) + })) + } + + public func sendMessage( + sessionKey: String, + agentID _: String?, + expectedSessionRoutingContract _: String?, + message: String, + thinking: String, + idempotencyKey: String, + attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse + { + try await self.sendMessage( + sessionKey: sessionKey, + message: message, + thinking: thinking, + idempotencyKey: idempotencyKey, + attachments: attachments) + } + public func createSession( key _: String, label _: String?, @@ -165,3 +306,55 @@ extension OpenClawChatTransport { userInfo: [NSLocalizedDescriptionKey: "sessions.patch(thinkingLevel) not supported by this transport"]) } } + +public enum OpenClawChatSessionRoutingContract { + public static let changedErrorReason = "session-routing-changed" + + public struct Components: Equatable, Sendable { + public let scope: String + public let mainKey: String + public let defaultAgentID: String + } + + /// Live sends may proceed before routing identity is available. Queued + /// replay acquires a separate route lease and never uses a nil contract. + public static func expectedValue( + _ contract: String?, + serverSupportsGuard: Bool) -> String? + { + guard serverSupportsGuard else { return nil } + return self.normalize(contract) + } + + public static func make( + scope: String?, + mainKey: String?, + defaultAgentID: String?) -> String? + { + let normalizedScope = self.normalize(scope) + let normalizedMainKey = self.normalize(mainKey) + let normalizedDefaultAgentID = self.normalize(defaultAgentID) + guard let normalizedScope, let normalizedMainKey, let normalizedDefaultAgentID else { return nil } + return "\(normalizedScope)|\(normalizedMainKey)|\(normalizedDefaultAgentID)" + } + + /// Scope and agent ids cannot contain `|`; parse from both ends so an + /// older custom main key containing the delimiter still round-trips. + public static func parse(_ contract: String?) -> Components? { + guard let normalized = self.normalize(contract), + let firstSeparator = normalized.firstIndex(of: "|"), + let lastSeparator = normalized.lastIndex(of: "|"), + firstSeparator != lastSeparator + else { return nil } + let scope = String(normalized[.. String? { + let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized?.isEmpty == false ? normalized : nil + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift index 3116a90e3d0..e4d7ce5ec8d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift @@ -93,6 +93,7 @@ public struct OpenClawChatView: View { private let showsAssistantAvatars: Bool private let composerChrome: ComposerChrome private let isComposerEnabled: Bool + private let isAttachmentInputEnabled: Bool private let messagePlaceholder: String? private let emptyAssistantIntro: String? private let emptyAssistantPrompts: [StarterPrompt] @@ -143,6 +144,7 @@ public struct OpenClawChatView: View { showsAssistantAvatars: Bool = true, composerChrome: ComposerChrome = .full, isComposerEnabled: Bool = true, + isAttachmentInputEnabled: Bool? = nil, messagePlaceholder: String? = nil, emptyAssistantIntro: String? = nil, emptyAssistantPrompts: [StarterPrompt] = [], @@ -161,6 +163,7 @@ public struct OpenClawChatView: View { self.showsAssistantAvatars = showsAssistantAvatars self.composerChrome = composerChrome self.isComposerEnabled = isComposerEnabled + self.isAttachmentInputEnabled = isAttachmentInputEnabled ?? isComposerEnabled self.messagePlaceholder = messagePlaceholder self.emptyAssistantIntro = emptyAssistantIntro self.emptyAssistantPrompts = emptyAssistantPrompts @@ -223,6 +226,7 @@ public struct OpenClawChatView: View { assistantAvatarTint: self.assistantAvatarTint, composerChrome: self.composerChrome, isComposerEnabled: self.isComposerEnabled, + isAttachmentInputEnabled: self.isAttachmentInputEnabled, messagePlaceholder: self.messagePlaceholder, talkControl: self.talkControl) } @@ -420,10 +424,9 @@ public struct OpenClawChatView: View { } } } - // No Delete while `.sending`: the transport call is already - // in flight and cannot be prevented, so removing the bubble - // would hide a message that may still reach the gateway. - if outboxState != .sending { + // Sending and acknowledged-but-unconfirmed rows may still + // reach canonical history, so deletion would hide real work. + if !outboxState.preventsDeletion { Button(role: .destructive) { self.viewModel.deleteOutboxMessage(msg.id) } label: { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Outbox.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Outbox.swift index f6aadc2ef28..a83156658da 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Outbox.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Outbox.swift @@ -1,4 +1,5 @@ import Foundation +import OpenClawKit import OSLog private let outboxLogger = Logger(subsystem: "ai.openclaw", category: "OpenClawChatOutbox") @@ -7,20 +8,30 @@ private let outboxLogger = Logger(subsystem: "ai.openclaw", category: "OpenClawC public enum OpenClawChatOutboxMessageState: Equatable, Sendable { case queued case sending + case confirming case failed(reason: String?) public var isFailed: Bool { if case .failed = self { return true } return false } + + var preventsDeletion: Bool { + self == .sending || self == .confirming + } } -// Durable offline command outbox. Sends made while the gateway is unhealthy -// are persisted (per gateway, alongside the transcript cache) and flushed -// strictly in createdAt order when health recovers. Each command's client -// UUID rides as the transport idempotency key, so at-least-once flushing plus -// gateway dedupe keeps the transcript exact. +/// Durable offline command outbox. Sends made while the gateway is unhealthy +/// are persisted (per gateway, alongside the transcript cache) and flushed +/// strictly in createdAt order when health recovers. A gateway ACK only moves +/// a row to awaiting-confirmation; canonical history owns durable completion. extension OpenClawChatViewModel { + struct OutboxDeliveryTarget: Hashable { + let presentationSessionKey: String + let deliverySessionKey: String + let agentID: String? + } + public func outboxState(for messageID: UUID) -> OpenClawChatOutboxMessageState? { self.outboxStatesByMessageID[messageID] } @@ -29,31 +40,89 @@ extension OpenClawChatViewModel { /// (so even an expired row can send again), and flush if healthy. public func retryOutboxMessage(_ messageID: UUID) { guard let outbox, let commandID = self.outboxCommandIDsByMessageID[messageID] else { return } - self.outboxStatesByMessageID[messageID] = .queued + let session = self.currentSessionSnapshot() Task { [weak self] in - await outbox.markCommandRetried(id: commandID) - self?.flushOutboxIfNeeded() + guard let self else { return } + let agentID = self.outboxAgentID(for: session) + if self.outboxRequiresAgentID(for: session), agentID == nil { + self.errorText = "Select an agent before retrying this message." + return + } + guard + let deliverySessionKey = self.outboxDeliverySessionKey(for: session, agentID: agentID), + let routingContract = self.outboxRoutingContract(for: session) + else { + self.errorText = "Reconnect to verify this message's delivery target before retrying." + return + } + let result = await outbox.markCommandRetriedIfPresent( + id: commandID, + agentID: agentID, + deliverySessionKey: deliverySessionKey, + routingContract: routingContract) + if result == .updated { + // Durable work is gateway-global. Flush even when the visible + // session changed while the SQLite update was suspended. + self.flushOutboxIfNeeded() + } + guard self.isCurrentSession(session) else { return } + switch result { + case .updated: + self.outboxStatesByMessageID[messageID] = .queued + case .missing, .confirmed: + self.clearOutboxState(forCommandID: commandID) + case .unavailable: + self.errorText = "Could not retry the queued message. Try again." + } } } public func deleteOutboxMessage(_ messageID: UUID) { guard let outbox, let commandID = self.outboxCommandIDsByMessageID[messageID] else { return } - // Tombstone first, synchronously: an active flush checks this set - // right before its transport call, so the deleted command cannot be - // sent even if the row deletion below races the flush's claim. - self.deletedOutboxCommandIDs.insert(commandID) + self.cancelingOutboxCommandIDs.insert(commandID) + self.outboxPresentationGeneration &+= 1 Task { [weak self] in - // Durable delete before the bubble disappears: if the process - // dies in this window, both the row and the visible bubble - // survive, so a user-deleted command can never silently - // resurrect and send on the next launch. - await outbox.deleteCommand(id: commandID) guard let self else { return } - // Row is durably gone; the tombstone has done its job. - self.deletedOutboxCommandIDs.remove(commandID) + let result = await outbox.cancelCommand(id: commandID) + guard result != .unavailable else { + self.finishOutboxCancellation(commandID) + self.errorText = "Could not delete the queued message. Try again." + return + } + if result == .confirmed { + self.finishOutboxCancellation(commandID) + self.clearOutboxState(forCommandID: commandID) + return + } + if result == .missing { + let presentationGeneration = self.outboxPresentationGeneration + let current = await outbox.loadCommands().first(where: { $0.id == commandID }) + guard presentationGeneration == self.outboxPresentationGeneration else { + self.finishOutboxCancellation(commandID) + return + } + if let current { + // Another view model claimed the row first. Keep the bubble + // and show its authoritative state; cancellation after claim + // would promise deletion while the request can still land. + self.finishOutboxCancellation(commandID) + self.presentOutboxCommands([current]) + return + } + } + if self.canonicalOutboxMessageKeys.contains("\(commandID):user") { + self.finishOutboxCancellation(commandID) + self.clearOutboxState(forCommandID: commandID) + return + } + self.finishOutboxCancellation(commandID) self.outboxCommandIDsByMessageID.removeValue(forKey: messageID) self.outboxMessageIDsByCommandID.removeValue(forKey: commandID) self.outboxStatesByMessageID.removeValue(forKey: messageID) + // `.missing` with no current row means another view already + // canceled (or canonical history completed) this mapping. Never + // leave its stale bubble looking like an ordinary sent message; + // canonical history can re-add a genuinely delivered row. self.replaceMessages(self.messages.filter { $0.id != messageID }) } } @@ -63,11 +132,30 @@ extension OpenClawChatViewModel { /// Offline capture path used by performSend when the gateway is /// unhealthy: persist first, then render the queued bubble. A full queue /// refuses the enqueue and keeps the draft so no text is lost. - func enqueueOutboxCommand(text: String, session: SessionSnapshot) async { + func enqueueOutboxCommand( + text: String, + draftInput: String, + session: SessionSnapshot) async + { guard let outbox else { return } + let agentID = self.outboxAgentID(for: session) + if self.outboxRequiresAgentID(for: session), agentID == nil { + self.errorText = "Select an agent before queueing this message." + return + } + guard + let deliverySessionKey = self.outboxDeliverySessionKey(for: session, agentID: agentID), + let routingContract = self.outboxRoutingContract(for: session) + else { + self.errorText = "Reconnect to verify this message's delivery target before queueing." + return + } let command = OpenClawChatOutboxCommand( id: UUID().uuidString, sessionKey: session.key, + deliverySessionKey: deliverySessionKey, + routingContract: routingContract, + agentID: agentID, text: text, thinking: self.effectiveThinkingLevelForSend, createdAt: Date().timeIntervalSince1970, @@ -80,7 +168,7 @@ extension OpenClawChatViewModel { self.errorText = "Offline queue is full. Delete a queued message or reconnect to send." return } - self.input = "" + if self.input == draftInput { self.input = "" } self.errorText = nil self.presentOutboxCommands([command]) // Health can recover between the send-gate check and the enqueue; @@ -90,34 +178,44 @@ extension OpenClawChatViewModel { } } - /// Requeue path for a live text send that failed at the transport while - /// `healthOK` was stale-true. Reuses the send's runId as the command ID so - /// the optimistic bubble's "\(runId):user" key and the gateway's dedupe - /// identity are preserved even if the failed send actually landed. + /// Durable path for a failed live text send. Known pre-dispatch route + /// changes remain queued; ambiguous results fail closed until canonical + /// history or explicit retry resolves them. /// Returns false when the queue refuses (caller keeps the failure path). - func requeueFailedLiveSend( + func preserveFailedLiveSend( runId: String, text: String, thinking: String, messageID: UUID, - session: SessionSnapshot) async -> Bool + session: SessionSnapshot, + deliveryIsAmbiguous: Bool) async -> Bool { guard let outbox else { return false } + let agentID = self.outboxAgentID(for: session) + guard !self.outboxRequiresAgentID(for: session) || agentID != nil, + let deliverySessionKey = self.outboxDeliverySessionKey(for: session, agentID: agentID), + let routingContract = self.outboxRoutingContract(for: session) + else { return false } let command = OpenClawChatOutboxCommand( id: runId, sessionKey: session.key, + deliverySessionKey: deliverySessionKey, + routingContract: routingContract, + agentID: agentID, text: text, thinking: thinking, createdAt: Date().timeIntervalSince1970, - status: .queued, + status: deliveryIsAmbiguous ? .failed : .queued, retryCount: 0, - lastError: nil) + lastError: deliveryIsAmbiguous + ? OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError + : nil) guard await outbox.enqueueCommand(command) else { return false } guard self.isCurrentSession(session) else { return true } self.mapOutboxCommand(command, to: messageID) self.errorText = nil - // Auto-retry immediately while health still reads healthy; either the - // transport recovered or the command lands visibly in 'failed'. + // Ambiguous rows only reconcile history; known-unsent rows may flush + // automatically once the replacement route becomes healthy. self.flushOutboxIfNeeded() return true } @@ -142,20 +240,98 @@ extension OpenClawChatViewModel { guard let outbox else { return } Task { [weak self] in guard let self else { return } - await self.recoverInterruptedOutboxSendsIfNeeded() - let commands = await outbox.loadCommands() - guard self.isCurrentSession(session) else { return } - self.presentOutboxCommands(commands.filter { $0.sessionKey == session.key }) - // The FIFO send gate assumes a backlog until this point. - self.hasRestoredOutboxMessages = true - // Relaunching while already healthy never sees an unhealthy -> - // healthy transition, so kick the flush here as well. - if self.healthOK, commands.contains(where: { $0.status == .queued }) { - self.flushOutboxIfNeeded() + guard await self.recoverInterruptedOutboxSendsIfNeeded() else { return } + while self.isCurrentSession(session) { + let presentationGeneration = self.outboxPresentationGeneration + guard let commands = await outbox.loadCommandsIfAvailable() else { return } + guard self.isCurrentSession(session) else { return } + if presentationGeneration != self.outboxPresentationGeneration { + // A cross-view cancellation/confirmation invalidated this + // snapshot. Reload so unrelated surviving rows still paint. + continue + } + self.presentOutboxCommands(commands.filter { self.commandMatchesTarget($0, session: session) }) + // The FIFO send gate assumes a backlog until this point. + self.hasRestoredOutboxMessages = true + // Relaunching while already healthy never sees an unhealthy -> + // healthy transition, so kick the flush here as well. + if self.healthOK, commands.contains(where: { $0.status == .queued }) { + self.flushOutboxIfNeeded() + } + return } } } + /// Canonical history is the durable acceptance boundary. Any matching + /// outbox row—including a delivery-unconfirmed row—is now safe to remove + /// without replaying the user turn. + func confirmOutboxCommands(in messages: [OpenClawChatMessage]) { + self.observeCanonicalOutboxMessageKeys(in: messages) + Task { [weak self] in + await self?.confirmOutboxCommandsNow(in: messages) + } + } + + func confirmOutboxCommandsNow(in messages: [OpenClawChatMessage]) async { + self.observeCanonicalOutboxMessageKeys(in: messages) + guard let outbox else { return } + let confirmedKeys = Set(messages.compactMap { Self.normalizedIdempotencyKey($0.idempotencyKey) }) + guard !confirmedKeys.isEmpty else { return } + let commands = await outbox.loadCommands().filter { command in + // Command UUIDs are gateway-global. Match the durable identity, + // not a presentation alias (`main` vs `agent::main`). + confirmedKeys.contains(Self.outboxUserIdempotencyKey(command.id)) + } + for command in commands { + if let canonicalMessage = messages.first(where: { + Self.normalizedIdempotencyKey($0.idempotencyKey) == + Self.outboxUserIdempotencyKey(command.id) + }) { + await self.persistCanonicalOutboxEvidence(canonicalMessage, for: command) + } + let result = await outbox.confirmCommand(id: command.id) + if result != .unavailable { + self.clearOutboxState(forCommandID: command.id) + } + } + } + + private func persistCanonicalOutboxEvidence( + _ message: OpenClawChatMessage, + for command: OpenClawChatOutboxCommand) async + { + guard let transcriptCache = transcriptCache as? any OpenClawChatCanonicalTranscriptMerging else { return } + let sessionKey = command.sessionKey + let cacheAgentID = Self.transcriptCacheAgentID( + sessionKey: sessionKey, + agentID: command.agentID) + let messageKey = Self.outboxUserIdempotencyKey(command.id) + let previous = self.pendingCacheWriteTask + let task = Task.detached { + await previous?.value + await transcriptCache.mergeCanonicalTranscriptMessage( + sessionKey: sessionKey, + agentID: cacheAgentID, + message: message, + canonicalMessageIdempotencyKey: messageKey) + } + self.pendingCacheWriteTask = task + await task.value + } + + private func observeCanonicalOutboxMessageKeys(in messages: [OpenClawChatMessage]) { + let keys = Set(messages.compactMap(\.idempotencyKey)) + for key in keys.sorted() { + self.canonicalOutboxMessageKeys.removeAll(where: { $0 == key }) + self.canonicalOutboxMessageKeys.append(key) + } + if self.canonicalOutboxMessageKeys.count > 512 { + self.canonicalOutboxMessageKeys.removeFirst(self.canonicalOutboxMessageKeys.count - 512) + } + self.transcriptCache?.observeCanonicalMessageIdempotencyKeys(keys) + } + /// Appends bubbles for commands in the current session, adopting rows /// that already carry the command's user idempotency key (cache pre-paint /// or an earlier restore), and refreshes their display states. @@ -164,9 +340,7 @@ extension OpenClawChatViewModel { guard !commands.isEmpty else { return } var next = self.messages for command in commands.sorted(by: { $0.createdAt < $1.createdAt }) { - // User-deleted commands awaiting durable removal must not be - // re-presented or re-mapped by a concurrent restore/flush pass. - if self.deletedOutboxCommandIDs.contains(command.id) { continue } + if self.cancelingOutboxCommandIDs.contains(command.id) { continue } let key = Self.outboxUserIdempotencyKey(command.id) if let existing = next.first(where: { $0.idempotencyKey == key }) { self.mapOutboxCommand(command, to: existing.id) @@ -266,50 +440,69 @@ extension OpenClawChatViewModel { private func performOutboxFlush() async { guard let outbox else { return } - await self.recoverInterruptedOutboxSendsIfNeeded() - var flushedCurrentSession = false - // One attempt per command per pass: if a delete/mark write ever fails - // (broken store), the pass ends instead of re-sending in a hot loop. - var attemptedCommandIDs = Set() + let presentationGeneration = self.outboxPresentationGeneration + guard let initialCommands = await outbox.loadCommandsIfAvailable() else { + self.applyTransportHealth(false) + return + } + guard presentationGeneration == self.outboxPresentationGeneration else { + self.isOutboxFlushRequestedWhileActive = true + return + } + let visibleSession = self.currentSessionSnapshot() + self.presentOutboxCommands(initialCommands.filter { self.commandMatchesTarget($0, session: visibleSession) }) + // Do not capability-gate ordinary live chat when no durable work + // needs a replay lease (notably against older gateways). + let hasRouteWork = initialCommands.contains { command in + command.status == .queued || + command.status == .sending || + Self.needsOutboxDeliveryReconciliation(command) + } + guard hasRouteWork else { return } + let routeResult = await self.transport.acquireOutboxRouteLease() + guard case let .available(routeLease) = routeResult else { + // The store owner no longer matches the active gateway route. + // Leave every row queued; the replacement view model owns the + // new gateway and a later matching reconnect can resume this one. + if case let .unavailable(reason) = routeResult, let reason { + self.errorText = reason + } + self.applyTransportHealth(false) + return + } + guard await self.recoverInterruptedOutboxSendsIfNeeded() else { + self.applyTransportHealth(false) + return + } + var confirmationTargets: Set = [] while self.healthOK { + let presentationGeneration = self.outboxPresentationGeneration let commands = await outbox.loadCommands() - self.presentOutboxCommands(commands.filter { $0.sessionKey == self.sessionKey }) - guard let next = commands.first(where: { - $0.status == .queued && !attemptedCommandIDs.contains($0.id) - }) else { break } - attemptedCommandIDs.insert(next.id) - // Delete-vs-flush race: the user may have removed this bubble - // after the pass loaded its snapshot. The tombstone catches the - // synchronous UI delete; the claiming UPDATE (zero rows changed - // = row already gone) catches the DB-side delete. Either way the - // command must not be sent. The tombstone is not consumed here: - // it lives until the delete task confirms the row is durably - // gone, so retries and later passes stay covered too. - if self.deletedOutboxCommandIDs.contains(next.id) { - self.clearOutboxState(forCommandID: next.id) + if presentationGeneration != self.outboxPresentationGeneration { + continue + } + confirmationTargets.formUnion( + commands.lazy + .filter(Self.needsOutboxDeliveryReconciliation) + .map(Self.deliveryTarget)) + let visibleSession = self.currentSessionSnapshot() + self.presentOutboxCommands(commands.filter { self.commandMatchesTarget($0, session: visibleSession) }) + guard let next = await outbox.claimNextCommand() else { break } + if self.transport.outboxRequiresSessionRoutingContract, + next.routingContract != routeLease.sessionRoutingContract + { + guard await self.parkOutboxCommandForChangedTarget(next, outbox: outbox) else { break } continue } // Same ordering contract as the live send path: a run must not // start on a stale model while a sessions.patch(model) for its // session is still in flight. await self.waitForPendingModelPatches(in: next.sessionKey) - guard await outbox.markCommandSending(id: next.id) else { - self.clearOutboxState(forCommandID: next.id) - continue - } - // The claim awaited off the main actor: a user delete may have - // landed during that suspension (tombstone set, row deletion in - // flight). Recheck before the transport call; the delete task - // owns the durable removal and drops the tombstone once the row - // is gone, even though the claim re-marked it 'sending'. - if self.deletedOutboxCommandIDs.contains(next.id) { - self.clearOutboxState(forCommandID: next.id) - continue - } self.setOutboxState(.sending, forCommandID: next.id) do { - let response = try await self.transport.sendMessage( - sessionKey: next.sessionKey, + let response = try await routeLease.sendMessage( + sessionKey: next.deliverySessionKey, + agentID: next.agentID, message: next.text, // Preserve the queued level when supported, but never send // an explicit unsupported level after the gate changes. @@ -327,70 +520,138 @@ extension OpenClawChatViewModel { reason: "Run failed to start (\(response.status)).") if handled { continue } else { break } } - // Ack: drop the durable row. The queued bubble stays and is - // adopted by the durable session.message/history row via the - // shared idempotency key, so no duplicate turn appears. - // // Deliberately no pendingRuns adoption for background flushes: // the reply still lands via handleChatEvent's external-run // final branch (session-scoped, run-id independent), // handleSessionMessageEvent, and the post-drain history // refresh below. Run tracking (typing indicator, streaming, // timeouts) stays owned by interactive performSend. - // Close the crash window between ack and the next canonical - // history write-through: splice the sent turn into the - // session's cached transcript before the outbox row goes - // away, so a cold offline reopen still shows it. Await the - // chained cache writes first so an in-flight older snapshot - // cannot land after the splice and drop the turn. + // chat.send ACK precedes durable user-turn persistence. Keep + // the outbox row until history carries its idempotency key; + // the cache splice makes the acknowledged turn visible while + // canonical history catches up. await self.pendingCacheWriteTask?.value await self.spliceSentCommandIntoCachedTranscript(next) - // From here the outbox row is the turn's last durable copy; - // remember its key so a lagging history snapshot cannot - // evict the visible row before confirming it. - self.recentlySentOutboxUserKeys.insert(Self.outboxUserIdempotencyKey(next.id)) - await outbox.deleteCommand(id: next.id) - self.clearOutboxState(forCommandID: next.id) - self.outboxTransportFailureStreak = 0 - if next.sessionKey == self.sessionKey { - flushedCurrentSession = true + let confirmationUpdate = await outbox.markCommandAwaitingConfirmation(id: next.id) + if confirmationUpdate == .unavailable { + self.applyTransportHealth(false) + break } - } catch { - // Transport-level failure (unreachable, socket drop): a - // connectivity blip, not a gateway verdict on the command. - // Keep the row queued without burning a durable retry - // attempt; the in-memory streak paces repeated throws up the - // delay ladder instead of hammering the first rung forever. - outboxLogger.error("outbox flush send failed \(error.localizedDescription, privacy: .public)") + if confirmationUpdate == .updated { + self.setOutboxState(.confirming, forCommandID: next.id) + } else { + // A concurrent canonical history/session.message + // confirmation already removed the row. + self.clearOutboxState(forCommandID: next.id) + } + confirmationTargets.insert(Self.deliveryTarget(for: next)) + } catch is OpenClawChatTransportSendError { + // The transport proved this payload never reached its request + // channel, so it is safe to retry automatically. await outbox.markCommandQueued( id: next.id, retryCount: next.retryCount, - lastError: error.localizedDescription) + lastError: nil) self.setOutboxState(.queued, forCommandID: next.id) - self.outboxTransportFailureStreak += 1 - if self.outboxTransportFailureStreak > self.outboxRetryDelaysMs.count { - // Ladder exhausted: the transport is not actually usable - // despite healthOK. Drop health so the reconnect/poll - // machinery owns pacing; the next genuine healthy - // transition re-flushes and the row stays queued. - self.healthOK = false - break + self.applyTransportHealth(false) + break + } catch is CancellationError { + // Cancellation while the request is suspended does not prove + // that the gateway rejected it. Never replay automatically. + let update = await self.parkOutboxCommandWithUnconfirmedDelivery(next, outbox: outbox) + if update == .updated { + confirmationTargets.insert(Self.deliveryTarget(for: next)) } - // Strict createdAt ordering: never skip ahead of a command - // that is still deliverable. - self.scheduleOutboxRetry(afterAttempts: self.outboxTransportFailureStreak) + self.applyTransportHealth(false) + break + } catch let error as GatewayResponseError { + if error.detailsReason == OpenClawChatSessionRoutingContract.changedErrorReason { + guard await self.parkOutboxCommandForChangedTarget(next, outbox: outbox) else { break } + continue + } + // A response error proves the gateway rejected the request; + // unlike a socket/timeout failure, replay cannot duplicate an + // accepted run and should consume the normal retry budget. + let handled = await self.recordOutboxRejection( + of: next, + outbox: outbox, + reason: error.localizedDescription) + if handled { continue } else { break } + } catch { + // A socket error or timeout is not a gateway rejection: the + // request may have landed before its ACK was lost. Preserve + // it for history reconciliation or explicit user retry. + outboxLogger.error("outbox flush send failed \(error.localizedDescription, privacy: .public)") + let update = await self.parkOutboxCommandWithUnconfirmedDelivery(next, outbox: outbox) + if update == .updated { + confirmationTargets.insert(Self.deliveryTarget(for: next)) + } + self.applyTransportHealth(false) break } } - // Tombstones are NOT cleared here: each lives until its delete task - // confirms the row is durably gone (process death in that window - // leaves both row and tombstone-protected bubble intact). The set - // stays bounded by in-flight user deletes. - if flushedCurrentSession { - await self.refreshHistoryAfterOutboxFlush() + if !confirmationTargets.isEmpty { + await self.refreshHistoriesAfterOutboxFlush( + targets: confirmationTargets, + routeLease: routeLease) } } + private static func needsOutboxDeliveryReconciliation(_ command: OpenClawChatOutboxCommand) -> Bool { + command.status == .awaitingConfirmation || + (command.status == .failed && + command.lastError == OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError) + } + + private static func deliveryTarget(for command: OpenClawChatOutboxCommand) -> OutboxDeliveryTarget { + OutboxDeliveryTarget( + presentationSessionKey: command.sessionKey, + deliverySessionKey: command.deliverySessionKey, + agentID: command.agentID) + } + + private func parkOutboxCommandWithUnconfirmedDelivery( + _ command: OpenClawChatOutboxCommand, + outbox: any OpenClawChatCommandOutbox) async -> OpenClawChatOutboxUpdateResult + { + let update = await outbox.markCommandFailedIfPresent( + id: command.id, + retryCount: command.retryCount, + lastError: OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError) + switch update { + case .updated: + self.setOutboxState( + .failed(reason: OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError), + forCommandID: command.id) + case .missing, .confirmed: + self.clearOutboxState(forCommandID: command.id) + case .unavailable: + self.applyTransportHealth(false) + } + return update + } + + private func parkOutboxCommandForChangedTarget( + _ command: OpenClawChatOutboxCommand, + outbox: any OpenClawChatCommandOutbox) async -> Bool + { + let update = await outbox.markCommandFailedIfPresent( + id: command.id, + retryCount: command.retryCount, + lastError: OpenClawChatSQLiteTranscriptCache.outboxChangedTargetError) + guard update != .unavailable else { + self.applyTransportHealth(false) + return false + } + if update == .updated { + let reason = "Gateway session routing changed; review and retry this message." + self.setOutboxState(.failed(reason: reason), forCommandID: command.id) + } else { + self.clearOutboxState(forCommandID: command.id) + } + return true + } + /// Gateway rejections ("error"/"timeout" send acks) burn a retry attempt /// and become terminally 'failed' after `maxOutboxSendAttempts`. Returns /// true when the flush pass may continue with younger commands. @@ -402,8 +663,21 @@ extension OpenClawChatViewModel { outboxLogger.error("outbox flush send rejected \(reason, privacy: .public)") let attempts = command.retryCount + 1 if attempts >= Self.maxOutboxSendAttempts { - await outbox.markCommandFailed(id: command.id, retryCount: attempts, lastError: reason) - self.setOutboxState(.failed(reason: reason), forCommandID: command.id) + let update = await outbox.markCommandFailedIfPresent( + id: command.id, + retryCount: attempts, + lastError: reason) + guard update != .unavailable else { + self.applyTransportHealth(false) + return false + } + if update == .updated { + self.setOutboxState(.failed(reason: reason), forCommandID: command.id) + } else { + // Canonical history may have removed the claimed row while + // the rejection was in flight. + self.clearOutboxState(forCommandID: command.id) + } // Terminal failure needs user action; let younger commands // flush instead of blocking behind it forever. return true @@ -440,21 +714,90 @@ extension OpenClawChatViewModel { private func spliceSentCommandIntoCachedTranscript(_ command: OpenClawChatOutboxCommand) async { guard let transcriptCache else { return } let key = Self.outboxUserIdempotencyKey(command.id) - var cached = await transcriptCache.loadTranscript(sessionKey: command.sessionKey) + let cacheAgentID = Self.transcriptCacheAgentID( + sessionKey: command.sessionKey, + agentID: command.agentID) + var cached = await transcriptCache.loadTranscript( + sessionKey: command.sessionKey, + agentID: cacheAgentID) guard !cached.contains(where: { $0.idempotencyKey == key }) else { return } cached.append(Self.outboxUserMessage(for: command)) - await transcriptCache.storeTranscript(sessionKey: command.sessionKey, messages: cached) + await transcriptCache.storeTranscript( + sessionKey: command.sessionKey, + agentID: cacheAgentID, + messages: cached) } - private func recoverInterruptedOutboxSendsIfNeeded() async { - guard let outbox, !self.hasRecoveredInterruptedOutboxSends else { return } - // Burn the once-per-launch gate only when the store was reachable: - // with Complete file protection the database is legitimately - // unavailable while the device is locked, and skipping recovery then - // would leave crashed 'sending' rows stuck forever after unlock. - if await outbox.recoverInterruptedSends() { - self.hasRecoveredInterruptedOutboxSends = true + private func recoverInterruptedOutboxSendsIfNeeded() async -> Bool { + guard let outbox else { return false } + // The store owns the once-per-process gate so overlapping/replacement + // view models cannot reset another active sender's claim. + return await outbox.recoverInterruptedSends() + } + + private func outboxAgentID(for session: SessionSnapshot) -> String? { + guard self.transport.outboxRequiresSessionRoutingContract else { return nil } + if session.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "unknown" { + return nil } + let normalized = session.deliveryAgentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized?.isEmpty == false ? normalized : nil + } + + private func outboxRequiresAgentID(for session: SessionSnapshot) -> Bool { + guard self.transport.outboxRequiresSessionRoutingContract else { return false } + return session.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() != "unknown" + } + + private func outboxRoutingContract(for session: SessionSnapshot) -> String? { + if !self.transport.outboxRequiresSessionRoutingContract { + return OpenClawChatOutboxCommand.legacyUnboundRoutingContract + } + let normalized = session.sessionRoutingContract? + .trimmingCharacters(in: .whitespacesAndNewlines) + return normalized?.isEmpty == false ? normalized : nil + } + + /// Resolve once, before persistence. Re-resolving a presentation alias + /// after reconnect could deliver to a newly selected/default agent. + private func outboxDeliverySessionKey( + for session: SessionSnapshot, + agentID: String?) -> String? + { + let raw = session.key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !raw.isEmpty else { return nil } + guard self.transport.outboxRequiresSessionRoutingContract else { return raw } + if raw.lowercased() == "unknown" { return raw } + guard let agentID else { return nil } + let normalized = raw.lowercased() + if normalized == "global" { return "global" } + if Self.agentID(fromSessionKey: raw) != nil { return raw } + // A malformed ownership prefix must fail closed, not become a nested + // key such as agent::agent::main. + guard !normalized.hasPrefix("agent:") else { return nil } + // The gateway owns structural normalization and preserves opaque + // Matrix/Signal peer IDs. Keep the request key byte-for-byte here. + return "agent:\(agentID):\(raw)" + } + + private func commandMatchesTarget( + _ command: OpenClawChatOutboxCommand, + session: SessionSnapshot) -> Bool + { + guard command.sessionKey == session.key else { return false } + // Failed rows never auto-send. Keep them reachable on their original + // presentation alias after an owner change for explicit retry/delete. + if command.status == .failed { return true } + // Migrated v2 aliases have no owner and are parked as failed. Show + // them so explicit retry can adopt the currently selected agent. + guard let commandAgentID = command.agentID else { return true } + guard let currentAgentID = self.outboxAgentID(for: session) else { + // Cold offline launch has not recovered gateway ownership yet. + // Keep the durable turn visible; the route lease verifies its + // captured owner and contract before any later delivery. + return true + } + return commandAgentID == currentAgentID } private func setOutboxState(_ state: OpenClawChatOutboxMessageState, forCommandID commandID: String) { @@ -468,6 +811,33 @@ extension OpenClawChatViewModel { self.outboxStatesByMessageID.removeValue(forKey: messageID) } + func handleOutboxChange(_ change: OpenClawChatOutboxChange) { + // Invalidates every command snapshot that started loading before the + // store mutation, including snapshots owned by another view model. + self.outboxPresentationGeneration &+= 1 + switch change { + case let .canceled(commandID): + // The initiating view owns its async result so canonical proof + // observed before that continuation can still preserve the row. + // Other views have no local cancellation task and apply the event. + guard !self.cancelingOutboxCommandIDs.contains(commandID) else { return } + guard let messageID = self.outboxMessageIDsByCommandID[commandID] else { return } + self.clearOutboxState(forCommandID: commandID) + self.replaceMessages(self.messages.filter { $0.id != messageID }) + case let .confirmed(commandID): + // Canonical history owns the message row; only its outbox badge + // and command mapping disappear. + self.clearOutboxState(forCommandID: commandID) + } + } + + private func finishOutboxCancellation(_ commandID: String) { + // Loads started while cancellation was in flight carry the previous + // generation and cannot re-present their stale command snapshot. + self.outboxPresentationGeneration &+= 1 + self.cancelingOutboxCommandIDs.remove(commandID) + } + private static func outboxDisplayState(for command: OpenClawChatOutboxCommand) -> OpenClawChatOutboxMessageState { @@ -476,6 +846,8 @@ extension OpenClawChatViewModel { .queued case .sending: .sending + case .awaitingConfirmation: + .confirming case .failed: .failed(reason: command.lastError) } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+SessionKeys.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+SessionKeys.swift index 4b2fe55fdb0..a282336721f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+SessionKeys.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+SessionKeys.swift @@ -4,8 +4,8 @@ extension OpenClawChatViewModel { public var sessionChoices: [OpenClawChatSessionEntry] { let now = Date().timeIntervalSince1970 * 1000 let cutoff = now - (24 * 60 * 60 * 1000) - let sorted = self.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) } - let mainSessionKey = self.resolvedMainSessionKey + let sorted = sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) } + let mainSessionKey = resolvedMainSessionKey var result: [OpenClawChatSessionEntry] = [] var included = Set() @@ -15,23 +15,23 @@ extension OpenClawChatViewModel { result.append(main) included.insert(main.key) } else { - result.append(self.placeholderSession(key: mainSessionKey)) + result.append(placeholderSession(key: mainSessionKey)) included.insert(mainSessionKey) } for entry in sorted { guard !included.contains(entry.key) else { continue } - guard entry.key == self.sessionKey || !Self.isHiddenInternalSession(entry.key) else { continue } + guard entry.key == sessionKey || !Self.isHiddenInternalSession(entry.key) else { continue } guard (entry.updatedAt ?? 0) >= cutoff else { continue } result.append(entry) included.insert(entry.key) } - if !included.contains(self.sessionKey) { + if !included.contains(sessionKey) { if let current = sorted.first(where: { $0.key == self.sessionKey }) { result.append(current) } else { - result.append(self.placeholderSession(key: self.sessionKey)) + result.append(placeholderSession(key: sessionKey)) } } @@ -72,8 +72,20 @@ extension OpenClawChatViewModel { let incomingNormalized = incoming.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let currentNormalized = current.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if incomingNormalized == currentNormalized { - if incomingNormalized == "global" { - return Self.matchesGlobalAgent(agentId: agentId, activeAgentId: activeAgentId) + if Self.agentIDFromSessionKey(currentNormalized) == nil { + // `global` is always agent-ambiguous. Ordinary exact keys can + // arrive before bootstrap publishes the active agent; accept + // them until there is ownership metadata to compare. + if currentNormalized != "global", + self.normalizedAgentId(activeAgentId) == nil + { + return true + } + return Self.matchesAliasAgent( + incomingKey: incomingNormalized, + agentId: agentId, + currentKey: currentNormalized, + activeAgentId: activeAgentId) } return true } @@ -84,10 +96,19 @@ extension OpenClawChatViewModel { current: currentNormalized, mainSessionKey: mainNormalized) { - if incomingNormalized == "global" || currentNormalized == "global" || mainNormalized == "global" { - return Self.matchesGlobalAgent(agentId: agentId, activeAgentId: activeAgentId) - } - return true + return Self.matchesAliasAgent( + incomingKey: incomingNormalized, + agentId: agentId, + currentKey: currentNormalized, + activeAgentId: activeAgentId, + allowIncomingOwnerWhenCurrentUnknown: true) + } + if Self.matchesSelectedAgentWrapper(incoming: incomingNormalized, current: currentNormalized) { + return Self.matchesAliasAgent( + incomingKey: incomingNormalized, + agentId: agentId, + currentKey: currentNormalized, + activeAgentId: activeAgentId) } if Self.matchesSelectedAgentGlobal( incoming: incomingNormalized, @@ -104,10 +125,46 @@ extension OpenClawChatViewModel { return normalized?.isEmpty == false ? normalized : nil } - private static func matchesGlobalAgent(agentId: String?, activeAgentId: String?) -> Bool { - guard let activeAgentId = normalizedAgentId(activeAgentId) else { return false } - guard let incomingAgentId = normalizedAgentId(agentId) else { return true } - return incomingAgentId == activeAgentId + private static func matchesAliasAgent( + incomingKey: String, + agentId: String?, + currentKey: String, + activeAgentId: String?, + allowIncomingOwnerWhenCurrentUnknown: Bool = false) -> Bool + { + let currentAgentID = self.agentIDFromSessionKey(currentKey) ?? self.normalizedAgentId(activeAgentId) + let payloadAgentID = self.normalizedAgentId(agentId) + let keyAgentID = self.agentIDFromSessionKey(incomingKey) + if let payloadAgentID, let keyAgentID, payloadAgentID != keyAgentID { + return false + } + let incomingAgentID = payloadAgentID ?? keyAgentID + guard let currentAgentID else { + return allowIncomingOwnerWhenCurrentUnknown && incomingAgentID != nil + } + guard let incomingAgentID else { return true } + return incomingAgentID == currentAgentID + } + + private static func agentIDFromSessionKey(_ sessionKey: String) -> String? { + let parts = sessionKey.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) + guard parts.count >= 3, parts[0] == "agent" else { return nil } + let agentID = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines) + return agentID.isEmpty ? nil : agentID + } + + private static func matchesSelectedAgentWrapper(incoming: String, current: String) -> Bool { + let incomingParts = incoming.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) + if incomingParts.count == 3, + incomingParts[0] == "agent", + String(incomingParts[2]) == current + { + return true + } + let currentParts = current.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) + return currentParts.count == 3 && + currentParts[0] == "agent" && + String(currentParts[2]) == incoming } private static func matchesMainAlias(incoming: String, current: String, mainSessionKey: String) -> Bool { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TranscriptCache.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TranscriptCache.swift index 65b2d0b46ea..d3122bb17fa 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TranscriptCache.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TranscriptCache.swift @@ -8,29 +8,41 @@ extension OpenClawChatViewModel { struct SessionSnapshot { var key: String var generation: UInt64 + var agentID: String? + var deliveryAgentID: String? + var sessionRoutingContract: String? } func replaceMessages(_ messages: [OpenClawChatMessage]) { guard self.messages != messages else { return } self.messages = messages - self.markTimelineChanged() + markTimelineChanged() } - func persistTranscriptToCache(sessionKey: String, messages: [OpenClawChatMessage]) { + func persistTranscriptToCache( + sessionKey: String, + agentID: String?, + messages: [OpenClawChatMessage], + canonicalMessageIdempotencyKeys: Set) + { guard let transcriptCache else { return } // Chain writes so an older snapshot can never land after a newer one; // detached tasks alone give no ordering guarantee across awaits. - let previous = self.pendingCacheWriteTask - self.pendingCacheWriteTask = Task.detached { + let previous = pendingCacheWriteTask + pendingCacheWriteTask = Task.detached { await previous?.value - await transcriptCache.storeTranscript(sessionKey: sessionKey, messages: messages) + await transcriptCache.storeCanonicalTranscript( + sessionKey: sessionKey, + agentID: Self.transcriptCacheAgentID(sessionKey: sessionKey, agentID: agentID), + messages: messages, + canonicalMessageIdempotencyKeys: canonicalMessageIdempotencyKeys) } } func persistSessionsToCache(_ sessions: [OpenClawChatSessionEntry]) { guard let transcriptCache else { return } - let previous = self.pendingCacheWriteTask - self.pendingCacheWriteTask = Task.detached { + let previous = pendingCacheWriteTask + pendingCacheWriteTask = Task.detached { await previous?.value await transcriptCache.storeSessions(sessions) } @@ -42,7 +54,7 @@ extension OpenClawChatViewModel { /// applyHistoryPayload reconciliation path. func paintFromCacheIfNeeded(session: SessionSnapshot) { guard let transcriptCache else { return } - if self.sessions.isEmpty, !self.hasAppliedLiveSessions { + if sessions.isEmpty, !hasAppliedLiveSessions { Task { [weak self] in let cached = await transcriptCache.loadSessions() guard let self, !cached.isEmpty else { return } @@ -52,9 +64,11 @@ extension OpenClawChatViewModel { self.sessions = cached } } - guard self.messages.isEmpty, !self.hasAppliedLiveHistory else { return } + guard messages.isEmpty, !hasAppliedLiveHistory else { return } Task { [weak self] in - let cached = await transcriptCache.loadTranscript(sessionKey: session.key) + let cached = await transcriptCache.loadTranscript( + sessionKey: session.key, + agentID: Self.transcriptCacheAgentID(sessionKey: session.key, agentID: session.agentID)) guard let self, !cached.isEmpty else { return } guard self.isCurrentSession(session), !self.hasAppliedLiveHistory, self.messages.isEmpty else { return @@ -63,4 +77,10 @@ extension OpenClawChatViewModel { self.isShowingCachedTranscript = true } } + + static func transcriptCacheAgentID(sessionKey: String, agentID: String?) -> String? { + guard Self.agentID(fromSessionKey: sessionKey) == nil else { return nil } + let normalized = agentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized?.isEmpty == false ? normalized : nil + } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index 20dcf496457..a7f23618d75 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -39,11 +39,18 @@ public final class OpenClawChatViewModel { public private(set) var isLoading = false public private(set) var isSending = false + private var isSubmittingDraft = false public private(set) var isAborting = false public var errorText: String? public var attachments: [OpenClawPendingAttachment] = [] /// Setter is module-internal for the health/outbox extension only. public internal(set) var healthOK: Bool = false + + /// True when this view model owns a gateway-scoped durable text outbox. + public var supportsOfflineTextOutbox: Bool { + self.outbox != nil + } + public private(set) var pendingRunCount: Int = 0 public private(set) var sessionKey: String @@ -76,34 +83,23 @@ public final class OpenClawChatViewModel { var outboxCommandIDsByMessageID: [UUID: String] = [:] @ObservationIgnored var outboxMessageIDsByCommandID: [String: UUID] = [:] + /// Recent canonical keys let the MainActor resolve proof that arrives + /// after SQLite cancellation commits but before its UI continuation runs. + @ObservationIgnored + var canonicalOutboxMessageKeys: [String] = [] @ObservationIgnored var isFlushingOutbox = false @ObservationIgnored var isOutboxFlushRequestedWhileActive = false @ObservationIgnored - var hasRecoveredInterruptedOutboxSends = false - /// Tombstones set synchronously on user delete so an active flush pass - /// never sends a command whose bubble the user just removed. + var cancelingOutboxCommandIDs: Set = [] @ObservationIgnored - var deletedOutboxCommandIDs: Set = [] + var outboxPresentationGeneration: UInt64 = 0 + @ObservationIgnored + var outboxChangesTask: Task? /// Backoff between failed flush attempts; internal so tests can shorten it. @ObservationIgnored var outboxRetryDelaysMs: [UInt64] = [2000, 8000] - /// Consecutive transport-level flush failures. Paces retries up the - /// delay ladder without touching the durable per-command retryCount - /// (reserved for gateway verdicts); past the ladder, health drops and - /// the reconnect machinery owns pacing. - @ObservationIgnored - var outboxTransportFailureStreak = 0 - /// User idempotency keys of turns just flushed from the outbox whose - /// durable outbox row is already deleted but which no history snapshot - /// has confirmed yet. Reconciliation must not evict them: the gateway - /// history can lag the ack, and eviction here would drop the turn from - /// both the screen and the write-through cache. Drained when a snapshot - /// contains the key; bounded because every flush drains or session - /// switches clear it. - @ObservationIgnored - var recentlySentOutboxUserKeys: Set = [] /// False until restoreOutboxMessages has adopted durable rows for the /// visible session. Until then the in-memory outbox state is blind to /// rows persisted by an earlier process, so the FIFO send gate must @@ -117,6 +113,7 @@ public final class OpenClawChatViewModel { @ObservationIgnored var pendingCacheWriteTask: Task? private(set) var activeAgentId: String? + private(set) var sessionRoutingContract: String? var sessionDefaults: OpenClawChatSessionsDefaults? private let prefersExplicitThinkingLevel: Bool private let onSessionChanged: (@MainActor (String) -> Void)? @@ -241,6 +238,7 @@ public final class OpenClawChatViewModel { sessionKey: String, transport: any OpenClawChatTransport, activeAgentId: String? = nil, + sessionRoutingContract: String? = nil, haptics: OpenClawChatHaptics = OpenClawChatHaptics(), transcriptCache: (any OpenClawChatTranscriptCache)? = nil, outbox: (any OpenClawChatCommandOutbox)? = nil, @@ -257,9 +255,12 @@ public final class OpenClawChatViewModel { self.modelPickerStore = modelPickerStore self.modelPickerFavorites = modelPickerStore.favorites self.modelPickerRecents = modelPickerStore.recents + self.outbox = outbox let normalizedAgentId = activeAgentId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() self.activeAgentId = normalizedAgentId?.isEmpty == false ? normalizedAgentId : nil - self.outbox = outbox + let normalizedRoutingContract = sessionRoutingContract? + .trimmingCharacters(in: .whitespacesAndNewlines) + self.sessionRoutingContract = normalizedRoutingContract?.isEmpty == false ? normalizedRoutingContract : nil let normalizedThinkingLevel = Self.normalizedThinkingLevel(initialThinkingLevel) let initialResolvedThinkingLevel = normalizedThinkingLevel ?? "off" self.thinkingLevel = initialResolvedThinkingLevel @@ -281,12 +282,22 @@ public final class OpenClawChatViewModel { } } } + if let outbox = self.outbox { + let changes = outbox.changes() + self.outboxChangesTask = Task { [weak self, changes] in + for await change in changes { + guard !Task.isCancelled else { return } + self?.handleOutboxChange(change) + } + } + } } deinit { self.eventTask?.cancel() self.bootstrapTask?.cancel() self.outboxRetryTask?.cancel() + self.outboxChangesTask?.cancel() for (_, task) in self.pendingRunTimeoutTasks { task.cancel() } @@ -353,21 +364,46 @@ public final class OpenClawChatViewModel { } public func syncActiveAgentId(_ agentId: String?) { + self.syncDeliveryIdentity( + activeAgentId: agentId, + sessionRoutingContract: self.sessionRoutingContract) + } + + public func syncSessionRoutingContract(_ contract: String?) { + self.syncDeliveryIdentity( + activeAgentId: self.activeAgentId, + sessionRoutingContract: contract) + } + + /// Updates the alias owner and its gateway routing contract as one + /// identity change so an intermediate bootstrap cannot win either value. + public func syncDeliveryIdentity( + activeAgentId agentId: String?, + sessionRoutingContract contract: String?) + { let normalized = agentId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let nextAgentId = normalized?.isEmpty == false ? normalized : nil - guard self.activeAgentId != nextAgentId else { return } + let normalizedContract = contract?.trimmingCharacters(in: .whitespacesAndNewlines) + let nextContract = normalizedContract?.isEmpty == false ? normalizedContract : nil + let agentChanged = self.activeAgentId != nextAgentId + let contractChanged = self.sessionRoutingContract != nextContract + guard agentChanged || contractChanged else { return } + // A scoped key can be the main alias under either side of a contract + // change. Check both or stale transcript state can survive the switch. + let contractRoutingChanged = contractChanged && + (usesMutableContractRouting(for: sessionRoutingContract) || + self.usesMutableContractRouting(for: nextContract)) self.activeAgentId = nextAgentId - let normalizedSessionKey = self.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let normalizedMainSessionKey = self.resolvedMainSessionKey - .trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard normalizedSessionKey == "global" || - normalizedSessionKey == "main" || - normalizedSessionKey == normalizedMainSessionKey - else { + self.sessionRoutingContract = nextContract + let bootstrapIdentityChanged = + (agentChanged && self.usesMutableAgentRouting) || + contractRoutingChanged + guard bootstrapIdentityChanged else { + if contractChanged, self.healthOK { flushOutboxIfNeeded() } return } - // Main aliases belong to the configured default agent. Changing that - // identity changes their transcript owner, so no old request or row may cross it. + // Restart when this key depends on a changed routing value so cleared + // state cannot remain stuck or cross session owners. self.advanceSessionGeneration() self.clearSessionOwnedState() self.startBootstrap() @@ -396,7 +432,7 @@ public final class OpenClawChatViewModel { query: String, filter: OpenClawChatCommandFilter) -> [OpenClawChatCommandChoice] { - if let cache = self.slashFilterCache, cache.query == query, cache.filter == filter { + if let cache = slashFilterCache, cache.query == query, cache.filter == filter { return cache.result } let result = Self.filteredSlashCommands(self.slashCommands, query: query, filter: filter) @@ -417,6 +453,29 @@ public final class OpenClawChatViewModel { return (trimmed?.isEmpty == false ? trimmed : nil) ?? "main" } + private var usesMutableAgentRouting: Bool { + Self.agentID(fromSessionKey: self.sessionKey) == nil + } + + private func usesMutableContractRouting(for contract: String?) -> Bool { + if self.usesMutableAgentRouting { return true } + let parts = self.sessionKey + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) + guard parts.count == 3 else { return false } + let normalizedSessionKey = parts[2].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let resolvedMainParts = self.resolvedMainSessionKey + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) + let normalizedMainSessionKey = String(resolvedMainParts.last ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let contractMainKey = OpenClawChatSessionRoutingContract.parse(contract)?.mainKey ?? "" + return normalizedSessionKey == "global" || + normalizedSessionKey == "main" || + normalizedSessionKey == normalizedMainSessionKey || + normalizedSessionKey == contractMainKey + } + public var showsModelPicker: Bool { !self.modelChoices.isEmpty } @@ -449,7 +508,7 @@ public final class OpenClawChatViewModel { } public var canSend: Bool { - !self.isSending && self.pendingRunCount == 0 && self.hasDraftToSend + !self.isSubmittingDraft && !self.isSending && self.pendingRunCount == 0 && self.hasDraftToSend } public var hasDraftToSend: Bool { @@ -458,7 +517,7 @@ public final class OpenClawChatViewModel { } public var canSendDraft: Bool { - !self.isSending && self.hasDraftToSend + !self.isSubmittingDraft && !self.isSending && self.hasDraftToSend } // MARK: - Internals @@ -491,11 +550,21 @@ public final class OpenClawChatViewModel { } func currentSessionSnapshot() -> SessionSnapshot { - SessionSnapshot(key: self.sessionKey, generation: self.sessionGeneration) + SessionSnapshot( + key: self.sessionKey, + generation: self.sessionGeneration, + agentID: self.activeAgentId, + deliveryAgentID: Self.agentID(fromSessionKey: self.sessionKey) ?? self.activeAgentId, + sessionRoutingContract: self.sessionRoutingContract) } func isCurrentSession(_ snapshot: SessionSnapshot) -> Bool { - self.sessionKey == snapshot.key && self.sessionGeneration == snapshot.generation + let contractSensitive = self.usesMutableContractRouting(for: snapshot.sessionRoutingContract) || + self.usesMutableContractRouting(for: self.sessionRoutingContract) + return self.sessionKey == snapshot.key && + self.sessionGeneration == snapshot.generation && + (!self.usesMutableAgentRouting || self.activeAgentId == snapshot.agentID) && + (!contractSensitive || self.sessionRoutingContract == snapshot.sessionRoutingContract) } private func isCurrentBootstrap(_ context: BootstrapContext) -> Bool { @@ -546,9 +615,9 @@ public final class OpenClawChatViewModel { syncThinkingOptions: Bool = false) -> Bool { guard self.canApplyHistory(request) else { return false } - let incoming = self.adoptingProvisionalFinalMessageIDs( + let incoming = adoptingProvisionalFinalMessageIDs( in: Self.decodeMessages(payload.messages ?? [])) - let unmatchedProvisionalFinalIDs = Set(self.provisionalFinalMessagesMissing(from: incoming).map(\.id)) + let unmatchedProvisionalFinalIDs = Set(provisionalFinalMessagesMissing(from: incoming).map(\.id)) var retainedMessageIDs = unmatchedProvisionalFinalIDs if request.historyMutationGeneration != self.historyMutationGeneration { for message in self.messages where request.visibleMessagesByID[message.id] != message { @@ -559,6 +628,10 @@ public final class OpenClawChatViewModel { } } } + // Durable outbox rows remain authoritative until canonical history + // confirms their idempotency key. Keep their bubbles through lagging + // snapshots, including across app relaunches and session switches. + retainedMessageIDs.formUnion(self.outboxCommandIDsByMessageID.keys) var nextMessages = if preservingOptimisticLocalMessages { Self.reconcileRunRefreshMessages( previous: self.messages, @@ -567,28 +640,17 @@ public final class OpenClawChatViewModel { } else { Self.reconcileMessageIDs(previous: self.messages, incoming: incoming) } - // Ack-to-history window: turns just flushed from the outbox may not - // be in this snapshot yet. Their durable rows are gone, so keep the - // visible rows (appended: they are the newest turns) until a - // snapshot carries their idempotency key. - if !self.recentlySentOutboxUserKeys.isEmpty { - self.recentlySentOutboxUserKeys.subtract(incoming.compactMap(\.idempotencyKey)) - let nextKeys = Set(nextMessages.compactMap(\.idempotencyKey)) - let preserved = self.messages.filter { message in - guard let key = message.idempotencyKey else { return false } - return self.recentlySentOutboxUserKeys.contains(key) && !nextKeys.contains(key) - } - nextMessages.append(contentsOf: preserved) - } let reconciledMessageIDs = Set(nextMessages.map(\.id)) nextMessages.append(contentsOf: self.messages.filter { message in retainedMessageIDs.contains(message.id) && !reconciledMessageIDs.contains(message.id) }) - self.replaceMessages(Self.dedupeMessages(nextMessages)) - self.prunePendingLocalUserEchoMessageIDs() - self.clearProvisionalFinalMarkersAdoptedByHistory(incoming) - self.pruneProvisionalFinalMessages() - self.pruneRunMessageScopes() + nextMessages = Self.dedupeMessages(nextMessages) + replaceMessages(nextMessages) + confirmOutboxCommands(in: incoming) + prunePendingLocalUserEchoMessageIDs() + clearProvisionalFinalMarkersAdoptedByHistory(incoming) + pruneProvisionalFinalMessages() + pruneRunMessageScopes() self.rescopeRunsAdoptedAfterHistoryRequest(request) self.sessionId = payload.sessionId self.applyInFlightRunSnapshot(payload.inFlightRun, for: request) @@ -609,7 +671,7 @@ public final class OpenClawChatViewModel { self.thinkingLevel = level } if syncThinkingOptions || appliedThinkingLevel != nil { - self.syncThinkingLevelOptions() + syncThinkingLevelOptions() } // Live history is the source of truth: it clears the cached marker and // is written through so the next cold open pre-paints current rows. @@ -618,15 +680,17 @@ public final class OpenClawChatViewModel { // An empty post-send refresh is incomplete by contract: reconciliation // preserves the visible transcript, so preserve its last canonical cache too. if !preservingOptimisticLocalMessages || !incoming.isEmpty { - // Persist the RECONCILED transcript, not raw incoming: a stale - // snapshot missing a just-acked turn must not overwrite the - // cache after the durable outbox row is already gone — the cache - // mirrors what the user sees. - self.persistTranscriptToCache(sessionKey: request.session.key, messages: nextMessages) + // Persist the reconciled transcript, including durable outbox + // rows retained while canonical history catches up. + persistTranscriptToCache( + sessionKey: request.session.key, + agentID: request.session.agentID, + messages: nextMessages, + canonicalMessageIdempotencyKeys: Set(incoming.compactMap(\.idempotencyKey))) } // Wholesale history replacement drops local-only queued bubbles; // re-adopt or re-append them from the durable outbox. - self.restoreOutboxMessages(session: request.session) + restoreOutboxMessages(session: request.session) return true } @@ -653,7 +717,7 @@ public final class OpenClawChatViewModel { for runId in self.pendingRuns { let scope = self.runMessageScopesByRunID[runId] if !request.pendingRunIDs.contains(runId) || scope?.latestUserTurn == nil { - self.runMessageScopesByRunID[runId] = self.currentRunMessageScope() + self.runMessageScopesByRunID[runId] = currentRunMessageScope() } } } @@ -690,7 +754,7 @@ public final class OpenClawChatViewModel { self.updateStreamingAssistantText(nil) } if self.runMessageScopesByRunID[runId] == nil { - self.runMessageScopesByRunID[runId] = self.currentRunMessageScope() + self.runMessageScopesByRunID[runId] = currentRunMessageScope() } self.armPendingRunTimeout(runId: runId) if !bufferedText.isEmpty { @@ -717,8 +781,8 @@ public final class OpenClawChatViewModel { let context = BootstrapContext( id: bootstrapGeneration, historyRequest: historyRequest) - self.paintFromCacheIfNeeded(session: context.session) - self.restoreOutboxMessages(session: context.session) + paintFromCacheIfNeeded(session: context.session) + restoreOutboxMessages(session: context.session) self.bootstrapTask = Task { [weak self] in guard let self else { return } await self.bootstrap(context: context) @@ -743,7 +807,7 @@ public final class OpenClawChatViewModel { for: context.historyRequest, preservingOptimisticLocalMessages: false, syncThinkingOptions: true) - await self.pollHealthIfNeeded(force: true, sessionSnapshot: context.session) + await pollHealthIfNeeded(force: true, sessionSnapshot: context.session) guard self.isCurrentBootstrap(context) else { return } await self.fetchSessions(limit: 50, sessionSnapshot: context.session) guard self.isCurrentBootstrap(context) else { return } @@ -784,7 +848,7 @@ public final class OpenClawChatViewModel { self.logDiagnostic( "chat.ui foreground refresh sessionKey=\(context.session.key) " + "pending=\(self.pendingRunCount)") - let refresh = await self.refreshHistoryAfterRun(historyRequest: context) + let refresh = await refreshHistoryAfterRun(historyRequest: context) guard self.isCurrentSession(context.session) else { return } if refresh.applied, refresh.runSnapshotApplied, @@ -799,7 +863,7 @@ public final class OpenClawChatViewModel { self.pendingToolCallsById = [:] self.updateStreamingAssistantText(nil) } - await self.pollHealthIfNeeded(force: true, sessionSnapshot: context.session) + await pollHealthIfNeeded(force: true, sessionSnapshot: context.session) } private static let resetTriggers: Set = ["/reset", "/clear"] @@ -814,7 +878,7 @@ public final class OpenClawChatViewModel { defer { self.isLoadingSlashCommands = false } do { - let commands = try await self.transport.listCommands(sessionKey: sessionSnapshot.key) + let commands = try await transport.listCommands(sessionKey: sessionSnapshot.key) guard self.isCurrentSession(sessionSnapshot) else { return } self.slashCommands = commands self.slashFilterCache = nil @@ -841,7 +905,7 @@ public final class OpenClawChatViewModel { } } - private func validateSlashCommandDraftForSend(trimmed: String) async -> Bool { + private func validateSlashCommandDraftForSend(trimmed: String, hasAttachments: Bool) async -> Bool { guard let slashName = Self.slashCommandName(from: trimmed) else { return true } @@ -854,7 +918,7 @@ public final class OpenClawChatViewModel { if self.hasLoadedSlashCommands, Self.isKnownSlashCommandText(trimmed, commands: self.slashCommands), - !self.attachments.isEmpty + hasAttachments { self.errorText = "Commands cannot be sent with attachments." return false @@ -884,7 +948,7 @@ public final class OpenClawChatViewModel { _ text: String, commands: [OpenClawChatCommandChoice]) -> Bool { - guard let commandName = self.slashCommandName(from: text), !commandName.isEmpty else { + guard let commandName = slashCommandName(from: text), !commandName.isEmpty else { return false } if self.commands(commands, containInvocationName: commandName) { @@ -1003,27 +1067,44 @@ public final class OpenClawChatViewModel { return nil } - private func handleLocalSlashCommandIfNeeded(_ command: String) async -> Bool { + private func handleLocalSlashCommandIfNeeded(_ command: String, draftInput: String) async -> Bool { if command == "/new" { - self.input = "" - await self.startNewSession() + if self.input == draftInput { self.input = "" } + await self.performStartNewSession(worktree: false) return true } if Self.resetTriggers.contains(command) { - self.input = "" + if self.input == draftInput { self.input = "" } await self.performReset() return true } if Self.compactTriggers.contains(command) { - self.input = "" + if self.input == draftInput { self.input = "" } await self.performCompact() return true } return false } + private static func isLiveOnlyLocalSlashCommand(_ command: String) -> Bool { + command == "/new" || self.resetTriggers.contains(command) || self.compactTriggers.contains(command) + } + + private func prepareLiveOnlyLocalSlashCommand(session: SessionSnapshot) async -> Bool { + // Always probe: a preserved view model can retain stale healthy state + // after its transport disconnects without a health event. performSend + // owns the send gate across this await. + await pollHealthIfNeeded(force: true, sessionSnapshot: session) + guard self.isCurrentSession(session) else { return false } + guard self.healthOK else { + self.errorText = "Connect to the gateway to run this command." + return false + } + return true + } + private func performSend() async { - guard !self.isSending else { + guard !self.isSubmittingDraft, !self.isSending else { self.logDiagnostic("chat.ui send ignored reason=sending sessionKey=\(self.sessionKey)") return } @@ -1033,39 +1114,53 @@ public final class OpenClawChatViewModel { + "pending=\(self.pendingRunCount)") return } - let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty || !self.attachments.isEmpty else { + let draftInput = self.input + let draftAttachments = self.attachments + let trimmed = draftInput.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty || !draftAttachments.isEmpty else { self.logDiagnostic("chat.ui send ignored reason=empty sessionKey=\(self.sessionKey)") return } + // Own every asynchronous validation/probe below. Slash catalog lookup + // can suspend, so taking this gate later permits duplicate enqueues. + // Keep it separate from isSending: local /compact checks that flag. + self.isSubmittingDraft = true + defer { self.isSubmittingDraft = false } + let command = trimmed.lowercased() - if await self.handleLocalSlashCommandIfNeeded(command) { - return - } - guard await self.validateSlashCommandDraftForSend(trimmed: trimmed) else { - return - } - let sessionSnapshot = self.currentSessionSnapshot() - let sessionKey = sessionSnapshot.key + if Self.isLiveOnlyLocalSlashCommand(command) { + let canRunCommand = await prepareLiveOnlyLocalSlashCommand(session: sessionSnapshot) + guard canRunCommand else { return } + } + if await self.handleLocalSlashCommandIfNeeded(command, draftInput: draftInput) { + return + } + guard await self.validateSlashCommandDraftForSend( + trimmed: trimmed, + hasAttachments: !draftAttachments.isEmpty) + else { + return + } - // Raised before the health poll so the entry guard covers the whole - // async path: a second submit during `pollHealthIfNeeded` would - // otherwise re-capture the same draft and enqueue a duplicate - // outbox row. self.isSending = true defer { self.isSending = false } + let sessionKey = sessionSnapshot.key + if !self.healthOK { - await self.pollHealthIfNeeded(force: true, sessionSnapshot: sessionSnapshot) + await pollHealthIfNeeded(force: true, sessionSnapshot: sessionSnapshot) guard self.isCurrentSession(sessionSnapshot) else { return } // Offline capture: queue text-only sends durably instead of // failing. Attachments stay on the live path (text only in v1). - if !self.healthOK, self.outbox != nil, self.attachments.isEmpty { + if !self.healthOK, self.outbox != nil, draftAttachments.isEmpty { self.logDiagnostic( "chat.ui send queued offline sessionKey=\(sessionKey) inputLen=\(trimmed.count)") - await self.enqueueOutboxCommand(text: trimmed, session: sessionSnapshot) + await enqueueOutboxCommand( + text: trimmed, + draftInput: draftInput, + session: sessionSnapshot) return } } @@ -1081,19 +1176,22 @@ public final class OpenClawChatViewModel { // documented as outside the ordering guarantee. Deliberately // session-scoped: other sessions' queued rows are separate // conversations with no ordering contract against this send. - if self.outbox != nil, self.attachments.isEmpty, + if self.outbox != nil, draftAttachments.isEmpty, !self.hasRestoredOutboxMessages || self.outboxStatesByMessageID.values.contains(where: { !$0.isFailed }) { self.logDiagnostic( "chat.ui send routed behind outbox sessionKey=\(sessionKey) inputLen=\(trimmed.count)") - await self.enqueueOutboxCommand(text: trimmed, session: sessionSnapshot) + await enqueueOutboxCommand( + text: trimmed, + draftInput: draftInput, + session: sessionSnapshot) return } self.errorText = nil let runId = UUID().uuidString - let messageText = trimmed.isEmpty && !self.attachments.isEmpty ? "See attached." : trimmed + let messageText = trimmed.isEmpty && !draftAttachments.isEmpty ? "See attached." : trimmed let storedThinkingLevel = self.thinkingLevel self.pendingRuns.insert(runId) self.armPendingRunTimeout(runId: runId) @@ -1117,7 +1215,7 @@ public final class OpenClawChatViewModel { name: nil, arguments: nil), ] - let encodedAttachments = self.attachments.map { att -> OpenClawChatAttachmentPayload in + let encodedAttachments = draftAttachments.map { att -> OpenClawChatAttachmentPayload in OpenClawChatAttachmentPayload( type: att.type, mimeType: att.mimeType, @@ -1148,11 +1246,12 @@ public final class OpenClawChatViewModel { timestamp: userMessageTimestamp, idempotencyKey: "\(runId):user")) self.pendingLocalUserEchoMessageIDsByRunID[runId] = userMessageID - self.runMessageScopesByRunID[runId] = self.currentRunMessageScope() + self.runMessageScopesByRunID[runId] = currentRunMessageScope() // Clear input immediately for responsive UX (before network await) - self.input = "" - self.attachments = [] + if self.input == draftInput { self.input = "" } + let sentAttachmentIDs = Set(draftAttachments.map(\.id)) + self.attachments.removeAll { sentAttachmentIDs.contains($0.id) } do { await self.waitForPendingModelPatches(in: sessionKey) @@ -1163,6 +1262,8 @@ public final class OpenClawChatViewModel { let thinkingLevel = self.effectiveThinkingLevelForSend(storedThinkingLevel) let response = try await transport.sendMessage( sessionKey: sessionKey, + agentID: sessionSnapshot.deliveryAgentID, + expectedSessionRoutingContract: sessionSnapshot.sessionRoutingContract, message: messageText, thinking: thinkingLevel, idempotencyKey: runId, @@ -1183,14 +1284,14 @@ public final class OpenClawChatViewModel { // The gateway can reuse an identical active run without writing // this second turn. Move the optimistic row onto that durable // identity, collapsing it if the canonical row is already here. - let rekeyedUserEcho = self.rekeyLocalUserEcho( + let rekeyedUserEcho = rekeyLocalUserEcho( messageID: pendingUserMessageID, runId: response.runId) self.pendingLocalUserEchoMessageIDsByRunID[response.runId] = rekeyedUserEcho?.pendingMessageID - let remoteRunScope = rekeyedUserEcho?.scope ?? localRunScope ?? self.currentRunMessageScope() + let remoteRunScope = rekeyedUserEcho?.scope ?? localRunScope ?? currentRunMessageScope() self.runMessageScopesByRunID[response.runId] = remoteRunScope - self.rescopeProvisionalFinalMessages(runId: response.runId, scope: remoteRunScope) - reusedRunAlreadyFinal = self.hasRecordedFinalMessage(runId: response.runId) + rescopeProvisionalFinalMessages(runId: response.runId, scope: remoteRunScope) + reusedRunAlreadyFinal = hasRecordedFinalMessage(runId: response.runId) if reusedRunAlreadyFinal { self.clearPendingRun(response.runId, hapticEvent: .runCompleted) self.pendingToolCallsById = [:] @@ -1208,7 +1309,7 @@ public final class OpenClawChatViewModel { !reusedRunAlreadyFinal { let historyContext = self.beginHistoryRequest(for: sessionSnapshot) - let refresh = await self.refreshHistoryAfterRun(historyRequest: historyContext) + let refresh = await refreshHistoryAfterRun(historyRequest: historyContext) guard self.isCurrentSession(sessionSnapshot) else { return } let hasInFlightRunSnapshot = refresh.applied && refresh.runSnapshotApplied && @@ -1231,31 +1332,37 @@ public final class OpenClawChatViewModel { } catch { guard self.isCurrentSession(sessionSnapshot) else { return } // Stale-healthy disconnects surface here instead of at the send - // gate. Requeue text-only sends durably (same runId = same - // idempotency identity, safe even if the send actually landed) - // and keep the optimistic bubble as the queued row. - if encodedAttachments.isEmpty { + // gate. A route rejection before dispatch stays auto-retryable; + // every ambiguous failure remains fail-closed. + if encodedAttachments.isEmpty, !(error is GatewayResponseError) { self.runMessageScopesByRunID.removeValue(forKey: runId) self.clearPendingRun(runId) - let requeued = await self.requeueFailedLiveSend( + let deliveryIsAmbiguous = !(error is OpenClawChatTransportSendError) + let preserved = await preserveFailedLiveSend( runId: runId, text: messageText, thinking: self.effectiveThinkingLevelForSend(storedThinkingLevel), messageID: userMessageID, - session: sessionSnapshot) - if requeued { + session: sessionSnapshot, + deliveryIsAmbiguous: deliveryIsAmbiguous) + if preserved { + self.applyTransportHealth(false) + let outcome = deliveryIsAmbiguous ? "delivery unconfirmed" : "queued after route change" self.logDiagnostic( - "chat.ui send requeued offline sessionKey=\(sessionKey) " + "chat.ui send \(outcome) sessionKey=\(sessionKey) " + "localRunId=\(runId) error=\(error.localizedDescription)") return } guard self.isCurrentSession(sessionSnapshot) else { return } - // Refused requeue (queue full / broken store): restore the + // Refused persistence (queue full / broken store): restore the // draft so the text is not lost with the failed bubble. if self.input.isEmpty { self.input = messageText } } + if encodedAttachments.isEmpty, self.input.isEmpty { + self.input = messageText + } self.removePendingLocalUserEcho(for: runId) self.runMessageScopesByRunID.removeValue(forKey: runId) self.errorText = error.localizedDescription @@ -1291,8 +1398,8 @@ public final class OpenClawChatViewModel { self.sessionDefaults = res.defaults self.hasAppliedLiveSessions = true self.syncSelectedModel() - self.syncThinkingLevelOptions() - self.persistSessionsToCache(res.sessions) + syncThinkingLevelOptions() + persistSessionsToCache(res.sessions) } catch { // Best-effort. } @@ -1356,14 +1463,13 @@ public final class OpenClawChatViewModel { /// Clears state owned by the current session/agent before a new identity can consume events. private func clearSessionOwnedState() { self.modelSelectionID = Self.defaultModelSelectionID - self.replaceMessages([]) + replaceMessages([]) self.isShowingCachedTranscript = false self.hasAppliedLiveHistory = false self.pendingLocalUserEchoMessageIDsByRunID.removeAll() self.runMessageScopesByRunID.removeAll() self.provisionalFinalMessagesByID.removeAll() - self.recentlySentOutboxUserKeys.removeAll() - self.resetOutboxPresentationForSessionSwitch() + resetOutboxPresentationForSessionSwitch() self.sessionId = nil self.pendingToolCallsById = [:] self.updateStreamingAssistantText(nil) @@ -1437,7 +1543,7 @@ public final class OpenClawChatViewModel { let sessionKey = self.sessionKey self.thinkingLevel = next - self.syncThinkingLevelOptions() + syncThinkingLevelOptions() self.updateCurrentSessionThinkingLevel(next, sessionKey: sessionKey) self.onThinkingLevelChanged?(next) self.nextThinkingSelectionRequestID &+= 1 @@ -1618,7 +1724,7 @@ public final class OpenClawChatViewModel { private func generatedNewSessionKey() -> String { let baseKey = "ios-\(UUID().uuidString.lowercased())" guard let agentID = Self.agentID(fromSessionKey: sessionKey) ?? - self.activeAgentId ?? + activeAgentId ?? Self.agentID(fromSessionKey: resolvedMainSessionKey) ?? sessions.lazy.compactMap({ Self.agentID(fromSessionKey: $0.key) }).first else { @@ -1627,7 +1733,7 @@ public final class OpenClawChatViewModel { return "agent:\(agentID):\(baseKey)" } - private static func agentID(fromSessionKey sessionKey: String) -> String? { + static func agentID(fromSessionKey sessionKey: String) -> String? { let parts = sessionKey .trimmingCharacters(in: .whitespacesAndNewlines) .split(separator: ":", omittingEmptySubsequences: false) @@ -1650,7 +1756,7 @@ public final class OpenClawChatViewModel { sessionKey: sessionKey, syncSelection: syncSelection) if sessionKey == self.sessionKey { - self.syncThinkingLevelOptions() + syncThinkingLevelOptions() } } @@ -1795,7 +1901,7 @@ public final class OpenClawChatViewModel { private func handleTransportEvent(_ evt: OpenClawChatTransportEvent) { switch evt { case let .health(ok): - self.applyTransportHealth(ok) + applyTransportHealth(ok) case .tick: let context = self.currentSessionSnapshot() Task { await self.pollHealthIfNeeded(force: false, sessionSnapshot: context) } @@ -1821,15 +1927,17 @@ public final class OpenClawChatViewModel { } private func handleSessionMessageEvent(_ payload: OpenClawSessionMessageEventPayload) { - if let sessionKey = payload.sessionKey, - !self.matchesCurrentSessionKey(incoming: sessionKey, agentId: payload.agentId, current: self.sessionKey) - { - return - } - guard let message = payload.message else { return } - let sanitized = Self.stripInboundMetadata(from: message) + let isCurrentSession = payload.sessionKey.map { + self.matchesCurrentSessionKey(incoming: $0, agentId: payload.agentId, current: self.sessionKey) + } ?? true + // Confirmation is gateway-scoped, not presentation-scoped. A flush + // can drain session A while session B is visible, and A's event must + // still retire its durable row before this handler returns early. + confirmOutboxCommands(in: [sanitized]) + guard isCurrentSession else { return } + self.invalidateHistorySnapshots() // The active client also receives the gateway's echo of the user turn it // just sent. performSend already appended an optimistic row carrying a @@ -1838,17 +1946,17 @@ public final class OpenClawChatViewModel { // Adopt the server record onto the exactly correlated row even when the // run's final event already cleared pending state. Same-content turns // without this key remain distinct. - if self.adoptCorrelatedUserMessage(incoming: sanitized) { + if adoptCorrelatedUserMessage(incoming: sanitized) { return } - if self.adoptProvisionalFinalMessage(incoming: sanitized) { + if adoptProvisionalFinalMessage(incoming: sanitized) { return } let reconciled = Self.reconcileMessageIDs(previous: self.messages, incoming: self.messages + [sanitized]) - self.replaceMessages(Self.dedupeMessages(reconciled)) - self.pruneProvisionalFinalMessages() - self.pruneRunMessageScopes() + replaceMessages(Self.dedupeMessages(reconciled)) + pruneProvisionalFinalMessages() + pruneRunMessageScopes() } private func handleChatEvent(_ chat: OpenClawChatEventPayload) { @@ -1960,14 +2068,14 @@ public final class OpenClawChatViewModel { } let runId = Self.normalizedRunID(chat.runId) - let scope = self.runMessageScope(for: runId) + let scope = runMessageScope(for: runId) guard self.isCurrentSession(scope.session) else { return } guard let reconciliationKey = Self.finalMessageReconciliationKey(for: message) else { return } - if let runId, self.hasRecordedFinalMessage(runId: runId) { + if let runId, hasRecordedFinalMessage(runId: runId) { return } - if self.hasCanonicalFinalMessageMatching(message, scope: scope) { + if hasCanonicalFinalMessageMatching(message, scope: scope) { if let runId { self.runMessageScopesByRunID.removeValue(forKey: runId) } @@ -1975,15 +2083,15 @@ public final class OpenClawChatViewModel { } let reconciled = Self.reconcileMessageIDs(previous: self.messages, incoming: self.messages + [message]) - self.replaceMessages(Self.dedupeMessages(reconciled)) + replaceMessages(Self.dedupeMessages(reconciled)) if self.messages.contains(where: { $0.id == message.id }) { self.provisionalFinalMessagesByID[message.id] = ProvisionalFinalMessage( reconciliationKey: reconciliationKey, runId: runId, scope: scope) } - self.pruneProvisionalFinalMessages() - self.pruneRunMessageScopes() + pruneProvisionalFinalMessages() + pruneRunMessageScopes() } static func isAssistantMessage(_ message: OpenClawChatMessage) -> Bool { @@ -2199,7 +2307,7 @@ public final class OpenClawChatViewModel { } self.logDiagnostic(diagnostic) let historyContext = self.beginHistoryRequest(for: sessionSnapshot) - let refresh = await self.refreshHistoryAfterRun(historyRequest: historyContext) + let refresh = await refreshHistoryAfterRun(historyRequest: historyContext) guard self.isCurrentSession(sessionSnapshot), self.pendingRuns.contains(runId) else { @@ -2219,7 +2327,7 @@ public final class OpenClawChatViewModel { @discardableResult private func clearPendingRunIfAssistantMessagePresent(runId: String, after timestamp: Double) -> Bool { - guard let hapticEvent = self.assistantHapticEvent(after: timestamp) else { return false } + guard let hapticEvent = assistantHapticEvent(after: timestamp) else { return false } self.clearPendingRun(runId, hapticEvent: hapticEvent) self.pendingToolCallsById = [:] self.updateStreamingAssistantText(nil) @@ -2248,7 +2356,7 @@ public final class OpenClawChatViewModel { } guard let refreshKey = userRefreshIdentityKey(for: messages[userIndex]) else { return LatestUserTurn( - idempotencyKey: self.normalizedIdempotencyKey(messages[userIndex].idempotencyKey), + idempotencyKey: normalizedIdempotencyKey(messages[userIndex].idempotencyKey), refreshKey: nil, occurrence: 0, timestamp: messages[userIndex].timestamp) @@ -2284,7 +2392,7 @@ public final class OpenClawChatViewModel { var occurrence = 0 var latestMatchingUserIndex: [OpenClawChatMessage].Index? for (index, message) in messages.enumerated() { - guard self.userRefreshIdentityKey(for: message) == refreshKey else { continue } + guard userRefreshIdentityKey(for: message) == refreshKey else { continue } occurrence += 1 latestMatchingUserIndex = index guard occurrence == user.occurrence else { continue } @@ -2347,7 +2455,7 @@ public final class OpenClawChatViewModel { } private func assistantHapticEventAfterLatestUser() -> OpenClawChatHaptics.Event? { - guard let userIndex = self.messages.lastIndex(where: { $0.role.lowercased() == "user" }) else { return nil } + guard let userIndex = messages.lastIndex(where: { $0.role.lowercased() == "user" }) else { return nil } let nextIndex = self.messages.index(after: userIndex) guard nextIndex < self.messages.endIndex else { return nil } return self.messages[nextIndex...].reversed().lazy.compactMap(Self.assistantHapticEvent).first @@ -2360,10 +2468,49 @@ public final class OpenClawChatViewModel { }.first } - /// Narrow seam for the outbox extension: pull durable history after a - /// flush so acked commands reconcile with their queued bubbles. - func refreshHistoryAfterOutboxFlush() async { - await self.refreshHistoryAfterRun() + /// Pull canonical history for every session touched by one route-bound + /// outbox pass. Background sessions only retire confirmed rows; the + /// visible session also runs the normal reconciliation/cache pipeline. + func refreshHistoriesAfterOutboxFlush( + targets: Set, + routeLease: OpenClawChatTransportRouteLease) async + { + let sortedTargets = targets.sorted { lhs, rhs in + if lhs.deliverySessionKey != rhs.deliverySessionKey { + return lhs.deliverySessionKey < rhs.deliverySessionKey + } + return (lhs.agentID ?? "") < (rhs.agentID ?? "") + } + for target in sortedTargets { + let visibleRequest = matchesCurrentSessionKey( + incoming: target.presentationSessionKey, + agentId: target.agentID, + current: self.sessionKey) + ? self.beginHistoryRequest() + : nil + do { + let payload = try await routeLease.requestHistory( + sessionKey: target.deliverySessionKey, + agentID: target.agentID) + let incoming = Self.decodeMessages(payload.messages ?? []) + await confirmOutboxCommandsNow(in: incoming) + if let visibleRequest { + _ = self.applyHistoryPayload( + payload, + for: visibleRequest, + preservingOptimisticLocalMessages: true) + } + } catch is CancellationError { + // The gateway route changed during confirmation. Keep every + // unconfirmed row durable for a later matching reconnect. + applyTransportHealth(false) + return + } catch { + self.logDiagnostic( + "chat.ui outbox history failed sessionKey=\(target.deliverySessionKey) " + + "error=\(error.localizedDescription)") + } + } } @discardableResult diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift index cf360f6849d..f4cf5e6a7c8 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift @@ -48,6 +48,12 @@ public struct GatewayNodeSessionRoute: Sendable, Equatable { fileprivate let channelGeneration: UInt64 } +/// A route lease became stale before its request touched the channel. Unlike +/// a socket cancellation, this proves the payload was never dispatched. +public enum GatewayNodeSessionRequestError: Error, Sendable { + case routeChangedBeforeDispatch +} + public actor GatewayNodeSession { private let logger = Logger(subsystem: "ai.openclaw", category: "node.gateway") private let decoder = JSONDecoder() @@ -68,6 +74,7 @@ public actor GatewayNodeSession { private var hasEverConnected = false private var hasNotifiedConnected = false private var snapshotReceived = false + private var serverCapabilities: Set? private var snapshotWaiters: [CheckedContinuation] = [] static func invokeWithTimeout( @@ -267,7 +274,7 @@ public actor GatewayNodeSession { channelGeneration = self.channelGeneration } - guard let channel = self.channel else { + guard let channel else { throw NSError(domain: "Gateway", code: 0, userInfo: [ NSLocalizedDescriptionKey: "gateway channel unavailable", ]) @@ -314,7 +321,7 @@ public actor GatewayNodeSession { @discardableResult public func refreshPluginSurfaceUrl(surface: String, timeoutSeconds: Int = 8) async -> String? { - guard let channel = self.channel else { return nil } + guard let channel else { return nil } let trimmedSurface = surface.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedSurface.isEmpty else { return nil } @@ -332,7 +339,7 @@ public actor GatewayNodeSession { } public func currentRemoteAddress() -> String? { - guard let url = self.activeURL else { return nil } + guard let url = activeURL else { return nil } guard let host = url.host else { return url.absoluteString } let port = url.port ?? (url.scheme == "wss" ? 443 : 80) if host.contains(":") { @@ -341,11 +348,28 @@ public actor GatewayNodeSession { return "\(host):\(port)" } - public func currentRoute() -> GatewayNodeSessionRoute? { + public func currentRoute(ifGatewayID expectedGatewayID: String? = nil) -> GatewayNodeSessionRoute? { guard self.channel != nil else { return nil } + if let expectedGatewayID { + let expected = expectedGatewayID.trimmingCharacters(in: .whitespacesAndNewlines) + let current = self.connectOptions?.deviceAuthGatewayID? + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !expected.isEmpty, current == expected else { return nil } + } return GatewayNodeSessionRoute(channelGeneration: self.channelGeneration) } + public func supportsServerCapability( + _ capability: GatewayServerCapability, + ifCurrentRoute expectedRoute: GatewayNodeSessionRoute) -> Bool? + { + guard expectedRoute.channelGeneration == self.channelGeneration, + self.channel != nil, + let serverCapabilities + else { return nil } + return serverCapabilities.contains(capability) + } + @discardableResult public func sendEvent( event: String, @@ -355,7 +379,7 @@ public actor GatewayNodeSession { if let expectedRoute, expectedRoute.channelGeneration != self.channelGeneration { return false } - guard let channel = self.channel else { return false } + guard let channel else { return false } let params: [String: AnyCodable] = [ "event": AnyCodable(event), "payloadJSON": AnyCodable(payloadJSON ?? NSNull()), @@ -370,13 +394,13 @@ public actor GatewayNodeSession { } public func send(method: String, paramsJSON: String?) async throws { - guard let channel = self.channel else { + guard let channel else { throw NSError(domain: "Gateway", code: 11, userInfo: [ NSLocalizedDescriptionKey: "not connected", ]) } - let params = try self.decodeParamsJSON(paramsJSON) + let params = try decodeParamsJSON(paramsJSON) try await channel.send(method: method, params: params) } @@ -384,18 +408,22 @@ public actor GatewayNodeSession { method: String, paramsJSON: String?, timeoutSeconds: Int = 15, - ifCurrentRoute expectedRoute: GatewayNodeSessionRoute? = nil) async throws -> Data + ifCurrentRoute expectedRoute: GatewayNodeSessionRoute? = nil, + distinguishPreDispatchRouteChange: Bool = false) async throws -> Data { if let expectedRoute, expectedRoute.channelGeneration != self.channelGeneration { + if distinguishPreDispatchRouteChange { + throw GatewayNodeSessionRequestError.routeChangedBeforeDispatch + } throw CancellationError() } - guard let channel = self.channel else { + guard let channel else { throw NSError(domain: "Gateway", code: 11, userInfo: [ NSLocalizedDescriptionKey: "not connected", ]) } - let params = try self.decodeParamsJSON(paramsJSON) + let params = try decodeParamsJSON(paramsJSON) return try await channel.request( method: method, params: params, @@ -418,6 +446,8 @@ public actor GatewayNodeSession { switch push { case let .snapshot(ok): self.pluginSurfaceUrls = self.normalizePluginSurfaceUrls(ok.pluginsurfaceurls) + self.serverCapabilities = Set( + GatewayServerCapability.allCases.filter { ok.supportsServerCapability($0) }) if self.hasEverConnected { self.broadcastServerEvent( EventFrame(type: "event", event: "seqGap", payload: nil, seq: nil, stateversion: nil)) @@ -426,7 +456,7 @@ public actor GatewayNodeSession { self.markSnapshotReceived() await self.notifyConnectedIfNeeded() case let .event(evt): - guard let channel = self.channel else { return } + guard let channel else { return } await self.handleEvent( evt, channel: channel, @@ -439,6 +469,7 @@ public actor GatewayNodeSession { private func resetConnectionState() { self.hasNotifiedConnected = false self.snapshotReceived = false + self.serverCapabilities = nil self.drainSnapshotWaiters(returning: false) } @@ -515,7 +546,7 @@ public actor GatewayNodeSession { method: method, params: params, timeoutMs: Double(timeoutSeconds * 1000)) - let decoded = try self.decoder.decode(PluginSurfaceRefreshResponse.self, from: data) + let decoded = try decoder.decode(PluginSurfaceRefreshResponse.self, from: data) let urls = self.normalizePluginSurfaceUrls(decoded.pluginSurfaceUrls) guard let refreshed = urls[surface] else { return nil } self.pluginSurfaceUrls[surface] = refreshed @@ -536,7 +567,7 @@ public actor GatewayNodeSession { self.logger.info("node invoke request received") guard let payload = evt.payload else { return } do { - let request = try self.decodeInvokeRequest(from: payload) + let request = try decodeInvokeRequest(from: payload) let timeoutLabel = request.timeoutMs.map(String.init) ?? "none" self.logger.info( "node invoke request decoded id=\(request.id, privacy: .public) command=\(request.command, privacy: .public) timeoutMs=\(timeoutLabel, privacy: .public)") @@ -587,7 +618,7 @@ public actor GatewayNodeSession { private func decodeInvokeRequest(from payload: OpenClawProtocol.AnyCodable) throws -> NodeInvokeRequestPayload { do { - let data = try self.encoder.encode(payload) + let data = try encoder.encode(payload) return try self.decoder.decode(NodeInvokeRequestPayload.self, from: data) } catch { if let raw = payload.value as? String, let data = raw.data(using: .utf8) { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPush.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPush.swift index 65e118ff14e..48fb1e0d390 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPush.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPush.swift @@ -1,5 +1,16 @@ import OpenClawProtocol +public enum GatewayServerCapability: String, CaseIterable, Sendable { + case chatSendRoutingContract = "chat-send-routing-contract" +} + +extension HelloOk { + public func supportsServerCapability(_ capability: GatewayServerCapability) -> Bool { + let values = features["capabilities"]?.value as? [AnyCodable] ?? [] + return values.contains { ($0.value as? String) == capability.rawValue } + } +} + /// Server-push messages from the gateway websocket. /// /// This is the in-process replacement for the legacy `NotificationCenter` fan-out. diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index de05bb94e9a..a35be15866d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -8556,6 +8556,7 @@ public struct ChatSendParams: Codable, Sendable { public let systeminputprovenance: [String: AnyCodable]? public let systemprovenancereceipt: String? public let suppresscommandinterpretation: Bool? + public let expectedsessionroutingcontract: String? public let idempotencykey: String public init( @@ -8576,6 +8577,7 @@ public struct ChatSendParams: Codable, Sendable { systeminputprovenance: [String: AnyCodable]?, systemprovenancereceipt: String?, suppresscommandinterpretation: Bool?, + expectedsessionroutingcontract: String?, idempotencykey: String) { self.sessionkey = sessionkey @@ -8595,6 +8597,7 @@ public struct ChatSendParams: Codable, Sendable { self.systeminputprovenance = systeminputprovenance self.systemprovenancereceipt = systemprovenancereceipt self.suppresscommandinterpretation = suppresscommandinterpretation + self.expectedsessionroutingcontract = expectedsessionroutingcontract self.idempotencykey = idempotencykey } @@ -8615,6 +8618,7 @@ public struct ChatSendParams: Codable, Sendable { systeminputprovenance: [String: AnyCodable]?, systemprovenancereceipt: String?, suppresscommandinterpretation: Bool?, + expectedsessionroutingcontract: String?, idempotencykey: String) { self.init( @@ -8635,6 +8639,7 @@ public struct ChatSendParams: Codable, Sendable { systeminputprovenance: systeminputprovenance, systemprovenancereceipt: systemprovenancereceipt, suppresscommandinterpretation: suppresscommandinterpretation, + expectedsessionroutingcontract: expectedsessionroutingcontract, idempotencykey: idempotencykey) } @@ -8656,6 +8661,7 @@ public struct ChatSendParams: Codable, Sendable { case systeminputprovenance = "systemInputProvenance" case systemprovenancereceipt = "systemProvenanceReceipt" case suppresscommandinterpretation = "suppressCommandInterpretation" + case expectedsessionroutingcontract = "expectedSessionRoutingContract" case idempotencykey = "idempotencyKey" } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptCacheStoreTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptCacheStoreTests.swift index e4af662dadb..9be2062c503 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptCacheStoreTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptCacheStoreTests.swift @@ -77,6 +77,25 @@ private func outboxCommand( } struct ChatTranscriptCacheStoreTests { + @Test func `verified routing identity survives a cold store reopen`() async throws { + let url = try makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let identity = try #require(OpenClawChatSessionRoutingIdentity( + scope: " Per-Sender ", + mainSessionKey: " Work ", + defaultAgentID: " Main ")) + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + + await store.storeSessionRoutingIdentity(identity) + #expect(OpenClawChatSQLiteTranscriptCache.loadSessionRoutingIdentity( + databaseURL: url, + gatewayID: "gw-a") == identity) + + let reopened = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + #expect(await reopened.loadSessionRoutingIdentity() == identity) + #expect(identity.contract == "per-sender|work|main") + } + @Test func `transcript and sessions round trip`() async throws { let url = try makeDatabaseURL() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } @@ -178,6 +197,46 @@ struct ChatTranscriptCacheStoreTests { #expect(await messageTexts(storeB.loadTranscript(sessionKey: "main")) == ["gateway B"]) } + @Test func `global transcripts are scoped per agent identity`() async throws { + let url = try makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + + await store.storeTranscript( + sessionKey: "global", + agentID: "agent-a", + messages: [cacheMessage( + role: "user", + text: "agent A", + timestamp: 1, + idempotencyKey: "c-agent-a:user")]) + await store.storeTranscript( + sessionKey: "global", + agentID: "agent-b", + messages: [cacheMessage(role: "user", text: "agent B", timestamp: 2)]) + + let agentAMessages = await store.loadTranscript(sessionKey: "global", agentID: "agent-a") + let agentBMessages = await store.loadTranscript(sessionKey: "global", agentID: "agent-b") + #expect(messageTexts(agentAMessages) == ["agent A"]) + #expect(messageTexts(agentBMessages) == ["agent B"]) + #expect(await store.loadTranscript(sessionKey: "global").isEmpty) + + #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( + id: "c-agent-a", + sessionKey: "global", + agentID: "agent-a", + text: "agent A", + thinking: "off", + createdAt: Date().timeIntervalSince1970, + status: .queued, + retryCount: 0, + lastError: nil))) + #expect(await store.cancelCommand(id: "c-agent-a") == .updated) + #expect(await store.loadTranscript(sessionKey: "global", agentID: "agent-a").isEmpty) + let survivingAgentBMessages = await store.loadTranscript(sessionKey: "global", agentID: "agent-b") + #expect(messageTexts(survivingAgentBMessages) == ["agent B"]) + } + @Test func `empty transcript store clears cached row`() async throws { let url = try makeDatabaseURL() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } @@ -277,7 +336,7 @@ struct ChatTranscriptCacheStoreTests { #expect(items[1].content == nil) } - @Test func `schema version mismatch drops and rebuilds silently`() async throws { + @Test func `v1 transcript cache migrates to durable outbox schema`() async throws { let url = try makeDatabaseURL() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } do { @@ -285,6 +344,282 @@ struct ChatTranscriptCacheStoreTests { await store.storeTranscript( sessionKey: "main", messages: [cacheMessage(role: "user", text: "old-schema", timestamp: 1)]) + await store.retire() + } + + var raw: OpaquePointer? + #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) + #expect(sqlite3_exec(raw, "DROP TABLE outbox_commands", nil, nil, nil) == SQLITE_OK) + #expect(sqlite3_exec(raw, "PRAGMA user_version = 1", nil, nil, nil) == SQLITE_OK) + sqlite3_close_v2(raw) + + let migrated = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + #expect(await messageTexts(migrated.loadTranscript(sessionKey: "main")) == ["old-schema"]) + #expect(await migrated.enqueueCommand(outboxCommand(id: "c-1", text: "preserved migration"))) + #expect(await migrated.loadCommands().map(\.text) == ["preserved migration"]) + } + + @Test func `v2 migration parks rows without a routing contract`() async throws { + let url = try makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let now = Date().timeIntervalSince1970 + do { + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( + id: "c-global", + sessionKey: "global", + agentID: "agent-a", + text: "targeted before downgrade", + thinking: "off", + createdAt: now, + status: .queued, + retryCount: 0, + lastError: nil))) + #expect(await store.enqueueCommand(outboxCommand( + id: "c-main", + sessionKey: "main", + text: "mutable main alias", + createdAt: now + 0.5))) + #expect(await store.enqueueCommand(outboxCommand( + id: "c-scoped", + sessionKey: "agent:agent-a:main", + text: "already scoped", + createdAt: now + 1))) + #expect(await store.enqueueCommand(outboxCommand( + id: "c-matrix", + sessionKey: "agent:agent-a:matrix:channel:!MixedRoomAbCdEf:example.org", + text: "case-sensitive room", + createdAt: now + 2))) + #expect(await store.enqueueCommand(outboxCommand( + id: "c-empty-agent", + sessionKey: "agent::main", + text: "missing agent", + createdAt: now + 3))) + #expect(await store.enqueueCommand(outboxCommand( + id: "c-empty-rest", + sessionKey: "agent:agent-a:", + text: "missing session", + createdAt: now + 4))) + await store.retire() + } + + var raw: OpaquePointer? + #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) + #expect(sqlite3_exec( + raw, + "UPDATE outbox_commands SET status = 'sending' WHERE client_uuid = 'c-scoped'", + nil, + nil, + nil) == SQLITE_OK) + #expect(sqlite3_exec( + raw, + "UPDATE outbox_commands SET status = 'awaiting_confirmation' WHERE client_uuid = 'c-matrix'", + nil, + nil, + nil) == SQLITE_OK) + #expect(sqlite3_exec(raw, "ALTER TABLE outbox_commands DROP COLUMN agent_id", nil, nil, nil) == SQLITE_OK) + #expect(sqlite3_exec( + raw, + "ALTER TABLE outbox_commands DROP COLUMN delivery_session_key", + nil, + nil, + nil) == SQLITE_OK) + #expect(sqlite3_exec( + raw, + "ALTER TABLE outbox_commands DROP COLUMN routing_contract", + nil, + nil, + nil) == SQLITE_OK) + #expect(sqlite3_exec(raw, "PRAGMA user_version = 2", nil, nil, nil) == SQLITE_OK) + sqlite3_close_v2(raw) + + let migrated = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + let commands = await migrated.loadCommands() + #expect(commands.map(\.agentID) == [nil, nil, nil, nil, nil, nil]) + #expect(commands.map(\.deliverySessionKey) == [ + "", + "", + "", + "", + "", + "", + ]) + #expect(commands.map(\.status) == [.failed, .failed, .failed, .failed, .failed, .failed]) + #expect(commands.map(\.lastError) == [ + OpenClawChatSQLiteTranscriptCache.outboxUnknownTargetError, + OpenClawChatSQLiteTranscriptCache.outboxUnknownTargetError, + OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError, + OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError, + OpenClawChatSQLiteTranscriptCache.outboxUnknownTargetError, + OpenClawChatSQLiteTranscriptCache.outboxUnknownTargetError, + ]) + #expect(await migrated.markCommandRetriedIfPresent( + id: "c-global", + agentID: "agent-b", + deliverySessionKey: "global", + routingContract: "per-sender|main|agent-b") == .updated) + let retried = await migrated.loadCommands().first { $0.id == "c-global" } + #expect(retried?.agentID == "agent-b") + #expect(retried?.deliverySessionKey == "global") + #expect(retried?.routingContract == "per-sender|main|agent-b") + #expect(retried?.status == .queued) + } + + @Test func `unknown failed command can retry without an agent`() async throws { + let url = try makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( + id: "c-unknown", + sessionKey: "unknown", + deliverySessionKey: "unknown", + routingContract: "per-sender|main|main", + agentID: nil, + text: "retry reserved", + thinking: "off", + createdAt: Date().timeIntervalSince1970, + status: .failed, + retryCount: 1, + lastError: "failed"))) + #expect(await store.loadCommands().first?.lastError == "failed") + + #expect(await store.markCommandRetriedIfPresent( + id: "c-unknown", + agentID: nil, + deliverySessionKey: "unknown", + routingContract: "per-sender|main|main") == .updated) + let command = try #require(await store.loadCommands().first) + #expect(command.status == .queued) + #expect(command.agentID == nil) + #expect(command.deliverySessionKey == "unknown") + } + + @Test func `retargeted retry removes optimistic row from previous agent cache`() async throws { + let url = try makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + await store.storeTranscript( + sessionKey: "main", + agentID: "agent-a", + messages: [ + cacheMessage( + role: "user", + text: "old owner", + timestamp: 1, + idempotencyKey: "c-retarget:user"), + ]) + #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( + id: "c-retarget", + sessionKey: "main", + deliverySessionKey: "agent:agent-a:main", + routingContract: "per-sender|main|agent-a", + agentID: "agent-a", + text: "old owner", + thinking: "off", + createdAt: Date().timeIntervalSince1970, + status: .failed, + retryCount: 1, + lastError: "changed"))) + + #expect(await store.markCommandRetriedIfPresent( + id: "c-retarget", + agentID: "agent-b", + deliverySessionKey: "agent:agent-b:main", + routingContract: "per-sender|main|agent-b") == .updated) + #expect(await store.loadTranscript(sessionKey: "main", agentID: "agent-a").isEmpty) + let command = try #require(await store.loadCommands().first) + #expect(command.agentID == "agent-b") + #expect(command.status == .queued) + } + + @Test func `v3 migration preserves delivery ambiguity without a routing contract`() async throws { + let url = try makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + do { + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( + id: "c-v3", + sessionKey: "main", + deliverySessionKey: "agent:agent-a:main", + routingContract: "per-sender|main|agent-a", + agentID: "agent-a", + text: "park after downgrade", + thinking: "off", + createdAt: Date().timeIntervalSince1970, + status: .queued, + retryCount: 0, + lastError: nil))) + #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( + id: "c-v3-acked", + sessionKey: "main", + deliverySessionKey: "agent:agent-a:main", + routingContract: "per-sender|main|agent-a", + agentID: "agent-a", + text: "possibly delivered", + thinking: "off", + createdAt: Date().timeIntervalSince1970 + 1, + status: .awaitingConfirmation, + retryCount: 0, + lastError: nil))) + await store.retire() + } + + var raw: OpaquePointer? + #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) + #expect(sqlite3_exec( + raw, + "ALTER TABLE outbox_commands DROP COLUMN routing_contract", + nil, + nil, + nil) == SQLITE_OK) + #expect(sqlite3_exec(raw, "PRAGMA user_version = 3", nil, nil, nil) == SQLITE_OK) + sqlite3_close_v2(raw) + + let migrated = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + let commands = await migrated.loadCommands() + #expect(commands.map(\.status) == [.failed, .failed]) + #expect(commands.map(\.agentID) == [nil, nil]) + #expect(commands.map(\.deliverySessionKey) == ["", ""]) + #expect(commands.map(\.routingContract) == [nil, nil]) + #expect(commands.map(\.lastError) == [ + OpenClawChatSQLiteTranscriptCache.outboxUnknownTargetError, + OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError, + ]) + } + + @Test func `v4 migration adds routing identity without touching outbox`() async throws { + let url = try makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + do { + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + #expect(await store.enqueueCommand(outboxCommand(id: "c-v4", text: "preserve me"))) + await store.retire() + } + + var raw: OpaquePointer? + #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) + #expect(sqlite3_exec(raw, "DROP TABLE gateway_routing_identity", nil, nil, nil) == SQLITE_OK) + #expect(sqlite3_exec(raw, "PRAGMA user_version = 4", nil, nil, nil) == SQLITE_OK) + sqlite3_close_v2(raw) + + let migrated = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + #expect(await migrated.loadCommands().map(\.id) == ["c-v4"]) + #expect(await migrated.loadSessionRoutingIdentity() == nil) + let identity = try #require(OpenClawChatSessionRoutingIdentity( + scope: "global", + mainSessionKey: "main", + defaultAgentID: "main")) + await migrated.storeSessionRoutingIdentity(identity) + #expect(await migrated.loadSessionRoutingIdentity() == identity) + } + + @Test func `unknown schema preserves durable outbox bytes and fails closed`() async throws { + let url = try makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + do { + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + #expect(await store.enqueueCommand(outboxCommand(id: "c-1", text: "do not delete"))) + await store.retire() } var raw: OpaquePointer? @@ -292,27 +627,38 @@ struct ChatTranscriptCacheStoreTests { #expect(sqlite3_exec(raw, "PRAGMA user_version = 99", nil, nil, nil) == SQLITE_OK) sqlite3_close_v2(raw) - let rebuilt = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await rebuilt.loadTranscript(sessionKey: "main").isEmpty) + let blocked = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + #expect(await blocked.loadCommands().isEmpty) + #expect(await !blocked.enqueueCommand(outboxCommand(id: "c-2", text: "must fail closed"))) - // Rebuilt store is fully functional again. - await rebuilt.storeTranscript( - sessionKey: "main", - messages: [cacheMessage(role: "user", text: "fresh", timestamp: 2)]) - #expect(await messageTexts(rebuilt.loadTranscript(sessionKey: "main")) == ["fresh"]) + // Restore the known version to prove the blocked open did not delete + // or rebuild the persistent outbox table. + raw = nil + #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) + #expect(sqlite3_exec( + raw, + "PRAGMA user_version = \(OpenClawChatSQLiteTranscriptCache.schemaVersion)", + nil, + nil, + nil) == SQLITE_OK) + sqlite3_close_v2(raw) + let recovered = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + #expect(await recovered.loadCommands().map(\.text) == ["do not delete"]) } - @Test func `corrupt database file drops and rebuilds silently`() async throws { + @Test func `corrupt existing database is preserved and fails closed`() async throws { let url = try makeDatabaseURL() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - try Data("this is not a sqlite database".utf8).write(to: url) + let original = Data("this is not a sqlite database".utf8) + try original.write(to: url) let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") #expect(await store.loadTranscript(sessionKey: "main").isEmpty) await store.storeTranscript( sessionKey: "main", messages: [cacheMessage(role: "user", text: "recovered", timestamp: 1)]) - #expect(await messageTexts(store.loadTranscript(sessionKey: "main")) == ["recovered"]) + #expect(await store.loadTranscript(sessionKey: "main").isEmpty) + #expect(try Data(contentsOf: url) == original) } @Test func `undecodable row is dropped and treated as miss`() async throws { @@ -366,35 +712,206 @@ struct ChatCommandOutboxStoreTests { #expect(loaded.map(\.sessionKey) == ["main", "main"]) } - @Test func `claiming a deleted command returns false`() async throws { + @Test func `claims are FIFO and exclusive until the sender advances`() async throws { let url = try makeDatabaseURL() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await store.enqueueCommand(outboxCommand(id: "c-1", text: "kept"))) - #expect(await store.markCommandSending(id: "c-1")) + let now = Date().timeIntervalSince1970 + #expect(await store.enqueueCommand(outboxCommand(id: "c-1", text: "first", createdAt: now))) + #expect(await store.enqueueCommand(outboxCommand(id: "c-2", text: "second", createdAt: now + 1))) - // Deleted (or never-existing) rows must refuse the claim so a flush - // pass working from a stale snapshot cannot send them. - await store.deleteCommand(id: "c-1") - #expect(await store.markCommandSending(id: "c-1") == false) - #expect(await store.markCommandSending(id: "never-existed") == false) + #expect(await store.claimNextCommand()?.id == "c-1") + #expect(await store.claimNextCommand() == nil) + #expect(await store.markCommandAwaitingConfirmation(id: "c-1") == .updated) + #expect(await store.claimNextCommand()?.id == "c-2") } - @Test func `interrupted sending rows revert to queued on recovery`() async throws { + @Test func `user cancellation cannot delete a claimed command`() async throws { + let url = try makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + #expect(await store.enqueueCommand(outboxCommand(id: "c-1", text: "claimed"))) + #expect(await store.claimNextCommand()?.id == "c-1") + + #expect(await store.cancelCommand(id: "c-1") == .missing) + #expect(await store.loadCommands().map(\.status) == [.sending]) + #expect(await store.confirmCommand(id: "c-1") == .updated) + #expect(await store.loadCommands().isEmpty) + } + + @Test func `queued cancellation atomically scrubs and suppresses its cached bubble`() async throws { + let url = try makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + #expect(await store.enqueueCommand(outboxCommand(id: "c-cancel", text: "cancel me"))) + let staleSnapshot = [ + cacheMessage( + role: "user", + text: "cancel me", + timestamp: 1, + idempotencyKey: "c-cancel:user"), + cacheMessage( + role: "assistant", + text: "newer row", + timestamp: 2, + idempotencyKey: "other-run"), + ] + await store.storeTranscript(sessionKey: "main", messages: staleSnapshot) + + #expect(await store.cancelCommand(id: "c-cancel") == .updated) + #expect(await messageTexts(store.loadTranscript(sessionKey: "main")) == ["newer row"]) + + // An overlapping view can finish a stale whole-transcript write after + // cancellation. The cache owner keeps the canceled UUID suppressed. + await store.storeTranscript(sessionKey: "main", messages: staleSnapshot) + #expect(await messageTexts(store.loadTranscript(sessionKey: "main")) == ["newer row"]) + + // Canonical history can prove an ambiguous send really landed. That + // authoritative row clears suppression and remains cacheable offline. + await store.storeCanonicalTranscript( + sessionKey: "main", + messages: staleSnapshot, + canonicalMessageIdempotencyKeys: ["c-cancel:user"]) + #expect(await messageTexts(store.loadTranscript(sessionKey: "main")) == ["cancel me", "newer row"]) + + #expect(await store.enqueueCommand(outboxCommand(id: "c-canonical-first", text: "already landed"))) + let canonicalFirst = [cacheMessage( + role: "user", + text: "already landed", + timestamp: 3, + idempotencyKey: "c-canonical-first:user")] + await store.storeCanonicalTranscript( + sessionKey: "main", + messages: canonicalFirst, + canonicalMessageIdempotencyKeys: ["c-canonical-first:user"]) + #expect(await store.cancelCommand(id: "c-canonical-first") == .confirmed) + #expect(await messageTexts(store.loadTranscript(sessionKey: "main")) == ["already landed"]) + } + + @Test func `canonical message merge preserves a newer cached snapshot`() async throws { + let url = try makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + await store.storeTranscript(sessionKey: "main", messages: [ + cacheMessage(role: "assistant", text: "newer row", timestamp: 2, idempotencyKey: "newer-run"), + ]) + + await store.mergeCanonicalTranscriptMessage( + sessionKey: "main", + agentID: nil, + message: cacheMessage( + role: "user", + text: "confirmed row", + timestamp: 1, + idempotencyKey: "confirmed:user"), + canonicalMessageIdempotencyKey: "confirmed:user") + + #expect(await messageTexts(store.loadTranscript(sessionKey: "main")) == ["confirmed row", "newer row"]) + } + + @Test func `scoped cancellation scrubs the canonical transcript partition`() async throws { + let url = try makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + let sessionKey = "agent:agent-a:matrix:channel:!MixedRoomAbCdEf:example.org" + #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( + id: "c-scoped-cancel", + sessionKey: sessionKey, + agentID: "agent-a", + text: "cancel scoped", + thinking: "off", + createdAt: Date().timeIntervalSince1970, + status: .queued, + retryCount: 0, + lastError: nil))) + let staleSnapshot = [cacheMessage( + role: "user", + text: "cancel scoped", + timestamp: 1, + idempotencyKey: "c-scoped-cancel:user")] + await store.storeTranscript(sessionKey: sessionKey, messages: staleSnapshot) + + #expect(await store.cancelCommand(id: "c-scoped-cancel") == .updated) + #expect(await store.loadTranscript(sessionKey: sessionKey).isEmpty) + + await store.storeTranscript(sessionKey: sessionKey, messages: staleSnapshot) + #expect(await store.loadTranscript(sessionKey: sessionKey).isEmpty) + } + + @Test func `cancellation lookup failure preserves the queued command`() async throws { + let url = try makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + #expect(await store.enqueueCommand(outboxCommand(id: "c-lookup", text: "keep me"))) + + var raw: OpaquePointer? + #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) + #expect(sqlite3_exec( + raw, + "ALTER TABLE outbox_commands RENAME TO outbox_commands_unavailable", + nil, + nil, + nil) == SQLITE_OK) + sqlite3_close_v2(raw) + + #expect(await store.cancelCommand(id: "c-lookup") == .unavailable) + + raw = nil + #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) + #expect(sqlite3_exec( + raw, + "ALTER TABLE outbox_commands_unavailable RENAME TO outbox_commands", + nil, + nil, + nil) == SQLITE_OK) + sqlite3_close_v2(raw) + #expect(await store.loadCommands().map(\.id) == ["c-lookup"]) + } + + @Test func `transcript lookup failure rolls back queued cancellation`() async throws { + let url = try makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + #expect(await store.enqueueCommand(outboxCommand(id: "c-cache", text: "keep me"))) + await store.storeTranscript( + sessionKey: "main", + messages: [cacheMessage( + role: "user", + text: "keep me", + timestamp: 1, + idempotencyKey: "c-cache:user")]) + + var raw: OpaquePointer? + #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) + #expect(sqlite3_exec( + raw, + "ALTER TABLE cached_transcripts RENAME TO cached_transcripts_unavailable", + nil, + nil, + nil) == SQLITE_OK) + sqlite3_close_v2(raw) + + #expect(await store.cancelCommand(id: "c-cache") == .unavailable) + #expect(await store.loadCommands().map(\.id) == ["c-cache"]) + } + + @Test func `interrupted sending rows fail closed on recovery`() async throws { let url = try makeDatabaseURL() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } do { let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") #expect(await store.enqueueCommand(outboxCommand(id: "c-1", text: "in flight"))) - await store.markCommandSending(id: "c-1") + #expect(await store.claimNextCommand()?.id == "c-1") #expect(await store.loadCommands().map(\.status) == [.sending]) } - // Simulated crash mid-send: a fresh process recovers the row to - // queued; the idempotency key makes the re-send safe. + // Simulated crash mid-send: the durable result is ambiguous, so a + // fresh process requires explicit user retry instead of replaying it. let reopened = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") #expect(await reopened.recoverInterruptedSends()) - #expect(await reopened.loadCommands().map(\.status) == [.queued]) + let recovered = await reopened.loadCommands() + #expect(recovered.map(\.status) == [.failed]) + #expect(recovered.map(\.lastError) == [OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError]) // An unreachable store must report failure so callers do not burn // their once-per-launch recovery gate while the DB is locked. @@ -402,6 +919,102 @@ struct ChatCommandOutboxStoreTests { #expect(await !reopened.recoverInterruptedSends()) } + @Test func `failed post-claim transition reopens same-process recovery`() async throws { + let url = try makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + #expect(await store.enqueueCommand(outboxCommand(id: "c-claim", text: "recover me"))) + #expect(await store.recoverInterruptedSends()) + #expect(await store.claimNextCommand()?.id == "c-claim") + + var raw: OpaquePointer? + #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) + #expect(sqlite3_exec( + raw, + "ALTER TABLE outbox_commands RENAME TO outbox_commands_unavailable", + nil, + nil, + nil) == SQLITE_OK) + sqlite3_close_v2(raw) + + #expect(await store.markCommandAwaitingConfirmation(id: "c-claim") == .unavailable) + + raw = nil + #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) + #expect(sqlite3_exec( + raw, + "ALTER TABLE outbox_commands_unavailable RENAME TO outbox_commands", + nil, + nil, + nil) == SQLITE_OK) + sqlite3_close_v2(raw) + + #expect(await store.recoverInterruptedSends()) + #expect(await store.loadCommands().map(\.status) == [.failed]) + } + + @Test func `failed terminal transition reports unavailable and reopens recovery`() async throws { + let url = try makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + #expect(await store.enqueueCommand(outboxCommand(id: "c-terminal", text: "stop retrying"))) + #expect(await store.recoverInterruptedSends()) + #expect(await store.claimNextCommand()?.id == "c-terminal") + + var raw: OpaquePointer? + #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) + #expect(sqlite3_exec( + raw, + "ALTER TABLE outbox_commands RENAME TO outbox_commands_unavailable", + nil, + nil, + nil) == SQLITE_OK) + sqlite3_close_v2(raw) + + #expect( + await store.markCommandFailedIfPresent(id: "c-terminal", retryCount: 3, lastError: "rejected") == + .unavailable) + + raw = nil + #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) + #expect(sqlite3_exec( + raw, + "ALTER TABLE outbox_commands_unavailable RENAME TO outbox_commands", + nil, + nil, + nil) == SQLITE_OK) + sqlite3_close_v2(raw) + + #expect(await store.recoverInterruptedSends()) + let command = await store.loadCommands().first + #expect(command?.status == .failed) + #expect(command?.retryCount == 0) + #expect(command?.lastError == OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError) + } + + @Test func `unknown row status is skipped without blocking later commands`() async throws { + let url = try makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + #expect(await store.enqueueCommand(outboxCommand(id: "c-valid", text: "send me", createdAt: 2))) + + var raw: OpaquePointer? + #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) + #expect(sqlite3_exec( + raw, + """ + INSERT INTO outbox_commands( + client_uuid, gateway_id, session_key, text, thinking, created_at, status, retry_count, last_error + ) VALUES ('c-unknown', 'gw-a', 'main', 'skip me', 'off', 1, 'future_status', 0, '') + """, + nil, + nil, + nil) == SQLITE_OK) + sqlite3_close_v2(raw) + + #expect(await store.loadCommands().map(\.id) == ["c-valid"]) + } + @Test func `queued commands expire to failed at the staleness boundary`() async throws { let url = try makeDatabaseURL() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } @@ -412,14 +1025,28 @@ struct ChatCommandOutboxStoreTests { outboxCommand(id: "c-stale", text: "stale", createdAt: now - maxAge - 60))) #expect(await store.enqueueCommand( outboxCommand(id: "c-fresh", text: "fresh", createdAt: now - maxAge + 60))) + #expect(await store.enqueueCommand( + OpenClawChatOutboxCommand( + id: "c-unconfirmed", + sessionKey: "main", + text: "unconfirmed", + thinking: "off", + createdAt: now - maxAge - 60, + status: .sending, + retryCount: 0, + lastError: nil))) + #expect(await store.markCommandAwaitingConfirmation(id: "c-unconfirmed") == .updated) let loaded = await store.loadCommands() let stale = try #require(loaded.first { $0.id == "c-stale" }) let fresh = try #require(loaded.first { $0.id == "c-fresh" }) + let unconfirmed = try #require(loaded.first { $0.id == "c-unconfirmed" }) #expect(stale.status == .failed) #expect(stale.lastError == OpenClawChatSQLiteTranscriptCache.outboxExpiredError) #expect(fresh.status == .queued) #expect(fresh.lastError == nil) + #expect(unconfirmed.status == .failed) + #expect(unconfirmed.lastError == OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError) } @Test func `enqueue refuses beyond the queue bound`() async throws { @@ -435,7 +1062,7 @@ struct ChatCommandOutboxStoreTests { #expect(await store.loadCommands().count == bound) // Deleting a row frees capacity again. - await store.deleteCommand(id: "c-0") + #expect(await store.cancelCommand(id: "c-0") == .updated) #expect(await store.enqueueCommand(outboxCommand(id: "c-after-delete", text: "fits now"))) } @@ -449,8 +1076,9 @@ struct ChatCommandOutboxStoreTests { #expect(await storeB.loadCommands().isEmpty) // Cross-gateway mutations must not leak either. - await storeB.markCommandFailed(id: "c-a", retryCount: 3, lastError: "boom") - await storeB.deleteCommand(id: "c-a") + #expect( + await storeB.markCommandFailedIfPresent(id: "c-a", retryCount: 3, lastError: "boom") == .missing) + #expect(await storeB.cancelCommand(id: "c-a") == .missing) let survivors = await storeA.loadCommands() #expect(survivors.map(\.id) == ["c-a"]) #expect(survivors.map(\.status) == [.queued]) @@ -468,13 +1096,17 @@ struct ChatCommandOutboxStoreTests { #expect(loaded.map(\.retryCount) == [2]) #expect(loaded.map(\.lastError) == ["socket closed"]) - await store.markCommandFailed(id: "c-1", retryCount: 3, lastError: "gave up") + #expect(await store.claimNextCommand()?.id == "c-1") + #expect(await store.markCommandFailedIfPresent( + id: "c-1", + retryCount: 3, + lastError: "gave up") == .updated) loaded = await store.loadCommands() #expect(loaded.map(\.status) == [.failed]) #expect(loaded.map(\.retryCount) == [3]) #expect(loaded.map(\.lastError) == ["gave up"]) - await store.deleteCommand(id: "c-1") + #expect(await store.cancelCommand(id: "c-1") == .updated) #expect(await store.loadCommands().isEmpty) } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelOutboxTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelOutboxTests.swift index 549d156081f..ba7fb805b43 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelOutboxTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelOutboxTests.swift @@ -10,6 +10,19 @@ private func makeOutboxDatabaseURL() throws -> URL { return dir.appendingPathComponent("chat-cache.sqlite", isDirectory: false) } +private func outboxTestCommand(id: String, text: String, createdAt: Double) -> OpenClawChatOutboxCommand { + OpenClawChatOutboxCommand( + id: id, + sessionKey: "main", + routingContract: "per-sender|main|main", + text: text, + thinking: "off", + createdAt: createdAt, + status: .queued, + retryCount: 0, + lastError: nil) +} + private func userTexts(_ vm: OpenClawChatViewModel) async -> [String] { await MainActor.run { vm.messages @@ -19,27 +32,55 @@ private func userTexts(_ vm: OpenClawChatViewModel) async -> [String] { } private struct OutboxSendError: Error, LocalizedError { - var errorDescription: String? { "transport unreachable" } + var errorDescription: String? { + "transport unreachable" + } } private actor OutboxTransportState { + var sessionRoutingContract = "per-sender|main|main" var healthy: Bool + var routeGeneration = 0 var sendFails: Bool + var sendFailsAfterRecording = false var sendRejects = false + var sendResponseErrors = false + var sendRoutingChanged = false var historyFails = false + var historyRequestCount = 0 var heldSendGate: DeleteGate? + var commandListGate: DeleteGate? + let commandListStarted = DeleteGate() var staleHistoryRows: [AnyCodable]? func setHeldSendGate(_ gate: DeleteGate?) { self.heldSendGate = gate } + func setCommandListGate(_ gate: DeleteGate?) { + self.commandListGate = gate + } + + func waitUntilCommandListStarted() async { + await self.commandListStarted.wait() + } + + func awaitCommandListGate() async { + await self.commandListStarted.open() + if let commandListGate { + await commandListGate.wait() + } + } + func setStaleHistoryRows(_ rows: [AnyCodable]?) { self.staleHistoryRows = rows } + var sentIdempotencyKeys: [String] = [] var sentMessages: [String] = [] var sentSessionKeys: [String] = [] + var sentAgentIDs: [String?] = [] + var historyRequestAgentIDs: [String?] = [] var sentThinkingLevels: [String] = [] init(healthy: Bool, sendFails: Bool) { @@ -51,20 +92,52 @@ private actor OutboxTransportState { self.historyFails = fails } + func recordHistoryRequest(agentID: String?) { + self.historyRequestCount += 1 + self.historyRequestAgentIDs.append(agentID) + } + func setHealthy(_ healthy: Bool) { self.healthy = healthy } + func setSessionRoutingContract(_ contract: String) { + self.sessionRoutingContract = contract + } + + func replaceRoute() { + self.routeGeneration += 1 + } + func setSendFails(_ fails: Bool) { self.sendFails = fails } + func setSendFailsAfterRecording(_ fails: Bool) { + self.sendFailsAfterRecording = fails + } + func setSendRejects(_ rejects: Bool) { self.sendRejects = rejects } - func recordSend(sessionKey: String, message: String, idempotencyKey: String, thinking: String) { + func setSendResponseErrors(_ rejects: Bool) { + self.sendResponseErrors = rejects + } + + func setSendRoutingChanged(_ changed: Bool) { + self.sendRoutingChanged = changed + } + + func recordSend( + sessionKey: String, + agentID: String?, + message: String, + idempotencyKey: String, + thinking: String) + { self.sentSessionKeys.append(sessionKey) + self.sentAgentIDs.append(agentID) self.sentMessages.append(message) self.sentIdempotencyKeys.append(idempotencyKey) self.sentThinkingLevels.append(thinking) @@ -77,16 +150,25 @@ private actor OutboxTransportState { private final class OutboxTestTransport: @unchecked Sendable, OpenClawChatTransport { let state: OutboxTransportState private let sessions: [OpenClawChatSessionEntry] + private let supportsSlashCommands: Bool + private let requiresRoutingContract: Bool + private let routeUnavailableReason: String? private let stream: AsyncStream private let continuation: AsyncStream.Continuation init( healthy: Bool, sendFails: Bool = false, - sessions: [OpenClawChatSessionEntry] = []) + sessions: [OpenClawChatSessionEntry] = [], + supportsSlashCommands: Bool = false, + requiresRoutingContract: Bool = true, + routeUnavailableReason: String? = nil) { self.state = OutboxTransportState(healthy: healthy, sendFails: sendFails) self.sessions = sessions + self.supportsSlashCommands = supportsSlashCommands + self.requiresRoutingContract = requiresRoutingContract + self.routeUnavailableReason = routeUnavailableReason var cont: AsyncStream.Continuation! self.stream = AsyncStream { c in cont = c } self.continuation = cont @@ -102,8 +184,33 @@ private final class OutboxTestTransport: @unchecked Sendable, OpenClawChatTransp } func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { + try await self.requestHistory(sessionKey: sessionKey, agentID: nil, expectedRoute: nil) + } + + var supportsSlashCommandCatalog: Bool { + self.supportsSlashCommands + } + + var outboxRequiresSessionRoutingContract: Bool { + self.requiresRoutingContract + } + + func listCommands(sessionKey _: String) async throws -> [OpenClawChatCommandChoice] { + await self.state.awaitCommandListGate() + return [] + } + + private func requestHistory( + sessionKey: String, + agentID: String?, + expectedRoute: Int?) async throws -> OpenClawChatHistoryPayload + { + await self.state.recordHistoryRequest(agentID: agentID) + if let expectedRoute, await state.routeGeneration != expectedRoute { + throw CancellationError() + } guard await self.state.healthy, await !self.state.historyFails else { throw OutboxSendError() } - if let stale = await self.state.staleHistoryRows { + if let stale = await state.staleHistoryRows { // Gateway lag: the snapshot predates the just-acked send. return OpenClawChatHistoryPayload( sessionKey: sessionKey, @@ -111,10 +218,18 @@ private final class OutboxTestTransport: @unchecked Sendable, OpenClawChatTransp messages: stale, thinkingLevel: "off") } - let keys = await self.state.sentIdempotencyKeys - let texts = await self.state.sentMessages - let durableUserRows = zip(keys.indices, keys).map { index, key in - AnyCodable([ + let keys = await state.sentIdempotencyKeys + let texts = await state.sentMessages + let sessions = await state.sentSessionKeys + let agents = await state.sentAgentIDs + let durableUserRows = keys.indices.compactMap { index -> AnyCodable? in + guard index < sessions.count, + index < agents.count, + sessions[index] == sessionKey, + agents[index] == agentID + else { return nil } + let key = keys[index] + return AnyCodable([ "role": "user", "content": [["type": "text", "text": index < texts.count ? texts[index] : ""]], "timestamp": Double(1000 + index), @@ -135,27 +250,111 @@ private final class OutboxTestTransport: @unchecked Sendable, OpenClawChatTransp idempotencyKey: String, attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse { - if let gate = await self.state.heldSendGate { + try await self.sendMessage( + sessionKey: sessionKey, + agentID: nil, + message: message, + thinking: thinking, + idempotencyKey: idempotencyKey, + expectedRoute: nil) + } + + private func sendMessage( + sessionKey: String, + agentID: String?, + message: String, + thinking: String, + idempotencyKey: String, + expectedRoute: Int?) async throws -> OpenClawChatSendResponse + { + if let expectedRoute, await state.routeGeneration != expectedRoute { + throw OpenClawChatTransportSendError.notDispatched + } + if let gate = await state.heldSendGate { // One-shot: only the first send is held so tests can pin the // window where the flush is mid-drain. await self.state.setHeldSendGate(nil) await gate.wait() } + if let expectedRoute, await state.routeGeneration != expectedRoute { + throw OpenClawChatTransportSendError.notDispatched + } if await self.state.sendFails { throw OutboxSendError() } + if await self.state.sendResponseErrors { + throw GatewayResponseError( + method: "chat.send", + code: "INVALID_REQUEST", + message: "rejected", + details: nil) + } + if await self.state.sendRoutingChanged { + throw GatewayResponseError( + method: "chat.send", + code: "INVALID_REQUEST", + message: "session routing changed; review and retry", + details: [ + "reason": AnyCodable(OpenClawChatSessionRoutingContract.changedErrorReason), + ]) + } if await self.state.sendRejects { // Gateway responded but refused to start the run. return OpenClawChatSendResponse(runId: idempotencyKey, status: "error") } await self.state.recordSend( sessionKey: sessionKey, + agentID: agentID, message: message, idempotencyKey: idempotencyKey, thinking: thinking) + if await self.state.sendFailsAfterRecording { + throw OutboxSendError() + } return OpenClawChatSendResponse(runId: idempotencyKey, status: "accepted") } + func acquireOutboxRouteLease() async -> OpenClawChatTransportRouteLeaseResult { + if let routeUnavailableReason { + return .unavailable(reason: routeUnavailableReason) + } + if !self.requiresRoutingContract { + let transport = self + return .available(OpenClawChatTransportRouteLease( + sendMessage: { sessionKey, message, thinking, idempotencyKey, attachments in + try await transport.sendMessage( + sessionKey: sessionKey, + message: message, + thinking: thinking, + idempotencyKey: idempotencyKey, + attachments: attachments) + }, + requestHistory: { sessionKey in + try await transport.requestHistory(sessionKey: sessionKey) + })) + } + let expectedRoute = await state.routeGeneration + let routingContract = await state.sessionRoutingContract + let transport = self + return .available(OpenClawChatTransportRouteLease( + sendTargetedMessage: { sessionKey, agentID, message, thinking, idempotencyKey, _ in + try await transport.sendMessage( + sessionKey: sessionKey, + agentID: agentID, + message: message, + thinking: thinking, + idempotencyKey: idempotencyKey, + expectedRoute: expectedRoute) + }, + requestTargetedHistory: { sessionKey, agentID in + try await transport.requestHistory( + sessionKey: sessionKey, + agentID: agentID, + expectedRoute: expectedRoute) + }, + sessionRoutingContract: routingContract)) + } + /// Gated model patch: `setSessionModel` blocks until `releaseModelPatch` /// so tests can hold a patch in flight while the outbox flushes. private let modelPatchGate = AsyncStream.makeStream() @@ -218,12 +417,17 @@ private func makeOutboxViewModel( transport: OutboxTestTransport, outbox: any OpenClawChatCommandOutbox, transcriptCache: (any OpenClawChatTranscriptCache)? = nil, - retryDelaysMs: [UInt64] = [1, 1]) async -> OpenClawChatViewModel + retryDelaysMs: [UInt64] = [1, 1], + sessionKey: String = "main", + activeAgentID: String? = "main", + sessionRoutingContract: String? = "per-sender|main|main") async -> OpenClawChatViewModel { await MainActor.run { let vm = OpenClawChatViewModel( - sessionKey: "main", + sessionKey: sessionKey, transport: transport, + activeAgentId: activeAgentID, + sessionRoutingContract: sessionRoutingContract, transcriptCache: transcriptCache, outbox: outbox) vm.outboxRetryDelaysMs = retryDelaysMs @@ -253,17 +457,36 @@ private func queuedStateCount(_ vm: OpenClawChatViewModel) -> Int { /// Forwarding outbox that can delay `loadCommands`, making restore-vs-send /// interleavings deterministic in tests. private actor DelayingOutbox: OpenClawChatCommandOutbox { - private let base: OpenClawChatSQLiteTranscriptCache + private nonisolated let base: OpenClawChatSQLiteTranscriptCache private var loadDelayNanoseconds: UInt64 = 0 + private var recoveryAvailable = true + private var terminalWritesAvailable = true + private let recoveryAttempted = DeleteGate() init(base: OpenClawChatSQLiteTranscriptCache) { self.base = base } + nonisolated func changes() -> AsyncStream { + self.base.changes() + } + func setLoadDelayNanoseconds(_ delay: UInt64) { self.loadDelayNanoseconds = delay } + func setRecoveryAvailable(_ available: Bool) { + self.recoveryAvailable = available + } + + func setTerminalWritesAvailable(_ available: Bool) { + self.terminalWritesAvailable = available + } + + func waitUntilRecoveryAttempted() async { + await self.recoveryAttempted.wait() + } + func enqueueCommand(_ command: OpenClawChatOutboxCommand) async -> Bool { await self.base.enqueueCommand(command) } @@ -275,30 +498,246 @@ private actor DelayingOutbox: OpenClawChatCommandOutbox { return await self.base.loadCommands() } - @discardableResult - func recoverInterruptedSends() async -> Bool { - await self.base.recoverInterruptedSends() + func loadCommandsIfAvailable() async -> [OpenClawChatOutboxCommand]? { + if self.loadDelayNanoseconds > 0 { + try? await Task.sleep(nanoseconds: self.loadDelayNanoseconds) + } + return await self.base.loadCommandsIfAvailable() } @discardableResult - func markCommandSending(id: String) async -> Bool { - await self.base.markCommandSending(id: id) + func recoverInterruptedSends() async -> Bool { + await self.recoveryAttempted.open() + guard self.recoveryAvailable else { return false } + return await self.base.recoverInterruptedSends() + } + + func claimNextCommand() async -> OpenClawChatOutboxCommand? { + await self.base.claimNextCommand() } func markCommandQueued(id: String, retryCount: Int, lastError: String?) async { await self.base.markCommandQueued(id: id, retryCount: retryCount, lastError: lastError) } - func markCommandFailed(id: String, retryCount: Int, lastError: String?) async { - await self.base.markCommandFailed(id: id, retryCount: retryCount, lastError: lastError) + func markCommandAwaitingConfirmation(id: String) async -> OpenClawChatOutboxUpdateResult { + await self.base.markCommandAwaitingConfirmation(id: id) } - func markCommandRetried(id: String) async { - await self.base.markCommandRetried(id: id) + func markCommandFailedIfPresent( + id: String, + retryCount: Int, + lastError: String?) async -> OpenClawChatOutboxUpdateResult + { + guard self.terminalWritesAvailable else { return .unavailable } + return await self.base.markCommandFailedIfPresent(id: id, retryCount: retryCount, lastError: lastError) } - func deleteCommand(id: String) async { - await self.base.deleteCommand(id: id) + func markCommandRetriedIfPresent( + id: String, + agentID: String?, + deliverySessionKey: String, + routingContract: String) async -> OpenClawChatOutboxUpdateResult + { + await self.base.markCommandRetriedIfPresent( + id: id, + agentID: agentID, + deliverySessionKey: deliverySessionKey, + routingContract: routingContract) + } + + func cancelCommand(id: String) async -> OpenClawChatOutboxUpdateResult { + await self.base.cancelCommand(id: id) + } + + func confirmCommand(id: String) async -> OpenClawChatOutboxUpdateResult { + await self.base.confirmCommand(id: id) + } +} + +/// Returns one already-read command snapshot only after the test releases it, +/// reproducing a restore that resumes after another view canceled the row. +private actor SnapshotHoldingOutbox: OpenClawChatCommandOutbox { + private nonisolated let base: OpenClawChatSQLiteTranscriptCache + private var captured = DeleteGate() + private var release = DeleteGate() + private var shouldHoldNextLoad = false + + init(base: OpenClawChatSQLiteTranscriptCache) { + self.base = base + } + + func waitUntilSnapshotCaptured() async { + await self.captured.wait() + } + + func holdNextLoad() { + self.captured = DeleteGate() + self.release = DeleteGate() + self.shouldHoldNextLoad = true + } + + func releaseSnapshot() async { + await self.release.open() + } + + nonisolated func changes() -> AsyncStream { + self.base.changes() + } + + func enqueueCommand(_ command: OpenClawChatOutboxCommand) async -> Bool { + await self.base.enqueueCommand(command) + } + + func loadCommands() async -> [OpenClawChatOutboxCommand] { + let commands = await base.loadCommands() + if self.shouldHoldNextLoad { + self.shouldHoldNextLoad = false + await self.captured.open() + await self.release.wait() + } + return commands + } + + func loadCommandsIfAvailable() async -> [OpenClawChatOutboxCommand]? { + guard let commands = await base.loadCommandsIfAvailable() else { return nil } + if self.shouldHoldNextLoad { + self.shouldHoldNextLoad = false + await self.captured.open() + await self.release.wait() + } + return commands + } + + @discardableResult + func recoverInterruptedSends() async -> Bool { + await self.base.recoverInterruptedSends() + } + + func claimNextCommand() async -> OpenClawChatOutboxCommand? { + await self.base.claimNextCommand() + } + + func markCommandQueued(id: String, retryCount: Int, lastError: String?) async { + await self.base.markCommandQueued(id: id, retryCount: retryCount, lastError: lastError) + } + + func markCommandAwaitingConfirmation(id: String) async -> OpenClawChatOutboxUpdateResult { + await self.base.markCommandAwaitingConfirmation(id: id) + } + + func markCommandFailedIfPresent( + id: String, + retryCount: Int, + lastError: String?) async -> OpenClawChatOutboxUpdateResult + { + await self.base.markCommandFailedIfPresent(id: id, retryCount: retryCount, lastError: lastError) + } + + func markCommandRetriedIfPresent( + id: String, + agentID: String?, + deliverySessionKey: String, + routingContract: String) async -> OpenClawChatOutboxUpdateResult + { + await self.base.markCommandRetriedIfPresent( + id: id, + agentID: agentID, + deliverySessionKey: deliverySessionKey, + routingContract: routingContract) + } + + func cancelCommand(id: String) async -> OpenClawChatOutboxUpdateResult { + await self.base.cancelCommand(id: id) + } + + func confirmCommand(id: String) async -> OpenClawChatOutboxUpdateResult { + await self.base.confirmCommand(id: id) + } +} + +/// Holds a completed durable cancellation before its result returns to the +/// MainActor, making late canonical-proof ordering deterministic. +private actor CancellationHoldingOutbox: OpenClawChatCommandOutbox { + private nonisolated let base: OpenClawChatSQLiteTranscriptCache + private let canceled = DeleteGate() + private let release = DeleteGate() + + init(base: OpenClawChatSQLiteTranscriptCache) { + self.base = base + } + + func waitUntilCanceled() async { + await self.canceled.wait() + } + + func releaseCancellation() async { + await self.release.open() + } + + nonisolated func changes() -> AsyncStream { + self.base.changes() + } + + func enqueueCommand(_ command: OpenClawChatOutboxCommand) async -> Bool { + await self.base.enqueueCommand(command) + } + + func loadCommands() async -> [OpenClawChatOutboxCommand] { + await self.base.loadCommands() + } + + func loadCommandsIfAvailable() async -> [OpenClawChatOutboxCommand]? { + await self.base.loadCommandsIfAvailable() + } + + @discardableResult + func recoverInterruptedSends() async -> Bool { + await self.base.recoverInterruptedSends() + } + + func claimNextCommand() async -> OpenClawChatOutboxCommand? { + await self.base.claimNextCommand() + } + + func markCommandQueued(id: String, retryCount: Int, lastError: String?) async { + await self.base.markCommandQueued(id: id, retryCount: retryCount, lastError: lastError) + } + + func markCommandAwaitingConfirmation(id: String) async -> OpenClawChatOutboxUpdateResult { + await self.base.markCommandAwaitingConfirmation(id: id) + } + + func markCommandFailedIfPresent( + id: String, + retryCount: Int, + lastError: String?) async -> OpenClawChatOutboxUpdateResult + { + await self.base.markCommandFailedIfPresent(id: id, retryCount: retryCount, lastError: lastError) + } + + func markCommandRetriedIfPresent( + id: String, + agentID: String?, + deliverySessionKey: String, + routingContract: String) async -> OpenClawChatOutboxUpdateResult + { + await self.base.markCommandRetriedIfPresent( + id: id, + agentID: agentID, + deliverySessionKey: deliverySessionKey, + routingContract: routingContract) + } + + func cancelCommand(id: String) async -> OpenClawChatOutboxUpdateResult { + let result = await base.cancelCommand(id: id) + await self.canceled.open() + await self.release.wait() + return result + } + + func confirmCommand(id: String) async -> OpenClawChatOutboxUpdateResult { + await self.base.confirmCommand(id: id) } } @@ -309,6 +748,7 @@ struct ChatViewModelOutboxTests { let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") let transport = OutboxTestTransport(healthy: false) let vm = await makeOutboxViewModel(transport: transport, outbox: store) + #expect(await MainActor.run { vm.supportsOfflineTextOutbox }) await MainActor.run { vm.load() } try await sendWhileOffline(vm, text: "hello offline") @@ -319,6 +759,7 @@ struct ChatViewModelOutboxTests { #expect(commands.map(\.text) == ["hello offline"]) #expect(commands.map(\.status) == [.queued]) #expect(commands.map(\.sessionKey) == ["main"]) + #expect(commands.map(\.deliverySessionKey) == ["agent:main:main"]) // The visible row carries the queued state and the draft was cleared. #expect(await MainActor.run { vm.input.isEmpty }) @@ -337,6 +778,369 @@ struct ChatViewModelOutboxTests { } } #expect(await userTexts(vm2) == ["hello offline"]) + + // A cold launch can restore before the gateway provides its default + // agent. The captured owner still keeps the bubble visible; replay + // remains blocked on the route lease's owner/contract verification. + let ownerlessColdOpen = await makeOutboxViewModel( + transport: transport, + outbox: store, + activeAgentID: nil, + sessionRoutingContract: nil) + await MainActor.run { ownerlessColdOpen.load() } + try await waitUntil("ownerless cold open restores queued bubble") { + await MainActor.run { + ownerlessColdOpen.messages.contains { + ownerlessColdOpen.outboxState(for: $0.id) == .queued + } + } + } + #expect(await userTexts(ownerlessColdOpen) == ["hello offline"]) + } + + @Test func `unsupported gateway keeps queued work and surfaces upgrade action`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let message = OpenClawChatTransportUpgradeMessage.routingContract + let transport = OutboxTestTransport( + healthy: false, + routeUnavailableReason: message) + let vm = await makeOutboxViewModel(transport: transport, outbox: store) + + await MainActor.run { vm.load() } + try await sendWhileOffline(vm, text: "wait for upgrade") + await transport.goOnline() + + try await waitUntil("gateway upgrade guidance") { + await MainActor.run { vm.errorText == message } + } + #expect(await store.loadCommands().map(\.status) == [.queued]) + #expect(await transport.state.sentMessages.isEmpty) + } + + @Test func `inert outbox does not capability gate healthy live chat`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let transport = OutboxTestTransport( + healthy: true, + routeUnavailableReason: OpenClawChatTransportUpgradeMessage.routingContract) + let vm = await makeOutboxViewModel(transport: transport, outbox: store) + + await MainActor.run { vm.load() } + try await waitUntil("empty outbox restore completes") { + await MainActor.run { vm.hasRestoredOutboxMessages } + } + try await Task.sleep(for: .milliseconds(50)) + #expect(await MainActor.run { vm.healthOK }) + #expect(await MainActor.run { vm.errorText == nil }) + + var parked = outboxTestCommand(id: "c-parked", text: "review me", createdAt: 1) + parked.status = .failed + parked.lastError = OpenClawChatSQLiteTranscriptCache.outboxChangedTargetError + #expect(await store.enqueueCommand(parked)) + let parkedVM = await makeOutboxViewModel(transport: transport, outbox: store) + await MainActor.run { parkedVM.load() } + try await waitUntil("parked outbox restore completes") { + await MainActor.run { parkedVM.hasRestoredOutboxMessages } + } + try await Task.sleep(for: .milliseconds(50)) + #expect(await MainActor.run { parkedVM.healthOK }) + #expect(await MainActor.run { parkedVM.errorText == nil }) + } + + @Test func `legacy transport preserves its untargeted session key`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let transport = OutboxTestTransport(healthy: false, requiresRoutingContract: false) + let vm = await makeOutboxViewModel( + transport: transport, + outbox: store, + sessionKey: "OpaquePeer", + activeAgentID: nil, + sessionRoutingContract: nil) + + await MainActor.run { vm.load() } + try await sendWhileOffline(vm, text: "legacy route") + let command = try #require(await store.claimNextCommand()) + #expect(command.deliverySessionKey == "OpaquePeer") + #expect(command.agentID == nil) + #expect(await store.markCommandFailedIfPresent( + id: command.id, + retryCount: 1, + lastError: "legacy failure") == .updated) + + let retryView = await makeOutboxViewModel( + transport: transport, + outbox: store, + sessionKey: "OpaquePeer", + activeAgentID: nil, + sessionRoutingContract: nil) + await MainActor.run { retryView.load() } + try await waitUntil("legacy failed bubble") { + await MainActor.run { + retryView.messages.contains { message in + retryView.outboxState(for: message.id)?.isFailed == true + } + } + } + let failedMessageID = try #require(await MainActor.run { retryView.messages.last?.id }) + await MainActor.run { retryView.retryOutboxMessage(failedMessageID) } + try await waitUntil("legacy retry queues") { + await store.loadCommands().first?.status == .queued + } + + await transport.goOnline() + try await waitUntil("legacy outbox send") { + await transport.state.sentMessages == ["legacy route"] + } + #expect(await transport.state.sentSessionKeys == ["OpaquePeer"]) + #expect(await transport.state.sentAgentIDs == [nil]) + } + + @Test func `reserved unknown session stays unscoped in durable delivery`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let transport = OutboxTestTransport(healthy: false) + let vm = await makeOutboxViewModel( + transport: transport, + outbox: store, + sessionKey: "unknown") + + await MainActor.run { vm.load() } + try await sendWhileOffline(vm, text: "reserved route") + let command = try #require(await store.loadCommands().first) + + #expect(command.deliverySessionKey == "unknown") + #expect(command.agentID == nil) + + await transport.goOnline() + try await waitUntil("reserved outbox send") { + await transport.state.sentMessages == ["reserved route"] + } + #expect(await transport.state.sentSessionKeys == ["unknown"]) + #expect(await transport.state.sentAgentIDs == [nil]) + } + + @Test(arguments: ["global", "main"]) + func `mutable alias queued turn keeps its original agent target`(_ sessionKey: String) async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let offlineTransport = OutboxTestTransport(healthy: false) + let agentAView = await makeOutboxViewModel( + transport: offlineTransport, + outbox: store, + transcriptCache: store, + sessionKey: sessionKey, + activeAgentID: "agent-a") + await MainActor.run { agentAView.load() } + try await sendWhileOffline(agentAView, text: "for agent A") + #expect(await store.loadCommands().map(\.agentID) == ["agent-a"]) + + let reconnectTransport = OutboxTestTransport(healthy: false) + let agentBView = await makeOutboxViewModel( + transport: reconnectTransport, + outbox: store, + transcriptCache: store, + sessionKey: sessionKey, + activeAgentID: "agent-b") + await MainActor.run { agentBView.load() } + try await waitUntil("agent B restore completes without adopting agent A bubble") { + await MainActor.run { agentBView.hasRestoredOutboxMessages } + } + #expect(await userTexts(agentBView).isEmpty) + + await reconnectTransport.goOnline() + try await waitUntil("agent A command drains after agent B reconnects") { + await store.loadCommands().isEmpty + } + let deliverySessionKey = sessionKey == "global" ? "global" : "agent:agent-a:main" + #expect(await reconnectTransport.state.sentSessionKeys == [deliverySessionKey]) + #expect(await reconnectTransport.state.sentAgentIDs == ["agent-a"]) + #expect(await reconnectTransport.state.historyRequestAgentIDs.contains("agent-a")) + #expect(await store.loadTranscript(sessionKey: sessionKey, agentID: "agent-a") + .map { $0.content.compactMap(\.text).joined() } == ["for agent A"]) + #expect(await store.loadTranscript(sessionKey: sessionKey, agentID: "agent-b").isEmpty) + } + + @Test func `unscoped opaque peer ID preserves case in its durable target`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let sessionKey = "Matrix:Channel:!MixedRoomAbCdEf:example.org" + let transport = OutboxTestTransport(healthy: false) + let vm = await makeOutboxViewModel( + transport: transport, + outbox: store, + sessionKey: sessionKey, + activeAgentID: "agent-a") + await MainActor.run { vm.load() } + try await sendWhileOffline(vm, text: "preserve opaque room") + + let command = try #require(await store.loadCommands().first) + #expect(command.deliverySessionKey == "agent:agent-a:\(sessionKey)") + + await transport.goOnline() + try await waitUntil("mixed-case target drains") { + await store.loadCommands().isEmpty + } + #expect(await transport.state.sentSessionKeys == ["agent:agent-a:\(sessionKey)"]) + } + + @Test func `changed default agent parks command visibly for retry`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let oldTransport = OutboxTestTransport(healthy: false) + await oldTransport.state.setSessionRoutingContract("per-sender|main|agent-a") + let oldView = await makeOutboxViewModel( + transport: oldTransport, + outbox: store, + activeAgentID: "agent-a", + sessionRoutingContract: "per-sender|main|agent-a") + await MainActor.run { oldView.load() } + try await sendWhileOffline(oldView, text: "old main target") + + let newTransport = OutboxTestTransport(healthy: false) + await newTransport.state.setSessionRoutingContract("per-sender|main|agent-b") + let newView = await makeOutboxViewModel( + transport: newTransport, + outbox: store, + activeAgentID: "agent-b", + sessionRoutingContract: "per-sender|main|agent-b") + await MainActor.run { newView.load() } + + await newTransport.goOnline() + try await waitUntil("changed target is parked") { + await store.loadCommands().map(\.status) == [.failed] + } + #expect(await store.loadCommands().first?.lastError == + OpenClawChatSQLiteTranscriptCache.outboxChangedTargetError) + #expect(await newTransport.state.sentMessages.isEmpty) + try await waitUntil("parked target stays visible") { + await MainActor.run { + newView.messages.contains { newView.outboxState(for: $0.id)?.isFailed == true } + } + } + + let messageID = try #require(await MainActor.run { + newView.messages.first { newView.outboxState(for: $0.id)?.isFailed == true }?.id + }) + await MainActor.run { newView.retryOutboxMessage(messageID) } + try await waitUntil("changed target retry drains") { + await store.loadCommands().isEmpty + } + #expect(await newTransport.state.sentAgentIDs == ["agent-b"]) + #expect(await newTransport.state.sentSessionKeys == ["agent:agent-b:main"]) + } + + @Test func `atomic gateway routing rejection parks without retrying`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let transport = OutboxTestTransport(healthy: false) + let vm = await makeOutboxViewModel(transport: transport, outbox: store) + await MainActor.run { vm.load() } + try await sendWhileOffline(vm, text: "do not cross config reload") + + await transport.state.setSendRoutingChanged(true) + await transport.goOnline() + try await waitUntil("atomic routing rejection parks") { + await store.loadCommands().first?.status == .failed + } + let command = try #require(await store.loadCommands().first) + #expect(command.lastError == OpenClawChatSQLiteTranscriptCache.outboxChangedTargetError) + #expect(command.retryCount == 0) + #expect(await transport.state.sentMessages.isEmpty) + } + + @Test func `failed alias row remains reachable after owner change`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( + id: "c-old-failure", + sessionKey: "main", + deliverySessionKey: "agent:agent-a:main", + routingContract: "per-sender|main|agent-a", + agentID: "agent-a", + text: "review old failure", + thinking: "off", + createdAt: Date().timeIntervalSince1970, + status: .failed, + retryCount: 1, + lastError: OpenClawChatSQLiteTranscriptCache.outboxExpiredError))) + let transport = OutboxTestTransport(healthy: false) + let vm = await makeOutboxViewModel( + transport: transport, + outbox: store, + activeAgentID: "agent-b", + sessionRoutingContract: "per-sender|main|agent-b") + + await MainActor.run { vm.load() } + try await waitUntil("old failed row stays visible") { + await MainActor.run { + vm.messages.contains { vm.outboxState(for: $0.id)?.isFailed == true } + } + } + let messageID = try #require(await MainActor.run { vm.messages.last?.id }) + await MainActor.run { vm.deleteOutboxMessage(messageID) } + try await waitUntil("old failed row can be deleted") { await store.loadCommands().isEmpty } + } + + @Test func `ownerless global retry stays failed without a selected agent`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( + id: "c-ownerless", + sessionKey: "global", + text: "choose my owner", + thinking: "off", + createdAt: Date().timeIntervalSince1970, + status: .failed, + retryCount: 0, + lastError: OpenClawChatSQLiteTranscriptCache.outboxUnknownTargetError))) + let transport = OutboxTestTransport(healthy: false) + let vm = await makeOutboxViewModel( + transport: transport, + outbox: store, + sessionKey: "global", + activeAgentID: nil) + await MainActor.run { vm.load() } + try await waitUntil("ownerless failed row visible") { + await MainActor.run { vm.messages.contains { vm.outboxState(for: $0.id)?.isFailed == true } } + } + let messageID = try #require(await MainActor.run { + vm.messages.first { vm.outboxState(for: $0.id)?.isFailed == true }?.id + }) + + await MainActor.run { vm.retryOutboxMessage(messageID) } + try await waitUntil("ownerless retry asks for an agent") { + await MainActor.run { vm.errorText == "Select an agent before retrying this message." } + } + let command = await store.loadCommands().first + #expect(command?.status == .failed) + #expect(command?.agentID == nil) + } + + @Test func `unavailable recovery keeps the live send FIFO gate closed`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let outbox = DelayingOutbox(base: store) + await outbox.setRecoveryAvailable(false) + let transport = OutboxTestTransport(healthy: false) + let vm = await makeOutboxViewModel(transport: transport, outbox: outbox) + + await MainActor.run { vm.load() } + await outbox.waitUntilRecoveryAttempted() + + #expect(await MainActor.run { !vm.hasRestoredOutboxMessages }) } @Test func `reconnect flushes queued commands in order with their idempotency keys`() async throws { @@ -362,7 +1166,7 @@ struct ChatViewModelOutboxTests { // here, keyed by its client UUID, in strict createdAt order. #expect(await transport.state.sentIdempotencyKeys == queuedIDs) #expect(await transport.state.sentMessages == ["first", "second"]) - #expect(await transport.state.sentSessionKeys == ["main", "main"]) + #expect(await transport.state.sentSessionKeys == ["agent:main:main", "agent:main:main"]) // Durable history replaced the queued bubbles without duplicating // them, and no outbox state markers remain. @@ -375,13 +1179,39 @@ struct ChatViewModelOutboxTests { // Crash-window durability: the sent turns were written through to the // transcript cache no later than outbox-row deletion, so a cold // offline reopen still shows them. - let cached = await store.loadTranscript(sessionKey: "main") + let cached = await store.loadTranscript(sessionKey: "main", agentID: "main") let cachedUserTexts = cached .filter { $0.role == "user" } .map { $0.content.compactMap(\.text).joined() } #expect(cachedUserTexts == ["first", "second"]) } + @Test func `overlapping view models share one atomic FIFO sender`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let now = Date().timeIntervalSince1970 + #expect(await store.enqueueCommand(outboxTestCommand(id: "c-1", text: "first", createdAt: now))) + #expect(await store.enqueueCommand(outboxTestCommand(id: "c-2", text: "second", createdAt: now + 1))) + let transport = OutboxTestTransport(healthy: false) + let firstVM = await makeOutboxViewModel(transport: transport, outbox: store) + let secondVM = await makeOutboxViewModel(transport: transport, outbox: store) + + await transport.state.setHealthy(true) + await MainActor.run { + firstVM.load() + secondVM.load() + firstVM.applyTransportHealth(true) + secondVM.applyTransportHealth(true) + } + + try await waitUntil("shared queue drained") { + await store.loadCommands().isEmpty + } + #expect(await transport.state.sentIdempotencyKeys == ["c-1", "c-2"]) + #expect(await transport.state.sentMessages == ["first", "second"]) + } + @Test func `assistant reply for a flushed run lands via the external-run final event`() async throws { let url = try makeOutboxDatabaseURL() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } @@ -427,7 +1257,7 @@ struct ChatViewModelOutboxTests { } } - @Test func `sent turn is cached before the outbox row is deleted`() async throws { + @Test func `acknowledged turn stays durable until history confirms it`() async throws { let url = try makeOutboxDatabaseURL() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") @@ -437,18 +1267,145 @@ struct ChatViewModelOutboxTests { await MainActor.run { vm.load() } try await sendWhileOffline(vm, text: "must survive") - // Sends succeed on reconnect but history stays unreachable, so the - // post-flush history write-through can never populate the cache. - // Only the pre-delete write-through can preserve the turn. + // chat.send ACKs before user-turn persistence. With history still + // unreachable, the row must remain durable and non-replayable. await transport.state.setHistoryFails(true) await transport.goOnline() - try await waitUntil("outbox drained") { - await store.loadCommands().isEmpty + try await waitUntil("acknowledgement awaits history") { + await store.loadCommands().map(\.status) == [.awaitingConfirmation] } - let cached = await store.loadTranscript(sessionKey: "main") + let cached = await store.loadTranscript(sessionKey: "main", agentID: "main") #expect(cached.map { $0.content.compactMap(\.text).joined() } == ["must survive"]) - _ = vm + #expect(await MainActor.run { + vm.messages.contains { vm.outboxState(for: $0.id) == .confirming } + }) + + await transport.state.setHistoryFails(false) + await MainActor.run { vm.refresh() } + try await waitUntil("canonical history confirms send") { + await store.loadCommands().isEmpty + } + } + + @Test func `healthy restore reconciles a previously acknowledged turn`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + #expect(await store.enqueueCommand(outboxTestCommand( + id: "c-awaiting", + text: "already acknowledged", + createdAt: Date().timeIntervalSince1970))) + #expect(await store.claimNextCommand()?.id == "c-awaiting") + #expect(await store.markCommandAwaitingConfirmation(id: "c-awaiting") == .updated) + + let transport = OutboxTestTransport(healthy: true) + await transport.state.recordSend( + sessionKey: "main", + agentID: nil, + message: "already acknowledged", + idempotencyKey: "c-awaiting", + thinking: "off") + let vm = await makeOutboxViewModel(transport: transport, outbox: store) + await MainActor.run { vm.load() } + + try await waitUntil("restore history confirms acknowledged turn") { + await store.loadCommands().isEmpty + } + #expect(await transport.state.sentMessages == ["already acknowledged"]) + } + + @Test func `delete race preserves a turn already proven by canonical history`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + #expect(await store.enqueueCommand(outboxTestCommand( + id: "c-delivered", + text: "delivered already", + createdAt: Date().timeIntervalSince1970))) + let holdingOutbox = CancellationHoldingOutbox(base: store) + let transport = OutboxTestTransport(healthy: false) + let vm = await makeOutboxViewModel( + transport: transport, + outbox: holdingOutbox, + transcriptCache: store) + await MainActor.run { vm.load() } + try await waitUntil("delivered command restored") { + await MainActor.run { vm.messages.contains { vm.outboxState(for: $0.id) == .queued } } + } + let canonicalMessage = OpenClawChatMessage( + role: "user", + content: [OpenClawChatMessageContent( + type: "text", + text: "delivered already", + mimeType: nil, + fileName: nil, + content: nil)], + timestamp: 1, + idempotencyKey: "c-delivered:user") + let messageID = try #require(await MainActor.run { vm.messages.last?.id }) + await MainActor.run { vm.deleteOutboxMessage(messageID) } + await holdingOutbox.waitUntilCanceled() + // Canonical proof lands after SQLite committed the cancellation but + // before its result can remove presentation state on the MainActor. + await MainActor.run { vm.confirmOutboxCommands(in: [canonicalMessage]) } + await holdingOutbox.releaseCancellation() + try await waitUntil("canonical proof wins delete race") { + let commandsEmpty = await store.loadCommands().isEmpty + let badgeCleared = await MainActor.run { vm.outboxState(for: messageID) == nil } + return commandsEmpty && badgeCleared + } + #expect(await userTexts(vm) == ["delivered already"]) + } + + @Test func `offline local slash command keeps its draft and skips transport`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let transport = OutboxTestTransport(healthy: false) + let vm = await makeOutboxViewModel(transport: transport, outbox: store) + await MainActor.run { + // A transient transport loss can leave the preserved view model + // healthy until the next explicit probe. + vm.healthOK = true + vm.input = "/new" + vm.send() + } + try await waitUntil("offline slash command rejected") { + await MainActor.run { vm.errorText == "Connect to the gateway to run this command." } + } + #expect(await MainActor.run { vm.input } == "/new") + #expect(await store.loadCommands().isEmpty) + #expect(await transport.state.sentMessages.isEmpty) + } + + @Test func `lagging history does not recursively refresh an acknowledged turn`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + #expect(await store.enqueueCommand(outboxTestCommand( + id: "c-lagging", + text: "not persisted yet", + createdAt: Date().timeIntervalSince1970))) + #expect(await store.claimNextCommand()?.id == "c-lagging") + #expect(await store.markCommandAwaitingConfirmation(id: "c-lagging") == .updated) + + let transport = OutboxTestTransport(healthy: true) + await transport.state.setStaleHistoryRows([]) + let vm = await makeOutboxViewModel(transport: transport, outbox: store) + await MainActor.run { vm.load() } + try await waitUntil("reconnect history request") { + await transport.state.historyRequestCount >= 1 + } + try await Task.sleep(nanoseconds: 200_000_000) + let settledRequestCount = await transport.state.historyRequestCount + try await Task.sleep(nanoseconds: 200_000_000) + + // load() also owns its ordinary visible-session history request. The + // exact count can be one or two; neither may trigger a recursive pass. + #expect(settledRequestCount <= 2) + #expect(await transport.state.historyRequestCount == settledRequestCount) + #expect(await store.loadCommands().map(\.status) == [.awaitingConfirmation]) } @Test func `gateway rejections burn attempts then fail terminally and support tap retry`() async throws { @@ -490,13 +1447,90 @@ struct ChatViewModelOutboxTests { #expect(await transport.state.sentIdempotencyKeys.count == 1) } - @Test func `transport failures keep commands queued without burning attempts`() async throws { + @Test func `unavailable terminal write drops health instead of advancing FIFO`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( + id: "c-terminal-write", + sessionKey: "main", + routingContract: "per-sender|main|main", + text: "do not skip me", + thinking: "off", + createdAt: Date().timeIntervalSince1970, + status: .queued, + retryCount: OpenClawChatViewModel.maxOutboxSendAttempts - 1, + lastError: "rejected"))) + let outbox = DelayingOutbox(base: store) + await outbox.setTerminalWritesAvailable(false) + let transport = OutboxTestTransport(healthy: false) + await transport.state.setSendRejects(true) + let vm = await makeOutboxViewModel(transport: transport, outbox: outbox) + + await MainActor.run { vm.load() } + await transport.goOnline() + + try await waitUntil("terminal write failure closes health with claim intact") { + let status = await store.loadCommands().first?.status + let healthDown = await MainActor.run { !vm.healthOK } + return status == .sending && healthDown + } + #expect(await MainActor.run { + vm.messages.allSatisfy { vm.outboxState(for: $0.id)?.isFailed != true } + }) + } + + @Test func `gateway response errors are definitive and burn retry attempts`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let transport = OutboxTestTransport(healthy: false) + let vm = await makeOutboxViewModel(transport: transport, outbox: store) + + await MainActor.run { vm.load() } + try await sendWhileOffline(vm, text: "definitively rejected") + await transport.state.setSendResponseErrors(true) + await transport.goOnline() + + try await waitUntil("response error exhausts retry budget") { + await store.loadCommands().map(\.status) == [.failed] + } + let failed = try #require(await store.loadCommands().first) + #expect(failed.retryCount == OpenClawChatViewModel.maxOutboxSendAttempts) + #expect(await transport.state.sentIdempotencyKeys.isEmpty) + } + + @Test func `definitive live-send rejection restores draft without queueing`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let transport = OutboxTestTransport(healthy: true) + await transport.state.setSendResponseErrors(true) + let vm = await makeOutboxViewModel(transport: transport, outbox: store) + + await MainActor.run { vm.load() } + try await waitUntil("bootstrap healthy") { + await MainActor.run { vm.healthOK } + } + await MainActor.run { + vm.input = "keep this draft" + vm.send() + } + try await waitUntil("definitive rejection surfaced") { + await MainActor.run { vm.errorText != nil } + } + #expect(await MainActor.run { vm.input } == "keep this draft") + #expect(await userTexts(vm).isEmpty) + #expect(await store.loadCommands().isEmpty) + } + + @Test func `ambiguous live transport failure requires explicit retry`() async throws { let url = try makeOutboxDatabaseURL() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") // Health reads true, but the actual send path is down: the send gate - // is bypassed and the transport error must requeue instead of losing - // the draft. + // is bypassed and the transport error must preserve instead of losing + // the optimistic turn. let transport = OutboxTestTransport(healthy: true, sendFails: true) let vm = await makeOutboxViewModel(transport: transport, outbox: store) @@ -509,40 +1543,61 @@ struct ChatViewModelOutboxTests { vm.send() } - // The optimistic bubble survives as a durable outbox row that stays - // queued: connectivity blips never burn retry attempts. - try await waitUntil("send requeued durably") { - await store.loadCommands().map(\.status) == [.queued] + // The optimistic bubble survives, but delivery is ambiguous. It must + // not return to the automatic queue after dedupe expiry or restart. + try await waitUntil("ambiguous send preserved durably") { + await store.loadCommands().map(\.status) == [.failed] } - // Give the automatic retry chain a few cycles to prove the row never - // escalates toward failed while the transport keeps throwing. The - // status may read 'sending' mid-attempt; the invariants are that it - // never turns failed and attempts stay unburned. - try await Task.sleep(nanoseconds: 50_000_000) - let requeued = try #require(await store.loadCommands().first) - #expect(requeued.status != .failed) - #expect(requeued.retryCount == 0) - #expect(requeued.text == "stale health send") + let preserved = try #require(await store.loadCommands().first) + #expect(preserved.lastError == OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError) + #expect(preserved.retryCount == 0) + #expect(preserved.text == "stale health send") #expect(await userTexts(vm) == ["stale health send"]) let bubbleKey = await MainActor.run { vm.messages.first { $0.role == "user" }?.idempotencyKey } - // Same idempotency identity as the failed live send, so a duplicate - // delivery on the gateway side stays deduped. - #expect(bubbleKey == "\(requeued.id):user") + #expect(bubbleKey == "\(preserved.id):user") + #expect(await MainActor.run { + vm.messages.contains { vm.outboxState(for: $0.id)?.isFailed == true } + }) + #expect(await MainActor.run { !vm.healthOK }) - // Once the transport recovers, the next healthy transition drains - // the row without any user action. (Repeated throws exhaust the - // retry ladder and drop health, so recovery is signaled the same way - // a real reconnect is.) + // Connectivity recovery only reconciles history; it cannot replay an + // unproven send. A user retry creates the new delivery intent. await transport.state.setSendFails(false) await transport.goOnline() - // Generous timeout: draining rides the millisecond retry chain, which - // can be starved under full parallel suite load. - try await waitUntil("requeued command drained", timeoutSeconds: 10) { + try await Task.sleep(nanoseconds: 50_000_000) + #expect(await store.loadCommands().map(\.status) == [.failed]) + #expect(await transport.state.sentIdempotencyKeys.isEmpty) + + let messageID = try #require(await MainActor.run { vm.messages.last?.id }) + await MainActor.run { vm.retryOutboxMessage(messageID) } + try await waitUntil("explicit retry drained", timeoutSeconds: 10) { await store.loadCommands().isEmpty } - #expect(await transport.state.sentIdempotencyKeys == [requeued.id]) + #expect(await transport.state.sentIdempotencyKeys == [preserved.id]) + } + + @Test func `lost queued send ack reconciles history without replay`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let transport = OutboxTestTransport(healthy: false) + let vm = await makeOutboxViewModel(transport: transport, outbox: store) + + await MainActor.run { vm.load() } + try await sendWhileOffline(vm, text: "accepted before disconnect") + await transport.state.setSendFailsAfterRecording(true) + await transport.goOnline() + + try await waitUntil("gateway accepted before ack loss") { + await transport.state.sentIdempotencyKeys.count == 1 + } + try await waitUntil("canonical history retires ambiguous send") { + await store.loadCommands().isEmpty + } + #expect(await transport.state.sentMessages == ["accepted before disconnect"]) + #expect(await transport.state.sentIdempotencyKeys.count == 1) } @Test func `tap retry refreshes createdAt so an expired command can resend`() async throws { @@ -602,6 +1657,7 @@ struct ChatViewModelOutboxTests { OpenClawChatOutboxCommand( id: "c-think", sessionKey: "reasoning-session", + routingContract: "per-sender|main|main", text: "think hard", thinking: "high", createdAt: now, @@ -612,6 +1668,7 @@ struct ChatViewModelOutboxTests { OpenClawChatOutboxCommand( id: "c-plain", sessionKey: "plain-session", + routingContract: "per-sender|main|main", text: "no thinking", thinking: "medium", createdAt: now + 1, @@ -633,7 +1690,7 @@ struct ChatViewModelOutboxTests { } #expect(await MainActor.run { !vm.showsThinkingPicker }) await transport.goOnline() - try await waitUntil("outbox drained") { + try await waitUntil("background send confirmed") { await store.loadCommands().isEmpty } #expect(await transport.state.sentThinkingLevels == ["high", "off"]) @@ -650,6 +1707,7 @@ struct ChatViewModelOutboxTests { OpenClawChatOutboxCommand( id: "c-background", sessionKey: "other-session", + routingContract: "per-sender|main|main", text: "sent from elsewhere", thinking: "off", createdAt: Date().timeIntervalSince1970, @@ -661,7 +1719,7 @@ struct ChatViewModelOutboxTests { await MainActor.run { vm.load() } await transport.goOnline() - try await waitUntil("outbox drained") { + try await waitUntil("background send confirmed without opening its session") { await store.loadCommands().isEmpty } @@ -670,7 +1728,55 @@ struct ChatViewModelOutboxTests { let cached = await store.loadTranscript(sessionKey: "other-session") #expect(cached.map { $0.content.compactMap(\.text).joined() } == ["sent from elsewhere"]) #expect(cached.map(\.idempotencyKey) == ["c-background:user"]) - _ = vm + } + + @Test func `background canonical alias event confirms by idempotency key`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( + id: "c-alias", + sessionKey: "main", + deliverySessionKey: "agent:main:main", + routingContract: "per-sender|main|main", + agentID: "main", + text: "canonical alias", + thinking: "off", + createdAt: Date().timeIntervalSince1970, + status: .queued, + retryCount: 0, + lastError: nil))) + #expect(await store.claimNextCommand()?.id == "c-alias") + #expect(await store.markCommandAwaitingConfirmation(id: "c-alias") == .updated) + let transport = OutboxTestTransport(healthy: false) + let vm = await makeOutboxViewModel(transport: transport, outbox: store, transcriptCache: store) + await MainActor.run { + vm.load() + vm.switchSession(to: "other-session") + } + + transport.emit(.sessionMessage(OpenClawSessionMessageEventPayload( + sessionKey: "agent:main:main", + agentId: "main", + message: OpenClawChatMessage( + role: "user", + content: [OpenClawChatMessageContent( + type: "text", + text: "canonical alias", + mimeType: nil, + fileName: nil, + content: nil)], + timestamp: 1, + idempotencyKey: "c-alias:user"), + messageId: "message-c-alias", + messageSeq: 1))) + + try await waitUntil("canonical alias confirms background command") { + await store.loadCommands().isEmpty + } + let cached = await store.loadTranscript(sessionKey: "main", agentID: "main") + #expect(cached.map(\.idempotencyKey) == ["c-alias:user"]) + #expect(cached.map { $0.content.compactMap(\.text).joined() } == ["canonical alias"]) } @Test func `full queue refuses enqueue and keeps the draft`() async throws { @@ -706,7 +1812,7 @@ struct ChatViewModelOutboxTests { #expect(await store.loadCommands().count == OpenClawChatSQLiteTranscriptCache.maxQueuedCommands) } - @Test func `repeated transport failures climb the backoff ladder then drop health`() async throws { + @Test func `queued send transport failure fails closed until explicit retry`() async throws { let url = try makeOutboxDatabaseURL() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") @@ -716,32 +1822,31 @@ struct ChatViewModelOutboxTests { await MainActor.run { vm.load() } try await sendWhileOffline(vm, text: "stuck in transit") - // Gateway reports healthy but every send throws: the flush must walk - // the retry ladder (streak 1, 2) and then drop health instead of - // retrying at the first rung forever. + // Gateway reports healthy but the send throws. One ambiguous attempt + // must fail closed and drop health without automatic replay. await transport.goOnline() - // healthOK starts false pre-goOnline, so the exhaustion signal is - // the streak walking past the ladder (2 rungs in tests) WITH health - // down again — not the initial offline state. - try await waitUntil("ladder exhausted and health dropped") { - await MainActor.run { vm.outboxTransportFailureStreak >= 3 && !vm.healthOK } + try await waitUntil("ambiguous queued send fails closed") { + let failed = await store.loadCommands().first?.status == .failed + let healthDown = await MainActor.run { !vm.healthOK } + return failed && healthDown } - // Row survives as queued: transport throws never burn durable - // attempts. - let commands = await store.loadCommands() - #expect(commands.map(\.status) == [.queued]) - #expect(commands.map(\.retryCount) == [0]) + let command = try #require(await store.loadCommands().first) + #expect(command.lastError == OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError) + #expect(command.retryCount == 0) + #expect(await transport.state.sentIdempotencyKeys.isEmpty) - // A genuine recovery flushes normally and resets the streak. + // Reconnect only reconciles. Explicit retry is required to send. await transport.state.setSendFails(false) await transport.goOnline() - try await waitUntil("command sent after recovery") { - await transport.state.sentIdempotencyKeys.count == 1 - } - try await waitUntil("row drained") { + try await Task.sleep(nanoseconds: 50_000_000) + #expect(await store.loadCommands().map(\.status) == [.failed]) + + let messageID = try #require(await MainActor.run { vm.messages.last?.id }) + await MainActor.run { vm.retryOutboxMessage(messageID) } + try await waitUntil("explicit retry drains command") { await store.loadCommands().isEmpty } - #expect(await MainActor.run { vm.outboxTransportFailureStreak } == 0) + #expect(await transport.state.sentIdempotencyKeys == [command.id]) } @Test func `deleting a queued message removes bubble and durable row`() async throws { @@ -749,7 +1854,7 @@ struct ChatViewModelOutboxTests { defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") let transport = OutboxTestTransport(healthy: false) - let vm = await makeOutboxViewModel(transport: transport, outbox: store) + let vm = await makeOutboxViewModel(transport: transport, outbox: store, transcriptCache: store) await MainActor.run { vm.load() } try await sendWhileOffline(vm, text: "changed my mind") @@ -757,6 +1862,19 @@ struct ChatViewModelOutboxTests { let messageID = try #require(await MainActor.run { vm.messages.first { vm.outboxState(for: $0.id) == .queued }?.id }) + let commandID = try #require(await store.loadCommands().first?.id) + await store.storeTranscript( + sessionKey: "main", + messages: [OpenClawChatMessage( + role: "user", + content: [OpenClawChatMessageContent( + type: "text", + text: "changed my mind", + mimeType: nil, + fileName: nil, + content: nil)], + timestamp: 1, + idempotencyKey: "\(commandID):user")]) await MainActor.run { vm.deleteOutboxMessage(messageID) } // The bubble disappears only after the durable delete lands, so a @@ -768,69 +1886,16 @@ struct ChatViewModelOutboxTests { try await waitUntil("durable row deleted") { await store.loadCommands().isEmpty } - } -} - -/// Holds `deleteCommand` until released so tests can pin the exact window -/// where a user delete races an in-flight flush pass (row still visible in -/// the pass's snapshot). -private final class HeldDeleteOutbox: @unchecked Sendable, OpenClawChatCommandOutbox { - private let base: OpenClawChatSQLiteTranscriptCache - private let gate = DeleteGate() - - init(base: OpenClawChatSQLiteTranscriptCache) { - self.base = base - } - - func releaseHeldDeletes() async { - await self.gate.open() - } - - /// Fired (once) just before the claim forwards, on the flush's task: - /// lets tests land a user delete inside the claim's await window. - private var onClaim: (@Sendable () async -> Void)? - - func setOnClaim(_ hook: @escaping @Sendable () async -> Void) { - self.onClaim = hook - } - - func enqueueCommand(_ command: OpenClawChatOutboxCommand) async -> Bool { - await self.base.enqueueCommand(command) - } - - func loadCommands() async -> [OpenClawChatOutboxCommand] { - await self.base.loadCommands() - } - - @discardableResult - func recoverInterruptedSends() async -> Bool { - await self.base.recoverInterruptedSends() - } - - @discardableResult - func markCommandSending(id: String) async -> Bool { - if let hook = self.onClaim { - self.onClaim = nil - await hook() + try await waitUntil("canceled bubble removed from transcript cache") { + await store.loadTranscript(sessionKey: "main", agentID: "main").isEmpty } - return await self.base.markCommandSending(id: id) - } - func markCommandQueued(id: String, retryCount: Int, lastError: String?) async { - await self.base.markCommandQueued(id: id, retryCount: retryCount, lastError: lastError) - } - - func markCommandFailed(id: String, retryCount: Int, lastError: String?) async { - await self.base.markCommandFailed(id: id, retryCount: retryCount, lastError: lastError) - } - - func markCommandRetried(id: String) async { - await self.base.markCommandRetried(id: id) - } - - func deleteCommand(id: String) async { - await self.gate.wait() - await self.base.deleteCommand(id: id) + // A later cold open must not repaint the canceled local bubble as an + // ordinary sent transcript row after its outbox metadata is gone. + let recreated = await makeOutboxViewModel(transport: transport, outbox: store, transcriptCache: store) + await MainActor.run { recreated.load() } + try await Task.sleep(nanoseconds: 100_000_000) + #expect(await userTexts(recreated).isEmpty) } } @@ -840,7 +1905,9 @@ private actor DeleteGate { func open() { self.isOpen = true - for waiter in self.waiters { waiter.resume() } + for waiter in self.waiters { + waiter.resume() + } self.waiters.removeAll() } @@ -880,6 +1947,34 @@ extension ChatViewModelOutboxTests { #expect(await MainActor.run { queuedStateCount(vm) } == 1) } + @Test func `double submit during slash validation enqueues once`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let transport = OutboxTestTransport(healthy: false, supportsSlashCommands: true) + let commandListGate = DeleteGate() + await transport.state.setCommandListGate(commandListGate) + let vm = await makeOutboxViewModel(transport: transport, outbox: store) + + await MainActor.run { + vm.input = "/remote-command" + vm.send() + } + await transport.state.waitUntilCommandListStarted() + await MainActor.run { + vm.input = "newer draft" + #expect(!vm.canSend) + vm.send() + } + await commandListGate.open() + + try await waitUntil("slash command queued once") { + await store.loadCommands().count == 1 + } + #expect(await store.loadCommands().map(\.text) == ["/remote-command"]) + #expect(await MainActor.run { vm.input } == "newer draft") + } + @Test func `stale history after the flush ack cannot evict the sent turn from the cache`() async throws { let url = try makeOutboxDatabaseURL() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } @@ -899,8 +1994,8 @@ extension ChatViewModelOutboxTests { ] as [String: Any]) await transport.state.setStaleHistoryRows([staleRow]) await transport.goOnline() - try await waitUntil("outbox drained") { - await store.loadCommands().isEmpty + try await waitUntil("acknowledgement awaits canonical history") { + await store.loadCommands().map(\.status) == [.awaitingConfirmation] } try await waitUntil("stale refresh applied") { await MainActor.run { vm.messages.contains { message in @@ -908,15 +2003,20 @@ extension ChatViewModelOutboxTests { } } } // Wait out the chained cache writes, then cold-reopen offline: the - // sent turn must still pre-paint even though the outbox row is gone - // and the last history snapshot did not contain it. + // sent turn must still pre-paint while the durable confirmation row + // protects it from the lagging snapshot. if let pendingWrite = await MainActor.run(body: { vm.pendingCacheWriteTask }) { await pendingWrite.value } - let cached = await store.loadTranscript(sessionKey: "main") + let cached = await store.loadTranscript(sessionKey: "main", agentID: "main") #expect(cached.contains { message in message.content.contains { $0.text == "must survive stale history" } }) + await transport.state.setStaleHistoryRows(nil) + await MainActor.run { vm.refresh() } + try await waitUntil("fresh history confirms send") { + await store.loadCommands().isEmpty + } } @Test func `send before restore adopts durable rows still queues behind them`() async throws { @@ -927,6 +2027,7 @@ extension ChatViewModelOutboxTests { #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( id: UUID().uuidString, sessionKey: "main", + routingContract: "per-sender|main|main", text: "queued by the previous launch", thinking: "off", createdAt: Date().timeIntervalSince1970 - 60, @@ -965,6 +2066,7 @@ extension ChatViewModelOutboxTests { #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( id: UUID().uuidString, sessionKey: "second", + routingContract: "per-sender|main|main", text: "backlog in second session", thinking: "off", createdAt: Date().timeIntervalSince1970 - 60, @@ -1073,73 +2175,169 @@ extension ChatViewModelOutboxTests { ]) } - @Test func `deleting during the claim await never sends`() async throws { + @Test func `a stale second view model cannot cancel a claimed send`() async throws { let url = try makeOutboxDatabaseURL() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") - let outbox = HeldDeleteOutbox(base: store) let transport = OutboxTestTransport(healthy: false) - let vm = await makeOutboxViewModel(transport: transport, outbox: outbox) + let sender = await makeOutboxViewModel(transport: transport, outbox: store) - await MainActor.run { vm.load() } - try await sendWhileOffline(vm, text: "deleted inside the claim") - let messageID = try #require(await MainActor.run { - vm.messages.first { vm.outboxState(for: $0.id) == .queued }?.id - }) - - // The delete lands inside markCommandSending's await window: after - // the pre-claim tombstone check, before the claim resolves. The - // post-claim recheck must catch it. - outbox.setOnClaim { - await MainActor.run { vm.deleteOutboxMessage(messageID) } + await MainActor.run { sender.load() } + try await sendWhileOffline(sender, text: "already claimed") + let observer = await makeOutboxViewModel(transport: transport, outbox: store) + await MainActor.run { observer.load() } + try await waitUntil("second view model restores queued bubble") { + await MainActor.run { queuedStateCount(observer) == 1 } } + + let sendGate = DeleteGate() + await transport.state.setHeldSendGate(sendGate) await transport.goOnline() - // The recheck path awaits the (held) durable delete, so give the - // flush a beat to reach it and prove nothing was sent meanwhile. - try await Task.sleep(nanoseconds: 100_000_000) - #expect(await transport.state.sentIdempotencyKeys.isEmpty) - await outbox.releaseHeldDeletes() - try await waitUntil("flush drains without sending") { - await MainActor.run { queuedStateCount(vm) == 0 } + try await waitUntil("first view model claims row") { + await store.loadCommands().map(\.status) == [.sending] } - #expect(await transport.state.sentIdempotencyKeys.isEmpty) - try await waitUntil("durable row deleted") { + let messageID = try #require(await MainActor.run { + observer.messages.first { observer.outboxState(for: $0.id) == .queued }?.id + }) + await MainActor.run { observer.deleteOutboxMessage(messageID) } + try await waitUntil("observer adopts sending status") { + await MainActor.run { observer.outboxState(for: messageID) == .sending } + } + #expect(await store.loadCommands().map(\.status) == [.sending]) + + await sendGate.open() + try await waitUntil("claimed send confirms") { await store.loadCommands().isEmpty } + #expect(await transport.state.sentMessages == ["already claimed"]) } - @Test func `deleting a queued bubble mid-flush never sends it`() async throws { + @Test func `both view models remove a command canceled by either one`() async throws { let url = try makeOutboxDatabaseURL() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") - let outbox = HeldDeleteOutbox(base: store) let transport = OutboxTestTransport(healthy: false) - let vm = await makeOutboxViewModel(transport: transport, outbox: outbox) + let first = await makeOutboxViewModel(transport: transport, outbox: store) + + await MainActor.run { first.load() } + try await sendWhileOffline(first, text: "cancel everywhere") + let second = await makeOutboxViewModel(transport: transport, outbox: store) + await MainActor.run { second.load() } + try await waitUntil("both views show queued command") { + await MainActor.run { queuedStateCount(first) == 1 && queuedStateCount(second) == 1 } + } + + let firstID = try #require(await MainActor.run { first.messages.last?.id }) + let secondID = try #require(await MainActor.run { second.messages.last?.id }) + await MainActor.run { first.deleteOutboxMessage(firstID) } + try await waitUntil("first view cancels durable row") { + let rowsEmpty = await store.loadCommands().isEmpty + let textEmpty = await userTexts(first).isEmpty + return rowsEmpty && textEmpty + } + + await MainActor.run { second.deleteOutboxMessage(secondID) } + try await waitUntil("second view removes stale canceled bubble") { + await userTexts(second).isEmpty + } + #expect(await transport.state.sentMessages.isEmpty) + } + + @Test func `cancellation invalidates another views in flight restore snapshot`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let transport = OutboxTestTransport(healthy: false) + let cancelingView = await makeOutboxViewModel(transport: transport, outbox: store) + + await MainActor.run { cancelingView.load() } + try await sendWhileOffline(cancelingView, text: "stale snapshot") + #expect(await store.enqueueCommand(outboxTestCommand( + id: "c-survivor", + text: "survivor", + createdAt: Date().timeIntervalSince1970 + 1))) + let staleOutbox = SnapshotHoldingOutbox(base: store) + await staleOutbox.holdNextLoad() + let staleView = await MainActor.run { + OpenClawChatViewModel( + sessionKey: "main", + transport: transport, + activeAgentId: "main", + sessionRoutingContract: "per-sender|main|main", + outbox: staleOutbox) + } + await MainActor.run { staleView.load() } + await staleOutbox.waitUntilSnapshotCaptured() + + let messageID = try #require(await MainActor.run { cancelingView.messages.last?.id }) + await MainActor.run { cancelingView.deleteOutboxMessage(messageID) } + try await waitUntil("durable cancellation broadcasts") { + await store.loadCommands().map(\.id) == ["c-survivor"] + } + await staleOutbox.releaseSnapshot() + try await Task.sleep(nanoseconds: 100_000_000) + + try await waitUntil("invalidated restore reloads surviving command") { + await userTexts(staleView) == ["survivor"] + } + #expect(await MainActor.run { queuedStateCount(staleView) } == 1) + #expect(await transport.state.sentMessages.isEmpty) + } + + @Test func `confirmation invalidates cancellation claimed row reload`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let outbox = SnapshotHoldingOutbox(base: store) + let transport = OutboxTestTransport(healthy: false) + let vm = await MainActor.run { + OpenClawChatViewModel( + sessionKey: "main", + transport: transport, + activeAgentId: "main", + sessionRoutingContract: "per-sender|main|main", + outbox: outbox) + } await MainActor.run { vm.load() } - try await sendWhileOffline(vm, text: "changed my mind mid-flush") - - // User deletes the bubble; the durable row deletion is held, so the - // next flush pass still sees the row in its snapshot — the exact - // race window the tombstone protects. - let messageID = try #require(await MainActor.run { - vm.messages.first { vm.outboxState(for: $0.id) == .queued }?.id - }) + try await sendWhileOffline(vm, text: "confirmed during delete") + let command = try #require(await store.claimNextCommand()) + let messageID = try #require(await MainActor.run { vm.messages.last?.id }) + await outbox.holdNextLoad() await MainActor.run { vm.deleteOutboxMessage(messageID) } + await outbox.waitUntilSnapshotCaptured() + #expect(await store.confirmCommand(id: command.id) == .updated) + await outbox.releaseSnapshot() + try await waitUntil("confirmation clears stale sending badge") { + await MainActor.run { vm.outboxState(for: messageID) == nil } + } + #expect(await userTexts(vm) == ["confirmed during delete"]) + } + + @Test func `route replacement cannot retarget a claimed command`() async throws { + let url = try makeOutboxDatabaseURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let transport = OutboxTestTransport(healthy: false) + let vm = await makeOutboxViewModel(transport: transport, outbox: store) + + await MainActor.run { vm.load() } + try await sendWhileOffline(vm, text: "belongs to the old route") + let sendGate = DeleteGate() + await transport.state.setHeldSendGate(sendGate) await transport.goOnline() - try await waitUntil("flush pass drains without sending") { - await MainActor.run { queuedStateCount(vm) == 0 } + try await waitUntil("old route claims command") { + await store.loadCommands().map(\.status) == [.sending] } - #expect(await transport.state.sentIdempotencyKeys.isEmpty) - // Once the held deletion completes, the row is gone for good and a - // later flush still sends nothing. - await outbox.releaseHeldDeletes() - try await waitUntil("durable row deleted") { - await store.loadCommands().isEmpty + await transport.state.replaceRoute() + await sendGate.open() + try await waitUntil("pre-dispatch route cancellation requeues command") { + await store.loadCommands().map(\.status) == [.queued] } - await transport.emit(.health(ok: true)) - #expect(await transport.state.sentIdempotencyKeys.isEmpty) + #expect(await transport.state.sentMessages.isEmpty) + #expect(await store.loadCommands().first?.lastError == nil) + #expect(await MainActor.run { !vm.healthOK }) } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index 4959d528b77..b749404b641 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -192,6 +192,7 @@ private func makeViewModel( sessionKey: String = "main", activeAgentId: String? = nil, historyResponses: [OpenClawChatHistoryPayload], + sessionRoutingContract: String? = nil, sessionsResponses: [OpenClawChatSessionsListResponse] = [], modelResponses: [[OpenClawChatModelChoice]] = [], commandResponses: [[OpenClawChatCommandChoice]] = [], @@ -216,7 +217,9 @@ private func makeViewModel( // Default to a throwaway suite so model selections in unrelated tests never // write favorites/recents into the test host's standard UserDefaults. let pickerStore = modelPickerStore - ?? ChatModelPickerStore(defaults: UserDefaults(suiteName: "ChatViewModelTests.\(UUID().uuidString)") ?? .standard) + ?? + ChatModelPickerStore(defaults: UserDefaults(suiteName: "ChatViewModelTests.\(UUID().uuidString)") ?? + .standard) let transport = TestChatTransport( historyResponses: historyResponses, sessionsResponses: sessionsResponses, @@ -238,6 +241,7 @@ private func makeViewModel( sessionKey: sessionKey, transport: transport, activeAgentId: activeAgentId, + sessionRoutingContract: sessionRoutingContract, modelPickerStore: pickerStore, initialThinkingLevel: initialThinkingLevel, onSessionChanged: onSessionChanged, @@ -454,6 +458,8 @@ private actor TestChatTransportState { var resetSessionKeys: [String] = [] var compactSessionKeys: [String] = [] var sentSessionKeys: [String] = [] + var sentAgentIDs: [String?] = [] + var sentRoutingContracts: [String?] = [] var sentMessages: [String] = [] var sentRunIds: [String] = [] var commandSessionKeys: [String] = [] @@ -574,6 +580,25 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor thinkingLevel: "off") } + func sendMessage( + sessionKey: String, + agentID: String?, + expectedSessionRoutingContract: String?, + message: String, + thinking: String, + idempotencyKey: String, + attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse + { + await self.state.sentAgentIDsAppend(agentID) + await self.state.sentRoutingContractsAppend(expectedSessionRoutingContract) + return try await self.sendMessage( + sessionKey: sessionKey, + message: message, + thinking: thinking, + idempotencyKey: idempotencyKey, + attachments: attachments) + } + func sendMessage( sessionKey: String, message: String, @@ -687,6 +712,14 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor await self.state.sentMessages } + func sentAgentIDs() async -> [String?] { + await self.state.sentAgentIDs + } + + func sentRoutingContracts() async -> [String?] { + await self.state.sentRoutingContracts + } + func commandSessionKeys() async -> [String] { await self.state.commandSessionKeys } @@ -819,6 +852,14 @@ extension TestChatTransportState { self.sentSessionKeys.append(v) } + fileprivate func sentAgentIDsAppend(_ v: String?) { + self.sentAgentIDs.append(v) + } + + fileprivate func sentRoutingContractsAppend(_ v: String?) { + self.sentRoutingContracts.append(v) + } + fileprivate func sentMessagesAppend(_ v: String) { self.sentMessages.append(v) } @@ -939,12 +980,12 @@ struct ChatViewModelTests { let olderUser = chatTextMessage( role: "user", text: "repeat request", - timestamp: now - 10_000, + timestamp: now - 10000, idempotencyKey: "older:prompt") let olderAssistant = chatTextMessage( role: "assistant", text: "older reply", - timestamp: now - 9_000, + timestamp: now - 9000, idempotencyKey: "older:assistant") let existingHistory = historyPayload(messages: [olderUser, olderAssistant]) let (_, vm) = await makeViewModel( @@ -1338,6 +1379,110 @@ struct ChatViewModelTests { #expect(await MainActor.run { vm.messages.isEmpty }) } + @Test func `live send binds the captured agent and routing contract`() async throws { + let contract = "per-sender|main|reviewer" + let (transport, vm) = await makeViewModel( + activeAgentId: "reviewer", + historyResponses: [historyPayload(), historyPayload()], + sessionRoutingContract: contract) + try await loadAndWaitBootstrap(vm: vm) + + await sendUserMessage(vm, text: "route safely") + _ = try await waitForLastSentRunId(transport) + + #expect(await transport.sentAgentIDs() == ["reviewer"]) + #expect(await transport.sentRoutingContracts() == [contract]) + } + + @Test func `alias routing contract change restarts bootstrap`() async throws { + let historyCalls = AsyncCounter() + let oldHistory = historyPayload(messages: [ + chatTextMessage(role: "assistant", text: "old route", timestamp: 1), + ]) + let newHistory = historyPayload(messages: [ + chatTextMessage(role: "assistant", text: "new route", timestamp: 2), + ]) + let (_, vm) = await makeViewModel( + activeAgentId: "main", + historyResponses: [oldHistory, newHistory], + requestHistoryHook: { _ in _ = await historyCalls.increment() }) + try await loadAndWaitBootstrap(vm: vm) + try await waitUntil("initial route history") { + await MainActor.run { vm.messages.first?.content.first?.text == "old route" } + } + + await MainActor.run { + vm.syncDeliveryIdentity( + activeAgentId: "work", + sessionRoutingContract: "per-sender|work-main|work") + } + + try await waitUntil("replacement route history") { + guard await historyCalls.current() == 2 else { return false } + return await MainActor.run { vm.messages.first?.content.first?.text == "new route" } + } + } + + @Test func `custom main routing contract change restarts bootstrap`() async throws { + let historyCalls = AsyncCounter() + let oldHistory = historyPayload( + sessionKey: "agent:ops:work", + messages: [chatTextMessage(role: "assistant", text: "old scope", timestamp: 1)]) + let newHistory = historyPayload( + sessionKey: "agent:ops:work", + messages: [chatTextMessage(role: "assistant", text: "new scope", timestamp: 2)]) + let (_, vm) = await makeViewModel( + sessionKey: "agent:ops:work", + activeAgentId: "ops", + historyResponses: [oldHistory, newHistory], + sessionRoutingContract: "global|work|ops", + requestHistoryHook: { _ in _ = await historyCalls.increment() }) + try await loadAndWaitBootstrap(vm: vm) + try await waitUntil("initial custom main history") { + await MainActor.run { vm.messages.first?.content.first?.text == "old scope" } + } + + await MainActor.run { + vm.syncSessionRoutingContract("per-sender|work|ops") + } + + try await waitUntil("replacement custom main history") { + guard await historyCalls.current() == 2 else { return false } + return await MainActor.run { vm.messages.first?.content.first?.text == "new scope" } + } + } + + @Test func `unscoped agent update replaces an active bootstrap`() async throws { + let firstHistoryGate = AsyncGate() + let historyCalls = AsyncCounter() + let firstHistory = historyPayload( + sessionKey: "Matrix:!Room:example.org", + messages: [chatTextMessage(role: "assistant", text: "old agent", timestamp: 1)]) + let replacementHistory = historyPayload( + sessionKey: "Matrix:!Room:example.org", + messages: [chatTextMessage(role: "assistant", text: "new agent", timestamp: 2)]) + let (_, vm) = await makeViewModel( + sessionKey: "Matrix:!Room:example.org", + historyResponses: [firstHistory, replacementHistory], + requestHistoryHook: { _ in + let call = await historyCalls.increment() + if call == 1 { await firstHistoryGate.wait() } + }) + + await MainActor.run { vm.load() } + try await waitUntil("first unscoped bootstrap") { await historyCalls.current() == 1 } + await MainActor.run { vm.syncActiveAgentId("work") } + try await waitUntil("replacement unscoped bootstrap") { + guard await historyCalls.current() == 2 else { return false } + return await MainActor.run { + !vm.isLoading && vm.messages.first?.content.first?.text == "new agent" + } + } + await firstHistoryGate.open() + try await Task.sleep(for: .milliseconds(25)) + #expect(await MainActor.run { vm.messages.first?.content.first?.text } == "new agent") + } + @Test func `intermediate session message preserves pending recovery snapshot`() async throws { let historyGate = AsyncGate() let historyCalls = AsyncCounter() @@ -3676,6 +3821,51 @@ struct ChatViewModelTests { #expect(await MainActor.run { vm.messages.isEmpty }) } + @Test func `exact ordinary session matches before agent bootstrap`() async { + let matches = await MainActor.run { + ( + OpenClawChatViewModel.matchesCurrentSessionKey( + incoming: "main", + agentId: "work", + current: "main", + mainSessionKey: "main"), + OpenClawChatViewModel.matchesCurrentSessionKey( + incoming: "main", + agentId: "work", + current: "main", + mainSessionKey: "main", + activeAgentId: "main")) + } + #expect(matches.0) + #expect(!matches.1) + } + + @Test func `agent scoped opaque event matches only its presentation owner`() async { + let matches = await MainActor.run { + ( + OpenClawChatViewModel.matchesCurrentSessionKey( + incoming: "agent:reviewer:Matrix:Channel:!MixedRoom:example.org", + current: "Matrix:Channel:!MixedRoom:example.org", + mainSessionKey: "main", + activeAgentId: "reviewer"), + OpenClawChatViewModel.matchesCurrentSessionKey( + incoming: "agent:reviewer:Matrix:Channel:!MixedRoom:example.org", + agentId: "work", + current: "Matrix:Channel:!MixedRoom:example.org", + mainSessionKey: "main", + activeAgentId: "reviewer"), + OpenClawChatViewModel.matchesCurrentSessionKey( + incoming: "agent:reviewer:Matrix:Channel:!MixedRoom:example.org", + current: "Matrix:Channel:!MixedRoom:example.org", + mainSessionKey: "main", + activeAgentId: "work")) + } + + #expect(matches.0) + #expect(!matches.1) + #expect(!matches.2) + } + @Test func `ignores agent main session message for different current main alias`() async throws { let now = Date().timeIntervalSince1970 * 1000 let (transport, vm) = await makeViewModel(historyResponses: [historyPayload()]) diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index d776c1075bf..cc35279ed3f 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -306,15 +306,16 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda } private static func invokeRequestData(id: String, command: String, paramsJSON: String?) -> Data { + let payload: [String: Any] = [ + "id": id, + "nodeId": "test-node", + "command": command, + "paramsJSON": paramsJSON ?? NSNull(), + ] let frame: [String: Any] = [ "type": "event", "event": "node.invoke.request", - "payload": [ - "id": id, - "nodeId": "test-node", - "command": command, - "paramsJSON": paramsJSON ?? (NSNull() as Any), - ], + "payload": payload, ] return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data() } @@ -594,7 +595,8 @@ struct GatewayNodeSessionTests { clientId: "openclaw-ios-test", clientMode: "node", clientDisplayName: "iOS Test", - includeDeviceIdentity: false) + includeDeviceIdentity: false, + deviceAuthGatewayID: "gw-a") try await gateway.connect( url: #require(URL(string: "ws://first.example.invalid")), @@ -606,7 +608,8 @@ struct GatewayNodeSessionTests { onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) - let firstRoute = try #require(await gateway.currentRoute()) + let firstRoute = try #require(await gateway.currentRoute(ifGatewayID: "gw-a")) + #expect(await gateway.currentRoute(ifGatewayID: "GW-A") == nil) try await gateway.connect( url: #require(URL(string: "ws://second.example.invalid")), @@ -633,6 +636,16 @@ struct GatewayNodeSessionTests { } catch is CancellationError { // Expected: the route lease belongs to the first channel. } + do { + _ = try await gateway.request( + method: "exec.approval.get", + paramsJSON: "{}", + ifCurrentRoute: firstRoute, + distinguishPreDispatchRouteChange: true) + Issue.record("typed stale route request unexpectedly reached the replacement channel") + } catch is GatewayNodeSessionRequestError { + // Expected: callers can distinguish a request rejected before dispatch. + } let replacementTask = try #require(session.latestTask()) #expect(replacementTask.sentRequestCount(method: "node.event") == 0) #expect(replacementTask.sentRequestCount(method: "exec.approval.get") == 0) diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index b45087a5c1a..927730cf4d7 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -16,7 +16,7 @@ Availability: iPhone app builds are distributed through Apple channels when enab - Receives `node.invoke` commands and reports node status events. - Browses the selected agent's workspace read-only from the Agents surface (Files): directory drill-down, syntax-highlighted text previews, image previews, and share-sheet export. No write operations; previews are size-capped by the gateway. - Keeps a small read-only offline cache of recent chat sessions and transcripts per paired gateway: cold opens paint the last known transcript immediately and refresh once the gateway responds, recent chats stay browsable while disconnected, and reset/forget purges the protected local cache. -- Queues text messages sent while disconnected in a durable per-gateway outbox (up to 50): queued bubbles show in the transcript, flush in order on reconnect with idempotency keys so nothing sends twice, retry with backoff before surfacing as "Not sent" with retry/delete in the message context menu, and expire instead of sending after 48 hours offline; reset/forget clears the queue with the cache. +- Queues text messages sent while disconnected in a durable per-gateway outbox (up to 50): queued bubbles show in the transcript, flush in order on reconnect with idempotent retries, remain durable until canonical history confirms the send, retry with backoff before surfacing a retry/delete action, and expire instead of sending after 48 hours offline; reset/forget clears the queue with the cache. ## Requirements diff --git a/packages/gateway-protocol/src/index.test.ts b/packages/gateway-protocol/src/index.test.ts index e7667adacb7..768d8eb5e17 100644 --- a/packages/gateway-protocol/src/index.test.ts +++ b/packages/gateway-protocol/src/index.test.ts @@ -794,6 +794,12 @@ describe("validateChatSendParams", () => { }; expect(validateChatSendParams(base)).toBe(true); + expect( + validateChatSendParams({ + ...base, + expectedSessionRoutingContract: "per-sender|main|main", + }), + ).toBe(true); expect(validateChatSendParams({ ...base, fastAutoOnSeconds: 2 })).toBe(true); expect(validateChatSendParams({ ...base, fastAutoOnSeconds: 0 })).toBe(false); }); diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 970f0924829..b52ffee0447 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -270,6 +270,7 @@ import { errorShape, type GatewayFrame, GatewayFrameSchema, + GATEWAY_SERVER_CAPS, type HelloOk, HelloOkSchema, type LogsTailParams, @@ -1130,6 +1131,7 @@ export function formatValidationErrors(errors: ValidationError[] | null | undefi // protocol surface changes. export { ConnectParamsSchema, + GATEWAY_SERVER_CAPS, HelloOkSchema, RequestFrameSchema, ResponseFrameSchema, diff --git a/packages/gateway-protocol/src/schema/frames.ts b/packages/gateway-protocol/src/schema/frames.ts index bba7504a7ee..ea431909dde 100644 --- a/packages/gateway-protocol/src/schema/frames.ts +++ b/packages/gateway-protocol/src/schema/frames.ts @@ -3,6 +3,10 @@ import { Type } from "typebox"; import { GatewayClientIdSchema, GatewayClientModeSchema, NonEmptyString } from "./primitives.js"; import { SnapshotSchema, StateVersionSchema } from "./snapshot.js"; +export const GATEWAY_SERVER_CAPS = { + CHAT_SEND_ROUTING_CONTRACT: "chat-send-routing-contract", +} as const; + /** * Top-level gateway frame schemas. * @@ -97,6 +101,7 @@ export const HelloOkSchema = Type.Object( { methods: Type.Array(NonEmptyString), events: Type.Array(NonEmptyString), + capabilities: Type.Optional(Type.Array(NonEmptyString)), }, { additionalProperties: false }, ), diff --git a/packages/gateway-protocol/src/schema/logs-chat.ts b/packages/gateway-protocol/src/schema/logs-chat.ts index 9137802f3b7..eb3b7afae22 100644 --- a/packages/gateway-protocol/src/schema/logs-chat.ts +++ b/packages/gateway-protocol/src/schema/logs-chat.ts @@ -96,6 +96,7 @@ export const ChatSendParamsSchema = Type.Object( systemInputProvenance: Type.Optional(InputProvenanceSchema), systemProvenanceReceipt: Type.Optional(Type.String()), suppressCommandInterpretation: Type.Optional(Type.Boolean()), + expectedSessionRoutingContract: Type.Optional(NonEmptyString), idempotencyKey: NonEmptyString, }, { additionalProperties: false }, diff --git a/src/config/sessions/main-session.ts b/src/config/sessions/main-session.ts index 3eac4225382..1e0630cca9b 100644 --- a/src/config/sessions/main-session.ts +++ b/src/config/sessions/main-session.ts @@ -7,6 +7,7 @@ import { import type { SessionScope } from "./types.js"; const FALLBACK_DEFAULT_AGENT_ID = "main"; +export const SESSION_ROUTING_CHANGED_ERROR_REASON = "session-routing-changed"; /** Builds the canonical main session key for an agent. */ function buildMainSessionKey(agentId: string, mainKey?: string): string { @@ -27,6 +28,20 @@ export function resolveMainSessionKey(cfg?: { return buildMainSessionKey(defaultAgentId, cfg?.session?.mainKey); } +/** Stable fingerprint for the config values that canonicalize chat session keys. */ +export function resolveSessionRoutingContract(cfg?: { + session?: { scope?: SessionScope; mainKey?: string }; + agents?: { list?: Array<{ id?: string; default?: boolean }> }; +}): string { + const agents = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : []; + const defaultAgentId = + agents.find((agent) => agent?.default)?.id ?? agents[0]?.id ?? FALLBACK_DEFAULT_AGENT_ID; + const scope = cfg?.session?.scope ?? "per-sender"; + return [scope, normalizeMainKey(cfg?.session?.mainKey), normalizeAgentId(defaultAgentId)].join( + "|", + ); +} + export { resolveAgentIdFromSessionKey }; /** Resolves the main session key for one explicit agent. */ diff --git a/src/gateway/server-methods/chat.error-broadcast.test.ts b/src/gateway/server-methods/chat.error-broadcast.test.ts index 62db20b706f..5f93d38f9cb 100644 --- a/src/gateway/server-methods/chat.error-broadcast.test.ts +++ b/src/gateway/server-methods/chat.error-broadcast.test.ts @@ -27,6 +27,93 @@ function createMockContext() { } describe("chat.send error broadcast", () => { + it("rejects a stale expected session routing contract before dispatch", async () => { + const ctx = createMockContext(); + const respond = vi.fn(); + + await chatHandlers["chat.send"]({ + params: { + sessionKey: "main", + message: "hello", + expectedSessionRoutingContract: "global|main|main", + idempotencyKey: "test-stale-routing", + }, + respond: respond as never, + context: ctx as unknown as GatewayRequestContext, + req: {} as never, + client: null as never, + isWebchatConnect: () => false, + }); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: "INVALID_REQUEST", + details: { reason: "session-routing-changed" }, + }), + ); + expect(ctx.addChatRun).not.toHaveBeenCalled(); + expect(ctx.broadcast).not.toHaveBeenCalled(); + }); + + it("returns an idempotent cached send after session routing changes", async () => { + const ctx = createMockContext(); + const respond = vi.fn(); + ctx.dedupe.set("chat:test-cached-routing", { + ts: Date.now(), + ok: true, + payload: { runId: "test-cached-routing", status: "started" }, + }); + + await chatHandlers["chat.send"]({ + params: { + sessionKey: "main", + message: "hello", + expectedSessionRoutingContract: "global|main|main", + idempotencyKey: "test-cached-routing", + }, + respond: respond as never, + context: ctx as unknown as GatewayRequestContext, + req: {} as never, + client: null as never, + isWebchatConnect: () => false, + }); + + expect(respond).toHaveBeenCalledWith( + true, + { runId: "test-cached-routing", status: "started" }, + undefined, + { cached: true }, + ); + }); + + it("rejects a stale routing contract before a stop side effect", async () => { + const ctx = createMockContext(); + const respond = vi.fn(); + + await chatHandlers["chat.send"]({ + params: { + sessionKey: "main", + message: "/stop", + expectedSessionRoutingContract: "global|main|main", + idempotencyKey: "test-stale-stop-routing", + }, + respond: respond as never, + context: ctx as unknown as GatewayRequestContext, + req: {} as never, + client: null as never, + isWebchatConnect: () => false, + }); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ details: { reason: "session-routing-changed" } }), + ); + expect(ctx.addChatRun).not.toHaveBeenCalled(); + }); + it("should broadcast error when addChatRun throws", async () => { const ctx = createMockContext(); const respond = vi.fn(); diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index a0bc758b303..65424450303 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -69,6 +69,10 @@ import { resolveSessionWorkStartError, updateSessionStoreEntry, } from "../../config/sessions.js"; +import { + resolveSessionRoutingContract, + SESSION_ROUTING_CHANGED_ERROR_REASON, +} from "../../config/sessions/main-session.js"; import { resolveMirroredTranscriptText } from "../../config/sessions/transcript-mirror.js"; import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -3663,6 +3667,7 @@ export const chatHandlers: GatewayRequestHandlers = { systemInputProvenance?: InputProvenance; systemProvenanceReceipt?: string; suppressCommandInterpretation?: boolean; + expectedSessionRoutingContract?: string; idempotencyKey: string; }; const suppressCommandInterpretation = p.suppressCommandInterpretation === true; @@ -3750,6 +3755,20 @@ export const chatHandlers: GatewayRequestHandlers = { ); const sessionLoadMs = roundedChatSendTimingMs(performance.now() - sessionLoadStartedAtMs); const { cfg, storePath, entry, canonicalKey: sessionKey, legacyKey } = sessionLoadResult; + const expectedSessionRoutingContract = normalizeOptionalText(p.expectedSessionRoutingContract); + const sessionRoutingChanged = (candidateConfig: OpenClawConfig) => + expectedSessionRoutingContract !== undefined && + expectedSessionRoutingContract.toLowerCase() !== + resolveSessionRoutingContract(candidateConfig); + const respondSessionRoutingChanged = () => { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "session routing changed; review and retry", { + details: { reason: SESSION_ROUTING_CHANGED_ERROR_REASON }, + }), + ); + }; const selectedAgent = validateChatSelectedAgent({ cfg, requestedSessionKey: rawSessionKey, @@ -3819,6 +3838,10 @@ export const chatHandlers: GatewayRequestHandlers = { } if (stopCommand) { + if (sessionRoutingChanged(cfg)) { + respondSessionRoutingChanged(); + return; + } const defaultAgentId = resolveDefaultAgentId(cfg); const stopAgentId = sessionKey === "global" ? (selectedAgent.agentId ?? defaultAgentId) : selectedAgent.agentId; @@ -3902,6 +3925,12 @@ export const chatHandlers: GatewayRequestHandlers = { }); return; } + // Cached/in-flight retries are already bound to their original target and + // must remain queryable after config changes. Gate only a new dispatch. + if (sessionRoutingChanged(cfg)) { + respondSessionRoutingChanged(); + return; + } const archivedSessionError = resolveSessionWorkStartError(sessionKey, entry); if (archivedSessionError) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, archivedSessionError)); @@ -4018,7 +4047,11 @@ export const chatHandlers: GatewayRequestHandlers = { }); return; } - const latestEntry = loadSessionEntry(rawSessionKey, sessionLoadOptions).entry; + const latestSession = loadSessionEntry(rawSessionKey, sessionLoadOptions); + if (sessionRoutingChanged(latestSession.cfg)) { + throw new Error(SESSION_ROUTING_CHANGED_ERROR_REASON); + } + const latestEntry = latestSession.entry; if (entry && !latestEntry) { throw new Error(`Session "${sessionKey}" was deleted while starting work. Retry.`); } @@ -4063,6 +4096,10 @@ export const chatHandlers: GatewayRequestHandlers = { }); } catch (err) { clearPendingChatSendReservation(); + if (err instanceof Error && err.message === SESSION_ROUTING_CHANGED_ERROR_REASON) { + respondSessionRoutingChanged(); + return; + } respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, formatForLog(err))); return; } @@ -4246,6 +4283,15 @@ export const chatHandlers: GatewayRequestHandlers = { return; } + // Attachment preparation and admission can suspend. Recheck immediately + // before ACK/dispatch so hot config reload cannot cross the send boundary. + if (sessionRoutingChanged(context.getRuntimeConfig())) { + cleanupAdmittedRun({ force: true }); + clearAgentRunContext(clientRunId, lifecycleGeneration); + respondSessionRoutingChanged(); + return; + } + try { const serverTiming = shouldIncludeChatSendAckServerTiming(clientInfo) ? { diff --git a/src/gateway/server.auth.default-token.suite.ts b/src/gateway/server.auth.default-token.suite.ts index d1bc46c6c72..f87affb1a8e 100644 --- a/src/gateway/server.auth.default-token.suite.ts +++ b/src/gateway/server.auth.default-token.suite.ts @@ -2,6 +2,7 @@ // protocol version checks, and token-backed operator/node clients. import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; +import { GATEWAY_SERVER_CAPS } from "../../packages/gateway-protocol/src/index.js"; import { connectReq, ConnectErrorDetailCodes, @@ -155,10 +156,14 @@ export function registerDefaultAuthTokenSuite(): void { const payload = res.payload as | { type?: unknown; + features?: { capabilities?: unknown }; snapshot?: { configPath?: string; stateDir?: string }; } | undefined; expect(payload?.type).toBe("hello-ok"); + expect(payload?.features?.capabilities).toContain( + GATEWAY_SERVER_CAPS.CHAT_SEND_ROUTING_CONTRACT, + ); expect(payload?.snapshot?.configPath).toBe(createConfigIO().configPath); expect(payload?.snapshot?.stateDir).toBe(STATE_DIR); diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index 7273274407a..d30deb643ab 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -7,7 +7,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vite import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import type { GetReplyOptions } from "../auto-reply/get-reply-options.types.js"; import type { InternalGetReplyOptions } from "../auto-reply/reply/get-reply.types.js"; -import { clearConfigCache } from "../config/config.js"; +import { clearConfigCache, getRuntimeConfig } from "../config/config.js"; import { invalidateSessionStoreCache } from "../config/sessions/store-cache.js"; import type { AgentModelConfig } from "../config/types.agents-shared.js"; import { rotateAgentEventLifecycleGeneration } from "../infra/agent-events.js"; @@ -1732,6 +1732,7 @@ describe("gateway server chat", () => { }); const context = { + getRuntimeConfig, loadGatewayModelCatalog: vi.fn( async () => [ { @@ -1873,6 +1874,7 @@ describe("gateway server chat", () => { const responses: Array<{ id: string; ok: boolean; payload?: unknown; error?: unknown }> = []; const context = { + getRuntimeConfig, loadGatewayModelCatalog: vi.fn(), logGateway: { info: vi.fn(), @@ -2182,6 +2184,7 @@ describe("gateway server chat", () => { const responses: Array<{ id: string; ok: boolean; payload?: unknown; error?: unknown }> = []; const context = { + getRuntimeConfig, loadGatewayModelCatalog: vi.fn(), logGateway: { info: vi.fn(), @@ -2297,6 +2300,7 @@ describe("gateway server chat", () => { const responses: Array<{ id: string; ok: boolean; payload?: unknown; error?: unknown }> = []; const context = { + getRuntimeConfig, loadGatewayModelCatalog: vi.fn(), logGateway: { info: vi.fn(), @@ -2650,6 +2654,7 @@ describe("gateway server chat", () => { const responses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = []; const broadcastToConnIds = vi.fn(); const context = { + getRuntimeConfig, loadGatewayModelCatalog: vi.fn(), logGateway: { info: vi.fn(), @@ -2802,6 +2807,7 @@ describe("gateway server chat", () => { const broadcast = vi.fn(); const broadcastToConnIds = vi.fn(); const context = { + getRuntimeConfig, loadGatewayModelCatalog: vi.fn(), logGateway: { info: vi.fn(), diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index aa94b757297..e7a60d8720d 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -27,6 +27,7 @@ import { type ErrorShape, errorShape, formatValidationErrors, + GATEWAY_SERVER_CAPS, MIN_PROBE_PROTOCOL_VERSION, PROTOCOL_VERSION, validateConnectParams, @@ -2121,7 +2122,11 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar version: resolveRuntimeServiceVersion(process.env), connId, }, - features: { methods: gatewayMethods, events }, + features: { + methods: gatewayMethods, + events, + capabilities: [GATEWAY_SERVER_CAPS.CHAT_SEND_ROUTING_CONTRACT], + }, snapshot, ...(controlUiTabs.length > 0 ? { controlUiTabs } : {}), ...(Object.keys(pluginSurfaceUrls).length > 0 ? { pluginSurfaceUrls } : {}),