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,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<SessionMessage>? = 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,
)
@@ -73,7 +73,6 @@ object CommandBlocklist {
"/image",
"/quit",
// TUI-only extras (meaningless outside the TUI)
"/compact",
"/logs",
"/mouse",
)
@@ -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"
@@ -11,6 +11,21 @@ import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
internal fun buildLegacyClarifyBatchResponses(
questions: List<ClarifyQuestionUi>,
answers: Map<String, String>,
lockedAnswers: Map<String, String>,
): List<Pair<String, String>> =
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<ChatUiState>,
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<String, Any>(
"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
@@ -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 ->
@@ -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<String, Any> {
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<SessionCompressResponse>(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<String>()
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<String, Any?>
handleSessionInfo(infoMap)
}
sessionHasServerPresence = true
}
private fun handlePrefillResult(
message: String,
notice: String,
@@ -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,
@@ -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()
}
@@ -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<String, @Composable () -> 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)
@@ -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.