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)