diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 9223d0c10303..86be09d86eeb 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -405,6 +405,8 @@ final class NodeAppModel { private var mainSessionBaseKey: String = "main" private var gatewaySessionScope: String? + private var gatewaySessionRoutingContract: String? + private var gatewayAgentSelectionRequired = false 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; @@ -728,6 +730,8 @@ final class NodeAppModel { self.gatewaySessionScope = identity.scope self.mainSessionBaseKey = identity.mainSessionKey self.gatewayDefaultAgentId = identity.defaultAgentID + self.gatewayAgentSelectionRequired = identity.selectionRequired + self.gatewaySessionRoutingContract = identity.contract self.synchronizeTalkSessionKey() } @@ -1604,7 +1608,9 @@ final class NodeAppModel { let routingIdentity = OpenClawChatSessionRoutingIdentity( scope: decoded.scope.value as? String, mainSessionKey: decoded.mainkey, - defaultAgentID: decoded.defaultid) + defaultAgentID: decoded.defaultid, + selectionRequired: decoded.selectionrequired ?? false, + sessionRoutingContract: decoded.sessionroutingcontract) guard shouldApply(), GatewayStableIdentifier.matches(self.chatTranscriptCacheGatewayID, sourceGatewayID) else { return } @@ -1612,6 +1618,8 @@ final class NodeAppModel { self.gatewayDefaultAgentId = decoded.defaultid self.gatewayAgents = decoded.agents self.gatewaySessionScope = decoded.scope.value as? String + self.gatewayAgentSelectionRequired = decoded.selectionrequired ?? false + self.gatewaySessionRoutingContract = routingIdentity?.contract self.applyMainSessionKey(decoded.mainkey) let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) @@ -3447,12 +3455,13 @@ extension NodeAppModel { } let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if !selected.isEmpty { return selected.lowercased() } + guard !self.gatewayAgentSelectionRequired else { return nil } let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) return defaultId.isEmpty ? nil : defaultId.lowercased() } var chatSessionRoutingContract: String? { - OpenClawChatSessionRoutingContract.make( + self.gatewaySessionRoutingContract ?? OpenClawChatSessionRoutingContract.make( scope: self.gatewaySessionScope, mainKey: self.mainSessionBaseKey, defaultAgentID: self.gatewayDefaultAgentId) @@ -3843,6 +3852,8 @@ extension NodeAppModel { LiveActivityManager.shared.endActivity(reason: "new_gateway_connect") self.mainSessionBaseKey = "main" self.gatewaySessionScope = nil + self.gatewaySessionRoutingContract = nil + self.gatewayAgentSelectionRequired = false self.gatewayDefaultAgentId = nil self.gatewayAgents = [] self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID) @@ -5108,6 +5119,8 @@ extension NodeAppModel { private func configureLocalGatewayFixtureSession(agents: [AgentSummary]) { self.mainSessionBaseKey = "main" self.gatewaySessionScope = "per-sender" + self.gatewaySessionRoutingContract = "per-sender|main|main" + self.gatewayAgentSelectionRequired = false self.selectedAgentId = nil self.gatewayDefaultAgentId = "main" self.gatewayAgents = agents diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index d9c49e3d04a8..291cc38bf476 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -2453,7 +2453,9 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi let identity = try #require(OpenClawChatSessionRoutingIdentity( scope: "per-sender", mainSessionKey: "restored-main", - defaultAgentID: "main")) + defaultAgentID: "main", + selectionRequired: true, + sessionRoutingContract: "per-sender|restored-main|unowned")) let store = databases.store(gatewayID: stableID) await store.storeSessionRoutingIdentity(identity) await store.retire() @@ -2480,6 +2482,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi #expect(talkMode.isGatewayConnected) #expect(appModel.chatSessionRoutingContract == identity.contract) + #expect(appModel.chatDeliveryAgentId == nil) #expect(talkMode.isUsingMainSessionKey(appModel.chatSessionKey)) } diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift index 69592966e743..6d81a6aabf1e 100644 --- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift +++ b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift @@ -1053,7 +1053,8 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate { let explicitAgentID = WebChatRoute.normalizedAgentID(agentID) let effectiveAgentID = Self.effectiveAgentID( explicitAgentID: explicitAgentID, - cachedDefaultAgentID: cachedRoutingIdentity?.defaultAgentID) + cachedDefaultAgentID: cachedRoutingIdentity?.defaultAgentID, + selectionRequired: cachedRoutingIdentity?.selectionRequired ?? false) self.init( sessionKey: sessionKey, initialDraft: initialDraft, @@ -1148,15 +1149,15 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate { // default refreshes only supply the fallback route. let effectiveAgentID = Self.effectiveAgentID( explicitAgentID: explicitAgentID, - cachedDefaultAgentID: routingIdentity.defaultAgentID) + cachedDefaultAgentID: routingIdentity.defaultAgentID, + selectionRequired: routingIdentity.selectionRequired) (transport as? MacGatewayChatTransport)? .updateDefaultGlobalAgentID(effectiveAgentID) if let store = transcriptCache as? OpenClawChatSQLiteTranscriptCache, - !usesPrimaryAppRuntime || store.gatewayID == MacChatTranscriptCache.currentGatewayID(), - let persistedIdentity = OpenClawChatSessionRoutingIdentity( - contract: routingIdentity.contract) + !usesPrimaryAppRuntime || store.gatewayID == MacChatTranscriptCache.currentGatewayID() { - await store.storeSessionRoutingIdentity(persistedIdentity) + // Persist the display default separately from the authoritative contract. + await store.storeSessionRoutingIdentity(routingIdentity) } vm.syncDeliveryIdentity( activeAgentId: effectiveAgentID, @@ -1254,10 +1255,14 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate { static func effectiveAgentID( explicitAgentID: String?, - cachedDefaultAgentID: String?) -> String? + cachedDefaultAgentID: String?, + selectionRequired: Bool = false) -> String? { - WebChatRoute.normalizedAgentID(explicitAgentID) - ?? WebChatRoute.normalizedAgentID(cachedDefaultAgentID) + if let explicitAgentID = WebChatRoute.normalizedAgentID(explicitAgentID) { + return explicitAgentID + } + guard !selectionRequired else { return nil } + return WebChatRoute.normalizedAgentID(cachedDefaultAgentID) } private static func makeWindow( diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift index 2108ab5c9181..f8581c85c092 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift @@ -668,11 +668,12 @@ private func assertConfigLookupCannotRecreateRoute( } @Test func `routing identity decodes agent and contract from one response`() throws { - let data = Data(#"{"defaultId":"Work","mainKey":"Primary","scope":"global","agents":[]}"#.utf8) + let data = Data(#"{"defaultId":"Work","mainKey":"Primary","scope":"per-sender","selectionRequired":true,"sessionRoutingContract":"per-sender|Primary|unowned","agents":[]}"#.utf8) let identity = try OpenClawChatGatewayPayloadCodec.decodeSessionRoutingIdentity(data) #expect(identity.defaultAgentID == "work") - #expect(identity.contract == "global|primary|work") + #expect(identity.selectionRequired) + #expect(identity.contract == "per-sender|Primary|unowned") } @Test(arguments: [ diff --git a/apps/macos/Tests/OpenClawIPCTests/WebChatSwiftUISmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/WebChatSwiftUISmokeTests.swift index 6b1c797e65e9..49c6c709c628 100644 --- a/apps/macos/Tests/OpenClawIPCTests/WebChatSwiftUISmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/WebChatSwiftUISmokeTests.swift @@ -219,6 +219,30 @@ struct WebChatSwiftUISmokeTests { fallback.close() } + @Test func `controller refuses cached display default when gateway requires selection`() throws { + let cachedIdentity = try #require(OpenClawChatSessionRoutingIdentity( + scope: "per-sender", + mainSessionKey: "main", + defaultAgentID: "main", + selectionRequired: true, + sessionRoutingContract: "per-sender|main|unowned")) + let unresolved = WebChatSwiftUIWindowController( + sessionKey: "main", + agentID: nil, + cachedRoutingIdentity: cachedIdentity, + store: nil) + let explicit = WebChatSwiftUIWindowController( + sessionKey: "main", + agentID: " Work ", + cachedRoutingIdentity: cachedIdentity, + store: nil) + + #expect(unresolved._testActiveAgentID == nil) + #expect(explicit._testActiveAgentID == "work") + unresolved.close() + explicit.close() + } + @Test func `max and Ultra thinking preferences survive reopen`() throws { let suiteName = "WebChatSwiftUISmokeTests.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift index 816b9d16b7bc..2aa54e5a764c 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift @@ -53,7 +53,9 @@ public enum OpenClawChatGatewayPayloadCodec { guard let identity = OpenClawChatSessionRoutingIdentity( scope: decoded.scope.value as? String, mainSessionKey: decoded.mainkey, - defaultAgentID: decoded.defaultid) + defaultAgentID: decoded.defaultid, + selectionRequired: decoded.selectionrequired ?? false, + sessionRoutingContract: decoded.sessionroutingcontract) else { throw CancellationError() } return identity } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift index ad5df747b619..272273a69460 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift @@ -328,15 +328,19 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, let gatewayID = self.gatewayID do { try await self.databases.stateQueue.write { db in + try OpenClawClientDatabases.ensureSessionRoutingIdentityColumns(in: db) try db.execute( sql: """ INSERT INTO gateway_routing_identity( - gateway_id, scope, main_session_key, default_agent_id, updated_at - ) VALUES (?, ?, ?, ?, ?) + gateway_id, scope, main_session_key, default_agent_id, + routing_contract, selection_required, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(gateway_id) DO UPDATE SET scope = excluded.scope, main_session_key = excluded.main_session_key, default_agent_id = excluded.default_agent_id, + routing_contract = excluded.routing_contract, + selection_required = excluded.selection_required, updated_at = excluded.updated_at """, arguments: [ @@ -344,6 +348,8 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, identity.scope, identity.mainSessionKey, identity.defaultAgentID, + identity.contract, + identity.selectionRequired ? 1 : 0, Date().timeIntervalSince1970, ]) } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCacheContracts.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCacheContracts.swift index f5db8914fe39..633372566bfa 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCacheContracts.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCacheContracts.swift @@ -410,6 +410,7 @@ public struct OpenClawChatSessionRoutingIdentity: Equatable, Sendable { public let scope: String public let mainSessionKey: String public let defaultAgentID: String + public let selectionRequired: Bool public let contract: String public init?(contract: String?) { @@ -417,15 +418,42 @@ public struct OpenClawChatSessionRoutingIdentity: Equatable, Sendable { self.scope = components.scope self.mainSessionKey = components.mainKey self.defaultAgentID = components.defaultAgentID + self.selectionRequired = false self.contract = "\(components.scope)|\(components.mainKey)|\(components.defaultAgentID)" } public init?(scope: String?, mainSessionKey: String?, defaultAgentID: String?) { - guard let contract = OpenClawChatSessionRoutingContract.make( + self.init( + scope: scope, + mainSessionKey: mainSessionKey, + defaultAgentID: defaultAgentID, + selectionRequired: false, + sessionRoutingContract: nil) + } + + public init?( + scope: String?, + mainSessionKey: String?, + defaultAgentID: String?, + selectionRequired: Bool, + sessionRoutingContract: String?) + { + guard let displayContract = OpenClawChatSessionRoutingContract.make( scope: scope, mainKey: mainSessionKey, - defaultAgentID: defaultAgentID) + defaultAgentID: defaultAgentID), + let display = OpenClawChatSessionRoutingContract.parse(displayContract) else { return nil } - self.init(contract: contract) + let authoritativeContract = sessionRoutingContract? + .trimmingCharacters(in: .whitespacesAndNewlines) + self.scope = display.scope + self.mainSessionKey = display.mainKey + self.defaultAgentID = display.defaultAgentID + self.selectionRequired = selectionRequired + if let authoritativeContract, !authoritativeContract.isEmpty { + self.contract = authoritativeContract + } else { + self.contract = displayContract + } } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ClientDatabases.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ClientDatabases.swift index 1c44c98892c9..d0933bf91417 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ClientDatabases.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ClientDatabases.swift @@ -76,11 +76,13 @@ public final class OpenClawClientDatabases: @unchecked Sendable { gatewayID: String) -> OpenClawChatSessionRoutingIdentity? { do { - return try self.stateQueue.read { db in + return try self.stateQueue.write { db in + try Self.ensureSessionRoutingIdentityColumns(in: db) guard let row = try Row.fetchOne( db, sql: """ - SELECT scope, main_session_key, default_agent_id + SELECT scope, main_session_key, default_agent_id, + selection_required, routing_contract FROM gateway_routing_identity WHERE gateway_id = ? """, arguments: [gatewayID]) @@ -88,7 +90,9 @@ public final class OpenClawClientDatabases: @unchecked Sendable { return OpenClawChatSessionRoutingIdentity( scope: row["scope"], mainSessionKey: row["main_session_key"], - defaultAgentID: row["default_agent_id"]) + defaultAgentID: row["default_agent_id"], + selectionRequired: (row["selection_required"] as Int?) == 1, + sessionRoutingContract: row["routing_contract"]) } } catch { databaseLogger.error("client state routing read failed: \(error.localizedDescription, privacy: .public)") @@ -447,6 +451,8 @@ extension OpenClawClientDatabases { scope TEXT NOT NULL, main_session_key TEXT NOT NULL, default_agent_id TEXT NOT NULL, + routing_contract TEXT, + selection_required INTEGER, updated_at REAL NOT NULL ); CREATE TABLE outbox_commands( @@ -565,6 +571,16 @@ extension OpenClawClientDatabases { return queue } + static func ensureSessionRoutingIdentityColumns(in db: Database) throws { + let columns = try Set(db.columns(in: "gateway_routing_identity").map(\.name)) + if !columns.contains("routing_contract") { + try db.execute(sql: "ALTER TABLE gateway_routing_identity ADD COLUMN routing_contract TEXT") + } + if !columns.contains("selection_required") { + try db.execute(sql: "ALTER TABLE gateway_routing_identity ADD COLUMN selection_required INTEGER") + } + } + private static func openRepairableCacheDatabase(at url: URL) throws -> DatabaseQueue { do { let queue = try DatabaseQueue( @@ -674,6 +690,8 @@ extension OpenClawClientDatabases { var scope: String var mainSessionKey: String var defaultAgentID: String + var routingContract: String? + var selectionRequired: Bool var updatedAt: Double } @@ -806,8 +824,15 @@ extension OpenClawClientDatabases { } } if try db.tableExists("gateway_routing_identity") { + let columns = try Set(db.columns(in: "gateway_routing_identity").map(\.name)) + func expression(_ name: String, fallback: String) -> String { + columns.contains(name) ? name : fallback + } let rows = try Row.fetchAll(db, sql: """ - SELECT gateway_id, scope, main_session_key, default_agent_id, updated_at + SELECT gateway_id, scope, main_session_key, default_agent_id, + \(expression("routing_contract", fallback: "NULL")) AS routing_contract, + \(expression("selection_required", fallback: "0")) AS selection_required, + updated_at FROM gateway_routing_identity """) snapshot.routingIdentities = rows.map { row in @@ -816,6 +841,8 @@ extension OpenClawClientDatabases { scope: row["scope"], mainSessionKey: row["main_session_key"], defaultAgentID: row["default_agent_id"], + routingContract: row["routing_contract"], + selectionRequired: (row["selection_required"] as Int) == 1, updatedAt: row["updated_at"]) } } @@ -825,6 +852,7 @@ extension OpenClawClientDatabases { private func writeLegacySnapshot(_ snapshot: LegacySnapshot) throws { try self.stateQueue.write { db in + try Self.ensureSessionRoutingIdentityColumns(in: db) let forgottenGatewayHashes = try Set(String.fetchAll( db, sql: """ @@ -837,12 +865,15 @@ extension OpenClawClientDatabases { try db.execute( sql: """ INSERT INTO gateway_routing_identity( - gateway_id, scope, main_session_key, default_agent_id, updated_at - ) VALUES (?, ?, ?, ?, ?) + gateway_id, scope, main_session_key, default_agent_id, + routing_contract, selection_required, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(gateway_id) DO UPDATE SET scope = excluded.scope, main_session_key = excluded.main_session_key, default_agent_id = excluded.default_agent_id, + routing_contract = excluded.routing_contract, + selection_required = excluded.selection_required, updated_at = excluded.updated_at WHERE excluded.updated_at > gateway_routing_identity.updated_at """, @@ -851,6 +882,8 @@ extension OpenClawClientDatabases { identity.scope, identity.mainSessionKey, identity.defaultAgentID, + identity.routingContract, + identity.selectionRequired ? 1 : 0, identity.updatedAt, ]) } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift index c44dee7f64c5..6e6eca698a97 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift @@ -412,10 +412,20 @@ struct ChatGatewayPayloadCodecTests { } @Test func `routing identity decodes agent and canonical contract`() throws { + let identity = try OpenClawChatGatewayPayloadCodec.decodeSessionRoutingIdentity( + Data(#"{"defaultId":"Work","mainKey":"Primary","scope":"per-sender","selectionRequired":true,"sessionRoutingContract":"per-sender|Primary|unowned","agents":[]}"#.utf8)) + + #expect(identity.defaultAgentID == "work") + #expect(identity.selectionRequired) + #expect(identity.contract == "per-sender|Primary|unowned") + } + + @Test func `routing identity reconstructs legacy gateway contract`() throws { let identity = try OpenClawChatGatewayPayloadCodec.decodeSessionRoutingIdentity( Data(#"{"defaultId":"Work","mainKey":"Primary","scope":"global","agents":[]}"#.utf8)) #expect(identity.defaultAgentID == "work") + #expect(!identity.selectionRequired) #expect(identity.contract == "global|primary|work") } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptCacheStoreTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptCacheStoreTests.swift index 232b4befefab..36e7648aaae3 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptCacheStoreTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptCacheStoreTests.swift @@ -730,13 +730,16 @@ final class ChatTranscriptCacheStoreTests: ClientDatabaseTestSuite, @unchecked S let identity = try #require(OpenClawChatSessionRoutingIdentity( scope: " Per-Sender ", mainSessionKey: " Work ", - defaultAgentID: " Main ")) + defaultAgentID: " Main ", + selectionRequired: true, + sessionRoutingContract: "per-sender|work|unowned")) await databases.store(gatewayID: "gw-a").storeSessionRoutingIdentity(identity) try databases.close() let reopened = try OpenClawClientDatabases(directoryURL: directory) #expect(reopened.loadSessionRoutingIdentity(gatewayID: "gw-a") == identity) - #expect(identity.contract == "per-sender|work|main") + #expect(identity.contract == "per-sender|work|unowned") + #expect(identity.selectionRequired) #expect(try await reopened.stateQueue.read { db in try String.fetchAll(db, sql: "SELECT identifier FROM grdb_migrations") } == [ @@ -748,6 +751,39 @@ final class ChatTranscriptCacheStoreTests: ClientDatabaseTestSuite, @unchecked S "client-state-outbox-attachment-rekey-v6", ]) } + + @Test func `routing identity lazily upgrades the legacy table shape`() async throws { + try databases.close() + let stateURL = directory.appendingPathComponent("client-state.sqlite") + try withRawDatabase(at: stateURL) { raw in + execute(raw, "ALTER TABLE gateway_routing_identity RENAME TO gateway_routing_identity_new") + execute(raw, """ + CREATE TABLE gateway_routing_identity( + gateway_id TEXT NOT NULL PRIMARY KEY, + scope TEXT NOT NULL, + main_session_key TEXT NOT NULL, + default_agent_id TEXT NOT NULL, + updated_at REAL NOT NULL + ) + """) + execute(raw, "DROP TABLE gateway_routing_identity_new") + } + let reopened = try OpenClawClientDatabases(directoryURL: directory) + let identity = try #require(OpenClawChatSessionRoutingIdentity( + scope: "per-sender", + mainSessionKey: "main", + defaultAgentID: "main", + selectionRequired: true, + sessionRoutingContract: "per-sender|main|unowned")) + + await reopened.store(gatewayID: "gw-a").storeSessionRoutingIdentity(identity) + + #expect(reopened.loadSessionRoutingIdentity(gatewayID: "gw-a") == identity) + #expect(try await reopened.stateQueue.read { db in + Set(try db.columns(in: "gateway_routing_identity").map(\.name)) + .isSuperset(of: ["routing_contract", "selection_required"]) + }) + } } final class ClientDatabaseLegacyImportTests: TemporaryDatabaseTestSuite, @unchecked Sendable {