diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 2961d12cfd0..37bafd8283f 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -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(null) val pendingGatewayTrust: StateFlow = _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 = _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 = 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 diff --git a/apps/android/app/src/main/java/ai/openclaw/app/SessionKey.kt b/apps/android/app/src/main/java/ai/openclaw/app/SessionKey.kt index 3719ec11bb9..18680e95c37 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/SessionKey.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/SessionKey.kt @@ -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)}" +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index 190e16bb648..6b3afd991ad 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -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 = _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, incoming: List): List { if (previous.isEmpty() || incoming.isEmpty()) return incoming diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt index 5883cdd965a..491d07dd98e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt @@ -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) } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt index f5948fb09e8..3f2f1bfca55 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt @@ -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 diff --git a/apps/android/app/src/test/java/ai/openclaw/app/SessionKeyTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/SessionKeyTest.kt new file mode 100644 index 00000000000..0a186bc69cf --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/SessionKeyTest.kt @@ -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")) + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionPolicyTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionPolicyTest.kt new file mode 100644 index 00000000000..4827030867f --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionPolicyTest.kt @@ -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) + } +}