fix(ios): keep offline chat sends on their original route (#100942)

This commit is contained in:
Peter Steinberger 2026-07-06 17:53:05 +01:00 committed by GitHub
parent 68487e4c1e
commit 06789ca4c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 5837 additions and 952 deletions

View file

@ -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,

View file

@ -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
}

View file

@ -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",

View file

@ -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 }

View file

@ -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"

View file

@ -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")

View file

@ -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`() {

View file

@ -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(

View file

@ -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"))

View file

@ -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
}
}

View file

@ -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<GatewayPush>.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)

View file

@ -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)

View file

@ -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,

View file

@ -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()

View file

@ -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? {

View file

@ -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()
}
}

View file

@ -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"
}

View file

@ -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[..<firstSeparator])
let mainKey = String(normalized[normalized.index(after: firstSeparator)..<lastSeparator])
let defaultAgentID = String(normalized[normalized.index(after: lastSeparator)...])
guard !scope.isEmpty, !mainKey.isEmpty, !defaultAgentID.isEmpty else { return nil }
return Components(scope: scope, mainKey: mainKey, defaultAgentID: defaultAgentID)
}
private static func normalize(_ value: String?) -> String? {
let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return normalized?.isEmpty == false ? normalized : nil
}
}

View file

@ -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: {

View file

@ -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 rowincluding a delivery-unconfirmed rowis 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:<id>: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<String>()
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<OutboxDeliveryTarget> = []
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:<id>: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)
}

View file

@ -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<String>()
@ -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 {

View file

@ -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<String>)
{
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
}
}

View file

@ -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<String> = []
@ObservationIgnored
var deletedOutboxCommandIDs: Set<String> = []
var outboxPresentationGeneration: UInt64 = 0
@ObservationIgnored
var outboxChangesTask: Task<Void, Never>?
/// 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<String> = []
/// 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<Void, Never>?
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<String> = ["/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<OutboxDeliveryTarget>,
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

View file

@ -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<GatewayServerCapability>?
private var snapshotWaiters: [CheckedContinuation<Bool, Never>] = []
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) {

View file

@ -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.

View file

@ -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"
}
}

View file

@ -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)
}
}

View file

@ -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()])

View file

@ -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)

View file

@ -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

View file

@ -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);
});

View file

@ -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,

View file

@ -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 },
),

View file

@ -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 },

View file

@ -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. */

View file

@ -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();

View file

@ -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)
? {

View file

@ -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);

View file

@ -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<GatewayRequestContext["loadGatewayModelCatalog"]>(
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<GatewayRequestContext["loadGatewayModelCatalog"]>(),
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<GatewayRequestContext["loadGatewayModelCatalog"]>(),
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<GatewayRequestContext["loadGatewayModelCatalog"]>(),
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<GatewayRequestContext["loadGatewayModelCatalog"]>(),
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<GatewayRequestContext["loadGatewayModelCatalog"]>(),
logGateway: {
info: vi.fn(),

View file

@ -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 } : {}),