Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a6f9cc10b | ||
|
|
c7f54321ad | ||
|
|
e6b9933d46 | ||
|
|
c27ee439ef | ||
|
|
68fce1b1da | ||
|
|
2865456761 | ||
|
|
4547031bba | ||
|
|
41e9acf4c2 | ||
|
|
a5f671c06c | ||
|
|
5372fc1fef | ||
|
|
6c5ecbb028 | ||
|
|
bd5a1c3335 | ||
|
|
2c740c9f04 | ||
|
|
3ec89680ed | ||
|
|
42860d38cd | ||
|
|
ef4b3bdb6c | ||
|
|
44bbb16cd3 | ||
|
|
1f5b7e68fc | ||
|
|
4bb8d6fa7b | ||
|
|
6395e73927 | ||
|
|
c956232b96 | ||
|
|
9814cdca55 | ||
|
|
0c337c9384 | ||
|
|
273e3f5aff | ||
|
|
ac1f42b7d5 | ||
|
|
88591fca89 | ||
|
|
75feb55adf | ||
|
|
f4b366389b | ||
|
|
2ec7b7aac9 |
+17
-1
@@ -6,15 +6,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [Android 1.16.1] - 2026-09-12
|
||||
|
||||
### 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 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)
|
||||
- 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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
+100
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+142
-5
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1525,10 +1525,14 @@ class DashboardApiClient(
|
||||
password: String,
|
||||
next: String = "/",
|
||||
): Result<DashboardLoginResponse> = withContext(Dispatchers.IO) {
|
||||
// Match the Dashboard's single-line HTML username/password controls:
|
||||
// remove only forbidden line breaks and preserve every other code point.
|
||||
val normalizedUsername = username.replace("\r", "").replace("\n", "")
|
||||
val normalizedPassword = password.replace("\r", "").replace("\n", "")
|
||||
val payload = buildJsonObject {
|
||||
put("provider", provider)
|
||||
put("username", username)
|
||||
put("password", password)
|
||||
put("username", normalizedUsername)
|
||||
put("password", normalizedPassword)
|
||||
put("next", next)
|
||||
}
|
||||
val httpUrl = resolveUrl("/auth/password-login")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -1212,8 +1227,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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
+35
@@ -12,6 +12,7 @@ import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
@@ -659,6 +660,40 @@ class DashboardApiClientTest {
|
||||
assertEquals("basic", session.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passwordLogin_stripsOnlyBrowserForbiddenLineBreaksFromCredentials() = runTest {
|
||||
val preservedCredential = " \t\u00A0påss\u200B "
|
||||
val cases = listOf(
|
||||
listOf("user", "line\rbreak", "user", "linebreak"),
|
||||
listOf("user", "line\nbreak", "user", "linebreak"),
|
||||
listOf("user", "line\r\nbreak", "user", "linebreak"),
|
||||
listOf("us\r\ner", "secret", "user", "secret"),
|
||||
listOf(
|
||||
preservedCredential,
|
||||
preservedCredential,
|
||||
preservedCredential,
|
||||
preservedCredential,
|
||||
),
|
||||
)
|
||||
repeat(cases.size) {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"ok": true, "next": "/"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
cases.forEach { (username, password, expectedUsername, expectedPassword) ->
|
||||
client.loginPassword(username = username, password = password).getOrThrow()
|
||||
|
||||
val body = Json.parseToJsonElement(server.takeRequest().body.readUtf8()).jsonObject
|
||||
assertEquals(expectedUsername, body["username"]?.jsonPrimitive?.content)
|
||||
assertEquals(expectedPassword, body["password"]?.jsonPrimitive?.content)
|
||||
}
|
||||
}
|
||||
|
||||
private fun storedCookie(
|
||||
name: String,
|
||||
value: String,
|
||||
|
||||
+10
-4
@@ -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 = " ",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
+54
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+2
-2
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user