Compare 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
|
### 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.
|
||||||
|
|||||||
+71
-5
@@ -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(
|
||||||
|
|||||||
+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
|
@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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user