Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci-android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1361,10 +1361,13 @@ class DashboardApiClient(
): Result<List<MessageItem>> = 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -730,11 +730,15 @@ class HermesApiClient(
): List<MessageItem> = 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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 })
}

Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
7 changes: 7 additions & 0 deletions docs/android-memory-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions scripts/android-prepush.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion test-fixtures/vanilla-gateway/tests/test_fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading