fix(apple): honor authoritative session routing

This commit is contained in:
RoboClaw
2026-08-19 16:20:19 +00:00
committed by GitHub
parent 3eb3557718
commit f0c8ceb69d
11 changed files with 189 additions and 28 deletions
+15 -2
View File
@@ -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
+4 -1
View File
@@ -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))
}
@@ -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(
@@ -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: [
@@ -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))
@@ -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
}
@@ -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,
])
}
@@ -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
}
}
}
@@ -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,
])
}
@@ -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")
}
@@ -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 {