Merge pull request #513 from Codename-11/fix/android-dashboard-history-auth-recovery

fix(android): recover Dashboard history auth failures
This commit is contained in:
Bailey Dixon
2026-08-31 15:59:09 -04:00
committed by GitHub
5 changed files with 229 additions and 42 deletions
@@ -2436,14 +2436,7 @@ internal fun Throwable.isDashboardSignInRequiredFailure(): Boolean {
java.util.IdentityHashMap<Throwable, Boolean>(),
)
while (current != null && seen.add(current)) {
if (
current is DashboardHttpException &&
current.statusCode == 401 &&
(
current.message.orEmpty().contains("no_cookie", ignoreCase = true) ||
current.message.orEmpty().contains("unauthenticated", ignoreCase = true)
)
) {
if (current is DashboardHttpException && current.statusCode == 401) {
return true
}
current = current.cause
@@ -209,6 +209,7 @@ internal class HermesRuntimeBinder(
chat.setProfileMessageLoaderWithMode { profileName, sessionId, mode ->
connection.loadProfileScopedMessages(profileName, sessionId, mode)
}
chat.setDashboardSignInRequiredHandler(connection::probeNow)
chat.setDashboardConfigLoader { connection.loadActiveDashboardConfig() }
chat.profileSessionDeleter = connection::deleteSession
chat.profileSessionRenamer = connection::renameSession
@@ -3849,6 +3849,12 @@ class ChatViewModel : ViewModel() {
profileSessionPageLister = lister
}
private var dashboardSignInRequiredHandler: (() -> Unit)? = null
fun setDashboardSignInRequiredHandler(handler: () -> Unit) {
dashboardSignInRequiredHandler = handler
}
/**
* Deletes a session scoped to the active profile on gateway connections
* (dashboard `DELETE /api/sessions/{id}?profile=`). The write twin of
@@ -4237,7 +4243,19 @@ class ChatViewModel : ViewModel() {
}
private fun publishHistoryLoadFailure(sessionId: String, error: Throwable) {
if (error.isDashboardSignInRequiredFailure()) return
if (error.isDashboardSignInRequiredFailure()) {
dashboardSignInRequiredHandler?.invoke()
DiagnosticsLog.record(
category = DiagnosticCategory.Auth,
severity = DiagnosticSeverity.Warning,
title = "Dashboard sign-in required for chat history",
detail = "stored_session=$sessionId; dashboard_auth=required",
operation = "load chat history",
endpointRole = "gateway",
suggestion = "Sign in to Dashboard on the active route, then retry this conversation.",
)
return
}
val rawError = error.message?.takeIf { it.isNotBlank() }
?: "The active profile's conversation history could not be reached."
_chatFailure.value = ChatFailureNotice(
@@ -7824,13 +7842,28 @@ class ChatViewModel : ViewModel() {
if (!queuedSuccessorPending.get()) {
val expectedSessionId = checkpoint.sessionId
viewModelScope.launch {
val history = loadSessionHistory(expectedSessionId)
if (handler.currentSessionId.value == expectedSessionId && history.isNotEmpty()) {
handler.loadMessageHistory(history)
refreshSessions()
scheduleTitleReconcile(expectedSessionId)
try {
val history = loadSessionHistory(expectedSessionId)
if (
handler.currentSessionId.value == expectedSessionId &&
history.isNotEmpty()
) {
handler.loadMessageHistory(history)
clearMatchingHistoryLoadFailure(expectedSessionId)
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
if (handler.currentSessionId.value == expectedSessionId) {
publishHistoryLoadFailure(expectedSessionId, e)
}
} finally {
if (handler.currentSessionId.value == expectedSessionId) {
refreshSessions()
scheduleTitleReconcile(expectedSessionId)
}
drainQueue()
}
drainQueue()
}
}
}
@@ -9712,34 +9745,44 @@ class ChatViewModel : ViewModel() {
)
}
viewModelScope.launch {
if (!turnErrored && !gatewayHistoryReconcileRequired) {
// Profile-aware read: a gateway turn on a non-default profile
// persists into THAT profile's own state.db, so the bare
// api_server `/api/sessions/{id}/messages` 404s → emptyList()
// → a silent wipe of the just-finished turn. loadSessionHistory
// prefers the `?profile=` dashboard loader on gateway connections.
val serverMessages = loadSessionHistory(sid)
val missingPersistedToolActivity =
completedTransport == "gateway" &&
handler.hasMissingPersistedToolActivity(serverMessages)
if (shouldReloadHistoryAfterSuccessfulTurn(
actualTransport = completedTransport,
gatewayReconcileRequired = gatewayHistoryReconcileRequired,
missingPersistedToolActivity = missingPersistedToolActivity,
)
) {
handler.loadMessageHistory(serverMessages)
try {
if (!turnErrored && !gatewayHistoryReconcileRequired) {
// Profile-aware read: a gateway turn on a non-default profile
// persists into THAT profile's own state.db, so the bare
// api_server `/api/sessions/{id}/messages` 404s → emptyList()
// → a silent wipe of the just-finished turn. loadSessionHistory
// prefers the `?profile=` dashboard loader on gateway connections.
val serverMessages = loadSessionHistory(sid)
val missingPersistedToolActivity =
completedTransport == "gateway" &&
handler.hasMissingPersistedToolActivity(serverMessages)
if (shouldReloadHistoryAfterSuccessfulTurn(
actualTransport = completedTransport,
gatewayReconcileRequired = gatewayHistoryReconcileRequired,
missingPersistedToolActivity = missingPersistedToolActivity,
)
) {
handler.loadMessageHistory(serverMessages)
clearMatchingHistoryLoadFailure(sid)
}
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
if (handler.currentSessionId.value == sid) {
publishHistoryLoadFailure(sid, e)
}
} finally {
// Re-sync the drawer now that the turn is persisted server-side.
// The only other auto-refresh fires ~160ms after session creation
// (RelayApp) — mid-stream, BEFORE the new session's first message
// 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)
drainQueue()
}
// Re-sync the drawer now that the turn is persisted server-side.
// The only other auto-refresh fires ~160ms after session creation
// (RelayApp) — mid-stream, BEFORE the new session's first message
// 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)
drainQueue()
}
Unit
} else {
@@ -562,17 +562,24 @@ class DashboardApiClientTest {
}
@Test
fun signInRequiredClassifierAcceptsNoCookieButRejectsForbidden() {
fun signInRequiredClassifierAcceptsEveryUnauthorizedShapeButRejectsForbidden() {
val noCookie = DashboardHttpException(
401,
"Session failed - HTTP 401: {\"reason\":\"no_cookie\",\"detail\":\"Unauthorized\"}",
)
val expired = DashboardHttpException(
401,
"Session failed - HTTP 401: {\"reason\":\"session_expired\",\"detail\":\"invalid_or_expired_session\"}",
)
val generic = DashboardHttpException(401, "Session failed - HTTP 401: Unauthorized")
val forbidden = DashboardHttpException(
403,
"Session failed - HTTP 403: forbidden",
)
assertTrue(noCookie.isDashboardSignInRequiredFailure())
assertTrue(expired.isDashboardSignInRequiredFailure())
assertTrue(generic.isDashboardSignInRequiredFailure())
assertFalse(forbidden.isDashboardSignInRequiredFailure())
}
@@ -238,6 +238,63 @@ class ChatViewModelGatewayInboundTurnTest {
assertEquals("gateway", diagnostic.endpointRole)
}
@Test
fun normalCompletionUnauthorizedHistoryPreservesTranscriptAndRunsCleanup() {
DiagnosticsLog.clear()
apiMessageRequestCount.set(0)
val owner = Profile(name = "owner", model = "model-a", description = "Owner")
val requestedProfiles = mutableListOf<String?>()
val sessionRefreshes = AtomicInteger(0)
val signInRequests = AtomicInteger(0)
viewModel.setSelectedProfileProvider { owner }
viewModel.setSessionProfileNameProvider { owner.name }
viewModel.setProfileMessageLoaderWithMode { profileName, sessionId, _ ->
requestedProfiles += profileName
assertEquals(STORED_SESSION_ID, sessionId)
Result.failure(
DashboardHttpException(401, "Session failed - HTTP 401: Unauthorized"),
)
}
viewModel.setProfileSessionLister { profileName ->
assertEquals(owner.name, profileName)
sessionRefreshes.incrementAndGet()
Result.success(emptyList())
}
viewModel.setDashboardSignInRequiredHandler { signInRequests.incrementAndGet() }
viewModel.sendMessage("Keep this local prompt")
gatewayHarness.awaitRpc("prompt.submit")
serverWs.send(
gatewayHarness.eventFrame(
"message.delta",
buildJsonObject { put("text", "Keep this local answer") },
"live-resumed",
),
)
serverWs.send(
gatewayHarness.eventFrame(
"message.complete",
buildJsonObject { put("text", "Keep this local answer") },
"live-resumed",
),
)
awaitCondition {
!handler.isStreaming.value &&
signInRequests.get() == 1 &&
sessionRefreshes.get() >= 1
}
assertTrue(handler.messages.value.any { it.content == "Keep this local prompt" })
assertTrue(handler.messages.value.any { it.content == "Keep this local answer" })
assertEquals(listOf(owner.name), requestedProfiles)
assertEquals(0, apiMessageRequestCount.get())
assertFalse(viewModel.steerableTurn.value)
assertNull(viewModel.chatFailure.value)
val diagnostic = DiagnosticsLog.recent(setOf(DiagnosticCategory.Auth), 1).single()
assertEquals("Dashboard sign-in required for chat history", diagnostic.title)
assertTrue(diagnostic.suggestion.orEmpty().contains("Sign in to Dashboard"))
}
@Test
fun missingRequiredProfileHistoryLoaderFailsClosedWithoutApiRead() {
DiagnosticsLog.clear()
@@ -2746,6 +2803,92 @@ class ChatViewModelGatewayInboundTurnTest {
)
}
@Test
fun recoveredCompletionUnauthorizedHistoryPreservesTranscriptAndRunsCleanup() {
DiagnosticsLog.clear()
apiMessageRequestCount.set(0)
val checkpointStore = MemoryCheckpointStore(
ChatTurnCheckpoint(
contextKey = PROFILE_CONTEXT,
sessionId = STORED_SESSION_ID,
liveSessionId = "live-resumed",
transport = "gateway",
user = ChatTurnUserCheckpoint("prior-user", "Recovered prompt", 1L),
assistant = ChatTurnAssistantCheckpoint(
id = "prior-assistant",
content = "Recovered partial",
timestamp = 2L,
),
priorUserMessageCount = 0,
baselineAssistantCount = 0,
startedAt = 2L,
updatedAt = System.currentTimeMillis(),
),
)
val requestedProfiles = mutableListOf<String?>()
val sessionRefreshes = AtomicInteger(0)
val signInRequests = AtomicInteger(0)
var failHistory = false
viewModel.setProfileMessageLoaderWithMode { profileName, sessionId, _ ->
requestedProfiles += profileName
assertEquals(STORED_SESSION_ID, sessionId)
if (failHistory) {
Result.failure(
DashboardHttpException(
401,
"Session failed - HTTP 401: {\"reason\":\"session_expired\"}",
),
)
} else {
Result.success(emptyList())
}
}
viewModel.setProfileSessionLister { profileName ->
assertNull(profileName)
sessionRefreshes.incrementAndGet()
Result.success(emptyList())
}
viewModel.setDashboardSignInRequiredHandler { signInRequests.incrementAndGet() }
gatewayHarness.recoveryRunning = true
gatewayHarness.recoveryAssistant = "Recovered partial"
viewModel.setChatTurnCheckpointStore(checkpointStore)
handler.setSessionId(null)
viewModel.switchProfileContext(PROFILE_CONTEXT, STORED_SESSION_ID)
viewModel.prewarmGateway()
gatewayHarness.awaitRpc("session.activate")
awaitCondition {
handler.messages.value.any { it.id == "prior-assistant" && it.isStreaming }
}
failHistory = true
serverWs.send(
gatewayHarness.eventFrame(
"message.complete",
buildJsonObject { put("text", "Recovered answer") },
"live-resumed",
),
)
awaitCondition {
!handler.isStreaming.value &&
signInRequests.get() == 1 &&
sessionRefreshes.get() >= 1
}
assertTrue(handler.messages.value.any {
it.id == "prior-assistant" &&
it.content.contains("Recovered answer") &&
!it.isStreaming
})
assertTrue(requestedProfiles.isNotEmpty())
assertTrue(requestedProfiles.all { it == null })
assertEquals(0, apiMessageRequestCount.get())
assertFalse(viewModel.steerableTurn.value)
awaitCondition { checkpointStore.checkpoint == null }
assertNull(viewModel.chatFailure.value)
val diagnostic = DiagnosticsLog.recent(setOf(DiagnosticCategory.Auth), 1).single()
assertEquals("Dashboard sign-in required for chat history", diagnostic.title)
}
@Test
fun lateCanceledCompletionDrainsBeforeImmediateNextTurn() {
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))