Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de8f5558c2 | ||
|
|
adcf4ded79 | ||
|
|
05d6ee4d7c | ||
|
|
ae18bbee24 | ||
|
|
105da550e7 | ||
|
|
febc26fe35 | ||
|
|
28a906215d |
@@ -15,6 +15,7 @@ contract here and in `RELEASE.md`.
|
||||
- Contributor setup → **[CONTRIBUTING.md](CONTRIBUTING.md)**
|
||||
- Gateway/session/reconnect testing → **[docs/gateway-contract-testing.md](docs/gateway-contract-testing.md)**
|
||||
- Android local/cloud verification → **[docs/android-build-lane.md](docs/android-build-lane.md)**
|
||||
- Android emulator lanes → **[docs/android-emulator-testing.md](docs/android-emulator-testing.md)** — suggest the smallest relevant API 36 lanes; never run the full matrix automatically
|
||||
- `android_*` toolset + MCP → **[docs/mcp-tooling.md](docs/mcp-tooling.md)**
|
||||
- Follow-ups / deferred work / known gaps → **[TODO.md](TODO.md)** (the single home for "what's next" — never DEVLOG, never scattered code comments)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android keeps completed chat text visible when Dashboard sign-in expires.** Generic and reason-coded history `401` responses settle the local turn, preserve its transcript, and surface the existing sign-in recovery without reading another profile's API history.
|
||||
- **Android Bot Chats render loaded history immediately.** Route-owned chat screens observe their own handler state from first composition, including fast history loads that settle before another frame. (Supersedes #453.)
|
||||
- **Supervised Gateway setup stays parent-owned.** Add Gateway is single-flight and checks live parent authority before allocating a draft, relock/back cancels the exact pending setup, and the locked Chat footer no longer attempts protected navigation.
|
||||
- **Generated images stay visible and use their intended Chat animation.** Completed image media survives a marker-lagging history refresh, and both the built-in `image_generate` tool and profile tools ending in `_create_image` use the image-generation presentation.
|
||||
|
||||
@@ -6,6 +6,19 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
|
||||
|
||||
---
|
||||
|
||||
## Consider hosted Android emulator execution
|
||||
|
||||
The local API 36 Gradle Managed Device lanes are intentionally on demand and
|
||||
individually selected. The current Android On-Demand workflow covers hosted
|
||||
source, unit, lint, and build verification only; it does not run emulators. If
|
||||
local emulator capacity becomes a recurring constraint, evaluate a separately
|
||||
approved hosted-emulator design with explicit cost, concurrency, artifact
|
||||
retention, and trigger policy. Do not schedule the full form-factor matrix or
|
||||
add a device farm until that policy is approved; keep live-server mutation tests
|
||||
outside any automatic matrix.
|
||||
|
||||
---
|
||||
|
||||
## Upstream a public Dashboard plugin WebSocket admission seam
|
||||
|
||||
The same-origin Relay ingress follows current upstream's bundled Dashboard
|
||||
|
||||
@@ -249,6 +249,58 @@ android {
|
||||
it.systemProperty("roborazzi.test.record", "true")
|
||||
it.maxHeapSize = "2g"
|
||||
}
|
||||
|
||||
// On-demand only. Keep each form factor as an individually selected
|
||||
// Gradle-managed device; there is deliberately no aggregate matrix
|
||||
// task or scheduled emulator job. See docs/android-emulator-testing.md.
|
||||
managedDevices {
|
||||
localDevices {
|
||||
create("compactPhoneApi36") {
|
||||
device = "Pixel 2"
|
||||
apiLevel = 36
|
||||
systemImageSource = "aosp"
|
||||
require64Bit = true
|
||||
testedAbi = "x86_64"
|
||||
}
|
||||
create("standardPhoneApi36") {
|
||||
device = "Pixel 6"
|
||||
apiLevel = 36
|
||||
systemImageSource = "aosp"
|
||||
require64Bit = true
|
||||
testedAbi = "x86_64"
|
||||
}
|
||||
create("largePhoneApi36") {
|
||||
device = "Pixel 7 Pro"
|
||||
apiLevel = 36
|
||||
systemImageSource = "aosp"
|
||||
require64Bit = true
|
||||
testedAbi = "x86_64"
|
||||
}
|
||||
create("foldableApi36") {
|
||||
device = "Pixel Fold"
|
||||
apiLevel = 36
|
||||
systemImageSource = "aosp"
|
||||
require64Bit = true
|
||||
testedAbi = "x86_64"
|
||||
}
|
||||
create("tabletApi36") {
|
||||
device = "Pixel Tablet"
|
||||
apiLevel = 36
|
||||
systemImageSource = "aosp"
|
||||
require64Bit = true
|
||||
testedAbi = "x86_64"
|
||||
}
|
||||
create("futureApi37Ps16k") {
|
||||
device = "Pixel 7 Pro"
|
||||
apiLevel = 37
|
||||
systemImageSource = "google_apis_playstore"
|
||||
require64Bit = true
|
||||
testedAbi = "x86_64"
|
||||
pageAlignment =
|
||||
com.android.build.api.dsl.ManagedVirtualDevice.PageAlignment.FORCE_16KB_PAGES
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +442,12 @@ dependencies {
|
||||
// Konsist — enforces the ADR 34 upstream/relay/shared package fence as a JUnit test
|
||||
testImplementation(libs.konsist)
|
||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||
// Compose UI Test still declares Espresso 3.5.0 transitively. API 37
|
||||
// removed the reflected InputManager.getInstance() seam; Espresso 3.7.0
|
||||
// uses Context.getSystemService and is the current stable AndroidX line.
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
|
||||
androidTestImplementation("androidx.test:runner:1.7.0")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.3.0")
|
||||
// On-device vanilla-Gateway contract tests exercise the production
|
||||
// Dashboard ticket + WebSocket stack over real loopback sockets.
|
||||
androidTestImplementation(libs.okhttp.mockwebserver)
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
|
||||
+201
-7
@@ -4,6 +4,7 @@ import android.os.Handler
|
||||
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
|
||||
@@ -17,6 +18,11 @@ 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.network.upstream.ChatHandler
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
@@ -27,6 +33,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
@@ -44,6 +51,7 @@ import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
@@ -74,10 +82,11 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
|
||||
@Volatile
|
||||
private var persistedHistory: List<MessageItem> = emptyList()
|
||||
private val historySignInRequired = MutableStateFlow(false)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
fixture = AndroidGatewayContractFixture()
|
||||
fixture = AndroidGatewayContractFixture().also { it.profileName = PROFILE_NAME }
|
||||
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val okHttp = OkHttpClient()
|
||||
gatewayClient = GatewayChatClient(
|
||||
@@ -97,6 +106,7 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
handler,
|
||||
)
|
||||
it.streamingEndpoint = "gateway"
|
||||
it.setSessionProfileNameProvider { PROFILE_NAME }
|
||||
it.setProfileMessageLoader { Result.success(persistedHistory) }
|
||||
it.updateGatewayClient(gatewayClient)
|
||||
it.setChatVisible(true)
|
||||
@@ -105,6 +115,7 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
compose.setContent {
|
||||
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||
val streaming by viewModel.isStreaming.collectAsStateWithLifecycle()
|
||||
val signInRequired by historySignInRequired.collectAsStateWithLifecycle()
|
||||
MaterialTheme {
|
||||
Column(Modifier.testTag("contract-transcript")) {
|
||||
Text(
|
||||
@@ -117,6 +128,14 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
modifier = Modifier.testTag("message-${message.id}"),
|
||||
)
|
||||
}
|
||||
if (signInRequired) {
|
||||
Button(
|
||||
onClick = {},
|
||||
modifier = Modifier.testTag("dashboard-sign-in-recovery"),
|
||||
) {
|
||||
Text("SIGN IN")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,9 +271,6 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
"session.interrupt",
|
||||
"prompt.submit",
|
||||
)
|
||||
val baseline = controlMethods.associateWith(fixture::rpcCount)
|
||||
val baselineActiveList = fixture.rpcCount("session.active_list")
|
||||
fixture.activeSessionStatus = "working"
|
||||
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val okHttp = OkHttpClient()
|
||||
gatewayClient = GatewayChatClient(
|
||||
@@ -269,6 +285,17 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
)
|
||||
viewModel.setChatTurnCheckpointStore(null)
|
||||
viewModel.updateGatewayClient(gatewayClient)
|
||||
assertTrue(runBlocking { gatewayClient.observeAwait() })
|
||||
serverSocket = fixture.awaitServerSocket()
|
||||
viewModel.switchProfileContext(
|
||||
AgentDisplay.profileContextKey("fixture-connection", PROFILE_NAME),
|
||||
STORED_SESSION_ID,
|
||||
)
|
||||
viewModel.updateSessionActivityDirectory(listOf(PROFILE_NAME to STORED_SESSION_ID))
|
||||
|
||||
val baseline = controlMethods.associateWith(fixture::rpcCount)
|
||||
val baselineActiveList = fixture.rpcCount("session.active_list")
|
||||
fixture.activeSessionStatus = "working"
|
||||
|
||||
viewModel.setChatVisible(true)
|
||||
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
|
||||
@@ -293,6 +320,141 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normalCompletion_genericHistory401RetainsTranscriptAndRequiresProfileSignIn() {
|
||||
bindDashboardHistoryFailure(
|
||||
body = "Unauthorized",
|
||||
profileName = PROFILE_NAME,
|
||||
)
|
||||
|
||||
viewModel.sendMessage("Keep this local transcript")
|
||||
fixture.awaitRpc("prompt.submit")
|
||||
serverSocket.send(fixture.event("message.start", null, LIVE_SESSION_ID))
|
||||
serverSocket.send(
|
||||
fixture.event(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", LOCAL_COMPLETION) },
|
||||
LIVE_SESSION_ID,
|
||||
),
|
||||
)
|
||||
serverSocket.send(
|
||||
fixture.event(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", LOCAL_COMPLETION) },
|
||||
LIVE_SESSION_ID,
|
||||
),
|
||||
)
|
||||
|
||||
compose.waitUntil(15_000) {
|
||||
historySignInRequired.value &&
|
||||
!handler.isStreaming.value &&
|
||||
handler.messages.value.any { it.content == LOCAL_COMPLETION }
|
||||
}
|
||||
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
|
||||
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
|
||||
|
||||
compose.onNodeWithTag("contract-transcript").assertIsDisplayed()
|
||||
compose.onNodeWithTag("stream-state").assertTextEquals("IDLE")
|
||||
compose.onNodeWithTag("dashboard-sign-in-recovery").assertIsDisplayed()
|
||||
assertFalse(viewModel.isLoadingHistory.value)
|
||||
assertTrue(handler.messages.value.any { it.content == "Keep this local transcript" })
|
||||
assertTrue(handler.messages.value.any { it.content == LOCAL_COMPLETION })
|
||||
assertNull(viewModel.chatFailure.value)
|
||||
assertExactProfileHistoryOnly(PROFILE_NAME)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveredCompletion_sessionExpiredHistoryRetainsSettledTranscript() {
|
||||
bindDashboardHistoryFailure(
|
||||
body = """{"reason":"session_expired"}""",
|
||||
profileName = PROFILE_NAME,
|
||||
)
|
||||
val now = System.currentTimeMillis()
|
||||
val contextKey = AgentDisplay.profileContextKey("fixture-connection", PROFILE_NAME)
|
||||
viewModel.setChatTurnCheckpointStore(
|
||||
MemoryCheckpointStore(
|
||||
ChatTurnCheckpoint(
|
||||
contextKey = contextKey,
|
||||
profileKey = PROFILE_NAME,
|
||||
sessionId = STORED_SESSION_ID,
|
||||
liveSessionId = LIVE_SESSION_ID,
|
||||
transport = "gateway",
|
||||
user = ChatTurnUserCheckpoint("recovered-user", "Resume this turn", now - 2_000L),
|
||||
assistant = ChatTurnAssistantCheckpoint(
|
||||
id = "recovered-assistant",
|
||||
content = "Recovered partial",
|
||||
timestamp = now - 1_900L,
|
||||
),
|
||||
priorUserMessageCount = 0,
|
||||
baselineAssistantCount = 0,
|
||||
startedAt = now - 2_000L,
|
||||
updatedAt = now,
|
||||
),
|
||||
),
|
||||
)
|
||||
fixture.recoveryRunning = true
|
||||
handler.setSessionId(null)
|
||||
viewModel.switchProfileContext(contextKey, STORED_SESSION_ID)
|
||||
fixture.awaitRpc("session.activate")
|
||||
|
||||
serverSocket.send(
|
||||
fixture.event(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", RECOVERED_COMPLETION) },
|
||||
LIVE_SESSION_ID,
|
||||
),
|
||||
)
|
||||
serverSocket.send(
|
||||
fixture.event(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", RECOVERED_COMPLETION) },
|
||||
LIVE_SESSION_ID,
|
||||
),
|
||||
)
|
||||
|
||||
compose.waitUntil(15_000) {
|
||||
historySignInRequired.value && !handler.isStreaming.value
|
||||
}
|
||||
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
|
||||
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
|
||||
|
||||
compose.onNodeWithTag("contract-transcript").assertIsDisplayed()
|
||||
compose.onNodeWithTag("stream-state").assertTextEquals("IDLE")
|
||||
compose.onNodeWithTag("dashboard-sign-in-recovery").assertIsDisplayed()
|
||||
assertFalse(viewModel.isLoadingHistory.value)
|
||||
assertTrue(
|
||||
"recovered completion was not retained: ${handler.messages.value}",
|
||||
handler.messages.value.any { it.content.contains(RECOVERED_COMPLETION.trim()) },
|
||||
)
|
||||
assertFalse(handler.messages.value.any { it.isStreaming || it.isThinkingStreaming })
|
||||
assertNull(viewModel.chatFailure.value)
|
||||
assertExactProfileHistoryOnly(PROFILE_NAME)
|
||||
}
|
||||
|
||||
private fun bindDashboardHistoryFailure(body: String, profileName: String) {
|
||||
fixture.profileName = profileName
|
||||
fixture.historyFailureBody = body
|
||||
val dashboard = DashboardApiClient(
|
||||
baseUrl = fixture.server.url("/").toString().trimEnd('/'),
|
||||
okHttpClient = OkHttpClient(),
|
||||
)
|
||||
viewModel.setProfileMessageLoaderWithMode { profile, sessionId, mode ->
|
||||
dashboard.getSessionMessages(sessionId, profile, mode)
|
||||
}
|
||||
viewModel.setDashboardSignInRequiredHandler {
|
||||
historySignInRequired.value = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertExactProfileHistoryOnly(profileName: String) {
|
||||
val historyRequests = fixture.historyRequestPaths()
|
||||
assertTrue("no Dashboard history request was observed", historyRequests.isNotEmpty())
|
||||
assertTrue(
|
||||
"history escaped the exact profile: $historyRequests",
|
||||
historyRequests.all { it.contains("profile=$profileName") },
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val STORED_SESSION_ID = "20260821_120000_fixture"
|
||||
const val LIVE_SESSION_ID = "fixture-live-1"
|
||||
@@ -301,6 +463,23 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
const val PARTIAL_ANSWER = "Partial foreground answer"
|
||||
const val AUTHORITATIVE_ANSWER = "Foreground task finished."
|
||||
const val FOREIGN_ANSWER = "Wrong session content"
|
||||
const val PROFILE_NAME = "research"
|
||||
const val LOCAL_COMPLETION = "Completed before Dashboard auth expired."
|
||||
const val RECOVERED_COMPLETION = " and then recovered to completion."
|
||||
}
|
||||
}
|
||||
|
||||
private class MemoryCheckpointStore(
|
||||
private var checkpoint: ChatTurnCheckpoint?,
|
||||
) : ChatTurnCheckpointStore {
|
||||
override suspend fun read(): ChatTurnCheckpoint? = checkpoint
|
||||
|
||||
override suspend fun write(checkpoint: ChatTurnCheckpoint) {
|
||||
this.checkpoint = checkpoint
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
checkpoint = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,6 +499,12 @@ internal class AndroidGatewayContractFixture {
|
||||
@Volatile
|
||||
var activeSessionStatus: String? = null
|
||||
|
||||
@Volatile
|
||||
var historyFailureBody: String? = null
|
||||
|
||||
@Volatile
|
||||
var profileName: String = "default"
|
||||
|
||||
private val listener = object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
sockets.add(webSocket)
|
||||
@@ -377,6 +562,11 @@ internal class AndroidGatewayContractFixture {
|
||||
"""{"ticket":"device-${ticketCount.incrementAndGet()}","ttl_seconds":30}""",
|
||||
)
|
||||
path.startsWith("/api/ws") -> MockResponse().withWebSocketUpgrade(listener)
|
||||
path.startsWith("/api/sessions/") && path.contains("/messages") &&
|
||||
historyFailureBody != null -> MockResponse()
|
||||
.setResponseCode(401)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(historyFailureBody.orEmpty())
|
||||
else -> MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
@@ -388,7 +578,7 @@ internal class AndroidGatewayContractFixture {
|
||||
put("session_id", sessionId)
|
||||
put("running", recoveryRunning)
|
||||
put("status", if (recoveryRunning) "streaming" else "idle")
|
||||
put("info", buildJsonObject { put("profile_name", "default") })
|
||||
put("info", buildJsonObject { put("profile_name", profileName) })
|
||||
}
|
||||
|
||||
fun event(type: String, payload: JsonObject?, sessionId: String?): String =
|
||||
@@ -406,7 +596,7 @@ internal class AndroidGatewayContractFixture {
|
||||
sockets.poll(5, TimeUnit.SECONDS) ?: error("Gateway WebSocket did not open")
|
||||
|
||||
fun awaitRpc(method: String): JsonObject {
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15)
|
||||
while (System.nanoTime() < deadline) {
|
||||
rpcLog.firstOrNull { it.first == method }?.let { return it.second }
|
||||
Thread.sleep(20)
|
||||
@@ -415,7 +605,7 @@ internal class AndroidGatewayContractFixture {
|
||||
}
|
||||
|
||||
fun awaitRpcCount(method: String, count: Int) {
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15)
|
||||
while (System.nanoTime() < deadline) {
|
||||
if (rpcCount(method) >= count) return
|
||||
Thread.sleep(20)
|
||||
@@ -425,6 +615,10 @@ internal class AndroidGatewayContractFixture {
|
||||
|
||||
fun requestsTo(path: String): Int = requestPaths.count { it.startsWith(path) }
|
||||
|
||||
fun historyRequestPaths(): List<String> = requestPaths.filter {
|
||||
it.startsWith("/api/sessions/") && it.contains("/messages")
|
||||
}
|
||||
|
||||
fun rpcCount(method: String): Int = rpcLog.count { it.first == method }
|
||||
|
||||
fun shutdown() {
|
||||
|
||||
@@ -2436,6 +2436,10 @@ internal fun Throwable.isDashboardSignInRequiredFailure(): Boolean {
|
||||
java.util.IdentityHashMap<Throwable, Boolean>(),
|
||||
)
|
||||
while (current != null && seen.add(current)) {
|
||||
// Every 401 from an authenticated Dashboard route means the saved
|
||||
// browser/native session can no longer authorize this request. Older
|
||||
// gateways used `no_cookie`/`unauthenticated`; current builds may return
|
||||
// reason codes such as `session_expired`, or no structured body at all.
|
||||
if (current is DashboardHttpException && current.statusCode == 401) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -3184,6 +3184,7 @@ class ChatViewModel : ViewModel() {
|
||||
) {
|
||||
val handler = chatHandler ?: return
|
||||
if (chatHandler !== handler || handler.currentSessionId.value != storedSessionId) return
|
||||
val contextKey = activeProfileContextKey
|
||||
gatewayHistoryReconcileJob?.cancel()
|
||||
gatewayHistoryReconcileJob = viewModelScope.launch {
|
||||
val expected = expectedAssistantText?.trim()?.takeIf { it.isNotEmpty() }
|
||||
@@ -3216,7 +3217,28 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
val transcriptSnapshot = handler.messages.value
|
||||
val serverMessages = loadGatewaySessionHistory(storedSessionId)
|
||||
val serverMessages = try {
|
||||
loadGatewaySessionHistory(
|
||||
sessionId = storedSessionId,
|
||||
requireProfileScope = true,
|
||||
)
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// A live completion is already visible and settled locally.
|
||||
// History auth loss must retain that transcript and promote
|
||||
// the existing sign-in recovery instead of escaping this
|
||||
// Main-scope coroutine and crashing the app.
|
||||
if (
|
||||
chatHandler === handler &&
|
||||
activeProfileContextKey == contextKey &&
|
||||
handler.currentSessionId.value == storedSessionId
|
||||
) {
|
||||
publishHistoryLoadFailure(storedSessionId, e)
|
||||
}
|
||||
gatewayHistoryReconcileJob = null
|
||||
return@launch
|
||||
}
|
||||
if (chatHandler !== handler || handler.currentSessionId.value != storedSessionId) {
|
||||
return@launch
|
||||
}
|
||||
@@ -5401,6 +5423,7 @@ class ChatViewModel : ViewModel() {
|
||||
// recovery state. Preserve cached history and
|
||||
// mark the directory unavailable without also
|
||||
// emitting a generic turn/error toast.
|
||||
dashboardSignInRequiredHandler?.invoke()
|
||||
} else if (scoped != null) {
|
||||
// The shared API list belongs to the launch/default
|
||||
// database. Preserve the current profile's rows and
|
||||
@@ -5423,7 +5446,9 @@ class ChatViewModel : ViewModel() {
|
||||
)
|
||||
retryUnavailable = true
|
||||
retryReadiness = retryReadiness || !e.isSessionReadTimeout()
|
||||
if (!e.isDashboardSignInRequiredFailure()) {
|
||||
if (e.isDashboardSignInRequiredFailure()) {
|
||||
dashboardSignInRequiredHandler?.invoke()
|
||||
} else {
|
||||
emitError(
|
||||
e,
|
||||
context = if (profileSessionLister != null) {
|
||||
@@ -7854,11 +7879,22 @@ class ChatViewModel : ViewModel() {
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (handler.currentSessionId.value == expectedSessionId) {
|
||||
// Recovery completion has already settled the
|
||||
// local turn. Keep it visible and route an
|
||||
// expired Dashboard session to sign-in.
|
||||
if (
|
||||
chatHandler === handler &&
|
||||
activeProfileContextKey == checkpoint.contextKey &&
|
||||
handler.currentSessionId.value == expectedSessionId
|
||||
) {
|
||||
publishHistoryLoadFailure(expectedSessionId, e)
|
||||
}
|
||||
} finally {
|
||||
if (handler.currentSessionId.value == expectedSessionId) {
|
||||
if (
|
||||
chatHandler === handler &&
|
||||
activeProfileContextKey == checkpoint.contextKey &&
|
||||
handler.currentSessionId.value == expectedSessionId
|
||||
) {
|
||||
refreshSessions()
|
||||
scheduleTitleReconcile(expectedSessionId)
|
||||
}
|
||||
@@ -9727,6 +9763,7 @@ class ChatViewModel : ViewModel() {
|
||||
// tool.complete. The structured reload recovers those calls without ever
|
||||
// parsing assistant prose and retains the profile-aware history boundary.
|
||||
val sid = handler.currentSessionId.value
|
||||
val historyContextKey = activeProfileContextKey
|
||||
// A turn that ended in an error (gateway ❌ lifecycle → "Error" badge)
|
||||
// has NO assistant message persisted server-side, so reconciling the
|
||||
// server transcript would WIPE the just-shown error bubble (the user
|
||||
@@ -9769,7 +9806,11 @@ class ChatViewModel : ViewModel() {
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (handler.currentSessionId.value == sid) {
|
||||
if (
|
||||
chatHandler === handler &&
|
||||
activeProfileContextKey == historyContextKey &&
|
||||
handler.currentSessionId.value == sid
|
||||
) {
|
||||
publishHistoryLoadFailure(sid, e)
|
||||
}
|
||||
} finally {
|
||||
@@ -9779,8 +9820,14 @@ class ChatViewModel : ViewModel() {
|
||||
// is persisted, so a brand-new chat would otherwise stay missing
|
||||
// from the drawer (carried only by the optimistic row) until a
|
||||
// manual reload. By message.complete the dashboard list includes it.
|
||||
refreshSessions()
|
||||
scheduleTitleReconcile(sid)
|
||||
if (
|
||||
chatHandler === handler &&
|
||||
activeProfileContextKey == historyContextKey &&
|
||||
handler.currentSessionId.value == sid
|
||||
) {
|
||||
refreshSessions()
|
||||
scheduleTitleReconcile(sid)
|
||||
}
|
||||
drainQueue()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# Android emulator testing
|
||||
|
||||
Hermes-Relay Android uses individually selected Gradle Managed Devices for
|
||||
repeatable, on-demand instrumentation. The routine virtual baseline is API 36.
|
||||
There is deliberately no aggregate matrix task and no scheduled emulator job:
|
||||
choose the smallest lane that can prove the behavior under review.
|
||||
|
||||
## Lanes
|
||||
|
||||
| Evidence lane | Gradle device | Hardware profile | Use it for |
|
||||
|---|---|---|---|
|
||||
| Real Device | None | Explicitly selected physical hardware | Firmware, radio, audio, camera, biometrics, background limits, accessibility, and release-candidate claims |
|
||||
| Compact Phone | `compactPhoneApi36` | Pixel 2 | Narrow phone layouts, compact height, keyboard pressure |
|
||||
| Standard Phone | `standardPhoneApi36` | Pixel 6 | Default functional and regression instrumentation |
|
||||
| Large Phone | `largePhoneApi36` | Pixel 7 Pro | Large handset layout and reachability |
|
||||
| Foldable | `foldableApi36` | Pixel Fold | Fold/unfold, posture, continuity, and width-class changes |
|
||||
| Tablet | `tabletApi36` | Pixel Tablet | Expanded layout, panes, and large-window behavior |
|
||||
| Future platform / native canary | `futureApi37Ps16k` | Pixel 7 Pro, API 37, forced 16 KB pages | On-demand platform and native-library compatibility only |
|
||||
|
||||
Routine API 36 lanes use the AOSP x86_64 image so deterministic app tests do not
|
||||
spend host capacity on unrelated Google-service startup. The API 37/16 KB device
|
||||
is not a screen-size lane and is not part of routine testing. Gradle Managed
|
||||
Devices may download a missing image on first use; that setup can be large and
|
||||
slow.
|
||||
|
||||
## Commands
|
||||
|
||||
List the registered tasks:
|
||||
|
||||
```powershell
|
||||
.\scripts\android-lane.ps1 gradle :app:tasks --all |
|
||||
Select-String 'Api36|Ps16k'
|
||||
```
|
||||
|
||||
Compile the app and instrumentation APK without starting an emulator:
|
||||
|
||||
```powershell
|
||||
.\scripts\android-lane.ps1 gradle `
|
||||
:app:assembleSideloadDebug `
|
||||
:app:assembleSideloadDebugAndroidTest
|
||||
```
|
||||
|
||||
Run one complete lane, normally Standard Phone first:
|
||||
|
||||
```powershell
|
||||
.\scripts\android-lane.ps1 gradle `
|
||||
:app:standardPhoneApi36SideloadDebugAndroidTest
|
||||
```
|
||||
|
||||
Run one test class on one lane:
|
||||
|
||||
```powershell
|
||||
.\scripts\android-lane.ps1 gradle `
|
||||
:app:standardPhoneApi36SideloadDebugAndroidTest `
|
||||
'-Pandroid.testInstrumentationRunnerArguments.class=com.hermesandroid.relay.viewmodel.GatewayForegroundRecoveryInstrumentedTest'
|
||||
```
|
||||
|
||||
Run the other virtual lanes only when their form factor is relevant:
|
||||
|
||||
```powershell
|
||||
.\scripts\android-lane.ps1 gradle :app:compactPhoneApi36SideloadDebugAndroidTest
|
||||
.\scripts\android-lane.ps1 gradle :app:largePhoneApi36SideloadDebugAndroidTest
|
||||
.\scripts\android-lane.ps1 gradle :app:foldableApi36SideloadDebugAndroidTest
|
||||
.\scripts\android-lane.ps1 gradle :app:tabletApi36SideloadDebugAndroidTest
|
||||
```
|
||||
|
||||
Run the future-platform/native canary explicitly:
|
||||
|
||||
```powershell
|
||||
.\scripts\android-lane.ps1 gradle `
|
||||
:app:futureApi37Ps16kSideloadDebugAndroidTest
|
||||
```
|
||||
|
||||
All Windows commands use the repository's machine-wide build lane; see
|
||||
[`docs/android-build-lane.md`](android-build-lane.md). Check the lane without
|
||||
starting work with:
|
||||
|
||||
```powershell
|
||||
.\scripts\android-lane.ps1 status
|
||||
```
|
||||
|
||||
Do not invoke every device task as one command. Run lanes serially, record each
|
||||
result, and stop when the relevant evidence is complete or the host reaches a
|
||||
capacity limit.
|
||||
|
||||
## Configuration coverage
|
||||
|
||||
Form factor is only one axis. Select additional states according to the change:
|
||||
|
||||
- Test dark mode first; also cover light mode when colors, contrast, system bars,
|
||||
or theme persistence changed.
|
||||
- Cover portrait and landscape when layout, keyboard, media, drawers, or panes
|
||||
changed. Foldable work must include a posture or width-class transition.
|
||||
- Check default font scale and at least one enlarged scale for text-heavy or
|
||||
accessibility-sensitive UI.
|
||||
- Use the default locale for functional regressions; add a long-string locale
|
||||
and an RTL locale when copy, formatting, or layout direction changed.
|
||||
- Record gesture versus three-button navigation when bottom insets, edge-to-edge,
|
||||
back handling, sheets, or overlays changed.
|
||||
|
||||
These dimensions are selected test conditions, not permanent duplicated device
|
||||
definitions. Record any non-default setting in the evidence.
|
||||
|
||||
## Deterministic fixtures and live servers
|
||||
|
||||
Embedded MockWebServer tests own deterministic transport regressions. They use
|
||||
production clients and view models against loopback HTTP/WebSocket boundaries,
|
||||
require no credentials, mutate no real sessions, and are the correct lane for
|
||||
authentication loss, reconnect gaps, malformed frames, profile isolation, and
|
||||
repeatable lifecycle assertions.
|
||||
|
||||
Live-server testing is separate and on demand. Use a disposable test or staging
|
||||
Hermes server with disposable profiles and sessions. Normally run only the
|
||||
Standard Phone emulator plus one explicitly selected real device when physical
|
||||
evidence is required. Never multiply live mutation testing across the full size
|
||||
matrix, use a production server, or use personal conversation data. Sanitize
|
||||
logs and exports before attaching them to a pull request.
|
||||
|
||||
## Evidence
|
||||
|
||||
For each executed lane, record:
|
||||
|
||||
```text
|
||||
Commit: <exact SHA>
|
||||
Artifact/variant: sideloadDebug app + androidTest
|
||||
Lane: Standard Phone (standardPhoneApi36), API 36
|
||||
Test selection: <class or package>
|
||||
Configuration: dark/light, orientation/posture, font scale, locale, navigation
|
||||
Result: pass/fail/blocked, test count, report path
|
||||
Notes: retries, emulator/image limitation, relevant sanitized observation
|
||||
```
|
||||
|
||||
Keep claims lane-specific. Emulator proof is not physical-device proof. A
|
||||
passing API 37/16 KB canary proves only that selected platform/native lane; it
|
||||
does not replace API 36 form-factor coverage or physical firmware evidence.
|
||||
Reference in New Issue
Block a user