Compare commits

...
10 changed files with 253 additions and 18 deletions
+1
View File
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Fixed ### Fixed
- 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 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) - **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. - 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.
@@ -5,11 +5,12 @@ import android.os.Looper
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button 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.MaterialTheme
import androidx.compose.material3.Text 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.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule 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.compose.ui.test.onNodeWithTag
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.AgentDisplay import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.ChatTurnAssistantCheckpoint import com.hermesandroid.relay.data.ChatTurnAssistantCheckpoint
import com.hermesandroid.relay.data.ChatTurnCheckpoint import com.hermesandroid.relay.data.ChatTurnCheckpoint
import com.hermesandroid.relay.data.ChatTurnCheckpointStore import com.hermesandroid.relay.data.ChatTurnCheckpointStore
import com.hermesandroid.relay.data.ChatTurnUserCheckpoint 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.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient 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.GatewayChatClient
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
import com.hermesandroid.relay.network.upstream.HermesApiClient import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.network.upstream.models.MessageItem import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessStrip import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessStrip
import com.hermesandroid.relay.ui.components.SubagentPreviewVisibility import com.hermesandroid.relay.ui.components.SubagentPreviewVisibility
import com.hermesandroid.relay.ui.screens.shouldOwnVisibleGateway
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
@@ -85,6 +89,8 @@ class GatewayForegroundRecoveryInstrumentedTest {
@Volatile @Volatile
private var persistedHistory: List<MessageItem> = emptyList() private var persistedHistory: List<MessageItem> = emptyList()
private val historySignInRequired = MutableStateFlow(false) private val historySignInRequired = MutableStateFlow(false)
private val coldStartAdmissionEnabled = MutableStateFlow(false)
private val coldStartGatewayAvailability = MutableStateFlow(GatewayAvailability.Unknown)
@Before @Before
fun setUp() { fun setUp() {
@@ -119,6 +125,19 @@ class GatewayForegroundRecoveryInstrumentedTest {
val streaming by viewModel.isStreaming.collectAsStateWithLifecycle() val streaming by viewModel.isStreaming.collectAsStateWithLifecycle()
val children by viewModel.subagentActivities.collectAsStateWithLifecycle() val children by viewModel.subagentActivities.collectAsStateWithLifecycle()
val signInRequired by historySignInRequired.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 { MaterialTheme {
Column(Modifier.testTag("contract-transcript")) { Column(Modifier.testTag("contract-transcript")) {
GatewayBackgroundProcessStrip( GatewayBackgroundProcessStrip(
@@ -156,6 +175,53 @@ class GatewayForegroundRecoveryInstrumentedTest {
fixture.awaitRpc("session.resume") 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 @After
fun tearDown() { fun tearDown() {
viewModel.updateGatewayClient(null) viewModel.updateGatewayClient(null)
@@ -463,6 +463,21 @@ internal fun shouldShowRetainedHistoryDashboardSignIn(
gatewayAvailability == GatewayAvailability.SignInRequired && gatewayAvailability == GatewayAvailability.SignInRequired &&
!apiReachable !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( internal fun shouldPresentChatFailureDuringDashboardSignIn(
failure: ChatFailureNotice, failure: ChatFailureNotice,
dashboardSignInRequired: Boolean, dashboardSignInRequired: Boolean,
@@ -1212,8 +1227,12 @@ fun ChatScreen(
// the foreground. setChatVisible owns that edge; an ordinary Gateway open // the foreground. setChatVisible owns that edge; an ordinary Gateway open
// warms only the observation socket and never attaches a saved session. // warms only the observation socket and never attaches a saved session.
val appForeground by com.hermesandroid.relay.util.AppForegroundTracker.isForeground.collectAsState() val appForeground by com.hermesandroid.relay.util.AppForegroundTracker.isForeground.collectAsState()
LaunchedEffect(isGatewayTransport, appForeground, chatReady) { LaunchedEffect(isGatewayTransport, appForeground, chatGatewayAvailability) {
val visibleGatewayOwner = appForeground && chatReady && isGatewayTransport val visibleGatewayOwner = shouldOwnVisibleGateway(
appForeground = appForeground,
isGatewayTransport = isGatewayTransport,
gatewayAvailability = chatGatewayAvailability,
)
chatViewModel.setChatVisible(visibleGatewayOwner) chatViewModel.setChatVisible(visibleGatewayOwner)
// updateGatewayClient owns the one-time catalog/reasoning bootstrap for // updateGatewayClient owns the one-time catalog/reasoning bootstrap for
// a newly-ready socket. Repeating it here created a duplicate cold-open // a newly-ready socket. Repeating it here created a duplicate cold-open
@@ -2987,9 +2987,10 @@ class ChatViewModel : ViewModel() {
_reasoningDisplay.value = null _reasoningDisplay.value = null
} }
} }
if (changed && client != null && streamRecovery != null && // Visibility can arrive before the runtime binder publishes its client.
AppForegroundTracker.isForeground.value // 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() prewarmGateway()
} }
if (changed && client != null) requestSessionActivityRefresh() if (changed && client != null) requestSessionActivityRefresh()
@@ -7,8 +7,9 @@ import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.RelayEndpoint import com.hermesandroid.relay.data.RelayEndpoint
import com.hermesandroid.relay.data.VoicePresentationMode import com.hermesandroid.relay.data.VoicePresentationMode
import com.hermesandroid.relay.network.upstream.GatewayAvailability 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.ChatConnectState
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.ChatTransportPath import com.hermesandroid.relay.viewmodel.ChatTransportPath
import com.hermesandroid.relay.viewmodel.ConnectionViewModel import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.resolveChatConnectState import com.hermesandroid.relay.viewmodel.resolveChatConnectState
@@ -196,6 +197,50 @@ class RelayAppStatusTest {
assertEquals(ChatRuntimeStatus.Connecting, status) 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 @Test
fun `dashboard sign-out is not masked by a reachable sibling API`() { fun `dashboard sign-out is not masked by a reachable sibling API`() {
val status = resolveAppChatRuntimeStatus( 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 @Test
fun offlineGatewaySendPublishesRetryableFailureAndKeepsPrompt() { fun offlineGatewaySendPublishesRetryableFailureAndKeepsPrompt() {
DiagnosticsLog.clear() 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) viewModel.updateGatewayClient(null)
gatewayClient.shutdown() gatewayClient.shutdown()
gatewayScope.cancel() gatewayScope.cancel()
@@ -4340,7 +4392,7 @@ class ChatViewModelGatewayInboundTurnTest {
scope = gatewayScope, scope = gatewayScope,
reconnectJitterUnit = { Math.nextDown(1.0) }, reconnectJitterUnit = { Math.nextDown(1.0) },
) )
viewModel.updateGatewayClient(gatewayClient) if (bind) viewModel.updateGatewayClient(gatewayClient)
return gatewayClient return gatewayClient
} }
+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) missing_fields = sorted({"id", "session_key", "status"} - item_strings)
if missing_fields: if missing_fields:
raise ValueError("active-list row missing field(s): " + ", ".join(missing_fields)) raise ValueError("active-list row missing field(s): " + ", ".join(missing_fields))
required_markers = ("_sessions_lock", "_sessions.items()", "_session_live_item(") snapshot_node = handler
missing_markers = [marker for marker in required_markers if marker not in handler_text] 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): if missing_markers or "sessions" not in _string_constants(handler):
raise ValueError( raise ValueError(
"session.active_list no longer snapshots the live registry: " "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(status, "starting, working, waiting, and idle derivation"),
server.evidence(item, "live row carries runtime and durable identities"), server.evidence(item, "live row carries runtime and durable identities"),
methods.evidence( 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) session, error = _sess_nowait(params, rid)
return _live_session_payload(params["session_id"], session) return _live_session_payload(params["session_id"], session)
def _snapshot_sessions(rid):
with _sessions_lock:
return list(_sessions.items()), None
@method("session.active_list") @method("session.active_list")
def _(rid, params): def _(rid, params):
snapshot, error = _snapshot_sessions(rid)
if error:
return error
current = str(params.get("current_session_id") or "") 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] rows = [_session_live_item(sid, session, current) for sid, session in snapshot]
return _ok(rid, {"sessions": rows}) return _ok(rid, {"sessions": rows})
''' '''
@@ -218,6 +218,7 @@ class FixtureTestCase(unittest.IsolatedAsyncioTestCase):
active = (await observer.receive_json())["result"]["sessions"] active = (await observer.receive_json())["result"]["sessions"]
self.assertEqual("working", active[0]["status"]) self.assertEqual("working", active[0]["status"])
self.assertNotIn("profile", active[0]) self.assertNotIn("profile", active[0])
async with self.session.get( async with self.session.get(
f"{base_url}/api/sessions/{fixture.scenario.stored_session_id}/messages", f"{base_url}/api/sessions/{fixture.scenario.stored_session_id}/messages",
params={"profile": "default", "limit": 500, "offset": 0, "order": "asc"}, 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.assertEqual(["session.active_list"], observer_methods)
self.assertNotIn("session.interrupt", 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: async def test_rapid_chunks_tools_and_interims_keep_wire_order(self) -> None:
_, base_url = await self.start("rapid_tools_interims") _, base_url = await self.start("rapid_tools_interims")
ws, _ = await self.connect(base_url) ws, _ = await self.connect(base_url)
@@ -455,6 +469,7 @@ class ScenarioTestCase(unittest.TestCase):
"active_status_lifecycle", "active_status_lifecycle",
"active_status_profile_scope", "active_status_profile_scope",
"active_status_unsupported", "active_status_unsupported",
"cold_start_observation",
"cross_client_observation", "cross_client_observation",
"initial_history_bind", "initial_history_bind",
"ordinary_turn", "ordinary_turn",
@@ -508,6 +523,10 @@ class ScenarioTestCase(unittest.TestCase):
("gateway.settled_session_info",), ("gateway.settled_session_info",),
load_scenario("terminal_gap_session_info").contract_requirements, load_scenario("terminal_gap_session_info").contract_requirements,
) )
self.assertEqual(
("gateway.session_active_list",),
load_scenario("cold_start_observation").contract_requirements,
)
self.assertEqual( self.assertEqual(
("gateway.message_complete", "gateway.session_active_list"), ("gateway.message_complete", "gateway.session_active_list"),
load_scenario("cross_client_observation").contract_requirements, 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": [
[]
]
}
}