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:
@@ -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 {
|
||||
|
||||
+8
-1
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
+143
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user