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:
1 parent
f5ce07b196
commit
9c7bb8635f
7 files changed
+213
-17
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<String>()
|
||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||
val sentSession = slot<String>()
|
||||
val sentText = slot<String>()
|
||||
val sentQueued = slot<Boolean>()
|
||||
every {
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
} answers {
|
||||
CompletableDeferred<Any?>(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<String>()
|
||||
var sendCalls = 0
|
||||
every {
|
||||
HermesWsClient.request(capture(rpcMethods), any(), any())
|
||||
} answers {
|
||||
CompletableDeferred<Any?>(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 <prompt>", 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<String>()
|
||||
val sentQueued = slot<Boolean>()
|
||||
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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user