mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-13 20:46:07 +00:00
feat(android): read-only offline cache for chat sessions and transcripts (#100227)
* feat(android): add read-only offline cache for chat sessions and transcripts Cache-first cold open plus offline browsing of the session list and cached transcripts. Room DB (KSP) scoped by stable gateway identity, bounded to 50 sessions / 200 text-only messages per session with eviction on write, destructive migrations only. Live chat.history/sessions.list responses replace cached rows wholesale via the existing generation-tracked reconciliation path; sending stays behind the existing health gate. Pairing/auth resets and gateway-side session deletes purge cached rows. Part of #100194 * chore(android): sync native i18n inventory * fix(android): bind transcript cache writes to gateway scope * docs(changelog): note Android offline chat cache * chore(android): refresh native i18n inventory * docs(changelog): keep Android offline cache unreleased * docs(changelog): drop PR-carried entry; release generation owns CHANGELOG
This commit is contained in:
committed by
GitHub
parent
426010dfb9
commit
09f9a85145
@@ -123,7 +123,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-state-text",
|
||||
"line": 102,
|
||||
"line": 103,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt",
|
||||
"source": "Searching…",
|
||||
"surface": "android",
|
||||
@@ -131,7 +131,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-state-text",
|
||||
"line": 191,
|
||||
"line": 192,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt",
|
||||
"source": "Mic off",
|
||||
"surface": "android",
|
||||
@@ -139,7 +139,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-state-text",
|
||||
"line": 201,
|
||||
"line": 202,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt",
|
||||
"source": "Off",
|
||||
"surface": "android",
|
||||
@@ -179,7 +179,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-state-text",
|
||||
"line": 514,
|
||||
"line": 516,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
|
||||
"source": "Offline",
|
||||
"surface": "android",
|
||||
@@ -187,7 +187,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 2049,
|
||||
"line": 2083,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
|
||||
"source": "Failed: this host requires wss:// or Tailscale Serve. No TLS endpoint detected.",
|
||||
"surface": "android",
|
||||
@@ -195,7 +195,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 2051,
|
||||
"line": 2085,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
|
||||
"source": "Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry.",
|
||||
"surface": "android",
|
||||
@@ -203,7 +203,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 2053,
|
||||
"line": 2087,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
|
||||
"source": "Failed: couldn't reach the secure gateway endpoint for this host.",
|
||||
"surface": "android",
|
||||
|
||||
@@ -56,6 +56,7 @@ plugins {
|
||||
alias(libs.plugins.ktlint)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -217,6 +218,9 @@ dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
implementation(libs.androidx.security.crypto)
|
||||
// Read-only offline cache for chat sessions/transcripts (disposable, destructive migrations only).
|
||||
implementation(libs.androidx.room.runtime)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.androidx.exifinterface)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.bcprov)
|
||||
|
||||
@@ -5,6 +5,7 @@ import ai.openclaw.app.chat.ChatMessage
|
||||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import ai.openclaw.app.chat.OutgoingAttachment
|
||||
import ai.openclaw.app.chat.deleteChatTranscriptCacheDatabase
|
||||
import ai.openclaw.app.gateway.DeviceAuthStore
|
||||
import ai.openclaw.app.gateway.DeviceIdentityStore
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
@@ -306,6 +307,8 @@ class MainViewModel(
|
||||
val deviceAuthStore = DeviceAuthStore(prefs)
|
||||
deviceAuthStore.clearToken(deviceId, "node")
|
||||
deviceAuthStore.clearToken(deviceId, "operator")
|
||||
// No runtime means no open Room handle, so the cache file can be deleted directly.
|
||||
deleteChatTranscriptCacheDatabase(nodeApp)
|
||||
}
|
||||
|
||||
internal fun saveGatewayConfigAndConnect(plan: GatewayConnectPlan) {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import ai.openclaw.app.chat.ChatCacheScope
|
||||
import ai.openclaw.app.chat.ChatCommandEntry
|
||||
import ai.openclaw.app.chat.ChatController
|
||||
import ai.openclaw.app.chat.ChatMessage
|
||||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import ai.openclaw.app.chat.OutgoingAttachment
|
||||
import ai.openclaw.app.chat.RoomChatTranscriptCache
|
||||
import ai.openclaw.app.gateway.DeviceAuthEntry
|
||||
import ai.openclaw.app.gateway.DeviceAuthStore
|
||||
import ai.openclaw.app.gateway.DeviceIdentityStore
|
||||
@@ -661,6 +663,8 @@ class NodeRuntime(
|
||||
_gatewayUpdateAvailable.value = hello.updateAvailable
|
||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||
syncMainSessionKey(resolveAgentIdFromMainSessionKey(hello.mainSessionKey))
|
||||
// Every successful connection refreshes history, including reconnects whose main key did not change.
|
||||
chat.refresh()
|
||||
refreshGatewayControlPage()
|
||||
updateStatus {
|
||||
operatorConnectionProblem = null
|
||||
@@ -836,14 +840,40 @@ class NodeRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
private val chatTranscriptCache: RoomChatTranscriptCache =
|
||||
RoomChatTranscriptCache(context = appContext)
|
||||
|
||||
private val chat: ChatController =
|
||||
ChatController(
|
||||
scope = scope,
|
||||
session = operatorSession,
|
||||
json = json,
|
||||
transcriptCache = chatTranscriptCache,
|
||||
cacheScope = ::chatCacheScope,
|
||||
).also {
|
||||
it.applyMainSessionKey(_mainSessionKey.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable per-gateway scope for the offline chat cache; resolved per call so cached transcripts
|
||||
* never leak across gateways. Null (nothing paired/configured) disables cache reads and writes.
|
||||
*/
|
||||
private fun chatCacheGatewayId(): String? {
|
||||
connectedEndpoint?.stableId?.let { return it }
|
||||
if (manualEnabled.value) {
|
||||
val host = manualHost.value.trim()
|
||||
val port = manualPort.value
|
||||
if (host.isEmpty() || port !in 1..65535) return null
|
||||
return GatewayEndpoint.manual(host = host, port = port).stableId
|
||||
}
|
||||
return lastDiscoveredStableId.value.trim().takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun chatCacheScope(): ChatCacheScope? =
|
||||
chatCacheGatewayId()?.let { gatewayId ->
|
||||
ChatCacheScope(gatewayId = gatewayId, connectionGeneration = connectAttemptSeq.get())
|
||||
}
|
||||
|
||||
private val voiceReplySpeakerLazy: Lazy<TalkModeManager> =
|
||||
lazy {
|
||||
// Reuse the existing TalkMode speech engine for native Android TTS playback
|
||||
@@ -1243,6 +1273,9 @@ class NodeRuntime(
|
||||
val deviceId = identityStore.loadOrCreate().deviceId
|
||||
deviceAuthStore.clearToken(deviceId, "node")
|
||||
deviceAuthStore.clearToken(deviceId, "operator")
|
||||
// A pairing/auth reset can precede pairing a different gateway principal at the same
|
||||
// endpoint id; purge offline transcripts so they cannot surface under the new pairing.
|
||||
scope.launch { runCatching { chatTranscriptCache.clearAll() } }
|
||||
}
|
||||
|
||||
/** Persists onboarding state; callers decide whether runtime startup is needed first. */
|
||||
@@ -1928,6 +1961,7 @@ class NodeRuntime(
|
||||
notificationOutbox.clear()
|
||||
invalidateNodeCapabilityApprovalState()
|
||||
val connectAttemptId = connectAttemptSeq.incrementAndGet()
|
||||
chat.onGatewayScopeChanging()
|
||||
_pendingGatewayTrust.value = null
|
||||
val tls = connectionManager.resolveTlsParams(endpoint)
|
||||
if (tls?.required == true) {
|
||||
@@ -2113,6 +2147,7 @@ class NodeRuntime(
|
||||
fun disconnect() {
|
||||
notificationOutbox.clear()
|
||||
connectAttemptSeq.incrementAndGet()
|
||||
chat.onGatewayScopeChanging()
|
||||
stopActiveVoiceSession()
|
||||
connectedEndpoint = null
|
||||
_gatewayControlPage.value = null
|
||||
|
||||
@@ -22,19 +22,31 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
// Capture before suspend points; both fields must still match before gateway data reaches UI state.
|
||||
internal data class ChatCacheScope(
|
||||
val gatewayId: String,
|
||||
val connectionGeneration: Long,
|
||||
)
|
||||
|
||||
class ChatController internal constructor(
|
||||
private val scope: CoroutineScope,
|
||||
private val json: Json,
|
||||
private val requestGateway: suspend (method: String, paramsJson: String?) -> String,
|
||||
private val transcriptCache: ChatTranscriptCache? = null,
|
||||
private val cacheScope: () -> ChatCacheScope? = { null },
|
||||
) {
|
||||
constructor(
|
||||
internal constructor(
|
||||
scope: CoroutineScope,
|
||||
session: GatewaySession,
|
||||
json: Json,
|
||||
transcriptCache: ChatTranscriptCache? = null,
|
||||
cacheScope: () -> ChatCacheScope? = { null },
|
||||
) : this(
|
||||
scope = scope,
|
||||
json = json,
|
||||
requestGateway = { method, paramsJson -> session.request(method, paramsJson) },
|
||||
transcriptCache = transcriptCache,
|
||||
cacheScope = cacheScope,
|
||||
)
|
||||
|
||||
private var appliedMainSessionKey = "main"
|
||||
@@ -47,6 +59,10 @@ class ChatController internal constructor(
|
||||
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
||||
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
|
||||
|
||||
// True while the transcript shown came from the offline cache and no live history replaced it yet.
|
||||
private val _messagesFromCache = MutableStateFlow(false)
|
||||
val messagesFromCache: StateFlow<Boolean> = _messagesFromCache.asStateFlow()
|
||||
|
||||
private val _historyLoading = MutableStateFlow(false)
|
||||
val historyLoading: StateFlow<Boolean> = _historyLoading.asStateFlow()
|
||||
|
||||
@@ -84,6 +100,7 @@ class ChatController internal constructor(
|
||||
|
||||
// Drops stale history responses after session switches or refresh races.
|
||||
private val historyLoadGeneration = AtomicLong(0)
|
||||
private val gatewayScopeApplyLock = Any()
|
||||
private val newChatCreateInFlight = AtomicBoolean(false)
|
||||
|
||||
private var lastHealthPollAtMs: Long? = null
|
||||
@@ -108,6 +125,18 @@ class ChatController internal constructor(
|
||||
_sessionId.value = null
|
||||
}
|
||||
|
||||
/** Invalidates and clears gateway-bound UI state before a target switch can race old responses. */
|
||||
fun onGatewayScopeChanging() {
|
||||
synchronized(gatewayScopeApplyLock) {
|
||||
beginHistoryLoad(
|
||||
key = normalizeRequestedSessionKey(_sessionKey.value),
|
||||
clearMessages = true,
|
||||
markLoading = false,
|
||||
)
|
||||
_sessions.value = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads a chat session, normalizing "main" to the current gateway-provided main session key. */
|
||||
fun load(sessionKey: String) {
|
||||
val key = normalizeRequestedSessionKey(sessionKey)
|
||||
@@ -227,6 +256,7 @@ class ChatController internal constructor(
|
||||
private fun beginHistoryLoad(
|
||||
key: String,
|
||||
clearMessages: Boolean,
|
||||
markLoading: Boolean = true,
|
||||
): Long {
|
||||
val generation = historyLoadGeneration.incrementAndGet()
|
||||
_sessionKey.value = key
|
||||
@@ -242,9 +272,10 @@ class ChatController internal constructor(
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
_sessionId.value = null
|
||||
_historyLoading.value = true
|
||||
_historyLoading.value = markLoading
|
||||
if (clearMessages) {
|
||||
_messages.value = emptyList()
|
||||
_messagesFromCache.value = false
|
||||
}
|
||||
return generation
|
||||
}
|
||||
@@ -483,24 +514,12 @@ class ChatController internal constructor(
|
||||
forceHealth: Boolean,
|
||||
refreshSessions: Boolean,
|
||||
) {
|
||||
// Cache-first cold open: prime before the live request so ordering is deterministic and the
|
||||
// live chat.history response always replaces cached rows wholesale.
|
||||
primeFromCache(sessionKey, generation)
|
||||
try {
|
||||
val historyJson =
|
||||
requestGateway(
|
||||
"chat.history",
|
||||
buildJsonObject { put("sessionKey", JsonPrimitive(sessionKey)) }.toString(),
|
||||
)
|
||||
if (!isCurrentHistoryLoad(sessionKey, _sessionKey.value, generation, historyLoadGeneration.get())) return
|
||||
val history = parseHistory(historyJson, sessionKey = sessionKey, previousMessages = _messages.value)
|
||||
updateSessionFromHistory(history)
|
||||
prunePersistedOptimisticMessages(history.messages)
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_sessionId.value = history.sessionId
|
||||
adoptInFlightRun(history.inFlightRun)
|
||||
if (!fetchAndApplyHistory(sessionKey, generation, updateSessionInfo = true)) return
|
||||
_historyLoading.value = false
|
||||
history.thinkingLevel
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { _thinkingLevel.value = it }
|
||||
|
||||
pollHealthIfNeeded(force = forceHealth)
|
||||
if (refreshSessions) {
|
||||
@@ -513,8 +532,107 @@ class ChatController internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests live history and applies it to controller state, replacing any cached transcript.
|
||||
* Returns false when a newer load superseded this request (stale responses are dropped).
|
||||
*/
|
||||
private suspend fun fetchAndApplyHistory(
|
||||
sessionKey: String,
|
||||
generation: Long,
|
||||
updateSessionInfo: Boolean,
|
||||
): Boolean {
|
||||
val requestCacheScope = currentCacheScope()
|
||||
val historyJson =
|
||||
requestGateway(
|
||||
"chat.history",
|
||||
buildJsonObject { put("sessionKey", JsonPrimitive(sessionKey)) }.toString(),
|
||||
)
|
||||
val history = parseHistory(historyJson, sessionKey = sessionKey, previousMessages = _messages.value)
|
||||
val applied =
|
||||
synchronized(gatewayScopeApplyLock) {
|
||||
if (
|
||||
!isCurrentHistoryLoad(sessionKey, _sessionKey.value, generation, historyLoadGeneration.get()) ||
|
||||
requestCacheScope != currentCacheScope()
|
||||
) {
|
||||
return@synchronized false
|
||||
}
|
||||
if (updateSessionInfo) {
|
||||
updateSessionFromHistory(history)
|
||||
}
|
||||
prunePersistedOptimisticMessages(history.messages)
|
||||
_messagesFromCache.value = false
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_sessionId.value = history.sessionId
|
||||
// All live history paths (bootstrap, reconnect recovery, cache-first
|
||||
// replace) adopt the gateway's in-flight run snapshot so restored
|
||||
// runs keep their pending state and streaming text.
|
||||
adoptInFlightRun(history.inFlightRun)
|
||||
history.thinkingLevel
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { _thinkingLevel.value = it }
|
||||
true
|
||||
}
|
||||
if (!applied) return false
|
||||
persistTranscript(requestCacheScope, sessionKey, history.messages)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Emits cached transcript/session rows for instant cold open; live data replaces them wholesale. */
|
||||
private suspend fun primeFromCache(
|
||||
sessionKey: String,
|
||||
generation: Long,
|
||||
) {
|
||||
val cache = transcriptCache ?: return
|
||||
val requestCacheScope = currentCacheScope() ?: return
|
||||
if (_messages.value.isEmpty()) {
|
||||
val cached = runCatching { cache.loadTranscript(requestCacheScope.gatewayId, sessionKey) }.getOrDefault(emptyList())
|
||||
synchronized(gatewayScopeApplyLock) {
|
||||
if (
|
||||
cached.isNotEmpty() &&
|
||||
_messages.value.isEmpty() &&
|
||||
requestCacheScope == currentCacheScope() &&
|
||||
isCurrentHistoryLoad(sessionKey, _sessionKey.value, generation, historyLoadGeneration.get())
|
||||
) {
|
||||
_messagesFromCache.value = true
|
||||
_messages.value = cached
|
||||
}
|
||||
}
|
||||
}
|
||||
if (_sessions.value.isEmpty()) {
|
||||
val cachedSessions = runCatching { cache.loadSessions(requestCacheScope.gatewayId) }.getOrDefault(emptyList())
|
||||
synchronized(gatewayScopeApplyLock) {
|
||||
if (cachedSessions.isNotEmpty() && _sessions.value.isEmpty() && requestCacheScope == currentCacheScope()) {
|
||||
_sessions.value = cachedSessions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write-through uses the scope captured before the live request. Re-resolving here could put
|
||||
// an old response under a newly selected gateway. Failures are ignored: the cache is disposable.
|
||||
private suspend fun persistTranscript(
|
||||
requestCacheScope: ChatCacheScope?,
|
||||
sessionKey: String,
|
||||
messages: List<ChatMessage>,
|
||||
) {
|
||||
val cache = transcriptCache ?: return
|
||||
val gatewayId = requestCacheScope?.gatewayId ?: return
|
||||
runCatching { cache.saveTranscript(gatewayId, sessionKey, messages) }
|
||||
}
|
||||
|
||||
private suspend fun persistSessions(
|
||||
requestCacheScope: ChatCacheScope?,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
) {
|
||||
val cache = transcriptCache ?: return
|
||||
val gatewayId = requestCacheScope?.gatewayId ?: return
|
||||
runCatching { cache.saveSessions(gatewayId, sessions) }
|
||||
}
|
||||
|
||||
private suspend fun fetchSessions(limit: Int?) {
|
||||
try {
|
||||
val requestCacheScope = currentCacheScope()
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("includeGlobal", JsonPrimitive(true))
|
||||
@@ -522,7 +640,15 @@ class ChatController internal constructor(
|
||||
if (limit != null && limit > 0) put("limit", JsonPrimitive(limit))
|
||||
}
|
||||
val res = requestGateway("sessions.list", params.toString())
|
||||
_sessions.value = parseSessions(res)
|
||||
val sessions = parseSessions(res)
|
||||
val applied =
|
||||
synchronized(gatewayScopeApplyLock) {
|
||||
if (requestCacheScope != currentCacheScope()) return@synchronized false
|
||||
_sessions.value = sessions
|
||||
true
|
||||
}
|
||||
if (!applied) return
|
||||
persistSessions(requestCacheScope, sessions)
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
}
|
||||
@@ -608,37 +734,11 @@ class ChatController internal constructor(
|
||||
_streamingAssistantText.value = null
|
||||
scope.launch {
|
||||
try {
|
||||
val currentSessionKey = _sessionKey.value
|
||||
val currentGeneration = historyLoadGeneration.get()
|
||||
val historyJson =
|
||||
requestGateway(
|
||||
"chat.history",
|
||||
buildJsonObject { put("sessionKey", JsonPrimitive(currentSessionKey)) }.toString(),
|
||||
)
|
||||
if (
|
||||
!isCurrentHistoryLoad(
|
||||
currentSessionKey,
|
||||
_sessionKey.value,
|
||||
currentGeneration,
|
||||
historyLoadGeneration.get(),
|
||||
)
|
||||
) {
|
||||
return@launch
|
||||
}
|
||||
val history =
|
||||
parseHistory(
|
||||
historyJson,
|
||||
sessionKey = currentSessionKey,
|
||||
previousMessages = _messages.value,
|
||||
)
|
||||
updateSessionFromHistory(history)
|
||||
prunePersistedOptimisticMessages(history.messages)
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_sessionId.value = history.sessionId
|
||||
history.thinkingLevel
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { _thinkingLevel.value = it }
|
||||
fetchAndApplyHistory(
|
||||
sessionKey = _sessionKey.value,
|
||||
generation = historyLoadGeneration.get(),
|
||||
updateSessionInfo = true,
|
||||
)
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
}
|
||||
@@ -816,36 +916,13 @@ class ChatController internal constructor(
|
||||
private fun refreshCurrentHistoryBestEffort() {
|
||||
scope.launch {
|
||||
try {
|
||||
val currentSessionKey = _sessionKey.value
|
||||
val currentGeneration = historyLoadGeneration.get()
|
||||
val historyJson =
|
||||
requestGateway(
|
||||
"chat.history",
|
||||
buildJsonObject { put("sessionKey", JsonPrimitive(currentSessionKey)) }.toString(),
|
||||
)
|
||||
if (
|
||||
!isCurrentHistoryLoad(
|
||||
currentSessionKey,
|
||||
_sessionKey.value,
|
||||
currentGeneration,
|
||||
historyLoadGeneration.get(),
|
||||
)
|
||||
) {
|
||||
return@launch
|
||||
}
|
||||
val history =
|
||||
parseHistory(
|
||||
historyJson,
|
||||
sessionKey = currentSessionKey,
|
||||
previousMessages = _messages.value,
|
||||
)
|
||||
prunePersistedOptimisticMessages(history.messages)
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_sessionId.value = history.sessionId
|
||||
history.thinkingLevel
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { _thinkingLevel.value = it }
|
||||
fetchAndApplyHistory(
|
||||
sessionKey = _sessionKey.value,
|
||||
generation = historyLoadGeneration.get(),
|
||||
// Intentionally skips session-info upserts: post-send refreshes should not reorder the
|
||||
// session list; sessions.changed events own that.
|
||||
updateSessionInfo = false,
|
||||
)
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
}
|
||||
@@ -960,6 +1037,19 @@ class ChatController internal constructor(
|
||||
private fun removeSessionEntry(sessionKey: String?) {
|
||||
val key = sessionKey?.trim()?.takeIf { it.isNotEmpty() } ?: return
|
||||
_sessions.value = _sessions.value.filterNot { it.key == key }
|
||||
// Gateway-side deletes must also purge the offline copy, or the deleted transcript would
|
||||
// reappear on the next offline cold open.
|
||||
val cache = transcriptCache ?: return
|
||||
val requestCacheScope = currentCacheScope() ?: return
|
||||
scope.launch {
|
||||
runCatching { cache.deleteSession(requestCacheScope.gatewayId, key) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentCacheScope(): ChatCacheScope? {
|
||||
val scope = cacheScope() ?: return null
|
||||
val gatewayId = scope.gatewayId.trim().takeIf { it.isNotEmpty() } ?: return null
|
||||
return if (gatewayId == scope.gatewayId) scope else scope.copy(gatewayId = gatewayId)
|
||||
}
|
||||
|
||||
private fun normalizeThinking(raw: String): String =
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
package ai.openclaw.app.chat
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Database
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.withTransaction
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.UUID
|
||||
|
||||
/** Upper bound of cached session rows per gateway; oldest list positions are evicted on write. */
|
||||
internal const val MAX_CACHED_SESSIONS = 50
|
||||
|
||||
internal const val CHAT_TRANSCRIPT_CACHE_DB_NAME = "chat-transcript-cache.db"
|
||||
|
||||
/**
|
||||
* Deletes the cache database file outright. Only safe while no [RoomChatTranscriptCache] is open
|
||||
* in this process; used by pairing-reset paths that run before the node runtime exists.
|
||||
*/
|
||||
internal fun deleteChatTranscriptCacheDatabase(context: Context) {
|
||||
context.deleteDatabase(CHAT_TRANSCRIPT_CACHE_DB_NAME)
|
||||
}
|
||||
|
||||
/** Upper bound of cached transcript rows per session; only the newest messages are kept. */
|
||||
internal const val MAX_CACHED_MESSAGES_PER_SESSION = 200
|
||||
|
||||
/**
|
||||
* Read-only offline cache of chat sessions and transcripts.
|
||||
*
|
||||
* The cache is disposable: it only speeds up cold open and enables offline browsing.
|
||||
* Live `chat.history` / `sessions.list` responses always replace cached data wholesale.
|
||||
*/
|
||||
interface ChatTranscriptCache {
|
||||
suspend fun loadSessions(gatewayId: String): List<ChatSessionEntry>
|
||||
|
||||
suspend fun loadTranscript(
|
||||
gatewayId: String,
|
||||
sessionKey: String,
|
||||
): List<ChatMessage>
|
||||
|
||||
suspend fun saveSessions(
|
||||
gatewayId: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
)
|
||||
|
||||
suspend fun saveTranscript(
|
||||
gatewayId: String,
|
||||
sessionKey: String,
|
||||
messages: List<ChatMessage>,
|
||||
)
|
||||
|
||||
/** Removes one session and its transcript, so gateway-side deletes also purge offline copies. */
|
||||
suspend fun deleteSession(
|
||||
gatewayId: String,
|
||||
sessionKey: String,
|
||||
)
|
||||
|
||||
/** Purges every cached row for all gateways; used when pairing/auth state is reset. */
|
||||
suspend fun clearAll()
|
||||
}
|
||||
|
||||
@Entity(tableName = "cached_sessions", primaryKeys = ["gatewayId", "sessionKey"])
|
||||
internal data class CachedSessionEntity(
|
||||
val gatewayId: String,
|
||||
val sessionKey: String,
|
||||
val displayName: String?,
|
||||
val updatedAtMs: Long?,
|
||||
// Preserves gateway list order so offline session rows render in the familiar order.
|
||||
val rowOrder: Int,
|
||||
)
|
||||
|
||||
@Entity(tableName = "cached_messages", primaryKeys = ["gatewayId", "sessionKey", "rowOrder"])
|
||||
internal data class CachedMessageEntity(
|
||||
val gatewayId: String,
|
||||
val sessionKey: String,
|
||||
val rowOrder: Int,
|
||||
val role: String,
|
||||
// JSON array of text part strings; attachments/binary parts are never persisted.
|
||||
val textPartsJson: String,
|
||||
val timestampMs: Long?,
|
||||
// Kept so live history reconciliation can match cached rows by identity key.
|
||||
val idempotencyKey: String?,
|
||||
)
|
||||
|
||||
@Dao
|
||||
internal interface ChatCacheDao {
|
||||
@Query("SELECT * FROM cached_sessions WHERE gatewayId = :gatewayId ORDER BY rowOrder ASC")
|
||||
suspend fun sessions(gatewayId: String): List<CachedSessionEntity>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM cached_messages WHERE gatewayId = :gatewayId AND sessionKey = :sessionKey ORDER BY rowOrder ASC",
|
||||
)
|
||||
suspend fun messages(
|
||||
gatewayId: String,
|
||||
sessionKey: String,
|
||||
): List<CachedMessageEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertSessions(rows: List<CachedSessionEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertSessionStub(row: CachedSessionEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertMessages(rows: List<CachedMessageEntity>)
|
||||
|
||||
@Query("DELETE FROM cached_sessions WHERE gatewayId = :gatewayId")
|
||||
suspend fun deleteSessions(gatewayId: String)
|
||||
|
||||
@Query("DELETE FROM cached_sessions")
|
||||
suspend fun deleteAllSessions()
|
||||
|
||||
@Query("DELETE FROM cached_messages")
|
||||
suspend fun deleteAllMessages()
|
||||
|
||||
@Query("DELETE FROM cached_sessions WHERE gatewayId = :gatewayId AND sessionKey = :sessionKey")
|
||||
suspend fun deleteSessionRow(
|
||||
gatewayId: String,
|
||||
sessionKey: String,
|
||||
)
|
||||
|
||||
@Query("DELETE FROM cached_messages WHERE gatewayId = :gatewayId AND sessionKey = :sessionKey")
|
||||
suspend fun deleteTranscript(
|
||||
gatewayId: String,
|
||||
sessionKey: String,
|
||||
)
|
||||
|
||||
@Query("SELECT COALESCE(MAX(rowOrder), -1) + 1 FROM cached_sessions WHERE gatewayId = :gatewayId")
|
||||
suspend fun nextSessionRowOrder(gatewayId: String): Int
|
||||
|
||||
// Keeps the just-written session even when the cache is full: without the exclusion, a stub
|
||||
// inserted at the highest rowOrder would be evicted immediately and deep-session transcripts
|
||||
// could never be cached once MAX_CACHED_SESSIONS rows exist.
|
||||
@Query(
|
||||
"DELETE FROM cached_sessions WHERE gatewayId = :gatewayId AND sessionKey != :keepSessionKey AND sessionKey NOT IN " +
|
||||
"(SELECT sessionKey FROM cached_sessions WHERE gatewayId = :gatewayId AND sessionKey != :keepSessionKey " +
|
||||
"ORDER BY rowOrder ASC LIMIT :keep)",
|
||||
)
|
||||
suspend fun evictSessionsBeyondKeeping(
|
||||
gatewayId: String,
|
||||
keepSessionKey: String,
|
||||
keep: Int,
|
||||
)
|
||||
|
||||
// Transcripts must never outlive their session row; this keeps total cache size bounded
|
||||
// by MAX_CACHED_SESSIONS * MAX_CACHED_MESSAGES_PER_SESSION rows per gateway.
|
||||
@Query(
|
||||
"DELETE FROM cached_messages WHERE gatewayId = :gatewayId AND sessionKey NOT IN " +
|
||||
"(SELECT sessionKey FROM cached_sessions WHERE gatewayId = :gatewayId)",
|
||||
)
|
||||
suspend fun evictOrphanedTranscripts(gatewayId: String)
|
||||
}
|
||||
|
||||
@Database(
|
||||
entities = [CachedSessionEntity::class, CachedMessageEntity::class],
|
||||
version = 1,
|
||||
exportSchema = false,
|
||||
)
|
||||
internal abstract class ChatCacheDatabase : RoomDatabase() {
|
||||
abstract fun dao(): ChatCacheDao
|
||||
|
||||
companion object {
|
||||
fun open(context: Context): ChatCacheDatabase =
|
||||
Room
|
||||
.databaseBuilder(context, ChatCacheDatabase::class.java, CHAT_TRANSCRIPT_CACHE_DB_NAME)
|
||||
// The cache is disposable by contract: any schema bump drops and rebuilds instead of migrating.
|
||||
.fallbackToDestructiveMigration(dropAllTables = true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Room-backed [ChatTranscriptCache]. Callers bind every operation to the gateway scope captured
|
||||
* before their suspend point, so a connection switch cannot re-scope an old response.
|
||||
*/
|
||||
class RoomChatTranscriptCache internal constructor(
|
||||
private val database: ChatCacheDatabase,
|
||||
) : ChatTranscriptCache {
|
||||
constructor(context: Context) : this(database = ChatCacheDatabase.open(context))
|
||||
|
||||
private val json = Json
|
||||
private val textPartsSerializer = ListSerializer(String.serializer())
|
||||
|
||||
override suspend fun loadSessions(gatewayId: String): List<ChatSessionEntry> {
|
||||
val gateway = scopedGatewayId(gatewayId) ?: return emptyList()
|
||||
return database.dao().sessions(gateway).map { row ->
|
||||
ChatSessionEntry(
|
||||
key = row.sessionKey,
|
||||
updatedAtMs = row.updatedAtMs,
|
||||
displayName = row.displayName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun loadTranscript(
|
||||
gatewayId: String,
|
||||
sessionKey: String,
|
||||
): List<ChatMessage> {
|
||||
val gateway = scopedGatewayId(gatewayId) ?: return emptyList()
|
||||
val key = sessionKey.trim().takeIf { it.isNotEmpty() } ?: return emptyList()
|
||||
return database.dao().messages(gateway, key).map { row ->
|
||||
ChatMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
role = row.role,
|
||||
content = decodeTextParts(row.textPartsJson).map { ChatMessageContent(type = "text", text = it) },
|
||||
timestampMs = row.timestampMs,
|
||||
idempotencyKey = row.idempotencyKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun saveSessions(
|
||||
gatewayId: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
) {
|
||||
val gateway = scopedGatewayId(gatewayId) ?: return
|
||||
val rows =
|
||||
sessions.take(MAX_CACHED_SESSIONS).mapIndexed { index, session ->
|
||||
CachedSessionEntity(
|
||||
gatewayId = gateway,
|
||||
sessionKey = session.key,
|
||||
displayName = session.displayName,
|
||||
updatedAtMs = session.updatedAtMs,
|
||||
rowOrder = index,
|
||||
)
|
||||
}
|
||||
val dao = database.dao()
|
||||
database.withTransaction {
|
||||
dao.deleteSessions(gateway)
|
||||
dao.insertSessions(rows)
|
||||
dao.evictOrphanedTranscripts(gateway)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun saveTranscript(
|
||||
gatewayId: String,
|
||||
sessionKey: String,
|
||||
messages: List<ChatMessage>,
|
||||
) {
|
||||
val gateway = scopedGatewayId(gatewayId) ?: return
|
||||
val key = sessionKey.trim().takeIf { it.isNotEmpty() } ?: return
|
||||
// Text rows only: attachment/binary parts are dropped, and messages without any text are skipped.
|
||||
val rows =
|
||||
messages
|
||||
.mapNotNull { message ->
|
||||
val textParts = message.content.filter { it.type == "text" }.mapNotNull { it.text }
|
||||
if (textParts.isEmpty()) return@mapNotNull null
|
||||
message to textParts
|
||||
}.takeLast(MAX_CACHED_MESSAGES_PER_SESSION)
|
||||
.mapIndexed { index, (message, textParts) ->
|
||||
CachedMessageEntity(
|
||||
gatewayId = gateway,
|
||||
sessionKey = key,
|
||||
rowOrder = index,
|
||||
role = message.role,
|
||||
textPartsJson = json.encodeToString(textPartsSerializer, textParts),
|
||||
timestampMs = message.timestampMs,
|
||||
idempotencyKey = message.idempotencyKey,
|
||||
)
|
||||
}
|
||||
val dao = database.dao()
|
||||
database.withTransaction {
|
||||
dao.deleteTranscript(gateway, key)
|
||||
dao.insertMessages(rows)
|
||||
// A transcript may arrive for a session missing from the cached list (e.g. deep session
|
||||
// switch); keep a stub row so the transcript stays reachable, then re-apply the bounds.
|
||||
dao.insertSessionStub(
|
||||
CachedSessionEntity(
|
||||
gatewayId = gateway,
|
||||
sessionKey = key,
|
||||
displayName = null,
|
||||
updatedAtMs = null,
|
||||
rowOrder = dao.nextSessionRowOrder(gateway),
|
||||
),
|
||||
)
|
||||
dao.evictSessionsBeyondKeeping(gateway, keepSessionKey = key, keep = MAX_CACHED_SESSIONS - 1)
|
||||
dao.evictOrphanedTranscripts(gateway)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearAll() {
|
||||
val dao = database.dao()
|
||||
database.withTransaction {
|
||||
dao.deleteAllSessions()
|
||||
dao.deleteAllMessages()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteSession(
|
||||
gatewayId: String,
|
||||
sessionKey: String,
|
||||
) {
|
||||
val gateway = scopedGatewayId(gatewayId) ?: return
|
||||
val key = sessionKey.trim().takeIf { it.isNotEmpty() } ?: return
|
||||
val dao = database.dao()
|
||||
database.withTransaction {
|
||||
dao.deleteSessionRow(gateway, key)
|
||||
dao.deleteTranscript(gateway, key)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scopedGatewayId(gatewayId: String): String? = gatewayId.trim().takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun decodeTextParts(encoded: String): List<String> = runCatching { json.decodeFromString(textPartsSerializer, encoded) }.getOrDefault(emptyList())
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package ai.openclaw.app.chat
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatControllerTranscriptCacheTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val gatewayScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1)
|
||||
|
||||
private class FakeTranscriptCache : ChatTranscriptCache {
|
||||
val transcripts = mutableMapOf<Pair<String, String>, List<ChatMessage>>()
|
||||
var sessions: List<ChatSessionEntry> = emptyList()
|
||||
val savedTranscripts = mutableListOf<Triple<String, String, List<ChatMessage>>>()
|
||||
val savedSessions = mutableListOf<Pair<String, List<ChatSessionEntry>>>()
|
||||
val deletedSessions = mutableListOf<Pair<String, String>>()
|
||||
|
||||
override suspend fun loadSessions(gatewayId: String): List<ChatSessionEntry> = sessions
|
||||
|
||||
override suspend fun loadTranscript(
|
||||
gatewayId: String,
|
||||
sessionKey: String,
|
||||
): List<ChatMessage> = transcripts[gatewayId to sessionKey].orEmpty()
|
||||
|
||||
override suspend fun saveSessions(
|
||||
gatewayId: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
) {
|
||||
savedSessions += gatewayId to sessions
|
||||
}
|
||||
|
||||
override suspend fun saveTranscript(
|
||||
gatewayId: String,
|
||||
sessionKey: String,
|
||||
messages: List<ChatMessage>,
|
||||
) {
|
||||
savedTranscripts += Triple(gatewayId, sessionKey, messages)
|
||||
}
|
||||
|
||||
override suspend fun deleteSession(
|
||||
gatewayId: String,
|
||||
sessionKey: String,
|
||||
) {
|
||||
deletedSessions += gatewayId to sessionKey
|
||||
}
|
||||
|
||||
override suspend fun clearAll() {
|
||||
transcripts.clear()
|
||||
sessions = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cachedMessage(
|
||||
text: String,
|
||||
role: String = "assistant",
|
||||
timestampMs: Long = 1L,
|
||||
): ChatMessage =
|
||||
ChatMessage(
|
||||
id = "cached-$text",
|
||||
role = role,
|
||||
content = listOf(ChatMessageContent(type = "text", text = text)),
|
||||
timestampMs = timestampMs,
|
||||
)
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun offlineColdOpenShowsCachedTranscriptAndSessionsAndKeepsSendBlocked() =
|
||||
runTest {
|
||||
val cache = FakeTranscriptCache()
|
||||
cache.transcripts["gateway-a" to "main"] = listOf(cachedMessage("cached hello"), cachedMessage("cached reply"))
|
||||
cache.sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = 5, displayName = "Main"))
|
||||
val controller =
|
||||
ChatController(
|
||||
scope = this,
|
||||
json = json,
|
||||
requestGateway = { _, _ -> throw IllegalStateException("offline") },
|
||||
transcriptCache = cache,
|
||||
cacheScope = { gatewayScope },
|
||||
)
|
||||
|
||||
controller.load("main")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
listOf("cached hello", "cached reply"),
|
||||
controller.messages.value.map { it.content.single().text },
|
||||
)
|
||||
assertTrue(controller.messagesFromCache.value)
|
||||
assertEquals(listOf("main"), controller.sessions.value.map { it.key })
|
||||
assertFalse(controller.healthOk.value)
|
||||
|
||||
val accepted =
|
||||
controller.sendMessageAwaitAcceptance(message = "hi", thinkingLevel = "off", attachments = emptyList())
|
||||
assertFalse(accepted)
|
||||
assertEquals("Gateway health not OK; cannot send", controller.errorText.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun cachedTranscriptEmitsFirstThenLiveHistoryReplacesWholesale() =
|
||||
runTest {
|
||||
val cache = FakeTranscriptCache()
|
||||
cache.transcripts["gateway-a" to "main"] =
|
||||
listOf(
|
||||
cachedMessage("cached hello", role = "user", timestampMs = 10),
|
||||
cachedMessage("stale line", role = "assistant", timestampMs = 11),
|
||||
)
|
||||
val historyGate = CompletableDeferred<Unit>()
|
||||
val controller =
|
||||
ChatController(
|
||||
scope = this,
|
||||
json = json,
|
||||
requestGateway = { method, _ ->
|
||||
when (method) {
|
||||
"chat.history" -> {
|
||||
historyGate.await()
|
||||
"""
|
||||
{
|
||||
"sessionId": "session-1",
|
||||
"messages": [
|
||||
{ "role": "user", "content": "cached hello", "timestamp": 10 },
|
||||
{ "role": "assistant", "content": "fresh reply", "timestamp": 20 }
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
else -> "{}"
|
||||
}
|
||||
},
|
||||
transcriptCache = cache,
|
||||
cacheScope = { gatewayScope },
|
||||
)
|
||||
|
||||
controller.load("main")
|
||||
runCurrent()
|
||||
|
||||
// Cached transcript is visible while chat.history is still in flight.
|
||||
assertTrue(controller.messagesFromCache.value)
|
||||
assertEquals(
|
||||
listOf("cached hello", "stale line"),
|
||||
controller.messages.value.map { it.content.single().text },
|
||||
)
|
||||
val cachedFirstMessageId =
|
||||
controller.messages.value
|
||||
.first()
|
||||
.id
|
||||
|
||||
historyGate.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse(controller.messagesFromCache.value)
|
||||
assertEquals(
|
||||
listOf("cached hello", "fresh reply"),
|
||||
controller.messages.value.map { it.content.single().text },
|
||||
)
|
||||
// Existing reconciliation keeps stable ids for rows the live history confirms.
|
||||
val liveFirstMessageId =
|
||||
controller.messages.value
|
||||
.first()
|
||||
.id
|
||||
assertEquals(cachedFirstMessageId, liveFirstMessageId)
|
||||
// Live history is written through to the cache.
|
||||
val savedTranscript = cache.savedTranscripts.last()
|
||||
assertEquals("gateway-a", savedTranscript.first)
|
||||
assertEquals("main", savedTranscript.second)
|
||||
assertEquals(
|
||||
listOf("cached hello", "fresh reply"),
|
||||
savedTranscript.third.map { it.content.single().text },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun switchSessionOfflineShowsCachedTranscriptForThatSession() =
|
||||
runTest {
|
||||
val cache = FakeTranscriptCache()
|
||||
cache.transcripts["gateway-a" to "agent:other:main"] = listOf(cachedMessage("other session text"))
|
||||
val controller =
|
||||
ChatController(
|
||||
scope = this,
|
||||
json = json,
|
||||
requestGateway = { _, _ -> throw IllegalStateException("offline") },
|
||||
transcriptCache = cache,
|
||||
cacheScope = { gatewayScope },
|
||||
)
|
||||
controller.load("main")
|
||||
advanceUntilIdle()
|
||||
assertEquals(emptyList<ChatMessage>(), controller.messages.value)
|
||||
|
||||
controller.switchSession("agent:other:main")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
listOf("other session text"),
|
||||
controller.messages.value.map { it.content.single().text },
|
||||
)
|
||||
assertTrue(controller.messagesFromCache.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun sessionDeleteEventPurgesCachedSession() =
|
||||
runTest {
|
||||
val cache = FakeTranscriptCache()
|
||||
val controller =
|
||||
ChatController(
|
||||
scope = this,
|
||||
json = json,
|
||||
requestGateway = { _, _ -> "{}" },
|
||||
transcriptCache = cache,
|
||||
cacheScope = { gatewayScope },
|
||||
)
|
||||
|
||||
controller.handleGatewayEvent(
|
||||
"sessions.changed",
|
||||
"""{"reason":"delete","sessionKey":"agent:old:main"}""",
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("gateway-a" to "agent:old:main"), cache.deletedSessions)
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun liveSessionListIsWrittenThroughToCache() =
|
||||
runTest {
|
||||
val cache = FakeTranscriptCache()
|
||||
val controller =
|
||||
ChatController(
|
||||
scope = this,
|
||||
json = json,
|
||||
requestGateway = { method, _ ->
|
||||
when (method) {
|
||||
"sessions.list" -> """{"sessions":[{"key":"main","updatedAt":7,"displayName":"Main"}]}"""
|
||||
"chat.history" -> """{"sessionId":"session-1","messages":[]}"""
|
||||
else -> "{}"
|
||||
}
|
||||
},
|
||||
transcriptCache = cache,
|
||||
cacheScope = { gatewayScope },
|
||||
)
|
||||
|
||||
controller.load("main")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("gateway-a", cache.savedSessions.last().first)
|
||||
assertEquals(
|
||||
listOf("main"),
|
||||
cache.savedSessions
|
||||
.last()
|
||||
.second
|
||||
.map { it.key },
|
||||
)
|
||||
assertEquals(listOf("main"), controller.sessions.value.map { it.key })
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun oldGatewayHistoryResponseIsNeitherAppliedNorCachedAfterScopeChange() =
|
||||
runTest {
|
||||
val cache = FakeTranscriptCache()
|
||||
val historyGate = CompletableDeferred<Unit>()
|
||||
var currentScope = gatewayScope
|
||||
val controller =
|
||||
ChatController(
|
||||
scope = this,
|
||||
json = json,
|
||||
requestGateway = { method, _ ->
|
||||
if (method == "chat.history") {
|
||||
historyGate.await()
|
||||
"""{"sessionId":"old","messages":[{"role":"assistant","content":"old gateway"}]}"""
|
||||
} else {
|
||||
"{}"
|
||||
}
|
||||
},
|
||||
transcriptCache = cache,
|
||||
cacheScope = { currentScope },
|
||||
)
|
||||
|
||||
controller.load("main")
|
||||
runCurrent()
|
||||
assertTrue(controller.historyLoading.value)
|
||||
currentScope = ChatCacheScope(gatewayId = "gateway-b", connectionGeneration = 2)
|
||||
controller.onGatewayScopeChanging()
|
||||
assertFalse(controller.historyLoading.value)
|
||||
historyGate.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(controller.messages.value.isEmpty())
|
||||
assertTrue(cache.savedTranscripts.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun oldGatewaySessionListIsNeitherAppliedNorCachedAfterScopeChange() =
|
||||
runTest {
|
||||
val cache = FakeTranscriptCache()
|
||||
val sessionsGate = CompletableDeferred<Unit>()
|
||||
var currentScope = gatewayScope
|
||||
val controller =
|
||||
ChatController(
|
||||
scope = this,
|
||||
json = json,
|
||||
requestGateway = { method, _ ->
|
||||
if (method == "sessions.list") {
|
||||
sessionsGate.await()
|
||||
"""{"sessions":[{"key":"old-gateway-session"}]}"""
|
||||
} else {
|
||||
"{}"
|
||||
}
|
||||
},
|
||||
transcriptCache = cache,
|
||||
cacheScope = { currentScope },
|
||||
)
|
||||
|
||||
controller.refreshSessions()
|
||||
runCurrent()
|
||||
currentScope = ChatCacheScope(gatewayId = "gateway-b", connectionGeneration = 2)
|
||||
sessionsGate.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(controller.sessions.value.isEmpty())
|
||||
assertTrue(cache.savedSessions.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package ai.openclaw.app.chat
|
||||
|
||||
import androidx.room.Room
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class RoomChatTranscriptCacheTest {
|
||||
private val database: ChatCacheDatabase =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(RuntimeEnvironment.getApplication(), ChatCacheDatabase::class.java)
|
||||
.build()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
database.close()
|
||||
}
|
||||
|
||||
private fun cache(): RoomChatTranscriptCache = RoomChatTranscriptCache(database = database)
|
||||
|
||||
private fun message(
|
||||
text: String,
|
||||
role: String = "user",
|
||||
timestampMs: Long? = 1L,
|
||||
idempotencyKey: String? = null,
|
||||
extraParts: List<ChatMessageContent> = emptyList(),
|
||||
): ChatMessage =
|
||||
ChatMessage(
|
||||
id = "id-$text",
|
||||
role = role,
|
||||
content = listOf(ChatMessageContent(type = "text", text = text)) + extraParts,
|
||||
timestampMs = timestampMs,
|
||||
idempotencyKey = idempotencyKey,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun transcriptRoundTripKeepsTextRowsOnly() =
|
||||
runTest {
|
||||
val store = cache()
|
||||
val imagePart = ChatMessageContent(type = "image", mimeType = "image/png", fileName = "a.png", base64 = "AAAA")
|
||||
store.saveTranscript(
|
||||
gatewayId = "gateway-a",
|
||||
sessionKey = "main",
|
||||
messages =
|
||||
listOf(
|
||||
message("hello", role = "user", timestampMs = 10, idempotencyKey = "run-1:user", extraParts = listOf(imagePart)),
|
||||
// Attachment-only messages have no cacheable text and are skipped entirely.
|
||||
ChatMessage(id = "img", role = "user", content = listOf(imagePart), timestampMs = 11),
|
||||
message("world", role = "assistant", timestampMs = 12),
|
||||
),
|
||||
)
|
||||
|
||||
val loaded = store.loadTranscript("gateway-a", "main")
|
||||
|
||||
assertEquals(listOf("hello", "world"), loaded.map { it.content.single().text })
|
||||
assertTrue(loaded.all { message -> message.content.all { part -> part.type == "text" && part.base64 == null } })
|
||||
assertEquals(listOf("user", "assistant"), loaded.map { it.role })
|
||||
assertEquals(listOf(10L, 12L), loaded.map { it.timestampMs })
|
||||
assertEquals(listOf("run-1:user", null), loaded.map { it.idempotencyKey })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcriptWriteKeepsOnlyNewestBoundedMessages() =
|
||||
runTest {
|
||||
val store = cache()
|
||||
store.saveTranscript(
|
||||
gatewayId = "gateway-a",
|
||||
sessionKey = "main",
|
||||
messages = (0 until MAX_CACHED_MESSAGES_PER_SESSION + 50).map { index -> message("m$index", timestampMs = index.toLong()) },
|
||||
)
|
||||
|
||||
val loadedTexts = store.loadTranscript("gateway-a", "main").map { it.content.single().text }
|
||||
|
||||
assertEquals(MAX_CACHED_MESSAGES_PER_SESSION, loadedTexts.size)
|
||||
assertEquals("m50", loadedTexts.first())
|
||||
assertEquals("m249", loadedTexts.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionWriteEvictsBeyondBoundAndDropsOrphanedTranscripts() =
|
||||
runTest {
|
||||
val store = cache()
|
||||
store.saveTranscript(gatewayId = "gateway-a", sessionKey = "session-10", messages = listOf(message("kept")))
|
||||
store.saveTranscript(gatewayId = "gateway-a", sessionKey = "session-55", messages = listOf(message("evicted")))
|
||||
|
||||
store.saveSessions(
|
||||
gatewayId = "gateway-a",
|
||||
sessions =
|
||||
(0 until MAX_CACHED_SESSIONS + 10).map { index ->
|
||||
ChatSessionEntry(key = "session-$index", updatedAtMs = 1000L - index, displayName = "Session $index")
|
||||
},
|
||||
)
|
||||
|
||||
val sessions = store.loadSessions("gateway-a")
|
||||
assertEquals(MAX_CACHED_SESSIONS, sessions.size)
|
||||
assertEquals("session-0", sessions.first().key)
|
||||
assertEquals("session-${MAX_CACHED_SESSIONS - 1}", sessions.last().key)
|
||||
assertEquals("Session 0", sessions.first().displayName)
|
||||
assertEquals(listOf("kept"), store.loadTranscript("gateway-a", "session-10").map { it.content.single().text })
|
||||
assertEquals(emptyList<ChatMessage>(), store.loadTranscript("gateway-a", "session-55"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcriptForSessionOutsideFullCachedListSurvivesEviction() =
|
||||
runTest {
|
||||
val store = cache()
|
||||
store.saveSessions(
|
||||
gatewayId = "gateway-a",
|
||||
sessions =
|
||||
(0 until MAX_CACHED_SESSIONS).map { index ->
|
||||
ChatSessionEntry(key = "session-$index", updatedAtMs = 1000L - index)
|
||||
},
|
||||
)
|
||||
|
||||
store.saveTranscript(gatewayId = "gateway-a", sessionKey = "deep-session", messages = listOf(message("deep text")))
|
||||
|
||||
assertEquals(listOf("deep text"), store.loadTranscript("gateway-a", "deep-session").map { it.content.single().text })
|
||||
val sessionKeys = store.loadSessions("gateway-a").map { it.key }
|
||||
assertEquals(MAX_CACHED_SESSIONS, sessionKeys.size)
|
||||
assertTrue(sessionKeys.contains("deep-session"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteSessionRemovesSessionRowAndTranscript() =
|
||||
runTest {
|
||||
val store = cache()
|
||||
store.saveSessions(
|
||||
gatewayId = "gateway-a",
|
||||
sessions =
|
||||
listOf(
|
||||
ChatSessionEntry(key = "main", updatedAtMs = 1),
|
||||
ChatSessionEntry(key = "other", updatedAtMs = 2),
|
||||
),
|
||||
)
|
||||
store.saveTranscript(gatewayId = "gateway-a", sessionKey = "main", messages = listOf(message("delete me")))
|
||||
store.saveTranscript(gatewayId = "gateway-a", sessionKey = "other", messages = listOf(message("keep me")))
|
||||
|
||||
store.deleteSession("gateway-a", "main")
|
||||
|
||||
assertEquals(emptyList<ChatMessage>(), store.loadTranscript("gateway-a", "main"))
|
||||
assertEquals(listOf("other"), store.loadSessions("gateway-a").map { it.key })
|
||||
assertEquals(listOf("keep me"), store.loadTranscript("gateway-a", "other").map { it.content.single().text })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcriptsAreScopedToGatewayIdentity() =
|
||||
runTest {
|
||||
val store = cache()
|
||||
store.saveTranscript(gatewayId = "gateway-a", sessionKey = "main", messages = listOf(message("gateway a text")))
|
||||
store.saveSessions("gateway-a", listOf(ChatSessionEntry(key = "main", updatedAtMs = 1)))
|
||||
|
||||
assertEquals(emptyList<ChatMessage>(), store.loadTranscript("gateway-b", "main"))
|
||||
assertEquals(emptyList<ChatSessionEntry>(), store.loadSessions("gateway-b"))
|
||||
store.saveTranscript(gatewayId = "gateway-b", sessionKey = "main", messages = listOf(message("gateway b text")))
|
||||
|
||||
assertEquals(listOf("gateway a text"), store.loadTranscript("gateway-a", "main").map { it.content.single().text })
|
||||
assertEquals(listOf("main"), store.loadSessions("gateway-a").map { it.key })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearAllPurgesEveryGatewayScope() =
|
||||
runTest {
|
||||
val store = cache()
|
||||
store.saveSessions("gateway-a", listOf(ChatSessionEntry(key = "main", updatedAtMs = 1)))
|
||||
store.saveTranscript(gatewayId = "gateway-a", sessionKey = "main", messages = listOf(message("a text")))
|
||||
store.saveTranscript(gatewayId = "gateway-b", sessionKey = "main", messages = listOf(message("b text")))
|
||||
|
||||
store.clearAll()
|
||||
|
||||
assertEquals(emptyList<ChatMessage>(), store.loadTranscript("gateway-b", "main"))
|
||||
assertEquals(emptyList<ChatSessionEntry>(), store.loadSessions("gateway-b"))
|
||||
assertEquals(emptyList<ChatMessage>(), store.loadTranscript("gateway-a", "main"))
|
||||
assertEquals(emptyList<ChatSessionEntry>(), store.loadSessions("gateway-a"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankGatewayIdentityDisablesReadsAndWrites() =
|
||||
runTest {
|
||||
val store = cache()
|
||||
store.saveTranscript(gatewayId = "", sessionKey = "main", messages = listOf(message("must not persist")))
|
||||
store.saveSessions("", listOf(ChatSessionEntry(key = "main", updatedAtMs = 1)))
|
||||
|
||||
assertEquals(emptyList<ChatMessage>(), store.loadTranscript("", "main"))
|
||||
assertEquals(emptyList<ChatSessionEntry>(), store.loadSessions(""))
|
||||
|
||||
// Nothing was written under a fallback scope either.
|
||||
assertEquals(emptyList<ChatMessage>(), store.loadTranscript("gateway-a", "main"))
|
||||
assertEquals(emptyList<ChatSessionEntry>(), store.loadSessions("gateway-a"))
|
||||
}
|
||||
}
|
||||
@@ -18,12 +18,14 @@ dnsjava = "3.6.5"
|
||||
junit = "4.13.2"
|
||||
junit-vintage = "6.1.0"
|
||||
kotest = "6.1.11"
|
||||
ksp = "2.3.9"
|
||||
ktlint-gradle = "14.2.0"
|
||||
kotlin = "2.4.0"
|
||||
material = "1.14.0"
|
||||
okhttp = "5.3.2"
|
||||
barcode-scanning = "17.3.0"
|
||||
robolectric = "4.16.1"
|
||||
room = "2.8.4"
|
||||
serialization-json = "1.11.0"
|
||||
|
||||
[libraries]
|
||||
@@ -43,6 +45,8 @@ androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling
|
||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
|
||||
androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "androidx-exifinterface" }
|
||||
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" }
|
||||
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
|
||||
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
||||
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "androidx-security" }
|
||||
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext" }
|
||||
androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-uiautomator" }
|
||||
@@ -72,4 +76,5 @@ android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
android-test = { id = "com.android.test", version.ref = "agp" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" }
|
||||
|
||||
Reference in New Issue
Block a user