feat(chat): rank slash autocomplete by usage (issue #865) (#874)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-10 19:56:01 +03:00
1 parent 5ab7ad16df
commit a9c3cd0691
9 files changed
+244 -3

No files matched your search

@@ -107,4 +107,40 @@ class ChatInputPolicyTest {
val value = ChatInputPolicy.commandFieldValue("/help")
assertTrue("cursor must be past the shared /h prefix", value.selection.start > 2)
}
// ── Slash suggestion ranking (issue #865) ───────────────────────────────
@Test
fun sortSlashSuggestions_mostUsedFirst_tiesKeepCatalogOrder() {
val commands = listOf("/help", "/model", "/new", "/stop")
val usage = mapOf("/stop" to 5, "/model" to 2)
assertEquals(
"most-used must surface first; equal counts keep catalog order",
listOf("/stop", "/model", "/help", "/new"),
ChatInputPolicy.sortSlashSuggestions(commands, usage),
)
}
@Test
fun sortSlashSuggestions_noUsage_keepsCatalogOrder() {
val commands = listOf("/help", "/model", "/new")
assertEquals(
"no history must fall back to catalog order unchanged",
commands,
ChatInputPolicy.sortSlashSuggestions(commands, emptyMap()),
)
}
@Test
fun sortSlashSuggestions_lookupIsCaseInsensitive() {
// Stored keys are always lowercase (normalized at dispatch time), but
// the lookup lowercases the catalog name so a mismatched case can
// never break the ranking.
val commands = listOf("/model", "/new")
assertEquals(
listOf("/model", "/new"),
ChatInputPolicy.sortSlashSuggestions(commands, mapOf("/MODEL" to 3)),
)
}
}
@@ -22,6 +22,7 @@ import com.m57.hermescontrol.data.ws.JsonRpcError
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.*
import io.mockk.coEvery
import io.mockk.coVerify
@@ -67,6 +68,7 @@ class ChatViewModelTest {
private val mockSwitchFlow = MutableSharedFlow<String>(extraBufferCapacity = 8)
private lateinit var app: Application
private lateinit var fakeRepo: FakeChatPersistenceRepository
private lateinit var fakeSlashUsageStore: FakeSlashUsageStore
/** Counter used to generate unique WS request IDs. */
private var reqCount = 0
@@ -114,6 +116,7 @@ class ChatViewModelTest {
app = mockk(relaxed = true)
fakeRepo = FakeChatPersistenceRepository()
fakeSlashUsageStore = FakeSlashUsageStore()
ActiveSessionHolder.set(null)
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
@@ -184,7 +187,7 @@ class ChatViewModelTest {
private fun createViewModel(startCleanup: Boolean = false): ChatViewModel =
// Both dispatchers injected — a real ioDispatcher would race the
// test scheduler (repo writes hop it) and shuffle RPC ordering.
ChatViewModel(app, startCleanup, fakeRepo, testDispatcher, testDispatcher)
ChatViewModel(app, startCleanup, fakeRepo, fakeSlashUsageStore, testDispatcher, testDispatcher)
/**
* Create ViewModel, simulate GatewayReady, feed SESSION_CREATE result,
@@ -402,6 +405,47 @@ class ChatViewModelTest {
verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
}
// ── Slash usage ranking (issue #865) ────────────────────────────────────
@Test
fun slashUsage_dispatchIncrementsCountPerCommand() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
every {
HermesWsClient.request(WsMethods.COMMAND_DISPATCH, any(), any())
} returns
CompletableDeferred(
mapOf("type" to "exec", "output" to "ok"),
)
viewModel.sendMessage("/help")
advanceUntilIdle()
viewModel.sendMessage("/help")
advanceUntilIdle()
viewModel.sendMessage("/new")
advanceUntilIdle()
assertEquals(2, viewModel.uiState.value.slashUsageCounts["/help"])
assertEquals(1, viewModel.uiState.value.slashUsageCounts["/new"])
}
@Test
fun slashUsage_blockedCommand_notCounted() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
viewModel.sendMessage("/clear")
advanceUntilIdle()
// A blocklisted command can never dispatch — it must not climb
// the autocomplete ranking either.
assertTrue(viewModel.uiState.value.slashUsageCounts.isEmpty())
verify(exactly = 0) {
HermesWsClient.request(WsMethods.COMMAND_DISPATCH, any(), any())
}
}
@Test
fun testSlashCommand_unknown_showsErrorMessage() =
runTest {
@@ -9,6 +9,7 @@ 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.every
import io.mockk.mockk
import io.mockk.mockkObject
@@ -126,7 +127,7 @@ class SlashCommandDispatchRpcTest {
}
private suspend fun TestScope.createViewModelWithSession(): Pair<ChatViewModel, String> {
val vm = ChatViewModel(app, false, fakeRepo, ioDispatcher = testDispatcher)
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null))
@@ -0,0 +1,26 @@
package com.m57.hermescontrol.ui.chat.fakes
import com.m57.hermescontrol.data.local.SlashUsageStore
import io.mockk.mockk
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
/**
* In-memory [SlashUsageStore] for ChatViewModel tests (issue #865).
*
* Mirrors the real store's semantics: [recordUse] bumps the count and
* [counts] emits the current map. The real store needs an Android Context
* (DataStore), which unit tests don't have — the relaxed mock is a dummy
* the overrides never touch (same pattern as the mocked Application in
* ChatViewModelTest).
*/
class FakeSlashUsageStore : SlashUsageStore(mockk(relaxed = true)) {
private val countsFlow = MutableStateFlow<Map<String, Int>>(emptyMap())
override fun counts(): Flow<Map<String, Int>> = countsFlow
override suspend fun recordUse(command: String) {
countsFlow.update { it + (command to ((it[command] ?: 0) + 1)) }
}
}