Compare commits

..
Author SHA1 Message Date
Bailey Dixon 578c074797 fix(android): surface assistant capture recovery 2026-08-29 13:37:35 -04:00
22 changed files with 400 additions and 572 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Fixed
- **The visible Android Sphere keeps its smooth procedural motion across startup and chat.** Backgrounded and motion-disabled surfaces remain still without reducing foreground animation to a stepped ambient pulse.
- **Android New Chat keeps the current profile and stays fresh across profile switches.** Starting from All Profiles no longer forces the literal default profile, choosing another profile from an empty draft no longer reopens that profile's previous session after route settlement or restart, and leaving a provisional phone Thread cannot route the next turn to its old chat under the new profile.
- **Android Assistant sessions explain when no speech was captured instead of appearing stuck at Ready.** Retry feedback survives the separate system overlay process, recreated session UI requests the current turn state, and locked sessions keep transcript, response, and technical error text private.
### Removed
+6 -1
View File
@@ -1300,7 +1300,12 @@ and whether the agent is waiting on the user.
permissions; exercise compact, expanded, collapsed, and full-Voice handoff
states, background tap-through, rotation and insets, cancel/back, microphone
denial, network failure, process kill/recreation, and wake→voice→wake
resumption. Measure idle battery drain because third-party assistants do not
resumption. For background and keyguard capture, record `AudioRecord`, AppOps,
and foreground-service state: the user-installed app owns capture outside the
separate session process, so confirm whether the selected Assistant role is
sufficient on each target OS or whether activation needs an explicit,
activation-scoped microphone foreground-service lease. Measure idle battery
drain because third-party assistants do not
receive Google's dedicated low-power hotword hardware.
- **Audio quality guardrails** — normalize output volume across realtime and
@@ -39,14 +39,38 @@ enum class AssistantSessionPhase {
Closed,
}
enum class AssistantSessionNotice {
NoSpeech,
}
data class AssistantSessionSnapshot(
val phase: AssistantSessionPhase = AssistantSessionPhase.Launching,
val transcript: String? = null,
val response: String = "",
val notice: AssistantSessionNotice? = null,
val error: String? = null,
val screenContextSupported: Boolean = false,
)
internal fun assistantSnapshotForPresentation(
snapshot: AssistantSessionSnapshot,
locked: Boolean,
): AssistantSessionSnapshot = if (locked) {
snapshot.copy(
transcript = null,
response = "",
error = null,
screenContextSupported = false,
)
} else {
snapshot
}
internal fun assistantSnapshotMatchesActivation(
expectedActivationId: String?,
receivedActivationId: String?,
): Boolean = expectedActivationId != null && expectedActivationId == receivedActivationId
object AssistantRole {
fun status(context: Context): AssistantRoleStatus {
val component = ComponentName(context, HermesVoiceInteractionService::class.java)
@@ -209,6 +233,7 @@ object AssistantSessionProtocol {
onFailure = { failure ->
publish(
application,
activation.id,
AssistantSessionSnapshot(
phase = AssistantSessionPhase.Error,
error = failure.message ?: "Hermes voice could not start",
@@ -219,13 +244,19 @@ object AssistantSessionProtocol {
return true
}
fun publish(context: Context, snapshot: AssistantSessionSnapshot) {
fun publish(
context: Context,
activationId: String,
snapshot: AssistantSessionSnapshot,
) {
context.sendBroadcast(
Intent(context, AssistantSessionStateReceiver::class.java).apply {
action = ACTION_STATUS
putExtra(EXTRA_ACTIVATION_ID, activationId)
putExtra(EXTRA_PHASE, snapshot.phase.name)
putExtra(EXTRA_TRANSCRIPT, snapshot.transcript)
putExtra(EXTRA_RESPONSE, snapshot.response)
putExtra(EXTRA_NOTICE, snapshot.notice?.name)
putExtra(EXTRA_ERROR, snapshot.error)
putExtra(EXTRA_SCREEN_CONTEXT_SUPPORTED, snapshot.screenContextSupported)
}
@@ -238,10 +269,6 @@ object AssistantSessionProtocol {
}
}
fun publish(context: Context, state: VoiceUiState) {
publish(context, snapshotFromVoiceState(state))
}
internal fun snapshotFromVoiceState(state: VoiceUiState): AssistantSessionSnapshot {
val phase = when {
!state.voiceMode -> AssistantSessionPhase.Closed
@@ -256,7 +283,10 @@ object AssistantSessionProtocol {
phase = phase,
transcript = state.transcribedText?.take(MAX_SESSION_TEXT_CHARS),
response = state.responseText.take(MAX_SESSION_TEXT_CHARS),
error = state.error?.take(MAX_SESSION_ERROR_CHARS),
notice = state.assistantNotice,
error = state.error
?.takeIf { phase == AssistantSessionPhase.Error }
?.take(MAX_SESSION_ERROR_CHARS),
)
}
@@ -350,6 +380,9 @@ object AssistantSessionProtocol {
phase = phase,
transcript = intent.getStringExtra(EXTRA_TRANSCRIPT),
response = intent.getStringExtra(EXTRA_RESPONSE).orEmpty(),
notice = intent.getStringExtra(EXTRA_NOTICE)?.let { raw ->
runCatching { AssistantSessionNotice.valueOf(raw) }.getOrNull()
},
error = intent.getStringExtra(EXTRA_ERROR),
screenContextSupported = intent.getBooleanExtra(
EXTRA_SCREEN_CONTEXT_SUPPORTED,
@@ -360,24 +393,33 @@ object AssistantSessionProtocol {
private const val MAX_SESSION_TEXT_CHARS = 4_000
private const val MAX_SESSION_ERROR_CHARS = 1_000
private const val EXTRA_NOTICE = "notice"
}
object AssistantSessionState {
private val _snapshot = MutableStateFlow(AssistantSessionSnapshot())
val snapshot: StateFlow<AssistantSessionSnapshot> = _snapshot.asStateFlow()
@Volatile private var activationId: String? = null
internal fun update(snapshot: AssistantSessionSnapshot) {
internal fun update(receivedActivationId: String?, snapshot: AssistantSessionSnapshot) {
if (!assistantSnapshotMatchesActivation(activationId, receivedActivationId)) return
_snapshot.value = snapshot
}
internal fun reset() {
internal fun reset(activationId: String) {
this.activationId = activationId
_snapshot.value = AssistantSessionSnapshot()
}
}
class AssistantSessionStateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
AssistantSessionState.update(AssistantSessionProtocol.readSnapshot(intent))
AssistantSessionState.update(
receivedActivationId = intent.getStringExtra(
AssistantSessionProtocol.EXTRA_ACTIVATION_ID
),
snapshot = AssistantSessionProtocol.readSnapshot(intent),
)
}
}
@@ -423,6 +465,7 @@ class AssistantSessionLifecycleReceiver : BroadcastReceiver() {
onFailure = { failure ->
AssistantSessionProtocol.publish(
application,
id,
AssistantSessionSnapshot(
phase = AssistantSessionPhase.Error,
error = failure.message ?: "Hermes voice could not start",
@@ -430,6 +473,7 @@ class AssistantSessionLifecycleReceiver : BroadcastReceiver() {
)
},
)
application.runtime.republishAssistantSnapshot(id)
return
}
if (AssistantSessionProtocol.isStartAction(intent.action)) {
@@ -3,6 +3,11 @@ package com.hermesandroid.relay.assistant
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.drawable.ColorDrawable
import android.app.KeyguardManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import android.service.voice.VoiceInteractionSession
import android.service.voice.VoiceInteractionSessionService
@@ -67,6 +72,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
@@ -108,6 +114,11 @@ internal fun shouldCancelVoiceWhenSessionUiEnds(
presentation: AssistantSessionPresentation,
): Boolean = presentation == AssistantSessionPresentation.Overlay
internal fun assistantPresentationLocked(
currentKeyguardLocked: Boolean?,
fallbackLocked: Boolean,
): Boolean = currentKeyguardLocked ?: fallbackLocked
private class HermesVoiceInteractionSession(
private val service: HermesVoiceInteractionSessionService,
) : VoiceInteractionSession(service) {
@@ -118,12 +129,19 @@ private class HermesVoiceInteractionSession(
private var surfaceExpanded by mutableStateOf(false)
private var activationId: String? = null
private var manualMic = false
private var keyguardLocked by mutableStateOf(false)
private var expectScreenContext: Boolean? = null
private var pendingSemantic = AssistantSemanticContext()
private var pendingScreenshot: ByteArray? = null
private var screenContextUi by mutableStateOf(AssistantScreenContextUi())
private val contextStore = assistantContextStore(service)
private var heartbeatJob: Job? = null
private var keyguardReceiverRegistered = false
private val keyguardReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
refreshKeyguardState()
}
}
init {
scope.launch {
@@ -139,6 +157,17 @@ private class HermesVoiceInteractionSession(
override fun onCreate() {
super.onCreate()
ContextCompat.registerReceiver(
service,
keyguardReceiver,
IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_OFF)
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_USER_PRESENT)
},
ContextCompat.RECEIVER_NOT_EXPORTED,
)
keyguardReceiverRegistered = true
window.window?.apply {
setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT))
clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
@@ -155,6 +184,7 @@ private class HermesVoiceInteractionSession(
PersistedHermesRelayTheme {
AssistantSessionSurface(
expanded = surfaceExpanded,
locked = keyguardLocked,
screenContext = screenContextUi,
onExpandedChange = { surfaceExpanded = it },
onCancel = { finishSession(cancelVoice = true) },
@@ -183,11 +213,22 @@ private class HermesVoiceInteractionSession(
override fun onShow(args: Bundle?, showFlags: Int) {
super.onShow(args, showFlags)
if (args?.getBoolean(HermesVoiceInteractionService.EXTRA_FROM_KEYGUARD, false) == true) {
refreshKeyguardState(
fallbackLocked = args?.getBoolean(
HermesVoiceInteractionService.EXTRA_FROM_KEYGUARD,
false,
) == true,
)
if (keyguardLocked) {
window.window?.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
} else {
window.window?.clearFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
}
setUiEnabled(true)
val startsNewLifecycle = presentation == AssistantSessionPresentation.Inactive
@@ -195,10 +236,10 @@ private class HermesVoiceInteractionSession(
if (!startsNewLifecycle) return
surfaceExpanded = false
AssistantSessionState.reset()
screenContextUi = AssistantScreenContextUi()
activationId = args?.getString(AssistantSessionProtocol.EXTRA_ACTIVATION_ID)
?: UUID.randomUUID().toString()
AssistantSessionState.reset(activationId!!)
manualMic = args?.getBoolean(AssistantSessionProtocol.EXTRA_MANUAL_MIC, false) ?: false
expectScreenContext = args?.getBoolean(
AssistantSessionProtocol.EXTRA_EXPECT_SCREEN_CONTEXT,
@@ -300,6 +341,10 @@ private class HermesVoiceInteractionSession(
pendingSemantic = AssistantSemanticContext()
pendingScreenshot = null
screenContextUi = AssistantScreenContextUi()
if (keyguardReceiverRegistered) {
runCatching { service.unregisterReceiver(keyguardReceiver) }
keyguardReceiverRegistered = false
}
viewOwner.stop()
scope.cancel()
super.onDestroy()
@@ -319,6 +364,7 @@ private class HermesVoiceInteractionSession(
)
}.onFailure {
AssistantSessionState.update(
activationId,
AssistantSessionSnapshot(
phase = AssistantSessionPhase.Error,
error = it.message ?: "Hermes could not open the voice session.",
@@ -337,6 +383,7 @@ private class HermesVoiceInteractionSession(
setUiEnabled(false)
}.onFailure {
AssistantSessionState.update(
activationId,
AssistantSessionSnapshot(
phase = AssistantSessionPhase.Error,
error = it.message ?: "Hermes could not open full voice.",
@@ -377,6 +424,14 @@ private class HermesVoiceInteractionSession(
}
}
private fun refreshKeyguardState(fallbackLocked: Boolean = keyguardLocked) {
keyguardLocked = assistantPresentationLocked(
currentKeyguardLocked = service.getSystemService(KeyguardManager::class.java)
?.isKeyguardLocked,
fallbackLocked = fallbackLocked,
)
}
@RequiresApi(android.os.Build.VERSION_CODES.Q)
private fun stageAssistState(state: AssistState) {
stageAssistData(state.assistStructure, state.assistContent)
@@ -463,6 +518,7 @@ private class AssistantSessionViewOwner :
@Composable
private fun AssistantSessionSurface(
expanded: Boolean,
locked: Boolean,
screenContext: AssistantScreenContextUi,
onExpandedChange: (Boolean) -> Unit,
onCancel: () -> Unit,
@@ -471,7 +527,8 @@ private fun AssistantSessionSurface(
onOpenFullVoice: () -> Unit,
onSurfaceBoundsChanged: (android.graphics.Rect) -> Unit,
) {
val snapshot by AssistantSessionState.snapshot.collectAsState()
val rawSnapshot by AssistantSessionState.snapshot.collectAsState()
val snapshot = assistantSnapshotForPresentation(rawSnapshot, locked)
val status = assistantStatus(snapshot.phase)
val transmittedScreenContext = if (snapshot.screenContextSupported) {
screenContext
@@ -650,6 +707,13 @@ private fun ExpandedAssistantSurface(
color = MaterialTheme.colorScheme.onSurface,
)
}
snapshot.notice?.let { notice ->
Text(
text = assistantNoticeText(notice),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
snapshot.error?.let { error ->
Text(
text = error,
@@ -896,5 +960,11 @@ private fun assistantStatus(phase: AssistantSessionPhase): String = when (phase)
private fun compactAssistantText(snapshot: AssistantSessionSnapshot): String =
snapshot.transcript?.takeIf { it.isNotBlank() }
?: snapshot.response.takeIf { it.isNotBlank() }
?: snapshot.notice?.let { assistantNoticeText(it) }
?: snapshot.error?.takeIf { it.isNotBlank() }
?: assistantStatus(snapshot.phase)
@Composable
private fun assistantNoticeText(notice: AssistantSessionNotice): String = when (notice) {
AssistantSessionNotice.NoSpeech -> stringResource(R.string.voice_no_speech_try_again)
}
@@ -240,6 +240,25 @@ class HermesProcessRuntime internal constructor(
}
}
fun republishAssistantSnapshot(activationId: String) {
val snapshot = synchronized(activationLock) {
if (currentActivationId != activationId ||
_initializationState.value != HermesRuntimeInitializationState.Ready
) {
null
} else {
binder.assistantSnapshot.value
}
} ?: return
if (snapshot.phase != com.hermesandroid.relay.assistant.AssistantSessionPhase.Closed) {
com.hermesandroid.relay.assistant.AssistantSessionProtocol.publish(
application,
activationId,
snapshot,
)
}
}
fun recordAssistantHeartbeat(
activationId: String,
nowElapsedMs: Long = SystemClock.elapsedRealtime(),
@@ -196,7 +196,6 @@ internal class HermesRuntimeBinder(
chat.profileSessionPinner = connection::setSessionPinned
chat.profileSessionArchiver = connection::setSessionArchived
chat.onSessionChanged = connection::saveLastSessionId
chat.onFreshDraftSelected = connection::saveFreshDraft
chat.setDemoModeWiring(
isDemo = { connection.isDemoMode.value },
handler = { connection.chatHandler },
@@ -377,7 +376,9 @@ internal class HermesRuntimeBinder(
if (!AssistantAppSessionState.active.value) return@collect
if (state.voiceMode) AssistantAppSessionState.markVoiceStarted()
if (state.voiceMode || AssistantAppSessionState.hasVoiceStarted()) {
AssistantSessionProtocol.publish(application, snapshot)
state.assistantActivationId?.let { activationId ->
AssistantSessionProtocol.publish(application, activationId, snapshot)
}
}
}
}
@@ -215,6 +215,7 @@ fun SessionDrawerContent(
/** Opens the separate Bot Mode messenger workspace; never changes drawer filters. */
onOpenBotMode: (() -> Unit)? = null,
onNewChat: () -> Unit,
onNewDefaultChat: (() -> Unit)? = null,
onSelectSession: (String) -> Unit,
onDeleteSession: (String) -> Unit,
onRenameSession: (String, String) -> Unit,
@@ -534,7 +535,13 @@ fun SessionDrawerContent(
// New Chat button
Button(
onClick = onNewChat,
onClick = {
if (showAllProfiles) {
onNewDefaultChat?.invoke() ?: onNewChat()
} else {
onNewChat()
}
},
modifier = Modifier.fillMaxWidth(),
enabled = newChatEnabled,
) {
@@ -2385,15 +2385,8 @@ fun ChatScreen(
}
val selectProfileFromShelf: (com.hermesandroid.relay.data.Profile?) -> Unit = { profile ->
if (AgentDisplay.profileSessionKey(profile?.name) != selectedProfileKey) {
val profileName = profile?.name
chatViewModel.selectProfileFromHeader(
profileName = profileName,
profile = profile,
contextKey = AgentDisplay.profileContextKey(
connectionId = activeConnection?.id,
profileName = profileName,
),
)
connectionViewModel.selectProfile(profile)
chatViewModel.activateGatewayProfile(profile)
}
}
val hasLiveConversationSurface = messages.isNotEmpty() || isStreaming
@@ -2476,6 +2469,25 @@ fun ChatScreen(
scope.launch { drawerState.close() }
}
},
onNewDefaultChat = {
if (isProfileLocked) return@SessionDrawerContent
val defaultProfile = agentProfiles.firstOrNull {
it.name.equals("default", ignoreCase = true)
} ?: com.hermesandroid.relay.data.Profile(
name = "default",
model = "",
description = "Default",
)
val opened = chatViewModel.createProfileChat(
profileName = "default",
profile = defaultProfile,
contextKey = AgentDisplay.profileContextKey(
connectionId = activeConnection?.id,
profileName = "default",
),
)
if (opened) scope.launch { drawerState.close() }
},
onSelectSession = { sessionId ->
chatViewModel.switchSession(sessionId)
scope.launch { drawerState.close() }
@@ -37,7 +37,6 @@ import com.hermesandroid.relay.data.applyMessageReaction
import com.hermesandroid.relay.data.parseChatQuotedPrompt
import com.hermesandroid.relay.data.prepareTextTransportAttachments
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.SessionTransport
import com.hermesandroid.relay.data.ProactiveInboxEntry
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
import com.hermesandroid.relay.data.RealtimeTurnTrace
@@ -661,7 +660,6 @@ class ChatViewModel : ViewModel() {
/** Callback to persist session ID — set by RelayApp */
var onSessionChanged: ((String?) -> Unit)? = null
var onFreshDraftSelected: ((String?, SessionTransport) -> Unit)? = null
/**
* Send a user message into an agent **Thread** (a `source=phone` session)
@@ -680,7 +678,6 @@ class ChatViewModel : ViewModel() {
*/
private data class PendingThread(val chatId: String, val name: String)
private var pendingThread: PendingThread? = null
private val threadNavigationGeneration = AtomicLong(0L)
/**
* A "+ New Thread" whose first message has been sent — we're now polling for
@@ -696,17 +693,6 @@ class ChatViewModel : ViewModel() {
)
private var creatingThread: CreatingThread? = null
/**
* Provisional phone Threads are route-owned drafts, not transferable chat
* drafts. Leaving that surface retires only the pending local route; durable
* inbox/session rows and learned session-to-chat-id mappings stay intact.
*/
private fun exitProvisionalThread() {
threadNavigationGeneration.incrementAndGet()
pendingThread = null
creatingThread = null
}
/**
* `sessionId` → phone-platform `chat_id`, learned for threads this app
* created ([switchToCreatedThread]) or received a message in
@@ -4222,7 +4208,6 @@ class ChatViewModel : ViewModel() {
sessionRefreshJob?.cancel()
_isLoadingSessions.value = false
conversationBindingController.reset()
exitProvisionalThread()
relayCapabilityGeneration.incrementAndGet()
relayReasoningCapabilities.value = emptyMap()
_reasoningCapabilityRevision.value += 1L
@@ -4270,7 +4255,6 @@ class ChatViewModel : ViewModel() {
sessionId: String,
): Boolean {
if (!selectConversationProfile(profileName, profile)) return false
exitProvisionalThread()
// Detach the old live gateway session without reading launch/global
// model options: session.info for the resumed owner is authoritative.
activateGatewayProfile(profile, refreshModelOptions = false)
@@ -4280,7 +4264,6 @@ class ChatViewModel : ViewModel() {
sessionId = sessionId,
explicitProfileName = profileName,
explicitDisplayProfile = profile,
explicitBinding = true,
)
return true
}
@@ -4291,15 +4274,11 @@ class ChatViewModel : ViewModel() {
* `default` profile wins over the server's sticky active profile everywhere.
*/
fun createProfileChat(
profileName: String?,
profileName: String,
profile: Profile?,
contextKey: String,
): Boolean {
if (!selectConversationProfile(profileName, profile)) return false
// A provisional phone Thread belongs to its original connection/chat_id
// and cannot transfer to another profile. Exit it before binding or
// persisting the destination draft so the next send uses session.create.
exitProvisionalThread()
activateGatewayProfile(profile, refreshModelOptions = false)
refreshActiveAgentName(profile, relabelGenericMessages = true)
switchProfileContextInternal(
@@ -4307,49 +4286,11 @@ class ChatViewModel : ViewModel() {
sessionId = null,
explicitProfileName = profileName,
explicitDisplayProfile = profile,
explicitBinding = true,
)
// Selection has already moved persistence to the target profile, so
// clear that profile/transport's stored last-session slot as part of
// the same draft transfer. A restart must reopen the draft, not the
// target profile's previous conversation.
persistFreshDraft(profileName)
AppAnalytics.onSessionCreated()
return true
}
/**
* Atomic owner switch for the Chat header.
*
* Empty ordinary drafts and provisional phone Threads both become a fresh
* destination-profile draft, but only after provisional routing is retired.
* Durable sessions keep the established profile-selection lifecycle, whose
* binder may restore the destination profile's compatible last session.
*/
fun selectProfileFromHeader(
profileName: String?,
profile: Profile?,
contextKey: String,
): Boolean {
val handler = chatHandler ?: return false
val currentSessionId = handler.currentSessionId.value
val activeSession = handler.sessions.value.firstOrNull {
it.sessionId == currentSessionId
}
if (currentSessionId == null || activeSession?.source == "phone") {
return createProfileChat(profileName, profile, contextKey)
}
if (!selectConversationProfile(profileName, profile)) return false
exitProvisionalThread()
activateGatewayProfile(profile)
return true
}
private fun persistFreshDraft(profileName: String?) {
val transport = SessionTransport.forEndpoint(streamingEndpoint)
onFreshDraftSelected?.invoke(profileName, transport) ?: onSessionChanged?.invoke(null)
}
fun switchProfileContext(contextKey: String, sessionId: String?) {
clearOpenedSessionOwner()
switchProfileContextInternal(contextKey, sessionId)
@@ -4372,19 +4313,14 @@ class ChatViewModel : ViewModel() {
sessionId: String?,
explicitProfileName: String? = null,
explicitDisplayProfile: Profile? = null,
explicitBinding: Boolean = false,
reconciliation: Boolean = false,
) {
val handler = chatHandler ?: return
dismissChatFailure()
val previousBinding = conversationBinding.value
val isInitialContextBinding = !previousBinding.isBound
val targetProfileName = if (explicitBinding) {
explicitProfileName
} else {
sessionProfileNameProvider()
}
if (explicitBinding) {
val targetProfileName = explicitProfileName ?: sessionProfileNameProvider()
if (explicitProfileName != null) {
val accepted = conversationBindingController.openExplicit(
contextKey = contextKey,
profileName = explicitProfileName,
@@ -4742,12 +4678,9 @@ class ChatViewModel : ViewModel() {
if (supervisedModePolicy.enabled && !supervisedModePolicy.capabilities.newChat) return
val handler = chatHandler ?: return
recordPreResetEvidence(handler, "new_chat")
// A new chat clears only the durable session identity. Keep the bound
// profile/context so an All Profiles conversation becomes a fresh
// draft for that same owner instead of falling back to the globally
// restored default profile.
conversationBindingController.startFreshDraft()
exitProvisionalThread()
clearOpenedSessionOwner()
pendingThread = null
creatingThread = null
// Gateway turns continue as detached siblings; SSE remains exclusive.
releaseTurnForNavigation(handler)
@@ -4777,7 +4710,7 @@ class ChatViewModel : ViewModel() {
_fastEnabled.value = null
approvalModeRevision.incrementAndGet()
pendingYolo = null
persistFreshDraft(currentSessionProfileName())
onSessionChanged?.invoke(null)
AppAnalytics.onSessionCreated()
onReady?.invoke(null)
return
@@ -4847,7 +4780,6 @@ class ChatViewModel : ViewModel() {
*/
fun startNewThread(name: String) {
val handler = chatHandler ?: return
exitProvisionalThread()
recordPreResetEvidence(handler, "new_thread")
releaseTurnForNavigation(handler)
cancelAnswerRecovery(settleUi = false)
@@ -4884,7 +4816,6 @@ class ChatViewModel : ViewModel() {
.sortedBy { it.receivedAt }
if (ordered.isEmpty()) return
exitProvisionalThread()
recordPreResetEvidence(handler, "open_proactive_thread")
releaseTurnForNavigation(handler)
@@ -4894,6 +4825,7 @@ class ChatViewModel : ViewModel() {
chatId = normalizedChatId,
name = ordered.last().title.ifBlank { "Hermes" },
)
creatingThread = null
gatewayClient?.clearSession()
handler.setSessionId(null)
selectBackgroundProcessSession(null)
@@ -4946,20 +4878,11 @@ class ChatViewModel : ViewModel() {
*/
private fun switchToCreatedThread() {
val creating = creatingThread ?: return
val generation = threadNavigationGeneration.get()
viewModelScope.launch {
for (delayMs in longArrayOf(900L, 1300L, 1800L, 2500L, 3500L, 4500L)) {
delay(delayMs)
if (
threadNavigationGeneration.get() != generation ||
creatingThread != creating
) return@launch
refreshSessions()
delay(400L) // let the refresh job land in the sessions flow
if (
threadNavigationGeneration.get() != generation ||
creatingThread != creating
) return@launch
val match = chatHandler?.sessions?.value?.firstOrNull {
it.source == "phone" && it.sessionId !in creating.knownIds
}
@@ -4989,7 +4912,8 @@ class ChatViewModel : ViewModel() {
val handler = chatHandler ?: return
dismissChatFailure()
if (streamingEndpoint != "gateway" && apiClient == null) return
exitProvisionalThread()
pendingThread = null
creatingThread = null
// Keep a Gateway sibling alive and detach its callbacks. SSE remains a
// single exclusive stream and is interrupted on navigation.
@@ -6884,7 +6884,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
// ground truth about which transport can resume it, robust to a
// turn that fell back from gateway to SSE.
val transport = SessionTransport.forSessionId(sessionId)
profileController.markSessionPersisted(connectionId, profileName, transport)
profileController.profileSessionStore.setSessionId(
connectionId,
profileName,
@@ -6921,20 +6920,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
}
/** Persist an intentional empty draft without conflating it with transient null state. */
fun saveFreshDraft(profileName: String?, transport: SessionTransport) {
_lastSessionId.value = null
val connectionId = activeConnectionId.value ?: return
profileController.markFreshDraft(connectionId, profileName, transport)
if (profileName == null) {
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { preferences ->
preferences.remove(KEY_LAST_SESSION_ID)
}
}
}
}
// --- Shared methods ---
fun setTheme(theme: String) {
@@ -102,18 +102,6 @@ internal class ConversationBindingController {
)
}
/** A user-requested draft keeps its owner and fences persisted-session reconciliation. */
fun startFreshDraft() {
val current = _state.value
if (!current.isBound) return
if (current.sessionId == null && current.hasExplicitOwner) return
_state.value = current.copy(
sessionId = null,
origin = ConversationBindingOrigin.ExplicitSession,
revision = current.revision + 1,
)
}
fun releaseExplicitOwner() {
if (!_state.value.hasExplicitOwner) return
reset()
@@ -52,6 +52,7 @@ import com.hermesandroid.relay.voice.VoiceCommandInterpreter
import com.hermesandroid.relay.voice.SpokenInterruptionLatch
import com.hermesandroid.relay.voice.voiceInterfaceContextPrompt
import com.hermesandroid.relay.assistant.assistantContextStore
import com.hermesandroid.relay.assistant.AssistantSessionNotice
import com.hermesandroid.relay.assistant.buildAssistantVoiceTurnPayload
// === PHASE3-voice-intents: voice→bridge intent routing ===
import com.hermesandroid.relay.voice.IntentResult
@@ -126,6 +127,25 @@ internal fun voiceSubmissionRetryState(state: VoiceUiState): VoiceUiState = stat
error = null,
)
internal fun voiceNoSpeechState(state: VoiceUiState): VoiceUiState = state.copy(
state = VoiceState.Idle,
amplitude = 0f,
outputAudioActive = false,
transcribedText = null,
error = null,
assistantNotice = AssistantSessionNotice.NoSpeech,
)
internal fun voiceCaptureCancellationState(
state: VoiceUiState,
notice: AssistantSessionNotice? = null,
): VoiceUiState = state.copy(
state = VoiceState.Idle,
amplitude = 0f,
outputAudioActive = false,
assistantNotice = notice,
)
internal data class AssistantContextTurnDisposition(
val retireForLaterTurns: Boolean,
val consumeOnTransportAcceptance: Boolean,
@@ -328,6 +348,10 @@ data class VoiceUiState(
val responseText: String = "",
/** Human-readable error surfaced in the overlay. */
val error: String? = null,
/** Content-free retry status safe for the system Assistant surface. */
val assistantNotice: AssistantSessionNotice? = null,
/** Stable owner for cross-process Assistant status; null for ordinary voice. */
val assistantActivationId: String? = null,
/** Currently-selected interaction mode. */
val interactionMode: InteractionMode = InteractionMode.TapToTalk,
/**
@@ -440,6 +464,7 @@ internal fun voiceSessionExitState(state: VoiceUiState): VoiceUiState =
transcribedText = null,
responseText = "",
error = null,
assistantNotice = null,
destructiveCountdown = null,
hermesConfirmation = null,
handoffStatus = null,
@@ -1630,6 +1655,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
state = VoiceState.Idle,
outputAudioActive = false,
error = null,
assistantActivationId = activationId,
hermesConfirmation = null,
backgroundRun = if (orphanedRun != null) null else it.backgroundRun,
)
@@ -2067,6 +2093,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
state = VoiceState.Listening,
outputAudioActive = false,
error = null,
assistantNotice = null,
responseText = "",
// v0.4.1 — fresh turn, drop any stale JIT permission chip
// from the previous dispatch.
@@ -2176,7 +2203,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private fun shouldDiscardVoiceCaptureBeforeStop(durationMs: Long): Boolean =
durationMs < MIN_VOICE_CAPTURE_DURATION_MS
private fun cancelListeningWithoutProcessing(title: String, detail: String? = null) {
private fun cancelListeningWithoutProcessing(
title: String,
detail: String? = null,
notice: AssistantSessionNotice? = null,
) {
responseInterruptedForVoiceCommand = false
silenceWatchdogJob?.cancel()
silenceWatchdogJob = null
@@ -2188,9 +2219,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
title = title,
detail = detail,
)
_uiState.update {
it.copy(state = VoiceState.Idle, amplitude = 0f, outputAudioActive = false)
}
_uiState.update { voiceCaptureCancellationState(it, notice) }
}
/** Reconcile microphone state after the Activity returns to foreground. */
@@ -2327,6 +2356,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
cancelListeningWithoutProcessing(
title = getApplication<Application>().getString(R.string.voice_status_no_speech),
detail = "No speech within ${IDLE_NO_SPEECH_MS / 1000}s",
notice = AssistantSessionNotice.NoSpeech,
)
return@launch
}
@@ -6497,13 +6527,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
detail = detail,
)
_uiState.update {
it.copy(
state = VoiceState.Idle,
amplitude = 0f,
outputAudioActive = false,
error = null,
transcribedText = null,
)
voiceNoSpeechState(it)
}
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
@@ -155,14 +155,6 @@ class ProfileController(
private val avatarRefreshGeneration = AtomicLong(0L)
private val petRefreshGeneration = AtomicLong(0L)
private val petGalleryGeneration = AtomicLong(0L)
private val sessionRestoreGeneration = AtomicLong(0L)
private val freshDraftScopes = ConcurrentHashMap.newKeySet<SessionScopeKey>()
private data class SessionScopeKey(
val connectionId: String,
val profileName: String?,
val transport: SessionTransport,
)
private val petThumbnailRequests = ConcurrentHashMap.newKeySet<String>()
val agentProfiles: StateFlow<List<Profile>> = combine(
@@ -1471,48 +1463,10 @@ class ProfileController(
}
}
/**
* Persist a user-requested empty draft for one exact conversation scope.
*
* The in-memory marker fences any stored-session read that was already in
* flight, while clearing the exact transport slot makes the draft survive a
* process restart. Other profiles, connections, transports, and the server's
* actual session/history rows are untouched.
*/
fun markFreshDraft(
connectionId: String,
profileName: String?,
transport: SessionTransport,
) {
val scopeKey = SessionScopeKey(connectionId, profileName, transport)
freshDraftScopes += scopeKey
sessionRestoreGeneration.incrementAndGet()
if (
activeConnectionId.value == connectionId &&
_selectedProfile.value?.name == profileName
) {
setLastSessionId(null)
}
scope.launch {
profileSessionStore.setSessionId(connectionId, profileName, transport, null)
}
}
/** A real session supersedes the fresh-draft marker for its exact scope. */
fun markSessionPersisted(
connectionId: String,
profileName: String?,
transport: SessionTransport,
) {
freshDraftScopes -= SessionScopeKey(connectionId, profileName, transport)
sessionRestoreGeneration.incrementAndGet()
}
fun refreshLastSessionForProfile(
connectionId: String?,
profileName: String?,
) {
val generation = sessionRestoreGeneration.incrementAndGet()
setLastSessionId(null)
if (connectionId == null) return
// Defer until the active transport is known — restoring an id the
@@ -1524,8 +1478,6 @@ class ProfileController(
// `default` (or any other name), but its last-session slot must remain
// distinct from explicitly selecting that named profile.
val sessionProfileName = profileName
val scopeKey = SessionScopeKey(connectionId, sessionProfileName, transport)
if (scopeKey in freshDraftScopes) return
scope.launch {
val profileScoped = profileSessionStore
.sessionIdFlow(connectionId, sessionProfileName, transport)
@@ -1541,8 +1493,6 @@ class ProfileController(
null
}
if (
sessionRestoreGeneration.get() == generation &&
scopeKey !in freshDraftScopes &&
activeConnectionId.value == connectionId &&
_selectedProfile.value?.name == profileName &&
activeSessionTransport() == transport
@@ -65,6 +65,122 @@ class AssistantSessionProtocolTest {
assertEquals("Microphone unavailable", error.error)
}
@Test
fun idleNoSpeech_isAVisibleRetryNotice() {
val snapshot = AssistantSessionProtocol.snapshotFromVoiceState(
VoiceUiState(
voiceMode = true,
state = VoiceState.Idle,
assistantNotice = AssistantSessionNotice.NoSpeech,
)
)
assertEquals(AssistantSessionPhase.Idle, snapshot.phase)
assertEquals(AssistantSessionNotice.NoSpeech, snapshot.notice)
assertNull(snapshot.error)
}
@Test
fun lockedPresentation_redactsConversationButKeepsGenericNotice() {
val presented = assistantSnapshotForPresentation(
AssistantSessionSnapshot(
phase = AssistantSessionPhase.Error,
transcript = "private request",
response = "private response",
notice = AssistantSessionNotice.NoSpeech,
error = "private route detail",
),
locked = true,
)
assertNull(presented.transcript)
assertEquals("", presented.response)
assertNull(presented.error)
assertEquals(AssistantSessionNotice.NoSpeech, presented.notice)
}
@Test
fun unlockedPresentation_restoresConversationContent() {
val snapshot = AssistantSessionSnapshot(
phase = AssistantSessionPhase.Speaking,
transcript = "request",
response = "response",
)
assertEquals(snapshot, assistantSnapshotForPresentation(snapshot, locked = false))
}
@Test
fun liveKeyguardState_overridesLaunchFallbackInBothDirections() {
assertTrue(
assistantPresentationLocked(
currentKeyguardLocked = true,
fallbackLocked = false,
)
)
assertFalse(
assistantPresentationLocked(
currentKeyguardLocked = false,
fallbackLocked = true,
)
)
}
@Test
fun statusSnapshots_areFencedToTheCurrentActivation() {
assertTrue(assistantSnapshotMatchesActivation("activation-b", "activation-b"))
assertFalse(assistantSnapshotMatchesActivation("activation-b", "activation-a"))
assertFalse(assistantSnapshotMatchesActivation("activation-b", null))
assertFalse(assistantSnapshotMatchesActivation(null, "activation-b"))
}
@Test
fun voiceStateRetainsItsOwningActivationAcrossLaterRuntimeChanges() {
val activationA = VoiceUiState(
voiceMode = true,
state = VoiceState.Listening,
assistantActivationId = "activation-a",
)
val currentRuntimeActivation = "activation-b"
assertEquals("activation-a", activationA.assistantActivationId)
assertFalse(
assistantSnapshotMatchesActivation(
expectedActivationId = currentRuntimeActivation,
receivedActivationId = activationA.assistantActivationId,
)
)
}
@Test
fun terminalVoiceStateRetainsActivationForClosedPublication() {
val exited = com.hermesandroid.relay.viewmodel.voiceSessionExitState(
VoiceUiState(
voiceMode = true,
state = VoiceState.Speaking,
assistantActivationId = "activation-a",
)
)
assertFalse(exited.voiceMode)
assertEquals("activation-a", exited.assistantActivationId)
assertEquals(
AssistantSessionPhase.Closed,
AssistantSessionProtocol.snapshotFromVoiceState(exited).phase,
)
}
@Test
fun subsequentOrdinaryVoiceEntryClearsPreviousAssistantOwner() {
val ordinaryEntry = VoiceUiState(
voiceMode = true,
state = VoiceState.Idle,
assistantActivationId = null,
)
assertNull(ordinaryEntry.assistantActivationId)
}
@Test
fun persistedSessionMarker_expiresAfterBoundedRecoveryWindow() {
val now = 2_000_000L
@@ -17,8 +17,7 @@ import kotlinx.coroutines.sync.withLock
class ProfileSessionStoreTest {
private val dataStore = InMemoryPreferencesDataStore()
private val store = ProfileSessionStore(dataStore)
private val store = ProfileSessionStore(InMemoryPreferencesDataStore())
@Test
fun setAndGet_defaultProfileSession() = runBlocking {
@@ -86,26 +85,6 @@ class ProfileSessionStoreTest {
assertEquals("session-sse", store.sessionIdFlow("conn-1", "mizu", SSE).first())
}
@Test
fun clearedDraftSurvivesStoreRecreationAndPreservesOtherScopes() = runBlocking {
store.setSessionId("conn-1", "mizu", GATEWAY, "session-gw")
store.setSessionId("conn-1", "mizu", SSE, "session-sse")
store.setSessionId("conn-2", "mizu", GATEWAY, "session-other")
store.setSessionId("conn-1", "mizu", GATEWAY, null)
val restartedStore = ProfileSessionStore(dataStore)
assertNull(restartedStore.sessionIdFlow("conn-1", "mizu", GATEWAY).first())
assertEquals(
"session-sse",
restartedStore.sessionIdFlow("conn-1", "mizu", SSE).first(),
)
assertEquals(
"session-other",
restartedStore.sessionIdFlow("conn-2", "mizu", GATEWAY).first(),
)
}
@Test
fun clearConnectionRemovesAllProfilesAndTransportsForThatConnectionOnly() = runBlocking {
store.setSessionId("conn-1", null, GATEWAY, "session-default")
@@ -304,8 +304,9 @@ class SessionDrawerTest {
}
@Test
fun `new chat from all profiles keeps the current conversation owner`() {
fun `new chat from all profiles requests an explicit default draft`() {
var scopedNewChats = 0
var defaultNewChats = 0
compose.setContent {
MaterialTheme {
SessionDrawerContent(
@@ -318,6 +319,7 @@ class SessionDrawerTest {
onRefreshAllProfiles = {},
onSelectProfileSession = { _, _ -> },
onNewChat = { scopedNewChats++ },
onNewDefaultChat = { defaultNewChats++ },
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
@@ -330,7 +332,8 @@ class SessionDrawerTest {
compose.onNodeWithText("New Chat").performClick()
compose.runOnIdle {
assertEquals(1, scopedNewChats)
assertEquals(0, scopedNewChats)
assertEquals(1, defaultNewChats)
}
}
@@ -14,12 +14,9 @@ import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
import com.hermesandroid.relay.data.HermesCardDispatch
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.ProactiveInboxEntry
import com.hermesandroid.relay.data.SessionTransport
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.network.relay.ProactiveMessage
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
@@ -434,17 +431,8 @@ class ChatViewModelGatewayInboundTurnTest {
assertEquals(owner.name, viewModel.conversationBinding.value.profileName)
viewModel.createNewChat()
assertTrue(viewModel.conversationBinding.value.hasExplicitOwner)
assertEquals(owner.name, viewModel.conversationBinding.value.profileName)
assertNull(viewModel.conversationBinding.value.sessionId)
assertEquals(owner.name, gatewayClient.sessionProfileProvider())
viewModel.reconcileProfileContext(
AgentDisplay.profileContextKey("connection-a", owner.name),
sessionId = "x-bot-session",
)
assertNull(viewModel.conversationBinding.value.sessionId)
assertNull(handler.currentSessionId.value)
assertFalse(viewModel.conversationBinding.value.hasExplicitOwner)
assertEquals(global.name, gatewayClient.sessionProfileProvider())
}
@Test
@@ -659,258 +647,7 @@ class ChatViewModelGatewayInboundTurnTest {
assertEquals("default", gatewayClient.sessionProfileProvider())
assertEquals(null, handler.currentSessionId.value)
assertEquals("Hermes", handler.activeAgentName)
assertEquals("cleared", persistedSession)
}
@Test
fun freshDraftTransferKeepsNullableServerDefaultAndRejectsOldSessionRestore() {
val named = Profile(name = "x-bot", model = "grok-4.3", description = "X Bot")
var selected: Profile? = named
var persistedDraft: Pair<String?, SessionTransport>? = null
viewModel.setSelectedProfileProvider { selected }
viewModel.setSessionProfileNameProvider { selected?.name }
viewModel.setProfileSelectionHandler { profile ->
selected = profile
true
}
viewModel.onFreshDraftSelected = { profileName, transport ->
persistedDraft = profileName to transport
}
assertTrue(
viewModel.createProfileChat(
profileName = null,
profile = null,
contextKey = AgentDisplay.profileContextKey("connection-a", null),
),
)
assertNull(selected)
assertTrue(viewModel.conversationBinding.value.hasExplicitOwner)
assertNull(viewModel.conversationBinding.value.profileName)
assertNull(viewModel.conversationBinding.value.sessionId)
assertEquals(null to SessionTransport.GATEWAY, persistedDraft)
assertNull(gatewayClient.sessionProfileProvider())
viewModel.reconcileProfileContext(
AgentDisplay.profileContextKey("connection-a", null),
sessionId = "old-default-session",
)
assertNull(viewModel.conversationBinding.value.sessionId)
assertNull(handler.currentSessionId.value)
}
@Test
fun freshDraftTransferToNamedProfileCreatesInsteadOfResumingItsOldSession() {
val alpha = Profile(name = "alpha", model = "model-a", description = "Alpha")
val beta = Profile(name = "beta", model = "model-b", description = "Beta")
var selected: Profile? = alpha
var persistedDraft: Pair<String?, SessionTransport>? = null
viewModel.setSelectedProfileProvider { selected }
viewModel.setSessionProfileNameProvider { selected?.name }
viewModel.setProfileSelectionHandler { profile ->
selected = profile
true
}
viewModel.onFreshDraftSelected = { profileName, transport ->
persistedDraft = profileName to transport
}
viewModel.openProfileSession(
profileName = alpha.name,
profile = alpha,
contextKey = AgentDisplay.profileContextKey("connection-a", alpha.name),
sessionId = "alpha-session",
)
viewModel.createNewChat()
assertTrue(
viewModel.selectProfileFromHeader(
profileName = beta.name,
profile = beta,
contextKey = AgentDisplay.profileContextKey("connection-a", beta.name),
),
)
assertEquals(beta, selected)
assertEquals(beta.name to SessionTransport.GATEWAY, persistedDraft)
viewModel.reconcileProfileContext(
AgentDisplay.profileContextKey("connection-a", beta.name),
sessionId = "beta-old-session",
)
assertNull(handler.currentSessionId.value)
gatewayHarness.createdSessionProfileName = beta.name
val resumeCountBeforeFreshSend = gatewayHarness.rpcLog.count {
it.first == "session.resume"
}
viewModel.sendMessage("Fresh beta turn")
val create = gatewayHarness.awaitRpc("session.create")
assertEquals(beta.name, (create["profile"] as JsonPrimitive).content)
assertEquals(
resumeCountBeforeFreshSend,
gatewayHarness.rpcLog.count { it.first == "session.resume" },
)
}
@Test
fun headerProfileSwitchExitsProvisionalThreadBeforeFreshProfileSend() {
val alpha = Profile(name = "alpha", model = "model-a", description = "Alpha")
val beta = Profile(name = "beta", model = "model-b", description = "Beta")
var selected: Profile? = alpha
val proactiveChatIds = mutableListOf<String?>()
viewModel.setSelectedProfileProvider { selected }
viewModel.setSessionProfileNameProvider { selected?.name }
viewModel.setProfileSelectionHandler { profile ->
selected = profile
true
}
viewModel.onProactiveReply = { _, chatId, _, _ -> proactiveChatIds += chatId }
viewModel.openProactiveThread(
chatId = "old-phone-chat",
entries = listOf(
ProactiveInboxEntry(
id = "inbox-1",
title = "Old phone thread",
text = "Continue here",
receivedAt = 1L,
chatId = "old-phone-chat",
connectionId = "connection-a",
),
),
)
assertNull(handler.currentSessionId.value)
assertTrue(
viewModel.selectProfileFromHeader(
profileName = beta.name,
profile = beta,
contextKey = AgentDisplay.profileContextKey("connection-a", beta.name),
),
)
viewModel.sendMessage("Fresh beta turn")
val create = gatewayHarness.awaitRpc("session.create")
assertEquals(beta.name, (create["profile"] as JsonPrimitive).content)
assertTrue(proactiveChatIds.isEmpty())
assertEquals(beta.name, viewModel.conversationBinding.value.profileName)
}
@Test
fun headerProfileSwitchExitsPromotedPhoneSessionWithoutReusingItsChatId() {
val alpha = Profile(name = "alpha", model = "model-a", description = "Alpha")
val beta = Profile(name = "beta", model = "model-b", description = "Beta")
var selected: Profile? = alpha
val proactiveChatIds = mutableListOf<String?>()
viewModel.setSelectedProfileProvider { selected }
viewModel.setSessionProfileNameProvider { selected?.name }
viewModel.setProfileSelectionHandler { profile ->
selected = profile
true
}
viewModel.onProactiveReply = { _, chatId, _, _ -> proactiveChatIds += chatId }
handler.addSession(
com.hermesandroid.relay.data.ChatSession(
sessionId = "promoted-phone-session",
title = "Promoted thread",
model = null,
source = "phone",
),
)
handler.setSessionId("promoted-phone-session")
assertTrue(
viewModel.selectProfileFromHeader(
profileName = beta.name,
profile = beta,
contextKey = AgentDisplay.profileContextKey("connection-a", beta.name),
),
)
assertNull(handler.currentSessionId.value)
viewModel.sendMessage("Fresh beta after Thread")
val create = gatewayHarness.awaitRpc("session.create")
assertEquals(beta.name, (create["profile"] as JsonPrimitive).content)
assertTrue(proactiveChatIds.isEmpty())
}
@Test
fun newChatAndConnectionSwitchRetireProvisionalThreadRouting() {
val entry = ProactiveInboxEntry(
id = "inbox-1",
title = "Old phone thread",
text = "Continue here",
receivedAt = 1L,
chatId = "old-phone-chat",
connectionId = "connection-a",
)
val inbound = ProactiveMessage(
messageId = "late-1",
chatId = "old-phone-chat",
text = "Late old-thread message",
title = "Old phone thread",
surfacing = "thread",
sentAt = 2L,
)
viewModel.openProactiveThread("old-phone-chat", listOf(entry))
viewModel.createNewChat()
assertFalse(viewModel.injectThreadMessage(inbound))
val switches = MutableSharedFlow<String>(extraBufferCapacity = 1)
viewModel.observeConnectionSwitches(switches)
viewModel.openProactiveThread("old-phone-chat", listOf(entry))
switches.tryEmit("connection-b")
awaitCondition { handler.messages.value.isEmpty() }
assertFalse(viewModel.injectThreadMessage(inbound))
}
@Test
fun staleThreadPromotionCannotReplaceTransferredProfileDraft() {
val beta = Profile(name = "beta", model = "model-b", description = "Beta")
var selected: Profile? = Profile(name = "alpha", model = "model-a")
viewModel.setSelectedProfileProvider { selected }
viewModel.setSessionProfileNameProvider { selected?.name }
viewModel.setProfileSelectionHandler { profile ->
selected = profile
true
}
viewModel.onProactiveReply = { _, _, _, _ -> }
viewModel.openProactiveThread(
"old-phone-chat",
listOf(
ProactiveInboxEntry(
id = "inbox-1",
title = "Old phone thread",
text = "Continue here",
receivedAt = 1L,
chatId = "old-phone-chat",
connectionId = "connection-a",
),
),
)
viewModel.sendMessage("Promote the old Thread")
assertTrue(
viewModel.selectProfileFromHeader(
profileName = beta.name,
profile = beta,
contextKey = AgentDisplay.profileContextKey("connection-a", beta.name),
),
)
handler.addSession(
com.hermesandroid.relay.data.ChatSession(
sessionId = "late-promoted-thread",
title = "Late promoted thread",
model = null,
source = "phone",
),
)
shadowOf(Looper.getMainLooper()).idleFor(2, TimeUnit.SECONDS)
Thread.sleep(100)
assertNull(handler.currentSessionId.value)
assertEquals(beta.name, viewModel.conversationBinding.value.profileName)
assertEquals("unchanged", persistedSession)
}
@Test
@@ -58,33 +58,6 @@ class ConversationBindingControllerTest {
assertEquals("a2", controller.state.value.sessionId)
}
@Test
fun newDraftKeepsExplicitAllProfilesOwnerAndRejectsStaleRestore() {
val alpha = Profile("alpha", "model-a", "Alpha")
controller.openExplicit("c::alpha", alpha.name, "a1", alpha, null)
controller.startFreshDraft()
assertEquals("c::alpha", controller.state.value.contextKey)
assertEquals("alpha", controller.state.value.profileName)
assertNull(controller.state.value.sessionId)
assertEquals(alpha, controller.state.value.displayProfile)
assertTrue(controller.state.value.hasExplicitOwner)
assertFalse(controller.reconcileGlobal("c::alpha", "alpha", "a1"))
assertNull(controller.state.value.sessionId)
}
@Test
fun newDraftPromotesGlobalOwnerAndRejectsItsStoredSession() {
controller.forceGlobal("c::alpha", "alpha", "a1")
controller.startFreshDraft()
assertTrue(controller.state.value.hasExplicitOwner)
assertNull(controller.state.value.sessionId)
assertFalse(controller.reconcileGlobal("c::alpha", "alpha", "a1"))
}
@Test
fun profileLockRejectsOtherOwnersAndAllowsTheLockedOwner() {
val locked = AgentDisplay.profileSessionKey("beta")
@@ -1,6 +1,9 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.assistant.AssistantSessionNotice
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -20,4 +23,35 @@ class VoiceCaptureGuardTest {
fun acceptsSettledShortUtterance() {
assertFalse(shouldDiscardVoiceCapture(durationMs = 420L, pcmBytes = 12_000))
}
@Test
fun noSpeechReturnsToRetryableIdleWithDurableFeedback() {
val state = voiceNoSpeechState(
VoiceUiState(
voiceMode = true,
state = VoiceState.Transcribing,
transcribedText = "stale",
)
)
assertEquals(VoiceState.Idle, state.state)
assertEquals(AssistantSessionNotice.NoSpeech, state.assistantNotice)
assertNull(state.error)
assertNull(state.transcribedText)
assertTrue(state.voiceMode)
}
@Test
fun unrelatedCaptureCancellationDoesNotClaimNoSpeech() {
val state = voiceCaptureCancellationState(
VoiceUiState(
voiceMode = true,
state = VoiceState.Listening,
assistantNotice = AssistantSessionNotice.NoSpeech,
)
)
assertEquals(VoiceState.Idle, state.state)
assertNull(state.assistantNotice)
}
}
@@ -4,7 +4,6 @@ import android.content.Context
import com.hermesandroid.relay.auth.AuthManager
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.SessionTransport
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.DashboardProfileScope
import com.hermesandroid.relay.network.upstream.GatewayAvailability
@@ -367,42 +366,4 @@ class ProfileControllerLockTest {
controller.selectProfile(coder)
assertEquals(coder, controller.selectedProfile.value)
}
@Test
fun freshDraftFencesRestoreAndClearsOnlyItsConnectionProfileTransport() = runBlocking {
val sessions = controller.profileSessionStore
sessions.setSessionId(connectionId, mizu.name, SessionTransport.SSE, "old-sse")
sessions.setSessionId(connectionId, mizu.name, SessionTransport.GATEWAY, "old-gateway")
sessions.setSessionId("other-connection", mizu.name, SessionTransport.SSE, "other-sse")
controller.selectProfile(mizu)
awaitSelected(mizu.name)
controller.markFreshDraft(connectionId, mizu.name, SessionTransport.SSE)
withTimeout(5_000) {
sessions.sessionIdFlow(connectionId, mizu.name, SessionTransport.SSE)
.first { it == null }
}
// Simulate an older read observing the pre-clear value: the live intent
// fence still wins until a real session supersedes the draft.
sessions.setSessionId(connectionId, mizu.name, SessionTransport.SSE, "stale-sse")
controller.refreshLastSessionForProfile(connectionId, mizu.name)
assertNull(lastSessionIds.last())
assertEquals(
"old-gateway",
sessions.sessionIdFlow(connectionId, mizu.name, SessionTransport.GATEWAY).first(),
)
assertEquals(
"other-sse",
sessions.sessionIdFlow("other-connection", mizu.name, SessionTransport.SSE).first(),
)
controller.markSessionPersisted(connectionId, mizu.name, SessionTransport.SSE)
sessions.setSessionId(connectionId, mizu.name, SessionTransport.SSE, "new-sse")
controller.refreshLastSessionForProfile(connectionId, mizu.name)
withTimeout(5_000) {
while (lastSessionIds.lastOrNull() != "new-sse") Thread.sleep(10)
}
}
}
+5 -14
View File
@@ -2495,6 +2495,8 @@ boundary.
- Activation heartbeats let the main runtime clean up after assistant-process loss.
Finish and show-failure paths clear pending/watchdog state, while Full Voice
explicitly transfers ownership before the session overlay stops heartbeats.
A recreated session process requests the current activation-fenced voice
snapshot rather than treating its empty local state as authoritative.
- Connection, chat, and voice runtime ownership is application-lifetime in the
main process rather than Activity-owned. The assistant service may initialize
that graph and start a turn while no Activity exists; the app UI later binds
@@ -2508,6 +2510,9 @@ boundary.
- Background and locked-screen invocation is mediated by Android's selected
assistant UI/session rather than an ordinary background Activity launch.
- Locked assistant UI exposes only generic phase and retry status. Transcript,
response, route-specific errors, diagnostics, and screen context remain hidden
until the device is unlocked; no-speech retry copy is deliberately content-free.
- Users can leave Hermes selected for gesture/power-button invocation while
turning continuous KWS off, or remove Hermes through Android's Assistant
settings.
@@ -2836,20 +2841,6 @@ session or starts a draft, never hot-swaps a live session. Model/provider,
personality, reasoning, approval, Fast, and YOLO state reset at the ViewModel
context boundary before destination session truth can repopulate them.
New Chat retains the current concrete conversation owner even when the drawer is
browsing All Profiles. A profile choice made from that empty draft transfers an
explicit fresh-draft intent rather than restoring the destination's previous
session. Android persists and generation-fences that intent by exact
connection/profile/transport; it clears only the resumable pointer, leaving the
stored conversation, transcript, and per-owner composer drafts intact.
Phone Threads keep their connection/chat-id ownership when leaving that surface.
They are never transferred into a different profile binding: the atomic header
switch retires provisional or in-progress promotion state before creating the
destination profile draft, while durable inbox rows, promoted sessions,
notification ownership, and session-to-chat-id indexes remain untouched. A
generation fence prevents a delayed promotion from replacing the new draft.
**Consequences.** The hamburger remains exclusively the Session Drawer. Agent
Passport stays focused on inspection and configuration. The drawer may widen
its read-only browse scope to all profiles and organize that combined set by
+7 -2
View File
@@ -475,7 +475,7 @@ Bottom navigation bar with 4 tabs:
- **Upstream animated pets** — the agent sheet consumes the profile-scoped Gateway `pet.info`, `pet.gallery`, `pet.select`, and `pet.disable` contracts. Android caches the bounded PNG/WebP sprite sheet by connection, effective profile, and `spritesheetRevision`; it sends `knownRevision` on refresh and reuses the existing bounded pet renderer for the returned geometry, row taxonomy, and activity states. The active upstream pet becomes the phone companion unless the user explicitly selected a phone-local floating pet. Selection and disable write Hermes `display.pet.*` state and therefore follow the profile across current Hermes surfaces; a method-not-found response leaves older hosts on the established local pet flow.
- **Profile creation** — Manage uses `profiles.create` on current Gateways and labels authentication as shared sign-in, copied credential snapshot, or isolated/no-copy. Android serializes `mirror_credentials` and `share_auth` explicitly, reports best-effort SOUL/model/credential results without claiming full success, and never receives or logs credentials. The user may explicitly enable the authenticated Dashboard create route as an older-host fallback only for the legacy shared/default choice; explicit isolation never degrades to an ambiguous older mutation.
- **Deletion boundary** — Hermes exposes no `profiles.delete` Gateway RPC. Android continues to delete profiles only through authenticated Dashboard `DELETE /api/profiles/{name}`.
- **Profile switch lifecycle** — selecting an inactive profile never changes Hermes' sticky server default and never hot-swaps a live session. Android switches connection/profile context, restores that profile's last session only from the compatible Gateway or SSE transport slot, or opens a fresh draft. New Chat from All Profiles keeps the current conversation owner, and selecting another profile while that draft is empty transfers fresh-draft intent to the destination instead of restoring its prior session. That intent is fenced and persisted by exact connection/profile/transport while the prior session and history remain available in the drawer. Provisional and promoted phone Threads are not transferable profile drafts: a header profile switch exits their local routing state before establishing the destination draft, preserves their durable inbox/session/index data, and generation-fences any pending promotion. Gateway turns detach and reconcile in their original durable session; live SSE switching is disabled. Model/provider, personality, reasoning, approval, Fast, and YOLO state are cleared before destination session truth re-seeds them.
- **Profile switch lifecycle** — selecting an inactive profile never changes Hermes' sticky server default and never hot-swaps a live session. Android switches connection/profile context, restores that profile's last session only from the compatible Gateway or SSE transport slot, or opens a fresh draft. Gateway turns detach and reconcile in their original durable session; live SSE switching is disabled. Model/provider, personality, reasoning, approval, Fast, and YOLO state are cleared before destination session truth re-seeds them.
- **Bot Mode workspace** — the session drawer exposes one entry into a separate full-screen messenger surface; it does not add Bot or group rows to the ordinary session taxonomy. Android refreshes every saved Dashboard/Gateway with bounded concurrency, preserves last-good rows as visibly offline, and collapses duplicate routes by upstream `install_id` before assigning source-qualified handles. Every Bot carries an immutable `(connectionId, profile)` owner; labels, installation metadata, and the currently resolved URL are presentation/routing data rather than identity. All gateways and one-gateway filters never mutate the foreground connection.
- **Canonical Bot Chat** — each individual row resolves the exact hidden session titled `Bot Chat` on its owning Gateway. Lookup failure is not absence, so Android creates and materializes the lazy row with `session.title` only after an authoritative empty exact-title result. The dedicated Bot Chat destination retains that route's pooled Gateway client, loads history through the same connection/profile Dashboard, sends only through Gateway, and returns directly to Bot Mode without rebinding Standard Chat or the global connection. `/new` or `/reset` compacts the canonical conversation instead of forking it. The route pool mints a fresh WebSocket ticket per dial, includes the immutable profile in the WebSocket URL, isolates credentials by exact trusted connection origin, and tears down only the removed connection's clients.
- **Bot group projection** — Android merges the bounded `ui_meta["hermes-bots-groups"]` v3 projection across gateways by durable room identity and newest revision. Rooms and recent messages are visibly read-only; Android does not create, rename, disband, join, send, coordinate member turns, or become a second room-log authority. Binary room images are ignored at this metadata boundary.
@@ -1015,7 +1015,10 @@ utilities.
application-lifetime owner, allowing assistant activation to start cold
without constructing or foregrounding `MainActivity`; full Voice later binds
that same runtime. Cancel, error, app/process recreation, and session finish
use the same scoped protocol. The
use the same scoped protocol. Recreated session UI requests an
activation-fenced snapshot from the app runtime, and capture/no-speech exits
retain a generic retry notice instead of collapsing to an unexplained Ready
state. The
wake recorder is released before the established voice recorder opens, and
assistant listening resumes only after the session exits. This mode is
mutually exclusive with the experimental notification-based foreground
@@ -1046,6 +1049,8 @@ utilities.
Realtime Agent sessions do not claim inclusion. The mic control follows the
active voice state, close remains separate, and **Open full voice** explicitly
transfers ownership so assistant-process cleanup cannot cancel the main-app flow.
While keyguard is active, the surface keeps only generic phase and retry copy;
transcript, response, route-specific errors, and screen context remain hidden.
- Stable voice integrates with `ChatViewModel` by **observing** `messages: StateFlow`; transcribed text goes through normal `chatVm.sendMessage(text)` so voice utterances appear as regular user messages in chat history. Experimental Realtime Agent creates a mirrored chat turn and applies broker events directly so tool state, transcript text, assistant deltas, and final responses appear without leaving voice mode.
- `VoiceModeOverlay` — full-screen UI with the MorphingSphere at 60% height in `voiceMode=true`, transcribed + response text, mic button supporting Tap / Hold / Continuous interaction modes.
- The optional `SYSTEM_ALERT_WINDOW` Voice control is user-invoked from an