From 53c8eca714a89361aab4071b03da8c2b68d6f71f Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:54:09 +0300 Subject: [PATCH] feat: adopt side-questions via prompt.btw and /btw command in Chat (fixes #1015) (#1026) Co-authored-by: M57 --- .../m57/hermescontrol/data/ws/EventParser.kt | 7 + .../com/m57/hermescontrol/data/ws/WsEvent.kt | 12 + .../m57/hermescontrol/data/ws/WsMethods.kt | 1 + .../m57/hermescontrol/ui/chat/ChatScreen.kt | 8 + .../hermescontrol/ui/chat/ChatViewModel.kt | 92 +++++++ .../ui/chat/ChatWsEventReducer.kt | 33 +++ .../ui/chat/SlashCommandDispatcher.kt | 14 + .../ui/chat/components/ChatComposer.kt | 4 +- .../ui/chat/components/SideQuestionSheet.kt | 239 ++++++++++++++++++ .../ui/chat/ChatViewModelTest.kt | 34 +++ .../ui/chat/ChatWsEventReducerTest.kt | 37 +++ .../ui/chat/SlashCommandDispatcherTest.kt | 8 + 12 files changed, 487 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/SideQuestionSheet.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index 453c8d58..e9287308 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -163,6 +163,13 @@ object EventParser { WsEvent.ReviewSummary(text, sessionId) } + "btw.complete" -> { + val taskId = payload?.get("task_id") as? String ?: "" + val question = payload?.get("question") as? String ?: "" + val text = (payload?.get("text") as? String)?.trim() ?: "" + WsEvent.BtwComplete(taskId, question, text, sessionId) + } + "session.updated" -> { WsEvent.SessionUpdated(payload) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index fd10205a..57558b90 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -150,6 +150,18 @@ sealed class WsEvent { val sessionId: String? = null, ) : WsEvent() + /** + * Context-aware side-question completion event (issue #1015). + * Emitted when `prompt.btw` completes answering a side question. + * Payload: `{ task_id: "...", question: "...", text: "..." }` + */ + data class BtwComplete( + val taskId: String = "", + val question: String = "", + val text: String = "", + val sessionId: String? = null, + ) : WsEvent() + // ── Status ─────────────────────────────────────────────────────────── data class StatusUpdate( diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index 1401ac15..d6f4a3ea 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -25,6 +25,7 @@ object WsMethods { // ── Interaction ─────────────────────────────────────────────────────── const val PROMPT_SUBMIT = "prompt.submit" + const val PROMPT_BTW = "prompt.btw" const val CLARIFY_RESPOND = "clarify.respond" const val APPROVAL_RESPOND = "approval.respond" const val SUDO_RESPOND = "sudo.respond" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 08f31bbe..364d14d2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -100,6 +100,7 @@ import com.m57.hermescontrol.ui.chat.components.ContextUsageChip import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay import com.m57.hermescontrol.ui.chat.components.ReloginDialog import com.m57.hermescontrol.ui.chat.components.SearchBarRow +import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet import com.m57.hermescontrol.ui.chat.components.TaskProgressChip import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController @@ -933,6 +934,13 @@ fun ChatScreen( ) } + state.btwState?.let { btw -> + SideQuestionSheet( + state = btw, + onDismiss = { viewModel.dismissBtw() }, + ) + } + viewingImage?.let { image -> ImageViewerDialog( image = image, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 74bde447..a167db16 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -353,6 +353,8 @@ data class ChatUiState( val reactionKind: String? = null, /** Monotonic trigger ID so consecutive same-kind reactions re-animate. */ val reactionTriggerId: Long = 0L, + /** Side-question state for /btw (issue #1015). */ + val btwState: BtwUiState? = null, /** Subagent delegation indicators (issue #538) — transient UI state. */ val subagentIndicators: List = emptyList(), /** Agent todo / plan items (issue #736). */ @@ -379,6 +381,17 @@ data class ClarifyUi( val clarifyId: String? = null, ) +/** + * State for the context-aware side-question bottom sheet (issue #1015, `/btw`). + */ +data class BtwUiState( + val taskId: String? = null, + val question: String = "", + val answer: String? = null, + val isLoading: Boolean = true, + val error: String? = null, +) + /** * String sent to the agent when a clarify prompt is dismissed (the Dismiss * button). This is a *reject* — "I'm not answering this question" — NOT an @@ -1546,6 +1559,12 @@ class ChatViewModel( // it matches the server echo and the transcript sync dedupes instead // of rendering a duplicate below its answer. val result = slashDispatcher.dispatch(command) + + if (result is SlashResult.SideQuestion) { + handleSideQuestionCommand(result.question) + return + } + val displayContent = if (result is SlashResult.QueuePrompt) result.displayContent else command val userMsg = ChatMessage(role = MessageRole.USER, content = displayContent) @@ -1627,6 +1646,10 @@ class ChatViewModel( handleQueueCommand(command) } + is SlashResult.SideQuestion -> { + handleSideQuestionCommand(result.question) + } + is SlashResult.RpcDispatch -> { dispatchViaRpc(command) } @@ -1651,6 +1674,75 @@ class ChatViewModel( submitPrompt(arg, queued = true) } + // ── Side Questions via /btw (issue #1015) ───────────────────────────── + + fun dismissBtw() { + _uiState.update { it.copy(btwState = null) } + } + + fun submitSideQuestion(question: String) { + val trimmed = question.trim() + if (trimmed.isBlank()) { + addAssistantMessage( + "Usage: `/btw ` — Ask a side question about this session without mutating its history.", + ) + return + } + val sessionId = runtimeSessionId ?: _uiState.value.currentSessionId + if (sessionId.isNullOrBlank()) { + addAssistantMessage("No active session for side questions. Start a chat first.") + return + } + + _uiState.update { + it.copy( + btwState = + BtwUiState( + question = trimmed, + isLoading = true, + ), + ) + } + + viewModelScope.launch(ioDispatcher) { + try { + val rpcResult = + wsClient + .request( + WsMethods.PROMPT_BTW, + mapOf("session_id" to sessionId, "text" to trimmed), + ).await() + val taskId = (rpcResult as? Map<*, *>)?.get("task_id") as? String + if (!taskId.isNullOrBlank()) { + _uiState.update { state -> + state.btwState?.let { current -> + state.copy(btwState = current.copy(taskId = taskId)) + } ?: state + } + } + } catch (e: Exception) { + _uiState.update { state -> + state.btwState?.let { current -> + state.copy( + btwState = + current.copy( + isLoading = false, + error = e.message ?: "Failed to dispatch side question", + ), + ) + } ?: state + } + } + } + } + + private fun handleSideQuestionCommand(question: String) { + viewModelScope.launch { + slashUsageStore.recordUse("btw") + } + submitSideQuestion(question) + } + // ── Update from chat (issue #862) ──────────────────────────────────── // `/update` can't travel via the slash worker (the backend handler is // interactive + session-exiting → guaranteed 45s timeout). Intercept it diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 7fe01143..acf51088 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -71,6 +71,7 @@ object ChatWsEventReducer { is WsEvent.ToolGenerating -> event.sessionId is WsEvent.SubagentEvent -> event.sessionId is WsEvent.ReviewSummary -> event.sessionId + is WsEvent.BtwComplete -> event.sessionId is WsEvent.SessionUsage -> event.sessionId else -> null } @@ -129,6 +130,8 @@ object ChatWsEventReducer { is WsEvent.ReviewSummary -> onReviewSummary(state, streamingState, event) + is WsEvent.BtwComplete -> onBtwComplete(state, streamingState, event) + is WsEvent.SessionUsage -> onSessionUsage(state, streamingState, event) is WsEvent.RpcError -> onRpcError(state, streamingState, event) @@ -652,6 +655,36 @@ object ChatWsEventReducer { ) } + // ── BtwComplete (Side question completed) ───────────────────────────── + + private fun onBtwComplete( + state: ChatUiState, + streamingState: StreamingState, + event: WsEvent.BtwComplete, + ): ReducerResult { + val currentBtw = state.btwState + val updatedBtw = + if (currentBtw != null) { + currentBtw.copy( + taskId = event.taskId.ifBlank { currentBtw.taskId }, + question = event.question.ifBlank { currentBtw.question }, + answer = event.text, + isLoading = false, + ) + } else { + BtwUiState( + taskId = event.taskId, + question = event.question, + answer = event.text, + isLoading = false, + ) + } + return ReducerResult( + state = state.copy(btwState = updatedBtw), + streamingState = streamingState, + ) + } + // ── RpcError ────────────────────────────────────────────────────── private fun onRpcError( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt index 0117879e..8cef91ab 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt @@ -61,6 +61,11 @@ class SlashCommandDispatcher { SlashResult.OpenHistory } + "/btw" -> { + val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim() + SlashResult.SideQuestion(question = arg) + } + else -> { SlashResult.RpcDispatch } @@ -129,4 +134,13 @@ sealed class SlashResult { data class QueuePrompt( val displayContent: String, ) : SlashResult() + + /** + * Context-aware side question about the current session (issue #1015). + * Dispatched via the `prompt.btw` RPC. Answers without mutating the + * session's history or invalidating prompt caching. + */ + data class SideQuestion( + val question: String, + ) : SlashResult() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index 891b93ee..e018f609 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -136,8 +136,8 @@ fun ChatInputBar( // also enforced at dispatch time (issue #576, deliverable #3). val hiddenSlashDisplay = CommandBlocklist.UNSUPPORTED val commandNames = - commandCatalog.pairs - .map { it[0] } + (commandCatalog.pairs.map { it[0] } + listOf("/btw", "/queue", "/fork", "/model", "/new", "/stop")) + .distinct() .filter { it.lowercase() !in hiddenSlashDisplay } androidx.compose.animation.AnimatedVisibility( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SideQuestionSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SideQuestionSheet.kt new file mode 100644 index 00000000..606dc07a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SideQuestionSheet.kt @@ -0,0 +1,239 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.HelpOutline +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.ui.chat.BtwUiState +import com.m57.hermescontrol.ui.chat.MarkdownText + +/** + * Bottom sheet displaying a context-aware side question and its answer (issue #1015, `/btw`). + * Answers from a conversation snapshot without mutating the chat transcript. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SideQuestionSheet( + state: BtwUiState, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val context = LocalContext.current + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier.testTag("side_question_sheet"), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp) + .verticalScroll(rememberScrollState()), + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.AutoMirrored.Filled.HelpOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Side Question", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + IconButton( + onClick = onDismiss, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Question Banner + if (state.question.isNotBlank()) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "Question", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = state.question, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(modifier = Modifier.height(12.dp)) + } + + // Loading state + if (state.isLoading) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + LinearProgressIndicator( + modifier = + Modifier + .fillMaxWidth() + .height(4.dp), + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Answering from conversation snapshot...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Error state + if (!state.error.isNullOrBlank()) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.ErrorOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = state.error, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + } + + // Answer state + val answer = state.answer + if (!answer.isNullOrBlank()) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + border = + androidx.compose.foundation.BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ), + ) { + Column(modifier = Modifier.padding(14.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Answer", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + IconButton( + onClick = { + val clipboard = + context.getSystemService( + Context.CLIPBOARD_SERVICE, + ) as? ClipboardManager + clipboard?.setPrimaryClip(ClipData.newPlainText("Side Question Answer", answer)) + Toast.makeText(context, "Copied answer", Toast.LENGTH_SHORT).show() + }, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy answer", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + MarkdownText( + text = answer, + textColor = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 6494f7e5..27e20089 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -478,6 +478,40 @@ class ChatViewModelTest { verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } } + @Test + fun testSlashCommand_btw_dispatchesPromptBtw_andSetsBtwState() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + + every { + HermesWsClient.request(WsMethods.PROMPT_BTW, any(), any()) + } returns + CompletableDeferred( + mapOf("task_id" to "btw_123456"), + ) + + viewModel.sendMessage("/btw which file was that in?") + advanceUntilIdle() + + val btw = viewModel.uiState.value.btwState + assertNotNull(btw) + assertEquals("which file was that in?", btw?.question) + assertEquals("btw_123456", btw?.taskId) + assertTrue(btw?.isLoading == true) + verify { + HermesWsClient.request( + WsMethods.PROMPT_BTW, + mapOf("session_id" to sessionId, "text" to "which file was that in?"), + any(), + ) + } + // Ensure transcript messages were NOT polluted with the side question + assertTrue( + viewModel.uiState.value.messages + .none { it.content.contains("which file was that in?") }, + ) + } + // ── Slash usage ranking (issue #865) ──────────────────────────────────── @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt index 848bee17..da930fb0 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt @@ -535,6 +535,43 @@ class ChatWsEventReducerTest { assertEquals("💾 Self-improvement review: Skill 'android-ci' patched", msg.content) } + @Test + fun testBtwComplete_updatesBtwState() { + val state = + ChatUiState( + currentSessionId = "session-1", + btwState = + BtwUiState( + question = "what model?", + isLoading = true, + ), + ) + val event = + WsEvent.BtwComplete( + taskId = "btw_123456", + question = "what model?", + text = "This conversation uses Gemini 3.7.", + sessionId = "session-1", + ) + + val result = + ChatWsEventReducer.reduce( + state = state, + streamingState = StreamingState(), + event = event, + currentSessionId = "session-1", + ) + + val btw = result.state.btwState + org.junit.Assert.assertNotNull(btw) + assertEquals("btw_123456", btw?.taskId) + assertEquals("what model?", btw?.question) + assertEquals("This conversation uses Gemini 3.7.", btw?.answer) + assertEquals(false, btw?.isLoading) + // Zero messages appended to transcript! + assertEquals(0, result.state.messages.size) + } + // ── Issue #771: reasoning survives a tool call (regression) ──────────── // // Real gateway capture for `run echo hi` (reasoning model): diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt index 6c72abe9..6f5db563 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt @@ -74,6 +74,14 @@ class SlashCommandDispatcherTest { assertEquals(SlashResult.OpenHistory, dispatcher.dispatch("/History")) } + @Test + fun `btw routes to SideQuestion`() { + assertEquals(SlashResult.SideQuestion("what model is this?"), dispatcher.dispatch("/btw what model is this?")) + assertEquals(SlashResult.SideQuestion("what model is this?"), dispatcher.dispatch("/BTW what model is this?")) + assertEquals(SlashResult.SideQuestion(""), dispatcher.dispatch("/btw")) + assertEquals(SlashResult.SideQuestion(""), dispatcher.dispatch("/btw ")) + } + @Test fun `NEW uppercase still routes to NewSession`() { // Dispatcher lower-cases before matching, so case must not matter.