Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44bbb16cd3 | ||
|
|
1f5b7e68fc | ||
|
|
9814cdca55 | ||
|
|
273e3f5aff |
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### 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)
|
||||
- **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.
|
||||
|
||||
+71
-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,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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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": [
|
||||
[]
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user