@@ -210,6 +210,7 @@ final class NodeAppModel {
210210 }
211211
212212 private var mainSessionBaseKey : String = " main "
213+ private var gatewaySessionScope : String ?
213214 private var focusedChatSessionKey : String ?
214215 // Two-part unread guard mirroring Android: the opened key survives read
215216 // confirmations so later unread episodes on the same open chat re-acknowledge;
@@ -329,7 +330,7 @@ final class NodeAppModel {
329330 return self . isOperatorGatewayConnected ? " operator " : " offline "
330331 }
331332
332- func makeChatTransport( ) -> any OpenClawChatTransport {
333+ func makeChatTransport( outboxGatewayID : String ? = nil ) -> any OpenClawChatTransport {
333334 if self . isScreenshotFixtureModeEnabled {
334335 return LocalFixtureChatTransport ( fixture: . appScreenshots)
335336 }
@@ -338,7 +339,8 @@ final class NodeAppModel {
338339 }
339340 return IOSGatewayChatTransport (
340341 gateway: self . operatorSession,
341- globalAgentId: self . chatAgentId)
342+ globalAgentId: self . chatDeliveryAgentId,
343+ outboxGatewayID: outboxGatewayID)
342344 }
343345
344346 /// Gateway identity the transcript cache is scoped to: the active
@@ -349,6 +351,7 @@ final class NodeAppModel {
349351 var chatTranscriptCacheGatewayID : String ? {
350352 guard !self . isLocalGatewayFixtureEnabled else { return nil }
351353 let stableID = self . activeGatewayConnectConfig? . effectiveStableID
354+ ?? self . connectedGatewayID
352355 ?? GatewaySettingsStore . loadLastGatewayConnection ( ) ? . stableID
353356 guard let stableID, !stableID. isEmpty else { return nil }
354357 return stableID
@@ -362,6 +365,14 @@ final class NodeAppModel {
362365 " \( self . chatTransportModeID) | \( self . chatTranscriptCacheGatewayID ?? " " ) | \( self . chatTranscriptCacheGeneration) "
363366 }
364367
368+ /// Stable owner key for the long-lived chat view model. Connectivity still
369+ /// changes `chatViewModelIdentityID` for session-list refreshes, but must
370+ /// not rebuild Chat and discard an offline draft on the same gateway.
371+ var chatViewModelOwnerID : String {
372+ let modeID = self . isLocalGatewayFixtureEnabled ? self . chatTransportModeID : " gateway "
373+ return " \( modeID) | \( self . chatTranscriptCacheGatewayID ?? " " ) | \( self . chatTranscriptCacheGeneration) "
374+ }
375+
365376 private var chatTranscriptCacheGeneration = 0
366377
367378 /// Offline transcript cache plus durable command outbox, both scoped to
@@ -379,6 +390,28 @@ final class NodeAppModel {
379390 return cache
380391 }
381392
393+ var hasVerifiedChatOfflineRoutingIdentity : Bool {
394+ self . chatTranscriptCacheGatewayID != nil &&
395+ self . chatDeliveryAgentId != nil &&
396+ self . chatSessionRoutingContract != nil
397+ }
398+
399+ func restoreChatSessionRoutingIdentityIfNeeded( ) async {
400+ guard !self . isLocalGatewayFixtureEnabled,
401+ self . chatSessionRoutingContract == nil ,
402+ let store = self . makeChatOfflineStore ( ) ,
403+ let identity = await store. loadSessionRoutingIdentity ( ) ,
404+ self . chatTranscriptCacheGatewayID == store. gatewayID,
405+ self . chatSessionRoutingContract == nil
406+ else { return }
407+ self . selectedAgentId = GatewaySettingsStore . loadGatewaySelectedAgentId ( stableID: store. gatewayID)
408+ self . gatewaySessionScope = identity. scope
409+ self . mainSessionBaseKey = identity. mainSessionKey
410+ self . gatewayDefaultAgentId = identity. defaultAgentID
411+ self . talkMode. updateMainSessionKey ( self . mainSessionKey)
412+ self . homeCanvasRevision &+= 1
413+ }
414+
382415 func loadCachedChatSessions( ) async -> [ OpenClawChatSessionEntry ] {
383416 guard let cache = self . makeChatOfflineStore ( ) else { return [ ] }
384417 return await cache. loadSessions ( )
@@ -1006,14 +1039,23 @@ final class NodeAppModel {
10061039
10071040 private func refreshBrandingFromGateway( shouldApply: ( ) -> Bool = { true } ) async {
10081041 do {
1009- let res = try await operatorGateway. request ( method: " config.get " , paramsJSON: " {} " , timeoutSeconds: 8 )
1042+ guard let sourceGatewayID = self . chatTranscriptCacheGatewayID,
1043+ let sourceRoute = await operatorGateway. currentRoute ( ifGatewayID: sourceGatewayID)
1044+ else { return }
1045+ let res = try await operatorGateway. request (
1046+ method: " config.get " ,
1047+ paramsJSON: " {} " ,
1048+ timeoutSeconds: 8 ,
1049+ ifCurrentRoute: sourceRoute)
10101050 guard let json = try JSONSerialization . jsonObject ( with: res) as? [ String : Any ] else { return }
10111051 guard let config = json [ " config " ] as? [ String : Any ] else { return }
10121052 let session = config [ " session " ] as? [ String : Any ]
10131053 let mainKey = SessionKey . normalizeMainKey ( session ? [ " mainKey " ] as? String )
1014- guard shouldApply ( ) else { return }
1054+ let scope = ( session ? [ " scope " ] as? String ) ?? " per-sender "
1055+ guard shouldApply ( ) , self . chatTranscriptCacheGatewayID == sourceGatewayID else { return }
10151056 await MainActor . run {
10161057 self . mainSessionBaseKey = mainKey
1058+ self . gatewaySessionScope = scope
10171059 self . talkMode. updateMainSessionKey ( self . mainSessionKey)
10181060 self . homeCanvasRevision &+= 1
10191061 }
@@ -1030,12 +1072,26 @@ final class NodeAppModel {
10301072
10311073 private func refreshAgentsFromGateway( shouldApply: ( ) -> Bool = { true } ) async {
10321074 do {
1033- let res = try await operatorGateway. request ( method: " agents.list " , paramsJSON: " {} " , timeoutSeconds: 8 )
1075+ guard let sourceGatewayID = self . chatTranscriptCacheGatewayID,
1076+ let sourceStore = self . makeChatOfflineStore ( ) ,
1077+ sourceStore. gatewayID == sourceGatewayID,
1078+ let sourceRoute = await operatorGateway. currentRoute ( ifGatewayID: sourceGatewayID)
1079+ else { return }
1080+ let res = try await operatorGateway. request (
1081+ method: " agents.list " ,
1082+ paramsJSON: " {} " ,
1083+ timeoutSeconds: 8 ,
1084+ ifCurrentRoute: sourceRoute)
10341085 let decoded = try JSONDecoder ( ) . decode ( AgentsListResult . self, from: res)
1035- guard shouldApply ( ) else { return }
1086+ let routingIdentity = OpenClawChatSessionRoutingIdentity (
1087+ scope: decoded. scope. value as? String ,
1088+ mainSessionKey: decoded. mainkey,
1089+ defaultAgentID: decoded. defaultid)
1090+ guard shouldApply ( ) , self . chatTranscriptCacheGatewayID == sourceGatewayID else { return }
10361091 await MainActor . run {
10371092 self . gatewayDefaultAgentId = decoded. defaultid
10381093 self . gatewayAgents = decoded. agents
1094+ self . gatewaySessionScope = decoded. scope. value as? String
10391095 self . applyMainSessionKey ( decoded. mainkey)
10401096
10411097 let selected = ( self . selectedAgentId ?? " " ) . trimmingCharacters ( in: . whitespacesAndNewlines)
@@ -1046,6 +1102,9 @@ final class NodeAppModel {
10461102 self . talkMode. updateMainSessionKey ( self . mainSessionKey)
10471103 self . homeCanvasRevision &+= 1
10481104 }
1105+ if let routingIdentity {
1106+ await sourceStore. storeSessionRoutingIdentity ( routingIdentity)
1107+ }
10491108 } catch {
10501109 // Best-effort only.
10511110 }
@@ -2344,6 +2403,26 @@ extension NodeAppModel {
23442403 return self . selectedOrDefaultAgentId
23452404 }
23462405
2406+ /// Verified routing owner for sends. Unlike `chatAgentId`, this has no
2407+ /// display fallback: a cold offline start must wait for persisted or
2408+ /// gateway-provided ownership before it can queue durable work.
2409+ var chatDeliveryAgentId : String ? {
2410+ if let sessionAgentId = SessionKey . agentId ( from: chatSessionKey) {
2411+ return sessionAgentId. lowercased ( )
2412+ }
2413+ let selected = ( self . selectedAgentId ?? " " ) . trimmingCharacters ( in: . whitespacesAndNewlines)
2414+ if !selected. isEmpty { return selected. lowercased ( ) }
2415+ let defaultId = ( self . gatewayDefaultAgentId ?? " " ) . trimmingCharacters ( in: . whitespacesAndNewlines)
2416+ return defaultId. isEmpty ? nil : defaultId. lowercased ( )
2417+ }
2418+
2419+ var chatSessionRoutingContract : String ? {
2420+ OpenClawChatSessionRoutingContract . make (
2421+ scope: self . gatewaySessionScope,
2422+ mainKey: self . mainSessionBaseKey,
2423+ defaultAgentID: self . gatewayDefaultAgentId)
2424+ }
2425+
23472426 var chatAgentName : String {
23482427 self . agentDisplayName ( for: self . chatAgentId, fallback: " Main " )
23492428 }
@@ -2636,7 +2715,6 @@ extension NodeAppModel {
26362715 self . gatewayConnected = false
26372716 setOperatorConnected ( false )
26382717 self . talkMode. updateGatewayConnected ( false )
2639- self . mainSessionBaseKey = " main "
26402718 self . talkMode. updateMainSessionKey ( self . mainSessionKey)
26412719 ShareGatewayRelaySettings . clearConfig ( )
26422720 showLocalCanvasOnDisconnect ( )
@@ -2684,12 +2762,17 @@ extension NodeAppModel {
26842762 self . voiceWakeSyncTask? . cancel ( )
26852763 self . voiceWakeSyncTask = nil
26862764 LiveActivityManager . shared. endActivity ( reason: " new_gateway_connect " )
2765+ self . mainSessionBaseKey = " main "
2766+ self . gatewaySessionScope = nil
26872767 self . gatewayDefaultAgentId = nil
26882768 self . gatewayAgents = [ ]
26892769 self . selectedAgentId = GatewaySettingsStore . loadGatewaySelectedAgentId ( stableID: stableID)
26902770 self . focusedChatSessionKey = nil
26912771 self . homeCanvasRevision &+= 1
26922772 self . apnsLastRegisteredTokenHex = nil
2773+ Task { [ weak self] in
2774+ await self ? . restoreChatSessionRoutingIdentityIfNeeded ( )
2775+ }
26932776 }
26942777
26952778 private func clearGatewayConnectionProblem( ) {
@@ -3476,7 +3559,8 @@ extension NodeAppModel {
34763559 self . gatewayConnected = false
34773560 self . setOperatorConnected ( false )
34783561 self . talkMode. updateGatewayConnected ( false )
3479- self . mainSessionBaseKey = " main "
3562+ // Retain the last verified routing contract for offline
3563+ // capture; reconnect compares it with the live gateway before replay.
34803564 self . talkMode. updateMainSessionKey ( self . mainSessionKey)
34813565 self . showLocalCanvasOnDisconnect ( )
34823566 }
@@ -3682,6 +3766,7 @@ extension NodeAppModel {
36823766 self . talkMode. setEnabled ( false )
36833767 self . talkMode. statusText = " Demo mode only "
36843768 self . mainSessionBaseKey = " main "
3769+ self . gatewaySessionScope = " per-sender "
36853770 self . selectedAgentId = nil
36863771 self . gatewayDefaultAgentId = " main "
36873772 self . gatewayAgents = AppleReviewDemoMode . agents
@@ -3723,6 +3808,7 @@ extension NodeAppModel {
37233808 self . setOperatorConnected ( true )
37243809 self . hasOperatorAdminScope = true
37253810 self . mainSessionBaseKey = " main "
3811+ self . gatewaySessionScope = " per-sender "
37263812 self . selectedAgentId = nil
37273813 self . gatewayDefaultAgentId = " main "
37283814 self . gatewayAgents = ScreenshotFixtureMode . agents
@@ -3887,7 +3973,7 @@ extension NodeAppModel {
38873973
38883974 let routeGeneration = self . gatewayRouteGeneration
38893975 guard let gatewayStableID = self . connectedGatewayID,
3890- let nodeRoute = await nodeGateway. currentRoute ( ) ,
3976+ let nodeRoute = await self . nodeGateway. currentRoute ( ) ,
38913977 shouldContinue ( ) ,
38923978 self . isCurrentGatewayRoute ( generation: routeGeneration, stableID: gatewayStableID)
38933979 else { return }
0 commit comments