diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index b14e6e35..e9742e19 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -716,10 +716,21 @@ object HermesWsClient { sessionId: String, text: String, onSent: ((String) -> Unit)? = null, + queued: Boolean = false, ): String { + val params = + buildMap { + put("session_id", sessionId) + put("text", text) + // Explicit queue semantics: the gateway's busy-input policy + // forces "run after, never interrupt" when prompt.submit + // carries queued=true (hermes-agent methods_prompt.py + // _handle_busy_submit) — used by /queue. + if (queued) put("queued", true) + } return send( method = WsMethods.PROMPT_SUBMIT, - params = mapOf("session_id" to sessionId, "text" to text), + params = params, onSent = onSent, ) } 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 3b5ef0f7..f156f8e8 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 @@ -1396,7 +1396,14 @@ class ChatViewModel( fun clearAttachments() = attachmentsDelegate.clearAttachments() private fun handleSlashCommand(command: String) { - val userMsg = ChatMessage(role = MessageRole.USER, content = command) + // Classify FIRST (pure logic) — /queue's optimistic bubble must show + // the queued TEXT (prefix stripped, see QueuePrompt.displayContent) so + // it matches the server echo and the transcript sync dedupes instead + // of rendering a duplicate below its answer. + val result = slashDispatcher.dispatch(command) + val displayContent = + if (result is SlashResult.QueuePrompt) result.displayContent else command + val userMsg = ChatMessage(role = MessageRole.USER, content = displayContent) val sessionId = _uiState.value.currentSessionId _uiState.update { it.copy(messages = it.messages + userMsg) } @@ -1428,7 +1435,7 @@ class ChatViewModel( slashUsageStore.recordUse(commandName) } - when (val result = slashDispatcher.dispatch(command)) { + when (result) { is SlashResult.Interrupt -> { interruptSession() } @@ -1456,12 +1463,34 @@ class ChatViewModel( _uiState.update { it.copy(openHistoryRequested = true) } } + is SlashResult.QueuePrompt -> { + handleQueueCommand(command) + } + is SlashResult.RpcDispatch -> { dispatchViaRpc(command) } } } + /** + * Queue a prompt to run after the current turn (backend contract + * `prompt.submit` `queued=true` — hermes-agent methods_prompt.py:147, + * _handle_busy_submit). The gateway then enqueues it as the next turn + * and NEVER redirects/interrupts the live turn, regardless of + * `display.busy_input_mode`. Intercepted client-side because the + * `command.dispatch` `queue` shim only echoes the text back as a plain + * submit, which loses the queued flag and hijacks the live turn. + */ + private fun handleQueueCommand(command: String) { + val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim() + if (arg.isBlank()) { + addAssistantMessage("usage: /queue ") + return + } + submitPrompt(arg, queued = true) + } + // ── 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 @@ -1634,9 +1663,12 @@ class ChatViewModel( /** * Submits [text] as a prompt to the current session via WS, without * adding a duplicate user message. Used by [handleDispatchResult] when - * a slash command resolves to a normal user prompt (e.g. `/queue` → "help me"). + * a slash command resolves to a normal user prompt (e.g. `/init` → "Scan this repo"). */ - private fun submitPrompt(text: String) { + private fun submitPrompt( + text: String, + queued: Boolean = false, + ) { if (text.isBlank()) return val sessionId = runtimeSessionId ?: return _uiState.update { it.copy(isAgentTyping = true) } @@ -1645,6 +1677,7 @@ class ChatViewModel( sessionId, text, onSent = { id -> trackRequest(id, WsMethods.PROMPT_SUBMIT) }, + queued = queued, ) } } 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 aea64bc5..51d6af68 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 @@ -31,6 +31,16 @@ class SlashCommandDispatcher { "/fork", "/branch" -> SlashResult.SessionBranch "/model" -> SlashResult.ModelSwitch "/update" -> SlashResult.Update + "/queue", "/q" -> { + val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim() + // Bubble shows the queued TEXT (prefix stripped) so the + // optimistic user message matches the server echo exactly — + // otherwise the transcript sync keeps both copies (logical + // dedupe can't match "/queue foo" vs "foo") and the echoed + // bubble lands below its answer. Bare command keeps the raw + // text so the usage message has something to sit under. + SlashResult.QueuePrompt(displayContent = arg.ifBlank { command }) + } "/resume", "/history" -> SlashResult.OpenHistory else -> SlashResult.RpcDispatch } @@ -80,4 +90,20 @@ sealed class SlashResult { * user picks a past session from history and resumes it from there. */ data object OpenHistory : SlashResult() + + /** + * Queue a prompt to run after the current turn. NOT command.dispatch — + * the backend's `queue` shim (methods_tools.py) only echoes the text back + * as a `send` payload that re-submits as a normal prompt, losing the + * queue semantics (a busy session then REDIRECTS the live turn instead — + * verified on-device). The ViewModel submits directly via `prompt.submit` + * with `queued=true`, which the gateway honors as "run after, never + * interrupt" regardless of `display.busy_input_mode`. + * + * [displayContent] is the optimistic user-bubble text: the queued text + * with the command prefix stripped (or the raw command when there is no + * argument). Matching the server echo verbatim lets the transcript sync + * dedupe the two copies of the same logical message. + */ + data class QueuePrompt(val displayContent: String) : SlashResult() } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt index 941526d8..f5a4ec18 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt @@ -114,7 +114,7 @@ class ChatUpdateCommandTest { arg<((String) -> Unit)?>(2)?.invoke(id) id } - every { HermesWsClient.sendMessage(any(), any(), any()) } answers { + every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers { reqCount++ val id = "req-msg-$reqCount" arg<((String) -> Unit)?>(2)?.invoke(id) 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 68313689..59780a0c 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 @@ -83,6 +83,34 @@ class ChatViewModelTest { assertEquals(listOf(latest), merged) } + @Test + fun mergeTranscriptWithLive_matchingUserContentCollapsesLocalAndRestCopies() { + // /queue bubbles show the stripped queued text, so the optimistic + // local copy and the later REST echo share content — the sync merge + // must collapse them into one row instead of rendering a duplicate + // below its answer (PR #892 follow-up). + val local = + ChatMessage( + id = "ws-local-1", + role = MessageRole.USER, + content = "do the thing", + timestamp = 100L, + ) + val rest = + ChatMessage( + id = "rest-sess-5", + role = MessageRole.USER, + content = "do the thing", + timestamp = 100L, + ) + + val merged = mergeTranscriptWithLive(listOf(rest), listOf(local)) + + assertEquals(1, merged.size) + // The richer local copy wins when both sides carry the same content. + assertEquals("ws-local-1", merged.single().id) + } + @Before fun setUp() { Dispatchers.setMain(testDispatcher) @@ -144,7 +172,7 @@ class ChatViewModelTest { arg<((String) -> Unit)?>(2)?.invoke(id) id } - every { HermesWsClient.sendMessage(any(), any(), any()) } answers { + every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers { reqCount++ val id = "req-msg-$reqCount" arg<((String) -> Unit)?>(2)?.invoke(id) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt index 0d3343b8..350f666a 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt @@ -108,7 +108,7 @@ class SlashCommandDispatchRpcTest { arg<((String) -> Unit)?>(2)?.invoke(id) id } - every { HermesWsClient.sendMessage(any(), any(), any()) } answers { + every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers { reqCount++ val id = "req-msg-$reqCount" arg<((String) -> Unit)?>(2)?.invoke(id) @@ -181,28 +181,99 @@ class SlashCommandDispatchRpcTest { } @Test - fun `slash command with args forwards arg separately`() = + fun `slash queue submits the arg with queued=true instead of command dispatch`() = runTest { val (vm, sessionId) = createViewModelWithSession() val methodCalls = mutableListOf() val paramsCalls = mutableListOf>() + val sentSession = slot() + val sentText = slot() + val sentQueued = slot() every { HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) } answers { CompletableDeferred(Unit) } + every { + HermesWsClient.sendMessage( + capture(sentSession), + capture(sentText), + any(), + capture(sentQueued), + ) + } answers { + "queued-msg-1" + } vm.sendMessage("/queue do the thing") advanceUntilIdle() - // Find the dispatch record (capture race — see the /help test). - val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH) - assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0) - val params = paramsCalls[dispatchIndex] - assertEquals("queue", params["name"]) - assertEquals("do the thing", params["arg"]) - assertEquals(sessionId, params["session_id"]) + // /queue must NOT ride command.dispatch — the backend shim returns + // a "send" payload that re-submits WITHOUT the queued flag, and a + // busy session then redirects the live turn (verified on-device: + // {"status":"redirected"} while sleep 60 was running, message lost). + assertTrue( + "expected NO command.dispatch for /queue, got $methodCalls", + WsMethods.COMMAND_DISPATCH !in methodCalls, + ) + assertEquals(sessionId, sentSession.captured) + assertEquals("do the thing", sentText.captured) + assertEquals(true, sentQueued.captured) + // The optimistic bubble shows the queued TEXT (prefix stripped) so + // the transcript sync dedupes it against the server echo. + val userBubble = + vm.uiState.value.messages.lastOrNull { it.role == MessageRole.USER } + assertEquals("do the thing", userBubble?.content) + } + + @Test + fun `slash queue with no arg shows usage and sends nothing`() = + runTest { + val (vm, _) = createViewModelWithSession() + + val rpcMethods = mutableListOf() + var sendCalls = 0 + every { + HermesWsClient.request(capture(rpcMethods), any(), any()) + } answers { + CompletableDeferred(Unit) + } + every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers { + sendCalls++ + "queued-msg-1" + } + + vm.sendMessage("/queue") + advanceUntilIdle() + + val last = vm.uiState.value.messages.lastOrNull() + assertEquals("usage: /queue ", last?.content) + assertEquals(0, sendCalls) + assertTrue( + "expected NO command.dispatch for bare /queue, got $rpcMethods", + WsMethods.COMMAND_DISPATCH !in rpcMethods, + ) + } + + @Test + fun `slash q alias queues the arg with queued=true`() = + runTest { + val (vm, _) = createViewModelWithSession() + + val sentText = slot() + val sentQueued = slot() + every { + HermesWsClient.sendMessage(any(), capture(sentText), any(), capture(sentQueued)) + } answers { + "queued-msg-1" + } + + vm.sendMessage("/q hey there") + advanceUntilIdle() + + assertEquals("hey there", sentText.captured) + assertEquals(true, sentQueued.captured) } @Test @@ -521,7 +592,7 @@ class SlashCommandDispatchRpcTest { } // submitPrompt() forwards the returned prompt via wsClient.sendMessage. every { - HermesWsClient.sendMessage(any(), capture(sentText), any()) + HermesWsClient.sendMessage(any(), capture(sentText), any(), any()) } answers { val id = "send-msg-1" arg<((String) -> Unit)?>(2)?.invoke(id) 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 1559caed..6c72abe9 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 @@ -81,6 +81,33 @@ class SlashCommandDispatcherTest { assertEquals(SlashResult.NewSession, dispatcher.dispatch("/New")) } + @Test + fun `queue routes to QueuePrompt with stripped display content`() { + // /queue queues the prompt for after the current turn (PR #892). The + // optimistic bubble shows the queued TEXT, not the raw command, so the + // transcript sync dedupes it against the server echo ("/queue foo" vs + // "foo" would never match logically and both would render). + assertEquals( + SlashResult.QueuePrompt("do the thing"), + dispatcher.dispatch("/queue do the thing"), + ) + assertEquals( + SlashResult.QueuePrompt("hi"), + dispatcher.dispatch("/q hi"), + ) + assertEquals( + SlashResult.QueuePrompt("hi"), + dispatcher.dispatch("/QUEUE hi"), + ) + } + + @Test + fun `bare queue keeps the raw command as display content`() { + // No argument -> the usage message renders under the raw command. + assertEquals(SlashResult.QueuePrompt("/queue"), dispatcher.dispatch("/queue")) + assertEquals(SlashResult.QueuePrompt("/q"), dispatcher.dispatch("/q")) + } + @Test fun `command with args forwards to RpcDispatch`() { // Anything not /stop, /interrupt, /new goes to the backend.