Compare commits

..
Author SHA1 Message Date
Bailey Dixon a717278e95 fix(android): guard unresolved profile assets and verify identity controls 2026-09-12 09:49:09 -04:00
Bailey Dixon 19d5237ebb chore: merge current dev into profile identity fix
# Conflicts:
#	CHANGELOG.md
2026-09-12 09:28:56 -04:00
Bailey Dixon 6fbb21f437 fix(android): resolve profile display identity and group server default 2026-09-12 09:28:33 -04:00
Bailey Dixon c7f54321ad Merge pull request #579 from Codename-11/release/android-1.16.1
release(android): android-v1.16.1
2026-09-12 09:26:59 -04:00
Bailey Dixon e6b9933d46 release(android): android-v1.16.1 2026-09-12 09:17:51 -04:00
Bailey Dixon c27ee439ef Merge pull request #578 from Codename-11/fix/android-gateway-directory-bootstrap
fix(android): release Gateway directory bootstrap
2026-09-12 08:55:33 -04:00
Bailey Dixon 68fce1b1da fix(android): release gateway directory bootstrap 2026-09-10 20:07:41 -04:00
Bailey Dixon 4547031bba Merge pull request #576 from Codename-11/docs/release-date-2026-09-10
docs(release): correct 1.16.0 and 1.11.2 dates
2026-09-10 09:52:19 -04:00
Bailey Dixon 41e9acf4c2 docs(release): correct 1.16.0 and 1.11.2 dates 2026-09-10 09:43:58 -04:00
Bailey Dixon 5372fc1fef Merge pull request #574 from Codename-11/release/android-1.16.0
release(android): android-v1.16.0
2026-09-09 22:44:43 -04:00
Bailey Dixon 6c5ecbb028 release(android): android-v1.16.0 2026-09-09 22:32:25 -04:00
Bailey Dixon bd5a1c3335 Merge pull request #573 from Codename-11/release/plugin-1.11.2
release(server): server-v1.11.2
2026-09-09 22:25:27 -04:00
Bailey Dixon 2c740c9f04 release(server): server-v1.11.2 2026-09-09 22:23:26 -04:00
Bailey Dixon 3ec89680ed Merge pull request #572 from Codename-11/fix/android-endpoint-cache-race
fix(android): serialize endpoint probe invalidation
2026-09-09 22:04:10 -04:00
Bailey Dixon 42860d38cd chore: merge queued Android fixes for endpoint verification 2026-09-09 21:49:24 -04:00
Bailey Dixon ef4b3bdb6c Merge pull request #571 from Codename-11/fix/android-gateway-cold-start
fix(android): wake gateway on cold foreground
2026-09-09 21:48:53 -04:00
Bailey Dixon 44bbb16cd3 chore: merge current dev before gateway startup integration 2026-09-09 21:35:37 -04:00
Bailey Dixon 1f5b7e68fc Merge pull request #570 from Codename-11/fix/android-basic-auth-paste
fix(android): normalize pasted dashboard credentials
2026-09-09 21:35:00 -04:00
Bailey Dixon 4bb8d6fa7b chore: merge current dev for endpoint invalidation verification 2026-09-09 21:20:42 -04:00
Bailey Dixon c956232b96 fix(android): serialize endpoint probe invalidation 2026-09-09 21:20:16 -04:00
Bailey Dixon 9814cdca55 chore: merge current dev for gateway startup verification 2026-09-09 21:17:51 -04:00
Bailey Dixon 273e3f5aff fix(android): wake gateway on cold foreground 2026-09-09 21:05:02 -04:00
64 changed files with 1230 additions and 196 deletions
+20 -1
View File
@@ -8,14 +8,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Fixed
- Android shows Hermes profile display names and groups the resolved server default under its agent identity, while preserving explicit profile selection and saved conversations.
## [Android 1.16.1] - 2026-09-12
### Fixed
- Android Dashboard-only connections start the profile-scoped session directory before Gateway readiness, so a cold launch no longer leaves both the directory and passive Gateway socket waiting on each other. (#495, #528)
## [Android 1.16.0] - 2026-09-10
### Fixed
- Android no longer crashes when a route probe finishes while a network change invalidates the endpoint cache.
- Android opens an authenticated Gateway chat on the first foreground launch instead of waiting for a background-and-resume cycle to leave the waking state. (#495, #528)
- Android Dashboard sign-in removes pasted line breaks from username and password fields, matching the browser login while preserving every other credential character. (#541)
- **Relay tool availability avoids repeated Windows loopback delays and preserves multi-PC capabilities.** Host-local Android, Desktop, and Phone paths use explicit IPv4 loopback, while Desktop checks share a bounded health snapshot that preserves per-client advertisements and fails closed when Hermes-Relay is unavailable. (#562, #563)
- Android keeps saved Dashboard sign-ins bound to their connection when switching gateways, rather than letting a stale resolver route invalidate another connection's session.
- Bot Mode no longer crashes when different connections have bots with the same profile name. Both the conversation list and Active Now strip preserve each bot's connection, and opening progress appears only on the selected bot.
- Android feedback uses themed banners and action cards instead of platform toasts and default snackbars. Dashboard errors no longer misidentify missing resources as an outdated Relay. Developer settings includes local-only message previews.
- Missing chat attachments show their error and retry in the attachment card without repeated global popups. Global action messages occupy the top message area instead of covering the composer.
- Chat distinguishes session preparation from response streaming and retains initialization errors that arrive before the session acknowledgement. Long-press the agent header to open a live session-diagnostics drawer.
- Delegated-agent activity survives parent replies and leaves compact history entries for later read-only review. The activity strip appears only while work runs; historical process views cannot stop or dismiss live work. (#447)
## [Plugin 1.11.2] - 2026-09-10
### Fixed
- **Relay tool availability avoids repeated Windows loopback delays and preserves multi-PC capabilities.** Host-local Android, Desktop, and Phone paths use explicit IPv4 loopback, while Desktop checks share a bounded health snapshot that preserves per-client advertisements and fails closed when Hermes-Relay is unavailable. (#562, #563)
- **`android_*` tools resolve bridge credentials written after host startup.** Requests retry profile-scoped env and active bridge-session credentials after a stale token is rejected, and vision navigation now shares the same current Relay transport instead of the retired standalone default.
- **`android_setup` accepts both its canonical and legacy schema keys.** `bridge_session_token` and `pairing_code` are accepted, while a missing token returns a structured error.
- **Android tool setup tests use a temporary Hermes home.** Test runs no longer write bridge settings into a developer environment.
+6 -4
View File
@@ -1,15 +1,17 @@
# Hermes-Relay Plugin v__VERSION__
**Release Date:** August 31, 2026
**Release Date:** September 10, 2026
## Summary
This patch restores native installation compatibility on affected Hermes versions and makes Relay prompt context advertise only capabilities the selected session can actually call. Standard Chat, Manage, standard voice, and ordinary inbound files remain upstream-owned.
This patch makes Android and Desktop tool availability fast and reliable when Relay is unavailable, starts late-created Android bridge sessions without restarting Hermes, and restores compatibility with both current and legacy `android_setup` arguments. Standard Chat, Manage, standard voice, and ordinary inbound files remain upstream-owned.
## Fixed
- **Native installer compatibility.** The plugin keeps its complete current manifest while avoiding the installer/runtime schema mismatch that caused `manifest_version 2` installs to fail after an apparent Hermes update.
- **Capability-gated phone context.** Phone-control and cross-platform delivery guidance now follows the selected session/profile tool catalog instead of implying unavailable `android_*` or `send_message` callables.
- **Fast, accurate tool availability.** Android and Desktop tool checks use explicit IPv4 loopback and one bounded health snapshot instead of repeated per-tool connection attempts. Multi-PC capability advertisements remain isolated, and unavailable Relay clients continue to fail closed.
- **Late Android bridge recovery.** `android_*` calls retry profile-scoped and active bridge-session credentials after a stale token is rejected, so a phone connected after Hermes startup becomes usable without restarting the host.
- **Compatible Android setup arguments.** `android_setup` accepts the canonical `bridge_session_token` and `pairing_code` fields as well as their legacy aliases, with structured errors when no usable credential is supplied.
- **Isolated setup tests.** Android tool setup tests use a temporary Hermes home instead of writing bridge settings into the operator environment.
## Install / update
+7 -20
View File
@@ -1,10 +1,10 @@
# Hermes-Relay Android v1.15.1
# Hermes-Relay Android v1.16.1
**Release Date:** September 2, 2026
**Release Date:** September 12, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.15.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
> Installing on your phone? Download `hermes-relay-1.16.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
@@ -12,29 +12,16 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This patch improves chat, media, and voice reliability. It reduces memory-heavy work, keeps attachment previews stable through rotation, and makes follow-up message behavior and voice errors easier to understand.
## Changed
- Choose Correct now or Queue next from a slim tray behind the composer. Chat settings sets the default; the tray overrides one message. Stop pauses the queue, Resume continues it, and editing or removing an item preserves its successors.
- Chat and Voice use readable centered layouts on wider screens, including landscape Voice Focus.
This patch fixes a remaining cold-start path that could leave a Dashboard-only connection waiting for Gateway readiness until the app resumed or its network route changed.
## Fixed
- Correction and delivery labels remain visible inside user-message bubbles.
- Voice errors open in a scrollable dialog with separate Retry and Dismiss actions.
- Attachment previews remain open through rotation, and video previews preserve their proportions.
- Release optimization preserves the native speech configuration required for wake-word startup.
- Standard Hermes attachments download directly to disk with bounded size checks.
- Session refresh avoids repeated request loops; history loads, Markdown, image previews, and media exports keep memory use bounded.
- Image-generation progress stays visible through gaps between interim replies and returned media.
- The first prompt waits for Gateway session readiness. Ownership refusals retain the prompt for retry and show the original server error.
- Dashboard-only connections now start the exact profile-scoped session directory before Gateway readiness, so the directory and passive Gateway socket no longer wait on each other during a cold launch.
## Install / Verify
- App version: **1.15.1** (versionCode **54**).
- App version: **1.16.1** (versionCode **56**).
- Standard Chat, sessions, profiles, Manage, voice, and ordinary media use current upstream Hermes. Speech-to-text still requires a configured provider on the host.
- Hermes-Relay Plugin **1.11.1** remains the current optional plugin release; this Android patch does not require a new plugin version.
- Paused text queues can be restored. Attachment bytes are not persisted in preferences; unrestorable attachment queues must be reviewed and sent again.
- Hermes-Relay Plugin **1.11.2** remains the matching optional release for Relay tools; this Gateway startup fix does not require it.
- Explicit Direct API/API-only connections remain supported and are not used as silent failover for Dashboard-owned chats.
- Granular Device Control and the system Voice Focus overlay remain sideload-only.
@@ -0,0 +1,100 @@
package com.hermesandroid.relay.network.shared
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.ApiEndpoint
import com.hermesandroid.relay.data.EndpointCandidate
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.InterruptedIOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
@RunWith(AndroidJUnit4::class)
class EndpointResolverConcurrencyInstrumentedTest {
@Test
fun probeCompletionRacingInvalidation_staysCrashFreeOnAndroidCollections() = runBlocking {
repeat(25) { iteration ->
val candidateCount = 8
val requestsStarted = CountDownLatch(candidateCount)
val releaseRequests = CountDownLatch(1)
val raceGate = CountDownLatch(1)
val requestSequence = AtomicInteger(0)
val client = OkHttpClient.Builder()
.addInterceptor { chain ->
if (requestSequence.incrementAndGet() <= candidateCount) {
requestsStarted.countDown()
releaseRequests.await(5, TimeUnit.SECONDS)
throw InterruptedIOException("instrumented invalidation race")
}
Response.Builder()
.request(chain.request())
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body("{}".toResponseBody())
.build()
}
.build()
val resolver = EndpointResolver(client)
val candidates = (1..candidateCount).map { index ->
EndpointCandidate(
role = "instrumented-$iteration-$index",
priority = 0,
api = ApiEndpoint(host = "127.0.0.1", port = 1, tls = false),
)
}
try {
val staleResolve = async(start = CoroutineStart.UNDISPATCHED) {
resolver.resolve(candidates, EndpointSurface.Api)
}
assertTrue(requestsStarted.await(5, TimeUnit.SECONDS))
val invalidation = async(Dispatchers.Default) {
raceGate.await(5, TimeUnit.SECONDS)
resolver.clearCache()
}
val completions = async(Dispatchers.Default) {
raceGate.await(5, TimeUnit.SECONDS)
releaseRequests.countDown()
}
raceGate.countDown()
withTimeout(2_000L) {
invalidation.await()
completions.await()
staleResolve.await()
}
assertTrue(resolver.cacheSnapshot().isEmpty())
resolver.clearCache()
val freshWinner = withTimeout(2_000L) {
resolver.resolve(listOf(candidates.first()), EndpointSurface.Api)
}
assertEquals(candidates.first(), freshWinner)
assertTrue(
resolver.probeOutcomes.value.getValue(
EndpointResolver.cacheKey(candidates.first(), EndpointSurface.Api),
).reachable,
)
} finally {
raceGate.countDown()
releaseRequests.countDown()
client.dispatcher.executorService.shutdown()
}
}
}
}
@@ -5,11 +5,12 @@ import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.Modifier
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
@@ -17,25 +18,30 @@ import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.ChatTurnAssistantCheckpoint
import com.hermesandroid.relay.data.ChatTurnCheckpoint
import com.hermesandroid.relay.data.ChatTurnCheckpointStore
import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.SessionItem
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessStrip
import com.hermesandroid.relay.ui.components.SubagentPreviewVisibility
import com.hermesandroid.relay.ui.screens.shouldOwnVisibleGateway
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -85,6 +91,8 @@ class GatewayForegroundRecoveryInstrumentedTest {
@Volatile
private var persistedHistory: List<MessageItem> = emptyList()
private val historySignInRequired = MutableStateFlow(false)
private val coldStartAdmissionEnabled = MutableStateFlow(false)
private val coldStartGatewayAvailability = MutableStateFlow(GatewayAvailability.Unknown)
@Before
fun setUp() {
@@ -119,6 +127,19 @@ class GatewayForegroundRecoveryInstrumentedTest {
val streaming by viewModel.isStreaming.collectAsStateWithLifecycle()
val children by viewModel.subagentActivities.collectAsStateWithLifecycle()
val signInRequired by historySignInRequired.collectAsStateWithLifecycle()
val admissionEnabled by coldStartAdmissionEnabled.collectAsStateWithLifecycle()
val admissionAvailability by coldStartGatewayAvailability.collectAsStateWithLifecycle()
LaunchedEffect(admissionEnabled, admissionAvailability) {
if (admissionEnabled) {
viewModel.setChatVisible(
shouldOwnVisibleGateway(
appForeground = true,
isGatewayTransport = true,
gatewayAvailability = admissionAvailability,
),
)
}
}
MaterialTheme {
Column(Modifier.testTag("contract-transcript")) {
GatewayBackgroundProcessStrip(
@@ -156,6 +177,122 @@ class GatewayForegroundRecoveryInstrumentedTest {
fixture.awaitRpc("session.resume")
}
@Test
fun authenticatedUnknownColdLaunch_opensObservationSocketWithoutLifecycleBounce() {
viewModel.setChatVisible(false)
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
gatewayScope.cancel()
val controlMethods = setOf(
"session.resume",
"session.activate",
"prompt.submit",
"session.interrupt",
)
val baseline = controlMethods.associateWith(fixture::rpcCount)
val ticketMintsBefore = fixture.requestsTo("/api/auth/ws-ticket")
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val okHttp = OkHttpClient()
gatewayClient = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
baseUrl = fixture.server.url("/").toString().trimEnd('/'),
okHttpClient = okHttp,
),
okHttpClient = okHttp,
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
scope = gatewayScope,
reconnectJitterUnit = { 0.0 },
)
viewModel.setChatTurnCheckpointStore(null)
viewModel.updateGatewayClient(gatewayClient)
coldStartGatewayAvailability.value = GatewayAvailability.Unknown
coldStartAdmissionEnabled.value = true
compose.waitUntil(5_000) {
gatewayClient.connectionState.value == GatewayConnectionState.Ready
}
serverSocket = fixture.awaitServerSocket()
assertEquals(ticketMintsBefore + 1, fixture.requestsTo("/api/auth/ws-ticket"))
controlMethods.forEach { method ->
assertEquals(
"cold observation sent $method",
baseline.getValue(method),
fixture.rpcCount(method),
)
}
}
@Test
fun dashboardOnlyColdLaunch_waitsForExactDirectoryThenOpensObservationSocket() {
viewModel.setChatVisible(false)
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
gatewayScope.cancel()
val directoryStarted = CompletableDeferred<Unit>()
val directoryResult = CompletableDeferred<Result<List<SessionItem>>>()
viewModel.setProfileSessionLister { profile ->
assertEquals(PROFILE_NAME, profile)
directoryStarted.complete(Unit)
directoryResult.await()
}
handler.setSessionId(null)
viewModel.switchProfileContext(
AgentDisplay.profileContextKey("fixture-connection", PROFILE_NAME),
STORED_SESSION_ID,
)
val controlMethods = setOf(
"session.resume",
"session.activate",
"prompt.submit",
"session.interrupt",
)
val baseline = controlMethods.associateWith(fixture::rpcCount)
val ticketMintsBefore = fixture.requestsTo("/api/auth/ws-ticket")
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val okHttp = OkHttpClient()
gatewayClient = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
baseUrl = fixture.server.url("/").toString().trimEnd('/'),
okHttpClient = okHttp,
),
okHttpClient = okHttp,
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
scope = gatewayScope,
reconnectJitterUnit = { 0.0 },
)
viewModel.setChatTurnCheckpointStore(null)
viewModel.updateGatewayClient(gatewayClient)
viewModel.setChatVisible(true)
// This is the production binder's Dashboard/profile hydration edge.
// The socket must stay passive and closed until the exact-owner REST
// directory publishes, then open without a lifecycle bounce.
viewModel.refreshSessions()
compose.waitUntil(5_000) { directoryStarted.isCompleted }
assertEquals(ticketMintsBefore, fixture.requestsTo("/api/auth/ws-ticket"))
directoryResult.complete(
Result.success(listOf(SessionItem(id = STORED_SESSION_ID, title = "Fixture session"))),
)
compose.waitUntil(5_000) {
gatewayClient.connectionState.value == GatewayConnectionState.Ready
}
serverSocket = fixture.awaitServerSocket()
assertEquals(ticketMintsBefore + 1, fixture.requestsTo("/api/auth/ws-ticket"))
controlMethods.forEach { method ->
assertEquals(
"directory-gated cold observation sent $method",
baseline.getValue(method),
fixture.rpcCount(method),
)
}
}
@After
fun tearDown() {
viewModel.updateGatewayClient(null)
@@ -1,3 +1,3 @@
v1.15.1 - Steadier chat, media, and voice
v1.16.1 - Dashboard-only cold starts recover
More reliable chats and media: fewer memory-heavy refreshes, smoother large histories, and attachment previews that survive rotation. Choose whether follow-ups correct the current response or wait in a queue. Voice errors are easier to read, image-generation progress stays visible, and wake-word startup and first-message readiness are fixed.
Dashboard-only connections now prepare the selected profile before Gateway readiness, fixing a remaining cold-start path that could stay on waking or waiting for Gateway until the app resumed or its network route changed.
+94
View File
@@ -1,6 +1,100 @@
{
"schema": 3,
"versions": [
{
"version": "1.16.1",
"title": "Dashboard-only cold starts recover",
"date": "2026-09-12",
"summary": "Dashboard-only connections can now prepare the selected profile and open Gateway chat without waiting for a background, resume, or network-route change.",
"changes": [
{
"id": "gateway-directory-bootstrap",
"kind": "fixed",
"title": "Open Gateway chat from a Dashboard-only cold start",
"summary": "The selected profile's session directory starts before Gateway readiness, so it cannot wait on the same passive socket that depends on its result.",
"highlight": true
}
],
"compatibility": [
"Standard Dashboard and Gateway chat continue to use current upstream Hermes without requiring the optional Hermes-Relay Plugin.",
"Hermes-Relay Plugin 1.11.2 remains the matching optional release for Relay tools."
],
"playNotes": "Dashboard-only connections now prepare the selected profile before Gateway readiness, fixing a remaining cold-start path that could stay on waking or waiting for Gateway until the app resumed or its network route changed.",
"sections": []
},
{
"version": "1.16.0",
"title": "Safer startup, connections, and activity",
"date": "2026-09-10",
"summary": "Gateway chat opens reliably from a cold launch, saved sign-ins stay with the correct connection, and network changes no longer race the route cache. Bot Mode and delegated-work feedback also remain stable across multiple gateways and later review.",
"changes": [
{
"id": "gateway-cold-start",
"kind": "fixed",
"title": "Open Gateway chat on the first launch",
"summary": "An authenticated Gateway wakes and opens from a cold foreground start instead of waiting for the app to background and resume.",
"highlight": true
},
{
"id": "connection-owned-signin",
"kind": "fixed",
"title": "Keep saved sign-ins with their connection",
"summary": "Switching gateways cannot reuse an outgoing resolver route to invalidate another connection's saved Dashboard session.",
"highlight": true
},
{
"id": "network-change-invalidation",
"kind": "fixed",
"title": "Recover safely when the network changes",
"summary": "Route-probe completion and endpoint-cache invalidation are serialized so Wi-Fi, mobile-data, VPN, or Tailscale changes do not trigger the reported crash.",
"highlight": true
},
{
"id": "bot-mode-connection-identity",
"kind": "fixed",
"title": "Open same-named bots from multiple gateways",
"summary": "Bot Mode and Active Now keep connection and profile identity together, avoiding duplicate list keys and opening progress on the selected bot."
},
{
"id": "delegated-activity-receipts",
"kind": "improved",
"title": "Review delegated work after it finishes",
"summary": "Compact, bounded activity receipts survive parent replies and remain available read-only, while the live strip appears only during active work.",
"highlight": true
},
{
"id": "chat-feedback-surfaces",
"kind": "improved",
"title": "Keep feedback with the surface that owns it",
"summary": "Themed banners and action cards replace platform popups, global actions stay clear of the composer, and local Developer previews make feedback states inspectable."
},
{
"id": "attachment-error-recovery",
"kind": "fixed",
"title": "Retry missing attachments in place",
"summary": "A missing attachment keeps its error and Retry action in the attachment card without producing repeated global messages."
},
{
"id": "session-preparation-diagnostics",
"kind": "improved",
"title": "See session preparation and initialization failures",
"summary": "Chat distinguishes session preparation from response streaming, retains early initialization errors, and opens session diagnostics from the agent header."
},
{
"id": "pasted-dashboard-credentials",
"kind": "fixed",
"title": "Paste Dashboard credentials without hidden line breaks",
"summary": "Username and password fields remove pasted carriage returns and line feeds while preserving every other credential character."
}
],
"compatibility": [
"Standard Chat, sessions, profiles, Manage, voice, Bot Mode, and delegated activity continue to use current upstream Hermes without requiring the optional Hermes-Relay Plugin.",
"Hermes-Relay Plugin 1.11.2 is the matching optional release for Relay tools. Existing erased or revoked Dashboard credentials still require a legitimate sign-in.",
"Granular Device Control and the system Voice Focus overlay remain sideload-only."
],
"playNotes": "Gateway chat now opens reliably on a cold launch. Saved Dashboard sign-ins stay bound to the correct connection, pasted credentials ignore accidental line breaks, and network changes no longer race the route cache. Bot Mode supports duplicate profile names across gateways, while delegated work, session setup, attachment errors, and feedback remain visible and easier to review.",
"sections": []
},
{
"version": "1.15.1",
"title": "Steadier chat, media, and voice",
+5 -18
View File
@@ -1,24 +1,11 @@
v1.15.1 - Steadier chat, media, and voice
v1.16.1 - Dashboard-only cold starts recover
Summary
* Chats use less memory, attachment previews stay in place, and voice failures are easier to recover from. Follow-up controls make it clear whether a message changes the current response or waits for the next turn.
* Dashboard-only connections can now prepare the selected profile and open Gateway chat without waiting for a background, resume, or network-route change.
Highlights
* Choose when follow-up messages are sent — A slim tray behind the composer offers Correct now or Queue next. Chat settings sets the default, and a composer choice applies to one message. Stop pauses pending work until Resume; editing or removing an item keeps the remaining queue usable.
* Keep attachment previews open through rotation — Image, video, audio, PDF, text, and file previews stay open as the screen rotates. Videos retain their original proportions.
* Keep large chats and media manageable — Automatic session refresh no longer loops. Routine history loads, chat rendering, image previews, and media exports use bounded memory instead of allocating entire large responses.
Improved
* Make better use of wider screens — Chat and Voice keep text and controls on readable centered layouts. Landscape Voice Focus separates identity controls from conversation activity.
Fixed
* Read message delivery status clearly — Correction and delivery labels use contrasting text instead of disappearing into the message bubble.
* Read and dismiss voice errors — Voice errors open in a contained dialog with scrollable details and separate Retry and Dismiss actions, without overlapping chat controls.
* Fix wake-word startup in release builds — Release optimization now preserves the native speech configuration names needed to initialize wake-word detection.
* Download attachments with less memory — Standard Hermes attachments stream into the on-disk cache while download size limits remain enforced.
* Keep image-generation progress visible — The working indicator stays visible between interim replies and the generated image, including gateways that omit tool activity events.
* Wait for new chats to be ready — The first message waits for the Gateway session to initialize. Ownership refusals keep the prompt retryable and show the server's error.
* Open Gateway chat from a Dashboard-only cold start — The selected profile's session directory starts before Gateway readiness, so it cannot wait on the same passive socket that depends on its result.
Compatibility
* Standard Chat, sessions, media, and voice continue to use upstream Hermes. Voice transcription still requires a configured speech-to-text provider on the Hermes host.
* Paused text queues can be restored. Attachment bytes are not stored in preferences; an attachment queue that cannot be restored must be reviewed and sent again.
* Standard Dashboard and Gateway chat continue to use current upstream Hermes without requiring the optional Hermes-Relay Plugin.
* Hermes-Relay Plugin 1.11.2 remains the matching optional release for Relay tools.
@@ -34,9 +34,7 @@ object AgentDisplay {
profiles: List<Profile>,
): Profile? = selectedProfile
// Display can use the root default profile's metadata without making it a
// request/session override. Verbose SOUL summaries are filtered by
// profileDisplayName below, so this is safe for headers/cards.
// Display resolution never changes selection or persistence identity.
fun effectiveDisplayProfile(
selectedProfile: Profile?,
profiles: List<Profile>,
@@ -44,36 +42,22 @@ object AgentDisplay {
): Profile? {
selectedProfile?.let { return it }
val resolvedServerDefault = profileRequestName(serverDefaultProfileName)
return resolvedServerDefault
?.let { activeName ->
profiles.firstOrNull { it.name.equals(activeName, ignoreCase = true) }
}
?: profiles.firstOrNull { it.name.equals("default", ignoreCase = true) }
// An absent roster row is not authority to substitute the root profile.
// Retain the confirmed name while its display metadata is loading.
return resolvedServerDefault?.let { activeName ->
profiles.firstOrNull { it.name == activeName }
?: Profile(name = activeName, model = "")
}
}
// The NAME goes in the name slot. Non-default profiles use their profile
// name first. The synthetic default profile uses its description only when
// that description looks like a concise human agent name ("Victor"), not a
// verbose SOUL summary.
// Match upstream Desktop: presentation-only display_name, then exact request name.
fun profileDisplayName(profile: Profile?): String? {
if (profile == null) return null
if (profile.name.equals("default", ignoreCase = true)) {
return defaultProfileDisplayName(profile)
}
return when {
profile.name.isNotBlank() -> titleCase(profile.name.trim())
profile.description.isNotBlank() -> profile.description.trim()
else -> null
}
return profile.displayName.trim().takeIf(String::isNotEmpty)
?: profile.name.trim().takeIf(String::isNotEmpty)
}
fun defaultProfileDisplayName(profile: Profile?): String? =
profile
?.description
?.trim()
?.takeIf { it.looksLikeConciseAgentName() }
?.let(::titleCase)
@Suppress("UNUSED_PARAMETER") // connectionLabel retained for source compatibility.
fun agentName(
profile: Profile?,
selectedPersonality: String,
@@ -86,7 +70,7 @@ object AgentDisplay {
// "none"/"neutral" are the upstream "cleared overlay" aliases — treat
// them like "default" for identity: fall through to the server default
// (or the base connection identity) rather than rendering the literal
// identity rather than rendering the literal
// word as an agent name.
val personalityName = if (
isClearedPersonality(selectedPersonality) &&
@@ -102,7 +86,6 @@ object AgentDisplay {
return when {
personalityName.isNotBlank() && personalityName != "default" ->
titleCase(personalityName.trim())
!connectionLabel.isNullOrBlank() -> connectionLabel.trim()
else -> "Hermes"
}
}
@@ -199,16 +182,6 @@ object AgentDisplay {
?.replace(Regex("\\s+"), " ")
?.takeIf { it.isNotEmpty() }
private fun String.looksLikeConciseAgentName(): Boolean {
if (isBlank() || length > 40 || contains('\n') || contains('\r')) {
return false
}
if (any { it == '.' || it == ':' || it == ';' }) {
return false
}
return trim().split(Regex("\\s+")).size <= 4
}
private fun titleCase(value: String): String =
value.replaceFirstChar { it.uppercase() }
}
@@ -111,6 +111,8 @@ data class Profile(
val hasAvatar: Boolean = false,
@SerialName("ui_meta")
val uiMeta: JsonObject = JsonObject(emptyMap()),
@SerialName("display_name")
val displayName: String = "",
) {
val hasIsolatedApi: Boolean
get() = !apiServerUrl.isNullOrBlank()
@@ -149,7 +149,10 @@ class EndpointResolver(
)
private val probeCache = ConcurrentHashMap<String, CacheEntry>()
private val inFlightProbes = ConcurrentHashMap<String, Deferred<Boolean>>()
// Every access is owned by [probeStateLock]. This must not be a
// concurrently-mutated collection: clearCache() takes a stable snapshot
// while completion callbacks remove finished probes.
private val inFlightProbes = mutableMapOf<String, Deferred<Boolean>>()
private val probeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val probeStateLock = Any()
private var probeGeneration = 0L
@@ -486,7 +489,13 @@ class EndpointResolver(
probe(candidate, surface, generation)
}.also { deferred ->
inFlightProbes[key] = deferred
deferred.invokeOnCompletion { inFlightProbes.remove(key, deferred) }
deferred.invokeOnCompletion {
synchronized(probeStateLock) {
// Identity-aware removal prevents an invalidated
// probe from removing its fresh replacement.
inFlightProbes.remove(key, deferred)
}
}
deferred.start()
}
}
@@ -29,6 +29,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
@@ -1506,7 +1507,7 @@ class DashboardApiClient(
val patched = buildJsonObject {
obj.forEach { (k, v) -> put(k, v) }
if (obj["name"] == null && !nameOverride.isNullOrBlank()) put("name", nameOverride)
if (obj["model"] == null) put("model", "")
if (obj["model"] == null || obj["model"] == JsonNull) put("model", "")
}
json.decodeFromJsonElement(Profile.serializer(), patched)
}.getOrNull()
@@ -1913,6 +1913,7 @@ class GatewayChatClient(
model = row.stringField("model").orEmpty(),
provider = row.stringField("provider").orEmpty(),
description = row.stringField("description").orEmpty(),
displayName = row.stringField("display_name").orEmpty(),
skillCount = (row["skill_count"] as? JsonPrimitive)?.intOrNull ?: 0,
isDefault = (row["is_default"] as? JsonPrimitive)?.booleanOrNull ?: false,
hasAvatar = (row["has_avatar"] as? JsonPrimitive)?.booleanOrNull ?: false,
@@ -5268,6 +5269,7 @@ private fun parseBotRosterEntry(row: JsonObject): BotRosterEntry? {
model = row.stringField("model").orEmpty(),
provider = row.stringField("provider").orEmpty(),
description = row.stringField("description")?.take(512).orEmpty(),
displayName = row.stringField("display_name").orEmpty(),
skillCount = (row["skill_count"] as? JsonPrimitive)?.intOrNull ?: 0,
isDefault = (row["is_default"] as? JsonPrimitive)?.booleanOrNull ?: false,
hasAvatar = (row["has_avatar"] as? JsonPrimitive)?.booleanOrNull ?: false,
@@ -351,14 +351,18 @@ internal class HermesRuntimeBinder(
connection.activeConnectionId,
connection.effectiveSessionProfileName,
connection.lastSessionId,
connection.activeEndpoint,
) { ready, connectionId, profileName, sessionId, activeEndpoint ->
// The session directory belongs to the standard Dashboard
// route. connection.activeEndpoint is the optional Relay
// socket's selected candidate and stays null on a valid
// Dashboard-only LAN connection.
connection.effectiveDashboardUrl,
) { ready, connectionId, profileName, sessionId, dashboardUrl ->
ProfileContextInputs(
ready,
connectionId,
profileName,
sessionId,
dashboardRouteResolved = activeEndpoint != null,
dashboardUrl = dashboardUrl,
)
}
combine(
@@ -374,7 +378,7 @@ internal class HermesRuntimeBinder(
)
}.collectLatest { inputs ->
profileContextReady.value = false
if (!shouldRefreshSessionDirectory(inputs.chatReady, inputs.dashboardRouteResolved)) {
if (!shouldRefreshSessionDirectory(inputs.chatReady, inputs.dashboardUrl)) {
return@collectLatest
}
if (!inputs.profileSelectionSettled) {
@@ -600,7 +604,7 @@ internal class HermesRuntimeBinder(
val connectionId: String?,
val profileName: String?,
val sessionId: String?,
val dashboardRouteResolved: Boolean,
val dashboardUrl: String,
val profileSelectionSettled: Boolean = false,
val profileLocked: Boolean = false,
val hiddenSources: Set<String> = emptySet(),
@@ -632,12 +636,13 @@ internal class HermesRuntimeBinder(
/**
* Session browsing is Dashboard HTTP state, not Gateway-socket state. API-only
* connections still use chat readiness; Dashboard connections can refresh once
* the resolver has selected a live route, after the profile-settle fence.
* their persisted/resolved Dashboard origin publishes, after the profile-settle
* fence. The optional Relay endpoint is deliberately not part of this decision.
*/
internal fun shouldRefreshSessionDirectory(
chatReady: Boolean,
dashboardRouteResolved: Boolean,
): Boolean = chatReady || dashboardRouteResolved
dashboardUrl: String,
): Boolean = chatReady || dashboardUrl.isNotBlank()
internal fun assistantCanTransmitScreenContext(engineMode: VoiceEngineMode): Boolean =
engineMode == VoiceEngineMode.HermesVoiceOutput
@@ -3625,7 +3625,7 @@ internal fun ProfileDisplayManagerDialog(
val isServerDefault = key == AgentDisplay.SERVER_DEFAULT_PROFILE_KEY
val profile = profiles.firstOrNull { it.name == key }
val label = if (isServerDefault) {
stringResource(R.string.conn_info_server_default)
stringResource(R.string.profile_follow_server_default)
} else {
profile?.let(AgentDisplay::profileDisplayName)
?: key.replaceFirstChar { it.uppercase() }
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -41,6 +42,8 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.Checkbox
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -80,9 +83,10 @@ object ProfileShelfPolicy {
profiles: List<Profile>,
presentation: ProfilePresentation,
selectedProfileName: String?,
serverDefaultProfileName: String? = null,
): List<ProfileChoice> {
val selectedKey = AgentDisplay.profileSessionKey(selectedProfileName)
return ProfilePresentationPolicy
val choices = ProfilePresentationPolicy
.visibleKeys(profiles, presentation, selectedKey)
.mapNotNull { key ->
if (key == AgentDisplay.SERVER_DEFAULT_PROFILE_KEY) {
@@ -91,6 +95,17 @@ object ProfileShelfPolicy {
profiles.firstOrNull { it.name == key }?.let { ProfileChoice(key, it) }
}
}
// Group only exact upstream identities, never matching display labels.
// Keep the selected choice's request/presentation key unchanged.
val hasDefault = choices.any { it.isServerDefault }
val hasResolved = choices.any { it.key == serverDefaultProfileName }
if (!hasDefault || !hasResolved) return choices
val omittedKey = if (selectedProfileName == serverDefaultProfileName) {
AgentDisplay.SERVER_DEFAULT_PROFILE_KEY
} else {
serverDefaultProfileName
}
return choices.filterNot { it.key == omittedKey }
}
fun canSwitch(isStreaming: Boolean, streamingEndpoint: String): Boolean =
@@ -124,8 +139,9 @@ fun ProfileShelf(
onHide: (String?) -> Unit,
modifier: Modifier = Modifier,
) {
val choices = remember(profiles, presentation, selectedProfile?.name) {
ProfileShelfPolicy.choices(profiles, presentation, selectedProfile?.name)
val serverDefaultProfile by connectionViewModel.serverDefaultDisplayProfile.collectAsState()
val choices = remember(profiles, presentation, selectedProfile?.name, serverDefaultProfile) {
ProfileShelfPolicy.choices(profiles, presentation, selectedProfile?.name, serverDefaultProfile?.name)
}
if (choices.size <= 1) return
@@ -157,7 +173,7 @@ fun ProfileShelf(
val label = if (selected) {
activeDisplayName
} else {
profileChoiceLabel(choice, resolvedProfile)
profileChoiceLabel(choice, serverDefaultProfile)
}
if (selected) {
val openPassportDescription = stringResource(R.string.profile_shelf_open_passport)
@@ -191,7 +207,7 @@ fun ProfileShelf(
ProfileChoiceAvatar(
connectionViewModel,
choice,
resolvedProfile,
serverDefaultProfile,
label,
36,
)
@@ -238,7 +254,7 @@ fun ProfileShelf(
ProfileChoiceAvatar(
connectionViewModel,
choice,
resolvedProfile,
serverDefaultProfile,
label,
36,
)
@@ -275,8 +291,8 @@ fun ProfileShelf(
actionChoice?.let { choice ->
val selected = ProfileShelfPolicy.isSelected(choice, selectedProfile?.name)
val target = choice.profile ?: resolvedProfile
val label = profileChoiceLabel(choice, resolvedProfile)
val target = choice.profile ?: serverDefaultProfile
val label = profileChoiceLabel(choice, serverDefaultProfile)
ProfileShelfActionsDialog(
label = label,
canSwitch = switchEnabled && !isProfileLocked,
@@ -319,10 +335,14 @@ fun ProfileSwitcherSheet(
onManageDisplay: () -> Unit,
onDismiss: () -> Unit,
) {
val choices = remember(profiles, presentation, selectedProfile?.name) {
ProfileShelfPolicy.choices(profiles, presentation, selectedProfile?.name)
val serverDefaultProfile by connectionViewModel.serverDefaultDisplayProfile.collectAsState()
val choices = remember(profiles, presentation, selectedProfile?.name, serverDefaultProfile) {
ProfileShelfPolicy.choices(profiles, presentation, selectedProfile?.name, serverDefaultProfile?.name)
}
ModalBottomSheet(onDismissRequest = onDismiss) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -346,8 +366,15 @@ fun ProfileSwitcherSheet(
}
choices.forEach { choice ->
val selected = ProfileShelfPolicy.isSelected(choice, selectedProfile?.name)
val label = profileChoiceLabel(choice, resolvedProfile)
val model = choice.profile?.model?.takeIf { it.isNotBlank() }
val label = profileChoiceLabel(choice, serverDefaultProfile)
val target = if (choice.isServerDefault) serverDefaultProfile else choice.profile
val defaultGroup = serverDefaultProfile != null &&
(choice.isServerDefault || choice.key == serverDefaultProfile?.name)
val detail = listOfNotNull(
stringResource(R.string.conn_info_server_default).takeIf { defaultGroup },
target?.name?.takeIf { it != label },
target?.model?.takeIf { it.isNotBlank() },
).joinToString(" · ")
ListItem(
modifier = Modifier.selectable(
selected = selected,
@@ -361,13 +388,13 @@ fun ProfileSwitcherSheet(
headlineContent = {
Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis)
},
supportingContent = if (model != null) {
{ Text(model, maxLines = 1, overflow = TextOverflow.Ellipsis) }
supportingContent = if (detail.isNotBlank()) {
{ Text(detail, maxLines = 2, overflow = TextOverflow.Ellipsis) }
} else {
null
},
leadingContent = {
ProfileChoiceAvatar(connectionViewModel, choice, resolvedProfile, label, 42)
ProfileChoiceAvatar(connectionViewModel, choice, serverDefaultProfile, label, 42)
},
trailingContent = if (selected) {
{ Icon(Icons.Filled.Check, contentDescription = null) }
@@ -375,6 +402,27 @@ fun ProfileSwitcherSheet(
null
},
)
if (defaultGroup && selected) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)
.toggleable(
value = choice.isServerDefault,
enabled = switchEnabled && !isProfileLocked,
role = Role.Checkbox,
onValueChange = {
onSelect(if (choice.isServerDefault) serverDefaultProfile else null)
onDismiss()
},
).padding(start = 64.dp, end = 24.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = choice.isServerDefault, onCheckedChange = null,
enabled = switchEnabled && !isProfileLocked)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.profile_follow_server_default),
style = MaterialTheme.typography.bodyMedium)
}
}
}
HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
TextButton(
@@ -518,7 +566,8 @@ private fun ProfileActionRow(
@Composable
private fun profileChoiceLabel(choice: ProfileChoice, resolvedProfile: Profile?): String =
if (choice.isServerDefault) {
stringResource(R.string.conn_info_server_default)
AgentDisplay.profileDisplayName(resolvedProfile)
?: stringResource(R.string.conn_info_server_default)
} else {
choice.profile?.let(AgentDisplay::profileDisplayName)
?: choice.profile?.name?.replaceFirstChar { it.uppercase() }
@@ -463,6 +463,21 @@ internal fun shouldShowRetainedHistoryDashboardSignIn(
gatewayAvailability == GatewayAvailability.SignInRequired &&
!apiReachable
/**
* A foreground Gateway-owned Chat must be allowed to open its observation
* socket before `gateway.ready` can make chatReady true. Authentication and
* protocol failures are terminal; ordinary reachability failures remain
* visible so the Gateway client's bounded retry policy can recover them.
*/
internal fun shouldOwnVisibleGateway(
appForeground: Boolean,
isGatewayTransport: Boolean,
gatewayAvailability: GatewayAvailability,
): Boolean = appForeground &&
isGatewayTransport &&
gatewayAvailability != GatewayAvailability.SignInRequired &&
gatewayAvailability != GatewayAvailability.Unsupported
internal fun shouldPresentChatFailureDuringDashboardSignIn(
failure: ChatFailureNotice,
dashboardSignInRequired: Boolean,
@@ -997,6 +1012,7 @@ fun ChatScreen(
// of available profiles itself now lives entirely inside the sheet.
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val effectiveProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
val serverDefaultDisplayProfile by connectionViewModel.serverDefaultDisplayProfile.collectAsState()
val profilePresentation by connectionViewModel.profilePresentation.collectAsState()
val isProfileLocked by connectionViewModel.isProfileLocked.collectAsState()
val lockedProfileName by connectionViewModel.lockedProfileName.collectAsState()
@@ -1212,8 +1228,12 @@ fun ChatScreen(
// the foreground. setChatVisible owns that edge; an ordinary Gateway open
// warms only the observation socket and never attaches a saved session.
val appForeground by com.hermesandroid.relay.util.AppForegroundTracker.isForeground.collectAsState()
LaunchedEffect(isGatewayTransport, appForeground, chatReady) {
val visibleGatewayOwner = appForeground && chatReady && isGatewayTransport
LaunchedEffect(isGatewayTransport, appForeground, chatGatewayAvailability) {
val visibleGatewayOwner = shouldOwnVisibleGateway(
appForeground = appForeground,
isGatewayTransport = isGatewayTransport,
gatewayAvailability = chatGatewayAvailability,
)
chatViewModel.setChatVisible(visibleGatewayOwner)
// updateGatewayClient owns the one-time catalog/reasoning bootstrap for
// a newly-ready socket. Repeating it here created a duplicate cold-open
@@ -2460,11 +2480,12 @@ fun ChatScreen(
}
}
val selectedProfileKey = AgentDisplay.profileSessionKey(selectedProfile?.name)
val profileShelfAvailable = !supervised && ProfilePresentationPolicy.shouldShowShelf(
val profileShelfAvailable = !supervised && com.hermesandroid.relay.ui.components.ProfileShelfPolicy.choices(
profiles = agentProfiles,
presentation = profilePresentation,
selectedKey = selectedProfileKey,
)
selectedProfileName = selectedProfile?.name,
serverDefaultProfileName = serverDefaultDisplayProfile?.name,
).size > 1
val profileSwitchEnabled = com.hermesandroid.relay.ui.components.ProfileShelfPolicy.canSwitch(
isStreaming = isStreaming,
streamingEndpoint = chatViewModel.streamingEndpoint,
@@ -4475,7 +4475,8 @@ internal fun summarizeObjectItem(
)
}
val title = obj.stringField("name")
val title = obj.stringField("display_name")?.takeIf(String::isNotBlank)
?: obj.stringField("name")
?: obj.stringField("id")
?: obj.stringField("title")
?: fallbackTitle
@@ -1287,7 +1287,7 @@ private fun ProfileLockDialog(
HorizontalDivider()
// Server default option.
ProfileLockOptionRow(
label = stringResource(R.string.settings_server_default),
label = stringResource(R.string.profile_follow_server_default),
secondary = stringResource(R.string.settings_use_default_profile),
selected = lockedIsServerDefault,
onSelect = { onLock(null) },
@@ -2987,9 +2987,10 @@ class ChatViewModel : ViewModel() {
_reasoningDisplay.value = null
}
}
if (changed && client != null && streamRecovery != null &&
AppForegroundTracker.isForeground.value
) {
// Visibility can arrive before the runtime binder publishes its client.
// Start the same socket-only warmup in either ordering; prewarmGateway
// retains the directory barrier and exact-checkpoint ownership rules.
if (changed && client != null && chatVisible) {
prewarmGateway()
}
if (changed && client != null) requestSessionActivityRefresh()
@@ -2357,6 +2357,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
get() = profileController.effectiveSessionProfileName
val effectiveDisplayProfile: StateFlow<Profile?>
get() = profileController.effectiveDisplayProfile
val serverDefaultDisplayProfile: StateFlow<Profile?>
get() = profileController.serverDefaultDisplayProfile
fun refreshDashboardProfiles() = profileController.refreshDashboardProfiles()
fun refreshDeferredProfileMetadata() = profileController.refreshDeferredProfileMetadata()
@@ -193,6 +193,13 @@ class ProfileController(
private val _serverDefaultProfileScope = MutableStateFlow<DashboardProfileScope?>(null)
val serverDefaultProfileScope: StateFlow<DashboardProfileScope?> =
_serverDefaultProfileScope.asStateFlow()
/** Default identity independent of the explicit selection; never a routing override. */
val serverDefaultDisplayProfile: StateFlow<Profile?> = combine(
agentProfiles,
serverDefaultProfileScope,
) { profiles, serverDefault ->
AgentDisplay.effectiveDisplayProfile(null, profiles, serverDefault?.active)
}.stateIn(scope, SharingStarted.Eagerly, null)
private val _serverDefaultProfileSettled = MutableStateFlow(false)
private fun pendingProfileNameForActiveConnection(): String? {
@@ -361,12 +368,8 @@ class ProfileController(
activeConnectionId,
selectedProfile,
serverDefaultProfileScope,
_gatewayProfiles,
) { connectionId, selected, serverDefault, gatewayProfiles ->
connectionId to (
selected?.name ?: serverDefault?.active
?: gatewayProfiles.firstOrNull(Profile::isDefault)?.name
)
) { connectionId, selected, serverDefault ->
connectionId to (selected?.name ?: serverDefault?.active)
}
/** Cached bytes fetched from Hermes; always preferred over the local fallback. */
@@ -398,12 +401,8 @@ class ProfileController(
fun profileIconFlow(profileName: String?): Flow<String?> = combine(
activeConnectionId,
serverDefaultProfileScope,
_gatewayProfiles,
) { connectionId, serverDefault, gatewayProfiles ->
connectionId to (
profileName ?: serverDefault?.active
?: gatewayProfiles.firstOrNull(Profile::isDefault)?.name
)
) { connectionId, serverDefault ->
connectionId to (profileName ?: serverDefault?.active)
}
.flatMapLatest { (connectionId, serverProfileName) ->
if (connectionId == null) return@flatMapLatest flowOf(null)
@@ -1282,7 +1281,6 @@ class ProfileController(
private fun resolveSharedAssetProfileName(): String? =
resolveSessionProfileName()
?: _gatewayProfiles.value.firstOrNull(Profile::isDefault)?.name
private companion object {
const val LOCAL_PROFILE_ICON_MAX_BYTES = 8_000_000
@@ -4439,4 +4439,5 @@
<string name="tool_progress_status_dispatched">Enviado</string>
<string name="chat_activity_history_notice">Atividade registrada. As atualizações de progresso não são mantidas; o histórico disponível dos subagentes pode ser aberto somente para leitura.</string>
<string name="chat_activity_output_unavailable">A saída não está mais disponível. Este registro preserva o estado registrado do processo.</string>
<string name="profile_follow_server_default">Seguir o perfil padrão do servidor</string>
</resources>
@@ -4520,4 +4520,5 @@
<string name="tool_progress_status_dispatched">已分派</string>
<string name="chat_activity_history_notice">已记录的活动。进度更新不会保留;可用的子代理历史记录可以以只读方式打开。</string>
<string name="chat_activity_output_unavailable">输出已不可用。此条目保留了记录的进程状态。</string>
<string name="profile_follow_server_default">跟随服务器默认配置</string>
</resources>
+1
View File
@@ -4596,4 +4596,5 @@
<string name="tool_progress_status_dispatched">Übergeben</string>
<string name="chat_activity_history_notice">Gespeicherte Aktivität. Fortschrittsmeldungen werden nicht gespeichert; der verfügbare Verlauf der Unteragenten kann schreibgeschützt geöffnet werden.</string>
<string name="chat_activity_output_unavailable">Die Ausgabe ist nicht mehr verfügbar. Dieser Eintrag enthält den gespeicherten Prozessstatus.</string>
<string name="profile_follow_server_default">Serverstandard folgen</string>
</resources>
+1
View File
@@ -4287,4 +4287,5 @@
<string name="tool_progress_status_dispatched">Enviado</string>
<string name="chat_activity_history_notice">Actividad registrada. No se conservan las actualizaciones de progreso; el historial disponible de los subagentes se puede abrir en modo de solo lectura.</string>
<string name="chat_activity_output_unavailable">La salida ya no está disponible. Esta entrada conserva el estado registrado del proceso.</string>
<string name="profile_follow_server_default">Seguir el perfil predeterminado del servidor</string>
</resources>
+1
View File
@@ -4591,4 +4591,5 @@
<string name="tool_progress_status_dispatched">ディスパッチ済み</string>
<string name="chat_activity_history_notice">記録されたアクティビティです。進捗の更新は保存されません。利用可能なサブエージェントの履歴は読み取り専用で開けます。</string>
<string name="chat_activity_output_unavailable">出力は利用できなくなりました。この項目には記録されたプロセスの状態が残っています。</string>
<string name="profile_follow_server_default">サーバーのデフォルトに従う</string>
</resources>
+1
View File
@@ -4335,4 +4335,5 @@
<string name="tool_progress_status_dispatched">Отправлено</string>
<string name="chat_activity_history_notice">Сохранённая активность. Обновления хода работы не сохраняются; доступную историю субагентов можно открыть только для чтения.</string>
<string name="chat_activity_output_unavailable">Вывод больше недоступен. Эта запись сохраняет зафиксированное состояние процесса.</string>
<string name="profile_follow_server_default">Следовать профилю сервера по умолчанию</string>
</resources>
+1
View File
@@ -4654,4 +4654,5 @@
<string name="dev_message_retry_result" translatable="false">Preview retry selected — no request sent.</string>
<string name="dev_message_clear" translatable="false">Clear previews</string>
<string name="dev_message_clear_desc" translatable="false">Dismiss only these samples; real app messages are preserved.</string>
<string name="profile_follow_server_default">Follow server default</string>
</resources>
@@ -43,13 +43,13 @@ class AgentDisplayTest {
}
@Test
fun effectiveDisplayProfile_usesDefaultProfileForDisplayOnly() {
fun effectiveDisplayProfile_doesNotAssumeRootWhenScopeIsUnknown() {
val effective = AgentDisplay.effectiveDisplayProfile(
selectedProfile = null,
profiles = listOf(mizu, defaultProfile),
)
assertEquals(defaultProfile, effective)
assertNull(effective)
}
@Test
@@ -86,7 +86,7 @@ class AgentDisplayTest {
fun agentName_usesProfileNameNotVerboseDescription() {
// The name slot shows the NAME, even when a (verbose) description exists.
assertEquals(
"Mizu",
"mizu",
AgentDisplay.agentName(
profile = mizu.copy(description = "Builds and maintains the codebase"),
selectedPersonality = "friendly",
@@ -96,7 +96,7 @@ class AgentDisplayTest {
)
assertEquals(
"Coder",
"coder",
AgentDisplay.agentName(
profile = mizu.copy(name = "coder", description = ""),
selectedPersonality = "friendly",
@@ -107,11 +107,11 @@ class AgentDisplayTest {
}
@Test
fun agentName_usesConciseDefaultDescriptionNotVerboseSummary() {
fun agentName_usesDisplayNameAndNeverInfersIdentityFromDescription() {
assertEquals(
"Victor",
AgentDisplay.agentName(
profile = defaultProfile.copy(description = "victor"),
profile = defaultProfile.copy(displayName = "Victor", description = "Summary"),
selectedPersonality = "default",
defaultPersonality = "",
connectionLabel = "Lab",
@@ -119,7 +119,7 @@ class AgentDisplayTest {
)
assertEquals(
"Lab",
"default",
AgentDisplay.agentName(
profile = defaultProfile.copy(description = "Builds and maintains the codebase."),
selectedPersonality = "default",
@@ -175,7 +175,7 @@ class AgentDisplayTest {
),
)
assertEquals(
"Lab",
"Hermes",
AgentDisplay.agentName(
profile = null,
selectedPersonality = "default",
@@ -218,7 +218,7 @@ class AgentDisplayTest {
),
)
assertEquals(
"Lab",
"Hermes",
AgentDisplay.agentName(
profile = null,
selectedPersonality = "none",
@@ -255,6 +255,18 @@ class AgentDisplayTest {
assertNull(AgentDisplay.effectiveSessionProfileName(null, null))
}
@Test
fun confirmedDefaultKeepsItsNameWhileRosterLoadsAndDoesNotBorrowRootMetadata() {
val effective = AgentDisplay.effectiveDisplayProfile(null, listOf(defaultProfile), "victor")
assertEquals("victor", effective?.name)
assertEquals("victor", AgentDisplay.profileDisplayName(effective))
assertEquals("default", AgentDisplay.profileDisplayName(defaultProfile))
val victor = Profile("victor", "", displayName = "Victor")
assertEquals("Victor", AgentDisplay.profileDisplayName(
AgentDisplay.effectiveDisplayProfile(null, listOf(defaultProfile, victor), "victor")))
assertEquals(defaultProfile, AgentDisplay.effectiveDisplayProfile(defaultProfile, listOf(victor), "victor"))
}
@Test
fun displayModelName_hidesGenericApiAlias() {
assertNull(AgentDisplay.displayModelName("hermes-agent"))
@@ -21,6 +21,22 @@ import kotlinx.coroutines.sync.withLock
* raw DataStore shape that its Context constructor resolves in production.
*/
class ProfileSelectionStoreTest {
@Test
fun restartedStoreRetainsIndependentDefaultAndNamedChoicesAcrossConnections() = runBlocking {
val data = InMemoryPreferencesDataStore()
val first = ProfileSelectionStore(data)
first.setSelectedProfile("a", AgentDisplay.SERVER_DEFAULT_PROFILE_KEY)
first.setSelectedProfile("b", "default")
first.setSelectedProfile("c", "victor")
val restarted = ProfileSelectionStore(data)
assertEquals(AgentDisplay.SERVER_DEFAULT_PROFILE_KEY, restarted.selectedProfileFlow("a").first())
assertEquals("default", restarted.selectedProfileFlow("b").first())
assertEquals("victor", restarted.selectedProfileFlow("c").first())
restarted.setSelectedProfile("a", "victor")
assertEquals("default", restarted.selectedProfileFlow("b").first())
assertEquals("victor", restarted.selectedProfileFlow("c").first())
}
private val store = ProfileSelectionStore(InMemoryPreferencesDataStore())
@@ -477,6 +477,151 @@ class EndpointResolverTest {
)
}
@Test
fun clearCache_handlesConcurrentProbeCompletions_withoutThrowingOrPublishingStaleState() = runTest {
val candidateCount = 24
val staleRequestsStarted = CountDownLatch(candidateCount)
val releaseStaleRequests = CountDownLatch(1)
val staleRequestsFinished = CountDownLatch(candidateCount)
val raceGate = CountDownLatch(1)
val requestSequence = AtomicInteger(0)
val blockingClient = fastClient.newBuilder()
.addInterceptor { chain ->
if (requestSequence.incrementAndGet() <= candidateCount) {
staleRequestsStarted.countDown()
try {
releaseStaleRequests.await(5, TimeUnit.SECONDS)
} finally {
staleRequestsFinished.countDown()
}
throw InterruptedIOException("concurrent invalidation test probe")
}
chain.proceed(chain.request())
}
.build()
val resolver = EndpointResolver(blockingClient, clock = { clockMillis.get() })
val candidates = (1..candidateCount).map { index ->
candidate("concurrent-clear-$index", priority = 0, server = reachableServer)
}
try {
val staleResolve = async(start = CoroutineStart.UNDISPATCHED) {
resolver.resolve(candidates, EndpointSurface.Api)
}
assertTrue(
"every physical probe must be active before the completion/invalidation race",
staleRequestsStarted.await(5, TimeUnit.SECONDS),
)
val invalidation = async(Dispatchers.Default) {
raceGate.await(5, TimeUnit.SECONDS)
resolver.clearCache()
}
val completions = async(Dispatchers.Default) {
raceGate.await(5, TimeUnit.SECONDS)
releaseStaleRequests.countDown()
}
raceGate.countDown()
withContext(Dispatchers.Default.limitedParallelism(1)) {
withTimeout(2_000L) {
invalidation.await()
completions.await()
staleResolve.await()
}
}
assertTrue(staleRequestsFinished.await(5, TimeUnit.SECONDS))
assertTrue(resolver.cacheSnapshot().isEmpty())
resolver.clearCache()
val freshWinner = withContext(Dispatchers.Default.limitedParallelism(1)) {
withTimeout(2_000L) {
resolver.resolve(listOf(candidates.first()), EndpointSurface.Api)
}
}
assertEquals(candidates.first(), freshWinner)
assertTrue(
"a completion racing invalidation must not overwrite the fresh generation",
resolver.probeOutcomes.value.getValue(
EndpointResolver.cacheKey(candidates.first(), EndpointSurface.Api),
).reachable,
)
} finally {
raceGate.countDown()
releaseStaleRequests.countDown()
}
}
@Test
fun invalidatedProbeCompletion_cannotRemoveFreshReplacement() = runTest {
val staleRequestStarted = CountDownLatch(1)
val releaseStaleRequest = CountDownLatch(1)
val staleRequestFinished = CountDownLatch(1)
val freshRequestStarted = CountDownLatch(1)
val releaseFreshRequest = CountDownLatch(1)
val requestSequence = AtomicInteger(0)
val blockingClient = fastClient.newBuilder()
.addInterceptor { chain ->
when (requestSequence.incrementAndGet()) {
1 -> {
staleRequestStarted.countDown()
try {
releaseStaleRequest.await(5, TimeUnit.SECONDS)
} finally {
staleRequestFinished.countDown()
}
throw InterruptedIOException("invalidated identity test probe")
}
2 -> {
freshRequestStarted.countDown()
releaseFreshRequest.await(5, TimeUnit.SECONDS)
chain.proceed(chain.request())
}
else -> chain.proceed(chain.request())
}
}
.build()
val resolver = EndpointResolver(blockingClient, clock = { clockMillis.get() })
val candidate = candidate("replacement-identity-test", priority = 0, server = reachableServer)
try {
val staleResolve = async(start = CoroutineStart.UNDISPATCHED) {
resolver.resolve(listOf(candidate), EndpointSurface.Api)
}
assertTrue(staleRequestStarted.await(5, TimeUnit.SECONDS))
resolver.clearCache()
val freshResolve = async(start = CoroutineStart.UNDISPATCHED) {
resolver.resolve(listOf(candidate), EndpointSurface.Api)
}
assertTrue(freshRequestStarted.await(5, TimeUnit.SECONDS))
releaseStaleRequest.countDown()
assertTrue(staleRequestFinished.await(5, TimeUnit.SECONDS))
withContext(Dispatchers.Default.limitedParallelism(1)) {
withTimeout(1_000L) { staleResolve.await() }
}
val joiningResolve = async(start = CoroutineStart.UNDISPATCHED) {
resolver.resolve(listOf(candidate), EndpointSurface.Api)
}
releaseFreshRequest.countDown()
withContext(Dispatchers.Default.limitedParallelism(1)) {
assertEquals(candidate, withTimeout(2_000L) { freshResolve.await() })
assertEquals(candidate, withTimeout(2_000L) { joiningResolve.await() })
}
assertEquals(
"the late stale completion must leave the fresh shared probe registered",
2,
requestSequence.get(),
)
} finally {
releaseStaleRequest.countDown()
releaseFreshRequest.countDown()
}
}
// ---------------------------------------------------------------
// Test 6 — cached-reachable result is re-probed after TTL
// ---------------------------------------------------------------
@@ -1070,6 +1070,18 @@ class DashboardApiClientTest {
assertEquals("claude-opus-4-8", profiles[1].model)
}
@Test
fun listProfiles_keepsDisplayIdentitySeparateAndAcceptsUnconfiguredRoot() = runTest {
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody(
"""{"profiles":[{"name":"default","model":null,"is_default":true},
{"name":"guide","model":"model","display_name":"Guide","description":"Summary"}]}"""))
val profiles = DashboardApiClient(baseUrl = server.url("/").toString()).listProfiles().getOrThrow()
assertEquals(listOf("default", "guide"), profiles.map { it.name })
assertEquals("", profiles[0].model)
assertEquals("Guide", profiles[1].displayName)
assertEquals("Summary", profiles[1].description)
}
@Test
fun listProfiles_parsesObjectMapShapeWithInjectedName() = runTest {
server.enqueue(
@@ -197,6 +197,7 @@ class GatewayClientHarness(
put("model", "gpt-5.6")
put("provider", "openai")
put("description", "Android operator")
put("display_name", "Guide")
put("skill_count", 3)
put("has_avatar", true)
put("ui_meta", buildJsonObject { put("accent", "#ff5500") })
@@ -1009,6 +1010,7 @@ class GatewayChatClientTest {
assertEquals(1, profiles.size)
assertEquals("operator", profiles.single().name)
assertEquals("Guide", profiles.single().displayName)
assertEquals("openai", profiles.single().provider)
assertTrue(profiles.single().hasAvatar)
assertEquals("#ff5500", (profiles.single().uiMeta["accent"] as JsonPrimitive).content)
@@ -6,23 +6,29 @@ import org.junit.Test
class HermesRuntimeBinderSessionDirectoryPolicyTest {
@Test
fun `session directory can refresh from either standard route owner`() {
fun `session directory starts from dashboard publication before Gateway readiness`() {
assertTrue(
shouldRefreshSessionDirectory(
chatReady = true,
dashboardRouteResolved = false,
dashboardUrl = "",
),
)
assertTrue(
shouldRefreshSessionDirectory(
chatReady = false,
dashboardRouteResolved = true,
dashboardUrl = "https://dashboard.example.test",
),
)
assertFalse(
shouldRefreshSessionDirectory(
chatReady = false,
dashboardRouteResolved = false,
dashboardUrl = "",
),
)
assertFalse(
shouldRefreshSessionDirectory(
chatReady = false,
dashboardUrl = " ",
),
)
}
@@ -0,0 +1,91 @@
package com.hermesandroid.relay.screenshots
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import org.junit.Assert.assertEquals
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.ProfilePresentation
import com.hermesandroid.relay.ui.components.ProfileSwitcherSheet
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import org.robolectric.RuntimeEnvironment
import java.io.File
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h720dp-xhdpi")
class ProfileIdentityScreenshotTest {
@get:Rule val compose = createComposeRule()
@Test fun profileSwitcher() = render("profile-switcher")
@Test fun enlargedText() = render("profile-switcher-large-text", fontScale = 1.5f)
@Test fun longName() = render("profile-switcher-long-name", fontScale = 1.5f,
displayName = "Research and planning assistant for the whole team")
@Test
@Config(qualifiers = "w720dp-h360dp-xhdpi")
fun landscape() = render("profile-switcher-landscape")
@Test
@Config(qualifiers = "w840dp-h720dp-xhdpi")
fun expanded() = render("profile-switcher-expanded")
private fun render(file: String, fontScale: Float = 1f, displayName: String = "Guide") {
RuntimeEnvironment.setFontScale(fontScale)
val agent = Profile(name = "guide", model = "example-model", displayName = displayName)
val vm = mockk<ConnectionViewModel>(relaxed = true)
every { vm.profileIconFlow(any()) } returns MutableStateFlow(null)
every { vm.serverDefaultDisplayProfile } returns MutableStateFlow(agent)
val selected = mutableStateOf<Profile?>(null)
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
CompositionLocalProvider(LocalDensity provides Density(LocalDensity.current.density, fontScale)) {
ProfileSwitcherSheet(vm, listOf(Profile("default", "root-model"), agent), selected.value,
selected.value ?: agent, ProfilePresentation(), false, true, { selected.value = it }, {}, {})
}
}
}
compose.onAllNodesWithText(displayName, substring = false).assertCountEquals(1)
compose.onNodeWithText("Follow server default").assertIsDisplayed().assertIsOn()
.assertHeightIsAtLeast(48.dp)
val output = File("build/ui-evidence/$file.png")
output.parentFile?.mkdirs()
compose.onRoot().captureRoboImage(output.absolutePath)
compose.onNodeWithText("Follow server default").performClick()
compose.runOnIdle { assertEquals("guide", selected.value?.name) }
compose.onNodeWithText("Follow server default").assertIsOff()
compose.onNodeWithText("Follow server default").performClick()
compose.runOnIdle { assertEquals(null, selected.value) }
// Scrollable even in landscape or at enlarged text sizes.
compose.onNodeWithText("default", substring = false).performScrollTo().performClick()
compose.runOnIdle { assertEquals("default", selected.value?.name) }
compose.onNodeWithText("default", substring = false).assertIsSelected()
}
}
@@ -7,8 +7,9 @@ import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.RelayEndpoint
import com.hermesandroid.relay.data.VoicePresentationMode
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.ui.screens.shouldOwnVisibleGateway
import com.hermesandroid.relay.viewmodel.ChatConnectState
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.ChatTransportPath
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.resolveChatConnectState
@@ -196,6 +197,50 @@ class RelayAppStatusTest {
assertEquals(ChatRuntimeStatus.Connecting, status)
}
@Test
fun `foreground Gateway owns cold observation before gateway ready`() {
assertTrue(
shouldOwnVisibleGateway(
appForeground = true,
isGatewayTransport = true,
gatewayAvailability = GatewayAvailability.Unknown,
),
)
assertTrue(
shouldOwnVisibleGateway(
appForeground = true,
isGatewayTransport = true,
gatewayAvailability = GatewayAvailability.Unreachable,
),
)
assertFalse(
shouldOwnVisibleGateway(
appForeground = false,
isGatewayTransport = true,
gatewayAvailability = GatewayAvailability.Unknown,
),
)
assertFalse(
shouldOwnVisibleGateway(
appForeground = true,
isGatewayTransport = false,
gatewayAvailability = GatewayAvailability.Unknown,
),
)
listOf(
GatewayAvailability.SignInRequired,
GatewayAvailability.Unsupported,
).forEach { terminal ->
assertFalse(
shouldOwnVisibleGateway(
appForeground = true,
isGatewayTransport = true,
gatewayAvailability = terminal,
),
)
}
}
@Test
fun `dashboard sign-out is not masked by a reachable sibling API`() {
val status = resolveAppChatRuntimeStatus(
@@ -9,6 +9,30 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class ProfileShelfPolicyTest {
@Test
fun resolvedDefaultGroupsOnlyExactIdentityAndRetainsSelectedRequestKey() {
val victor = Profile("victor", "", displayName = "Victor")
val duplicate = Profile("other", "", displayName = "Victor")
val roster = listOf(Profile("default", ""), victor, duplicate)
for (selected in listOf(null, "victor", "default", "other")) {
val choices = ProfileShelfPolicy.choices(roster, ProfilePresentation(), selected, "victor")
assertEquals(3, choices.size)
assertTrue(choices.any { ProfileShelfPolicy.isSelected(it, selected) })
assertTrue(choices.any { it.key == "default" })
assertTrue(choices.any { it.key == "other" })
val grouped = choices.first { it.isServerDefault || it.key == "victor" }
assertEquals(if (selected == "victor") "victor" else null, grouped.profile?.name)
}
}
@Test
fun unknownDefaultNeverCollapsesRootOrNamesWithMatchingLabels() {
val roster = listOf(Profile("default", ""), Profile("one", "", displayName = "Same"),
Profile("two", "", displayName = "Same"))
assertEquals(4, ProfileShelfPolicy.choices(roster, ProfilePresentation(), null).size)
assertEquals(4, ProfileShelfPolicy.choices(roster, ProfilePresentation(), null, "missing").size)
}
private val profiles = listOf(
Profile(name = "default", model = "root"),
Profile(name = "alpha", model = "a"),
@@ -182,6 +182,55 @@ class ChatViewModelGatewayInboundTurnTest {
)
}
@Test
fun coldGatewayClientBeforeVisibilityOpensObservationWithoutControlRpc() {
viewModel.setChatVisible(false)
replaceGatewayClient(ticketTimeoutMs = 5_000L)
val controlMethods = setOf(
"session.resume",
"session.activate",
"prompt.submit",
"session.interrupt",
)
val baseline = controlMethods.associateWith { method ->
gatewayHarness.rpcLog.count { it.first == method }
}
val ticketMintsBefore = gatewayHarness.ticketMints.get()
viewModel.setChatVisible(true)
awaitCondition { gatewayClient.connectionState.value == GatewayConnectionState.Ready }
assertEquals(ticketMintsBefore + 1, gatewayHarness.ticketMints.get())
controlMethods.forEach { method ->
assertEquals(baseline.getValue(method), gatewayHarness.rpcLog.count { it.first == method })
}
}
@Test
fun coldGatewayVisibilityBeforeClientBindingOpensObservationWithoutControlRpc() {
viewModel.setChatVisible(false)
replaceGatewayClient(ticketTimeoutMs = 5_000L, bind = false)
val controlMethods = setOf(
"session.resume",
"session.activate",
"prompt.submit",
"session.interrupt",
)
val baseline = controlMethods.associateWith { method ->
gatewayHarness.rpcLog.count { it.first == method }
}
val ticketMintsBefore = gatewayHarness.ticketMints.get()
viewModel.setChatVisible(true)
viewModel.updateGatewayClient(gatewayClient)
awaitCondition { gatewayClient.connectionState.value == GatewayConnectionState.Ready }
assertEquals(ticketMintsBefore + 1, gatewayHarness.ticketMints.get())
controlMethods.forEach { method ->
assertEquals(baseline.getValue(method), gatewayHarness.rpcLog.count { it.first == method })
}
}
@Test
fun offlineGatewaySendPublishesRetryableFailureAndKeepsPrompt() {
DiagnosticsLog.clear()
@@ -4322,7 +4371,10 @@ class ChatViewModelGatewayInboundTurnTest {
),
)
private fun replaceGatewayClient(ticketTimeoutMs: Long): GatewayChatClient {
private fun replaceGatewayClient(
ticketTimeoutMs: Long,
bind: Boolean = true,
): GatewayChatClient {
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
gatewayScope.cancel()
@@ -4340,7 +4392,7 @@ class ChatViewModelGatewayInboundTurnTest {
scope = gatewayScope,
reconnectJitterUnit = { Math.nextDown(1.0) },
)
viewModel.updateGatewayClient(gatewayClient)
if (bind) viewModel.updateGatewayClient(gatewayClient)
return gatewayClient
}
@@ -250,6 +250,48 @@ class ProfileControllerLockTest {
Thread.sleep(100)
assertEquals("newer", controller.effectiveSessionProfileName.value)
assertEquals("newer", awaitFlow(controller.serverDefaultDisplayProfile) { it?.name == "newer" }?.name)
}
@Test
fun connectionSwitchRejectsOldDefaultIdentity() {
dashboardUrl = "https://dashboard.example"
val started = CompletableDeferred<Unit>()
val old = CompletableDeferred<DashboardProfileScope>()
coEvery { dashboardClient.getActiveProfileScope() } coAnswers {
started.complete(Unit)
Result.success(old.await())
}
controller.refreshDashboardProfileScope()
runBlocking { withTimeout(5_000) { started.await() } }
controller.resetForConnectionSwitch()
activeConnectionId.value = "other-connection"
coEvery { dashboardClient.getActiveProfileScope() } returns Result.success(
DashboardProfileScope(active = "other", current = "default"))
controller.refreshDashboardProfileScope()
assertEquals("other", awaitFlow(controller.serverDefaultDisplayProfile) { it?.name == "other" }?.name)
old.complete(DashboardProfileScope(active = "victor", current = "default"))
assertEquals("other", awaitFlow(controller.effectiveSessionProfileName) { it == "other" })
assertNull(controller.selectedProfile.value)
}
@Test
fun unresolvedDefaultDoesNotBorrowRootAvatarFromGatewayRoster() = runBlocking {
gatewayClient = mockk(relaxed = true)
coEvery { gatewayClient!!.petInfo(any(), any()) } returns Result.failure(
IllegalStateException("Pet metadata temporarily unavailable"))
coEvery { gatewayClient!!.listProfiles() } returns Result.success(
listOf(literalDefault.copy(isDefault = true, hasAvatar = true)))
coEvery { gatewayClient!!.getProfileAvatar("default") } returns Result.failure(
IllegalStateException("Avatar temporarily unavailable"))
controller.refreshGatewayProfiles()
awaitFlow(controller.agentProfiles) { it.any { profile -> profile.name == "default" } }
controller.profileIconStore.setServerAvatar(connectionId, "default", "root-avatar.png")
assertNull(controller.profileIconFlow(null).first())
assertEquals("root-avatar.png", controller.profileIconFlow("default").first())
assertNull(controller.serverDefaultDisplayProfile.value)
controller.clearSharedProfileAvatar()
coVerify(exactly = 0) { gatewayClient!!.clearProfileAvatar(any()) }
}
@After
@@ -352,6 +394,7 @@ class ProfileControllerLockTest {
assertEquals("pinned", awaitFlow(controller.effectiveSessionProfileName) { it == "pinned" })
assertEquals(pinned, awaitFlow(controller.effectiveDisplayProfile) { it?.name == "pinned" })
assertEquals(pinned, awaitFlow(controller.serverDefaultDisplayProfile) { it?.name == "pinned" })
assertEquals("default", controller.serverDefaultProfileScope.value?.current)
assertTrue(awaitFlow(controller.selectionSettled) { it })
runBlocking { controller.listProfileScopedSessions()?.getOrThrow() }
@@ -0,0 +1,96 @@
# Android profile identity audit
## Upstream contract
Source inspection used clean upstream commit
`b0c383cdf7d8e9e540087610324ec3bb89f3b250` from `NousResearch/hermes-agent`.
- [`hermes_cli/profiles.py`](https://github.com/NousResearch/hermes-agent/blob/b0c383cdf7d8e9e540087610324ec3bb89f3b250/hermes_cli/profiles.py):
`default` is the reserved request name for the root Hermes home. Named profiles
resolve beneath the profiles directory. `is_default` marks that root row; it
does not identify the sticky selection. The active-profile marker is written
atomically, and selecting root removes the marker. Profile `display_name` is
presentation metadata in `profile.yaml`; renaming root changes that display
metadata rather than moving its home.
- [`hermes_cli/web_routers/profiles.py`](https://github.com/NousResearch/hermes-agent/blob/b0c383cdf7d8e9e540087610324ec3bb89f3b250/hermes_cli/web_routers/profiles.py):
`/api/profiles` returns `name`, `display_name`, `description`, and `is_default`.
`/api/profiles/active` returns two different authorities: `active` is the sticky
choice for new invocations, while `current` describes the running Dashboard.
- [`tui_gateway/methods_profiles.py`](https://github.com/NousResearch/hermes-agent/blob/b0c383cdf7d8e9e540087610324ec3bb89f3b250/tui_gateway/methods_profiles.py):
`profiles.list` returns the same profile identity plus avatar/UI metadata.
Standard Android requests `include_sessions:false`; Bot Mode retains the
richer roster contract and its own optional Bot title.
- [`tui_gateway/methods_session.py`](https://github.com/NousResearch/hermes-agent/blob/b0c383cdf7d8e9e540087610324ec3bb89f3b250/tui_gateway/methods_session.py):
explicit `profile` selects the profile home for session creation and resume.
Omission uses launch context; it is not inherently a request for the sticky
active profile. Android already resolves the sticky setting explicitly for
standard profile-scoped sessions and retains that behavior.
- [`apps/desktop/src/store/profile.ts`](https://github.com/NousResearch/hermes-agent/blob/b0c383cdf7d8e9e540087610324ec3bb89f3b250/apps/desktop/src/store/profile.ts):
`profileLabel` uses trimmed `display_name`, then the request name. TUI's
[`appLayout.tsx`](https://github.com/NousResearch/hermes-agent/blob/b0c383cdf7d8e9e540087610324ec3bb89f3b250/ui-tui/src/components/appLayout.tsx)
supplies session `profile_name` to its composer prompt rather than a server
connection label.
The `active_status_profile_scope` scenario's current-upstream conformance check
passed against this SHA. This is source-only evidence, with no provider calls.
## Product decision
The agent name is Hermes `display_name`, otherwise the exact profile request
name. Descriptions are not identity. A connection name remains infrastructure
identity. Existing local aliases and personality fallback remain supported; an
otherwise unknown agent is **Hermes**.
When the server default resolves to a catalog profile, the shelf and canonical
switcher show one identity with a home badge. The switcher shows **Server default**
as secondary status and **Follow server default** as a separate selection control.
Unchecking it explicitly selects the resolved profile; checking it restores the
null selection. Both use the existing profile-switch lifecycle. No server setting
is written and no session, draft, history, lock, or asset key is migrated.
The explicit root `default` choice remains available. Equal display names never
cause grouping. Grouping requires exact upstream request identity within the
current connection. Missing scope stays unresolved; a known scope without roster
metadata retains its request name rather than borrowing root metadata.
## Surface review
| Surface | Result |
| --- | --- |
| Chat header and Passport | Shared identity helper consumes display metadata; connection label and description no longer masquerade as an agent. |
| Profile shelf and canonical switcher | Exact-identity grouping, selected-key preservation, role text, follow control, unchanged switch callbacks and local/shared avatar lookup. |
| Connection selection | Connection identity and credentials remain separately scoped; no endpoint or persistence changes. |
| Settings locks and display management | Follow-default preference is labeled as an action; independent lock/order/hidden keys remain visible for management. |
| Manage profile catalog | Uses upstream display name while preserving the raw name for actions. |
| Bot Mode | Already prioritizes Bot title, then display name, then request name; parsing now also retains display name in its Profile metadata. Route keys and connection-qualified handles stay unchanged. |
| Restoration and diagnostics | Raw profile/session/connection keys remain authoritative. No display-label parsing, normalization, session deletion, or routing changes. |
| Supervised mode, avatars, pets | Lock gates and exact asset keys remain unchanged. An unresolved default cannot borrow or modify the root avatar from `is_default`; shared-avatar actions require a resolved or explicit profile. The follow control is disabled under a lock. |
| Legacy private agent sheet | Not the canonical switcher; retained without a separate routing/model rewrite. |
## Rendered evidence
Sanitized fixtures render the production Compose profile switcher with mocked
metadata and avatar flows. They make no network requests and contain no profile
contents or private infrastructure. These are JVM/Robolectric renders, not phone
screenshots or live-server certification.
| Before | After |
| --- | --- |
| ![Three apparent identities](assets/profile-identity/before.png) | ![Resolved identity and follow-default control](assets/profile-identity/after.png) |
The rendered checks cover 360 x 720 dp, 720 x 360 dp landscape, and
840 x 720 dp expanded layouts, plus 1.5x system font scale and a long display
name. Text truncates with ellipsis, the request name remains available as
supporting text, and the follow control preserves checkbox state and a 48 dp
minimum target. Landscape opens fully expanded with scrollable content.
- [Large text](assets/profile-identity/large-text.png)
- [Landscape](assets/profile-identity/landscape.png)
- [Expanded layout](assets/profile-identity/expanded.png)
- [Long name](assets/profile-identity/long-name.png)
Focused sideload and Google Play suites each passed 497 tests with no failures
or skips. Lint and build results are recorded with the PR. Live-server,
physical-device, screen-reader, and fold-posture transition behavior remain
separate verification gaps. No APK was installed and no server profile was
modified.
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+4 -1
View File
@@ -2896,7 +2896,10 @@ the selected-hidden exception. Local icons remain connection/profile scoped.
Compose renders state and invokes ViewModel actions; it never writes a store.
Server default is represented only by `SERVER_DEFAULT_PROFILE_KEY`/a null
selection and uses a home glyph. A profile literally named `default` retains its
selection. Its resolved agent is grouped once with a home badge and a follow-default
control; the selected request key remains unchanged. Upstream `display_name` is
presentation-only. Descriptions and connection names are not profile names, and
an unresolved default never assumes root identity. A profile named `default` retains its
own request, lock, icon, presentation, and session keys. Selecting either does
not call the upstream sticky-default mutation.
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
"main": "0b368eebdd369c06304aa7fff7954bdad2c0d5da8974e36a57d9c8d44d3e9cee",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
"main": "0b368eebdd369c06304aa7fff7954bdad2c0d5da8974e36a57d9c8d44d3e9cee",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
"main": "0b368eebdd369c06304aa7fff7954bdad2c0d5da8974e36a57d9c8d44d3e9cee",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
"main": "0b368eebdd369c06304aa7fff7954bdad2c0d5da8974e36a57d9c8d44d3e9cee",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
"main": "0b368eebdd369c06304aa7fff7954bdad2c0d5da8974e36a57d9c8d44d3e9cee",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
"main": "0b368eebdd369c06304aa7fff7954bdad2c0d5da8974e36a57d9c8d44d3e9cee",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
+2 -2
View File
@@ -91,9 +91,9 @@ This app is a community project and is not affiliated with or endorsed by NousRe
Paste into Play Console → **What's new** (≤500 characters):
```
v1.15.1 - Steadier chat, media, and voice
v1.16.1 - Dashboard-only cold starts recover
More reliable chats and media: fewer memory-heavy refreshes, smoother large histories, and attachment previews that survive rotation. Choose whether follow-ups correct the current response or wait in a queue. Voice errors are easier to read, image-generation progress stays visible, and wake-word startup and first-message readiness are fixed.
Dashboard-only connections now prepare the selected profile before Gateway readiness, fixing a remaining cold-start path that could stay on waking or waiting for Gateway until the app resumed or its network route changed.
```
## Category
+1 -1
View File
@@ -601,7 +601,7 @@ Bottom navigation bar with 4 tabs:
1. **Connection chip** — tap to open `ConnectionSwitcherSheet` (all paired servers + health indicator). Auto-hidden when you only have one Connection. See `docs/decisions.md` §19.
2. **Agent avatar/name region** — tap to expand or collapse the Profile Shelf immediately below the app bar. With only one visible effective identity, the shelf stays hidden and the same tap opens Agent Passport.
3. Remaining top-bar actions (session drawer hamburger, ambient toggle, etc.).
- **Profile Shelf** — the active avatar/name/chevron capsule opens Agent Passport; inactive profiles are avatar-only 48 dp switch targets; a fixed overflow opens the canonical full switcher also used by Passport. The shelf scrolls horizontally, honors `ProfilePresentationStore` ordering/hidden preferences, keeps a hidden selected profile disclosed, and disappears when only one visible identity remains. Hermes-owned avatars win by default, followed by device-local icons and display initials; an explicit per-connection/profile **This phone only** override lets the local icon win without mutating Hermes. Server default uses a home glyph and remains distinct from a profile literally named `default`.
- **Profile Shelf** — the active avatar/name/chevron capsule opens Agent Passport; inactive profiles are avatar-only 48 dp switch targets; a fixed overflow opens the canonical full switcher also used by Passport. The shelf scrolls horizontally, honors `ProfilePresentationStore` ordering/hidden preferences, keeps a hidden selected profile disclosed, and disappears when only one visible identity remains. Hermes-owned avatars win by default, followed by device-local icons and display initials; an explicit per-connection/profile **This phone only** override lets the local icon win without mutating Hermes. The resolved server default is grouped with its exact named profile and carries a home badge; a follow-default control preserves implicit and explicit selection keys. Display identity uses upstream `display_name`, then the exact profile name, never a connection label or description. Unknown default identity stays unresolved rather than borrowing root metadata.
- **Hermes-owned profile identity** — on a current Gateway, Android calls `profiles.list {include_sessions:false}` and consumes bounded `ui_meta` plus `has_avatar`. A true avatar flag triggers `profiles.get_asset`; validated server bytes are cached per connection/profile and win over the older device-local `ProfileIconStore`. A false flag or successful clear removes only the server cache. Refresh generations and exact connection identity prevent a late fetch from repainting another connection or resurrecting a cleared avatar.
- **Separated shared and phone avatar controls** — **Shared across Hermes** directly selects or removes the upstream `profiles.set_asset` avatar without changing local presentation. **This phone only** is a persisted per-connection/profile override populated from a phone image. The Relay-host `GET /api/profiles/{name}/avatar` conventional-file importer is a legacy enhancement only; it does not own or replace upstream profile assets. Selecting a phone image enables the override, while disabling it immediately returns to the shared avatar. Phone-local PNG/JPEG/WebP/GIF bytes are magic-checked and capped at 8 MB; Coil renders animated GIF/WebP consistently anywhere the profile icon appears. The shared picker accepts any image Android can decode, applies its display orientation, and downscales/re-encodes when necessary while retaining the exact upstream PNG/JPEG/WebP and 2,000,000-byte storage contract. Pet sheets and Sphere skins never enter `ui_meta` or profile assets.
- **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.
+2 -2
View File
@@ -1,6 +1,6 @@
[versions]
appVersionName = "1.15.1"
appVersionCode = "54"
appVersionName = "1.16.1"
appVersionCode = "56"
agp = "9.4.0"
kotlin = "2.4.10"
compose-bom = "2026.08.00"
+1 -1
View File
@@ -3,7 +3,7 @@
"label": "Hermes-Relay",
"description": "Paired devices, Bridge activity, media tokens, and remote access for Hermes-Relay",
"icon": "Activity",
"version": "1.11.1",
"version": "1.11.2",
"tab": {
"path": "/relay",
"position": "after:skills"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hermes-relay-dashboard",
"version": "1.11.1",
"version": "1.11.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hermes-relay-dashboard",
"version": "1.11.1",
"version": "1.11.2",
"devDependencies": {
"esbuild": "^0.25.12",
"qrcode": "^1.5.4"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hermes-relay-dashboard",
"version": "1.11.1",
"version": "1.11.2",
"private": true,
"description": "Hermes-Relay dashboard plugin frontend (IIFE bundle). Loaded verbatim by the hermes-agent dashboard via the Plugin SDK global.",
"scripts": {
+1 -1
View File
@@ -2,7 +2,7 @@ name: hermes-relay
# Temporary v1 shim for Hermes installers that reject manifests the runtime supports; see docs/project/TODO.md.
manifest_version: 1
api_version: 1
version: 1.11.1
version: 1.11.2
description: "Hermes-Relay plugin for QR pairing, relay sessions, dashboard management, remote desktop/phone tooling, and optional legacy compatibility diagnostics. Standard chat, Manage, and dashboard voice remain vanilla upstream Hermes surfaces."
author: Axiom Labs
license: MIT
+1 -1
View File
@@ -19,7 +19,7 @@ See ``plugin/relay/server.py`` for the aiohttp server,
# CLI+UI releases use desktop/package.json and desktop-v* tags. The /health endpoint
# reports this plugin version, and stale values make live diagnosis harder than
# it should be.
__version__ = "1.11.1"
__version__ = "1.11.2"
from .server import create_app, main # noqa: E402 — must come after __version__
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hermes-relay"
version = "1.11.1"
version = "1.11.2"
description = "Hermes-Relay plugin — Android device control toolset, QR pairing CLI, and WSS relay server for hermes-agent"
requires-python = ">=3.11"
dependencies = [
+13 -3
View File
@@ -333,8 +333,18 @@ def _check_active_list(server: SourceFile, methods: SourceFile) -> CheckResult:
missing_fields = sorted({"id", "session_key", "status"} - item_strings)
if missing_fields:
raise ValueError("active-list row missing field(s): " + ", ".join(missing_fields))
required_markers = ("_sessions_lock", "_sessions.items()", "_session_live_item(")
missing_markers = [marker for marker in required_markers if marker not in handler_text]
snapshot_node = handler
snapshot_text = handler_text
if "_snapshot_sessions(" in handler_text:
snapshot_node = methods.function("_snapshot_sessions")
snapshot_text = methods.segment(snapshot_node)
required_snapshot_markers = ("_sessions_lock", "_sessions.items()")
missing_snapshot_markers = [
marker for marker in required_snapshot_markers if marker not in snapshot_text
]
missing_markers = list(missing_snapshot_markers)
if "_session_live_item(" not in handler_text:
missing_markers.append("_session_live_item(")
if missing_markers or "sessions" not in _string_constants(handler):
raise ValueError(
"session.active_list no longer snapshots the live registry: "
@@ -352,7 +362,7 @@ def _check_active_list(server: SourceFile, methods: SourceFile) -> CheckResult:
server.evidence(status, "starting, working, waiting, and idle derivation"),
server.evidence(item, "live row carries runtime and durable identities"),
methods.evidence(
handler, "active list snapshots the process-wide in-memory registry"
snapshot_node, "active list snapshots the process-wide in-memory registry"
),
),
)
@@ -110,11 +110,16 @@ def _(rid, params):
session, error = _sess_nowait(params, rid)
return _live_session_payload(params["session_id"], session)
def _snapshot_sessions(rid):
with _sessions_lock:
return list(_sessions.items()), None
@method("session.active_list")
def _(rid, params):
snapshot, error = _snapshot_sessions(rid)
if error:
return error
current = str(params.get("current_session_id") or "")
with _sessions_lock:
snapshot = list(_sessions.items())
rows = [_session_live_item(sid, session, current) for sid, session in snapshot]
return _ok(rid, {"sessions": rows})
'''
@@ -218,6 +218,7 @@ class FixtureTestCase(unittest.IsolatedAsyncioTestCase):
active = (await observer.receive_json())["result"]["sessions"]
self.assertEqual("working", active[0]["status"])
self.assertNotIn("profile", active[0])
async with self.session.get(
f"{base_url}/api/sessions/{fixture.scenario.stored_session_id}/messages",
params={"profile": "default", "limit": 500, "offset": 0, "order": "asc"},
@@ -244,6 +245,31 @@ class FixtureTestCase(unittest.IsolatedAsyncioTestCase):
self.assertEqual(["session.active_list"], observer_methods)
self.assertNotIn("session.interrupt", observer_methods)
async def test_cold_start_observer_opens_socket_without_control_rpc(self) -> None:
_, base_url = await self.start("cold_start_observation")
# Android's cold-start barrier hydrates the profile-scoped Dashboard
# directory before it opens the passive Gateway observation socket.
# The REST read is independent of gateway.ready and must not create or
# attach a live runtime.
async with self.session.get(
f"{base_url}/api/sessions",
params={"profile": "default", "limit": 50, "offset": 0},
) as response:
self.assertEqual(200, response.status)
self.assertEqual([], (await response.json())["sessions"])
observer, _ = await self.connect(base_url)
await self.rpc(observer, 1, "session.active_list")
active = (await observer.receive_json())["result"]["sessions"]
self.assertEqual([], active)
async with self.session.get(f"{base_url}/__fixture__/evidence") as response:
evidence = await response.json()
methods = [entry["method"] for entry in evidence["entries"] if "method" in entry]
self.assertEqual({"session.active_list"}, set(methods))
self.assertTrue(any(entry.get("event_type") == "gateway.ready" for entry in evidence["entries"]))
async def test_rapid_chunks_tools_and_interims_keep_wire_order(self) -> None:
_, base_url = await self.start("rapid_tools_interims")
ws, _ = await self.connect(base_url)
@@ -455,6 +481,7 @@ class ScenarioTestCase(unittest.TestCase):
"active_status_lifecycle",
"active_status_profile_scope",
"active_status_unsupported",
"cold_start_observation",
"cross_client_observation",
"initial_history_bind",
"ordinary_turn",
@@ -508,6 +535,10 @@ class ScenarioTestCase(unittest.TestCase):
("gateway.settled_session_info",),
load_scenario("terminal_gap_session_info").contract_requirements,
)
self.assertEqual(
("gateway.session_active_list",),
load_scenario("cold_start_observation").contract_requirements,
)
self.assertEqual(
("gateway.message_complete", "gateway.session_active_list"),
load_scenario("cross_client_observation").contract_requirements,
@@ -0,0 +1,17 @@
{
"name": "cold_start_observation",
"live_session_id": "fixture-cold-live",
"stored_session_id": "fixture-cold-stored",
"profile": "default",
"contract_requirements": [
"gateway.session_active_list"
],
"initial_history": [],
"turns": [],
"active_list": {
"supported": true,
"snapshots": [
[]
]
}
}
@@ -33,6 +33,7 @@ class GatewayFixture:
[
web.post("/api/auth/ws-ticket", self._ticket),
web.get("/api/ws", self._websocket),
web.get("/api/sessions", self._sessions),
web.get("/api/sessions/{session_id}/messages", self._history),
web.get("/__fixture__/state", self._state),
web.get("/__fixture__/evidence", self._evidence),
@@ -289,6 +290,18 @@ class GatewayFixture:
{"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}},
)
async def _sessions(self, request: web.Request) -> web.Response:
profile = request.query.get("profile")
if profile not in (None, self.scenario.profile):
raise web.HTTPNotFound(text="profile not found")
self.evidence.add("directory", outcome="listed")
return web.json_response(
{
"sessions": [],
"pagination": {"limit": 50, "offset": 0, "returned": 0},
},
)
async def _history(self, request: web.Request) -> web.Response:
if request.match_info["session_id"] != self.scenario.stored_session_id:
raise web.HTTPNotFound(text="session not found")
+29 -15
View File
@@ -36,30 +36,44 @@ Or clone from an existing profile:
hermes profile create coder --clone
```
The phone doesn't create profiles — you do that on the server. The phone just picks them up on the next pairing (or the next `auth.ok` round-trip after a relay restart).
Current Hermes gateways support profile creation in Manage. The upstream Dashboard
and Gateway expose the profile catalog without requiring Relay.
## What "switching profile" does on the phone
## What switching profile does on the phone
When a profile is selected, the phone first checks whether the relay advertised that profile's own Hermes API server.
Standard connections use the selected profile's upstream Dashboard/Gateway
session namespace. Switching restores its last compatible conversation or opens
a fresh draft. It preserves the old conversation and does not change its agent.
The optional API-only compatibility path can use an advertised profile API route.
- **With a profile API server:** chat, session browsing, memory, tools, model, and SOUL come from that profile's routed API. The chat session drawer clears and refetches through that profile route, so you see that profile's sessions instead of the default agent's sessions.
- **Without a profile API server:** the app falls back to the compatibility overlay. It sends the profile `model.default` and `SOUL.md` on each chat turn, but memory, sessions, tools, and provider auth still come from the active Connection.
- **Voice:** relay-owned voice routes receive the selected profile too. Voice Settings shows whether TTS/STT, streaming voice output, or realtime voice came from profile config or fell back to relay/global defaults. Saving voice output or experimental realtime settings while a named profile is active writes that profile's `voice_output:` / `realtime_voice:` section, so profiles like `mizuki` and `victor` can keep different voices.
## Identity and server default
**If you want true profile isolation,** run that profile's gateway as its own service on its own port:
Android uses Hermes `display_name`, then the exact profile request name.
Descriptions summarize a profile; they are not names. A connection name identifies
the server and does not become the agent name. Without a known profile or
personality identity, Android shows **Hermes**. Local display aliases remain
phone-only overrides.
```bash
hermes -p mizu platform start api --port 8643
```
**Server default** is a role. The switcher shows the resolved agent once with that
role as secondary text. **Follow server default** switches between following the
server setting and explicitly selecting that agent. It never changes the server
setting. Both choices retain independent conversation, draft, lock, and local
presentation keys.
Then make sure the relay advertises that API server in the profile metadata, or add that gateway as a separate **Connection** on the phone. Each routed profile API has its own sessions, memory, and state because it is a distinct gateway.
The exact name `default` addresses the root profile and remains selectable when
another profile is the server default. Hermes' sticky `active` setting may differ
from the running server's `current` launch profile. Android resolves the sticky
setting when available. Older hosts without that metadata retain launch-profile
behavior and an unresolved **Server default** choice. A missing catalog does not
justify borrowing root profile metadata or its avatar. Shared-avatar changes
require a resolved default or an explicit profile selection.
## Profile Shelf behaviour
- **Collapsible and compact.** Tap the avatar/name in the Chat header to expand or collapse the shelf. The hamburger still opens only the active profile's Session Drawer.
- **Active capsule.** The active avatar/name/chevron opens Agent Passport. Inactive agents are 48 dp avatar targets; the fixed overflow opens the same full switcher used by Passport's **Switch agent** control.
- **Hidden for one effective identity.** The shelf takes no space when only one visible identity remains. Saved ordering and hidden preferences are honored, but a hidden active profile stays visible until you switch away.
- **Server default is distinct.** The home-glyph **Server default** choice follows the server's sticky default without changing it. A profile literally named `default` is a separate explicit profile with its own session and presentation state.
- **Server default is a role.** The resolved agent carries a home badge. Its follow-default control preserves implicit and explicit selections.
- **Transport-safe switching.** Gateway turns can continue in the background and reconcile to their original conversation, so profile switching remains available. SSE switching is disabled only while an SSE turn is live.
- **No live-session hot swap.** Switching restores the destination profile's last compatible Gateway or SSE session, or opens a fresh draft. It never changes the agent inside the conversation currently on screen.
- **Session controls reset.** Model, personality, reasoning, approval, Fast, and YOLO choices from the old session do not leak into the new profile.
@@ -106,8 +120,8 @@ Very large files (SOUL or a memory entry) are still truncated server-side; when
The Profile Shelf and its canonical full switcher use these conventions:
- **Server default** — the no-override row with a home glyph. It follows the server's current sticky default without changing that setting.
- Actual profiles use their configured display description when available, then their profile name. A local agent icon wins over the display initial, and the full switcher can show the profile's model without inventing presence or activity state.
- **Server default** is secondary status on the resolved agent. Grouping uses exact request names, never matching display labels.
- Profiles use upstream display names, then exact request names. Supporting text disambiguates a display name from its request name. Shared avatars and phone-local overrides retain their existing precedence and scope.
- When the server emits a `profiles.updated` push (profile added, renamed, or removed on the server side), the app applies the new list immediately and shows a brief "Profiles updated" snackbar. A profile you had selected that the server then removes falls back to Server default automatically.
The Settings card is visible whether or not a profile is currently active; when there's no active profile, the card renders at half opacity with "No active agent" and does nothing when tapped.
@@ -130,7 +144,7 @@ If you select a profile AND a personality, the **profile wins** — its `SOUL.md
## At a glance
- **Connection** = a whole Hermes server.
- **Profile** = a named agent *on* that server, discovered from `~/.hermes/profiles/`. Picking one overlays its model + SOUL for chat turns.
- **Profile** = a named agent *on* that server, discovered from `~/.hermes/profiles/`. Picking one selects its upstream session namespace on standard connections.
- **Personality** = a system-prompt preset *within* the agent's config.
See [Connections](./connections.md) for the server-level concept and [Personalities](./personalities.md) for the preset-prompt layer.