fix: isolate device chat defaults (#53752) (thanks @lixuankai)

* [feat]Multiple nodes session context isolated from each other

* feat(android): Multiple nodes session context isolated from each other

* feat(android): Multiple nodes session context isolated from each other

* feat(android): Multiple nodes session context isolated from each other

* fix(android): isolate device chat defaults

---------

Co-authored-by: lixuankai <lixuankai@oppo.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
lixuankai
2026-03-28 14:49:47 +08:00
committed by GitHub
parent 7db10d2103
commit f0a57fad42
7 changed files with 123 additions and 30 deletions

View File

@@ -24,7 +24,6 @@ import ai.openclaw.app.voice.TalkModeManager
import ai.openclaw.app.voice.VoiceConversationEntry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -34,7 +33,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
@@ -195,7 +193,12 @@ class NodeRuntime(
private val _pendingGatewayTrust = MutableStateFlow<GatewayTrustPrompt?>(null)
val pendingGatewayTrust: StateFlow<GatewayTrustPrompt?> = _pendingGatewayTrust.asStateFlow()
private val _mainSessionKey = MutableStateFlow("main")
private fun resolveNodeMainSessionKey(agentId: String? = gatewayDefaultAgentId): String {
val deviceId = identityStore.loadOrCreate().deviceId
return buildNodeMainSessionKey(deviceId, agentId)
}
private val _mainSessionKey = MutableStateFlow(resolveNodeMainSessionKey())
val mainSessionKey: StateFlow<String> = _mainSessionKey.asStateFlow()
private val cameraHudSeq = AtomicLong(0)
@@ -243,7 +246,7 @@ class NodeRuntime(
_serverName.value = name
_remoteAddress.value = remote
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
applyMainSessionKey(mainSessionKey)
syncMainSessionKey(resolveAgentIdFromMainSessionKey(mainSessionKey))
updateStatus()
micCapture.onGatewayConnectionChanged(true)
scope.launch {
@@ -259,9 +262,6 @@ class NodeRuntime(
_serverName.value = null
_remoteAddress.value = null
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
_mainSessionKey.value = "main"
}
chat.applyMainSessionKey(resolveMainSessionKey())
chat.onDisconnected(message)
updateStatus()
@@ -320,7 +320,9 @@ class NodeRuntime(
session = operatorSession,
json = json,
supportsChatSubscribe = false,
)
).also {
it.applyMainSessionKey(_mainSessionKey.value)
}
private val voiceReplySpeakerLazy: Lazy<TalkModeManager> = lazy {
// Reuse the existing TalkMode speech engine for native Android TTS playback
// without enabling the legacy talk capture loop.
@@ -404,13 +406,12 @@ class NodeRuntime(
)
}
private fun applyMainSessionKey(candidate: String?) {
val trimmed = normalizeMainKey(candidate) ?: return
if (isCanonicalMainSessionKey(_mainSessionKey.value)) return
if (_mainSessionKey.value == trimmed) return
_mainSessionKey.value = trimmed
talkMode.setMainSessionKey(trimmed)
chat.applyMainSessionKey(trimmed)
private fun syncMainSessionKey(agentId: String?) {
val resolvedKey = resolveNodeMainSessionKey(agentId)
if (_mainSessionKey.value == resolvedKey) return
_mainSessionKey.value = resolvedKey
talkMode.setMainSessionKey(resolvedKey)
chat.applyMainSessionKey(resolvedKey)
updateHomeCanvasState()
}
@@ -960,9 +961,7 @@ class NodeRuntime(
val config = root?.get("config").asObjectOrNull()
val ui = config?.get("ui").asObjectOrNull()
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
val sessionCfg = config?.get("session").asObjectOrNull()
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
applyMainSessionKey(mainKey)
syncMainSessionKey(gatewayDefaultAgentId)
val parsed = parseHexColorArgb(raw)
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
@@ -995,7 +994,7 @@ class NodeRuntime(
gatewayDefaultAgentId = defaultAgentId.ifEmpty { null }
gatewayAgents = agents
applyMainSessionKey(mainKey)
syncMainSessionKey(resolveAgentIdFromMainSessionKey(mainKey) ?: gatewayDefaultAgentId)
updateHomeCanvasState()
} catch (_: Throwable) {
// ignore

View File

@@ -11,3 +11,14 @@ internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
if (trimmed == "global") return true
return trimmed.startsWith("agent:")
}
internal fun resolveAgentIdFromMainSessionKey(raw: String?): String? {
val trimmed = raw?.trim().orEmpty()
if (!trimmed.startsWith("agent:")) return null
return trimmed.removePrefix("agent:").substringBefore(':').trim().ifEmpty { null }
}
internal fun buildNodeMainSessionKey(deviceId: String, agentId: String?): String {
val resolvedAgentId = agentId?.trim().orEmpty().ifEmpty { "main" }
return "agent:$resolvedAgentId:node-${deviceId.take(12)}"
}

View File

@@ -24,6 +24,7 @@ class ChatController(
private val json: Json,
private val supportsChatSubscribe: Boolean,
) {
private var appliedMainSessionKey = "main"
private val _sessionKey = MutableStateFlow("main")
val sessionKey: StateFlow<String> = _sessionKey.asStateFlow()
@@ -73,7 +74,7 @@ class ChatController(
}
fun load(sessionKey: String) {
val key = sessionKey.trim().ifEmpty { "main" }
val key = normalizeRequestedSessionKey(sessionKey)
_sessionKey.value = key
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
}
@@ -81,9 +82,15 @@ class ChatController(
fun applyMainSessionKey(mainSessionKey: String) {
val trimmed = mainSessionKey.trim()
if (trimmed.isEmpty()) return
if (_sessionKey.value == trimmed) return
if (_sessionKey.value != "main") return
_sessionKey.value = trimmed
val nextState =
applyMainSessionKey(
currentSessionKey = normalizeRequestedSessionKey(_sessionKey.value),
appliedMainSessionKey = appliedMainSessionKey,
nextMainSessionKey = trimmed,
)
appliedMainSessionKey = nextState.appliedMainSessionKey
if (_sessionKey.value == nextState.currentSessionKey) return
_sessionKey.value = nextState.currentSessionKey
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
}
@@ -102,7 +109,7 @@ class ChatController(
}
fun switchSession(sessionKey: String) {
val key = sessionKey.trim()
val key = normalizeRequestedSessionKey(sessionKey)
if (key.isEmpty()) return
if (key == _sessionKey.value) return
_sessionKey.value = key
@@ -111,6 +118,13 @@ class ChatController(
scope.launch { bootstrap(forceHealth = true, refreshSessions = false) }
}
private fun normalizeRequestedSessionKey(sessionKey: String): String {
val key = sessionKey.trim()
if (key.isEmpty()) return appliedMainSessionKey
if (key == "main" && appliedMainSessionKey != "main") return appliedMainSessionKey
return key
}
fun sendMessage(
message: String,
thinkingLevel: String,
@@ -532,6 +546,28 @@ class ChatController(
}
}
internal data class MainSessionState(
val currentSessionKey: String,
val appliedMainSessionKey: String,
)
internal fun applyMainSessionKey(
currentSessionKey: String,
appliedMainSessionKey: String,
nextMainSessionKey: String,
): MainSessionState {
if (currentSessionKey == appliedMainSessionKey) {
return MainSessionState(
currentSessionKey = nextMainSessionKey,
appliedMainSessionKey = nextMainSessionKey,
)
}
return MainSessionState(
currentSessionKey = currentSessionKey,
appliedMainSessionKey = nextMainSessionKey,
)
}
internal fun reconcileMessageIds(previous: List<ChatMessage>, incoming: List<ChatMessage>): List<ChatMessage> {
if (previous.isEmpty() || incoming.isEmpty()) return incoming

View File

@@ -61,7 +61,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val sessions by viewModel.chatSessions.collectAsState()
LaunchedEffect(mainSessionKey) {
LaunchedEffect(Unit) {
viewModel.loadChat(mainSessionKey)
}

View File

@@ -19,7 +19,6 @@ import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import androidx.core.content.ContextCompat
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.isCanonicalMainSessionKey
import java.util.Locale
import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
@@ -131,7 +130,6 @@ class TalkModeManager(
fun setMainSessionKey(sessionKey: String?) {
val trimmed = sessionKey?.trim().orEmpty()
if (trimmed.isEmpty()) return
if (isCanonicalMainSessionKey(mainSessionKey)) return
mainSessionKey = trimmed
}
@@ -911,9 +909,6 @@ class TalkModeManager(
val res = session.request("talk.config", "{}")
val root = json.parseToJsonElement(res).asObjectOrNull()
val parsed = TalkModeGatewayConfigParser.parse(root?.get("config").asObjectOrNull())
if (!isCanonicalMainSessionKey(mainSessionKey)) {
mainSessionKey = parsed.mainSessionKey
}
silenceWindowMs = parsed.silenceTimeoutMs
parsed.interruptOnSpeech?.let { interruptOnSpeech = it }
configLoaded = true

View File

@@ -0,0 +1,20 @@
package ai.openclaw.app
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class SessionKeyTest {
@Test
fun buildNodeMainSessionKeyUsesStableDeviceScopedSuffix() {
val key = buildNodeMainSessionKey(deviceId = "1234567890abcdef", agentId = "ops")
assertEquals("agent:ops:node-1234567890ab", key)
}
@Test
fun resolveAgentIdFromMainSessionKeyParsesCanonicalAgentKey() {
assertEquals("ops", resolveAgentIdFromMainSessionKey("agent:ops:main"))
assertNull(resolveAgentIdFromMainSessionKey("global"))
}
}

View File

@@ -0,0 +1,32 @@
package ai.openclaw.app.chat
import org.junit.Assert.assertEquals
import org.junit.Test
class ChatControllerSessionPolicyTest {
@Test
fun applyMainSessionKeyMovesCurrentSessionWhenStillOnDefault() {
val state =
applyMainSessionKey(
currentSessionKey = "main",
appliedMainSessionKey = "main",
nextMainSessionKey = "agent:ops:node-device",
)
assertEquals("agent:ops:node-device", state.currentSessionKey)
assertEquals("agent:ops:node-device", state.appliedMainSessionKey)
}
@Test
fun applyMainSessionKeyKeepsUserSelectedSession() {
val state =
applyMainSessionKey(
currentSessionKey = "custom",
appliedMainSessionKey = "agent:ops:node-old",
nextMainSessionKey = "agent:ops:node-new",
)
assertEquals("custom", state.currentSessionKey)
assertEquals("agent:ops:node-new", state.appliedMainSessionKey)
}
}