feat(chat): support first-class /compress and /compact session compression (#1306)
This commit is contained in:
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",
|
"/image",
|
||||||
"/quit",
|
"/quit",
|
||||||
// TUI-only extras (meaningless outside the TUI)
|
// TUI-only extras (meaningless outside the TUI)
|
||||||
"/compact",
|
|
||||||
"/logs",
|
"/logs",
|
||||||
"/mouse",
|
"/mouse",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ object WsMethods {
|
|||||||
const val SESSION_TITLE = "session.title"
|
const val SESSION_TITLE = "session.title"
|
||||||
const val SESSION_BRANCH = "session.branch"
|
const val SESSION_BRANCH = "session.branch"
|
||||||
const val SESSION_BRANCH_WHOLE = "session.branch_whole"
|
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. */
|
/** Replay recorded events newer than client's last-seen seq for a session. */
|
||||||
const val SESSION_EVENTS_SINCE = "session.events.since"
|
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.buildJsonObject
|
||||||
import kotlinx.serialization.json.put
|
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(
|
class ChatClarifyDelegate(
|
||||||
private val uiState: MutableStateFlow<ChatUiState>,
|
private val uiState: MutableStateFlow<ChatUiState>,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
@@ -114,14 +129,17 @@ class ChatClarifyDelegate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isBatch) {
|
if (isBatch) {
|
||||||
for (q in questions) {
|
for ((questionId, ans) in buildLegacyClarifyBatchResponses(
|
||||||
val ans = answers[q.qid]?.trim().orEmpty()
|
questions = questions,
|
||||||
|
answers = answers,
|
||||||
|
lockedAnswers = lockedAnswers,
|
||||||
|
)) {
|
||||||
val params =
|
val params =
|
||||||
mutableMapOf<String, Any>(
|
mutableMapOf<String, Any>(
|
||||||
"session_id" to sessionId,
|
"session_id" to sessionId,
|
||||||
"response" to ans,
|
"response" to ans,
|
||||||
"answer" to ans,
|
"answer" to ans,
|
||||||
"question_id" to q.qid,
|
"question_id" to questionId,
|
||||||
)
|
)
|
||||||
if (clarifyId != null) {
|
if (clarifyId != null) {
|
||||||
params["clarify_id"] = clarifyId
|
params["clarify_id"] = clarifyId
|
||||||
|
|||||||
@@ -807,6 +807,8 @@ fun ChatScreen(
|
|||||||
onSaveAttachment = onSaveAttachment,
|
onSaveAttachment = onSaveAttachment,
|
||||||
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
||||||
openingAttachmentPath = state.openingAttachmentPath,
|
openingAttachmentPath = state.openingAttachmentPath,
|
||||||
|
isCompressing = state.isCompressing && !timelineState.isHistorical,
|
||||||
|
compressionStatus = state.compressionStatus.takeUnless { timelineState.isHistorical },
|
||||||
onImageClick = { viewingImage = it },
|
onImageClick = { viewingImage = it },
|
||||||
speakingMessageId = speakingMessageId,
|
speakingMessageId = speakingMessageId,
|
||||||
onToggleSpeak = { message ->
|
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.ModelCapabilities
|
||||||
import com.m57.hermescontrol.data.model.ModelProvider
|
import com.m57.hermescontrol.data.model.ModelProvider
|
||||||
import com.m57.hermescontrol.data.model.PinnedModel
|
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.SessionTimelineEntry
|
||||||
import com.m57.hermescontrol.data.model.UsageSnapshotResponse
|
import com.m57.hermescontrol.data.model.UsageSnapshotResponse
|
||||||
import com.m57.hermescontrol.data.model.parseContextBreakdown
|
import com.m57.hermescontrol.data.model.parseContextBreakdown
|
||||||
@@ -234,6 +235,8 @@ data class ChatUiState(
|
|||||||
// until the first successful session.usage fetch) — drives the
|
// until the first successful session.usage fetch) — drives the
|
||||||
// "compressed ×N" badge on the context chip.
|
// "compressed ×N" badge on the context chip.
|
||||||
val compressionCount: Int? = null,
|
val compressionCount: Int? = null,
|
||||||
|
val isCompressing: Boolean = false,
|
||||||
|
val compressionStatus: String? = null,
|
||||||
/** Rolling output tokens/sec over the last ~10 calls. */
|
/** Rolling output tokens/sec over the last ~10 calls. */
|
||||||
val latestTps: Double? = null,
|
val latestTps: Double? = null,
|
||||||
/** Latest cumulative backend usage snapshot for the current session. */
|
/** Latest cumulative backend usage snapshot for the current session. */
|
||||||
@@ -3701,6 +3704,10 @@ class ChatViewModel(
|
|||||||
handleUndoCommand(result.count)
|
handleUndoCommand(result.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is SlashResult.Compress -> {
|
||||||
|
compressSession(result.focusTopic)
|
||||||
|
}
|
||||||
|
|
||||||
is SlashResult.RpcDispatch -> {
|
is SlashResult.RpcDispatch -> {
|
||||||
dispatchViaRpc(command)
|
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(
|
private fun handlePrefillResult(
|
||||||
message: String,
|
message: String,
|
||||||
notice: String,
|
notice: String,
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ object ChatWsEventReducer {
|
|||||||
|
|
||||||
is WsEvent.SessionUpdated -> onSessionUpdated(state, streamingState)
|
is WsEvent.SessionUpdated -> onSessionUpdated(state, streamingState)
|
||||||
|
|
||||||
is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState)
|
is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState, event)
|
||||||
|
|
||||||
is WsEvent.ConnectionRequest,
|
is WsEvent.ConnectionRequest,
|
||||||
is WsEvent.ConnectionUpdate,
|
is WsEvent.ConnectionUpdate,
|
||||||
@@ -1046,7 +1046,38 @@ object ChatWsEventReducer {
|
|||||||
private fun onStatusUpdate(
|
private fun onStatusUpdate(
|
||||||
state: ChatUiState,
|
state: ChatUiState,
|
||||||
streamingState: StreamingState,
|
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(
|
private fun onUnknown(
|
||||||
state: ChatUiState,
|
state: ChatUiState,
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ class SlashCommandDispatcher {
|
|||||||
SlashResult.Undo(count = arg)
|
SlashResult.Undo(count = arg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"/compress", "/compact" -> {
|
||||||
|
val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim()
|
||||||
|
SlashResult.Compress(focusTopic = arg)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
SlashResult.RpcDispatch
|
SlashResult.RpcDispatch
|
||||||
}
|
}
|
||||||
@@ -172,4 +177,11 @@ sealed class SlashResult {
|
|||||||
data class Undo(
|
data class Undo(
|
||||||
val count: String,
|
val count: String,
|
||||||
) : SlashResult()
|
) : 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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -107,6 +111,8 @@ fun FullBleedChatList(
|
|||||||
replyErrorContent: (@Composable () -> Unit)? = null,
|
replyErrorContent: (@Composable () -> Unit)? = null,
|
||||||
speakingMessageId: String? = null,
|
speakingMessageId: String? = null,
|
||||||
onToggleSpeak: ((ChatMessage) -> Unit)? = null,
|
onToggleSpeak: ((ChatMessage) -> Unit)? = null,
|
||||||
|
isCompressing: Boolean = false,
|
||||||
|
compressionStatus: String? = null,
|
||||||
) {
|
) {
|
||||||
if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) {
|
if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) {
|
||||||
Box(
|
Box(
|
||||||
@@ -149,6 +155,23 @@ fun FullBleedChatList(
|
|||||||
val tailItems =
|
val tailItems =
|
||||||
buildMap<String, @Composable () -> Unit> {
|
buildMap<String, @Composable () -> Unit> {
|
||||||
replyErrorContent?.let { put("reply_error", it) }
|
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 ->
|
agentStatus?.let { status ->
|
||||||
put("agent_status") {
|
put("agent_status") {
|
||||||
AgentStatusIndicator(status = 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)
|
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
|
@Test
|
||||||
fun credentialPrompt_serverRequestId_controls_response_and_cancellation() {
|
fun credentialPrompt_serverRequestId_controls_response_and_cancellation() {
|
||||||
val scope = TestScope(dispatcher)
|
val scope = TestScope(dispatcher)
|
||||||
|
|||||||
@@ -1478,4 +1478,131 @@ class ChatWsEventReducerTest {
|
|||||||
assertTrue(result.state.messages.none { it.id != "orphan" })
|
assertTrue(result.state.messages.none { it.id != "orphan" })
|
||||||
assertTrue(!result.streamingState.turnUsageBaselineCaptured)
|
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"))
|
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
|
@Test
|
||||||
fun `NEW uppercase still routes to NewSession`() {
|
fun `NEW uppercase still routes to NewSession`() {
|
||||||
// Dispatcher lower-cases before matching, so case must not matter.
|
// Dispatcher lower-cases before matching, so case must not matter.
|
||||||
|
|||||||
Reference in new issue
Block a user