feat: queue prompts while the agent is running (#598)
Allow the chat input to be sent while the agent is mid-turn or awaiting
approval. The backend's prompt.submit busy-input policy already queues any
prompt that lands during a running turn (tui_gateway/server.py:_handle_busy_submit)
and drains it as the next turn, so the message is never dropped.
- ChatInputPolicy: pure, testable send/placeholder decision logic
- ChatScreen: canSend stays enabled while typing/approving; placeholder shows
'Queued — sends when agent is free' when text is present during a busy turn
- ChatInputPolicyTest: truth-table covering typing, approval-pending,
disconnected, empty, attachment, and slash-command cases
- strings: chat_input_placeholder_queue
Note: this supersedes the Genesi599 fork's approach (dispatchViaRpc('queue ...')),
which double-submits and relies on a 4018-prone command.dispatch path. The
backend-queued prompt.submit path is the correct, single-submission contract.
Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
1 parent
d1fc477bcd
commit
5376d46683
4 files changed
+135
-4
No files matched your search
@@ -0,0 +1,35 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure, testable decision logic for the chat input bar.
|
||||||
|
*
|
||||||
|
* The gateway's `prompt.submit` busy-input policy queues any prompt that lands
|
||||||
|
* while the agent is mid-turn (tui_gateway/server.py:_handle_busy_submit), so a
|
||||||
|
* message sent while the agent is typing or awaiting approval is never dropped —
|
||||||
|
* it runs as the next turn. These helpers keep that contract explicit and
|
||||||
|
* unit-testable without a Compose/Android harness.
|
||||||
|
*/
|
||||||
|
object ChatInputPolicy {
|
||||||
|
/**
|
||||||
|
* Whether the send affordance should be enabled.
|
||||||
|
*
|
||||||
|
* Unlike slash commands (which were always allowed mid-turn), regular
|
||||||
|
* prompts are now allowed too: the backend queues them. The only gates are
|
||||||
|
* a non-empty input (or a pending attachment) and an active connection.
|
||||||
|
*/
|
||||||
|
fun canSend(
|
||||||
|
text: String,
|
||||||
|
pendingAttachments: List<Any>,
|
||||||
|
isConnected: Boolean,
|
||||||
|
): Boolean = (text.isNotBlank() || pendingAttachments.isNotEmpty()) && isConnected
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the input placeholder should read "queued" rather than the plain
|
||||||
|
* "waiting" hint. Shown only while the agent is typing AND the user has
|
||||||
|
* already typed something — i.e. a press of send would enqueue a turn.
|
||||||
|
*/
|
||||||
|
fun showQueuePlaceholder(
|
||||||
|
text: String,
|
||||||
|
isAgentTyping: Boolean,
|
||||||
|
): Boolean = isAgentTyping && text.isNotBlank()
|
||||||
|
}
|
||||||
@@ -700,11 +700,12 @@ private fun ChatInputBar(
|
|||||||
onFileTap: () -> Unit = {},
|
onFileTap: () -> Unit = {},
|
||||||
onRemoveAttachment: (Int) -> Unit = {},
|
onRemoveAttachment: (Int) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
// Allow sending slash commands even while agent is typing
|
// Allow sending while the agent is mid-turn or awaiting approval: the
|
||||||
|
// gateway's prompt.submit busy-input policy queues it as the next turn
|
||||||
|
// (tui_gateway/server.py:_handle_busy_submit), so the message is never
|
||||||
|
// dropped. Slash commands were already allowed; regular prompts now are too.
|
||||||
val isSlashCommand = inputText.startsWith("/")
|
val isSlashCommand = inputText.startsWith("/")
|
||||||
val canSend =
|
val canSend = ChatInputPolicy.canSend(inputText, pendingAttachments, isConnected)
|
||||||
(inputText.isNotBlank() || pendingAttachments.isNotEmpty()) &&
|
|
||||||
isConnected && (!isAgentTyping || isSlashCommand)
|
|
||||||
|
|
||||||
// Attachment menu state
|
// Attachment menu state
|
||||||
var showAttachmentMenu by remember { mutableStateOf(false) }
|
var showAttachmentMenu by remember { mutableStateOf(false) }
|
||||||
@@ -916,6 +917,8 @@ private fun ChatInputBar(
|
|||||||
Text(
|
Text(
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
stringResource(R.string.chat_input_placeholder_not_connected)
|
stringResource(R.string.chat_input_placeholder_not_connected)
|
||||||
|
} else if (ChatInputPolicy.showQueuePlaceholder(inputText, isAgentTyping)) {
|
||||||
|
stringResource(R.string.chat_input_placeholder_queue)
|
||||||
} else if (isAgentTyping) {
|
} else if (isAgentTyping) {
|
||||||
stringResource(R.string.chat_input_placeholder_waiting)
|
stringResource(R.string.chat_input_placeholder_waiting)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -112,6 +112,7 @@
|
|||||||
<string name="chat_reasoning_collapse">Collapse reasoning</string>
|
<string name="chat_reasoning_collapse">Collapse reasoning</string>
|
||||||
<string name="chat_input_placeholder_not_connected">Not connected\u2026</string>
|
<string name="chat_input_placeholder_not_connected">Not connected\u2026</string>
|
||||||
<string name="chat_input_placeholder_waiting">Waiting for response\u2026</string>
|
<string name="chat_input_placeholder_waiting">Waiting for response\u2026</string>
|
||||||
|
<string name="chat_input_placeholder_queue">Queued \u2014 sends when agent is free\u2026</string>
|
||||||
<string name="chat_input_placeholder_type_message">Type a message\u2026</string>
|
<string name="chat_input_placeholder_type_message">Type a message\u2026</string>
|
||||||
<string name="chat_tool_stderr">Error Output (stderr):\n%1$s</string>
|
<string name="chat_tool_stderr">Error Output (stderr):\n%1$s</string>
|
||||||
<string name="chat_tool_execution_error">Execution Error: %1$s</string>
|
<string name="chat_tool_execution_error">Execution Error: %1$s</string>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue #589-followup — Queue prompts while the agent is running.
|
||||||
|
*
|
||||||
|
* Asserts the input-bar decision contract the backend relies on: a prompt sent
|
||||||
|
* while the agent is typing or awaiting approval must be allowed (the gateway
|
||||||
|
* queues it via prompt.submit's busy-input policy), and the placeholder should
|
||||||
|
* signal "queued" when the user has text ready during a busy turn.
|
||||||
|
*/
|
||||||
|
class ChatInputPolicyTest {
|
||||||
|
@Test
|
||||||
|
fun canSend_allowsRegularPromptWhileAgentTyping() {
|
||||||
|
assertTrue(
|
||||||
|
"regular prompt must be sendable while agent is typing",
|
||||||
|
ChatInputPolicy.canSend("next task", emptyList(), isConnected = true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun canSend_allowsRegularPromptWhileApprovalPending() {
|
||||||
|
// Approval-pending surfaces as isAgentTyping=false in this client, but
|
||||||
|
// the gateway still queues the prompt.submit — connection + non-empty
|
||||||
|
// text are the only gates, so it must be allowed.
|
||||||
|
assertTrue(
|
||||||
|
"regular prompt must be sendable while an approval is pending",
|
||||||
|
ChatInputPolicy.canSend("yes, proceed", emptyList(), isConnected = true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun canSend_blocksWhenDisconnected() {
|
||||||
|
assertFalse(
|
||||||
|
"no sends while disconnected",
|
||||||
|
ChatInputPolicy.canSend("next task", emptyList(), isConnected = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun canSend_blocksWhenInputEmptyAndNoAttachments() {
|
||||||
|
assertFalse(
|
||||||
|
"empty input with no attachments must not enable send",
|
||||||
|
ChatInputPolicy.canSend("", emptyList(), isConnected = true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun canSend_allowsAttachmentWithEmptyText() {
|
||||||
|
assertTrue(
|
||||||
|
"a pending attachment enables send even with empty text",
|
||||||
|
ChatInputPolicy.canSend("", listOf("att"), isConnected = true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showQueuePlaceholder_trueWhenTypingAndHasText() {
|
||||||
|
assertTrue(
|
||||||
|
"placeholder should read 'queued' while typing with text present",
|
||||||
|
ChatInputPolicy.showQueuePlaceholder("next task", isAgentTyping = true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showQueuePlaceholder_falseWhenTypingButNoText() {
|
||||||
|
assertFalse(
|
||||||
|
"plain 'waiting' hint when typing but input empty",
|
||||||
|
ChatInputPolicy.showQueuePlaceholder("", isAgentTyping = true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showQueuePlaceholder_falseWhenIdle() {
|
||||||
|
assertFalse(
|
||||||
|
"no 'queued' hint when the agent is idle",
|
||||||
|
ChatInputPolicy.showQueuePlaceholder("next task", isAgentTyping = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun slashCommandStillRoutesThroughSendPath() {
|
||||||
|
// Slash commands were already allowed mid-turn; ensure the policy does
|
||||||
|
// not regress that by blocking a non-blank slash command.
|
||||||
|
assertTrue(
|
||||||
|
"/stop must remain sendable while typing",
|
||||||
|
ChatInputPolicy.canSend("/stop", emptyList(), isConnected = true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user