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",
|
||||
"/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.
|
||||
|
||||
Reference in new issue
Block a user