From bbfcfada6b71058a04c3d298675db751b13164e1 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:47:59 +0300 Subject: [PATCH] feat(chat): support first-class /compress and /compact session compression (#1306) --- .../data/model/SessionCompressModels.kt | 32 ++ .../hermescontrol/data/ws/CommandModels.kt | 1 - .../m57/hermescontrol/data/ws/WsMethods.kt | 1 + .../ui/chat/ChatClarifyDelegate.kt | 24 +- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 2 + .../hermescontrol/ui/chat/ChatViewModel.kt | 114 ++++ .../ui/chat/ChatWsEventReducer.kt | 35 +- .../ui/chat/SlashCommandDispatcher.kt | 12 + .../ui/chat/fullbleed/FullBleedChatList.kt | 23 + .../ui/chat/ChatCompressRpcTest.kt | 533 ++++++++++++++++++ .../ui/chat/ChatPromptProtocolTest.kt | 22 + .../ui/chat/ChatWsEventReducerTest.kt | 127 +++++ .../ui/chat/SlashCommandDispatcherTest.kt | 9 + 13 files changed, 929 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/SessionCompressModels.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionCompressModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionCompressModels.kt new file mode 100644 index 00000000..8a9555db --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionCompressModels.kt @@ -0,0 +1,32 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class SessionCompressResponse( + val status: String? = null, + val removed: Int? = null, + val before_messages: Int? = null, + val after_messages: Int? = null, + val before_tokens: Long? = null, + val after_tokens: Long? = null, + val summary: CompressionSummary? = null, + val compressed: Boolean? = null, + val lock_held: Boolean? = null, + val message: String? = null, + val messages: List? = null, + val usage: JsonElement? = null, + val info: JsonElement? = null, +) + +@Serializable +data class CompressionSummary( + val noop: Boolean = false, + val aborted: Boolean = false, + val refused_would_grow: Boolean? = null, + val fallback_used: Boolean? = null, + val headline: String = "", + val token_line: String = "", + val note: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt index 0e0fb7c4..411cce9b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt @@ -73,7 +73,6 @@ object CommandBlocklist { "/image", "/quit", // TUI-only extras (meaningless outside the TUI) - "/compact", "/logs", "/mouse", ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index a6de9886..f2e07a5a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -16,6 +16,7 @@ object WsMethods { const val SESSION_TITLE = "session.title" const val SESSION_BRANCH = "session.branch" const val SESSION_BRANCH_WHOLE = "session.branch_whole" + const val SESSION_COMPRESS = "session.compress" /** Replay recorded events newer than client's last-seen seq for a session. */ const val SESSION_EVENTS_SINCE = "session.events.since" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt index 566d7f0e..92bce3f6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt @@ -11,6 +11,21 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put +internal fun buildLegacyClarifyBatchResponses( + questions: List, + answers: Map, + lockedAnswers: Map, +): List> = + questions + .asSequence() + // A replayed pending_clarify can contain answers already locked by the + // server. ClarifyBubble has no editable input for those rows, so its + // answer map carries an empty placeholder. Never send that placeholder + // back through legacy clarify.respond and overwrite accepted state. + .filterNot { it.qid in lockedAnswers } + .map { it.qid to answers[it.qid]?.trim().orEmpty() } + .toList() + class ChatClarifyDelegate( private val uiState: MutableStateFlow, private val scope: CoroutineScope, @@ -114,14 +129,17 @@ class ChatClarifyDelegate( } if (isBatch) { - for (q in questions) { - val ans = answers[q.qid]?.trim().orEmpty() + for ((questionId, ans) in buildLegacyClarifyBatchResponses( + questions = questions, + answers = answers, + lockedAnswers = lockedAnswers, + )) { val params = mutableMapOf( "session_id" to sessionId, "response" to ans, "answer" to ans, - "question_id" to q.qid, + "question_id" to questionId, ) if (clarifyId != null) { params["clarify_id"] = clarifyId diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 6dd3b8e9..a3e8dada 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -807,6 +807,8 @@ fun ChatScreen( onSaveAttachment = onSaveAttachment, savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath, openingAttachmentPath = state.openingAttachmentPath, + isCompressing = state.isCompressing && !timelineState.isHistorical, + compressionStatus = state.compressionStatus.takeUnless { timelineState.isHistorical }, onImageClick = { viewingImage = it }, speakingMessageId = speakingMessageId, onToggleSpeak = { message -> diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index ff2e727a..465e4356 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -14,6 +14,7 @@ import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.model.ModelCapabilities import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel +import com.m57.hermescontrol.data.model.SessionCompressResponse import com.m57.hermescontrol.data.model.SessionTimelineEntry import com.m57.hermescontrol.data.model.UsageSnapshotResponse import com.m57.hermescontrol.data.model.parseContextBreakdown @@ -234,6 +235,8 @@ data class ChatUiState( // until the first successful session.usage fetch) — drives the // "compressed ×N" badge on the context chip. val compressionCount: Int? = null, + val isCompressing: Boolean = false, + val compressionStatus: String? = null, /** Rolling output tokens/sec over the last ~10 calls. */ val latestTps: Double? = null, /** Latest cumulative backend usage snapshot for the current session. */ @@ -3701,6 +3704,10 @@ class ChatViewModel( handleUndoCommand(result.count) } + is SlashResult.Compress -> { + compressSession(result.focusTopic) + } + is SlashResult.RpcDispatch -> { dispatchViaRpc(command) } @@ -4125,6 +4132,113 @@ class ChatViewModel( } } + private fun compressSession(focusTopic: String) { + val sessionId = runtimeSessionId ?: _uiState.value.currentSessionId + if (sessionId == null) { + addAssistantMessage("No active session to compress.") + return + } + if (_uiState.value.isCompressing) { + return + } + + _uiState.update { + it.copy( + isCompressing = true, + compressionStatus = "⏳ Compressing context...", + ) + } + + viewModelScope.launch(ioDispatcher) { + try { + val params = + buildMap { + put("session_id", sessionId) + if (focusTopic.isNotBlank()) { + put("focus_topic", focusTopic) + } + } + // Use extended timeout (e.g. 300_000L / 5 minutes) so LLM summarization doesn't timeout + val result = + wsClient + .request( + WsMethods.SESSION_COMPRESS, + params, + timeoutMs = 300_000L, + ).await() + + handleCompressionResult(result) + } catch (e: HermesWsClient.HermesRpcException) { + addAssistantMessage("/compress: ${e.message ?: "compression failed"}") + } catch (e: Exception) { + addAssistantMessage("/compress: ${e.message ?: "compression failed"}") + } finally { + _uiState.update { + it.copy( + isCompressing = false, + compressionStatus = null, + ) + } + } + } + } + + private suspend fun handleCompressionResult(result: Any?) { + val response = + try { + val jsonElement = result.toJsonElement() + OkHttpProvider.json.decodeFromJsonElement(jsonElement) + } catch (e: Exception) { + Log.e(TAG, "Failed to decode session.compress response", e) + null + } + + val sessionId = runtimeSessionId ?: _uiState.value.currentSessionId + + // 1. Authoritative transcript replacement + if (response?.messages != null && sessionId != null) { + val replacementMessages = + withContext(historyDispatcher) { + mapServerMessages( + sessionId = sessionId, + messages = response.messages, + offset = 0, + latestPaging = latestPaging, + liveMessages = emptyList(), + context = getApplication(), + ) + } + _uiState.update { it.copy(messages = replacementMessages) } + persistHistoryPage(replacementMessages, sessionId) + } + + // 2. Display result summary via addAssistantMessage + val summary = response?.summary + val lines = mutableListOf() + summary?.headline?.takeIf { it.isNotBlank() }?.let { lines.add(it) } + summary?.token_line?.takeIf { it.isNotBlank() }?.let { lines.add(it) } + summary?.note?.takeIf { it.isNotBlank() }?.let { lines.add(it) } + + val feedback = + if (lines.isNotEmpty()) { + lines.joinToString("\n") + } else { + response?.message ?: "Context compressed." + } + addAssistantMessage(feedback) + + // 3. Refresh usage & sessions + fetchContextUsage() + loadSessions() + + // 4. Handle lineage/session info if provided + response?.info?.let { infoElement -> + val infoMap = (infoElement.toAny() as? Map<*, *>)?.filterKeys { it is String } as? Map + handleSessionInfo(infoMap) + } + sessionHasServerPresence = true + } + private fun handlePrefillResult( message: String, notice: String, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index f5dad2b2..1a0c983b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -208,7 +208,7 @@ object ChatWsEventReducer { is WsEvent.SessionUpdated -> onSessionUpdated(state, streamingState) - is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState) + is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState, event) is WsEvent.ConnectionRequest, is WsEvent.ConnectionUpdate, @@ -1046,7 +1046,38 @@ object ChatWsEventReducer { private fun onStatusUpdate( state: ChatUiState, streamingState: StreamingState, - ): ReducerResult = ReducerResult(state = state, streamingState = streamingState) + event: WsEvent.StatusUpdate, + ): ReducerResult { + val kind = event.data?.get("kind") as? String + val text = event.data?.get("text") as? String + return when (kind) { + "compressing", "compacting" -> { + ReducerResult( + state = + state.copy( + isCompressing = true, + compressionStatus = text ?: "⏳ Compressing context...", + ), + streamingState = streamingState, + ) + } + + "compacted" -> { + ReducerResult( + state = + state.copy( + isCompressing = false, + compressionStatus = text, + ), + streamingState = streamingState, + ) + } + + else -> { + ReducerResult(state = state, streamingState = streamingState) + } + } + } private fun onUnknown( state: ChatUiState, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt index b1e49b0a..c1aba996 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt @@ -77,6 +77,11 @@ class SlashCommandDispatcher { SlashResult.Undo(count = arg) } + "/compress", "/compact" -> { + val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim() + SlashResult.Compress(focusTopic = arg) + } + else -> { SlashResult.RpcDispatch } @@ -172,4 +177,11 @@ sealed class SlashResult { data class Undo( val count: String, ) : SlashResult() + + /** + * Compress session context via the session.compress WebSocket RPC. + */ + data class Compress( + val focusTopic: String, + ) : SlashResult() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt index 6d0bb55b..0deaff27 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt @@ -3,11 +3,15 @@ package com.m57.hermescontrol.ui.chat.fullbleed import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -107,6 +111,8 @@ fun FullBleedChatList( replyErrorContent: (@Composable () -> Unit)? = null, speakingMessageId: String? = null, onToggleSpeak: ((ChatMessage) -> Unit)? = null, + isCompressing: Boolean = false, + compressionStatus: String? = null, ) { if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) { Box( @@ -149,6 +155,23 @@ fun FullBleedChatList( val tailItems = buildMap Unit> { replyErrorContent?.let { put("reply_error", it) } + if (isCompressing || compressionStatus != null) { + put("compression_status") { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = compressionStatus ?: "⏳ Compressing context...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } agentStatus?.let { status -> put("agent_status") { AgentStatusIndicator(status = status) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt new file mode 100644 index 00000000..daed040b --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt @@ -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(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()) } returns 0 + every { android.util.Log.e(any(), any(), any()) } returns 0 + + mockkObject(AuthManager) + every { AuthManager.getPinnedModels() } returns emptyList() + every { AuthManager.dataScopeFlow } returns MutableStateFlow(null) + every { AuthManager.activeProfileId } returns MutableStateFlow(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(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(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() + every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow() + + app = mockk(relaxed = true) + fakeRepo = FakeChatPersistenceRepository() + mockConnectionStatus.value = ConnectionStatus.DISCONNECTED + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + private suspend fun TestScope.createViewModelWithSession(): Pair { + 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() + every { + HermesWsClient.request(capture(methodCalls), any(), any()) + } answers { + CompletableDeferred(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() + val paramsCalls = mutableListOf>() + every { + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) + } answers { + CompletableDeferred(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() + val paramsCalls = mutableListOf>() + every { + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) + } answers { + CompletableDeferred(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() + val paramsCalls = mutableListOf>() + every { + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) + } answers { + CompletableDeferred(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() + val paramsCalls = mutableListOf>() + every { + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) + } answers { + CompletableDeferred(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(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(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() + every { + HermesWsClient.request(capture(methodCalls), any(), any()) + } answers { + val method = arg(0) + if (method == WsMethods.SESSION_COMPRESS) { + val responseJson = + buildJsonObject { + put("status", JsonPrimitive("ok")) + put("message", JsonPrimitive("Context compressed.")) + } + CompletableDeferred(responseJson) + } else { + CompletableDeferred(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() + 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() + 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() + 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() + 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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPromptProtocolTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPromptProtocolTest.kt index 55f61e9d..412c20f7 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPromptProtocolTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPromptProtocolTest.kt @@ -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) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt index 7b5814ae..6d3dc887 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt @@ -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) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt index b85b4f23..272ad6ea 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt @@ -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.