fix(chat): /queue submits with queued=true so the backend queues instead of redirecting (#892)

* 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 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-13 12:16:16 +03:00
1 parent f5ce07b196
commit 9c7bb8635f
7 files changed
+213 -17

No files matched your search

@@ -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 <prompt>")
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,
)
}
}
@@ -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()
}