From 9c7bb8635f60bb4ec042e8762364292548b7db4f Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:16:16 +0300 Subject: [PATCH] fix(chat): /queue submits with queued=true so the backend queues instead of redirecting (#892) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(chat): /queue submits with queued=true so the backend queues instead of redirecting The mobile app routed /queue through command.dispatch, whose backend shim only echoes the text back as a plain send payload. The re-submitted prompt.submit carried no queued flag, so a busy session applied the default busy_input_mode (interrupt) and REDIRECTED the live turn instead of queueing — verified on-device: backend replied {"status":"redirected"} and the queued message was dropped, never answered. Intercept /queue and /q client-side and submit prompt.submit directly with queued=true, which the gateway honors as run-after-never-interrupt regardless of display.busy_input_mode (hermes-agent methods_prompt.py _handle_busy_submit). Verified on the emulator: {"status":"queued"}, the running turn completed untouched, and the queued prompt ran as the next turn and was answered. * fix(chat): /queue bubble shows the queued text so the sync dedupes the echo The optimistic bubble for /queue carried the raw command ("/queue foo"), but the server only ever sees the stripped text ("foo") — the transcript sync's logical dedupe (sameLogicalMessage) can't match them, so the echoed user row survived as a duplicate that rendered BELOW its own answer. QueuePrompt now carries the display content: the stripped queued text (or the raw command when bare). handleSlashCommand classifies first and uses it for the optimistic bubble, so local and REST copies match verbatim and the merge collapses them into one row at the correct position. --------- Co-authored-by: M57 --- .../hermescontrol/data/ws/HermesWsClient.kt | 13 ++- .../hermescontrol/ui/chat/ChatViewModel.kt | 41 ++++++++- .../ui/chat/SlashCommandDispatcher.kt | 26 ++++++ .../ui/chat/ChatUpdateCommandTest.kt | 2 +- .../ui/chat/ChatViewModelTest.kt | 30 +++++- .../ui/chat/SlashCommandDispatchRpcTest.kt | 91 +++++++++++++++++-- .../ui/chat/SlashCommandDispatcherTest.kt | 27 ++++++ 7 files changed, 213 insertions(+), 17 deletions(-) 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.