Compare commits

...
20 changed files with 529 additions and 32 deletions
+8 -1
View File
@@ -8,14 +8,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### 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-09
### 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 9, 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
@@ -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,28 @@ 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.ui.components.GatewayBackgroundProcessStrip
import com.hermesandroid.relay.ui.components.SubagentPreviewVisibility
import com.hermesandroid.relay.ui.screens.shouldOwnVisibleGateway
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 +89,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 +125,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 +175,53 @@ 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),
)
}
}
@After
fun tearDown() {
viewModel.updateGatewayClient(null)
@@ -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()
}
}
@@ -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
// ---------------------------------------------------------------
@@ -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(
@@ -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
}
+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,19 @@ 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")
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 +469,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 +523,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": [
[]
]
}
}