mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(ios): show sidebar sessions on multi-agent gateways (#131356)
* fix(ios): scope sidebar sessions to selected agent * fix(ios): fence session roster ownership * fix(ios): partition cached sessions by agent * refactor(chat): remove unused session list overload * fix(macos): scope session rosters to selected agent * test(ios): guard canonical activity roster owner * fix(chat): fail closed for legacy scoped caches
This commit is contained in:
@@ -328,7 +328,8 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
let request = OpenClawChatGatewayRequests.sessionsList(
|
||||
limit: limit,
|
||||
search: search,
|
||||
archived: archived)
|
||||
archived: archived,
|
||||
agentID: self.globalAgentId)
|
||||
let res = try await gateway.request(request)
|
||||
return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: res)
|
||||
}
|
||||
|
||||
@@ -1276,6 +1276,8 @@ struct CommandSessionsScreen: View {
|
||||
// New Group editor) alongside the fresh session list.
|
||||
self.knownGroups = SessionGroupStore.load()
|
||||
let requestsArchived = self.showArchived
|
||||
let sourceGatewayID = self.appModel.chatTranscriptCacheGatewayID
|
||||
let sourceAgentID = self.appModel.chatDeliveryAgentId
|
||||
self.isLoading = true
|
||||
self.loadErrorText = nil
|
||||
defer { self.isLoading = false }
|
||||
@@ -1288,7 +1290,9 @@ struct CommandSessionsScreen: View {
|
||||
self.sessions = roster.sessions
|
||||
} catch {
|
||||
guard requestsArchived == self.showArchived else { return }
|
||||
self.sessions = requestsArchived ? [] : await self.appModel.loadCachedChatSessions()
|
||||
self.sessions = requestsArchived ? [] : await self.appModel.loadCachedChatSessions(
|
||||
gatewayID: sourceGatewayID,
|
||||
agentID: sourceAgentID)
|
||||
self.loadErrorText = self.sessions.isEmpty ? "Try again after the gateway reconnects." : nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,23 +208,20 @@ struct IPadActivityScreen: View {
|
||||
|
||||
private func refreshSessions() async {
|
||||
guard self.scenePhase == .active else { return }
|
||||
guard self.sessionsAvailable else {
|
||||
self.sessions = await self.appModel.loadCachedChatSessions()
|
||||
self.loadErrorText = nil
|
||||
return
|
||||
}
|
||||
|
||||
self.isLoading = true
|
||||
self.loadErrorText = nil
|
||||
defer { self.isLoading = false }
|
||||
|
||||
do {
|
||||
let transport = self.appModel.makeChatTransport()
|
||||
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
|
||||
self.sessions = response.sessions
|
||||
await self.appModel.storeCachedChatSessions(response.sessions)
|
||||
let roster = try await self.appModel.loadChatSessionRoster(
|
||||
limit: CommandCenterTab.recentSessionsFetchLimit)
|
||||
self.sessions = roster.sessions
|
||||
} catch {
|
||||
self.sessions = await self.appModel.loadCachedChatSessions()
|
||||
let sourceGatewayID = self.appModel.chatTranscriptCacheGatewayID
|
||||
let sourceAgentID = self.appModel.chatDeliveryAgentId
|
||||
self.sessions = await self.appModel.loadCachedChatSessions(
|
||||
gatewayID: sourceGatewayID,
|
||||
agentID: sourceAgentID)
|
||||
self.loadErrorText = self.sessions.isEmpty ? "Try again after the gateway reconnects." : nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -650,13 +650,12 @@ final class NodeAppModel {
|
||||
return stableID
|
||||
}
|
||||
|
||||
/// Recreation key for the chat view model. Includes the cache gateway
|
||||
/// identity: switching paired gateways while the transport mode stays
|
||||
/// "operator" must rebuild the view model so transcripts are never read
|
||||
/// from or written under another gateway's cache scope.
|
||||
/// Session-list refresh identity. Gateway and agent changes must restart
|
||||
/// requests so no roster or cached projection crosses either owner.
|
||||
var chatViewModelIdentityID: String {
|
||||
let gatewayID = self.chatTranscriptCacheGatewayIdentityComponent
|
||||
return "\(self.chatTransportModeID)|\(gatewayID)|\(self.chatTranscriptCacheGeneration)"
|
||||
let agentID = self.chatDeliveryAgentId ?? ""
|
||||
return "\(self.chatTransportModeID)|\(gatewayID)|\(agentID)|\(self.chatTranscriptCacheGeneration)"
|
||||
}
|
||||
|
||||
/// Stable owner key for the long-lived chat view model. Connectivity still
|
||||
@@ -732,14 +731,37 @@ final class NodeAppModel {
|
||||
self.synchronizeTalkSessionKey()
|
||||
}
|
||||
|
||||
func loadCachedChatSessions() async -> [OpenClawChatSessionEntry] {
|
||||
guard let cache = self.makeChatOfflineStore() else { return [] }
|
||||
return await cache.loadSessions()
|
||||
func loadCachedChatSessions(
|
||||
gatewayID: String?,
|
||||
agentID: String?) async -> [OpenClawChatSessionEntry]
|
||||
{
|
||||
guard GatewayStableIdentifier.matches(self.chatTranscriptCacheGatewayID, gatewayID),
|
||||
let cache = self.makeChatOfflineStore(),
|
||||
GatewayStableIdentifier.matches(cache.gatewayID, gatewayID)
|
||||
else { return [] }
|
||||
let sessions = await cache.loadSessions(agentID: agentID)
|
||||
guard GatewayStableIdentifier.matches(self.chatTranscriptCacheGatewayID, gatewayID),
|
||||
self.chatDeliveryAgentId == agentID
|
||||
else { return [] }
|
||||
return sessions.filter {
|
||||
ChatSessionSidebarModel.isSessionInActiveAgentScope(
|
||||
key: $0.key,
|
||||
agentID: $0.agentId,
|
||||
activeAgentID: agentID)
|
||||
}
|
||||
}
|
||||
|
||||
func storeCachedChatSessions(_ sessions: [OpenClawChatSessionEntry]) async {
|
||||
guard let cache = self.makeChatOfflineStore() else { return }
|
||||
await cache.storeSessions(sessions)
|
||||
func storeCachedChatSessions(
|
||||
_ sessions: [OpenClawChatSessionEntry],
|
||||
gatewayID: String?,
|
||||
agentID: String?) async
|
||||
{
|
||||
guard GatewayStableIdentifier.matches(self.chatTranscriptCacheGatewayID, gatewayID),
|
||||
self.chatDeliveryAgentId == agentID,
|
||||
let cache = self.makeChatOfflineStore(),
|
||||
GatewayStableIdentifier.matches(cache.gatewayID, gatewayID)
|
||||
else { return }
|
||||
await cache.storeSessions(sessions, agentID: agentID)
|
||||
}
|
||||
|
||||
/// Delete one forgotten gateway's cache and durable state, or both
|
||||
@@ -1691,6 +1713,8 @@ final class NodeAppModel {
|
||||
}
|
||||
if selectedAgentChanged {
|
||||
self.focusedChatSessionKey = nil
|
||||
self.shareDeliveryChannel = nil
|
||||
self.shareDeliveryTo = nil
|
||||
}
|
||||
self.synchronizeTalkSessionKey()
|
||||
if let relay = ShareGatewayRelaySettings.loadConfig() {
|
||||
@@ -1704,6 +1728,13 @@ final class NodeAppModel {
|
||||
deliveryChannel: self.shareDeliveryChannel,
|
||||
deliveryTo: self.shareDeliveryTo))
|
||||
}
|
||||
if selectedAgentChanged {
|
||||
// Delivery metadata belongs to the selected agent. Rehydrate it
|
||||
// after the selection commit; request-time identity fences stale replies.
|
||||
Task { [weak self] in
|
||||
await self?.refreshShareRouteFromGateway()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setGlobalWakeWords(_ words: [String]) async {
|
||||
@@ -5193,23 +5224,32 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
do {
|
||||
guard let sourceGatewayID = self.chatTranscriptCacheGatewayID,
|
||||
let sourceRoute = await operatorGateway.currentRoute(ifGatewayID: sourceGatewayID)
|
||||
else { return }
|
||||
let sourceAgentID = self.chatDeliveryAgentId
|
||||
let sourceMainSessionKey = self.mainSessionKey
|
||||
let request = OpenClawChatGatewayRequests.sessionsList(
|
||||
limit: 80,
|
||||
search: nil,
|
||||
archived: false,
|
||||
agentID: sourceAgentID,
|
||||
timeoutMs: 10000)
|
||||
let response = try await operatorGateway.request(request)
|
||||
let response = try await operatorGateway.request(request, ifCurrentRoute: sourceRoute)
|
||||
let decoded = try JSONDecoder().decode(SessionsListResult.self, from: response)
|
||||
let currentKey = self.mainSessionKey
|
||||
let sorted = decoded.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
||||
let exactMatch = sorted.first { row in
|
||||
row.key == currentKey && normalize(row.lastChannel) != nil && normalize(row.lastTo) != nil
|
||||
row.key == sourceMainSessionKey && normalize(row.lastChannel) != nil && normalize(row.lastTo) != nil
|
||||
}
|
||||
let selected = exactMatch
|
||||
let channel = normalize(selected?.lastChannel)
|
||||
let to = normalize(selected?.lastTo)
|
||||
|
||||
guard shouldApply() else { return }
|
||||
guard shouldApply(),
|
||||
GatewayStableIdentifier.matches(self.chatTranscriptCacheGatewayID, sourceGatewayID),
|
||||
self.chatDeliveryAgentId == sourceAgentID,
|
||||
self.mainSessionKey == sourceMainSessionKey
|
||||
else { return }
|
||||
await MainActor.run {
|
||||
self.shareDeliveryChannel = channel
|
||||
self.shareDeliveryTo = to
|
||||
|
||||
@@ -94,16 +94,19 @@ extension NodeAppModel {
|
||||
archived: Bool = false,
|
||||
allowCachedFallback: Bool = true) async throws -> ChatSessionRosterSnapshot
|
||||
{
|
||||
let sourceGatewayID = self.chatTranscriptCacheGatewayID
|
||||
let sourceAgentID = self.chatDeliveryAgentId
|
||||
guard self.isLocalChatFixtureEnabled || self.isOperatorGatewayConnected else {
|
||||
guard allowCachedFallback else { throw URLError(.notConnectedToInternet) }
|
||||
return await ChatSessionRosterSnapshot(
|
||||
sessions: archived ? [] : self.loadCachedChatSessions(),
|
||||
sessions: archived ? [] : self.loadCachedChatSessions(
|
||||
gatewayID: sourceGatewayID,
|
||||
agentID: sourceAgentID),
|
||||
isCached: true,
|
||||
isComplete: false)
|
||||
}
|
||||
|
||||
do {
|
||||
let sourceGatewayID = self.chatTranscriptCacheGatewayID
|
||||
let snapshot: ChatSessionRosterSnapshot
|
||||
if self.isLocalChatFixtureEnabled {
|
||||
let response = try await self.makeChatTransport().listSessions(limit: limit, archived: archived)
|
||||
@@ -124,11 +127,14 @@ extension NodeAppModel {
|
||||
limit: limit,
|
||||
search: nil,
|
||||
archived: archived,
|
||||
agentID: sourceAgentID,
|
||||
offset: offset)
|
||||
let data = try await self.operatorSession.request(request, ifCurrentRoute: route)
|
||||
return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data)
|
||||
}
|
||||
guard GatewayStableIdentifier.matches(self.chatTranscriptCacheGatewayID, sourceGatewayID) else {
|
||||
guard GatewayStableIdentifier.matches(self.chatTranscriptCacheGatewayID, sourceGatewayID),
|
||||
self.chatDeliveryAgentId == sourceAgentID
|
||||
else {
|
||||
throw CancellationError()
|
||||
}
|
||||
}
|
||||
@@ -136,9 +142,13 @@ extension NodeAppModel {
|
||||
if !archived {
|
||||
// An interrupted page must not replace a more complete offline roster.
|
||||
if snapshot.isComplete {
|
||||
await self.storeCachedChatSessions(snapshot.sessions)
|
||||
await self.storeCachedChatSessions(
|
||||
snapshot.sessions,
|
||||
gatewayID: sourceGatewayID,
|
||||
agentID: sourceAgentID)
|
||||
if let sourceGatewayID,
|
||||
!GatewayStableIdentifier.matches(self.chatTranscriptCacheGatewayID, sourceGatewayID)
|
||||
!GatewayStableIdentifier.matches(self.chatTranscriptCacheGatewayID, sourceGatewayID) ||
|
||||
self.chatDeliveryAgentId != sourceAgentID
|
||||
{
|
||||
throw CancellationError()
|
||||
}
|
||||
@@ -149,7 +159,9 @@ extension NodeAppModel {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
guard allowCachedFallback, !archived else { throw error }
|
||||
let cached = await self.loadCachedChatSessions()
|
||||
let cached = await self.loadCachedChatSessions(
|
||||
gatewayID: sourceGatewayID,
|
||||
agentID: sourceAgentID)
|
||||
guard !cached.isEmpty else { throw error }
|
||||
return ChatSessionRosterSnapshot(sessions: cached, isCached: true, isComplete: false)
|
||||
}
|
||||
@@ -460,7 +472,8 @@ final class RootSidebarModel {
|
||||
case let .sessionObserver(digest):
|
||||
self.sessions = ChatSessionSidebarModel.applying(
|
||||
observerDigest: digest,
|
||||
to: self.sessions)
|
||||
to: self.sessions,
|
||||
activeAgentId: appModel.chatDeliveryAgentId)
|
||||
case .seqGap:
|
||||
await self.refreshSessions(appModel: appModel)
|
||||
return true
|
||||
|
||||
@@ -2043,7 +2043,7 @@ private func waitUntil(
|
||||
token: nil,
|
||||
password: nil,
|
||||
sessionKey: "main")
|
||||
defaults.set(try JSONEncoder().encode(otherRelay), forKey: "share.gatewayRelay.config.v1")
|
||||
try defaults.set(JSONEncoder().encode(otherRelay), forKey: "share.gatewayRelay.config.v1")
|
||||
let mismatched = try #require(ShareGatewayRelaySettings.loadConfig())
|
||||
#expect(mismatched.token == nil)
|
||||
#expect(mismatched.password == nil)
|
||||
@@ -2638,7 +2638,7 @@ private func waitUntil(
|
||||
useTLS: false,
|
||||
lastConnectedAtMs: nil))
|
||||
let appModel = NodeAppModel()
|
||||
let session = OpenClawChatSessionEntry(
|
||||
var session = OpenClawChatSessionEntry(
|
||||
key: "agent:main:a",
|
||||
kind: nil,
|
||||
displayName: "Gateway A session",
|
||||
@@ -2658,12 +2658,34 @@ private func waitUntil(
|
||||
modelProvider: nil,
|
||||
model: nil,
|
||||
contextTokens: nil)
|
||||
session.agentId = "main"
|
||||
var matchingBare = session
|
||||
matchingBare.key = "shared-tool"
|
||||
var ownerlessPrefixed = session
|
||||
ownerlessPrefixed.key = "agent:main:legacy"
|
||||
ownerlessPrefixed.agentId = nil
|
||||
appModel.gatewayDefaultAgentId = "main"
|
||||
|
||||
await appModel.storeCachedChatSessions([session])
|
||||
await appModel.storeCachedChatSessions(
|
||||
[session, matchingBare, ownerlessPrefixed],
|
||||
gatewayID: gatewayA,
|
||||
agentID: "main")
|
||||
var workGlobal = session
|
||||
workGlobal.key = "global"
|
||||
workGlobal.agentId = "work"
|
||||
appModel.selectedAgentId = "work"
|
||||
await appModel.storeCachedChatSessions([workGlobal], gatewayID: gatewayA, agentID: "work")
|
||||
_ = GatewaySettingsStore.setActiveGateway(stableID: gatewayB)
|
||||
#expect(await appModel.loadCachedChatSessions().isEmpty)
|
||||
#expect(await appModel.loadCachedChatSessions(gatewayID: gatewayB, agentID: "work").isEmpty)
|
||||
_ = GatewaySettingsStore.setActiveGateway(stableID: gatewayA)
|
||||
#expect(await appModel.loadCachedChatSessions() == [session])
|
||||
appModel.selectedAgentId = "main"
|
||||
#expect(await appModel.loadCachedChatSessions(gatewayID: gatewayA, agentID: "main") == [
|
||||
session,
|
||||
matchingBare,
|
||||
ownerlessPrefixed,
|
||||
])
|
||||
appModel.selectedAgentId = "work"
|
||||
#expect(await appModel.loadCachedChatSessions(gatewayID: gatewayA, agentID: "work") == [workGlobal])
|
||||
}
|
||||
|
||||
private static func makeNodeOptions(
|
||||
|
||||
@@ -1167,18 +1167,24 @@ private final class TimingOutDeviceStatusService: DeviceStatusServicing {
|
||||
#expect(appModel.chatDeliveryAgentId == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func `chat delivery owner requires persisted or gateway ownership`() {
|
||||
@Test @MainActor func `chat delivery owner and refresh identity follow gateway ownership`() {
|
||||
let appModel = NodeAppModel()
|
||||
let ownerlessIdentity = appModel.chatViewModelIdentityID
|
||||
#expect(appModel.chatDeliveryAgentId == nil)
|
||||
|
||||
appModel.gatewayDefaultAgentId = " Agent-A "
|
||||
let defaultIdentity = appModel.chatViewModelIdentityID
|
||||
#expect(appModel.chatDeliveryAgentId == "agent-a")
|
||||
#expect(defaultIdentity != ownerlessIdentity)
|
||||
|
||||
appModel.setSelectedAgentId(" Agent-B ")
|
||||
let selectedIdentity = appModel.chatViewModelIdentityID
|
||||
#expect(appModel.chatDeliveryAgentId == "agent-b")
|
||||
#expect(selectedIdentity != defaultIdentity)
|
||||
|
||||
appModel.openChat(sessionKey: "agent:Agent-C:incident")
|
||||
#expect(appModel.chatDeliveryAgentId == "agent-c")
|
||||
#expect(appModel.chatViewModelIdentityID != selectedIdentity)
|
||||
}
|
||||
|
||||
@Test @MainActor func `init preserves saved talk mode preference`() {
|
||||
|
||||
@@ -103,6 +103,39 @@ struct RootTabsSourceGuardTests {
|
||||
#expect(refreshID.contains("self.scenePhase == .active"))
|
||||
}
|
||||
|
||||
@Test func `session roster lifecycle stays on one gateway and agent owner`() throws {
|
||||
let sidebarSource = try String(contentsOf: Self.rootSidebarModelSourceURL(), encoding: .utf8)
|
||||
let appModelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
|
||||
let rosterLoad = try Self.extract(
|
||||
sidebarSource,
|
||||
from: "func loadChatSessionRoster(",
|
||||
to: "@MainActor\n@Observable")
|
||||
let observerApply = try Self.extract(
|
||||
sidebarSource,
|
||||
from: "private func handleSessionEvent(",
|
||||
to: "func reportSessionError(")
|
||||
let refreshIdentity = try Self.extract(
|
||||
appModelSource,
|
||||
from: "var chatViewModelIdentityID: String",
|
||||
to: "var chatViewModelOwnerID: String")
|
||||
let shareRoute = try Self.extract(
|
||||
appModelSource,
|
||||
from: "private func refreshShareRouteFromGateway(",
|
||||
to: "func runSharePipelineSelfTest()")
|
||||
|
||||
#expect(rosterLoad.contains("let sourceAgentID = self.chatDeliveryAgentId"))
|
||||
#expect(rosterLoad.contains("agentID: sourceAgentID"))
|
||||
#expect(rosterLoad.contains("self.chatDeliveryAgentId == sourceAgentID"))
|
||||
#expect(rosterLoad.contains("self.chatDeliveryAgentId != sourceAgentID"))
|
||||
#expect(observerApply.contains("activeAgentId: appModel.chatDeliveryAgentId"))
|
||||
#expect(refreshIdentity.contains("self.chatDeliveryAgentId"))
|
||||
#expect(shareRoute.contains("let sourceAgentID = self.chatDeliveryAgentId"))
|
||||
#expect(shareRoute.contains("let sourceMainSessionKey = self.mainSessionKey"))
|
||||
#expect(shareRoute.contains("ifCurrentRoute: sourceRoute"))
|
||||
#expect(shareRoute.contains("self.chatDeliveryAgentId == sourceAgentID"))
|
||||
#expect(shareRoute.contains("self.mainSessionKey == sourceMainSessionKey"))
|
||||
}
|
||||
|
||||
@Test func `sidebar dashboard keeps per field last known good values and drains cron pages`() throws {
|
||||
let source = try String(contentsOf: Self.rootSidebarModelSourceURL(), encoding: .utf8)
|
||||
let dashboardCommit = try Self.extract(
|
||||
@@ -289,7 +322,8 @@ struct RootTabsSourceGuardTests {
|
||||
let projectSource = try String(contentsOf: Self.xcodeProjectSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(activitySource.contains("struct IPadActivityScreen: View"))
|
||||
#expect(activitySource.contains("self.appModel.makeChatTransport()"))
|
||||
#expect(activitySource.contains("self.appModel.loadChatSessionRoster("))
|
||||
#expect(!activitySource.contains("self.appModel.makeChatTransport()"))
|
||||
#expect(appModelSource.contains("return IOSGatewayChatTransport("))
|
||||
#expect(appModelSource.contains("globalAgentId: self.chatDeliveryAgentId"))
|
||||
#expect(!appModelSource.contains("defaultAgentId: self.gatewayDefaultAgentId"))
|
||||
|
||||
@@ -277,7 +277,7 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
search: String?,
|
||||
archived: Bool) async throws -> OpenClawChatSessionsListResponse
|
||||
{
|
||||
let request = OpenClawChatGatewayRequests.sessionsList(
|
||||
let request = self.sessionsListRequest(
|
||||
limit: limit,
|
||||
search: search,
|
||||
archived: archived)
|
||||
@@ -309,6 +309,18 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
sessions: decoded.sessions)
|
||||
}
|
||||
|
||||
func sessionsListRequest(
|
||||
limit: Int?,
|
||||
search: String?,
|
||||
archived: Bool) -> OpenClawChatGatewayRequest
|
||||
{
|
||||
OpenClawChatGatewayRequests.sessionsList(
|
||||
limit: limit,
|
||||
search: search,
|
||||
archived: archived,
|
||||
agentID: self.routingIdentity.currentAgentID())
|
||||
}
|
||||
|
||||
func listChildSessions(parentKey: String) async throws -> [OpenClawChatSessionEntry] {
|
||||
try await self.listChildSessions(parentKey: parentKey, serverLease: nil)
|
||||
}
|
||||
@@ -1149,6 +1161,11 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
|
||||
cachedDefaultAgentID: routingIdentity.defaultAgentID)
|
||||
(transport as? MacGatewayChatTransport)?
|
||||
.updateDefaultGlobalAgentID(effectiveAgentID)
|
||||
// Keep request and cache ownership in lockstep before the
|
||||
// persistence await can admit a roster refresh.
|
||||
vm.syncDeliveryIdentity(
|
||||
activeAgentId: effectiveAgentID,
|
||||
sessionRoutingContract: routingIdentity.contract)
|
||||
if let store = transcriptCache as? OpenClawChatSQLiteTranscriptCache,
|
||||
!usesPrimaryAppRuntime || store.gatewayID == MacChatTranscriptCache.currentGatewayID(),
|
||||
let persistedIdentity = OpenClawChatSessionRoutingIdentity(
|
||||
@@ -1156,9 +1173,6 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
|
||||
{
|
||||
await store.storeSessionRoutingIdentity(persistedIdentity)
|
||||
}
|
||||
vm.syncDeliveryIdentity(
|
||||
activeAgentId: effectiveAgentID,
|
||||
sessionRoutingContract: routingIdentity.contract)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,21 @@ struct MacGatewayChatTransportMappingTests {
|
||||
agentID: nil))
|
||||
}
|
||||
|
||||
@Test func `session list request follows the current routing agent`() {
|
||||
let transport = MacGatewayChatTransport(defaultGlobalAgentID: " Agent-A ")
|
||||
|
||||
let first = transport.sessionsListRequest(limit: 50, search: nil, archived: false)
|
||||
#expect(first.params["agentId"]?.value as? String == "agent-a")
|
||||
|
||||
transport.updateDefaultGlobalAgentID("Agent-B")
|
||||
let second = transport.sessionsListRequest(limit: nil, search: "recent", archived: true)
|
||||
#expect(second.params["agentId"]?.value as? String == "agent-b")
|
||||
|
||||
let unowned = MacGatewayChatTransport()
|
||||
.sessionsListRequest(limit: nil, search: nil, archived: false)
|
||||
#expect(unowned.params["agentId"] == nil)
|
||||
}
|
||||
|
||||
@Test func `fixed connection does not inherit app wide cache routing`() async throws {
|
||||
let url = try #require(URL(string: "wss://fixed.example"))
|
||||
let connection = GatewayConnection(configProvider: {
|
||||
|
||||
@@ -192,6 +192,7 @@ public enum OpenClawChatGatewayRequests {
|
||||
limit: Int?,
|
||||
search: String?,
|
||||
archived: Bool,
|
||||
agentID: String? = nil,
|
||||
includeGlobal: Bool = true,
|
||||
includeUnknown: Bool = false,
|
||||
activeMinutes: Int? = nil,
|
||||
@@ -204,6 +205,9 @@ public enum OpenClawChatGatewayRequests {
|
||||
"includeGlobal": AnyCodable(includeGlobal),
|
||||
"includeUnknown": AnyCodable(includeUnknown),
|
||||
]
|
||||
if let agentID = normalized(agentID) {
|
||||
params["agentId"] = AnyCodable(agentID)
|
||||
}
|
||||
if let limit {
|
||||
params["limit"] = AnyCodable(limit)
|
||||
}
|
||||
|
||||
@@ -502,9 +502,17 @@ public enum ChatSessionSidebarModel {
|
||||
status != "running"
|
||||
}
|
||||
|
||||
static func isSessionInActiveAgentScope(key: String, activeAgentID: String?) -> Bool {
|
||||
public static func isSessionInActiveAgentScope(
|
||||
key: String,
|
||||
agentID: String? = nil,
|
||||
activeAgentID: String?) -> Bool
|
||||
{
|
||||
let normalizedAgent = activeAgentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
|
||||
guard !normalizedAgent.isEmpty else { return true }
|
||||
// Gateway row ownership outranks ambiguous bare/global keys. Missing
|
||||
// metadata is a shipped legacy-cache state and keeps key-only behavior.
|
||||
let rowAgent = agentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if let rowAgent, !rowAgent.isEmpty, rowAgent != normalizedAgent { return false }
|
||||
let parts = key.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false)
|
||||
guard parts.count == 3, parts[0].lowercased() == "agent" else { return true }
|
||||
return parts[1].lowercased() == normalizedAgent
|
||||
|
||||
@@ -85,6 +85,7 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache,
|
||||
OpenClawChatCommandOutbox
|
||||
{
|
||||
public static let maxCachedSessions = 50
|
||||
public static let maxCachedSessionOwners = 50
|
||||
public static let maxCachedTranscripts = 50
|
||||
public static let maxCachedMessagesPerSession = 200
|
||||
public static let maxQueuedCommands = 50
|
||||
@@ -137,17 +138,23 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache,
|
||||
// MARK: - Gateway cache
|
||||
|
||||
public func loadSessions() async -> [OpenClawChatSessionEntry] {
|
||||
await self.loadSessions(agentID: nil)
|
||||
}
|
||||
|
||||
public func loadSessions(agentID: String?) async -> [OpenClawChatSessionEntry] {
|
||||
guard !self.isRetired else { return [] }
|
||||
let normalizedAgentID = Self.normalizedAgentID(agentID)
|
||||
let gatewayID = self.gatewayID
|
||||
do {
|
||||
return try await self.databases.cacheQueue.write { db in
|
||||
try OpenClawClientDatabases.ensureAgentSessionCacheSchema(db)
|
||||
let rows = try Row.fetchAll(
|
||||
db,
|
||||
sql: """
|
||||
SELECT payload_json FROM cached_sessions
|
||||
WHERE gateway_id = ? ORDER BY position
|
||||
SELECT payload_json FROM cached_agent_sessions
|
||||
WHERE gateway_id = ? AND agent_id = ? ORDER BY position
|
||||
""",
|
||||
arguments: [gatewayID])
|
||||
arguments: [gatewayID, normalizedAgentID])
|
||||
do {
|
||||
return try rows.map { row in
|
||||
let payload: String = row["payload_json"]
|
||||
@@ -156,11 +163,20 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache,
|
||||
} catch {
|
||||
cacheLogger.error(
|
||||
"gateway session cache decode failed: \(error.localizedDescription, privacy: .public)")
|
||||
// Decode and cleanup share one transaction so a newer
|
||||
// snapshot can never land between the failed read and delete.
|
||||
// Decode and cleanup share one agent partition transaction;
|
||||
// corruption in one roster must not erase another agent.
|
||||
try db.execute(
|
||||
sql: "DELETE FROM cached_sessions WHERE gateway_id = ?",
|
||||
arguments: [gatewayID])
|
||||
sql: """
|
||||
DELETE FROM cached_agent_sessions
|
||||
WHERE gateway_id = ? AND agent_id = ?
|
||||
""",
|
||||
arguments: [gatewayID, normalizedAgentID])
|
||||
try db.execute(
|
||||
sql: """
|
||||
DELETE FROM cached_session_rosters
|
||||
WHERE gateway_id = ? AND agent_id = ?
|
||||
""",
|
||||
arguments: [gatewayID, normalizedAgentID])
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -214,30 +230,80 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache,
|
||||
}
|
||||
|
||||
public func storeSessions(_ sessions: [OpenClawChatSessionEntry]) async {
|
||||
await self.storeSessions(sessions, agentID: nil)
|
||||
}
|
||||
|
||||
public func storeSessions(_ sessions: [OpenClawChatSessionEntry], agentID: String?) async {
|
||||
guard !self.isRetired else { return }
|
||||
let bounded = Self.boundedSessions(sessions)
|
||||
let normalizedAgentID = Self.normalizedAgentID(agentID)
|
||||
guard let owned = Self.sessionsOwnedBy(sessions, agentID: normalizedAgentID) else {
|
||||
cacheLogger.error("gateway session cache rejected a mixed-agent snapshot")
|
||||
return
|
||||
}
|
||||
let bounded = Self.boundedSessions(owned)
|
||||
let gatewayID = self.gatewayID
|
||||
do {
|
||||
let encoded = try bounded.map(Self.encodeJSON)
|
||||
try await self.databases.cacheQueue.write { db in
|
||||
try OpenClawClientDatabases.ensureAgentSessionCacheSchema(db)
|
||||
// The legacy gateway-wide rows cannot represent agent ownership.
|
||||
// Keep them empty so a downgraded client cannot paint a mixed roster.
|
||||
try db.execute(
|
||||
sql: "DELETE FROM cached_sessions WHERE gateway_id = ?",
|
||||
arguments: [gatewayID])
|
||||
try db.execute(
|
||||
sql: """
|
||||
INSERT INTO cached_session_rosters(gateway_id, agent_id, last_used_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(gateway_id, agent_id)
|
||||
DO UPDATE SET last_used_at = excluded.last_used_at
|
||||
""",
|
||||
arguments: [gatewayID, normalizedAgentID, Date().timeIntervalSince1970])
|
||||
try db.execute(
|
||||
sql: """
|
||||
DELETE FROM cached_agent_sessions
|
||||
WHERE gateway_id = ? AND agent_id = ?
|
||||
""",
|
||||
arguments: [gatewayID, normalizedAgentID])
|
||||
for (position, pair) in zip(bounded, encoded).enumerated() {
|
||||
try db.execute(
|
||||
sql: """
|
||||
INSERT INTO cached_sessions(
|
||||
gateway_id, session_key, position, updated_at, payload_json
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO cached_agent_sessions(
|
||||
gateway_id, agent_id, session_key, position, updated_at, payload_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
arguments: [
|
||||
gatewayID,
|
||||
normalizedAgentID,
|
||||
pair.0.key,
|
||||
position,
|
||||
pair.0.updatedAt ?? 0,
|
||||
pair.1,
|
||||
])
|
||||
}
|
||||
let staleOwners = try String.fetchAll(
|
||||
db,
|
||||
sql: """
|
||||
SELECT agent_id FROM cached_session_rosters
|
||||
WHERE gateway_id = ?
|
||||
ORDER BY last_used_at DESC, agent_id
|
||||
LIMIT -1 OFFSET ?
|
||||
""",
|
||||
arguments: [gatewayID, Self.maxCachedSessionOwners])
|
||||
for staleOwner in staleOwners {
|
||||
try db.execute(
|
||||
sql: """
|
||||
DELETE FROM cached_agent_sessions
|
||||
WHERE gateway_id = ? AND agent_id = ?
|
||||
""",
|
||||
arguments: [gatewayID, staleOwner])
|
||||
try db.execute(
|
||||
sql: """
|
||||
DELETE FROM cached_session_rosters
|
||||
WHERE gateway_id = ? AND agent_id = ?
|
||||
""",
|
||||
arguments: [gatewayID, staleOwner])
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
cacheLogger.error("gateway session cache write failed: \(error.localizedDescription, privacy: .public)")
|
||||
@@ -1489,6 +1555,26 @@ extension OpenClawChatSQLiteTranscriptCache {
|
||||
.prefix(self.maxCachedSessions))
|
||||
}
|
||||
|
||||
private nonisolated static func sessionsOwnedBy(
|
||||
_ sessions: [OpenClawChatSessionEntry],
|
||||
agentID: String) -> [OpenClawChatSessionEntry]?
|
||||
{
|
||||
guard !agentID.isEmpty else { return sessions }
|
||||
var owned: [OpenClawChatSessionEntry] = []
|
||||
owned.reserveCapacity(sessions.count)
|
||||
for var session in sessions {
|
||||
let rowAgentID = self.normalizedAgentID(session.agentId)
|
||||
let keyAgentID = self.normalizedAgentID(OpenClawChatSessionKey.agentID(from: session.key))
|
||||
guard rowAgentID.isEmpty || rowAgentID == agentID,
|
||||
keyAgentID.isEmpty || keyAgentID == agentID
|
||||
else { return nil }
|
||||
// The request owner is authoritative for bare and global keys.
|
||||
session.agentId = agentID
|
||||
owned.append(session)
|
||||
}
|
||||
return owned
|
||||
}
|
||||
|
||||
private static func attachmentByteCount(_ attachments: [OpenClawChatOutboxAttachment]) -> Int? {
|
||||
var total = 0
|
||||
for attachment in attachments {
|
||||
|
||||
@@ -4,13 +4,15 @@ import Foundation
|
||||
///
|
||||
/// The cache only pre-paints cold opens and covers offline browsing; connected
|
||||
/// reads always come from the gateway and replace cached content wholesale.
|
||||
/// Implementations must scope every row by gateway identity so one shared
|
||||
/// installation database can safely serve all paired gateways.
|
||||
/// Implementations must scope every row by gateway and agent identity so one
|
||||
/// shared installation database can safely serve all paired gateways.
|
||||
public protocol OpenClawChatTranscriptCache: Sendable {
|
||||
func loadSessions() async -> [OpenClawChatSessionEntry]
|
||||
func loadSessions(agentID: String?) async -> [OpenClawChatSessionEntry]
|
||||
func loadTranscript(sessionKey: String) async -> [OpenClawChatMessage]
|
||||
func loadTranscript(sessionKey: String, agentID: String?) async -> [OpenClawChatMessage]
|
||||
func storeSessions(_ sessions: [OpenClawChatSessionEntry]) async
|
||||
func storeSessions(_ sessions: [OpenClawChatSessionEntry], agentID: String?) async
|
||||
/// Canonical gateway rows can prove that an ambiguously delivered local
|
||||
/// command landed after cancellation and must override local suppression.
|
||||
func storeCanonicalTranscript(
|
||||
@@ -24,6 +26,18 @@ public protocol OpenClawChatTranscriptCache: Sendable {
|
||||
}
|
||||
|
||||
extension OpenClawChatTranscriptCache {
|
||||
public func loadSessions(agentID: String?) async -> [OpenClawChatSessionEntry] {
|
||||
// Legacy conformers have no agent partition. Scoped access must fail
|
||||
// closed or an ownerless roster can cross an agent switch.
|
||||
guard agentID == nil else { return [] }
|
||||
return await self.loadSessions()
|
||||
}
|
||||
|
||||
public func storeSessions(_ sessions: [OpenClawChatSessionEntry], agentID: String?) async {
|
||||
guard agentID == nil else { return }
|
||||
await self.storeSessions(sessions)
|
||||
}
|
||||
|
||||
public func loadTranscript(sessionKey: String, agentID: String?) async -> [OpenClawChatMessage] {
|
||||
guard agentID == nil else { return [] }
|
||||
return await self.loadTranscript(sessionKey: sessionKey)
|
||||
|
||||
@@ -1053,14 +1053,10 @@ extension OpenClawChatTransport {
|
||||
[]
|
||||
}
|
||||
|
||||
/// Conveniences for callers that only page a list. Transports must
|
||||
/// Convenience for callers that only select archive state. Transports must
|
||||
/// implement the canonical `listSessions(limit:search:archived:)`
|
||||
/// requirement; same-name methods on a conformer are shadowed by these
|
||||
/// sugars and never called through the protocol.
|
||||
public func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse {
|
||||
try await self.listSessions(limit: limit, search: nil, archived: false)
|
||||
}
|
||||
|
||||
/// requirement; same-name methods on a conformer are shadowed by this
|
||||
/// sugar and never called through the protocol.
|
||||
public func listSessions(limit: Int?, archived: Bool) async throws -> OpenClawChatSessionsListResponse {
|
||||
try await self.listSessions(limit: limit, search: nil, archived: archived)
|
||||
}
|
||||
|
||||
@@ -83,13 +83,13 @@ extension OpenClawChatViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func persistSessionsToCache(_ sessions: [OpenClawChatSessionEntry]) {
|
||||
func persistSessionsToCache(_ sessions: [OpenClawChatSessionEntry], agentID: String?) {
|
||||
guard let transcriptCache else { return }
|
||||
let durableSessions = sessions.map(Self.durableSessionCacheProjection)
|
||||
let previous = pendingCacheWriteTask
|
||||
pendingCacheWriteTask = Task.detached {
|
||||
await previous?.value
|
||||
await transcriptCache.storeSessions(durableSessions)
|
||||
await transcriptCache.storeSessions(durableSessions, agentID: agentID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ extension OpenClawChatViewModel {
|
||||
guard let transcriptCache else { return }
|
||||
if sessions.isEmpty, !hasAppliedLiveSessions {
|
||||
Task { [weak self] in
|
||||
let cached = await transcriptCache.loadSessions()
|
||||
let cached = await transcriptCache.loadSessions(agentID: session.deliveryAgentID)
|
||||
guard let self, !cached.isEmpty else { return }
|
||||
// A live sessions response (even an empty one) is authoritative;
|
||||
// a slow cache read must never repaint over it.
|
||||
@@ -116,8 +116,14 @@ extension OpenClawChatViewModel {
|
||||
}
|
||||
let durableSessions = cached.map(Self.durableSessionCacheProjection)
|
||||
let organized = OpenClawChatSessionListOrganizer.organize(durableSessions)
|
||||
let agentScoped = organized.filter {
|
||||
ChatSessionSidebarModel.isSessionInActiveAgentScope(
|
||||
key: $0.key,
|
||||
agentID: $0.agentId,
|
||||
activeAgentID: self.activeAgentId)
|
||||
}
|
||||
let scoped = ChatSessionSidebarModel.clearingForeignGlobalObserverDigest(
|
||||
in: organized,
|
||||
in: agentScoped,
|
||||
activeAgentId: self.activeAgentId)
|
||||
self.sessions = self.applyingLocalUnreadOverrides(
|
||||
to: scoped)
|
||||
|
||||
@@ -261,7 +261,9 @@ extension OpenClawChatViewModel {
|
||||
activeRunIDs: change.activeRunIds,
|
||||
activeRunIDsPresent: change.activeRunIdsPresent)
|
||||
self.sessions = OpenClawChatSessionListOrganizer.organize(updated)
|
||||
self.persistSessionsToCache(self.sessions)
|
||||
self.persistSessionsToCache(
|
||||
self.sessions,
|
||||
agentID: self.currentSessionSnapshot().deliveryAgentID)
|
||||
return .merged
|
||||
}
|
||||
|
||||
|
||||
@@ -1126,7 +1126,7 @@ extension OpenClawChatViewModel {
|
||||
self.hasAppliedLiveSessions = true
|
||||
self.syncSelectedModel()
|
||||
syncThinkingLevelOptions()
|
||||
persistSessionsToCache(organized)
|
||||
persistSessionsToCache(organized, agentID: session.deliveryAgentID)
|
||||
self.readySessionMetadataGeneration = metadataGeneration
|
||||
if self.healthOK {
|
||||
reconcilePendingOutboxBranchScopes()
|
||||
|
||||
@@ -198,6 +198,12 @@ public final class OpenClawClientDatabases: @unchecked Sendable {
|
||||
arguments: [gatewayID])
|
||||
}
|
||||
try self.cacheQueue.write { db in
|
||||
try db.execute(
|
||||
sql: "DELETE FROM cached_agent_sessions WHERE gateway_id = ?",
|
||||
arguments: [gatewayID])
|
||||
try db.execute(
|
||||
sql: "DELETE FROM cached_session_rosters WHERE gateway_id = ?",
|
||||
arguments: [gatewayID])
|
||||
try db.execute(sql: "DELETE FROM cached_sessions WHERE gateway_id = ?", arguments: [gatewayID])
|
||||
try db.execute(sql: "DELETE FROM cached_transcripts WHERE gateway_id = ?", arguments: [gatewayID])
|
||||
}
|
||||
@@ -641,8 +647,36 @@ extension OpenClawClientDatabases {
|
||||
INSERT OR REPLACE INTO cache_metadata(id, format_version)
|
||||
VALUES (1, \(self.gatewayCacheFormatVersion));
|
||||
""")
|
||||
try self.ensureAgentSessionCacheSchema(db)
|
||||
}
|
||||
}
|
||||
|
||||
/// Session rosters are disposable cache state, so this additive surface is
|
||||
/// lazily ensured without advancing the cache format or erasing transcripts.
|
||||
static func ensureAgentSessionCacheSchema(_ db: Database) throws {
|
||||
try db.execute(sql: """
|
||||
CREATE TABLE IF NOT EXISTS cached_session_rosters(
|
||||
gateway_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
last_used_at REAL NOT NULL,
|
||||
PRIMARY KEY(gateway_id, agent_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS cached_agent_sessions(
|
||||
gateway_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
session_key TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
PRIMARY KEY(gateway_id, agent_id, session_key),
|
||||
FOREIGN KEY(gateway_id, agent_id)
|
||||
REFERENCES cached_session_rosters(gateway_id, agent_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS cached_agent_sessions_order
|
||||
ON cached_agent_sessions(gateway_id, agent_id, position);
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
extension OpenClawClientDatabases {
|
||||
|
||||
@@ -68,7 +68,8 @@ struct ChatGatewayRequestTests {
|
||||
let request = OpenClawChatGatewayRequests.sessionsList(
|
||||
limit: 12,
|
||||
search: " incident ",
|
||||
archived: true)
|
||||
archived: true,
|
||||
agentID: " Reviewer ")
|
||||
|
||||
#expect(request.method == "sessions.list")
|
||||
#expect(request.timeoutMs == 15000)
|
||||
@@ -77,6 +78,14 @@ struct ChatGatewayRequestTests {
|
||||
#expect(request.params["limit"]?.value as? Int == 12)
|
||||
#expect(request.params["search"]?.value as? String == "incident")
|
||||
#expect(request.params["archived"]?.value as? Bool == true)
|
||||
#expect(request.params["agentId"]?.value as? String == "Reviewer")
|
||||
|
||||
let unscoped = OpenClawChatGatewayRequests.sessionsList(
|
||||
limit: nil,
|
||||
search: nil,
|
||||
archived: false,
|
||||
agentID: " ")
|
||||
#expect(unscoped.params["agentId"] == nil)
|
||||
}
|
||||
|
||||
@Test func `child session request encodes focused pagination filters`() {
|
||||
|
||||
@@ -682,6 +682,39 @@ struct ChatSessionSidebarModelTests {
|
||||
#expect(replayed[0].observerDigest?.revision == 4)
|
||||
}
|
||||
|
||||
@Test func `global observer events require the active agent owner`() {
|
||||
let running = self.entry(
|
||||
key: "global",
|
||||
status: "running",
|
||||
hasActiveRun: true,
|
||||
activeRunIds: ["run-work"])
|
||||
let accepted = ChatSessionSidebarModel.applying(
|
||||
observerDigest: SessionObserverDigest(
|
||||
sessionkey: "global",
|
||||
agentid: "work",
|
||||
runid: "run-work",
|
||||
revision: 1,
|
||||
updatedat: 100,
|
||||
headline: "Work status",
|
||||
health: .onTrack),
|
||||
to: [running],
|
||||
activeAgentId: "work")
|
||||
let rejected = ChatSessionSidebarModel.applying(
|
||||
observerDigest: SessionObserverDigest(
|
||||
sessionkey: "global",
|
||||
agentid: "main",
|
||||
runid: "run-work",
|
||||
revision: 2,
|
||||
updatedat: 200,
|
||||
headline: "Foreign status",
|
||||
health: .stuck),
|
||||
to: accepted,
|
||||
activeAgentId: "work")
|
||||
|
||||
#expect(accepted[0].observerDigest?.headline == "Work status")
|
||||
#expect(rejected[0].observerDigest?.headline == "Work status")
|
||||
}
|
||||
|
||||
@Test func `run rollover clears a stale digest before the replacement event`() throws {
|
||||
let existing = self.entry(
|
||||
key: "agent:main:work",
|
||||
|
||||
+104
-5
@@ -32,11 +32,16 @@ func cacheMessage(
|
||||
idempotencyKey: idempotencyKey)
|
||||
}
|
||||
|
||||
func cacheSessionEntry(key: String, updatedAt: Double) -> OpenClawChatSessionEntry {
|
||||
func cacheSessionEntry(
|
||||
key: String,
|
||||
updatedAt: Double,
|
||||
agentID: String? = nil) -> OpenClawChatSessionEntry
|
||||
{
|
||||
OpenClawChatSessionEntry(
|
||||
key: key,
|
||||
kind: nil,
|
||||
displayName: nil,
|
||||
agentId: agentID,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
@@ -277,6 +282,88 @@ final class ChatTranscriptCacheStoreTests: ClientDatabaseTestSuite, @unchecked S
|
||||
#expect(!messageRows[0].payloadJSON.hasPrefix("["))
|
||||
}
|
||||
|
||||
@Test func `agent session snapshots preserve another agents offline roster`() async throws {
|
||||
await store.storeSessions([
|
||||
cacheSessionEntry(key: "global", updatedAt: 1, agentID: "agent-a"),
|
||||
], agentID: "agent-a")
|
||||
await store.storeSessions([
|
||||
cacheSessionEntry(key: "global", updatedAt: 2, agentID: "agent-b"),
|
||||
], agentID: "agent-b")
|
||||
|
||||
#expect(await store.loadSessions(agentID: "agent-a").map(\.agentId) == ["agent-a"])
|
||||
#expect(await store.loadSessions(agentID: "agent-b").map(\.agentId) == ["agent-b"])
|
||||
try databases.close()
|
||||
|
||||
let reopened = try OpenClawClientDatabases(directoryURL: directory)
|
||||
#expect(await reopened.store(gatewayID: "gw-a").loadSessions(agentID: "agent-a").map(\.agentId) == [
|
||||
"agent-a",
|
||||
])
|
||||
#expect(await reopened.store(gatewayID: "gw-a").loadSessions(agentID: "agent-b").map(\.agentId) == [
|
||||
"agent-b",
|
||||
])
|
||||
}
|
||||
|
||||
@Test func `empty or rejected agent snapshot cannot erase another roster`() async {
|
||||
await store.storeSessions([
|
||||
cacheSessionEntry(key: "global", updatedAt: 1, agentID: "agent-a"),
|
||||
], agentID: "agent-a")
|
||||
await store.storeSessions([
|
||||
cacheSessionEntry(key: "global", updatedAt: 2, agentID: "agent-b"),
|
||||
], agentID: "agent-b")
|
||||
|
||||
await store.storeSessions([
|
||||
cacheSessionEntry(key: "agent:agent-a:main", updatedAt: 3, agentID: "agent-a"),
|
||||
], agentID: "agent-b")
|
||||
|
||||
#expect(await store.loadSessions(agentID: "agent-a").map(\.agentId) == ["agent-a"])
|
||||
#expect(await store.loadSessions(agentID: "agent-b").map(\.agentId) == ["agent-b"])
|
||||
|
||||
await store.storeSessions([], agentID: "agent-b")
|
||||
|
||||
#expect(await store.loadSessions(agentID: "agent-a").map(\.agentId) == ["agent-a"])
|
||||
#expect(await store.loadSessions(agentID: "agent-b").isEmpty)
|
||||
}
|
||||
|
||||
@Test func `agent session owner partitions remain bounded`() async throws {
|
||||
for index in 0...OpenClawChatSQLiteTranscriptCache.maxCachedSessionOwners {
|
||||
let agentID = "agent-\(index)"
|
||||
await store.storeSessions([
|
||||
cacheSessionEntry(key: "global", updatedAt: Double(index), agentID: agentID),
|
||||
], agentID: agentID)
|
||||
}
|
||||
|
||||
let counts = try await databases.cacheQueue.read { db in
|
||||
try (
|
||||
Int.fetchOne(db, sql: "SELECT COUNT(*) FROM cached_session_rosters WHERE gateway_id = 'gw-a'"),
|
||||
Int.fetchOne(db, sql: "SELECT COUNT(*) FROM cached_agent_sessions WHERE gateway_id = 'gw-a'"))
|
||||
}
|
||||
#expect(counts.0 == OpenClawChatSQLiteTranscriptCache.maxCachedSessionOwners)
|
||||
#expect(counts.1 == OpenClawChatSQLiteTranscriptCache.maxCachedSessionOwners)
|
||||
}
|
||||
|
||||
@Test func `agent snapshot discards legacy roster without touching transcripts`() async throws {
|
||||
await store.storeTestTranscript(
|
||||
sessionKey: "global",
|
||||
agentID: "agent-a",
|
||||
messages: [cacheMessage(role: "assistant", text: "preserved", timestamp: 1)])
|
||||
try await databases.cacheQueue.write { db in
|
||||
try db.execute(
|
||||
sql: """
|
||||
INSERT INTO cached_sessions(gateway_id, session_key, position, updated_at, payload_json)
|
||||
VALUES ('gw-a', 'global', 0, 1, '{}')
|
||||
""")
|
||||
}
|
||||
|
||||
await store.storeSessions([
|
||||
cacheSessionEntry(key: "global", updatedAt: 2, agentID: "agent-a"),
|
||||
], agentID: "agent-a")
|
||||
|
||||
#expect(try await databases.cacheQueue.read { db in
|
||||
try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM cached_sessions WHERE gateway_id = 'gw-a'")
|
||||
} == 0)
|
||||
#expect(await messageTexts(store.loadTranscript(sessionKey: "global", agentID: "agent-a")) == ["preserved"])
|
||||
}
|
||||
|
||||
@Test func `cache format mismatch rebuilds without touching client state`() async throws {
|
||||
let stateIdentity = try #require(OpenClawChatSessionRoutingIdentity(
|
||||
scope: "per-sender",
|
||||
@@ -421,21 +508,33 @@ final class ChatTranscriptCacheStoreTests: ClientDatabaseTestSuite, @unchecked S
|
||||
}
|
||||
|
||||
@Test func `malformed cache partitions are discarded atomically`() async throws {
|
||||
await store.storeSessions([cacheSessionEntry(key: "main", updatedAt: 1)])
|
||||
await store.storeSessions([
|
||||
cacheSessionEntry(key: "global", updatedAt: 1, agentID: "agent-a"),
|
||||
], agentID: "agent-a")
|
||||
await store.storeSessions([
|
||||
cacheSessionEntry(key: "global", updatedAt: 2, agentID: "agent-b"),
|
||||
], agentID: "agent-b")
|
||||
await store.storeTestTranscript(
|
||||
sessionKey: "main",
|
||||
messages: [cacheMessage(role: "assistant", text: "cached", timestamp: 1)])
|
||||
try await databases.cacheQueue.write { db in
|
||||
try db.execute(
|
||||
sql: "UPDATE cached_sessions SET payload_json = 'not-json' WHERE gateway_id = 'gw-a'")
|
||||
sql: """
|
||||
UPDATE cached_agent_sessions SET payload_json = 'not-json'
|
||||
WHERE gateway_id = 'gw-a' AND agent_id = 'agent-a'
|
||||
""")
|
||||
try db.execute(
|
||||
sql: "UPDATE cached_messages SET payload_json = 'not-json' WHERE gateway_id = 'gw-a'")
|
||||
}
|
||||
|
||||
#expect(await store.loadSessions().isEmpty)
|
||||
#expect(await store.loadSessions(agentID: "agent-a").isEmpty)
|
||||
#expect(await store.loadSessions(agentID: "agent-b").map(\.agentId) == ["agent-b"])
|
||||
#expect(await store.loadTranscript(sessionKey: "main").isEmpty)
|
||||
#expect(try await databases.cacheQueue.read { db in
|
||||
try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM cached_sessions WHERE gateway_id = 'gw-a'")
|
||||
try Int.fetchOne(db, sql: """
|
||||
SELECT COUNT(*) FROM cached_agent_sessions
|
||||
WHERE gateway_id = 'gw-a' AND agent_id = 'agent-a'
|
||||
""")
|
||||
} == 0)
|
||||
#expect(try await databases.cacheQueue.read { db in
|
||||
try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM cached_transcripts WHERE gateway_id = 'gw-a'")
|
||||
|
||||
+97
-7
@@ -49,6 +49,10 @@ private actor TestTranscriptCache: OpenClawChatTranscriptCache {
|
||||
return self.sessions
|
||||
}
|
||||
|
||||
func loadSessions(agentID _: String?) async -> [OpenClawChatSessionEntry] {
|
||||
await self.loadSessions()
|
||||
}
|
||||
|
||||
func loadTranscript(sessionKey: String) async -> [OpenClawChatMessage] {
|
||||
self.transcripts[sessionKey] ?? []
|
||||
}
|
||||
@@ -57,6 +61,10 @@ private actor TestTranscriptCache: OpenClawChatTranscriptCache {
|
||||
self.sessions = sessions
|
||||
}
|
||||
|
||||
func storeSessions(_ sessions: [OpenClawChatSessionEntry], agentID _: String?) async {
|
||||
await self.storeSessions(sessions)
|
||||
}
|
||||
|
||||
func storeCanonicalTranscript(
|
||||
sessionKey: String,
|
||||
agentID _: String?,
|
||||
@@ -68,6 +76,35 @@ private actor TestTranscriptCache: OpenClawChatTranscriptCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Old conformers compile through the protocol defaults but cannot partition
|
||||
/// selected-agent rosters, so scoped access must never reach this storage.
|
||||
private actor LegacyTranscriptCache: OpenClawChatTranscriptCache {
|
||||
private var sessions: [OpenClawChatSessionEntry]
|
||||
|
||||
init(sessions: [OpenClawChatSessionEntry] = []) {
|
||||
self.sessions = sessions
|
||||
}
|
||||
|
||||
func loadSessions() async -> [OpenClawChatSessionEntry] {
|
||||
self.sessions
|
||||
}
|
||||
|
||||
func loadTranscript(sessionKey _: String) async -> [OpenClawChatMessage] {
|
||||
[]
|
||||
}
|
||||
|
||||
func storeSessions(_ sessions: [OpenClawChatSessionEntry]) async {
|
||||
self.sessions = sessions
|
||||
}
|
||||
|
||||
func storeCanonicalTranscript(
|
||||
sessionKey _: String,
|
||||
agentID _: String?,
|
||||
messages _: [OpenClawChatMessage],
|
||||
canonicalMessageIdempotencyKeys _: Set<String>) async
|
||||
{}
|
||||
}
|
||||
|
||||
/// Minimal FIFO transport whose history responses can be gated during cold open.
|
||||
private final class GatedHistoryChatTransport: @unchecked Sendable, OpenClawChatTransport {
|
||||
private let historyResult: @Sendable (String, Int) async throws -> OpenClawChatHistoryPayload
|
||||
@@ -127,7 +164,7 @@ private func makeViewModel(
|
||||
sessionKey: String = "main",
|
||||
transport: GatedHistoryChatTransport,
|
||||
activeAgentID: String? = nil,
|
||||
cache: TestTranscriptCache,
|
||||
cache: any OpenClawChatTranscriptCache,
|
||||
load: Bool = true) -> OpenClawChatViewModel
|
||||
{
|
||||
let vm = OpenClawChatViewModel(
|
||||
@@ -311,7 +348,7 @@ struct ChatViewModelTranscriptCacheTests {
|
||||
#expect(await MainActor.run { vm.sessions.isEmpty })
|
||||
}
|
||||
|
||||
@Test func `ownerless shared cache cannot claim the selected global owner`() async throws {
|
||||
@Test func `legacy cache cannot paint an ownerless roster for either selected agent`() async throws {
|
||||
var global = cacheSessionEntry(key: "global", updatedAt: 1000)
|
||||
global.observerDigest = OpenClawChatSessionObserverDigest(
|
||||
runId: "run-legacy",
|
||||
@@ -319,7 +356,7 @@ struct ChatViewModelTranscriptCacheTests {
|
||||
updatedAt: 1000,
|
||||
headline: "Ambiguous legacy owner",
|
||||
health: "on-track")
|
||||
let cache = TestTranscriptCache(sessions: [global])
|
||||
let cache = LegacyTranscriptCache(sessions: [global])
|
||||
let transport = GatedHistoryChatTransport { sessionKey, _ in
|
||||
historyPayload(sessionKey: sessionKey, sessionID: "unused-live-session")
|
||||
}
|
||||
@@ -332,12 +369,65 @@ struct ChatViewModelTranscriptCacheTests {
|
||||
let snapshot = await MainActor.run { vm.currentSessionSnapshot() }
|
||||
|
||||
await MainActor.run { vm.paintFromCacheIfNeeded(session: snapshot) }
|
||||
try await waitUntil("shared cache row painted without ambiguous digest") {
|
||||
await MainActor.run { vm.sessions.count == 1 }
|
||||
try await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
await MainActor.run { vm.syncActiveAgentId("gadget") }
|
||||
let switchedSnapshot = await MainActor.run { vm.currentSessionSnapshot() }
|
||||
await MainActor.run { vm.paintFromCacheIfNeeded(session: switchedSnapshot) }
|
||||
try await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
#expect(await MainActor.run { vm.sessions.isEmpty })
|
||||
}
|
||||
|
||||
@Test func `legacy cache rejects scoped roster writes`() async {
|
||||
let original = cacheSessionEntry(key: "agent-a", updatedAt: 1000)
|
||||
let cache = LegacyTranscriptCache(sessions: [original])
|
||||
|
||||
await cache.storeSessions(
|
||||
[cacheSessionEntry(key: "agent-b", updatedAt: 2000)],
|
||||
agentID: "agent-b")
|
||||
|
||||
#expect(await cache.loadSessions().map(\.key) == ["agent-a"])
|
||||
#expect(await cache.loadSessions(agentID: "agent-a").isEmpty)
|
||||
#expect(await cache.loadSessions(agentID: "agent-b").isEmpty)
|
||||
}
|
||||
|
||||
@Test func `cached session prepaint stays within the selected agent`() async throws {
|
||||
var matchingBare = cacheSessionEntry(key: "shared-tool", updatedAt: 2000)
|
||||
matchingBare.agentId = "main"
|
||||
var foreignBare = cacheSessionEntry(key: "foreign-tool", updatedAt: 1750)
|
||||
foreignBare.agentId = "gadget"
|
||||
var foreignGlobal = cacheSessionEntry(key: "global", updatedAt: 1500)
|
||||
foreignGlobal.agentId = "gadget"
|
||||
let cache = TestTranscriptCache(sessions: [
|
||||
cacheSessionEntry(key: "agent:main:owned", updatedAt: 3000),
|
||||
cacheSessionEntry(key: "agent:gadget:foreign", updatedAt: 2500),
|
||||
matchingBare,
|
||||
foreignBare,
|
||||
foreignGlobal,
|
||||
cacheSessionEntry(key: "legacy-ownerless", updatedAt: 1000),
|
||||
])
|
||||
let transport = GatedHistoryChatTransport { sessionKey, _ in
|
||||
historyPayload(sessionKey: sessionKey, sessionID: "unused-live-session")
|
||||
}
|
||||
let vm = await makeViewModel(
|
||||
sessionKey: "agent:main:main",
|
||||
transport: transport,
|
||||
activeAgentID: "main",
|
||||
cache: cache,
|
||||
load: false)
|
||||
let snapshot = await MainActor.run { vm.currentSessionSnapshot() }
|
||||
|
||||
await MainActor.run { vm.paintFromCacheIfNeeded(session: snapshot) }
|
||||
try await waitUntil("cached sessions painted") {
|
||||
await MainActor.run { !vm.sessions.isEmpty }
|
||||
}
|
||||
|
||||
#expect(await MainActor.run { vm.sessions[0].key == "global" })
|
||||
#expect(await MainActor.run { vm.sessions[0].observerDigest == nil })
|
||||
#expect(await MainActor.run { vm.sessions.map(\.key) } == [
|
||||
"agent:main:owned",
|
||||
"shared-tool",
|
||||
"legacy-ownerless",
|
||||
])
|
||||
}
|
||||
|
||||
@Test func `session cache strips active markers and preserves terminal recap`() {
|
||||
|
||||
Reference in New Issue
Block a user