diff --git a/CHANGELOG.md b/CHANGELOG.md index 7759609b9..81922abb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ### Fixed +- Android avoids redundant conversation history in Gateway send/recovery responses and pauses automatic conversation reopening after a recorded out-of-memory crash, preserving saved conversations and drafts. (#700) - Android preserves native Dashboard sign-in during reverse-proxy access failures and retries refresh on a later request when the route is reachable again. (#711) - Secure Link accepts PEM certificate chains, exports only the leaf certificate for pairing, and reports invalid certificate/key configurations without exposing private file paths. (#712) - Android keeps ONNX Runtime on 1.27.0 so sherpa-onnx native libs match the packaged runtime ABI. diff --git a/app/src/androidTest/kotlin/com/hermesandroid/relay/viewmodel/ChatMemoryRecoveryInstrumentedTest.kt b/app/src/androidTest/kotlin/com/hermesandroid/relay/viewmodel/ChatMemoryRecoveryInstrumentedTest.kt new file mode 100644 index 000000000..470ac0863 --- /dev/null +++ b/app/src/androidTest/kotlin/com/hermesandroid/relay/viewmodel/ChatMemoryRecoveryInstrumentedTest.kt @@ -0,0 +1,88 @@ +package com.hermesandroid.relay.viewmodel + +import android.graphics.Bitmap +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.test.platform.app.InstrumentationRegistry +import com.hermesandroid.relay.R +import com.hermesandroid.relay.data.AgentDisplay +import com.hermesandroid.relay.network.upstream.ChatHandler +import com.hermesandroid.relay.network.upstream.models.MessageItem +import com.hermesandroid.relay.reliability.ReliabilityCenter +import com.hermesandroid.relay.ui.components.CrashReportGate +import com.hermesandroid.relay.ui.theme.HermesRelayTheme +import com.hermesandroid.relay.util.CrashReporter +import java.io.File +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class ChatMemoryRecoveryInstrumentedTest { + @get:Rule val compose = createAndroidComposeRule() + + @Test + fun memoryReportDismissalAndLifecycleDoNotReopenHistoryUntilSelection() { + val reads = AtomicInteger() + val owner = AgentDisplay.profileContextKey("fixture", null) + lateinit var vm: ChatViewModel + compose.runOnUiThread { + val context = compose.activity + ReliabilityCenter.recordFatal(context, OutOfMemoryError("synthetic bounded recovery test"), "fixture") + vm = ChatViewModel().also { + it.pauseAutomaticSessionRestoreAfterMemoryFailure( + CrashReporter.isMemoryFailure(CrashReporter.peekPending(context)), + ) + it.initialize(null, ChatHandler()) + it.streamingEndpoint = "gateway" + it.setProfileMessageLoader { + reads.incrementAndGet() + Result.success(listOf(MessageItem(id = "row", role = "assistant", content = JsonPrimitive("Saved answer")))) + } + it.switchProfileContext(owner, "stored-long") + } + } + compose.setContent { + val messages by vm.messages.collectAsStateWithLifecycle() + HermesRelayTheme(themePreference = "dark") { + Column { + Button(onClick = { vm.switchSession("stored-long") }) { Text("Open saved conversation") } + messages.forEach { Text(it.content) } + } + CrashReportGate() + } + } + val explanation = compose.activity.getString(R.string.crash_memory_recovery_body) + compose.onNodeWithText(explanation).assertIsDisplayed() + compose.onNode(isDialog()).captureToImage().asAndroidBitmap().let { bitmap -> + val output = InstrumentationRegistry.getArguments().getString("additionalTestOutputDir") + ?.let(::File) ?: compose.activity.cacheDir + output.mkdirs() + File(output, "memory-recovery-dialog.png").outputStream().use { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) + } + } + compose.onNodeWithText(compose.activity.getString(R.string.common_dismiss)).performClick() + compose.activityRule.scenario.moveToState(Lifecycle.State.CREATED) + compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) + compose.runOnUiThread { vm.reconcileProfileContext(owner, "stored-long") } + compose.waitForIdle() + assertEquals(0, reads.get()) + compose.onNodeWithText("Open saved conversation").performClick() + compose.waitUntil(5_000) { reads.get() == 1 } + compose.onNodeWithText("Saved answer").assertIsDisplayed() + } +} diff --git a/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/GatewayChatClient.kt b/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/GatewayChatClient.kt index 83485cca0..2cb33c529 100644 --- a/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/GatewayChatClient.kt +++ b/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/GatewayChatClient.kt @@ -1430,6 +1430,7 @@ class GatewayChatClient( "session.activate", buildJsonObject { put("session_id", preferredLiveId) + put("omit_messages", true) }, ) response = activated.getOrNull() @@ -1457,6 +1458,7 @@ class GatewayChatClient( put("session_id", storedId) put("cols", DEFAULT_COLS) put("source", sessionSource) + put("omit_messages", true) requestedProfile?.let { put("profile", it) } }, ).getOrElse { error -> @@ -3645,6 +3647,8 @@ class GatewayChatClient( put("session_id", requestedStoredId) put("cols", DEFAULT_COLS) put("source", sessionSource) + // Visible history comes from Dashboard HTTP, not this binding response. + put("omit_messages", true) requestedProfile?.let { put("profile", it) } }, ) @@ -4544,7 +4548,10 @@ class GatewayChatClient( val socket = webSocket ?: throw GatewayRpcException("gateway socket changed") val activated = rpc( "session.activate", - buildJsonObject { put("session_id", preservedLiveId) }, + buildJsonObject { + put("session_id", preservedLiveId) + put("omit_messages", true) + }, expectedConnection = ready, expectedSocket = socket, ) diff --git a/app/src/main/kotlin/com/hermesandroid/relay/runtime/HermesProcessRuntime.kt b/app/src/main/kotlin/com/hermesandroid/relay/runtime/HermesProcessRuntime.kt index 37728f240..c507364ee 100644 --- a/app/src/main/kotlin/com/hermesandroid/relay/runtime/HermesProcessRuntime.kt +++ b/app/src/main/kotlin/com/hermesandroid/relay/runtime/HermesProcessRuntime.kt @@ -78,6 +78,9 @@ class HermesProcessRuntime internal constructor( val chatViewModel: ChatViewModel by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { viewModelProvider[ChatViewModel::class.java].also { chat -> + chat.pauseAutomaticSessionRestoreAfterMemoryFailure( + com.hermesandroid.relay.util.CrashReporter.pauseAutomaticSessionRestore, + ) chat.installComposerDraftStore( PersistentChatComposerDraftStore( File(application.noBackupFilesDir, "chat-composer-drafts"), diff --git a/app/src/main/kotlin/com/hermesandroid/relay/ui/components/CrashReportDialog.kt b/app/src/main/kotlin/com/hermesandroid/relay/ui/components/CrashReportDialog.kt index c1c78b140..6293c52c2 100644 --- a/app/src/main/kotlin/com/hermesandroid/relay/ui/components/CrashReportDialog.kt +++ b/app/src/main/kotlin/com/hermesandroid/relay/ui/components/CrashReportDialog.kt @@ -128,7 +128,10 @@ private fun CrashReportDialog(report: ReliabilityReport, onDismiss: () -> Unit) Spacer(Modifier.height(8.dp)) Text( - text = stringResource(R.string.crash_body), + text = stringResource( + if (CrashReporter.isMemoryFailure(report)) R.string.crash_memory_recovery_body + else R.string.crash_body, + ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/kotlin/com/hermesandroid/relay/util/CrashReporter.kt b/app/src/main/kotlin/com/hermesandroid/relay/util/CrashReporter.kt index 49875caab..61825da34 100644 --- a/app/src/main/kotlin/com/hermesandroid/relay/util/CrashReporter.kt +++ b/app/src/main/kotlin/com/hermesandroid/relay/util/CrashReporter.kt @@ -24,11 +24,18 @@ object CrashReporter { @Volatile private var installed = false + /** Latched before UI/report dismissal; acknowledging a report cannot reopen the failed chat. */ + var pauseAutomaticSessionRestore: Boolean = false + private set + fun install(context: Context) { ReliabilityCenter.initialize(context) migrateLegacy(context) if (installed) return installed = true + pauseAutomaticSessionRestore = runCatching { + isMemoryFailure(ReliabilityCenter.pendingCrash(context)) + }.getOrDefault(false) val appContext = context.applicationContext val previous = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> @@ -52,6 +59,9 @@ object CrashReporter { return ReliabilityCenter.pendingCrash(context) } + fun isMemoryFailure(report: ReliabilityReport?): Boolean = + report?.summary?.let { it == "OutOfMemoryError" || it.startsWith("OutOfMemoryError:") } == true + fun clearPending(context: Context, reportId: String? = null) { val target = reportId ?: peekPending(context)?.reportId ?: return ReliabilityCenter.markReviewed(context, target) diff --git a/app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ChatViewModel.kt b/app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ChatViewModel.kt index 48d54cefe..d78aef04e 100644 --- a/app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ChatViewModel.kt +++ b/app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ChatViewModel.kt @@ -722,6 +722,12 @@ class ChatViewModel : ViewModel() { ) private var deferredGatewayPrewarm: DeferredGatewayPrewarm? = null private var automaticGatewayPrewarmBlocked = false + private var memoryRecoverySelectionRequired = false + + /** Startup-only latch. Keep saved session slots, drafts and checkpoints until explicit selection. */ + fun pauseAutomaticSessionRestoreAfterMemoryFailure(paused: Boolean) { + memoryRecoverySelectionRequired = paused + } private var imageActivityJob: Job? = null private val historyLoadGeneration = AtomicInteger(0) private val sessionRefreshGeneration = AtomicInteger(0) @@ -1769,6 +1775,7 @@ class ChatViewModel : ViewModel() { return@launch } if (startingSessionId == null) { + memoryRecoverySelectionRequired = false handler.setSessionId(preparedSessionId) selectBackgroundProcessSession(preparedSessionId) gatewayProcessController.sessionReady(preparedSessionId) @@ -5055,6 +5062,7 @@ class ChatViewModel : ViewModel() { sessionId: String, ): Boolean { if (!selectConversationProfile(profileName, profile)) return false + memoryRecoverySelectionRequired = false if (activeProfileContextKey == contextKey && chatHandler?.currentSessionId?.value == sessionId && currentSessionProfileName() == profileName ) { @@ -5091,6 +5099,7 @@ class ChatViewModel : ViewModel() { contextKey: String, ): Boolean { if (!selectConversationProfile(profileName, profile)) return false + memoryRecoverySelectionRequired = false // A provisional phone Thread belongs to its original connection/chat_id // and cannot transfer to another profile. Exit it before binding or // persisting the destination draft so the next send uses session.create. @@ -5147,7 +5156,7 @@ class ChatViewModel : ViewModel() { fun switchProfileContext(contextKey: String, sessionId: String?) { clearOpenedSessionOwner() - switchProfileContextInternal(contextKey, sessionId) + switchProfileContextInternal(contextKey, sessionId.takeUnless { memoryRecoverySelectionRequired }) } /** @@ -5159,7 +5168,9 @@ class ChatViewModel : ViewModel() { * profile/session tuple, the reducer converges it to ordinary global state. */ fun reconcileProfileContext(contextKey: String, sessionId: String?) { - switchProfileContextInternal(contextKey, sessionId, reconciliation = true) + switchProfileContextInternal( + contextKey, sessionId.takeUnless { memoryRecoverySelectionRequired }, reconciliation = true, + ) } private fun switchProfileContextInternal( @@ -5622,6 +5633,9 @@ class ChatViewModel : ViewModel() { } private fun automaticGatewayWorkDeferred(sessionId: String?): Boolean { + // The empty recovery view still needs socket readiness. Only the saved + // session must stay detached until an explicit selection. + if (memoryRecoverySelectionRequired && sessionId != null) return true // The directory barrier exists only when this connection exposes a // Dashboard-backed, profile-scoped session directory. Legacy/test // Gateway bindings without that surface must retain socket readiness @@ -5977,6 +5991,8 @@ class ChatViewModel : ViewModel() { if (supervisedModePolicy.enabled && !supervisedModePolicy.capabilities.newChat) return val handler = chatHandler ?: return recordPreResetEvidence(handler, "new_chat") + if (memoryRecoverySelectionRequired) persistFreshDraft(currentSessionProfileName()) + memoryRecoverySelectionRequired = false // A new chat clears only the durable session identity. Keep the bound // profile/context so an All Profiles conversation becomes a fresh // draft for that same owner instead of falling back to the globally @@ -6229,6 +6245,7 @@ class ChatViewModel : ViewModel() { val handler = chatHandler ?: return dismissChatFailure() if (streamingEndpoint != "gateway" && apiClient == null) return + memoryRecoverySelectionRequired = false exitProvisionalThread() // Keep a Gateway sibling alive and detach its callbacks. SSE remains a @@ -8213,6 +8230,7 @@ class ChatViewModel : ViewModel() { handler: ChatHandler, sessionId: String, ): Boolean { + if (memoryRecoverySelectionRequired) return false val contextKey = activeProfileContextKey ?: return false val key = TurnCheckpointKey(contextKey, sessionId) val checkpoint = backgroundTurnCheckpoints.remove(key) @@ -9104,6 +9122,11 @@ class ChatViewModel : ViewModel() { isolateComposer: Boolean = false, ) { AppAnalytics.onMessageSent() + // Sending from the recovery view explicitly chooses a fresh draft. Clear + // only its remembered selection, or a later readiness emission could + // reopen the old conversation while the new session is being created. + if (memoryRecoverySelectionRequired) persistFreshDraft(currentSessionProfileName()) + memoryRecoverySelectionRequired = false val displayText = text.trim() val outboundText = transportText.trim() val interfaceContextPrompt = if (isolateComposer) { @@ -9292,6 +9315,8 @@ class ChatViewModel : ViewModel() { fun startRealtimeAgentTurn(userText: String, chatSessionId: String?): String { val handler = chatHandler ?: return UUID.randomUUID().toString() + if (memoryRecoverySelectionRequired && chatSessionId == null) persistFreshDraft(currentSessionProfileName()) + memoryRecoverySelectionRequired = false AppAnalytics.onMessageSent() val trimmed = userText.trim().ifBlank { "Listening..." } val userMessageId = UUID.randomUUID().toString() diff --git a/app/src/main/res/values-b+pt+BR/strings.xml b/app/src/main/res/values-b+pt+BR/strings.xml index de743d01d..850ad1cc2 100644 --- a/app/src/main/res/values-b+pt+BR/strings.xml +++ b/app/src/main/res/values-b+pt+BR/strings.xml @@ -4475,4 +4475,5 @@ Não foi possível reduzir a duração da sessão. Atualize as sessões do Relay e tente uma duração menor, ou pareie novamente com um novo código aprovado pelo operador para renovar o acesso. Atualizando sessão… Não há uma duração predefinida mais curta. Você pode revogar esta sessão ou parear novamente para renovar o acesso. + O aplicativo ficou sem memória. A reabertura automática de conversas está pausada nesta inicialização. Feche este relatório e escolha uma conversa em Sessões ou inicie um novo chat. As conversas e os rascunhos salvos não foram excluídos. O Hermes pode continuar trabalhando. diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index 6ecfba1e5..a3434e40d 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -4556,4 +4556,5 @@ 无法缩短会话有效期。请刷新 Relay 会话并尝试更短的时长,或使用经操作员批准的新配对码重新配对以续期访问权限。 正在更新会话… 没有更短的预设时长。你可以撤销此会话,或重新配对以续期访问权限。 + 应用内存不足。本次启动已暂停自动重新打开对话。关闭此报告后,请从“会话”中选择一个对话或开始新聊天。已保存的对话和草稿未被删除。Hermes 上的任务可能仍在运行。 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 2835e136a..7d0b20efb 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -4632,4 +4632,5 @@ Die Sitzung konnte nicht verkürzt werden. Aktualisiere die Relay-Sitzungen und versuche eine kürzere Dauer, oder kopple erneut mit einem neuen, vom Betreiber genehmigten Code, um den Zugriff zu erneuern. Sitzung wird aktualisiert… Keine kürzere Vorgabe verfügbar. Du kannst diese Sitzung widerrufen oder zur Erneuerung erneut koppeln. + Der App ist der Arbeitsspeicher ausgegangen. Das automatische Wiederöffnen von Unterhaltungen ist für diesen Start pausiert. Schließe diesen Bericht und wähle dann unter Sitzungen eine Unterhaltung aus oder starte einen neuen Chat. Gespeicherte Unterhaltungen und Entwürfe wurden nicht gelöscht. Aufgaben auf Hermes können noch laufen. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 63d5e61a4..8d122c662 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -4323,4 +4323,5 @@ No se pudo acortar la sesión. Actualiza las sesiones de Relay y prueba una duración menor, o vuelve a emparejar con un nuevo código aprobado por el operador para renovar el acceso. Actualizando sesión… No queda ninguna duración predefinida más corta. Puedes revocar esta sesión o volver a emparejar para renovar el acceso. + La aplicación se quedó sin memoria. La reapertura automática de conversaciones está pausada durante este inicio. Cierra este informe y elige una conversación en Sesiones o inicia un chat nuevo. No se han eliminado las conversaciones ni los borradores guardados. Hermes podría seguir trabajando. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 6911a14c1..204965643 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -4627,4 +4627,5 @@ セッションの期間を短縮できませんでした。Relay セッションを更新してより短い期間を試すか、オペレーターが承認した新しいコードで再度ペアリングしてアクセスを更新してください。 セッションを更新中… これより短い選択肢はありません。このセッションを取り消すか、再度ペアリングしてアクセスを更新できます。 + アプリのメモリが不足しました。今回は会話を自動で開き直しません。このレポートを閉じてから、セッションで会話を選ぶか、新しいチャットを開始してください。保存済みの会話と下書きは削除されていません。Hermes では処理が続いている可能性があります。 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 6d3dfd763..f67edb7ce 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -4371,4 +4371,5 @@ Не удалось сократить срок сеанса. Обновите список сеансов Relay и выберите меньший срок или выполните сопряжение заново с новым кодом, одобренным оператором, чтобы обновить доступ. Обновление сеанса… Более коротких готовых вариантов нет. Можно отозвать этот сеанс или выполнить сопряжение заново для обновления доступа. + Приложению не хватило памяти. Автоматическое открытие последнего разговора приостановлено на этот запуск. Закройте этот отчёт, затем выберите разговор в разделе «Сессии» или начните новый чат. Сохранённые разговоры и черновики не удалены. Задачи в Hermes могут ещё выполняться. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b0851fc45..4a4476ec0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,6 @@ + The app ran out of memory. Automatic conversation reopening is paused for this launch. Dismiss this report, then choose a conversation from Sessions or start a new chat. Saved conversations and drafts have not been deleted. Work on Hermes may still be running. Question %1$d of %2$d All questions answered This request has ended diff --git a/app/src/test/kotlin/com/hermesandroid/relay/network/upstream/GatewayChatClientTest.kt b/app/src/test/kotlin/com/hermesandroid/relay/network/upstream/GatewayChatClientTest.kt index 415493c73..45a9b0a0f 100644 --- a/app/src/test/kotlin/com/hermesandroid/relay/network/upstream/GatewayChatClientTest.kt +++ b/app/src/test/kotlin/com/hermesandroid/relay/network/upstream/GatewayChatClientTest.kt @@ -63,6 +63,7 @@ class GatewayClientHarness( private val allServerSockets = ConcurrentLinkedQueue() val rpcLog = ConcurrentLinkedQueue>() val serverResponses = LinkedBlockingQueue() + val responseSizes = ConcurrentHashMap() var failTicketMint = false var malformedTicketMint = false val transientTicketFailures = AtomicInteger(0) @@ -593,7 +594,16 @@ class GatewayClientHarness( } } } - webSocket.send(reply.toString()) + // Upstream omits display history, but retains live state and metadata. + val wireReply = if (method in setOf("session.resume", "session.activate") && + (params["omit_messages"] as? JsonPrimitive)?.booleanOrNull == true && result != null + ) { + JsonObject(reply + ("result" to JsonObject(result + + ("messages" to JsonArray(emptyList()))))) + } else reply + val wire = wireReply.toString() + responseSizes[method] = wire.length + webSocket.send(wire) } } @@ -2371,6 +2381,54 @@ class GatewayChatClientTest { assertEquals(listOf("yours"), r.textDeltas.toList()) } + @Test + fun `long history send omits unused rows before websocket decoding`() { + installLargeStoredHistory() + val r = Recorder() + client.sendTurn("stored-long", "continue", null, r.callbacks) { + r.preflightFailures += it + } + harness.awaitRpc("prompt.submit") + assertEquals(true, (harness.awaitRpc("session.resume")["omit_messages"] as? JsonPrimitive)?.booleanOrNull) + assertTrue("resume transferred history: ${harness.responseSizes}", harness.responseSizes.getValue("session.resume") < 2048) + assertEquals(48, harness.recoveryMessages.size) + assertTrue(r.preflightFailures.isEmpty()) + assertFalse(harness.rpcLog.any { it.first == "session.create" }) + } + + @Test + fun `long history recovery omits unused rows but preserves inflight state`() = runBlocking { + installLargeStoredHistory() + harness.recoveryRunning = true + harness.recoveryAssistant = "partial answer" + val recovery = client.recoverTurn("stored-long", "live-original", Recorder().callbacks).getOrThrow() + assertEquals("partial answer", recovery.inflight?.assistant) + assertTrue(recovery.running) + assertEquals(true, (harness.awaitRpc("session.activate")["omit_messages"] as? JsonPrimitive)?.booleanOrNull) + assertTrue(harness.responseSizes.getValue("session.activate") < 2048) + assertEquals(48, harness.recoveryMessages.size) + } + + @Test + fun `long history fallback resume omits unused rows`() = runBlocking { + installLargeStoredHistory() + harness.methodNotFound += "session.activate" + harness.recoveryRunning = true + val recovery = client.recoverTurn("stored-long", "expired-live", Recorder().callbacks).getOrThrow() + assertTrue(recovery.running) + assertEquals(true, (harness.awaitRpc("session.resume")["omit_messages"] as? JsonPrimitive)?.booleanOrNull) + assertTrue(harness.responseSizes.getValue("session.resume") < 2048) + assertEquals(48, harness.recoveryMessages.size) + } + + private fun installLargeStoredHistory() { + // Bounded synthetic tool history, retained independently of live state. + val text = "tool output \"quoted\"\n".repeat(4096) + harness.recoveryMessages = JsonArray(List(48) { + buildJsonObject { put("role", "tool"); put("text", text) } + }) + } + @Test fun `existing session id is resumed not recreated`() { val r = Recorder() @@ -2654,6 +2712,7 @@ class GatewayChatClientTest { @Test fun `socket loss mid-turn activates original session without resume and completes`() { + installLargeStoredHistory() val r = Recorder() client.sendTurn(null, "hello", null, r.callbacks) { r.preflightFailures += it } val ws1 = harness.awaitServerSocket() @@ -2670,6 +2729,7 @@ class GatewayChatClientTest { assertTrue("reconnect must mint a fresh ticket", harness.ticketMints.get() >= 2) val activate = harness.awaitRpc("session.activate") assertEquals("live-1", (activate["session_id"] as? JsonPrimitive)?.contentOrNull) + assertEquals(true, (activate["omit_messages"] as? JsonPrimitive)?.booleanOrNull) ws2.send( harness.eventFrame("message.delta", buildJsonObject { put("text", "after rejoin") }, "live-1"), @@ -2679,6 +2739,8 @@ class GatewayChatClientTest { ) assertTrue("turn never completed after rejoin", r.completeLatch.await(10, TimeUnit.SECONDS)) + awaitCondition { harness.responseSizes.containsKey("session.activate") } + assertTrue(harness.responseSizes.getValue("session.activate") < 2048) assertEquals(listOf("after rejoin"), r.textDeltas.toList()) assertEquals(1, r.reconcileRequests.get()) assertTrue("rejoined turn must not error, got ${r.errors}", r.errors.isEmpty()) diff --git a/app/src/test/kotlin/com/hermesandroid/relay/network/upstream/GatewayPayloadMemoryTest.kt b/app/src/test/kotlin/com/hermesandroid/relay/network/upstream/GatewayPayloadMemoryTest.kt new file mode 100644 index 000000000..0288d997c --- /dev/null +++ b/app/src/test/kotlin/com/hermesandroid/relay/network/upstream/GatewayPayloadMemoryTest.kt @@ -0,0 +1,75 @@ +package com.hermesandroid.relay.network.upstream + +import java.io.File +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Controlled decoder pressure, separate from Android rendering and the transport harness. */ +class GatewayPayloadMemoryTest { + @Test + fun omittedHistoryAvoidsDecoderExhaustionUnderBoundedHeap() { + val directory = Files.createTempDirectory("gateway-memory").toFile() + try { + val full = File(directory, "full.json") + full.bufferedWriter().use { writer -> + writer.write("{\"id\":1,\"result\":{\"session_id\":\"live\",\"messages\":[{\"text\":\"") + // 32 Mi characters, with escapes exercising the release stack's string builder. + val block = "a\\n".repeat(1024) + repeat(10923) { writer.write(block) } + writer.write("\"}],\"running\":false}}") + } + val omitted = File(directory, "omitted.json").apply { + writeText("""{"id":1,"result":{"session_id":"live","messages":[],"running":false}}""") + } + assertEquals("full history should reproduce decoder OOM", 42, decodeInBoundedProcess(full)) + assertEquals("metadata-only response should decode repeatedly", 0, decodeInBoundedProcess(omitted)) + } finally { + directory.listFiles()?.forEach { it.delete() } + directory.delete() + } + } + + private fun decodeInBoundedProcess(payload: File): Int { + val classpath = listOf( + GatewayPayloadMemoryProbe::class.java, Json::class.java, + KSerializer::class.java, Unit::class.java, + ).map { File(requireNotNull(it.protectionDomain).codeSource.location.toURI()).absolutePath } + .distinct().joinToString(File.pathSeparator) + val java = File(System.getProperty("java.home"), "bin/java").absolutePath + val log = File(payload.parentFile, "${payload.name}.log") + val process = ProcessBuilder( + java, "-Xmx128m", "-XX:-CompactStrings", "-cp", classpath, + GatewayPayloadMemoryProbe::class.java.name, payload.absolutePath, + ).redirectErrorStream(true).redirectOutput(log).start() + try { + assertTrue("memory probe timed out", process.waitFor(30, TimeUnit.SECONDS)) + assertTrue("probe failed before decoding: ${log.readText().take(4000)}", process.exitValue() in setOf(0, 42)) + return process.exitValue() + } finally { + if (process.isAlive) process.destroyForcibly() + } + } +} + +object GatewayPayloadMemoryProbe { + private lateinit var retained: ByteArray + + @JvmStatic + fun main(args: Array) { + // Fixed retained state models an already-open transcript. No Compose or network is present. + retained = ByteArray(16 * 1024 * 1024) + val text = Files.readString(File(args.single()).toPath()) + try { + repeat(20) { Json.parseToJsonElement(text) } + check(retained.size == 16 * 1024 * 1024) + } catch (_: OutOfMemoryError) { + // Only decoding is caught: a failure reading the frame is a different test result. + kotlin.system.exitProcess(42) + } + } +} diff --git a/app/src/test/kotlin/com/hermesandroid/relay/reliability/ReliabilityReportTest.kt b/app/src/test/kotlin/com/hermesandroid/relay/reliability/ReliabilityReportTest.kt index b648e30e1..235b7a2bc 100644 --- a/app/src/test/kotlin/com/hermesandroid/relay/reliability/ReliabilityReportTest.kt +++ b/app/src/test/kotlin/com/hermesandroid/relay/reliability/ReliabilityReportTest.kt @@ -12,6 +12,13 @@ import org.junit.Assert.assertTrue import org.junit.Test class ReliabilityReportTest { + @Test + fun memoryRecoveryDoesNotTreatOtherCrashesAsMemoryFailures() { + val base = report("memory", "2026-10-15T00:00:00Z") + assertTrue(CrashReporter.isMemoryFailure(base.copy(summary = "OutOfMemoryError: allocation failed"))) + assertFalse(CrashReporter.isMemoryFailure(base)) + assertFalse(CrashReporter.isMemoryFailure(null)) + } private val environment = ReliabilityEnvironment( versionName = "1.6.1", versionCode = 38, diff --git a/app/src/test/kotlin/com/hermesandroid/relay/viewmodel/ChatMemoryRecoveryTest.kt b/app/src/test/kotlin/com/hermesandroid/relay/viewmodel/ChatMemoryRecoveryTest.kt new file mode 100644 index 000000000..a0eaa86ae --- /dev/null +++ b/app/src/test/kotlin/com/hermesandroid/relay/viewmodel/ChatMemoryRecoveryTest.kt @@ -0,0 +1,140 @@ +package com.hermesandroid.relay.viewmodel + +import android.os.Looper +import com.hermesandroid.relay.data.AgentDisplay +import com.hermesandroid.relay.data.ChatComposerDraft +import com.hermesandroid.relay.data.ChatComposerDraftKey +import com.hermesandroid.relay.data.ChatTurnCheckpoint +import com.hermesandroid.relay.data.ChatTurnCheckpointStore +import com.hermesandroid.relay.data.InMemoryChatComposerDraftStore +import com.hermesandroid.relay.network.upstream.ChatHandler +import com.hermesandroid.relay.network.upstream.models.MessageItem +import com.hermesandroid.relay.network.upstream.models.SessionItem +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ChatMemoryRecoveryTest { + @Test + fun startupPauseKeepsDirectoryDraftAndCheckpointUntilExplicitSelection() { + val handler = ChatHandler().also { it.setSessionId("long-session") } + val owner = AgentDisplay.profileContextKey("connection", null) + val draftKey = ChatComposerDraftKey("connection", "default", "long-session") + val drafts = InMemoryChatComposerDraftStore() + runBlocking { drafts.save(draftKey, ChatComposerDraft("unsent draft")) } + val historyReads = mutableListOf() + val persistedSelections = mutableListOf() + var checkpointReads = 0 + var checkpointClears = 0 + var checkpointWrites = 0 + val vm = ChatViewModel().also { + it.pauseAutomaticSessionRestoreAfterMemoryFailure(true) + it.installComposerDraftStore(drafts) + it.initialize(null, handler) + it.streamingEndpoint = "gateway" + it.setProfileMessageLoader { id -> + historyReads += id + Result.success(listOf(MessageItem(id = "row", role = "assistant", content = JsonPrimitive("Saved answer")))) + } + it.setProfileSessionLister { Result.success(listOf(SessionItem(id = "long-session"))) } + it.onSessionChanged = { id -> persistedSelections += id } + it.setChatTurnCheckpointStore(object : ChatTurnCheckpointStore { + override suspend fun read(): ChatTurnCheckpoint? { checkpointReads++; return null } + override suspend fun write(checkpoint: ChatTurnCheckpoint) { checkpointWrites++ } + override suspend fun clear() { checkpointClears++ } + }) + } + vm.switchProfileContext(owner, "long-session") + vm.reconcileProfileContext(owner, "long-session") + vm.refreshSessions() + await { handler.sessions.value.isNotEmpty() } + assertNull(handler.currentSessionId.value) + assertTrue(vm.initialChatSettled.value) + assertTrue(historyReads.isEmpty()) + assertTrue(persistedSelections.isEmpty()) + assertEquals(0, checkpointReads) + assertEquals(0, checkpointClears) + assertEquals(0, checkpointWrites) + assertEquals("unsent draft", runBlocking { drafts.snapshot(draftKey).text }) + + vm.switchSession("long-session") + await { handler.messages.value.isNotEmpty() } + assertEquals(listOf("long-session"), historyReads) + assertEquals("long-session", handler.currentSessionId.value) + assertEquals("unsent draft", runBlocking { drafts.snapshot(draftKey).text }) + vm.reconcileProfileContext(owner, "long-session") + assertEquals("long-session", handler.currentSessionId.value) + assertEquals(0, checkpointClears) + } + + @Test + fun explicitNewChatRetiresOnlyTheRememberedSelection() { + val handler = ChatHandler() + val selections = mutableListOf() + val owner = AgentDisplay.profileContextKey("connection", null) + val vm = ChatViewModel().also { + it.pauseAutomaticSessionRestoreAfterMemoryFailure(true) + it.initialize(null, handler) + it.streamingEndpoint = "gateway" + it.onSessionChanged = { id -> selections += id } + } + vm.switchProfileContext(owner, "long-session") + assertTrue(selections.isEmpty()) + vm.createNewChat() + assertEquals(listOf(null), selections) + vm.reconcileProfileContext(owner, null) + assertNull(handler.currentSessionId.value) + } + + @Test + fun normalStartupStillLoadsItsRememberedConversation() { + val handler = ChatHandler() + val reads = mutableListOf() + val vm = ChatViewModel().also { + it.initialize(null, handler) + it.streamingEndpoint = "gateway" + it.setProfileMessageLoader { id -> + reads += id + Result.success(emptyList()) + } + } + vm.switchProfileContext(AgentDisplay.profileContextKey("connection", null), "saved-session") + await { reads.isNotEmpty() } + assertEquals(listOf("saved-session"), reads) + assertEquals("saved-session", handler.currentSessionId.value) + } + + @Test + fun explicitVoiceTurnIsNotReplacedByAutomaticReconciliation() { + val handler = ChatHandler() + val owner = AgentDisplay.profileContextKey("connection", null) + val vm = ChatViewModel().also { + it.pauseAutomaticSessionRestoreAfterMemoryFailure(true) + it.initialize(null, handler) + it.streamingEndpoint = "gateway" + } + vm.switchProfileContext(owner, "long-session") + vm.startRealtimeAgentTurn("spoken prompt", "voice-session") + vm.reconcileProfileContext(owner, "voice-session") + assertEquals("voice-session", handler.currentSessionId.value) + assertTrue(handler.messages.value.any { it.content == "spoken prompt" }) + } + + private fun await(condition: () -> Boolean) { + val deadline = System.nanoTime() + 5_000_000_000L + while (!condition() && System.nanoTime() < deadline) { + shadowOf(Looper.getMainLooper()).idle() + Thread.sleep(10) + } + assertTrue(condition()) + } +} diff --git a/docs/android-memory-recovery.md b/docs/android-memory-recovery.md new file mode 100644 index 000000000..fcd2a335d --- /dev/null +++ b/docs/android-memory-recovery.md @@ -0,0 +1,106 @@ +# Android conversation memory recovery + +## Release evidence + +Issue [#700](https://github.com/Codename-11/hermes-relay/issues/700) reports +Google Play 1.18.1 (version code 59) exhausting its 256 MiB heap while sending +in a long conversation, then crashing repeatedly when that conversation reopens. +The failed 576-byte allocation is the final failed allocation, not a frame size. + +The immutable `android-v1.18.1` tag resolves to +`ca941b6ac999f74fff72e3363eb852cf5cbecb12`. The published Play AAB has SHA-256 +`df89e47194a0fce71d2e6328d948a8e14ee4eb5489703c692920f252d759251c`. +Its embedded `BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.map` +has SHA-256 `ff6825451d86d7f83f25a172ed16bcefd0e0934f601df6d8e44af851ec6cd912`. +That exact mapping resolves `ua3.onMessage(SourceFile:51)` to +`GatewayChatClient.handleFrame` at release source line 3920, inlined through the +listener at line 3798. The failing expression is `json.parseToJsonElement`. + +This identifies the Gateway listener, but not the RPC/event or frame contents. +It does not establish whether one oversized frame, retained history, decoding +copies, rendering pressure, or a combination exhausted the reporter's heap. +Markdown issue #465 and attachment issue #531 remain separate failures. + +The text frame had already reached the listener. Parsing can retain that text +alongside decoded strings, escape-decoding buffers and the JSON tree. The +visible transcript and rendering allocations share the heap, but this stack +does not measure their contribution or demonstrate a retained-state leak. + +## Binding without duplicate display history + +Android's visible Gateway transcript is loaded by the profile-scoped Dashboard +HTTP history reader. Gateway resume and activation still supply live identity, +model/profile information, in-flight output, queued work and open requests. +Their `messages` display-history array is unused by foreground send and recovery. + +The prewarm path already requested `omit_messages`; cold send, checkpoint resume, +checkpoint activation and mid-turn activation now request it as well. This +avoids serializing, buffering and decoding the redundant history array on +supporting upstream versions. It does not limit or delete HTTP history and does +not omit in-flight output. Child-watch resume still requests its history because +that consumer uses the returned rows. Existing prewarm `defer_history` behavior +is unchanged; send/recovery do not newly defer server initialization. + +The upstream contract was inspected at +`NousResearch/hermes-agent@f9bdaa303e1614245cf1104db52ed0efd90b6f13`: +`tui_gateway/methods_session.py` reads `omit_messages` for resume and activation; +`_Resume.messages` and `tui_gateway/session_registry.py::_live_session_payload` +skip display projection when requested. The latter retains live state and open +requests. Older servers may ignore this additive option and retain the larger +response. The client still buffers complete WebSocket frames and parses JSON +trees; this change is not a universal incoming-frame memory bound. + +PR [#709](https://github.com/Codename-11/hermes-relay/pull/709), reviewed at +`86b5a27eaa50822ff63aa8e8beb205905fcd4364`, changes HTTP history pagination to +include compacted rows. It can increase retained visible history, but does not +change this WebSocket decoder. Its history change is not incorporated here. + +## Recovery after process death + +At application startup, a pending locally recorded `OutOfMemoryError` latches +a pause before the process chat runtime binds. Automatic profile/session +reconciliation opens an empty view and leaves the remembered session slot, +composer drafts and turn checkpoints intact. The session directory remains +available. Socket-only warmup can establish readiness, but automatic session +attachment/checkpoint recovery stays paused. + +The existing crash dialog explains how to dismiss the report and choose a +conversation or start a new chat. Dismissing the report does not lift the pause +in that process; explicit conversation selection or a new send does. Retrying +the same oversized conversation on an older server may still fail. Capture must +have succeeded before process termination for the recorded-crash gate to apply. + +## Verification lanes + +- `GatewayChatClientTest` uses real loopback HTTP/WebSocket transport with + bounded synthetic tool history. Send, activation, fallback resume and socket + rejoin must omit unused rows while preserving session identity and live state. +- `GatewayPayloadMemoryTest` runs the production JSON library in a separate + 128 MiB JVM with UTF-16 strings, a fixed 16 MiB retained allocation and a + synthetic 32 Mi-character escaped frame. Reading and decoding are separate + phases. It distinguishes decoder OOM from input-buffer failure, then repeats + metadata-only decoding. This is controlled pressure, not Pixel/ART profiling + or a measurement of the reporter's conversation. +- `ChatMemoryRecoveryTest` checks directory access, deferred history/checkpoint + loading, saved selection and draft preservation, and explicit reopening. +- `ChatMemoryRecoveryInstrumentedTest` exercises the production crash dialog, + dismissal, lifecycle return and explicit history loading on Android. +- The shared `initial_history_bind` scenario checks metadata-only resume and + activation while retaining full HTTP history. Its manifest is checked against + current upstream. The source checker follows called local resume helpers so + upstream extraction does not masquerade as contract removal. + +No private conversation data or production payload is required by these tests. +Actual Pixel 8 Pro / Android 17 behavior and the reporter's exact trigger require +separate device evidence; a synthetic test cannot certify them. + +## Rendered recovery evidence + +The focused `ChatMemoryRecoveryInstrumentedTest` passed on Standard Phone API 36 +(Pixel 6 profile, x86_64) at `08ba907721c7e180f37e15a7e45f47fb09f1cbd2`: one test, +zero failures or skips. It exercised the production crash dialog and chat state +under dismissal and Activity lifecycle return, followed by explicit history loading. +The dialog capture uses the default dark app theme in the instrumentation harness; +it is not a physical-device or full production-startup certification. + +![OOM recovery dialog on Standard Phone API 36](audits/assets/issue-700/memory-recovery-dialog.png) diff --git a/docs/audits/assets/issue-700/memory-recovery-dialog.png b/docs/audits/assets/issue-700/memory-recovery-dialog.png new file mode 100644 index 000000000..86271fcdd Binary files /dev/null and b/docs/audits/assets/issue-700/memory-recovery-dialog.png differ diff --git a/docs/gateway-contract-testing.md b/docs/gateway-contract-testing.md index 06656a00e..8b3df15be 100644 --- a/docs/gateway-contract-testing.md +++ b/docs/gateway-contract-testing.md @@ -27,6 +27,11 @@ hermetic tests. ## Session directory boundary +For duplicate resume-history memory pressure and post-OOM startup recovery, see +[Android conversation memory recovery](android-memory-recovery.md). The +`initial_history_bind` scenario also proves that `omit_messages` on resume and +activation leaves HTTP history available. + The Gateway fixture proves live chat transport, activity, reconnect, and authoritative post-turn history reconciliation. It is not the route authority for the session drawer. On the standard path, profile-scoped session browsing diff --git a/docs/localization-status.json b/docs/localization-status.json index 36504caf8..7122a2366 100644 --- a/docs/localization-status.json +++ b/docs/localization-status.json @@ -13,7 +13,7 @@ "verification": "ai-translated", "review_refs": [], "source_sha256": { - "main": "bccd06f1a0b9afc5b09a7ddb287d9f5ac7d37861c45075bc63e80c03a76f6de1", + "main": "330f082596881b2d74fcba1ca1fe45773b42c8b1670d5f140f0f0889979ed28c", "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2" }, "surfaces": { @@ -48,7 +48,7 @@ "verification": "ai-translated", "review_refs": [], "source_sha256": { - "main": "bccd06f1a0b9afc5b09a7ddb287d9f5ac7d37861c45075bc63e80c03a76f6de1", + "main": "330f082596881b2d74fcba1ca1fe45773b42c8b1670d5f140f0f0889979ed28c", "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2" }, "surfaces": { @@ -72,7 +72,7 @@ "verification": "ai-translated", "review_refs": [], "source_sha256": { - "main": "bccd06f1a0b9afc5b09a7ddb287d9f5ac7d37861c45075bc63e80c03a76f6de1", + "main": "330f082596881b2d74fcba1ca1fe45773b42c8b1670d5f140f0f0889979ed28c", "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2" }, "surfaces": { @@ -96,7 +96,7 @@ "verification": "ai-translated", "review_refs": [], "source_sha256": { - "main": "bccd06f1a0b9afc5b09a7ddb287d9f5ac7d37861c45075bc63e80c03a76f6de1", + "main": "330f082596881b2d74fcba1ca1fe45773b42c8b1670d5f140f0f0889979ed28c", "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2" }, "surfaces": { @@ -120,7 +120,7 @@ "verification": "ai-translated", "review_refs": [], "source_sha256": { - "main": "bccd06f1a0b9afc5b09a7ddb287d9f5ac7d37861c45075bc63e80c03a76f6de1", + "main": "330f082596881b2d74fcba1ca1fe45773b42c8b1670d5f140f0f0889979ed28c", "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2" }, "surfaces": { @@ -135,7 +135,7 @@ "verification": "ai-translated", "review_refs": [], "source_sha256": { - "main": "bccd06f1a0b9afc5b09a7ddb287d9f5ac7d37861c45075bc63e80c03a76f6de1", + "main": "330f082596881b2d74fcba1ca1fe45773b42c8b1670d5f140f0f0889979ed28c", "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2" }, "surfaces": { diff --git a/docs/spec.md b/docs/spec.md index f3300c2a5..994104044 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -745,6 +745,7 @@ Bottom navigation bar with 4 tabs: - **Model-selection consent** — Every Gateway model transition, including Server default, uses upstream `config.set` as a preflight. A fresh draft first creates a profile-bound default session without raw `model`/`provider` fields, preventing `session.create` from bypassing the guard and preventing sessionless global writes. A named-profile draft is usable only when the create result confirms the exact `info.profile_name`; missing or different ownership fails closed before `config.set`. When Hermes returns `confirm_required`, Android restores the previous selection, displays the exact server `confirm_message` (including cost and data-training policy warnings), and applies the model only through a second request carrying `confirm_expensive_model: true`. A profile or session change invalidates the pending confirmation. Older hosts that do not return the fields keep the existing one-step selection behavior. - **Host resource pressure** — Android reads the optional upstream Dashboard `/api/status.memory` and `.disk` blocks during the separate post-selection status refresh, never the lightweight route-readiness probe. `elevated` and `critical` values, plus a server-reported suspected out-of-memory restart, produce a persistent in-app warning about chat continuity and save risk. Android uses the upstream classifications as-is, adds no client thresholds or sampling, sends no telemetry, and stays quiet when older hosts omit the blocks. - **Empty state** — Logo + "Start a conversation" + suggestion chips that populate input +- **Memory-crash recovery** — After a locally recorded out-of-memory crash, automatic conversation reopening and Gateway checkpoint recovery pause for that launch. Sessions remain browsable; explicit selection or a new chat resumes normal operation. Dismissing the crash report does not reopen the previous conversation. Saved history, drafts and checkpoints remain intact. See [Android conversation memory recovery](android-memory-recovery.md). - **Agent Passport — profile inspection/configuration** — upstream Hermes profiles are selected from the Profile Shelf or the Passport's shared full switcher. Passport retains identity customization, model/personality/reasoning/safety configuration, inspection, and session analytics; it is not a second profile-picker implementation. See `docs/decisions.md` §21 and ADR 48. - **Agent sheet — Personality section** — personalities fetched from `GET /api/config` (`config.agent.personalities`). Shows server default (from `config.display.personality`) + all configured. Active personality name shown on assistant chat bubbles. - **Agent sheet — Approval controls** — gateway contract v3 exposes the profile-persisted `approvals.mode` policy (`manual` / `smart` / `off`) separately from YOLO. The launch/default profile gets the three-way control; multiplexed non-launch profiles reconcile `session.info.approval_mode` read-only until upstream config RPCs honor profile scope. The existing YOLO switch remains an explicit per-session override and never silently writes profile configuration. Older gateways keep chat and YOLO available while the profile control explains that an upstream update is required. diff --git a/scripts/android-prepush.py b/scripts/android-prepush.py index 1e75377d8..d4381c27a 100644 --- a/scripts/android-prepush.py +++ b/scripts/android-prepush.py @@ -21,6 +21,9 @@ REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] FOCUSED_TESTS = ( + "com.hermesandroid.relay.network.upstream.GatewayPayloadMemoryTest", + "com.hermesandroid.relay.viewmodel.ChatMemoryRecoveryTest", + "com.hermesandroid.relay.reliability.ReliabilityReportTest", "com.hermesandroid.relay.data.ChatUnreadStoreTest", "com.hermesandroid.relay.data.ChatTurnCheckpointStoreTest", "com.hermesandroid.relay.notifications.ChatNotificationIntentTest", diff --git a/scripts/check-gateway-scenario-conformance.py b/scripts/check-gateway-scenario-conformance.py index fa8251254..b820a4c1b 100644 --- a/scripts/check-gateway-scenario-conformance.py +++ b/scripts/check-gateway-scenario-conformance.py @@ -294,8 +294,24 @@ def _check_resume(methods: SourceFile) -> CheckResult: contract = SESSION_RESUME try: handler = methods.method_handler("session.resume") - text = methods.segment(handler) - strings = _string_constants(handler) + # Current upstream extracted resume into local helpers and a _Resume + # context. Follow only definitions reachable from this handler, so an + # unused legacy implementation cannot satisfy the contract. + definitions = { + node.name: node for node in methods.tree.body + if isinstance(node, (ast.FunctionDef, ast.ClassDef)) and node.name != "_" + } + nodes = [handler] + seen = {id(handler)} + for node in nodes: + for call in ast.walk(node): + if isinstance(call, ast.Call) and isinstance(call.func, ast.Name): + target = definitions.get(call.func.id) + if target is not None and id(target) not in seen: + nodes.append(target) + seen.add(id(target)) + text = "\n".join(methods.segment(node) for node in nodes) + strings = set().union(*(_string_constants(node) for node in nodes)) required_markers = ( "db.get_session(", "db.resolve_resume_session_id(", @@ -316,7 +332,7 @@ def _check_resume(methods: SourceFile) -> CheckResult: if not {4007, 4130}.issubset( { child.value - for child in ast.walk(handler) + for node in nodes for child in ast.walk(node) if isinstance(child, ast.Constant) and isinstance(child.value, int) } ): diff --git a/scripts/tests/check_gateway_scenario_conformance_test.py b/scripts/tests/check_gateway_scenario_conformance_test.py index 4e684bb96..f38d14ae0 100644 --- a/scripts/tests/check_gateway_scenario_conformance_test.py +++ b/scripts/tests/check_gateway_scenario_conformance_test.py @@ -269,6 +269,18 @@ def test_complete_contract_fixture_passes(self): self.assertEqual(module.ALL_CONTRACTS, tuple(result.contract for result in results)) self.assertTrue(all(result.passed for result in results), results) + def test_resume_follows_extracted_helpers_but_not_unreachable_history(self): + path = self.root / module.SESSION_METHODS + original = path.read_text(encoding="utf-8") + marker = '@method("session.resume")\ndef _(rid, params):' + extracted = original.replace(marker, + '@method("session.resume")\ndef _(rid, params):\n' + ' return _resume_impl(rid, params)\n\ndef _resume_impl(rid, params):') + path.write_text(extracted, encoding="utf-8") + self.assertTrue(module.audit_sources(self.root, (module.SESSION_RESUME,))[0].passed) + path.write_text(extracted.replace('return _resume_impl(rid, params)', 'return {}'), encoding="utf-8") + self.assertFalse(module.audit_sources(self.root, (module.SESSION_RESUME,))[0].passed) + def test_extracted_registry_keeps_identity_and_status_requirements(self): path = self.root / "tui_gateway/session_registry.py" path.write_text(SERVER_SOURCE, encoding="utf-8") diff --git a/test-fixtures/vanilla-gateway/tests/test_fixture.py b/test-fixtures/vanilla-gateway/tests/test_fixture.py index eedb2aa24..b738e95d2 100644 --- a/test-fixtures/vanilla-gateway/tests/test_fixture.py +++ b/test-fixtures/vanilla-gateway/tests/test_fixture.py @@ -18,6 +18,24 @@ class FixtureTestCase(unittest.IsolatedAsyncioTestCase): + async def test_metadata_only_bind_preserves_http_history(self) -> None: + fixture, base_url = await self.start("initial_history_bind") + ws, _ = await self.connect(base_url) + for request_id, method, session_id in ( + (1, "session.resume", fixture.scenario.stored_session_id), + (2, "session.activate", fixture.scenario.live_session_id), + ): + await self.rpc(ws, request_id, method, {"session_id": session_id, "omit_messages": True}) + result = (await ws.receive_json())["result"] + self.assertEqual([], result["messages"]) + self.assertEqual(2, result["message_count"]) + self.assertEqual(fixture.scenario.live_session_id, result["session_id"]) + self.assertFalse(result["running"]) + async with self.session.get(f"{base_url}/api/sessions/{fixture.scenario.stored_session_id}/messages") as response: + self.assertIn("Durable Bot Chat history is ready.", await response.text()) + await self.rpc(ws, 3, "session.resume", {"session_id": fixture.scenario.stored_session_id}) + self.assertEqual(2, len((await ws.receive_json())["result"]["messages"])) + async def test_quiet_turns_finish_and_publish_authoritative_unsolicited_state(self) -> None: fixture, base_url = await self.start("quiet_turn_recovery") ws, _ = await self.connect(base_url) diff --git a/test-fixtures/vanilla-gateway/vanilla_gateway/scenarios/initial_history_bind.json b/test-fixtures/vanilla-gateway/vanilla_gateway/scenarios/initial_history_bind.json index 77713d0f9..bb2bc4467 100644 --- a/test-fixtures/vanilla-gateway/vanilla_gateway/scenarios/initial_history_bind.json +++ b/test-fixtures/vanilla-gateway/vanilla_gateway/scenarios/initial_history_bind.json @@ -4,7 +4,8 @@ "stored_session_id": "20260829_120000_bot_chat", "profile": "research", "contract_requirements": [ - "gateway.session_resume_durable" + "gateway.session_resume_durable", + "gateway.session_activate_live" ], "initial_history": [ {"id": 1, "role": "user", "content": "Open the durable Bot Chat.", "timestamp": 1.0}, diff --git a/test-fixtures/vanilla-gateway/vanilla_gateway/server.py b/test-fixtures/vanilla-gateway/vanilla_gateway/server.py index 52b802044..d8895be6e 100644 --- a/test-fixtures/vanilla-gateway/vanilla_gateway/server.py +++ b/test-fixtures/vanilla-gateway/vanilla_gateway/server.py @@ -231,6 +231,8 @@ async def _handle_rpc( await self._rpc_error(socket, request_id, 4040, "Stored session not found") return result = self._session_snapshot(include_stored=True) + result["message_count"] = len(self._history_rows) + result["messages"] = [] if params.get("omit_messages") else list(self._history_rows) if self._clarify is not None or self._native_request is not None: self._clarify_owner = socket, connection elif method == "session.activate": @@ -239,6 +241,8 @@ async def _handle_rpc( await self._rpc_error(socket, request_id, 4041, "Live session not found") return result = self._session_snapshot(include_stored=True) + result["message_count"] = len(self._history_rows) + result["messages"] = [] if params.get("omit_messages") else list(self._history_rows) if self._clarify is not None or self._native_request is not None: self._clarify_owner = socket, connection elif method == "prompt.submit":