feat(chat): add explicit busy-send modes and recoverable queue (#1254)

Co-authored-by: m57 <41645758+Hy4ri@users.noreply.github.com>
This commit is contained in:
AlexDev_andm57 authored and GitHub committed 2026-09-25 13:26:25 +03:00
1 parent f3921957b5
commit 35e6ebc8d5
33 files changed
+4482 -229

No files matched your search

@@ -4,16 +4,19 @@ import android.content.pm.PackageManager
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.core.content.ContextCompat
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.ui.common.ActionProgressController
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -125,4 +128,37 @@ class ChatScreenTest {
composeTestRule.onNodeWithTag("chat_input").assertIsDisplayed()
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
}
@Test
fun chatScreen_primarySendUsesSavedBusyDefault() {
val uiState =
ChatUiState(
connectionStatus = ConnectionStatus.CONNECTED,
isSessionReady = true,
busySendMode = BusySendMode.QUEUE,
)
val mockViewModel = mockk<ChatViewModel>(relaxed = true)
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow()
every { mockViewModel.connectionOperationState } returns
MutableStateFlow(ConnectionOperationUiState()).asStateFlow()
every { mockViewModel.actionProgress } returns
ActionProgressController(scope = CoroutineScope(Dispatchers.Main))
every { mockViewModel.sendMessage(any(), any()) } returns true
composeTestRule.setContent {
ChatScreen(
onOpenDrawer = {},
sessionId = null,
viewModel = mockViewModel,
)
}
composeTestRule.onNodeWithTag("chat_input").performTextInput("Use my saved default")
composeTestRule.onNodeWithTag("send_button").performClick()
verify(exactly = 1) { mockViewModel.sendMessage("Use my saved default", null) }
verify(exactly = 0) { mockViewModel.sendMessage("Use my saved default", BusySendMode.CORRECT) }
}
}
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
@@ -12,8 +13,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
@@ -24,9 +27,12 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.test.espresso.Espresso.pressBack
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import androidx.test.platform.app.InstrumentationRegistry
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.ws.CommandCatalog
import org.junit.Assert.assertEquals
import org.junit.Rule
@@ -42,7 +48,7 @@ import org.junit.runner.RunWith
@MediumTest
class ComposerInteractionTest {
@get:Rule
val composeTestRule = createComposeRule()
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private var micTaps = 0
private var sends = 0
@@ -52,6 +58,158 @@ class ComposerInteractionTest {
private var photosTaps = 0
private var fileTaps = 0
private fun setContextualComposer(
text: String,
isMainTurnBusy: Boolean,
attachments: List<Attachment> = emptyList(),
onSend: () -> Unit = {},
onBusySend: (BusySendMode) -> Unit = {},
isConnected: Boolean = true,
pendingReasoningLevel: String? = null,
) {
composeTestRule.setContent {
ChatInputBar(
inputFieldValue = TextFieldValue(text),
onInputChange = {},
onSend = onSend,
onBusySend = onBusySend,
onMicTap = {},
isListening = false,
isAgentTyping = isMainTurnBusy,
isMainTurnBusy = isMainTurnBusy,
isConnected = isConnected,
commandCatalog = CommandCatalog(),
isSessionReady = true,
pendingAttachments = attachments,
pendingReasoningLevel = pendingReasoningLevel,
)
}
}
@Test
fun idleDraft_showsRegularSendWithoutBusyActions() {
setContextualComposer(text = "hello", isMainTurnBusy = false)
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
}
@Test
fun busyBlankDraft_hidesBusyActions() {
setContextualComposer(text = "", isMainTurnBusy = true)
composeTestRule.onNodeWithTag("send_button").assertDoesNotExist()
composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
}
@Test
fun busyTextDraft_showsSendAndQueueAndRoutesCallbacks() {
var regularSends = 0
val selected = mutableListOf<BusySendMode>()
setContextualComposer(
text = "change course",
isMainTurnBusy = true,
onSend = { regularSends++ },
onBusySend = selected::add,
)
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed().performClick()
composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed().performClick()
composeTestRule.runOnIdle {
assertEquals(1, regularSends)
assertEquals(listOf(BusySendMode.QUEUE), selected)
}
}
@Test
fun busyAttachmentOnly_showsSendAndQueue() {
val attachment = Attachment("content://document/1", "notes.txt", "text/plain")
setContextualComposer(text = "", isMainTurnBusy = true, attachments = listOf(attachment))
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed()
}
@Test
fun busyDisconnectedDraft_disablesSendAndQueue() {
setContextualComposer(text = "hello", isMainTurnBusy = true, isConnected = false)
composeTestRule.onNodeWithTag("send_button").assertIsNotEnabled()
composeTestRule.onNodeWithTag("queue_button").assertIsNotEnabled()
}
@Test
fun busyPendingReasoning_disablesSendAndQueue() {
setContextualComposer(text = "hello", isMainTurnBusy = true, pendingReasoningLevel = "high")
composeTestRule.onNodeWithTag("send_button").assertIsNotEnabled()
composeTestRule.onNodeWithTag("queue_button").assertIsNotEnabled()
}
@Test
fun busySlashDraft_usesRegularSendWithoutBusyActions() {
var regularSends = 0
setContextualComposer(
text = "/stop",
isMainTurnBusy = true,
onSend = { regularSends++ },
)
composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
composeTestRule.onNodeWithTag("send_button").performClick()
composeTestRule.runOnIdle { assertEquals(1, regularSends) }
}
@Test
fun busyBecomesIdle_hidesQueueAndPreservesTypedDraft() {
val busy = mutableStateOf(true)
composeTestRule.setContent {
var input by remember { mutableStateOf(TextFieldValue("draft in progress")) }
ChatInputBar(
inputFieldValue = input,
onInputChange = { input = it },
onSend = {},
onBusySend = {},
onMicTap = {},
isListening = false,
isAgentTyping = busy.value,
isMainTurnBusy = busy.value,
isConnected = true,
commandCatalog = CommandCatalog(),
isSessionReady = true,
)
}
composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed()
composeTestRule.runOnUiThread { busy.value = false }
composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
composeTestRule.onNodeWithTag("chat_input").assertTextEquals("draft in progress")
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
}
@Test
fun busyDraft_narrowWidthKeepsSendAndQueueAccessible() {
composeTestRule.setContent {
Box(Modifier.width(220.dp)) {
ChatInputBar(
inputFieldValue = TextFieldValue("change course"),
onInputChange = {},
onSend = {},
onBusySend = {},
onMicTap = {},
isListening = false,
isAgentTyping = true,
isMainTurnBusy = true,
isConnected = true,
commandCatalog = CommandCatalog(),
isSessionReady = true,
)
}
}
composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed()
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
}
/** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */
private fun setComposer(
reasoningLevel: String? = "medium",
@@ -246,7 +404,8 @@ class ComposerInteractionTest {
"short model pill should remain content-sized, width=$pillWidth toolbar=$toolbarWidth"
}
check(actionButton.right > toolbarBounds.right - 32.dp) {
"trailing action should stay near the toolbar edge, right=${actionButton.right} toolbarRight=${toolbarBounds.right}"
"trailing action should stay near the toolbar edge, " +
"right=${actionButton.right} toolbarRight=${toolbarBounds.right}"
}
}
@@ -337,11 +496,11 @@ class ComposerInteractionTest {
}
@Test
fun attachmentTray_dismissesOnBackPress() {
fun attachmentTray_dismissesOnActivityBack() {
setComposer()
openAttachmentTray()
pressBack()
composeTestRule.runOnUiThread { composeTestRule.activity.onBackPressedDispatcher.onBackPressed() }
composeTestRule.mainClock.advanceTimeBy(300)
composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist()
@@ -0,0 +1,98 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.activity.ComponentActivity
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.ui.chat.PendingSend
import com.m57.hermescontrol.ui.chat.PendingSendState
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PendingSendPanelTest {
@get:Rule
val compose = createAndroidComposeRule<ComponentActivity>()
private fun row(state: PendingSendState) =
PendingSend("receipt", "scope", "session", "My message", mode = BusySendMode.CORRECT, state = state)
@Test
fun ordinarySendNeverShowsDeliveryPanel() {
val state = mutableStateOf(PendingSendState.SENDING)
compose.setContent { PendingSendPanel(listOf(row(state.value)), mainTurnBusy = false, onSendNow = {}) }
compose.onNodeWithText("My message").assertDoesNotExist()
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_sends)).assertDoesNotExist()
compose.runOnIdle { state.value = PendingSendState.ACCEPTED }
compose.onNodeWithText("My message").assertDoesNotExist()
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_accepted)).assertDoesNotExist()
}
@Test
fun queueDisappearsWhenDispatchedWithoutAwaitingLabel() {
val state = mutableStateOf(PendingSendState.QUEUED)
compose.setContent { PendingSendPanel(listOf(row(state.value)), mainTurnBusy = false, onSendNow = {}) }
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_queued)).assertIsDisplayed()
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now_warning)).assertDoesNotExist()
compose.runOnIdle { state.value = PendingSendState.SENDING }
compose.onNodeWithText("My message").assertDoesNotExist()
compose.runOnIdle { state.value = PendingSendState.ACCEPTED }
compose.onNodeWithText("My message").assertDoesNotExist()
}
@Test
fun failedAndUncertainDeliveryRemainRecoverable() {
val state = mutableStateOf(PendingSendState.REJECTED)
var retried: String? = null
compose.setContent {
PendingSendPanel(
listOf(row(state.value)),
mainTurnBusy = false,
onSendNow = { retried = it },
)
}
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_rejected)).assertIsDisplayed()
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now)).performClick()
compose.runOnIdle {
assertEquals("receipt", retried)
state.value = PendingSendState.UNKNOWN
}
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_unknown)).assertIsDisplayed()
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now_warning)).assertIsDisplayed()
}
@Test
fun busyPanelWarnsAndLabelsEveryRecoverableActionAsStopAndSend() {
val state = mutableStateOf(PendingSendState.QUEUED)
compose.setContent {
PendingSendPanel(
sends = listOf(row(state.value)),
mainTurnBusy = true,
onSendNow = {},
)
}
listOf(
PendingSendState.QUEUED,
PendingSendState.PARKED,
PendingSendState.REJECTED,
PendingSendState.UNKNOWN,
).forEach { pendingState ->
compose.runOnIdle { state.value = pendingState }
compose
.onNodeWithText(
compose.activity.getString(R.string.chat_pending_send_now_warning),
).assertIsDisplayed()
compose.onNodeWithText(compose.activity.getString(R.string.chat_busy_stop_and_send)).assertIsDisplayed()
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now)).assertDoesNotExist()
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_again)).assertDoesNotExist()
}
}
}
@@ -1,14 +1,24 @@
package com.m57.hermescontrol.ui.settings.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import androidx.test.platform.app.InstrumentationRegistry
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.BusySendMode
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
@@ -20,6 +30,25 @@ class ChatSectionTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun busySendDefaultDropdownRoutesSelection() {
var chosen: BusySendMode? = null
composeTestRule.setContent {
ChatSection(
typingEffectEnabled = false,
onTypingEffectEnabledChange = {},
typingEffectDelayMs = 30,
onTypingEffectDelayMsChange = {},
busySendMode = BusySendMode.CORRECT,
onBusySendModeChange = { chosen = it },
)
}
composeTestRule.onNodeWithTag("busy_send_default").performClick()
val queueLabel = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.chat_busy_queue)
composeTestRule.onNodeWithText(queueLabel).performClick()
assertEquals(BusySendMode.QUEUE, chosen)
}
@Test
fun messageStatsMasterOff_keepsChildrenVisibleCheckedAndDisabled() {
composeTestRule.setContent {
@@ -48,31 +77,47 @@ class ChatSectionTest {
var assistantChanges = 0
var tpsChanges = 0
composeTestRule.setContent {
ChatSection(
typingEffectEnabled = false,
onTypingEffectEnabledChange = {},
typingEffectDelayMs = 30,
onTypingEffectDelayMsChange = {},
messageStatsEnabled = true,
onMessageStatsEnabledChange = { masterChanges++ },
showUserMessageTokens = true,
onUserMessageTokensChange = { userChanges++ },
showAssistantMessageTokens = true,
onAssistantMessageTokensChange = { assistantChanges++ },
showTokensPerSecond = true,
onTokensPerSecondChange = { tpsChanges++ },
)
// PR #1254: match the real settings page's scrollable viewport.
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
ChatSection(
typingEffectEnabled = false,
onTypingEffectEnabledChange = {},
typingEffectDelayMs = 30,
onTypingEffectDelayMsChange = {},
messageStatsEnabled = true,
onMessageStatsEnabledChange = { masterChanges++ },
showUserMessageTokens = true,
onUserMessageTokensChange = { userChanges++ },
showAssistantMessageTokens = true,
onAssistantMessageTokensChange = { assistantChanges++ },
showTokensPerSecond = true,
onTokensPerSecondChange = { tpsChanges++ },
)
}
}
composeTestRule.onNodeWithTag("settings_user_message_tokens").assertIsEnabled().performClick()
composeTestRule.onNodeWithTag("settings_assistant_message_tokens").assertIsEnabled().performClick()
composeTestRule.onNodeWithTag("settings_tps").assertIsEnabled().performClick()
composeTestRule
.onNodeWithTag("settings_user_message_tokens")
.performScrollTo()
.assertIsEnabled()
.performClick()
composeTestRule
.onNodeWithTag(
"settings_assistant_message_tokens",
).performScrollTo()
.assertIsEnabled()
.performClick()
composeTestRule
.onNodeWithTag("settings_tps")
.performScrollTo()
.assertIsEnabled()
.performClick()
assertEquals(0, masterChanges)
assertEquals(1, userChanges)
assertEquals(1, assistantChanges)
assertEquals(1, tpsChanges)
composeTestRule.onNodeWithTag("settings_message_stats").performClick()
composeTestRule.onNodeWithTag("settings_message_stats").performScrollTo().performClick()
assertEquals(1, masterChanges)
assertEquals(1, userChanges)
assertEquals(1, assistantChanges)