feat(chat): support first-class /compress and /compact session compression (#1306)

This commit is contained in:
m57 authored and GitHub committed 2026-09-27 20:47:59 +03:00
1 parent 3c56026a1c
commit bbfcfada6b
13 files changed
+929 -6

No files matched your search

@@ -0,0 +1,533 @@
package com.m57.hermescontrol.ui.chat
import android.app.Application
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.model.CompressionSummary
import com.m57.hermescontrol.data.model.SessionCompressResponse
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ChatCompressRpcTest {
private val testDispatcher = StandardTestDispatcher()
private val mockEventsFlow = MutableSharedFlow<WsEvent>(extraBufferCapacity = 64)
private val mockConnectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED)
private lateinit var app: Application
private lateinit var fakeRepo: FakeChatPersistenceRepository
private var reqCount = 0
private var sessionCreateRequestId: String? = null
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
reqCount = 0
sessionCreateRequestId = null
mockkStatic(android.util.Log::class)
every { android.util.Log.d(any(), any()) } returns 0
every { android.util.Log.i(any(), any()) } returns 0
every { android.util.Log.w(any(), any<String>()) } returns 0
every { android.util.Log.e(any(), any(), any()) } returns 0
mockkObject(AuthManager)
every { AuthManager.getPinnedModels() } returns emptyList()
every { AuthManager.dataScopeFlow } returns MutableStateFlow<DataScope?>(null)
every { AuthManager.activeProfileId } returns MutableStateFlow<String?>(null)
every { AuthManager.currentDataScope() } returns null
every { AuthManager.getToken() } returns "test-token"
every { AuthManager.getBaseUrl() } returns "http://test.local/"
every { AuthManager.getSelectedProfileId() } returns null
every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.isMessageStatsEnabled() } returns false
every { AuthManager.isUserMessageTokensEnabled() } returns true
every { AuthManager.isAssistantMessageTokensEnabled() } returns true
every { AuthManager.isTokensPerSecondEnabled() } returns true
every { AuthManager.isModelProviderShown() } returns false
every { AuthManager.isAutoReconnect() } returns false
every { AuthManager.isRestoreLastSession() } returns false
every { AuthManager.getLastOpenedSessionId() } returns null
every { AuthManager.setLastOpenedSessionId(any()) } returns Unit
every { AuthManager.clearLastOpenedSessionId() } returns Unit
mockkObject(HermesWsClient)
every { HermesWsClient.events } returns mockEventsFlow
every { HermesWsClient.connectionStatus } returns mockConnectionStatus
every { HermesWsClient.connect() } answers {
mockConnectionStatus.value = ConnectionStatus.CONNECTING
}
every { HermesWsClient.disconnect() } returns Unit
every { HermesWsClient.send(any(), any(), any()) } answers {
reqCount++
val id = "req-id-$reqCount"
if (arg<String>(0) == WsMethods.SESSION_CREATE) {
sessionCreateRequestId = id
}
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
reqCount++
val id = "req-msg-$reqCount"
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
every { HermesWsClient.request(any(), any(), any()) } answers {
HermesWsClient.send(arg(0), arg(1)) {}
CompletableDeferred<Any?>(Unit)
}
mockkObject(ApiClient)
coEvery { ApiClient.hermesApi.getModelInfo() } returns retrofit2.Response.success(mockk(relaxed = true))
coEvery { ApiClient.hermesApi.getProfiles() } returns
retrofit2.Response.success(
com.m57.hermescontrol.data.model
.ProfilesResponse(emptyList()),
)
mockkObject(HermesDatabase)
mockkObject(ProfileSwitchCoordinator)
every { ProfileSwitchCoordinator.switched } returns MutableSharedFlow<String>()
every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow<String>()
app = mockk(relaxed = true)
fakeRepo = FakeChatPersistenceRepository()
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
private suspend fun TestScope.createViewModelWithSession(): Pair<ChatViewModel, String> {
val vm =
ChatViewModel(
app,
startCleanup = false,
repo = fakeRepo,
slashUsageStore = FakeSlashUsageStore(),
searchDispatcher = testDispatcher,
ioDispatcher = testDispatcher,
historyDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
val createRequestId = requireNotNull(sessionCreateRequestId) { "session.create was not sent" }
mockEventsFlow.emit(WsEvent.RpcResult(createRequestId, mapOf("session_id" to "session-xyz")))
advanceUntilIdle()
return Pair(vm, "session-xyz")
}
@Test
fun `compress calls SESSION_COMPRESS not command dispatch or slash exec`() =
runTest {
val (vm, _) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
every {
HermesWsClient.request(capture(methodCalls), any(), any())
} answers {
CompletableDeferred<Any?>(null)
}
vm.sendMessage("/compress")
advanceUntilIdle()
assertTrue("expected SESSION_COMPRESS in $methodCalls", WsMethods.SESSION_COMPRESS in methodCalls)
assertTrue("expected NO command.dispatch", WsMethods.COMMAND_DISPATCH !in methodCalls)
assertTrue("expected NO slash.exec", "slash.exec" !in methodCalls)
}
@Test
fun `compress request parameters contain session_id and omit focus_topic when blank`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
CompletableDeferred<Any?>(null)
}
vm.sendMessage("/compress")
advanceUntilIdle()
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
val params = paramsCalls[compressIndex]
assertEquals(sessionId, params["session_id"])
assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic"))
}
@Test
fun `compress request parameters forward focus_topic when non-blank`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
CompletableDeferred<Any?>(null)
}
vm.sendMessage("/compress auth decisions")
advanceUntilIdle()
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
val params = paramsCalls[compressIndex]
assertEquals(sessionId, params["session_id"])
assertEquals("auth decisions", params["focus_topic"])
}
@Test
fun `compact behaves identically to compress omitting focus_topic when blank`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
CompletableDeferred<Any?>(null)
}
vm.sendMessage("/compact")
advanceUntilIdle()
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
val params = paramsCalls[compressIndex]
assertEquals(sessionId, params["session_id"])
assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic"))
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
}
@Test
fun `compact forwards focus_topic when non-blank`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
CompletableDeferred<Any?>(null)
}
vm.sendMessage("/compact architecture details")
advanceUntilIdle()
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
val params = paramsCalls[compressIndex]
assertEquals(sessionId, params["session_id"])
assertEquals("architecture details", params["focus_topic"])
}
@Test
fun `final response summary headline token_line note added to messages`() =
runTest {
val (vm, _) = createViewModelWithSession()
val responseJson =
buildJsonObject {
put("status", JsonPrimitive("ok"))
put(
"summary",
buildJsonObject {
put("headline", JsonPrimitive("Context compressed"))
put("token_line", JsonPrimitive("Tokens: 120k -> 35k (70% saved)"))
put("note", JsonPrimitive("Trimmed 14 earlier turns"))
},
)
}
every {
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
} answers {
CompletableDeferred<Any?>(responseJson)
}
vm.sendMessage("/compress")
advanceUntilIdle()
val lastAssistantMessage =
vm.uiState.value.messages
.lastOrNull { it.role == MessageRole.ASSISTANT }
val expectedContent = "Context compressed\nTokens: 120k -> 35k (70% saved)\nTrimmed 14 earlier turns"
assertEquals(expectedContent, lastAssistantMessage?.content)
}
@Test
fun `returned messages replace and reconcile visible live transcript`() =
runTest {
val (vm, _) = createViewModelWithSession()
val serverMessages =
listOf(
SessionMessage(
id = 101,
role = "user",
content = JsonPrimitive("Original prompt"),
),
SessionMessage(
id = 102,
role = "assistant",
content = JsonPrimitive("Compressed response"),
),
)
val responseJson =
buildJsonObject {
put("status", JsonPrimitive("ok"))
put(
"summary",
buildJsonObject {
put("headline", JsonPrimitive("Summary headline"))
},
)
put(
"messages",
buildJsonArray {
add(
buildJsonObject {
put("id", JsonPrimitive(101))
put("role", JsonPrimitive("user"))
put("content", JsonPrimitive("Original prompt"))
},
)
add(
buildJsonObject {
put("id", JsonPrimitive(102))
put("role", JsonPrimitive("assistant"))
put("content", JsonPrimitive("Compressed response"))
},
)
},
)
}
every {
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
} answers {
CompletableDeferred<Any?>(responseJson)
}
vm.sendMessage("/compress")
advanceUntilIdle()
val messages = vm.uiState.value.messages
// The transcript contains serverMessages, plus the assistant summary feedback
val userMsg = messages.firstOrNull { it.role == MessageRole.USER && it.content == "Original prompt" }
assertEquals("Original prompt", userMsg?.content)
val serverAssistantMsg =
messages.firstOrNull {
it.role == MessageRole.ASSISTANT && it.content == "Compressed response"
}
assertTrue("expected replacement assistant message", serverAssistantMsg != null)
val summaryMsg = messages.lastOrNull { it.role == MessageRole.ASSISTANT }
assertEquals("Summary headline", summaryMsg?.content)
}
@Test
fun `fetchContextUsage is triggered after successful compression`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
every {
HermesWsClient.request(capture(methodCalls), any(), any())
} answers {
val method = arg<String>(0)
if (method == WsMethods.SESSION_COMPRESS) {
val responseJson =
buildJsonObject {
put("status", JsonPrimitive("ok"))
put("message", JsonPrimitive("Context compressed."))
}
CompletableDeferred<Any?>(responseJson)
} else {
CompletableDeferred<Any?>(Unit)
}
}
methodCalls.clear()
vm.sendMessage("/compress")
advanceUntilIdle()
// fetchContextUsage calls SESSION_CONTEXT_BREAKDOWN and SESSION_USAGE with sessionId
assertTrue(
"expected SESSION_CONTEXT_BREAKDOWN in $methodCalls",
WsMethods.SESSION_CONTEXT_BREAKDOWN in methodCalls,
)
assertTrue(
"expected SESSION_USAGE in $methodCalls",
WsMethods.SESSION_USAGE in methodCalls,
)
}
@Test
fun `isCompressing is reset to false on success`() =
runTest {
val (vm, _) = createViewModelWithSession()
val deferred = CompletableDeferred<Any?>()
every {
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
} returns deferred
vm.sendMessage("/compress")
// Progress before completion
testDispatcher.scheduler.runCurrent()
assertTrue("isCompressing should be true while in flight", vm.uiState.value.isCompressing)
assertEquals("⏳ Compressing context...", vm.uiState.value.compressionStatus)
deferred.complete(
buildJsonObject {
put("status", JsonPrimitive("ok"))
},
)
advanceUntilIdle()
assertFalse("isCompressing should be reset to false", vm.uiState.value.isCompressing)
assertEquals(null, vm.uiState.value.compressionStatus)
}
@Test
fun `isCompressing is reset to false on RPC error`() =
runTest {
val (vm, _) = createViewModelWithSession()
val deferred = CompletableDeferred<Any?>()
every {
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
} returns deferred
vm.sendMessage("/compress")
testDispatcher.scheduler.runCurrent()
assertTrue(vm.uiState.value.isCompressing)
deferred.completeExceptionally(
HermesWsClient.HermesRpcException("compression failed on backend", 4018),
)
advanceUntilIdle()
assertFalse("isCompressing should be false on RPC error", vm.uiState.value.isCompressing)
assertEquals(null, vm.uiState.value.compressionStatus)
val errorMsg =
vm.uiState.value.messages
.lastOrNull { it.role == MessageRole.ASSISTANT }
assertTrue(errorMsg?.content?.contains("compression failed on backend") == true)
}
@Test
fun `isCompressing is reset to false on generic exception`() =
runTest {
val (vm, _) = createViewModelWithSession()
val deferred = CompletableDeferred<Any?>()
every {
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
} returns deferred
vm.sendMessage("/compress")
testDispatcher.scheduler.runCurrent()
assertTrue(vm.uiState.value.isCompressing)
deferred.completeExceptionally(RuntimeException("network disconnected"))
advanceUntilIdle()
assertFalse("isCompressing should be false on generic exception", vm.uiState.value.isCompressing)
assertEquals(null, vm.uiState.value.compressionStatus)
val errorMsg =
vm.uiState.value.messages
.lastOrNull { it.role == MessageRole.ASSISTANT }
assertTrue(errorMsg?.content?.contains("network disconnected") == true)
}
@Test
fun `duplicate compress while isCompressing is true is a no-op`() =
runTest {
val (vm, _) = createViewModelWithSession()
var compressCalls = 0
val deferred = CompletableDeferred<Any?>()
every {
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
} answers {
compressCalls++
deferred
}
vm.sendMessage("/compress")
testDispatcher.scheduler.runCurrent()
assertTrue(vm.uiState.value.isCompressing)
assertEquals(1, compressCalls)
// Second invocation while in flight
vm.sendMessage("/compress")
testDispatcher.scheduler.runCurrent()
assertEquals("duplicate call should not trigger another RPC", 1, compressCalls)
// Also check /compact is blocked while in flight
vm.sendMessage("/compact")
testDispatcher.scheduler.runCurrent()
assertEquals("compact while compressing should not trigger another RPC", 1, compressCalls)
deferred.complete(null)
advanceUntilIdle()
assertFalse(vm.uiState.value.isCompressing)
}
}
@@ -64,6 +64,28 @@ class ChatPromptProtocolTest {
assertEquals("new answer", answers?.get("q1")?.jsonPrimitive?.content)
}
@Test
fun legacyClarifyBatchReplay_skipsLockedAnswers_withoutEmptyOverwrite() {
val responses =
buildLegacyClarifyBatchResponses(
questions =
listOf(
ClarifyQuestionUi(qid = "q0", question = "Already answered?"),
ClarifyQuestionUi(qid = "q1", question = "Still waiting?"),
ClarifyQuestionUi(qid = "q2", question = "Optional skip?"),
),
answers =
mapOf(
"q0" to "",
"q1" to " new answer ",
"q2" to "",
),
lockedAnswers = mapOf("q0" to "yes"),
)
assertEquals(listOf("q1" to "new answer", "q2" to ""), responses)
}
@Test
fun credentialPrompt_serverRequestId_controls_response_and_cancellation() {
val scope = TestScope(dispatcher)
@@ -1478,4 +1478,131 @@ class ChatWsEventReducerTest {
assertTrue(result.state.messages.none { it.id != "orphan" })
assertTrue(!result.streamingState.turnUsageBaselineCaptured)
}
@Test
fun testStatusUpdate_compressingState() {
val initialMessage = ChatMessage(role = MessageRole.USER, content = "hello")
val state =
ChatUiState(
currentSessionId = "session-1",
messages = listOf(initialMessage),
isCompressing = false,
compressionStatus = null,
)
val event =
WsEvent.StatusUpdate(
status = null,
data =
mapOf(
"kind" to "compressing",
"text" to "⏳ Compressing context...",
),
)
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
event = event,
currentSessionId = "session-1",
)
assertTrue(result.state.isCompressing)
assertEquals("⏳ Compressing context...", result.state.compressionStatus)
assertEquals(listOf(initialMessage), result.state.messages)
}
@Test
fun testStatusUpdate_compactingState() {
val state =
ChatUiState(
currentSessionId = "session-1",
isCompressing = false,
compressionStatus = null,
)
val event =
WsEvent.StatusUpdate(
status = null,
data =
mapOf(
"kind" to "compacting",
"text" to "🗜️ Compacting...",
),
)
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
event = event,
currentSessionId = "session-1",
)
assertTrue(result.state.isCompressing)
assertEquals("🗜️ Compacting...", result.state.compressionStatus)
}
@Test
fun testStatusUpdate_compactedState() {
val state =
ChatUiState(
currentSessionId = "session-1",
isCompressing = true,
compressionStatus = "🗜️ Compacting...",
)
val event =
WsEvent.StatusUpdate(
status = null,
data =
mapOf(
"kind" to "compacted",
"text" to "Finished",
),
)
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
event = event,
currentSessionId = "session-1",
)
assertFalse(result.state.isCompressing)
// Accepts either "Finished" or null if cleared
assertTrue(result.state.compressionStatus == "Finished" || result.state.compressionStatus == null)
}
@Test
fun testStatusUpdate_unrelatedKindIgnored() {
val initialMessages = listOf(ChatMessage(role = MessageRole.USER, content = "test"))
val state =
ChatUiState(
currentSessionId = "session-1",
messages = initialMessages,
isCompressing = false,
compressionStatus = "idle",
)
val event =
WsEvent.StatusUpdate(
status = null,
data =
mapOf(
"kind" to "other",
"text" to "Something else",
),
)
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
event = event,
currentSessionId = "session-1",
)
assertFalse(result.state.isCompressing)
assertEquals("idle", result.state.compressionStatus)
assertEquals(initialMessages, result.state.messages)
}
}
@@ -98,6 +98,15 @@ class SlashCommandDispatcherTest {
assertEquals(SlashResult.Undo(""), dispatcher.dispatch("/Undo"))
}
@Test
fun `compress and compact route to Compress`() {
assertEquals(SlashResult.Compress(""), dispatcher.dispatch("/compress"))
assertEquals(SlashResult.Compress(""), dispatcher.dispatch("/compact"))
assertEquals(SlashResult.Compress("auth decisions"), dispatcher.dispatch("/compress auth decisions"))
assertEquals(SlashResult.Compress("auth decisions"), dispatcher.dispatch("/compact auth decisions"))
assertEquals(SlashResult.Compress(""), dispatcher.dispatch("/compress "))
}
@Test
fun `NEW uppercase still routes to NewSession`() {
// Dispatcher lower-cases before matching, so case must not matter.