From 5376d46683409eca8b0c362223e327358cbb13a1 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:46:29 +0300 Subject: [PATCH] feat: queue prompts while the agent is running (#598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../hermescontrol/ui/chat/ChatInputPolicy.kt | 35 +++++++ .../m57/hermescontrol/ui/chat/ChatScreen.kt | 11 ++- app/src/main/res/values/strings.xml | 1 + .../ui/chat/ChatInputPolicyTest.kt | 92 +++++++++++++++++++ 4 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatInputPolicy.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatInputPolicy.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatInputPolicy.kt new file mode 100644 index 00000000..e10fff01 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatInputPolicy.kt @@ -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, + 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() +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index b04d1d8b..b687a0dc 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -700,11 +700,12 @@ private fun ChatInputBar( onFileTap: () -> 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 canSend = - (inputText.isNotBlank() || pendingAttachments.isNotEmpty()) && - isConnected && (!isAgentTyping || isSlashCommand) + val canSend = ChatInputPolicy.canSend(inputText, pendingAttachments, isConnected) // Attachment menu state var showAttachmentMenu by remember { mutableStateOf(false) } @@ -916,6 +917,8 @@ private fun ChatInputBar( Text( if (!isConnected) { stringResource(R.string.chat_input_placeholder_not_connected) + } else if (ChatInputPolicy.showQueuePlaceholder(inputText, isAgentTyping)) { + stringResource(R.string.chat_input_placeholder_queue) } else if (isAgentTyping) { stringResource(R.string.chat_input_placeholder_waiting) } else { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b2bcb8eb..4d8c1025 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -112,6 +112,7 @@ Collapse reasoning Not connected\u2026 Waiting for response\u2026 + Queued \u2014 sends when agent is free\u2026 Type a message\u2026 Error Output (stderr):\n%1$s Execution Error: %1$s diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt new file mode 100644 index 00000000..5348ad2f --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt @@ -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), + ) + } +}