From 1f15c64379992f91655c18f8fef1953fac0cd074 Mon Sep 17 00:00:00 2001 From: ophirhan Date: Tue, 13 Oct 2026 17:21:06 +0300 Subject: [PATCH 1/4] fix(android): request include_compacted on session message loads Hermes in-place compaction archives older turns under the same session. Without include_compacted=true, reopen only showed the summary and short active tail. Always pass the flag on Dashboard and Hermes API message pagination so scrollback matches stored display history. --- CHANGELOG.md | 1 + .../relay/network/upstream/DashboardApiClient.kt | 3 +++ .../hermesandroid/relay/network/upstream/HermesApiClient.kt | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4209ef7d..5f1f6113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this - 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. - Android Voice no longer ends a reply while the last synthesized audio file is still playing, so Continuous mode reopens the microphone only after playback has finished. +- Android session transcript loads request include_compacted=true so reopen keeps compaction-archived scrollback. - Secure Link supports paired-device listing, session shortening, and revocation with the existing Relay authorization rules. - Android pauses both wake-word listener modes before voice capture and resumes listening after voice exits, preventing microphone conflicts. (#681) - Android Relay sessions distinguish shortening from operator-approved renewal, retain actionable update failures, and expose accessible pairing duration controls. (#659) diff --git a/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/DashboardApiClient.kt b/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/DashboardApiClient.kt index 39c1076b..36b8571f 100644 --- a/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/DashboardApiClient.kt +++ b/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/DashboardApiClient.kt @@ -1361,10 +1361,13 @@ class DashboardApiClient( ): Result> = withContext(Dispatchers.IO) { val name = profile?.trim().orEmpty() loadSessionMessages(mode) { page -> + // include_compacted: same display contract as desktop/web — keep + // compaction-archived turns so reopen is not summary+tail only. val query = buildList { add("limit=${page.limit}") add("offset=${page.offset}") add("order=${page.order}") + add("include_compacted=true") if (name.isNotBlank()) add("profile=${pathSegment(name)}") }.joinToString(prefix = "?", separator = "&") getJson( diff --git a/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/HermesApiClient.kt b/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/HermesApiClient.kt index 487c52bb..a5f45d02 100644 --- a/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/HermesApiClient.kt +++ b/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/HermesApiClient.kt @@ -730,11 +730,15 @@ class HermesApiClient( ): List = withContext(Dispatchers.IO) { loadSessionMessages(mode) { page -> try { + // include_compacted: display history must keep turns archived by + // in-place compaction (active=0, compacted=1). Without it the + // chat reopens as summary + tail only after compression. val url = "$baseUrl/api/sessions/$sessionId/messages".toHttpUrlOrNull() ?.newBuilder() ?.addQueryParameter("limit", page.limit.toString()) ?.addQueryParameter("offset", page.offset.toString()) ?.addQueryParameter("order", page.order) + ?.addQueryParameter("include_compacted", "true") ?.build() ?: error("invalid session messages URL") val request = authRequest(url.toString()).get().build() From 15935ad5c70d847b587cfc0faa488e952830205a Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Mon, 19 Oct 2026 17:43:54 -0400 Subject: [PATCH 2/4] test(android): verify compacted history remains bounded Preserve compacted history on both HTTP transports without restoring redundant Gateway history. Cover complete and latest request parameters and the transcript message cap; align the fixture expectation with its resume/activation manifest. --- .../upstream/DashboardApiClientTest.kt | 6 ++++++ .../network/upstream/HermesApiClientTest.kt | 20 +++++++++++++++++++ .../upstream/SessionMessagePaginationTest.kt | 13 ++++++++++++ docs/android-memory-recovery.md | 7 +++++++ .../vanilla-gateway/tests/test_fixture.py | 2 +- 5 files changed, 47 insertions(+), 1 deletion(-) diff --git a/app/src/test/kotlin/com/hermesandroid/relay/network/upstream/DashboardApiClientTest.kt b/app/src/test/kotlin/com/hermesandroid/relay/network/upstream/DashboardApiClientTest.kt index 5b986520..98c14190 100644 --- a/app/src/test/kotlin/com/hermesandroid/relay/network/upstream/DashboardApiClientTest.kt +++ b/app/src/test/kotlin/com/hermesandroid/relay/network/upstream/DashboardApiClientTest.kt @@ -1533,6 +1533,7 @@ class DashboardApiClientTest { assertEquals("500", url.queryParameter("limit")) assertEquals("0", url.queryParameter("offset")) assertEquals("latest", url.queryParameter("order")) + assertEquals("true", url.queryParameter("include_compacted")) assertEquals(2, messages.size) assertEquals("user", messages[0].role) assertEquals("assistant", messages[1].role) @@ -1555,6 +1556,9 @@ class DashboardApiClientTest { val second = server.takeRequest().requestUrl!! assertEquals(listOf("0", "500"), listOf(first, second).map { it.queryParameter("offset") }) assertEquals(listOf("mizu", "mizu"), listOf(first, second).map { it.queryParameter("profile") }) + assertEquals(listOf("true", "true"), listOf(first, second).map { it.queryParameter("include_compacted") }) + assertEquals(listOf("500", "500"), listOf(first, second).map { it.queryParameter("limit") }) + assertEquals(listOf("oldest", "oldest"), listOf(first, second).map { it.queryParameter("order") }) assertEquals((0..500).map(Int::toString), messages.map { it.id }) } @@ -1568,6 +1572,8 @@ class DashboardApiClientTest { val request = server.takeRequest().requestUrl!! assertEquals("latest", request.queryParameter("order")) + assertEquals("true", request.queryParameter("include_compacted")) + assertEquals("500", request.queryParameter("limit")) assertEquals("0", request.queryParameter("offset")) assertEquals(500, messages.size) assertEquals(1, server.requestCount) diff --git a/app/src/test/kotlin/com/hermesandroid/relay/network/upstream/HermesApiClientTest.kt b/app/src/test/kotlin/com/hermesandroid/relay/network/upstream/HermesApiClientTest.kt index faffa136..c3445793 100644 --- a/app/src/test/kotlin/com/hermesandroid/relay/network/upstream/HermesApiClientTest.kt +++ b/app/src/test/kotlin/com/hermesandroid/relay/network/upstream/HermesApiClientTest.kt @@ -36,12 +36,32 @@ class HermesApiClientTest { val second = server.takeRequest().requestUrl!! assertEquals(listOf("0", "500"), listOf(first, second).map { it.queryParameter("offset") }) assertEquals(listOf("oldest", "oldest"), listOf(first, second).map { it.queryParameter("order") }) + assertEquals(listOf("true", "true"), listOf(first, second).map { it.queryParameter("include_compacted") }) + assertEquals(listOf("500", "500"), listOf(first, second).map { it.queryParameter("limit") }) assertEquals((0..500).map(Int::toString), messages.map { it.id }) } finally { server.shutdown() } } + @Test + fun getMessages_latestIncludesCompactedHistoryInOneBoundedPage() = runTest { + MockWebServer().use { server -> + server.enqueue(apiMessagePageResponse(start = 500, count = 500, returned = 500)) + + val messages = HermesApiClient(server.url("/").toString(), "test-key") + .getMessages("sess-a", SessionMessageLoadMode.LATEST) + + val url = server.takeRequest().requestUrl!! + assertEquals("true", url.queryParameter("include_compacted")) + assertEquals("500", url.queryParameter("limit")) + assertEquals("0", url.queryParameter("offset")) + assertEquals("latest", url.queryParameter("order")) + assertEquals(500, messages.size) + assertEquals(1, server.requestCount) + } + } + @Test fun getMessages_acceptsLegacyUnpaginatedApiEnvelope() = runTest { val server = MockWebServer() diff --git a/app/src/test/kotlin/com/hermesandroid/relay/network/upstream/SessionMessagePaginationTest.kt b/app/src/test/kotlin/com/hermesandroid/relay/network/upstream/SessionMessagePaginationTest.kt index 436f5620..8b50e4bb 100644 --- a/app/src/test/kotlin/com/hermesandroid/relay/network/upstream/SessionMessagePaginationTest.kt +++ b/app/src/test/kotlin/com/hermesandroid/relay/network/upstream/SessionMessagePaginationTest.kt @@ -76,6 +76,19 @@ class SessionMessagePaginationTest { } } + @Test + fun `message safety bound fails without returning a partial transcript`() = runTest { + var calls = 0 + val result = loadSessionMessages(SessionMessageLoadMode.COMPLETE) { request -> + calls++ + Result.success(page(request.offset, 500, returned = 500)) + } + + assertEquals(101, calls) + assertEquals(SessionTranscriptTooLargeException::class, result.exceptionOrNull()?.let { it::class }) + assertEquals(null, result.getOrNull()) + } + @Test fun `payload safety bound fails clearly before another page`() = runTest { val result = loadSessionMessages(SessionMessageLoadMode.COMPLETE) { diff --git a/docs/android-memory-recovery.md b/docs/android-memory-recovery.md index fcd2a335..b3ce8c3a 100644 --- a/docs/android-memory-recovery.md +++ b/docs/android-memory-recovery.md @@ -28,6 +28,13 @@ does not measure their contribution or demonstrate a retained-state leak. ## Binding without duplicate display history +Both HTTP transcript readers request `include_compacted=true` so reopening a +conversation retains turns archived by compaction. This remains independent of +Gateway `omit_messages`: HTTP reads still use 500-row pages, latest-only recovery +still makes one request, and complete reads fail at the existing 50,000-message +or 32-million-character payload limit. These are load bounds, not proof that any +transcript below them fits every device heap. + 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. diff --git a/test-fixtures/vanilla-gateway/tests/test_fixture.py b/test-fixtures/vanilla-gateway/tests/test_fixture.py index b738e95d..184b03a5 100644 --- a/test-fixtures/vanilla-gateway/tests/test_fixture.py +++ b/test-fixtures/vanilla-gateway/tests/test_fixture.py @@ -716,7 +716,7 @@ def test_active_list_scenario_rejects_unknown_status(self) -> None: def test_contract_manifests_select_upstream_contracts(self) -> None: self.assertEqual( - ("gateway.session_resume_durable",), + ("gateway.session_resume_durable", "gateway.session_activate_live"), load_scenario("initial_history_bind").contract_requirements, ) self.assertEqual( From db19018601c3ecfcf37abef965b355bf7e152c2d Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Mon, 19 Oct 2026 18:42:00 -0400 Subject: [PATCH 3/4] test(android): cover compacted history in hosted gates --- .github/workflows/ci-android.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci-android.yml b/.github/workflows/ci-android.yml index 06feefa3..f72ccb5b 100644 --- a/.github/workflows/ci-android.yml +++ b/.github/workflows/ci-android.yml @@ -182,6 +182,9 @@ jobs: run: | ./gradlew :app:testSideloadDebugUnitTest \ --tests com.hermesandroid.relay.network.ArchitectureBoundaryTest \ + --tests com.hermesandroid.relay.network.upstream.DashboardApiClientTest \ + --tests com.hermesandroid.relay.network.upstream.HermesApiClientTest \ + --tests com.hermesandroid.relay.network.upstream.SessionMessagePaginationTest \ --tests com.hermesandroid.relay.network.relay.RelayUrlDeriverTest \ --tests com.hermesandroid.relay.data.RelayEndpointContractTest \ --tests com.hermesandroid.relay.network.relay.RelayHttpClientSessionPolicyTest \ From d88380c492414ff14a7763e9290792b9f75e0e97 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Mon, 19 Oct 2026 18:42:25 -0400 Subject: [PATCH 4/4] test(android): include history suites in on-demand checks --- scripts/android-prepush.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/android-prepush.py b/scripts/android-prepush.py index 258f1311..58ca88c1 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.DashboardApiClientTest", + "com.hermesandroid.relay.network.upstream.HermesApiClientTest", + "com.hermesandroid.relay.network.upstream.SessionMessagePaginationTest", "com.hermesandroid.relay.network.upstream.GatewayPayloadMemoryTest", "com.hermesandroid.relay.viewmodel.ChatMemoryRecoveryTest", "com.hermesandroid.relay.reliability.ReliabilityReportTest",