From 35e6ebc8d5a9bab7c6c46c60f24b308375106627 Mon Sep 17 00:00:00 2001 From: AlexDev_ <56083016+alexdev03@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:26:25 +0200 Subject: [PATCH] feat(chat): add explicit busy-send modes and recoverable queue (#1254) Co-authored-by: m57 <41645758+Hy4ri@users.noreply.github.com> --- .../hermescontrol/ui/chat/ChatScreenTest.kt | 36 + .../components/ComposerInteractionTest.kt | 171 +- .../chat/components/PendingSendPanelTest.kt | 98 + .../ui/settings/components/ChatSectionTest.kt | 81 +- .../data/config/ServerStoreState.kt | 2 + .../hermescontrol/data/local/AuthManager.kt | 7 + .../data/local/ChatMessageDao.kt | 3 + .../hermescontrol/data/model/BusySendMode.kt | 11 + .../m57/hermescontrol/data/ws/WsMethods.kt | 1 + .../ui/chat/BusySendModeLabel.kt | 15 + .../ui/chat/ChatPersistenceRepository.kt | 4 + .../ui/chat/ChatQueuePresentation.kt | 17 + .../m57/hermescontrol/ui/chat/ChatScreen.kt | 27 +- .../hermescontrol/ui/chat/ChatSendStore.kt | 149 ++ .../hermescontrol/ui/chat/ChatViewModel.kt | 1456 ++++++++++-- .../ui/chat/components/ChatComposer.kt | 14 +- .../ui/chat/components/ComposerToolbar.kt | 83 +- .../ui/chat/components/PendingSendPanel.kt | 103 + .../ui/settings/SettingsSubPages.kt | 2 + .../ui/settings/SettingsViewModel.kt | 9 + .../settings/components/AppearanceSection.kt | 36 + app/src/main/res/values-ar/strings.xml | 24 +- app/src/main/res/values-ko/strings.xml | 24 +- app/src/main/res/values-zh/strings.xml | 20 + app/src/main/res/values/strings.xml | 23 + .../ui/chat/ChatPersistenceRepositoryTest.kt | 18 + .../ui/chat/ChatQueuePresentationTest.kt | 48 + .../ui/chat/ChatSendStoreTest.kt | 207 ++ .../ui/chat/ChatUpdateCommandTest.kt | 44 +- .../ui/chat/ChatViewModelTest.kt | 1946 ++++++++++++++++- .../ui/chat/SlashCommandDispatchRpcTest.kt | 14 +- .../ui/chat/fakes/FakeChatMessageDao.kt | 4 + .../ui/settings/SettingsViewModelTest.kt | 14 + 33 files changed, 4482 insertions(+), 229 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanelTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/BusySendMode.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/BusySendModeLabel.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentation.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSendStore.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanel.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentationTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSendStoreTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt index 2b4b9731..5e5c5b79 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt @@ -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(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) } + } } diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt index 2da52438..63317410 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt @@ -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() 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 = 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() + 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() diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanelTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanelTest.kt new file mode 100644 index 00000000..ab69dd29 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanelTest.kt @@ -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() + + 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() + } + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/ChatSectionTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/ChatSectionTest.kt index cd0082c8..06ac9727 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/ChatSectionTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/ChatSectionTest.kt @@ -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) diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt index f069698d..e81a38cb 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.data.config +import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreset @@ -22,6 +23,7 @@ data class ServerStoreState( val pinnedModels: List = emptyList(), val wsAuthParam: String = "token", val typingEffectEnabled: Boolean = true, + val busySendMode: BusySendMode = BusySendMode.CORRECT, val typingEffectDelayMs: Int = 30, val chatFontScale: Float = 1.0f, val messageStatsEnabled: Boolean = false, diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index f8c0c98c..d8d0fc33 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -13,6 +13,7 @@ import com.m57.hermescontrol.data.config.ServerUrlMigration import com.m57.hermescontrol.data.config.resolvedBaseUrl import com.m57.hermescontrol.data.config.resolvedHost import com.m57.hermescontrol.data.config.resolvedPort +import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.remote.CleartextPolicy import com.m57.hermescontrol.data.remote.CookieManager @@ -805,6 +806,12 @@ object AuthManager { fun isTypingEffectEnabled(): Boolean = serverStore.getLatestState().typingEffectEnabled + fun getBusySendMode(): BusySendMode = serverStore.getLatestState().busySendMode + + fun setBusySendMode(mode: BusySendMode) { + serverStore.update { it.copy(busySendMode = mode) } + } + fun setTypingEffectEnabled(enabled: Boolean) { serverStore.update { it.copy(typingEffectEnabled = enabled) } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt index e4f6cc96..8901202b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt @@ -86,4 +86,7 @@ interface ChatMessageDao { @Query("DELETE FROM chat_messages WHERE session_id = :sessionId") suspend fun deleteMessagesForSession(sessionId: String) + + @Query("DELETE FROM chat_messages WHERE id = :id AND rest_id IS NULL") + suspend fun deleteUnconfirmedMessage(id: String) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/BusySendMode.kt b/app/src/main/java/com/m57/hermescontrol/data/model/BusySendMode.kt new file mode 100644 index 00000000..44f6163e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/BusySendMode.kt @@ -0,0 +1,11 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable + +@Serializable +enum class BusySendMode { + CORRECT, + QUEUE, + GUIDE, + INTERRUPT, +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index f4e79a7f..cdbf2e51 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -11,6 +11,7 @@ object WsMethods { const val SESSION_CREATE = "session.create" const val SESSION_INTERRUPT = "session.interrupt" const val SESSION_REDIRECT = "session.redirect" + const val SESSION_STEER = "session.steer" const val SESSION_DELETE = "session.delete" const val SESSION_TITLE = "session.title" const val SESSION_BRANCH = "session.branch" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/BusySendModeLabel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/BusySendModeLabel.kt new file mode 100644 index 00000000..99a4d91e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/BusySendModeLabel.kt @@ -0,0 +1,15 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.BusySendMode + +@Composable +fun BusySendMode.label(): String = + when (this) { + BusySendMode.CORRECT -> stringResource(R.string.chat_busy_correct) + BusySendMode.QUEUE -> stringResource(R.string.chat_busy_queue) + BusySendMode.GUIDE -> stringResource(R.string.chat_busy_guide) + BusySendMode.INTERRUPT -> stringResource(R.string.chat_busy_interrupt) + } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt index da1b4537..89c5e210 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt @@ -84,4 +84,8 @@ open class ChatPersistenceRepository( suspend fun clearMessagesForSession(sessionId: String) { daoProvider().deleteMessagesForSession(sessionId) } + + suspend fun deleteMessage(id: String) { + daoProvider().deleteUnconfirmedMessage(id) + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentation.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentation.kt new file mode 100644 index 00000000..c95b505d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentation.kt @@ -0,0 +1,17 @@ +package com.m57.hermescontrol.ui.chat + +/** Presentation only: retain queued messages internally for persistence and receipt matching. */ +internal fun messagesWithoutUnsentQueue( + messages: List, + pendingSends: List, +): List { + val waitingIds = + pendingSends + .filter { it.state == PendingSendState.QUEUED || it.state == PendingSendState.PARKED } + .map { it.id } + .toSet() + if (waitingIds.isEmpty()) return messages + return messages.filterNot { + it.role == MessageRole.USER && it.id in waitingIds && it.canonicalRestId == null + } +} 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 78850553..3dec359f 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 @@ -86,6 +86,7 @@ import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.AttachmentSource +import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.model.reasoningSupport import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient @@ -172,7 +173,11 @@ fun ChatScreen( // Snapshot-backed search state — read directly so only the scopes that // read its fields recompose on search changes (bar, matched bubbles). val searchState = viewModel.searchState - val displayedMessages = timelineState.historyMessages ?: state.messages + val sourceMessages = timelineState.historyMessages ?: state.messages + val displayedMessages = + remember(sourceMessages, state.pendingSends) { + messagesWithoutUnsentQueue(sourceMessages, state.pendingSends) + } val lifecycleOwner = LocalLifecycleOwner.current val context = LocalContext.current var browserAuthInFlight by rememberSaveable { mutableStateOf(false) } @@ -195,6 +200,7 @@ fun ChatScreen( } Lifecycle.Event.ON_RESUME -> { + viewModel.refreshSettings() connectorsViewModel.onResume() val legacyBrowserReturned = browserAuthInFlight && browserAuthDeparted val operationId = connectionBrowserOperationId.takeIf { connectionBrowserDeparted } @@ -314,7 +320,7 @@ fun ChatScreen( // streaming tail. LaunchedEffect( timelineState.isHistorical, - state.messages, + displayedMessages, streamingState.streamingMessage, streamingState.isThinking, state.subagentIndicators, @@ -328,13 +334,13 @@ fun ChatScreen( scrollController.onTailChanged( tailKey = tailContentKey( - messages = state.messages, + messages = displayedMessages, streamingMessage = streamingState.streamingMessage, isThinking = streamingState.isThinking, subagentIndicators = state.subagentIndicators, clarifyRequest = state.clarifyRequest, ), - messageCount = state.messages.size, + messageCount = displayedMessages.size, ) } LaunchedEffect(timelineState.historyAnchorRowId) { @@ -872,6 +878,12 @@ fun ChatScreen( }, ) + com.m57.hermescontrol.ui.chat.components.PendingSendPanel( + sends = state.pendingSends, + mainTurnBusy = state.isMainTurnBusy, + onSendNow = viewModel::sendQueuedNow, + ) + ChatInputBar( inputFieldValue = inputFieldValue, onInputChange = { inputFieldValue = it }, @@ -882,9 +894,16 @@ fun ChatScreen( scrollController.jumpToBottom(animated = true) } }, + onBusySend = { mode -> + if (viewModel.sendMessage(inputFieldValue.text, mode)) { + inputFieldValue = TextFieldValue("") + scrollController.jumpToBottom(animated = true) + } + }, onMicTap = mediaLaunchers.onMicTap, isListening = mediaLaunchers.isListening, isAgentTyping = state.isAgentTyping, + isMainTurnBusy = state.isMainTurnBusy, isConnected = state.isConnected, isSessionReady = state.isSessionReady && !timelineState.isHistorical, sessionPreparationFailed = state.resumeError != null, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSendStore.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSendStore.kt new file mode 100644 index 00000000..a4a9263e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSendStore.kt @@ -0,0 +1,149 @@ +package com.m57.hermescontrol.ui.chat + +import android.content.Context +import android.content.SharedPreferences +import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.data.model.BusySendMode +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** The receipt stays on device until REST confirms a user row; a gateway queue is only volatile. */ +@Serializable +data class PendingSend( + val id: String, + val scope: String, + val sessionId: String, + val text: String, + val attachments: List = emptyList(), + val mode: BusySendMode, + val state: PendingSendState = PendingSendState.QUEUED, + val createdAt: Long = System.currentTimeMillis(), + val attempts: Int = 0, + /** Legacy source URIs are retained for an explicit user retry, never auto-dispatched. */ + val requiresAttachmentRecovery: Boolean = false, +) + +@Serializable +enum class PendingSendState { + QUEUED, + PARKED, + SENDING, + ACCEPTED, + UNKNOWN, + REJECTED, +} + +/** + * Reconcile only identities that the transcript merge already mapped to a durable REST row. + * Content matching belongs to [matchTranscriptMessages], which normalizes gateway wrappers and + * consumes duplicate occurrences once; repeating that matching against receipts can acknowledge + * more sends than the history page actually contains. + */ +internal fun pendingSendIdsConfirmedByDurableAliases( + confirmedAliases: List, + pending: List, +): Set { + val durableUserAliasIds = + confirmedAliases + .asSequence() + .filter { it.role == MessageRole.USER && it.canonicalRestId != null } + .map { it.id } + .toSet() + return pending + .asSequence() + .filter { + it.state in + setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN) + }.map { it.id } + .filter { it in durableUserAliasIds } + .toSet() +} + +/** Synchronous writes keep the queue recoverable when Android kills the process just after a tap. */ +class ChatSendStore( + private val prefs: SharedPreferences? = null, +) { + constructor(context: Context) : this( + context.applicationContext.getSharedPreferences("chat_send_outbox", Context.MODE_PRIVATE), + ) + + private val json = Json { ignoreUnknownKeys = true } + private var rows: List = + prefs?.getString("rows", null)?.let { raw -> + runCatching { json.decodeFromString>(raw) }.getOrDefault(emptyList()) + } ?: emptyList() + + init { + // A request may have reached the gateway before process death. Never replay it automatically. + replace( + rows.map { + it.quarantineLegacyAttachments().let { quarantined -> + if (quarantined.state == PendingSendState.SENDING || + quarantined.state == PendingSendState.ACCEPTED + ) { + quarantined.copy(state = PendingSendState.UNKNOWN) + } else { + quarantined + } + } + }, + ) + } + + private fun PendingSend.quarantineLegacyAttachments(): PendingSend = + if (attachments.any { !it.uri.startsWith("file:", ignoreCase = true) }) { + copy(state = PendingSendState.REJECTED, requiresAttachmentRecovery = true) + } else { + this + } + + @Synchronized + fun all(): List = rows + + @Synchronized + fun put(row: PendingSend) { + replace(rows.filterNot { it.id == row.id } + row.quarantineLegacyAttachments()) + } + + @Synchronized + fun remove(id: String) { + replace(rows.filterNot { it.id == id }) + } + + @Synchronized + fun update( + id: String, + transform: (PendingSend) -> PendingSend, + ) { + replace(rows.map { if (it.id == id) transform(it) else it }) + } + + @Synchronized + fun promote(id: String) { + val row = rows.firstOrNull { it.id == id } ?: return + replace(listOf(row.copy(state = PendingSendState.QUEUED)) + rows.filterNot { it.id == id }) + } + + @Synchronized + fun park( + scope: String, + sessionId: String, + ) { + replace( + rows.map { + if (it.scope == scope && it.sessionId == sessionId && it.state == PendingSendState.QUEUED) { + it.copy(state = PendingSendState.PARKED) + } else { + it + } + }, + ) + } + + private fun replace(next: List) { + if (prefs != null && !prefs.edit().putString("rows", json.encodeToString(next)).commit()) { + error("Could not save chat send queue") + } + rows = next + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index dfdabd63..05f5101a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -9,6 +9,7 @@ import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.HermesDatabase import com.m57.hermescontrol.data.local.SlashUsageStore import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.model.ModelCapabilities import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel @@ -28,6 +29,7 @@ import com.m57.hermescontrol.data.ws.CommandCatalog import com.m57.hermescontrol.data.ws.ConnectionOperationParser import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.data.ws.JsonRpcError import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.toAny @@ -106,6 +108,10 @@ private data class PreparedAttachment( val encodedFile: File, ) +private class QueuedAttachmentTooLargeException( + val attachment: Attachment, +) : Exception("Attachment too large: ${attachment.name}") + private sealed interface PrepareAttachmentResult { data class Success( val prepared: PreparedAttachment, @@ -123,6 +129,10 @@ data class ChatUiState( val chatTitle: String = "Hermes", val connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED, val isAgentTyping: Boolean = false, + val isMainTurnBusy: Boolean = false, + val isSending: Boolean = false, + val busySendMode: BusySendMode = BusySendMode.CORRECT, + val pendingSends: List = emptyList(), val isThinking: Boolean = false, val thinkingText: String = "", val isLoading: Boolean = false, @@ -403,6 +413,7 @@ class ChatViewModel( searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default, private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO, private val historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = searchDispatcher, + private val sendStore: ChatSendStore = ChatSendStore(application), ) : AndroidViewModel(application) { constructor(application: Application) : this(application, startCleanup = true) @@ -427,8 +438,53 @@ class ChatViewModel( val connectionCheckpoint: ConnectionResumeCheckpoint? = null, ) + private data class SendOwner( + val scope: String, + val storageSessionId: String, + val agentSessionId: String, + val generation: Long, + ) + + private data class HardInterruptRequest( + val messageId: String, + val mainTurnEpoch: Long, + val owner: SendOwner, + ) + + private data class PendingSendReservation( + val pending: PendingSend, + val userMessage: ChatMessage, + val wasStreaming: Boolean, + val storageSessionId: String, + val agentSessionId: String, + val scope: String, + val generation: Long, + val mainTurnEpoch: Long, + val originalPending: PendingSend? = null, + ) + private val sessionRequestById = ConcurrentHashMap() + private val outgoingRequestById = ConcurrentHashMap() + private val hardInterruptRequestById = ConcurrentHashMap() + private val queuedStagingIds = ConcurrentHashMap.newKeySet() + private val pendingSendReservationLock = Any() + private val pendingSendReservations = mutableListOf() + private var pendingSendReservationJob: Job? = null + private var queueDrainJob: Job? = null + private var mainTurnBusy = false + set(busy) { + field = busy + _uiState.update { state -> + if (state.isMainTurnBusy == busy) state else state.copy(isMainTurnBusy = busy) + } + } + + private var lastMainCompletionAt = 0L + private var lastSubmissionKey: String? = null + private var lastSubmissionAt = 0L private var sessionGeneration = 0L + private var mainTurnEpoch = 0L + private var lastPendingSendCreatedAt = 0L private var resumeRequestSequence = 0L private var activeResumeRequestSequence = 0L @@ -446,6 +502,8 @@ class ChatViewModel( val attachments: List, val wasStreaming: Boolean, val userMessage: ChatMessage, + val createdAt: Long, + val mainTurnEpoch: Long, ) private var pendingInitialPrompt: PendingPrompt? = null @@ -680,6 +738,27 @@ class ChatViewModel( status == ConnectionStatus.NO_NETWORK || status == ConnectionStatus.AUTH_EXPIRED ) { + mainTurnBusy = false + // A gateway acceptance is not a durable REST acknowledgement. Recover even + // receipts whose RPC correlation was already removed, without resending them. + val acceptedIds = + sendStore + .all() + .filter { + it.scope == sendScope() && it.state == PendingSendState.ACCEPTED + }.map { it.id } + (outgoingRequestById.values + hardInterruptRequestById.values.map { it.messageId } + acceptedIds) + .distinct() + .forEach { messageId -> + if (sendStore.all().any { + it.id == messageId && + (it.state == PendingSendState.SENDING || it.state == PendingSendState.ACCEPTED) + } + ) { + markPendingSend(messageId, PendingSendState.UNKNOWN) + removeUnconfirmedBubble(messageId) + } + } _uiState.update { it.copy(isLoading = false) } subagentsDelegate.closeSubagentTranscript() // The runtime session id is only valid while the socket @@ -695,6 +774,8 @@ class ChatViewModel( activeHydrationRequestSequence = ++hydrationRequestSequence // Preserve metadata so late results and errors are rejected as stale. sessionGeneration++ + queueDrainJob?.cancel() + queueDrainJob = null // #129: invalidate and cancel paged work together, including its busy flags. hydrationJob?.cancel() olderJob?.cancel() @@ -867,6 +948,8 @@ class ChatViewModel( // session errors here so the shared reducer cannot clear loading or // surface an error for a newly selected session. if (event is WsEvent.RpcError && isStaleSessionRequest(event.id)) { + outgoingRequestById.remove(event.id)?.let { markPendingSend(it, PendingSendState.UNKNOWN) } + hardInterruptRequestById.remove(event.id)?.let { markPendingSend(it.messageId, PendingSendState.UNKNOWN) } forgetRequest(event.id) return } @@ -930,18 +1013,25 @@ class ChatViewModel( } is WsEvent.MessageToken -> { + if (isCurrentSession(event.sessionId)) mainTurnBusy = true streamingController.handleMessageToken(event) } is WsEvent.ThinkingDelta -> { + if (isCurrentSession(event.sessionId)) mainTurnBusy = true streamingController.handleThinkingDelta(event) } is WsEvent.ReasoningDelta -> { + if (isCurrentSession(event.sessionId)) mainTurnBusy = true streamingController.handleReasoningDelta(event) } is WsEvent.MessageStart -> { + if (isCurrentSession(event.sessionId)) { + mainTurnEpoch++ + mainTurnBusy = true + } // The server accepted a prompt for this session — its DB row // now exists (created lazily at prompt.submit), so a reconnect // resume will succeed. @@ -950,15 +1040,26 @@ class ChatViewModel( } is WsEvent.MessageComplete -> { + if (isCurrentSession(event.sessionId)) { + mainTurnBusy = false + lastMainCompletionAt = System.currentTimeMillis() + refreshSendReceipts() + } // Buffers cleared before reduce; ViewModel resets them after streamingController.resetStreaming() } is WsEvent.MessageDone -> { + if (isCurrentSession(event.sessionId)) { + mainTurnBusy = false + lastMainCompletionAt = System.currentTimeMillis() + refreshSendReceipts() + } streamingController.resetStreaming() } is WsEvent.ToolStart -> { + if (isCurrentSession(event.sessionId)) mainTurnBusy = true // Issue #771: the reducer keeps the streaming message (and its // reasoning) alive across the tool call so the finalized answer // retains the thinking card. Only the token buffers are cleared @@ -1327,6 +1428,7 @@ class ChatViewModel( // Session info pushed by backend when config changes // (model switch, reasoning level, etc.) if (info != null) { + (info["running"] as? Boolean)?.let { mainTurnBusy = it } val model = info["model"] as? String val provider = info["provider"] as? String val reasoningEffort = info["reasoning_effort"] as? String @@ -1416,6 +1518,46 @@ class ChatViewModel( // ── RPC response handling ──────────────────────────────────────────── + private fun settleOutgoingResult( + requestId: String, + method: String, + result: Any?, + ) { + val messageId = outgoingRequestById.remove(requestId) ?: return + val status = rpcResultMap(result)?.get("status") as? String + val accepted = + when (method) { + WsMethods.SESSION_REDIRECT -> status == "redirected" || status == "queued" + WsMethods.SESSION_STEER -> status == "queued" || status == "steered" + else -> status == "streaming" || status == "queued" + } + when { + accepted -> { + markPendingSend(messageId, PendingSendState.ACCEPTED) + if (sendStore.all().any { it.id == messageId && lastMainCompletionAt >= it.createdAt }) { + refreshSendReceipts() + } + } + + status == "rejected" && method != WsMethods.PROMPT_SUBMIT -> { + removeUnconfirmedBubble(messageId) + sendStore.update(messageId) { it.copy(mode = BusySendMode.QUEUE, state = PendingSendState.QUEUED) } + publishPendingSends() + drainPendingQueue() + } + + status == "rejected" -> { + markPendingSend(messageId, PendingSendState.REJECTED) + removeUnconfirmedBubble(messageId) + } + + else -> { + markPendingSend(messageId, PendingSendState.UNKNOWN) + removeUnconfirmedBubble(messageId) + } + } + } + @Suppress("UNCHECKED_CAST") private fun handleRpcResult( id: String, @@ -1423,7 +1565,16 @@ class ChatViewModel( ) { val method = idToMethod.remove(id) ?: return val request = sessionRequestById.remove(id) - if (request != null && isStaleSessionRequest(request)) return + if (request != null && isStaleSessionRequest(request)) { + outgoingRequestById.remove(id)?.let { markPendingSend(it, PendingSendState.UNKNOWN) } + hardInterruptRequestById.remove(id)?.let { markPendingSend(it.messageId, PendingSendState.UNKNOWN) } + return + } + if (method == WsMethods.PROMPT_SUBMIT || method == WsMethods.SESSION_REDIRECT || + method == WsMethods.SESSION_STEER + ) { + settleOutgoingResult(id, method, result) + } when (method) { WsMethods.SESSION_CREATE -> { val resultMap = result as? Map @@ -1455,6 +1606,7 @@ class ChatViewModel( sessionUsage = null, ) } + publishPendingSends() // A gone-session recovery just landed — announce it now that // the message list has been reset by the create. if (pendingGoneSessionNotice) { @@ -1474,13 +1626,27 @@ class ChatViewModel( val pending = pendingInitialPrompt pendingInitialPrompt = null if (pending != null) { - dispatchPrompt( - text = pending.text, - attachments = pending.attachments, - wasStreaming = pending.wasStreaming, - storageSessionId = storageId, - agentSessionId = runtimeId, - userMessage = pending.userMessage, + enqueuePendingSendReservation( + PendingSendReservation( + pending = + PendingSend( + id = pending.userMessage.id, + scope = sendScope(), + sessionId = storageId, + text = pending.text, + attachments = pending.attachments, + mode = BusySendMode.CORRECT, + state = PendingSendState.SENDING, + createdAt = pending.createdAt, + ), + userMessage = pending.userMessage, + wasStreaming = pending.wasStreaming, + storageSessionId = storageId, + agentSessionId = runtimeId, + scope = sendScope(), + generation = sessionGeneration, + mainTurnEpoch = pending.mainTurnEpoch, + ), ) } } @@ -1549,6 +1715,7 @@ class ChatViewModel( } runtimeSessionId = runtimeId connectionOperationDelegate.bindSession(runtimeId) + mainTurnBusy = resultMap["running"] as? Boolean ?: false // Resume succeeded — the gateway confirmed the DB row. sessionHasServerPresence = true @@ -1623,6 +1790,7 @@ class ChatViewModel( val generation = resumeRequest.generation resumedGeneration = generation finishResumeWhenHydrated(generation) + publishPendingSends() subagentsDelegate.hydrateSubagents(runtimeSessionId ?: sessionId) // Reconnect replay: resume payload can carry `pending_approval` // (server `_session_info_payload`); surface it, then ask for @@ -1649,20 +1817,70 @@ class ChatViewModel( } WsMethods.SESSION_INTERRUPT -> { - // Issue #842 follow-up: seal whatever the agent streamed so far - // (interim commentary + partial answer) BEFORE clearing the - // streaming state. The old tool.start orphan seal used to leave - // pre-tool text behind on interrupt; with that seal gone, the - // partial would otherwise vanish entirely. - sealStreamingMessageIfAny() - _uiState.update { - it.copy( - isAgentTyping = false, - ) + val hardInterrupt = hardInterruptRequestById.remove(id) + val status = rpcResultMap(result)?.get("status") + val targetsCurrentTurn = hardInterrupt == null || hardInterrupt.mainTurnEpoch == mainTurnEpoch + if (targetsCurrentTurn && status in setOf("interrupted", "not_interrupted")) { + // Never let a late result for an older same-session turn + // erase a newer stream or make its replacement race it. + sealStreamingMessageIfAny() + _uiState.update { it.copy(isAgentTyping = false) } + _streamingState.update { StreamingState() } + streamingController.resetStreaming() + if (status == "interrupted") addSystemMessage("Session interrupted") + mainTurnBusy = false + } + hardInterrupt?.let { tracked -> + val messageId = tracked.messageId + val row = sendStore.all().firstOrNull { it.id == messageId } ?: return@let + when { + !targetsCurrentTurn && status in setOf("interrupted", "not_interrupted") -> { + // The replacement is known unsent, but this successful interrupt + // result belongs to an older turn. Wait behind the newer turn. + sendStore.update(messageId) { + it.copy(state = PendingSendState.QUEUED, mode = BusySendMode.QUEUE) + } + publishPendingSends() + } + + status == "interrupted" -> { + val message = + _uiState.value.messages.firstOrNull { it.id == row.id } + ?: ChatMessage( + id = row.id, + role = MessageRole.USER, + content = row.text, + attachments = row.attachments, + ) + dispatchPrompt( + text = row.text, + attachments = row.attachments, + wasStreaming = false, + storageSessionId = row.sessionId, + agentSessionId = tracked.owner.agentSessionId, + userMessage = message, + mode = BusySendMode.INTERRUPT, + queued = true, + owner = tracked.owner, + pendingReceipt = row, + outboundAlreadyStarted = true, + ) + } + + status == "not_interrupted" -> { + sendStore.update(messageId) { + it.copy(state = PendingSendState.QUEUED, mode = BusySendMode.QUEUE) + } + publishPendingSends() + drainPendingQueue() + } + + else -> { + markPendingSend(messageId, PendingSendState.UNKNOWN) + removeUnconfirmedBubble(messageId) + } + } } - _streamingState.update { StreamingState() } - streamingController.resetStreaming() - addSystemMessage("Session interrupted") } WsMethods.COMMANDS_CATALOG -> { @@ -1759,19 +1977,45 @@ class ChatViewModel( ) { val method = idToMethod.remove(id) ?: return val request = sessionRequestById.remove(id) - if (request != null && isStaleSessionRequest(request)) return + if (request != null && isStaleSessionRequest(request)) { + outgoingRequestById.remove(id)?.let { markPendingSend(it, PendingSendState.UNKNOWN) } + hardInterruptRequestById.remove(id)?.let { markPendingSend(it.messageId, PendingSendState.UNKNOWN) } + return + } val errorMsg = when (error) { is Map<*, *> -> error["message"] as? String ?: error.toString() else -> error.toString() } - if (method == WsMethods.PROMPT_SUBMIT || method == WsMethods.SESSION_REDIRECT) { + val messageId = outgoingRequestById.remove(id) + if (messageId != null) { + val code = (error as? JsonRpcError)?.code + val correction = method == WsMethods.SESSION_REDIRECT || method == WsMethods.SESSION_STEER + if (correction && code in setOf(4001, 4009, 4010)) { + removeUnconfirmedBubble(messageId) + sendStore.update(messageId) { it.copy(mode = BusySendMode.QUEUE, state = PendingSendState.QUEUED) } + publishPendingSends() + drainPendingQueue() + } else { + markPendingSend( + messageId, + if (code != null && code in 4000..4999) PendingSendState.REJECTED else PendingSendState.UNKNOWN, + ) + removeUnconfirmedBubble(messageId) + } + } + hardInterruptRequestById.remove(id)?.let { tracked -> + markPendingSend(tracked.messageId, PendingSendState.UNKNOWN) + removeUnconfirmedBubble(tracked.messageId) + } + + if (method == WsMethods.PROMPT_SUBMIT && !mainTurnBusy) { // A prompt rejection can arrive before message.start, leaving the // UI with only the optimistic typing state. Clear the live tail so // a failed generation cannot leave stale dots or reasoning behind. sealStreamingMessageIfAny() - _uiState.update { it.copy(isAgentTyping = false) } + _uiState.update { it.copy(isAgentTyping = mainTurnBusy) } _streamingState.update { StreamingState() } streamingController.resetStreaming() } @@ -1798,9 +2042,13 @@ class ChatViewModel( if (method == WsMethods.SESSION_CREATE) { val pending = pendingInitialPrompt pendingInitialPrompt = null + publishPendingSends() if (pending != null) { _uiState.update { it.copy( + messages = it.messages.filterNot { message -> message.id == pending.userMessage.id }, + pendingAttachments = pending.attachments + it.pendingAttachments, + composerTextToRestore = pending.text, isAgentTyping = false, errorMessage = "Failed to create session: $errorMsg", ) @@ -1822,6 +2070,566 @@ class ChatViewModel( // ── Send message ───────────────────────────────────────────────────── + private fun sendScope(): String = + listOf( + AuthManager.getBaseUrl(), + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + + private fun isCurrentSendContext( + scope: String, + storageSessionId: String, + agentSessionId: String, + generation: Long, + ): Boolean = + generation == sessionGeneration && + scope == sendScope() && + storageSessionId == _uiState.value.currentSessionId && + agentSessionId == runtimeSessionId + + private fun isCurrentSendContext(owner: SendOwner): Boolean = + isCurrentSendContext( + owner.scope, + owner.storageSessionId, + owner.agentSessionId, + owner.generation, + ) + + private fun captureSendOwner( + storageSessionId: String, + agentSessionId: String, + scope: String = sendScope(), + generation: Long = sessionGeneration, + ): SendOwner = SendOwner(scope, storageSessionId, agentSessionId, generation) + + /** Context invalidation and synchronous WS enqueue are serialized on Main. */ + private suspend fun withCurrentSendOwner( + owner: SendOwner, + action: () -> T, + ): T? = + withContext(Dispatchers.Main.immediate) { + if (isCurrentSendContext(owner)) action() else null + } + + private fun restoreKnownUnsentReceipt( + attempted: PendingSend, + rollback: PendingSend?, + deleteSnapshot: Boolean, + ) { + if (rollback != null) sendStore.put(rollback) else sendStore.remove(attempted.id) + publishPendingSends() + if (deleteSnapshot && rollback?.attachments.orEmpty().none { it.uri.startsWith("file:", ignoreCase = true) }) { + viewModelScope.launch(ioDispatcher) { deleteQueuedAttachmentSnapshot(attempted.id) } + } + } + + /** Commit/claim and enqueue the first RPC without releasing Main ownership. */ + private suspend fun commitReceiptAndEnqueue( + owner: SendOwner, + attempted: PendingSend, + rollback: PendingSend?, + deleteSnapshotOnRollback: Boolean, + enqueue: () -> T, + ): T? = + withContext(Dispatchers.Main.immediate) { + if (!isCurrentSendContext(owner)) { + restoreKnownUnsentReceipt(attempted, rollback, deleteSnapshotOnRollback) + return@withContext null + } + sendStore.put(attempted) + publishPendingSends() + // Store callbacks can re-enter profile selection. + if (!isCurrentSendContext(owner)) { + restoreKnownUnsentReceipt(attempted, rollback, deleteSnapshotOnRollback) + return@withContext null + } + enqueue() + } + + private fun deleteQueuedAttachmentSnapshot(messageId: String) { + File(getApplication().filesDir, "chat-send/$messageId").deleteRecursively() + } + + @Synchronized + private fun nextPendingSendCreatedAt(): Long { + lastPendingSendCreatedAt = maxOf(System.currentTimeMillis(), lastPendingSendCreatedAt + 1L) + return lastPendingSendCreatedAt + } + + private fun hasPendingSendReservation( + scope: String, + sessionId: String?, + ): Boolean = + synchronized(pendingSendReservationLock) { + pendingSendReservations.any { it.scope == scope && it.storageSessionId == sessionId } + } + + private fun enqueuePendingSendReservation(reservation: PendingSendReservation) { + synchronized(pendingSendReservationLock) { pendingSendReservations += reservation } + startPendingSendReservationDrain() + } + + @Synchronized + private fun startPendingSendReservationDrain() { + if (pendingSendReservationJob?.isActive == true) return + pendingSendReservationJob = + viewModelScope.launch(ioDispatcher) { + try { + while (true) { + val reservation = + synchronized(pendingSendReservationLock) { pendingSendReservations.firstOrNull() } + ?: break + try { + processPendingSendReservation(reservation) + } finally { + synchronized(pendingSendReservationLock) { + pendingSendReservations.removeAll { it.pending.id == reservation.pending.id } + } + } + } + } finally { + pendingSendReservationJob = null + if (synchronized(pendingSendReservationLock) { pendingSendReservations.isNotEmpty() }) { + startPendingSendReservationDrain() + } else { + drainPendingQueue() + } + } + } + } + + private suspend fun processPendingSendReservation(reservation: PendingSendReservation) { + val owner = + captureSendOwner( + reservation.storageSessionId, + reservation.agentSessionId, + reservation.scope, + reservation.generation, + ) + try { + var staged = stageQueuedAttachments(reservation.pending).copy(requiresAttachmentRecovery = false) + kotlinx.coroutines.yield() + if (withCurrentSendOwner(owner) { true } != true) { + if (reservation.originalPending == null) deleteQueuedAttachmentSnapshot(reservation.pending.id) + return + } + if (reservation.wasStreaming && staged.mode == BusySendMode.INTERRUPT && + reservation.mainTurnEpoch != mainTurnEpoch + ) { + staged = staged.copy(mode = BusySendMode.QUEUE, state = PendingSendState.QUEUED) + } + val stagedMessage = + reservation.userMessage.copy( + attachments = staged.attachments.takeIf { it.isNotEmpty() }, + ) + if (withCurrentSendOwner(owner) { + _uiState.update { state -> + val messages = + if (state.messages.any { it.id == stagedMessage.id }) { + state.messages.map { if (it.id == stagedMessage.id) stagedMessage else it } + } else { + state.messages + stagedMessage + } + state.copy(messages = messages, isSending = true) + } + true + } != true + ) { + if (reservation.originalPending == null) deleteQueuedAttachmentSnapshot(staged.id) + return + } + + when { + staged.state == PendingSendState.QUEUED -> { + val committed = + withContext(Dispatchers.Main.immediate) { + if (!isCurrentSendContext(owner)) return@withContext false + sendStore.put(staged) + publishPendingSends() + if (!isCurrentSendContext(owner)) { + restoreKnownUnsentReceipt( + staged, + reservation.originalPending, + deleteSnapshot = reservation.originalPending != null, + ) + false + } else { + true + } + } + if (committed) repo.persistMessage(stagedMessage, owner.storageSessionId) + } + + reservation.wasStreaming && staged.mode == BusySendMode.INTERRUPT -> { + commitReceiptAndEnqueue( + owner, + staged, + reservation.originalPending, + deleteSnapshotOnRollback = true, + ) { + wsClient.send( + WsMethods.SESSION_INTERRUPT, + mapOf("session_id" to owner.agentSessionId), + onSent = { id -> trackHardInterrupt(id, staged.id, owner, reservation.mainTurnEpoch) }, + ) + } + } + + else -> { + dispatchPrompt( + text = staged.text, + attachments = staged.attachments, + wasStreaming = reservation.wasStreaming, + storageSessionId = owner.storageSessionId, + agentSessionId = owner.agentSessionId, + userMessage = stagedMessage, + mode = if (reservation.wasStreaming) staged.mode else BusySendMode.CORRECT, + owner = owner, + pendingReceipt = staged, + rollbackReceipt = reservation.originalPending, + deleteSnapshotOnRollback = true, + ).join() + } + } + } catch (e: Exception) { + if (reservation.originalPending != null) { + sendStore.put(reservation.originalPending) + } else { + sendStore.remove(reservation.pending.id) + deleteQueuedAttachmentSnapshot(reservation.pending.id) + } + publishPendingSends() + if (e is CancellationException) throw e + if (withCurrentSendOwner(owner) { true } == true) { + if (reservation.originalPending == null) { + _uiState.update { state -> + state.copy( + messages = state.messages.filterNot { it.id == reservation.pending.id }, + pendingAttachments = reservation.pending.attachments + state.pendingAttachments, + composerTextToRestore = reservation.pending.text, + errorMessage = + if (e is QueuedAttachmentTooLargeException) { + attachmentTooLargeMessage(e.attachment) + } else { + "Could not retain attachment" + }, + ) + } + } else if (e is QueuedAttachmentTooLargeException) { + _uiState.update { it.copy(errorMessage = attachmentTooLargeMessage(e.attachment)) } + } + publishPendingSends() + } + } + } + + private fun publishPendingSends() { + val sessionId = _uiState.value.currentSessionId + val scope = sendScope() + val current = sendStore.all().filter { it.scope == scope && it.sessionId == sessionId } + _uiState.update { + it.copy( + pendingSends = current, + isSending = + pendingInitialPrompt != null || current.any { row -> row.state == PendingSendState.SENDING }, + ) + } + } + + private fun trackOutgoingRequest( + requestId: String, + messageId: String, + method: String, + generation: Long, + sessionId: String, + ) { + outgoingRequestById[requestId] = messageId + trackSessionRequest(requestId, method, generation, sessionId = sessionId) + if (!isTestEnvironment()) { + viewModelScope.launch { + delay(30_000L) + expireOutgoingRequest(requestId) + } + } + } + + internal fun expireOutgoingRequest(requestId: String) { + val messageId = + outgoingRequestById[requestId] + ?: hardInterruptRequestById[requestId]?.messageId + ?: return + if (sendStore.all().any { it.id == messageId && it.state == PendingSendState.SENDING }) { + markPendingSend(messageId, PendingSendState.UNKNOWN) + removeUnconfirmedBubble(messageId) + } + } + + private fun trackHardInterrupt( + requestId: String, + messageId: String, + owner: SendOwner, + interruptedTurnEpoch: Long, + ) { + hardInterruptRequestById[requestId] = HardInterruptRequest(messageId, interruptedTurnEpoch, owner) + trackSessionRequest( + requestId, + WsMethods.SESSION_INTERRUPT, + owner.generation, + sessionId = owner.storageSessionId, + ) + if (!isTestEnvironment()) { + viewModelScope.launch { + delay(30_000L) + expireOutgoingRequest(requestId) + } + } + } + + private fun markPendingSend( + messageId: String, + state: PendingSendState, + ) { + sendStore.update(messageId) { it.copy(state = state) } + publishPendingSends() + } + + private fun removePendingSend(messageId: String) { + sendStore.remove(messageId) + viewModelScope.launch(ioDispatcher) { deleteQueuedAttachmentSnapshot(messageId) } + publishPendingSends() + } + + private fun removeUnconfirmedBubble(messageId: String) { + val row = _uiState.value.messages.firstOrNull { it.id == messageId && it.canonicalRestId == null } ?: return + _uiState.update { state -> state.copy(messages = state.messages.filterNot { it.id == row.id }) } + viewModelScope.launch(ioDispatcher) { repo.deleteMessage(row.id) } + } + + private fun failOutgoingBeforeSubmit( + messageId: String, + reason: String, + generation: Long = sessionGeneration, + ) { + markPendingSend(messageId, PendingSendState.REJECTED) + if (generation != sessionGeneration) return + removeUnconfirmedBubble(messageId) + _uiState.update { it.copy(errorMessage = reason, isAgentTyping = mainTurnBusy) } + } + + /** Send one locally queued turn only after the previous receipt has reached REST history. */ + @Synchronized + private fun drainPendingQueue() { + val sessionId = _uiState.value.currentSessionId ?: return + val runtimeId = runtimeSessionId ?: return + val scope = sendScope() + if (mainTurnBusy || !_uiState.value.isSessionReady || + wsClient.connectionStatus.value != ConnectionStatus.CONNECTED + ) { + return + } + if (queueDrainJob?.isActive == true) return + if (hasPendingSendReservation(scope, sessionId)) return + val rows = sendStore.all().filter { it.scope == scope && it.sessionId == sessionId } + if (rows.any { + it.state == PendingSendState.SENDING || it.state == PendingSendState.UNKNOWN || + it.state == PendingSendState.ACCEPTED + } + ) { + return + } + val next = rows.firstOrNull { it.state == PendingSendState.QUEUED } ?: return + if (queuedStagingIds.contains(next.id)) return + val owner = captureSendOwner(sessionId, runtimeId, scope, sessionGeneration) + queueDrainJob = + viewModelScope.launch(ioDispatcher) { + try { + val staged = stageQueuedAttachments(next) + if (withCurrentSendOwner(owner) { true } != true) return@launch + val message = + _uiState.value.messages.firstOrNull { it.id == next.id } + ?: ChatMessage( + id = next.id, + role = MessageRole.USER, + content = next.text, + attachments = staged.attachments, + ) + dispatchPrompt( + text = next.text, + attachments = staged.attachments, + wasStreaming = false, + storageSessionId = owner.storageSessionId, + agentSessionId = owner.agentSessionId, + userMessage = message, + mode = BusySendMode.QUEUE, + queued = true, + owner = owner, + pendingReceipt = + staged.copy( + state = PendingSendState.SENDING, + attempts = next.attempts + 1, + ), + rollbackReceipt = next, + ).join() + } catch (e: Exception) { + if (e is CancellationException) throw e + failOutgoingBeforeSubmit( + next.id, + if (e is QueuedAttachmentTooLargeException) { + attachmentTooLargeMessage(e.attachment) + } else { + "Could not retain queued attachment" + }, + ) + } + } + } + + private fun stageQueuedAttachments(row: PendingSend): PendingSend { + if (row.attachments.isEmpty()) return row + val context = getApplication() + val dir = File(context.filesDir, "chat-send/${row.id}") + if (!dir.exists() && !dir.mkdirs()) error("Could not create attachment snapshot") + val staged = + row.attachments.mapIndexed { index, attachment -> + val uri = Uri.parse(attachment.uri) + val target = File(dir, index.toString()) + if (target.exists()) { + attachment.copy(uri = Uri.fromFile(target).toString(), size = target.length()) + } else { + val partial = File(dir, "$index.part") + var copied = 0L + try { + val input = context.contentResolver.openInputStream(uri) ?: error("Attachment unavailable") + input.use { source -> + partial.outputStream().use { output -> + val buffer = ByteArray(8192) + while (true) { + val count = source.read(buffer) + if (count < 0) break + copied += count + if (copied > MAX_CHAT_ATTACHMENT_BYTES) { + throw QueuedAttachmentTooLargeException(attachment) + } + output.write(buffer, 0, count) + } + } + } + if (!partial.renameTo(target)) error("Could not save attachment snapshot") + attachment.copy(uri = Uri.fromFile(target).toString(), size = copied) + } catch (e: Exception) { + partial.delete() + throw e + } + } + } + return row.copy(attachments = staged) + } + + private fun refreshSendReceipts() { + val sessionId = _uiState.value.currentSessionId ?: return + if (sendStore.all().any { + it.scope == sendScope() && it.sessionId == sessionId && + it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN) + } + ) { + if (sessionHasServerPresence) syncCurrentSession() else loadSessionMessages(sessionId, sessionGeneration) + } else { + drainPendingQueue() + } + } + + /** A manual promotion is the only path that may retry an uncertain send. */ + @Synchronized + fun sendQueuedNow(id: String) { + val row = sendStore.all().firstOrNull { it.id == id } ?: return + if (row.scope != sendScope() || row.sessionId != _uiState.value.currentSessionId || + runtimeSessionId == null + ) { + return + } + if (row.state !in + setOf( + PendingSendState.QUEUED, + PendingSendState.PARKED, + PendingSendState.REJECTED, + PendingSendState.UNKNOWN, + PendingSendState.ACCEPTED, + ) + ) { + return + } + if (queueDrainJob?.isActive == true || + synchronized(pendingSendReservationLock) { pendingSendReservations.any { it.pending.id == id } } + ) { + return + } + val others = + sendStore.all().any { + it.id != id && it.scope == row.scope && it.sessionId == row.sessionId && + it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN) + } + if (others) { + _uiState.update { it.copy(errorMessage = "Wait for the current send to settle") } + return + } + if (row.attachments.isNotEmpty()) { + val runtimeId = runtimeSessionId ?: return + val generation = sessionGeneration + val wasStreaming = mainTurnBusy + val message = + _uiState.value.messages.firstOrNull { it.id == row.id } + ?: ChatMessage( + id = row.id, + role = MessageRole.USER, + content = row.text, + attachments = row.attachments, + ) + enqueuePendingSendReservation( + PendingSendReservation( + pending = + row.copy( + mode = if (wasStreaming) BusySendMode.INTERRUPT else BusySendMode.QUEUE, + state = PendingSendState.SENDING, + attempts = row.attempts + 1, + ), + userMessage = message, + wasStreaming = wasStreaming, + storageSessionId = row.sessionId, + agentSessionId = runtimeId, + scope = row.scope, + generation = generation, + mainTurnEpoch = mainTurnEpoch, + originalPending = row, + ), + ) + return + } + sendStore.promote(id) + publishPendingSends() + if (mainTurnBusy) { + val runtimeId = runtimeSessionId ?: return + val owner = captureSendOwner(row.sessionId, runtimeId, row.scope, sessionGeneration) + val interruptedTurnEpoch = mainTurnEpoch + viewModelScope.launch { + commitReceiptAndEnqueue( + owner, + row.copy(state = PendingSendState.SENDING, attempts = row.attempts + 1), + row, + deleteSnapshotOnRollback = false, + ) { + wsClient.send( + WsMethods.SESSION_INTERRUPT, + mapOf("session_id" to owner.agentSessionId), + onSent = { requestId -> trackHardInterrupt(requestId, id, owner, interruptedTurnEpoch) }, + ) + } + } + } else { + drainPendingQueue() + } + } + /** * Send a user prompt, uploading any pending attachments to the backend * first via their dedicated RPC methods. @@ -1837,7 +2645,10 @@ class ChatViewModel( * 6. For each file → await `file.attach` (requires session_id), collect @file: refs * 7. Send `prompt.submit` with text + @file: refs — images auto-picked up by backend */ - fun sendMessage(text: String): Boolean { + fun sendMessage( + text: String, + modeOverride: BusySendMode? = null, + ): Boolean { if (!canSubmitMessage()) return false if (text.isBlank() && _uiState.value.pendingAttachments.isEmpty()) return false @@ -1861,17 +2672,45 @@ class ChatViewModel( _uiState.update { it.copy( errorMessage = attachmentTooLargeMessage(oversizedAttachment), - composerTextToRestore = text, ) } - return true + return false } - // Snapshot + clear attachments so the input bar empties immediately val attachments = _uiState.value.pendingAttachments.toList() - clearAttachments() + val submissionKey = + listOf(_uiState.value.currentSessionId, text, attachments.joinToString { it.uri }).joinToString("\u001f") + val now = System.nanoTime() + if (submissionKey == lastSubmissionKey && now - lastSubmissionAt < 500_000_000L) return false + val wasStreaming = mainTurnBusy + val clickedMainTurnEpoch = mainTurnEpoch + val hasOutstandingDelivery = + sendStore.all().any { + it.scope == sendScope() && it.sessionId == _uiState.value.currentSessionId && + it.state in + setOf( + PendingSendState.SENDING, + PendingSendState.ACCEPTED, + PendingSendState.UNKNOWN, + ) + } || hasPendingSendReservation(sendScope(), _uiState.value.currentSessionId) + val busy = wasStreaming || hasOutstandingDelivery + val selectedMode = modeOverride ?: _uiState.value.busySendMode + val mode = + when { + selectedMode == BusySendMode.INTERRUPT && !wasStreaming && hasOutstandingDelivery -> { + BusySendMode.QUEUE + } - val wasStreaming = _uiState.value.isAgentTyping + busy && selectedMode != BusySendMode.INTERRUPT && + (attachments.isNotEmpty() || !wasStreaming) -> { + BusySendMode.QUEUE + } + + else -> { + selectedMode + } + } val userMessage = ChatMessage( @@ -1882,21 +2721,132 @@ class ChatViewModel( messageProvenance = MessageProvenance.LOCAL_PENDING, ) - // Update UI immediately + val storageSessionId = _uiState.value.currentSessionId + val agentSessionId = runtimeSessionId + val scope = sendScope() + val createdAt = nextPendingSendCreatedAt() + if (storageSessionId != null && agentSessionId != null && + (attachments.isNotEmpty() || hasPendingSendReservation(scope, storageSessionId)) + ) { + val generation = sessionGeneration + val pending = + PendingSend( + id = userMessage.id, + scope = scope, + sessionId = storageSessionId, + text = text, + attachments = attachments, + mode = mode, + state = + if (busy && mode == BusySendMode.QUEUE) { + PendingSendState.QUEUED + } else { + PendingSendState.SENDING + }, + createdAt = createdAt, + ) + lastSubmissionKey = submissionKey + lastSubmissionAt = now + clearAttachments() + enqueuePendingSendReservation( + PendingSendReservation( + pending = pending, + userMessage = userMessage, + wasStreaming = wasStreaming, + storageSessionId = storageSessionId, + agentSessionId = agentSessionId, + scope = scope, + generation = generation, + mainTurnEpoch = clickedMainTurnEpoch, + ), + ) + return true + } + if (storageSessionId != null && agentSessionId != null && busy && mode == BusySendMode.QUEUE) { + try { + sendStore.put( + PendingSend( + id = userMessage.id, + scope = scope, + sessionId = storageSessionId, + text = text, + attachments = attachments, + mode = mode, + createdAt = createdAt, + state = + if (busy && mode == BusySendMode.QUEUE) { + PendingSendState.QUEUED + } else { + PendingSendState.SENDING + }, + ), + ) + } catch (e: Exception) { + _uiState.update { it.copy(errorMessage = "Could not save message for delivery") } + return false + } + } + lastSubmissionKey = submissionKey + lastSubmissionAt = now + clearAttachments() + _uiState.update { state -> state.copy( messages = state.messages + userMessage, - isAgentTyping = true, + isSending = true, ) } - - val storageSessionId = _uiState.value.currentSessionId - val agentSessionId = runtimeSessionId + publishPendingSends() if (storageSessionId == null || agentSessionId == null) { // Issue #969: Session creation is still in-flight. Hold the prompt // so it is dispatched automatically the moment SESSION_CREATE lands. - pendingInitialPrompt = PendingPrompt(text, attachments, wasStreaming, userMessage) + pendingInitialPrompt = + PendingPrompt(text, attachments, wasStreaming, userMessage, createdAt, clickedMainTurnEpoch) + publishPendingSends() + return true + } + + if (busy && mode == BusySendMode.QUEUE) { + queuedStagingIds.add(userMessage.id) + viewModelScope.launch(ioDispatcher) { + try { + stageQueuedAttachments(sendStore.all().first { it.id == userMessage.id }) + repo.persistMessage(userMessage, storageSessionId) + } catch (e: Exception) { + if (e is CancellationException) throw e + failOutgoingBeforeSubmit(userMessage.id, "Could not retain queued attachment") + } finally { + queuedStagingIds.remove(userMessage.id) + drainPendingQueue() + } + } + return true + } + + if (wasStreaming && mode == BusySendMode.INTERRUPT) { + val owner = captureSendOwner(storageSessionId, agentSessionId, scope, sessionGeneration) + val interruptedTurnEpoch = mainTurnEpoch + val receipt = + PendingSend( + id = userMessage.id, + scope = scope, + sessionId = storageSessionId, + text = text, + attachments = attachments, + mode = mode, + state = PendingSendState.SENDING, + createdAt = createdAt, + ) + viewModelScope.launch { + commitReceiptAndEnqueue(owner, receipt, null, deleteSnapshotOnRollback = false) { + wsClient.send( + WsMethods.SESSION_INTERRUPT, + mapOf("session_id" to owner.agentSessionId), + onSent = { id -> trackHardInterrupt(id, userMessage.id, owner, interruptedTurnEpoch) }, + ) + } + } return true } @@ -1907,6 +2857,19 @@ class ChatViewModel( storageSessionId = storageSessionId, agentSessionId = agentSessionId, userMessage = userMessage, + mode = if (wasStreaming) mode else BusySendMode.CORRECT, + owner = captureSendOwner(storageSessionId, agentSessionId, scope), + pendingReceipt = + PendingSend( + id = userMessage.id, + scope = scope, + sessionId = storageSessionId, + text = text, + attachments = attachments, + mode = if (wasStreaming) mode else BusySendMode.CORRECT, + state = PendingSendState.SENDING, + createdAt = createdAt, + ), ) return true } @@ -1933,8 +2896,15 @@ class ChatViewModel( storageSessionId: String, agentSessionId: String, userMessage: ChatMessage? = null, - ) { - val dispatchGeneration = sessionGeneration + mode: BusySendMode = BusySendMode.CORRECT, + queued: Boolean = false, + owner: SendOwner = captureSendOwner(storageSessionId, agentSessionId), + pendingReceipt: PendingSend? = null, + rollbackReceipt: PendingSend? = null, + deleteSnapshotOnRollback: Boolean = false, + outboundAlreadyStarted: Boolean = false, + ): Job { + val dispatchGeneration = owner.generation AuthManager.setLastOpenedSessionId(storageSessionId) val msgToPersist = userMessage ?: ChatMessage( @@ -1944,16 +2914,52 @@ class ChatViewModel( tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 }, messageProvenance = MessageProvenance.LOCAL_PENDING, ) + val attemptedReceipt = + pendingReceipt + ?: sendStore.all().firstOrNull { it.id == msgToPersist.id }?.copy(state = PendingSendState.SENDING) + ?: PendingSend( + id = msgToPersist.id, + scope = owner.scope, + sessionId = owner.storageSessionId, + text = text, + attachments = attachments, + mode = mode, + state = PendingSendState.SENDING, + ) - // Upload attachments then submit prompt - viewModelScope.launch(ioDispatcher) { + // PR #1254: callers draining FIFO work retain ownership until preparation/enqueue ends. + return viewModelScope.launch(ioDispatcher) { val fileRefs = mutableListOf() val preparedAttachments = mutableListOf() + var receiptCommitted = outboundAlreadyStarted + var outboundStarted = outboundAlreadyStarted + + suspend fun enqueueOwned(action: () -> T): T? { + val value = + if (!receiptCommitted) { + commitReceiptAndEnqueue( + owner, + attemptedReceipt, + rollbackReceipt, + deleteSnapshotOnRollback, + action, + ) + } else { + withCurrentSendOwner(owner, action) + } + if (value == null) { + if (outboundStarted) { + sendStore.update(msgToPersist.id) { it.copy(state = PendingSendState.UNKNOWN) } + publishPendingSends() + } + return null + } + receiptCommitted = true + outboundStarted = true + return value + } try { - // Snapshot every attachment before the first RPC. This closes the - // content-URI TOCTOU window without retaining multiple Base64 - // strings in the heap: encoded snapshots live in private cache. for (attachment in attachments) { when (val result = prepareAttachment(attachment)) { is PrepareAttachmentResult.Success -> { @@ -1961,30 +2967,81 @@ class ChatViewModel( } PrepareAttachmentResult.TooLarge -> { - rejectOversizedAttachment( - attachment = attachment, - attachments = attachments, - message = msgToPersist, - wasStreaming = wasStreaming, - generation = dispatchGeneration, - ) + if (!outboundStarted) { + restoreKnownUnsentReceipt( + attemptedReceipt, + rollbackReceipt, + deleteSnapshotOnRollback, + ) + } + if (!outboundStarted && rollbackReceipt != null) { + withCurrentSendOwner(owner) { + removeUnconfirmedBubble(msgToPersist.id) + _uiState.update { it.copy(errorMessage = attachmentTooLargeMessage(attachment)) } + } + } else { + rejectOversizedAttachment( + attachment, + attachments, + msgToPersist, + wasStreaming, + dispatchGeneration, + ) + } return@launch } PrepareAttachmentResult.Unreadable -> { - Log.w(TAG, "Skipping unreadable attachment: ${attachment.name}") + if (!outboundStarted) { + restoreKnownUnsentReceipt( + attemptedReceipt, + rollbackReceipt, + deleteSnapshotOnRollback, + ) + } + if (!outboundStarted && rollbackReceipt != null) { + withCurrentSendOwner(owner) { + removeUnconfirmedBubble(msgToPersist.id) + _uiState.update { + it.copy( + errorMessage = "Attachment unavailable: ${attachment.name}", + isAgentTyping = mainTurnBusy, + ) + } + } + } else { + failOutgoingBeforeSubmit( + msgToPersist.id, + "Attachment unavailable: ${attachment.name}", + dispatchGeneration, + ) + } + return@launch } } } - // Persist only after every readable attachment has a bounded, - // immutable snapshot and no partial server upload can occur. + if (withCurrentSendOwner(owner) { true } != true) { + if (!outboundStarted) { + restoreKnownUnsentReceipt( + attemptedReceipt, + rollbackReceipt, + deleteSnapshotOnRollback, + ) + } + return@launch + } try { - repo.persistMessage(msgToPersist, storageSessionId) + repo.persistMessage(msgToPersist, owner.storageSessionId) } catch (e: Exception) { - if (e is kotlinx.coroutines.CancellationException) throw e + if (e is CancellationException) throw e Log.e(TAG, "Failed to persist outgoing message", e) - if (dispatchGeneration == sessionGeneration) { + if (outboundStarted) { + sendStore.update(msgToPersist.id) { it.copy(state = PendingSendState.UNKNOWN) } + } else { + restoreKnownUnsentReceipt(attemptedReceipt, rollbackReceipt, deleteSnapshotOnRollback) + } + if (isCurrentSendContext(owner)) { _uiState.update { state -> state.copy( messages = state.messages.filterNot { it.id == msgToPersist.id }, @@ -2000,68 +3057,58 @@ class ChatViewModel( for ((attachment, encodedFile) in preparedAttachments) { try { - // Materialize one bounded Base64 payload at a time. The - // awaited RPC completes before the next snapshot is read. val b64 = encodedFile.readText(Charsets.US_ASCII) - if (attachment.isImage) { - // Await so the backend stages the image into - // session["attached_images"] BEFORE prompt.submit runs - // (a fire-and-forget send raced prompt.submit and the - // image was dropped). Requires session_id or the gateway - // 4001s "session not found" (desktop passes it too). - val result = - sendRpcAndAwait( - method = WsMethods.IMAGE_ATTACH_BYTES, - params = + val deferred = + enqueueOwned { + wsClient.request( + WsMethods.IMAGE_ATTACH_BYTES, mapOf( - "session_id" to agentSessionId, + "session_id" to owner.agentSessionId, "content_base64" to "data:${attachment.mimeType};base64,$b64", "filename" to attachment.name, "ext" to attachment.fileExtension, ), - ).let { if (it is JsonElement) it.toAny() else it } - if (result != null) { - @Suppress("UNCHECKED_CAST") - val ok = (result as? Map)?.get("attached") as? Boolean - if (ok != true) { - Log.w(TAG, "Image attach for ${attachment.name} returned non-ok: $result") - } - } - } else { - // Await the @file: ref text so we can embed it in the prompt. - // file.attach also requires session_id or the gateway 4001s - // "session not found" (same resolver as image.attach_bytes). - sendRpcAndAwait( - method = WsMethods.FILE_ATTACH, - params = - mapOf( - "session_id" to agentSessionId, - "data_url" to "data:${attachment.mimeType};base64,$b64", - "name" to attachment.name, - ), - )?.let { response -> - // request() returns raw JsonElement values; normalize before reading Kotlin types. - val result = if (response is JsonElement) response.toAny() else response + ) + } ?: return@launch + val response = deferred.await() + val result = if (response is JsonElement) response.toAny() else response - @Suppress("UNCHECKED_CAST") - val refText = - (result as? Map)?.get("ref_text") as? String - if (!refText.isNullOrBlank()) fileRefs.add(refText) - } + @Suppress("UNCHECKED_CAST") + val ok = (result as? Map)?.get("attached") as? Boolean + if (ok != true) error("Image attachment was not accepted") + } else { + val deferred = + enqueueOwned { + wsClient.request( + WsMethods.FILE_ATTACH, + mapOf( + "session_id" to owner.agentSessionId, + "data_url" to "data:${attachment.mimeType};base64,$b64", + "name" to attachment.name, + ), + ) + } ?: return@launch + val response = deferred.await() + val result = if (response is JsonElement) response.toAny() else response + + @Suppress("UNCHECKED_CAST") + val refText = (result as? Map)?.get("ref_text") as? String + if (refText.isNullOrBlank()) error("File attachment was not accepted") + fileRefs.add(refText) } } catch (e: Exception) { - if (e is kotlinx.coroutines.CancellationException) throw e + if (e is CancellationException) throw e Log.e(TAG, "Failed to upload attachment ${attachment.name}", e) - if (dispatchGeneration == sessionGeneration) { - _uiState.update { - it.copy(errorMessage = "Upload failed: ${attachment.name}") - } - } + failOutgoingBeforeSubmit( + msgToPersist.id, + "Upload failed: ${attachment.name}", + dispatchGeneration, + ) + return@launch } } - // Build prompt text — prepend @file: refs for non-image files val fullText = if (fileRefs.isEmpty()) { text @@ -2069,53 +3116,69 @@ class ChatViewModel( fileRefs.joinToString("\n") + if (text.isNotBlank()) "\n\n$text" else "" } - - if (dispatchGeneration != sessionGeneration) return@launch - - // While a turn is actively streaming and this is a plain text prompt - // (no attachments — session.redirect carries text only), steer the - // in-flight turn via session.redirect instead of queueing a fresh - // prompt.submit. The backend rewrites the live turn when it can, or - // queues the correction as the next turn otherwise (issue #710). - if (dispatchGeneration == sessionGeneration) { - ActiveSessionHolder.set(agentSessionId, storageSessionId) + if (isCurrentSendContext(owner)) { + ActiveSessionHolder.set( + owner.agentSessionId, + owner.storageSessionId, + ) } captureTurnUsageBaselineIfNeeded() - if (wasStreaming && attachments.isEmpty()) { - wsClient.sendRedirect( - agentSessionId, - fullText, - onSent = { id -> - trackSessionRequest( - id = id, - method = WsMethods.SESSION_REDIRECT, - generation = dispatchGeneration, - sessionId = storageSessionId, - ) - }, - ) + if (wasStreaming && attachments.isEmpty() && !queued) { + val sent = + if (mode == BusySendMode.GUIDE) { + enqueueOwned { + wsClient.send( + WsMethods.SESSION_STEER, + mapOf("session_id" to owner.agentSessionId, "text" to fullText), + onSent = { id -> + trackOutgoingRequest( + id, + msgToPersist.id, + WsMethods.SESSION_STEER, + dispatchGeneration, + owner.storageSessionId, + ) + }, + ) + } + } else { + enqueueOwned { + wsClient.sendRedirect( + owner.agentSessionId, + fullText, + onSent = { id -> + trackOutgoingRequest( + id, + msgToPersist.id, + WsMethods.SESSION_REDIRECT, + dispatchGeneration, + owner.storageSessionId, + ) + }, + ) + } + } + if (sent == null) return@launch } else { - // Arm the durable turn boundary BEFORE prompt.submit leaves - // the device: the REST high-watermark has to be read while - // the reply row cannot exist yet. A failed capture is - // non-fatal — the prompt is still sent, the turn just - // becomes uncorrelatable and its reply notification is never - // auto-dismissed from hydration instead of being bound to a - // guessed row. - prepareTurnCorrelation(storageSessionId) - if (dispatchGeneration != sessionGeneration) return@launch - wsClient.sendMessage( - agentSessionId, - fullText, - onSent = { id -> - trackSessionRequest( - id = id, - method = WsMethods.PROMPT_SUBMIT, - generation = dispatchGeneration, - sessionId = storageSessionId, + if (!queued) prepareTurnCorrelation(owner.storageSessionId) + val sent = + enqueueOwned { + wsClient.sendMessage( + owner.agentSessionId, + fullText, + onSent = { id -> + trackOutgoingRequest( + id, + msgToPersist.id, + WsMethods.PROMPT_SUBMIT, + dispatchGeneration, + owner.storageSessionId, + ) + }, + queued = queued, ) - }, - ) + } + if (sent == null) return@launch } } finally { preparedAttachments.forEach { it.encodedFile.delete() } @@ -2188,6 +3251,7 @@ class ChatViewModel( ) { Log.w(TAG, "Rejecting oversized attachment: ${attachment.name}") if (generation != sessionGeneration) return + removePendingSend(message.id) _uiState.update { state -> state.copy( messages = state.messages.filterNot { it.id == message.id }, @@ -2276,7 +3340,7 @@ class ChatViewModel( _uiState.update { it.copy(messages = it.messages + userMsg) } // Persist — OUTSIDE update{} - if (sessionId != null) { + if (sessionId != null && result !is SlashResult.QueuePrompt) { viewModelScope.launch(ioDispatcher) { repo.persistMessage(userMsg, sessionId) } @@ -2355,7 +3419,7 @@ class ChatViewModel( } is SlashResult.QueuePrompt -> { - handleQueueCommand(command) + handleQueueCommand(command, userMsg) } is SlashResult.SideQuestion -> { @@ -2381,13 +3445,54 @@ class ChatViewModel( * `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) { + private fun handleQueueCommand( + command: String, + userMessage: ChatMessage, + ) { val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim() if (arg.isBlank()) { addAssistantMessage("usage: /queue ") return } - submitPrompt(arg, queued = true) + val storageId = _uiState.value.currentSessionId ?: return + val runtimeId = runtimeSessionId ?: return + val busy = + mainTurnBusy || + sendStore.all().any { + it.scope == sendScope() && it.sessionId == storageId && + it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN) + } + try { + sendStore.put( + PendingSend( + id = userMessage.id, + scope = sendScope(), + sessionId = storageId, + text = arg, + mode = BusySendMode.QUEUE, + state = if (busy) PendingSendState.QUEUED else PendingSendState.SENDING, + ), + ) + } catch (e: Exception) { + removeUnconfirmedBubble(userMessage.id) + _uiState.update { it.copy(errorMessage = "Could not save message for delivery") } + return + } + publishPendingSends() + if (busy) { + viewModelScope.launch(ioDispatcher) { repo.persistMessage(userMessage, storageId) } + } else { + dispatchPrompt( + text = arg, + attachments = emptyList(), + wasStreaming = false, + storageSessionId = storageId, + agentSessionId = runtimeId, + userMessage = userMessage, + mode = BusySendMode.QUEUE, + queued = true, + ) + } } private fun handleReasoningSlashCommand(arg: String) { @@ -2786,6 +3891,10 @@ class ChatViewModel( fun interruptSession() { val sessionId = runtimeSessionId ?: return + _uiState.value.currentSessionId?.let { storageId -> + sendStore.park(sendScope(), storageId) + publishPendingSends() + } viewModelScope.launch(ioDispatcher) { wsClient.send( WsMethods.SESSION_INTERRUPT, @@ -2944,6 +4053,7 @@ class ChatViewModel( _uiState.update { state -> state.copy( typingEffectEnabled = AuthManager.isTypingEffectEnabled(), + busySendMode = AuthManager.getBusySendMode(), typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(), messageStatsEnabled = AuthManager.isMessageStatsEnabled(), showUserMessageTokens = AuthManager.isUserMessageTokensEnabled(), @@ -2952,6 +4062,7 @@ class ChatViewModel( showModelProvider = AuthManager.isModelProviderShown(), ) } + publishPendingSends() } /** @@ -3303,6 +4414,31 @@ class ChatViewModel( ): List? { while (isCurrent()) { val snapshot = _uiState.value + // Receipt bubbles can be absent after rejection/reconnect. Offer their identities + // to the canonical matcher without displaying unconfirmed messages again. + val visibleIds = snapshot.messages.map { it.id }.toSet() + val receiptCandidates = + if (cached) { + emptyList() + } else { + sendStore + .all() + .filter { + it.scope == sendScope() && it.sessionId == snapshot.currentSessionId && + it.id !in visibleIds && + it.state in + setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN) + }.map { + ChatMessage( + id = it.id, + role = MessageRole.USER, + content = it.text, + attachments = it.attachments, + timestamp = it.createdAt, + ) + } + } + val receiptCandidateIds = receiptCandidates.map { it.id }.toSet() val computed = withContext(historyDispatcher) { val mapped = mapPage(snapshot.messages) @@ -3334,10 +4470,10 @@ class ChatViewModel( } else { mergeTranscriptWithLive( page, - snapshot.messages, + snapshot.messages + receiptCandidates, chronological = !prepend, preserveLiveIds = true, - ) + ).filterNot { it.id in receiptCandidateIds && it.canonicalRestId == null } } val stableMessages = if (merged == snapshot.messages) snapshot.messages else merged Triple(page, stableMessages, hydrateTodosFromMessages(merged).ifEmpty { snapshot.todos }) @@ -3517,6 +4653,12 @@ class ChatViewModel( ) repo.confirmIdentities(aliases, sessionId) } + val scope = sendScope() + val scopedPending = + sendStore.all().filter { it.scope == scope && it.sessionId == sessionId } + pendingSendIdsConfirmedByDurableAliases(aliases, scopedPending).forEach(::removePendingSend) + publishPendingSends() + drainPendingQueue() } // ── Session resume recovery (desktop parity) ───────────────────────── @@ -3552,7 +4694,12 @@ class ChatViewModel( isLoading: Boolean, ): Long { val generation = ++sessionGeneration + // A slow drain belongs to the old session and must not block the resumed queue. + queueDrainJob?.cancel() + queueDrainJob = null connectionOperationDelegate.reset() + mainTurnBusy = false + lastMainCompletionAt = 0L cancelResumeRetry() contextUsageJob?.cancel() contextUsageJob = null @@ -3586,6 +4733,7 @@ class ChatViewModel( currentSessionId = sessionId, chatTitle = title, isAgentTyping = false, + isSending = false, isThinking = false, thinkingText = "", isLoading = isLoading, @@ -3626,6 +4774,7 @@ class ChatViewModel( pendingPrefillText = null, ) } + publishPendingSends() return generation } @@ -3691,6 +4840,7 @@ class ChatViewModel( isSessionReady = runtimeSessionId != null, ) } + drainPendingQueue() } private fun resumeRetryDelayMs(attempt: Int): Long = diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index cf67124f..331528d1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.chat.components +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -37,6 +38,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -59,6 +61,7 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.ws.CommandBlocklist import com.m57.hermescontrol.data.ws.CommandCatalog @@ -75,9 +78,11 @@ fun ChatInputBar( inputFieldValue: TextFieldValue, onInputChange: (TextFieldValue) -> Unit, onSend: () -> Unit, + onBusySend: (BusySendMode) -> Unit = {}, onMicTap: () -> Unit, isListening: Boolean, isAgentTyping: Boolean, + isMainTurnBusy: Boolean = false, isConnected: Boolean, commandCatalog: CommandCatalog, isSessionReady: Boolean, @@ -111,10 +116,14 @@ fun ChatInputBar( val canSend = pendingReasoningLevel == null && ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady) + val hasDraft = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty() + val isSlashDraft = inputFieldValue.text.trimStart().startsWith("/") + val showBusyActions = isMainTurnBusy && hasDraft && !isSlashDraft // Attachment tray state var showAttachmentTray by remember { mutableStateOf(false) } val palette = composerPalette() + BackHandler(enabled = showAttachmentTray) { showAttachmentTray = false } AnimatedVisibility( visible = true, @@ -319,8 +328,11 @@ fun ChatInputBar( reasoningLevel = reasoningLevel, isListening = isListening, canSend = canSend, - showSend = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty(), + showSend = hasDraft, + showQueue = showBusyActions, onSend = onSend, + onQueue = { onBusySend(BusySendMode.QUEUE) }, + onStopAndSend = { onBusySend(BusySendMode.INTERRUPT) }, onAttachTap = { showAttachmentTray = !showAttachmentTray }, onModelTap = onModelTap, onReasoningSelected = onReasoningTap, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt index bfee7e24..943967ef 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt @@ -6,8 +6,10 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -27,6 +29,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AddToQueue import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Stop @@ -56,7 +59,9 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.scale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -83,6 +88,7 @@ import com.m57.hermescontrol.R * When [supportsReasoning] is false the model takes no reasoning parameter * and the level menu is disabled. */ +@OptIn(ExperimentalFoundationApi::class) @Composable fun ComposerToolbar( isConnected: Boolean, @@ -96,7 +102,10 @@ fun ComposerToolbar( modifier: Modifier = Modifier, canSend: Boolean = false, showSend: Boolean = canSend, + showQueue: Boolean = false, onSend: () -> Unit = {}, + onQueue: () -> Unit = {}, + onStopAndSend: () -> Unit = {}, canDisableReasoning: Boolean? = null, supportsReasoning: Boolean? = null, fastMode: Boolean = false, @@ -430,29 +439,62 @@ fun ComposerToolbar( } } + AnimatedVisibility( + visible = showQueue, + enter = fadeIn() + scaleIn(initialScale = 0.8f), + exit = fadeOut() + scaleOut(targetScale = 0.8f), + ) { + FilledIconButton( + onClick = onQueue, + enabled = canSend, + colors = flatIconButtonColors(palette), + modifier = Modifier.size(48.dp).testTag("queue_button"), + ) { + Icon( + imageVector = Icons.Default.AddToQueue, + contentDescription = stringResource(R.string.chat_busy_queue_action), + modifier = Modifier.size(20.dp), + ) + } + } + // Action button — send when a send is possible, mic / stop otherwise - FilledIconButton( - onClick = if (showSend) onSend else onMicTap, - enabled = if (showSend) canSend else isConnected, - colors = - if (!showSend && isListening) { - listeningIconButtonColors() - } else { - IconButtonDefaults.filledIconButtonColors( - containerColor = palette.action, - contentColor = palette.onAction, - ) - }, + val stopAndSendLabel = stringResource(R.string.chat_busy_stop_and_send) + val actionEnabled = if (showSend) canSend else isConnected + Box( modifier = Modifier - .size(ControlSize) - .testTag( + .size(if (showSend) 48.dp else ControlSize) + .clip(CircleShape) + .background( + if (!showSend && isListening) { + MaterialTheme.colorScheme.errorContainer + } else { + palette.action + }, + ).semantics { + if (showSend && showQueue && canSend) { + customActions = + listOf( + CustomAccessibilityAction(stopAndSendLabel) { + onStopAndSend() + true + }, + ) + } + }.combinedClickable( + enabled = actionEnabled, + onClick = if (showSend) onSend else onMicTap, + onLongClick = if (showSend && showQueue) onStopAndSend else null, + onLongClickLabel = if (showSend && showQueue) stopAndSendLabel else null, + ).testTag( when { showSend -> "send_button" isListening -> "mic_stop_button" else -> "mic_button" }, ), + contentAlignment = Alignment.Center, ) { Crossfade( targetState = @@ -468,15 +510,24 @@ fun ComposerToolbar( Icon( imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.chat_send_desc), + tint = palette.onAction, ) } ActionGlyph.STOP -> { - Icon(imageVector = Icons.Default.Stop, contentDescription = "Stop listening") + Icon( + imageVector = Icons.Default.Stop, + contentDescription = "Stop listening", + tint = if (isListening) MaterialTheme.colorScheme.onErrorContainer else palette.onAction, + ) } ActionGlyph.VOICE -> { - Icon(imageVector = Icons.Outlined.Mic, contentDescription = "Mic") + Icon( + imageVector = Icons.Outlined.Mic, + contentDescription = "Mic", + tint = palette.onAction, + ) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanel.kt new file mode 100644 index 00000000..9147e2fe --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanel.kt @@ -0,0 +1,103 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.chat.PendingSend +import com.m57.hermescontrol.ui.chat.PendingSendState + +@Composable +fun PendingSendPanel( + sends: List, + mainTurnBusy: Boolean, + onSendNow: (String) -> Unit, +) { + // Delivery receipts stay internal; only queued work and recovery need UI. + val visibleSends = sends.filter { it.state != PendingSendState.SENDING && it.state != PendingSendState.ACCEPTED } + if (visibleSends.isEmpty()) return + Surface( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Column(modifier = Modifier.padding(8.dp)) { + Text(stringResource(R.string.chat_pending_sends), style = MaterialTheme.typography.titleSmall) + if (mainTurnBusy || visibleSends.any { it.state == PendingSendState.UNKNOWN }) { + Text( + stringResource(R.string.chat_pending_send_now_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + LazyColumn(modifier = Modifier.heightIn(max = 170.dp)) { + items(visibleSends, key = { it.id }) { send -> + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Column(modifier = Modifier.weight(1f)) { + Text( + send.text.ifBlank { stringResource(R.string.chat_pending_attachments) }, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = + when (send.state) { + PendingSendState.QUEUED -> stringResource(R.string.chat_pending_queued) + PendingSendState.PARKED -> stringResource(R.string.chat_pending_parked) + PendingSendState.SENDING -> stringResource(R.string.chat_pending_sending) + PendingSendState.ACCEPTED -> stringResource(R.string.chat_pending_accepted) + PendingSendState.UNKNOWN -> stringResource(R.string.chat_pending_unknown) + PendingSendState.REJECTED -> stringResource(R.string.chat_pending_rejected) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (send.state in + setOf( + PendingSendState.QUEUED, + PendingSendState.PARKED, + PendingSendState.UNKNOWN, + PendingSendState.REJECTED, + PendingSendState.ACCEPTED, + ) + ) { + TextButton(onClick = { onSendNow(send.id) }) { + Text( + stringResource( + if (mainTurnBusy) { + R.string.chat_busy_stop_and_send + } else if (send.state in + setOf( + PendingSendState.UNKNOWN, + PendingSendState.ACCEPTED, + ) + ) { + R.string.chat_pending_send_again + } else { + R.string.chat_pending_send_now + }, + ), + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt index 75344ee2..174605b6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt @@ -193,6 +193,8 @@ internal fun SettingsChatPage( verticalArrangement = Arrangement.spacedBy(16.dp), ) { ChatSection( + busySendMode = state.busySendMode, + onBusySendModeChange = viewModel::onBusySendModeChange, typingEffectEnabled = state.typingEffectEnabled, onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange, typingEffectDelayMs = state.typingEffectDelayMs, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index c1e38f2f..411f45bb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.config.ConnectionProfile import com.m57.hermescontrol.data.config.resolveBaseUrl import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.SessionListCacheStore +import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.CleartextPolicy import com.m57.hermescontrol.data.remote.NetworkResult @@ -37,6 +38,7 @@ data class SettingsUiState( val testResult: String? = null, val isSaved: Boolean = false, val typingEffectEnabled: Boolean = false, + val busySendMode: BusySendMode = BusySendMode.CORRECT, val typingEffectDelayMs: Int = 30, val chatFontScale: Float = 1.0f, val messageStatsEnabled: Boolean = false, @@ -85,6 +87,7 @@ class SettingsViewModel( val useDynamicColors = AuthManager.isUseDynamicColors() val themePreset = AuthManager.getThemePreset() val typingEffectEnabled = AuthManager.isTypingEffectEnabled() + val busySendMode = AuthManager.getBusySendMode() val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs() val chatFontScale = AuthManager.getChatFontScale() val messageStatsEnabled = AuthManager.isMessageStatsEnabled() @@ -112,6 +115,7 @@ class SettingsViewModel( useDynamicColors = useDynamicColors, themePreset = themePreset, typingEffectEnabled = typingEffectEnabled, + busySendMode = busySendMode, typingEffectDelayMs = typingEffectDelayMs, chatFontScale = chatFontScale, messageStatsEnabled = messageStatsEnabled, @@ -336,6 +340,11 @@ class SettingsViewModel( AuthManager.setTypingEffectEnabled(enabled) } + fun onBusySendModeChange(mode: BusySendMode) { + _uiState.update { it.copy(busySendMode = mode, isSaved = false) } + AuthManager.setBusySendMode(mode) + } + fun onTypingEffectDelayMsChange(delayMs: Int) { _uiState.update { it.copy(typingEffectDelayMs = delayMs, isSaved = false) } AuthManager.setTypingEffectDelayMs(delayMs) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt index 87d2e175..e59f37a6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt @@ -38,8 +38,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreset +import com.m57.hermescontrol.ui.chat.label import com.m57.hermescontrol.ui.settings.SectionCard import kotlin.math.abs import kotlin.math.roundToInt @@ -215,6 +217,8 @@ internal fun ChatSection( onTokensPerSecondChange: (Boolean) -> Unit = {}, showModelProvider: Boolean = false, onShowModelProviderChange: (Boolean) -> Unit = {}, + busySendMode: BusySendMode = BusySendMode.CORRECT, + onBusySendModeChange: (BusySendMode) -> Unit = {}, ) { val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f) val currentIndex = @@ -223,6 +227,38 @@ internal fun ChatSection( .takeIf { it >= 0 } ?: 1 SectionCard { + var busyModeExpanded by remember { mutableStateOf(false) } + Text( + text = stringResource(R.string.chat_busy_mode_title), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.chat_busy_mode_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Box { + OutlinedButton(onClick = { busyModeExpanded = true }, modifier = Modifier.testTag("busy_send_default")) { + Text(busySendMode.label()) + } + DropdownMenu(expanded = busyModeExpanded, onDismissRequest = { busyModeExpanded = false }) { + BusySendMode.entries.forEach { mode -> + DropdownMenuItem( + text = { Text(mode.label()) }, + onClick = { + onBusySendModeChange(mode) + busyModeExpanded = false + }, + ) + } + } + } + Text( + text = stringResource(R.string.chat_busy_interrupt_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 1f8b9619..858a5b53 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1543,6 +1543,7 @@ تشغيل الفيديو جارٍ تجهيز الجلسة… الجلسة غير جاهزة — أعد المحاولة للمتابعة + إعداد %1$s %1$d من %2$d تم التفويض، لكن الأدوات غير متاحة @@ -1556,4 +1557,25 @@ %1$s مطلوب تعذر إرسال رد الإعداد. حاول مرة أخرى. بانتظار Hermes… - \ No newline at end of file + +أثناء عمل الوكيل +اختر كيفية وصول الرسالة الجديدة إلى المحادثة الحالية. تُرسل الرسائل بشكل عادي عند الخمول. +تصحيح الدور الحالي +الإرسال في الدور التالي +توجيه عند النقطة الآمنة التالية +مقاطعة قسرية ثم إرسال +قد توقف المقاطعة القسرية الوكلاء الفرعيين. يسمح الانتظار والتوجيه باستمرار العمل الحالي. +وضع إرسال هذه الرسالة +يسري هذا الاختيار مرة واحدة فقط ولا يغير الإعداد الافتراضي المحفوظ. +رسائل معلّقة +في قائمة الانتظار على هذا الجهاز +موقوفة بعد الإيقاف +جارٍ الإرسال… +قبلها الخادم؛ بانتظار ظهورها في السجل. قد تؤدي إعادة الإرسال إلى تكرارها. +حالة التسليم غير معروفة؛ قد يؤدي الإرسال مجددًا إلى تكرارها +لم تُسلّم؛ اختر الإرسال الآن لإعادة المحاولة +إرسال الآن +قد يقطع إرسال رسالة معلّقة الآن العمل الجاري ويوقف الوكلاء الفرعيين. +إرسال مجددًا +ستُرسل المرفقات مع هذه الرسالة. + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 1d7a72c3..d85ee3c7 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1199,6 +1199,7 @@ 비디오 재생 세션 준비 중… 세션이 준비되지 않았습니다. 다시 시도하세요 + %1$s 설정 %2$d개 중 %1$d개 인증되었지만 도구를 사용할 수 없습니다 @@ -1239,4 +1240,25 @@ 첨부 파일 오류: %1$s 활성 작업자 활성 작업자가 없습니다 - \ No newline at end of file + + 에이전트가 작업 중일 때 + 새 메시지를 현재 채팅에 전달할 방식을 선택합니다. 유휴 상태에서는 일반 전송됩니다. + 현재 턴 수정 + 다음 턴 대기열에 추가 + 다음 안전 지점에서 안내 + 강제 중단 후 전송 + 강제 중단은 하위 에이전트를 중지할 수 있습니다. 대기열과 안내는 현재 작업을 계속합니다. + 이 메시지의 전송 방식 + 이 선택은 한 번만 적용되며 저장된 기본값은 바뀌지 않습니다. + 대기 중인 메시지 + 이 기기에서 대기 중 + 중지 후 보류됨 + 전송 중… + 서버에서 수락됨; 대화 기록 대기 중. 다시 보내면 중복될 수 있습니다. + 전달 여부를 알 수 없습니다. 다시 보내면 중복될 수 있습니다. + 전달되지 않았습니다. 지금 보내기를 눌러 재시도하세요. + 지금 보내기 + 대기 중인 메시지를 지금 보내면 진행 중인 작업이 중단되고 하위 에이전트가 멈출 수 있습니다. + 다시 보내기 + 첨부 파일도 함께 전송됩니다. + diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 71b28b63..43541c9f 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1509,6 +1509,26 @@ %1$s为必填项 无法发送设置响应,请重试。 正在等待 Hermes… + 智能体工作时 + 选择新消息如何送达当前聊天。空闲时正常发送。 + 修正当前轮次 + 排队等待下一轮 + 在下一个安全点提供指导 + 强制中断后发送 + 强制中断可能停止子智能体。排队和指导会让当前工作继续。 + 本条消息的发送方式 + 此选择仅生效一次,不会更改保存的默认值。 + 待发送消息 + 已在本机排队 + 停止后已暂停 + 正在发送… + 服务器已接收,等待写入聊天记录。再次发送可能造成重复。 + 送达状态未知;再次发送可能重复 + 未送达;点按“立即发送”重试 + 立即发送 + 现在发送待处理消息可能会中断当前任务并停止子代理。 + 再次发送 + 附件将随这条消息发送。 时间线 回到最新 此窗口之前和之后还有更多消息。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1074ec6e..f6d1261c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -194,6 +194,29 @@ Waiting for response\u2026 Queued \u2014 sends when agent is free\u2026 Type a message\u2026 + While the agent is working + Choose how a new message reaches the current chat. Idle messages send normally. + Correct current turn + Queue next turn + Guide at next safe point + Hard interrupt, then send + Hard interrupt can stop child agents. Queue and guide let current work continue. + Queue + Stop & send + More + Send mode for this message + This choice applies once; the saved default stays the same. + Pending messages + Queued on this device + Parked after Stop + Sending\u2026 + Accepted by server; awaiting transcript. Sending again may duplicate it. + Delivery unknown; sending again may duplicate it + Not delivered; choose Send now to retry + Send now + Sending a pending message now may interrupt active work and stop child agents. + Send again + Attachments will be sent with this message. Execution Error: %1$s Exit Code: %1$d %1$d characters omitted from process output diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepositoryTest.kt index bb26ccd7..7761d123 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepositoryTest.kt @@ -4,6 +4,7 @@ import com.m57.hermescontrol.ui.chat.fakes.FakeChatMessageDao import kotlinx.coroutines.async import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Before import org.junit.Test @@ -106,6 +107,23 @@ class ChatPersistenceRepositoryTest { assertEquals(1000L, entity.timestamp) } + @Test + fun deleteMessageRemovesOnlyUnconfirmedOptimisticRows() = + runTest { + repository.persistMessage(ChatMessage(id = "local", role = MessageRole.USER, content = "draft"), "s") + repository.persistMessage( + ChatMessage(id = "confirmed", role = MessageRole.USER, content = "history", restId = "rest-s-7"), + "s", + ) + + repository.deleteMessage("local") + repository.deleteMessage("confirmed") + + val rows = repository.loadMessages("s") + assertFalse(rows.any { it.id == "local" }) + assertEquals("confirmed", rows.single().id) + } + @Test fun persistMessages_upsertsAllToDao() = runTest { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentationTest.kt new file mode 100644 index 00000000..e11e1dc4 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentationTest.kt @@ -0,0 +1,48 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.BusySendMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatQueuePresentationTest { + private val message = ChatMessage("queued-id", MessageRole.USER, "Later") + + private fun pending(state: PendingSendState) = + PendingSend(message.id, "scope", "session", message.content, mode = BusySendMode.QUEUE, state = state) + + @Test + fun queuedAndParkedMessagesRemainInternalUntilDispatch() { + val messages = listOf(message) + for (state in listOf(PendingSendState.QUEUED, PendingSendState.PARKED)) { + val receipts = listOf(pending(state)) + assertTrue(messagesWithoutUnsentQueue(messages, receipts).isEmpty()) + assertEquals(listOf(message), messages) + assertEquals(state, receipts.single().state) + } + } + + @Test + fun queuedMessageAppearsOnceWhenSendingStarts() { + val messages = listOf(message) + assertTrue(messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))).isEmpty()) + for (state in listOf(PendingSendState.SENDING, PendingSendState.ACCEPTED)) { + assertEquals(messages, messagesWithoutUnsentQueue(messages, listOf(pending(state)))) + } + } + + @Test + fun ordinarySendAndOtherMessagesAreUnchanged() { + val other = ChatMessage("other", MessageRole.USER, "Send now") + val messages = listOf(other, message) + assertSame(messages, messagesWithoutUnsentQueue(messages, emptyList())) + assertEquals(listOf(other), messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED)))) + } + + @Test + fun canonicalHistoryIsNeverHiddenByStaleQueueReceipt() { + val messages = listOf(message.copy(restId = "rest-session-12")) + assertEquals(messages, messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED)))) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSendStoreTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSendStoreTest.kt new file mode 100644 index 00000000..89d3e122 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSendStoreTest.kt @@ -0,0 +1,207 @@ +package com.m57.hermescontrol.ui.chat + +import android.content.SharedPreferences +import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.data.model.BusySendMode +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatSendStoreTest { + private fun pending( + id: String, + text: String, + state: PendingSendState = PendingSendState.ACCEPTED, + ) = PendingSend(id, "scope", "session", text, mode = BusySendMode.QUEUE, state = state) + + private fun mappedHistoryAliases( + incoming: List, + existing: List, + ): List = + incoming.zip(matchTranscriptMessages(incoming, existing)) { rest, match -> + match?.copy(restId = rest.canonicalRestId) ?: rest + } + + @Test + fun durableAliasConfirmsAttachmentAndSteerNormalizedReceipt() { + val local = ChatMessage("send-1", MessageRole.USER, "See attachment") + val rest = + ChatMessage( + "rest-session-10", + MessageRole.USER, + """ + [OUT-OF-BAND USER MESSAGE — direct steering] + @file:files/agent-vault/hermes/attachments/note.txt + + See attachment + [/OUT-OF-BAND USER MESSAGE] + """.trimIndent(), + ) + + val confirmed = + pendingSendIdsConfirmedByDurableAliases( + mappedHistoryAliases(listOf(rest), listOf(local)), + listOf(pending("send-1", "See attachment")), + ) + + assertEquals(setOf("send-1"), confirmed) + } + + @Test + fun durableAliasConsumesOnlyOneDuplicateReceiptOccurrence() { + val existing = + listOf( + ChatMessage("send-1", MessageRole.USER, "repeat"), + ChatMessage("send-2", MessageRole.USER, "repeat"), + ) + val aliases = + mappedHistoryAliases( + listOf(ChatMessage("rest-session-10", MessageRole.USER, "repeat")), + existing, + ) + + val confirmed = + pendingSendIdsConfirmedByDurableAliases( + aliases, + listOf(pending("send-1", "repeat"), pending("send-2", "repeat")), + ) + + assertEquals(setOf("send-1"), confirmed) + } + + @Test + fun repeatedHydrationDoesNotConfirmAnotherDuplicateReceipt() { + val aliases = + mappedHistoryAliases( + listOf(ChatMessage("rest-session-10", MessageRole.USER, "repeat")), + listOf(ChatMessage("send-1", MessageRole.USER, "repeat")), + ) + val first = + pendingSendIdsConfirmedByDurableAliases( + aliases, + listOf(pending("send-1", "repeat"), pending("send-2", "repeat")), + ) + val remaining = listOf(pending("send-2", "repeat")).filterNot { it.id in first } + + assertTrue(pendingSendIdsConfirmedByDurableAliases(aliases, remaining).isEmpty()) + } + + @Test + fun durableAliasDoesNotConfirmQueuedOrParkedReceipts() { + val aliases = + listOf( + ChatMessage("queued", MessageRole.USER, "same", restId = "rest-session-10"), + ChatMessage("parked", MessageRole.USER, "same", restId = "rest-session-11"), + ) + + val confirmed = + pendingSendIdsConfirmedByDurableAliases( + aliases, + listOf( + pending("queued", "same", PendingSendState.QUEUED), + pending("parked", "same", PendingSendState.PARKED), + ), + ) + + assertTrue(confirmed.isEmpty()) + } + + @Test + fun promoteMovesTheChosenMessageAheadOfEarlierQueuedMessages() { + val store = ChatSendStore() + store.put(PendingSend("first", "scope", "session", "one", mode = BusySendMode.QUEUE)) + store.put(PendingSend("second", "scope", "session", "two", mode = BusySendMode.QUEUE)) + + store.promote("second") + + assertEquals(listOf("second", "first"), store.all().map { it.id }) + } + + @Test + fun recreationPreservesQueueAndQuarantinesAmbiguousSendsAcrossScopes() { + var saved: String? = null + val prefs = mockk() + val editor = mockk() + every { prefs.getString("rows", null) } answers { saved } + every { prefs.edit() } returns editor + every { editor.putString("rows", any()) } answers { + saved = secondArg() + editor + } + every { editor.commit() } returns true + + val first = ChatSendStore(prefs) + first.put( + PendingSend( + "queued", + "connection-a", + "session-a", + "later", + attachments = listOf(Attachment("file:///private/photo", "photo.png", "image/png", 3)), + mode = BusySendMode.QUEUE, + ), + ) + first.put( + PendingSend( + "sending", + "connection-b", + "session-b", + "maybe", + mode = BusySendMode.CORRECT, + state = PendingSendState.SENDING, + ), + ) + first.park("connection-a", "session-a") + + val restored = ChatSendStore(prefs) + assertEquals(PendingSendState.PARKED, restored.all().single { it.id == "queued" }.state) + assertEquals(PendingSendState.UNKNOWN, restored.all().single { it.id == "sending" }.state) + assertEquals("connection-a", restored.all().single { it.id == "queued" }.scope) + assertEquals( + "file:///private/photo", + restored + .all() + .single { it.id == "queued" } + .attachments + .single() + .uri, + ) + assertEquals("session-b", restored.all().single { it.id == "sending" }.sessionId) + + restored.remove("queued") + assertTrue(ChatSendStore(prefs).all().none { it.id == "queued" }) + assertEquals(1, ChatSendStore(prefs).all().size) + } + + @Test + fun recreationQuarantinesAcceptedReceiptWithoutAutomaticReplay() { + var saved: String? = null + val prefs = mockk() + val editor = mockk() + every { prefs.getString("rows", null) } answers { saved } + every { prefs.edit() } returns editor + every { editor.putString("rows", any()) } answers { + saved = secondArg() + editor + } + every { editor.commit() } returns true + + ChatSendStore(prefs).put( + PendingSend( + "accepted", + "connection-a", + "session-a", + "possibly accepted", + mode = BusySendMode.QUEUE, + state = PendingSendState.ACCEPTED, + ), + ) + + val restored = ChatSendStore(prefs).all().single() + + assertEquals(PendingSendState.UNKNOWN, restored.state) + assertEquals(0, restored.attempts) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt index daf6f132..3c0c3b57 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt @@ -87,6 +87,10 @@ class ChatUpdateCommandTest { mockConnectionStatus.value = ConnectionStatus.DISCONNECTED every { AuthManager.getToken() } returns "test-token" + every { AuthManager.getPinnedModels() } returns emptyList() + every { AuthManager.getBaseUrl() } returns "http://test.local/" + every { AuthManager.getSelectedProfileId() } returns null + every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT every { AuthManager.isTypingEffectEnabled() } returns true every { AuthManager.getTypingEffectDelayMs() } returns 30 every { AuthManager.isMessageStatsEnabled() } returns false @@ -148,7 +152,15 @@ class ChatUpdateCommandTest { // threads — with a relaxed-mock app that NPEs on cacheDir and leaks // an uncaught exception into the NEXT runTest class (CI flakes: // UncaughtExceptionsBeforeTest). - val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher) + val vm = + ChatViewModel( + app, + false, + fakeRepo, + FakeSlashUsageStore(), + ioDispatcher = testDispatcher, + sendStore = ChatSendStore(), + ) advanceUntilIdle() mockConnectionStatus.value = ConnectionStatus.CONNECTED mockEventsFlow.emit(WsEvent.GatewayReady(null)) @@ -185,7 +197,15 @@ class ChatUpdateCommandTest { @Test fun `applyUpdate triggers the REST action and starts the progress popup`() = runTest { - val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher) + val vm = + ChatViewModel( + app, + false, + fakeRepo, + FakeSlashUsageStore(), + ioDispatcher = testDispatcher, + sendStore = ChatSendStore(), + ) coEvery { mockApi.updateHermes() } returns Response.success(ActionResponse(ok = true, name = "hermes-update")) // One running poll, then the action exits — runTest's teardown @@ -224,7 +244,15 @@ class ChatUpdateCommandTest { @Test fun `applyUpdate settles on success when the action exits cleanly`() = runTest { - val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher) + val vm = + ChatViewModel( + app, + false, + fakeRepo, + FakeSlashUsageStore(), + ioDispatcher = testDispatcher, + sendStore = ChatSendStore(), + ) coEvery { mockApi.updateHermes() } returns Response.success(ActionResponse(ok = true, name = "hermes-update")) coEvery { mockApi.getActionStatus("hermes-update") } returns @@ -252,7 +280,15 @@ class ChatUpdateCommandTest { @Test fun `applyUpdate surfaces a rejected trigger in the popup`() = runTest { - val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher) + val vm = + ChatViewModel( + app, + false, + fakeRepo, + FakeSlashUsageStore(), + ioDispatcher = testDispatcher, + sendStore = ChatSendStore(), + ) coEvery { mockApi.updateHermes() } returns Response.error(404, "no update endpoint".toResponseBody()) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 8bd9a1c1..57f46ef5 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.chat import android.app.Application import android.content.ContentResolver import android.content.Intent +import android.content.SharedPreferences import android.net.Uri import android.util.Log import androidx.core.content.FileProvider @@ -10,6 +11,7 @@ import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.HermesDatabase import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.AttachmentSource +import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.model.PaginationInfo import com.m57.hermescontrol.data.model.SessionMessage import com.m57.hermescontrol.data.model.SessionMessagesAroundPagination @@ -46,6 +48,7 @@ import io.mockk.verify import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first @@ -83,6 +86,7 @@ class ChatViewModelTest { private lateinit var fakeRepo: FakeChatPersistenceRepository private lateinit var fakeSlashUsageStore: FakeSlashUsageStore private lateinit var attachmentCacheDir: java.io.File + private lateinit var attachmentFilesDir: java.io.File /** Counter used to generate unique WS request IDs. */ private var reqCount = 0 @@ -279,7 +283,12 @@ class ChatViewModelTest { java.nio.file.Files .createTempDirectory("chat-attachments-test") .toFile() + attachmentFilesDir = + java.nio.file.Files + .createTempDirectory("chat-send-files-test") + .toFile() every { app.cacheDir } returns attachmentCacheDir + every { app.filesDir } returns attachmentFilesDir fakeRepo = FakeChatPersistenceRepository() fakeSlashUsageStore = FakeSlashUsageStore() ActiveSessionHolder.set(null) @@ -293,6 +302,7 @@ class ChatViewModelTest { every { ProfileSwitchCoordinator.switched } returns mockSwitchFlow every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow() every { AuthManager.isTypingEffectEnabled() } returns true + every { AuthManager.getBusySendMode() } returns BusySendMode.CORRECT every { AuthManager.getTypingEffectDelayMs() } returns 30 every { AuthManager.isMessageStatsEnabled() } returns false every { AuthManager.isUserMessageTokensEnabled() } returns true @@ -357,6 +367,7 @@ class ChatViewModelTest { fun tearDown() { Dispatchers.resetMain() attachmentCacheDir.deleteRecursively() + attachmentFilesDir.deleteRecursively() unmockkAll() } @@ -366,9 +377,9 @@ class ChatViewModelTest { private fun createViewModel( startCleanup: Boolean = false, historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = testDispatcher, + sendStore: ChatSendStore = ChatSendStore(), ): ChatViewModel = - // Both dispatchers injected — a real ioDispatcher would race the - // test scheduler (repo writes hop it) and shuffle RPC ordering. + // All injected dispatchers share the test scheduler so RPC ordering is deterministic. ChatViewModel( app, startCleanup, @@ -377,6 +388,7 @@ class ChatViewModelTest { testDispatcher, testDispatcher, historyDispatcher, + sendStore, ) /** @@ -389,8 +401,9 @@ class ChatViewModelTest { private suspend fun TestScope.createViewModelWithSession( startCleanup: Boolean = false, historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = testDispatcher, + sendStore: ChatSendStore = ChatSendStore(), ): Pair { - val viewModel = createViewModel(startCleanup, historyDispatcher) + val viewModel = createViewModel(startCleanup, historyDispatcher, sendStore) advanceUntilIdle() mockConnectionStatus.value = ConnectionStatus.CONNECTED @@ -828,7 +841,7 @@ class ChatViewModelTest { advanceUntilIdle() // 3. WS returns interrupt success -> adds "Session interrupted" - mockEventsFlow.emit(WsEvent.RpcResult("interrupt-req-1", mapOf("ok" to true))) + mockEventsFlow.emit(WsEvent.RpcResult("interrupt-req-1", mapOf("status" to "interrupted"))) advanceUntilIdle() // 4. Server transcript sync arrives with newer turns @@ -1814,6 +1827,36 @@ class ChatViewModelTest { // ── Clarify tests ──────────────────────────────────────────────────────── + @Test + fun mainTurnBusyTracksRuntimeEventsNotClarificationTyping() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + assertFalse(viewModel.uiState.value.isMainTurnBusy) + + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isMainTurnBusy) + + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + assertFalse(viewModel.uiState.value.isMainTurnBusy) + + mockEventsFlow.emit( + WsEvent.ClarifyRequest( + text = "Continue?", + options = listOf("Yes"), + clarifyId = "clarify-busy-state", + sessionId = sessionId, + ), + ) + advanceUntilIdle() + viewModel.respondToClarify("Yes") + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.isAgentTyping) + assertFalse(viewModel.uiState.value.isMainTurnBusy) + } + @Test fun testClarifyRequestAndRespond() = runTest { @@ -2295,20 +2338,16 @@ class ChatViewModelTest { size = MAX_CHAT_ATTACHMENT_BYTES + 1, ) - viewModel.sendMessage("Inspect this archive") + assertFalse(viewModel.sendMessage("Inspect this archive")) advanceUntilIdle() assertEquals(2, viewModel.uiState.value.pendingAttachments.size) - assertEquals("Inspect this archive", viewModel.uiState.value.composerTextToRestore) + assertNull(viewModel.uiState.value.composerTextToRestore) assertTrue( viewModel.uiState.value.errorMessage ?.contains("too large") == true, ) verify(exactly = 0) { contentResolver.openInputStream(any()) } - - viewModel.consumeComposerTextRestore() - advanceUntilIdle() - assertNull(viewModel.uiState.value.composerTextToRestore) } @Test @@ -2460,11 +2499,24 @@ class ChatViewModelTest { val (viewModel, _) = createViewModelWithSession() val uriString = "content://test/note" val uri = mockk() + val snapshotUri = mockk() val resolver = mockk() + lateinit var snapshotFile: java.io.File mockkStatic(Uri::class) every { Uri.parse(uriString) } returns uri + every { Uri.fromFile(any()) } answers { + snapshotFile = firstArg() + snapshotUri + } + every { snapshotUri.toString() } returns "file://private-note-copy" + every { Uri.parse("file://private-note-copy") } returns snapshotUri every { app.contentResolver } returns resolver every { resolver.openInputStream(uri) } answers { "hello".byteInputStream() } + every { resolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() } + mockkConstructor(android.util.Base64OutputStream::class) + every { anyConstructed().write(any(), any(), any()) } returns + Unit + every { anyConstructed().close() } returns Unit every { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } returns CompletableDeferred( buildJsonObject { @@ -2786,7 +2838,7 @@ class ChatViewModelTest { viewModel.uiState.value.messages[1] .role, ) - assertTrue(viewModel.uiState.value.isAgentTyping) + assertTrue(viewModel.uiState.value.isSending) assertEquals(sessionId, ActiveSessionHolder.resolveStoredSessionId(sessionId)) verify { HermesWsClient.sendMessage(sessionId, "Hello Hermes", any()) } @@ -2800,6 +2852,8 @@ class ChatViewModelTest { // First send starts streaming viewModel.sendMessage("Hello Hermes") advanceUntilIdle() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() assertTrue(viewModel.uiState.value.isAgentTyping) // Second send while streaming triggers redirect @@ -2809,6 +2863,1834 @@ class ChatViewModelTest { verify { HermesWsClient.sendRedirect(sessionId, "Wait, correction", any()) } } + @Test + fun rejectedRedirectRemovesGhostAndQueuesRecoverableText() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + var redirectId = "" + every { HermesWsClient.sendRedirect(sessionId, any(), any()) } answers { + redirectId = "rejected-redirect" + arg<((String) -> Unit)?>(2)?.invoke(redirectId) + redirectId + } + + viewModel.sendMessage("First turn") + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.sendMessage("Correction") + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult(redirectId, mapOf("status" to "rejected"))) + advanceUntilIdle() + + assertFalse( + viewModel.uiState.value.messages + .any { it.content == "Correction" }, + ) + assertFalse(fakeRepo.loadMessages(sessionId).any { it.content == "Correction" }) + assertTrue( + viewModel.uiState.value.pendingSends + .any { it.text == "Correction" }, + ) + assertTrue(viewModel.uiState.value.isAgentTyping) + } + + @Test + fun rejectedRedirectFallsBackToQueueAndRestHistoryRecoversOneUserRow() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + var redirectId = "" + var queuedId = "" + var historyHasCorrection = false + every { HermesWsClient.sendRedirect(sessionId, any(), any()) } answers { + redirectId = "redirect-for-history" + arg<((String) -> Unit)?>(2)?.invoke(redirectId) + redirectId + } + every { HermesWsClient.sendMessage(sessionId, "Correction", any(), true) } answers { + queuedId = "queued-for-history" + arg<((String) -> Unit)?>(2)?.invoke(queuedId) + queuedId + } + coEvery { + ApiClient.hermesApi.getSessionMessages(sessionId, any(), any(), any(), any()) + } answers { + retrofit2.Response.success( + SessionMessagesResponse( + messages = + if (historyHasCorrection) { + listOf( + SessionMessage( + id = 12, + role = "user", + content = JsonPrimitive("Correction"), + timestamp = JsonPrimitive(System.currentTimeMillis()), + ), + ) + } else { + emptyList() + }, + pagination = PaginationInfo(order = "latest"), + ), + ) + } + + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.sendMessage("Correction") + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult(redirectId, mapOf("status" to "rejected"))) + advanceUntilIdle() + assertFalse( + viewModel.uiState.value.messages + .any { it.content == "Correction" }, + ) + + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + assertEquals("queued-for-history", queuedId) + mockEventsFlow.emit(WsEvent.RpcResult(queuedId, mapOf("status" to "queued"))) + advanceUntilIdle() + assertEquals( + PendingSendState.ACCEPTED, + viewModel.uiState.value.pendingSends + .single() + .state, + ) + + historyHasCorrection = true + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + mockEventsFlow.emit(WsEvent.MessageComplete("Answered", sessionId)) + advanceUntilIdle() + assertTrue( + viewModel.uiState.value.pendingSends + .isEmpty(), + ) + assertEquals( + 1, + viewModel.uiState.value.messages + .count { it.content == "Correction" }, + ) + assertTrue( + viewModel.uiState.value.messages + .any { it.content == "Correction" && it.canonicalRestId != null }, + ) + } + + @Test + fun busyQueueWaitsForCompletionAndUsesQueuedSubmit() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + + assertTrue(viewModel.sendMessage("Next turn", BusySendMode.QUEUE)) + advanceUntilIdle() + assertEquals( + PendingSendState.QUEUED, + viewModel.uiState.value.pendingSends + .single() + .state, + ) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Next turn", any(), any()) } + + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Next turn", any(), true) } + } + + @Test + fun queueTapAfterTurnCompletionSubmitsNormally() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + + assertTrue(viewModel.sendMessage("Late queue tap", BusySendMode.QUEUE)) + advanceUntilIdle() + + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Late queue tap", any(), false) } + assertFalse( + viewModel.uiState.value.pendingSends + .any { it.text == "Late queue tap" && it.state == PendingSendState.QUEUED }, + ) + } + + @Test + fun queueDrainKeepsOwnershipWhileFirstReceiptPersistenceIsSuspended() = + runTest { + fakeRepo = spyk(fakeRepo) + val releasePersistence = CompletableDeferred() + var holdPersistence = false + var preparationCount = 0 + coEvery { fakeRepo.persistMessage(match { it.content == "FIFO first" }, any()) } coAnswers { + if (holdPersistence) { + preparationCount++ + releasePersistence.await() + } + Unit + } + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.sendMessage("FIFO first", BusySendMode.QUEUE) + viewModel.sendMessage("FIFO second", BusySendMode.QUEUE) + advanceUntilIdle() + + holdPersistence = true + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + assertEquals(1, preparationCount) + // A duplicate completion must not start another drain during pre-enqueue IO. + mockEventsFlow.emit(WsEvent.MessageComplete("Done again", sessionId)) + advanceUntilIdle() + val preparationsBeforeRelease = preparationCount + releasePersistence.complete(Unit) + advanceUntilIdle() + + assertEquals(1, preparationsBeforeRelease) + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "FIFO first", any(), true) } + verify(exactly = 0) { HermesWsClient.sendMessage(sessionId, "FIFO second", any(), any()) } + } + + @Test + fun slashQueueSharesDurableLocalQueueWhileBusy() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.sendMessage("/queue after tools") + advanceUntilIdle() + assertEquals( + PendingSendState.QUEUED, + viewModel.uiState.value.pendingSends + .single() + .state, + ) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "after tools", any(), any()) } + + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "after tools", any(), true) } + } + + @Test + fun busyAttachmentIsSnapshottedAndQueuedWithoutRedirect() = + runTest { + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val sourceUri = mockk() + val snapshotUri = mockk() + val resolver = mockk() + lateinit var snapshotFile: java.io.File + mockkStatic(Uri::class) + every { Uri.parse("content://queued/photo") } returns sourceUri + every { Uri.parse("file://queued-copy") } returns snapshotUri + every { Uri.fromFile(any()) } answers { + snapshotFile = firstArg() + snapshotUri + } + every { snapshotUri.toString() } returns "file://queued-copy" + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(1, 2, 3).inputStream() } + every { resolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() } + io.mockk.mockkConstructor(android.util.Base64OutputStream::class) + every { + anyConstructed().write(any(), any(), any()) + } returns Unit + every { anyConstructed().close() } returns Unit + every { HermesWsClient.request(WsMethods.IMAGE_ATTACH_BYTES, any(), any()) } returns + CompletableDeferred(mapOf("attached" to true)) + + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.addAttachment("content://queued/photo", "photo.png", "image/png", 3) + viewModel.sendMessage("See photo") + assertTrue("The receipt must not exist before the private snapshot completes", store.all().isEmpty()) + advanceUntilIdle() + assertEquals( + PendingSendState.QUEUED, + viewModel.uiState.value.pendingSends + .single() + .state, + ) + assertTrue(snapshotFile.exists()) + assertEquals( + "file://queued-copy", + store + .all() + .single() + .attachments + .single() + .uri, + ) + verify(exactly = 0) { HermesWsClient.sendRedirect(any(), any(), any()) } + + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "See photo", any(), true) } + } + + @Test + fun manualAttachmentRetryCannotOverlapActiveQueuePreparation() = + runTest { + fakeRepo = spyk(fakeRepo) + val releasePersistence = CompletableDeferred() + var holdPersistence = false + var preparations = 0 + coEvery { fakeRepo.persistMessage(match { it.content == "Queued attachment" }, any()) } coAnswers { + if (holdPersistence) { + preparations++ + releasePersistence.await() + } + Unit + } + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val sourceUri = mockk() + val snapshotUri = mockk() + val resolver = mockk() + lateinit var snapshot: java.io.File + mockkStatic(Uri::class) + every { Uri.parse("content://queue/overlap") } returns sourceUri + every { Uri.fromFile(any()) } answers { + snapshot = firstArg() + snapshotUri + } + every { snapshotUri.toString() } returns "file://queue-overlap" + every { Uri.parse("file://queue-overlap") } returns snapshotUri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(1, 2, 3).inputStream() } + every { resolver.openInputStream(snapshotUri) } answers { snapshot.inputStream() } + mockkConstructor(android.util.Base64OutputStream::class) + every { anyConstructed().write(any(), any(), any()) } returns + Unit + every { anyConstructed().close() } returns Unit + every { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } returns + CompletableDeferred(mapOf("ref_text" to "@file:overlap.txt")) + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.addAttachment("content://queue/overlap", "overlap.txt", "text/plain", 3) + viewModel.sendMessage("Queued attachment", BusySendMode.QUEUE) + advanceUntilIdle() + val receipt = store.all().single() + holdPersistence = true + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + assertEquals(1, preparations) + + viewModel.sendQueuedNow(receipt.id) + viewModel.sendQueuedNow(receipt.id) + advanceUntilIdle() + val preparationsBeforeRelease = preparations + releasePersistence.complete(Unit) + advanceUntilIdle() + + assertEquals(1, preparationsBeforeRelease) + assertEquals(1, store.all().size) + assertEquals(PendingSendState.SENDING, store.all().single().state) + verify(exactly = 1) { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } + verify(exactly = 1) { + HermesWsClient.sendMessage(sessionId, "@file:overlap.txt\n\nQueued attachment", any(), true) + } + verify(exactly = 0) { + HermesWsClient.sendMessage(sessionId, "@file:overlap.txt\n\nQueued attachment", any(), false) + } + } + + @Test + fun idleAttachmentReceiptIsCommittedOnlyWithPrivateSnapshotUri() = + runTest { + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val sourceUri = mockk() + val snapshotUri = mockk() + val resolver = mockk() + lateinit var snapshotFile: java.io.File + mockkStatic(Uri::class) + every { Uri.parse("content://idle/photo") } returns sourceUri + every { Uri.fromFile(any()) } answers { + snapshotFile = firstArg() + snapshotUri + } + every { snapshotUri.toString() } returns "file://private-idle-copy" + every { Uri.parse("file://private-idle-copy") } returns snapshotUri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(1, 2, 3).inputStream() } + every { resolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() } + mockkConstructor(android.util.Base64OutputStream::class) + every { anyConstructed().write(any(), any(), any()) } returns + Unit + every { anyConstructed().close() } returns Unit + every { HermesWsClient.request(WsMethods.IMAGE_ATTACH_BYTES, any(), any()) } returns + CompletableDeferred(mapOf("attached" to true)) + + viewModel.addAttachment("content://idle/photo", "photo.png", "image/png", 3) + assertTrue(viewModel.sendMessage("Idle attachment")) + assertTrue(store.all().isEmpty()) + advanceUntilIdle() + + assertTrue(snapshotFile.exists()) + assertEquals( + "file://private-idle-copy", + store + .all() + .single() + .attachments + .single() + .uri, + ) + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Idle attachment", any(), false) } + } + + @Test + fun interruptAttachmentReceiptIsPrivateBeforeInterruptRequest() = + runTest { + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val sourceUri = mockk() + val snapshotUri = mockk() + val resolver = mockk() + mockkStatic(Uri::class) + every { Uri.parse("content://interrupt/note") } returns sourceUri + every { Uri.fromFile(any()) } returns snapshotUri + every { snapshotUri.toString() } returns "file://private-interrupt-copy" + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(4, 5, 6).inputStream() } + + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.addAttachment("content://interrupt/note", "note.txt", "text/plain", 3) + assertTrue(viewModel.sendMessage("Replace with file", BusySendMode.INTERRUPT)) + assertTrue(store.all().isEmpty()) + advanceUntilIdle() + + assertEquals( + "file://private-interrupt-copy", + store + .all() + .single() + .attachments + .single() + .uri, + ) + verify(exactly = 1) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Replace with file", any(), any()) } + } + + @Test + fun slowAttachmentPrecommitKeepsLaterTextBehindItsReceipt() = + runTest { + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val sourceUri = mockk() + val snapshotUri = mockk() + val resolver = mockk() + lateinit var snapshotFile: java.io.File + mockkStatic(Uri::class) + every { Uri.parse("content://slow/first") } returns sourceUri + every { Uri.fromFile(any()) } answers { + snapshotFile = firstArg() + snapshotUri + } + every { snapshotUri.toString() } returns "file://private-first-copy" + every { Uri.parse("file://private-first-copy") } returns snapshotUri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { + assertTrue(viewModel.sendMessage("Later text")) + byteArrayOf(1, 2, 3).inputStream() + } + every { resolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() } + mockkConstructor(android.util.Base64OutputStream::class) + every { anyConstructed().write(any(), any(), any()) } returns + Unit + every { anyConstructed().close() } returns Unit + every { HermesWsClient.request(WsMethods.IMAGE_ATTACH_BYTES, any(), any()) } returns + CompletableDeferred(mapOf("attached" to true)) + + viewModel.addAttachment("content://slow/first", "first.png", "image/png", 3) + assertTrue(viewModel.sendMessage("First attachment")) + advanceUntilIdle() + + assertEquals(listOf("First attachment", "Later text"), store.all().map { it.text }) + assertTrue(store.all().zipWithNext().all { (a, b) -> a.createdAt < b.createdAt }) + assertEquals(PendingSendState.SENDING, store.all()[0].state) + assertEquals(PendingSendState.QUEUED, store.all()[1].state) + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "First attachment", any(), false) } + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Later text", any(), any()) } + } + + @Test + fun busyAttachmentSnapshotFailureRestoresDraftWithoutReceiptOrGhost() = + runTest { + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val sourceUri = mockk() + val resolver = mockk() + mockkStatic(Uri::class) + every { Uri.parse("content://queued/missing") } returns sourceUri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } returns null + + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.addAttachment("content://queued/missing", "missing.txt", "text/plain", 4) + + assertTrue(viewModel.sendMessage("Keep my draft", BusySendMode.QUEUE)) + advanceUntilIdle() + + assertTrue(store.all().isEmpty()) + assertEquals("Keep my draft", viewModel.uiState.value.composerTextToRestore) + assertEquals( + "content://queued/missing", + viewModel.uiState.value.pendingAttachments + .single() + .uri, + ) + assertFalse( + viewModel.uiState.value.messages + .any { it.content == "Keep my draft" }, + ) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Keep my draft", any(), any()) } + } + + @Test + fun busyAttachmentSnapshotFinishingAfterSessionSwitchDoesNotCommitOrSubmit() = + runTest { + stubSession456Rests(success = true) + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val sourceUri = mockk() + val snapshotUri = mockk() + val resolver = mockk() + lateinit var stagedFile: java.io.File + mockkStatic(Uri::class) + every { Uri.parse("content://queued/session-switch") } returns sourceUri + every { Uri.fromFile(any()) } answers { + stagedFile = firstArg() + snapshotUri + } + every { snapshotUri.toString() } returns "file://stale-copy" + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { + viewModel.switchSession("session-456") + byteArrayOf(1, 2, 3).inputStream() + } + + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.addAttachment( + "content://queued/session-switch", + "switch.txt", + "text/plain", + 3, + ) + assertTrue(viewModel.sendMessage("Old session queued", BusySendMode.QUEUE)) + assertFalse( + viewModel.uiState.value.messages + .any { it.content == "Old session queued" }, + ) + + advanceUntilIdle() + + assertEquals("session-456", viewModel.uiState.value.currentSessionId) + assertTrue(store.all().isEmpty()) + assertFalse(stagedFile.exists()) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Old session queued", any(), any()) } + } + + @Test + fun idleIgnoresBusyModeAndSubmitsNormally() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + assertTrue(viewModel.sendMessage("Idle", BusySendMode.GUIDE)) + advanceUntilIdle() + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Idle", any(), false) } + verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_STEER, any(), any()) } + } + + @Test + fun subagentActivityAloneDoesNotTurnAnIdleMainSessionBusy() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit( + WsEvent.SubagentEvent( + type = "subagent.progress", + payload = mapOf("subagent_id" to "child-1", "status" to "running"), + sessionId = sessionId, + ), + ) + advanceUntilIdle() + viewModel.sendMessage("Main prompt", BusySendMode.GUIDE) + advanceUntilIdle() + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Main prompt", any(), false) } + verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_STEER, any(), any()) } + } + + @Test + fun toolStartMarksMainTurnBusyEvenWithoutMessageStart() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.ToolStart("shell", emptyMap(), sessionId)) + advanceUntilIdle() + viewModel.sendMessage("Guide", BusySendMode.GUIDE) + advanceUntilIdle() + verify(exactly = 1) { HermesWsClient.send(WsMethods.SESSION_STEER, any(), any()) } + verify(exactly = 0) { HermesWsClient.sendMessage(sessionId, "Guide", any(), any()) } + } + + @Test + fun refreshSettingsPicksUpChangedSavedBusyMode() = + runTest { + var savedMode = BusySendMode.CORRECT + every { AuthManager.getBusySendMode() } answers { savedMode } + val viewModel = createViewModel() + advanceUntilIdle() + assertEquals(BusySendMode.CORRECT, viewModel.uiState.value.busySendMode) + + savedMode = BusySendMode.GUIDE + viewModel.refreshSettings() + advanceUntilIdle() + assertEquals(BusySendMode.GUIDE, viewModel.uiState.value.busySendMode) + } + + @Test + fun rapidDuplicateTapCreatesOneReceiptAndOneRpc() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + assertTrue(viewModel.sendMessage("One tap")) + assertFalse(viewModel.sendMessage("One tap")) + advanceUntilIdle() + assertEquals(1, viewModel.uiState.value.pendingSends.size) + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "One tap", any(), any()) } + } + + @Test + fun stalePromptAckAfterSessionSwitchKeepsOldReceiptAndCurrentSessionClear() = + runTest { + stubSession456Rests(success = true) + val sendStore = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = sendStore) + val requestId = "old-prompt" + every { HermesWsClient.sendMessage(sessionId, "Old session", any(), any()) } answers { + arg<((String) -> Unit)?>(2)?.invoke(requestId) + requestId + } + viewModel.sendMessage("Old session") + advanceUntilIdle() + viewModel.switchSession("session-456") + advanceUntilIdle() + + mockEventsFlow.emit(WsEvent.RpcResult(requestId, mapOf("status" to "streaming"))) + advanceUntilIdle() + assertTrue( + viewModel.uiState.value.pendingSends + .isEmpty(), + ) + assertEquals(PendingSendState.UNKNOWN, sendStore.all().single().state) + } + + @Test + fun busyGuideUsesSteerAndUnsupportedFallsBackToQueueWithoutInterrupt() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + + viewModel.sendMessage("Keep the tests", BusySendMode.GUIDE) + advanceUntilIdle() + val id = sentRequestMethods.last { it.first == WsMethods.SESSION_STEER }.second + mockEventsFlow.emit(WsEvent.RpcError(id, JsonRpcError(4010, "steer unsupported"))) + advanceUntilIdle() + + assertEquals( + PendingSendState.QUEUED, + viewModel.uiState.value.pendingSends + .single() + .state, + ) + verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } + } + + @Test + fun interruptWithUnknownDeliveryQueuesWithoutInterrupting() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + every { HermesWsClient.sendMessage(sessionId, "Uncertain", any(), false) } answers { + arg<((String) -> Unit)?>(2)?.invoke("uncertain-submit") + "uncertain-submit" + } + + viewModel.sendMessage("Uncertain") + advanceUntilIdle() + viewModel.expireOutgoingRequest("uncertain-submit") + advanceUntilIdle() + + viewModel.sendMessage("Replacement", BusySendMode.INTERRUPT) + advanceUntilIdle() + + val replacement = + viewModel.uiState.value.pendingSends + .single { it.text == "Replacement" } + assertEquals(BusySendMode.QUEUE, replacement.mode) + assertEquals(PendingSendState.QUEUED, replacement.state) + assertEquals( + PendingSendState.UNKNOWN, + viewModel.uiState.value.pendingSends + .single { it.text == "Uncertain" } + .state, + ) + verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) } + } + + @Test + fun interruptWithAcceptedDeliveryQueuesWithoutInterrupting() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + every { HermesWsClient.sendMessage(sessionId, "Accepted", any(), false) } answers { + arg<((String) -> Unit)?>(2)?.invoke("accepted-submit") + "accepted-submit" + } + + viewModel.sendMessage("Accepted") + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult("accepted-submit", mapOf("status" to "streaming"))) + advanceUntilIdle() + + viewModel.sendMessage("Replacement", BusySendMode.INTERRUPT) + advanceUntilIdle() + + val replacement = + viewModel.uiState.value.pendingSends + .single { it.text == "Replacement" } + assertEquals(BusySendMode.QUEUE, replacement.mode) + assertEquals(PendingSendState.QUEUED, replacement.state) + assertEquals( + PendingSendState.ACCEPTED, + viewModel.uiState.value.pendingSends + .single { it.text == "Accepted" } + .state, + ) + verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) } + } + + @Test + fun interruptAfterCompletedTurnSubmitsNormally() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + + viewModel.sendMessage("Fresh turn", BusySendMode.INTERRUPT) + advanceUntilIdle() + + verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Fresh turn", any(), false) } + } + + @Test + fun hardInterruptWaitsForAckBeforeSubmitting() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + + viewModel.sendMessage("Urgent", BusySendMode.INTERRUPT) + advanceUntilIdle() + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Urgent", any(), any()) } + val id = sentRequestMethods.last { it.first == WsMethods.SESSION_INTERRUPT }.second + mockEventsFlow.emit(WsEvent.RpcResult(id, mapOf("status" to "interrupted"))) + advanceUntilIdle() + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Urgent", any(), true) } + } + + @Test + fun notInterruptedQueuesKnownUnsentReplacementAndSubmitsExactlyOnce() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + + viewModel.sendMessage("Replacement", BusySendMode.INTERRUPT) + advanceUntilIdle() + val interruptId = sentRequestMethods.last { it.first == WsMethods.SESSION_INTERRUPT }.second + + mockEventsFlow.emit(WsEvent.RpcResult(interruptId, mapOf("status" to "not_interrupted"))) + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult(interruptId, mapOf("status" to "not_interrupted"))) + advanceUntilIdle() + + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Replacement", any(), true) } + assertEquals( + PendingSendState.SENDING, + viewModel.uiState.value.pendingSends + .single { it.text == "Replacement" } + .state, + ) + } + + @Test + fun lateNotInterruptedDoesNotClearOrRaceANewerSameSessionTurn() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.sendMessage("Replacement", BusySendMode.INTERRUPT) + advanceUntilIdle() + val interruptId = sentRequestMethods.last { it.first == WsMethods.SESSION_INTERRUPT }.second + + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + mockEventsFlow.emit(WsEvent.MessageToken("new turn", sessionId)) + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult(interruptId, mapOf("status" to "not_interrupted"))) + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.isMainTurnBusy) + assertEquals( + "new turn", + viewModel.streamingState.value.streamingMessage + ?.content, + ) + assertEquals( + PendingSendState.QUEUED, + viewModel.uiState.value.pendingSends + .single { it.text == "Replacement" } + .state, + ) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) } + + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Replacement", any(), true) } + } + + @Test + fun lateInterruptedDoesNotClearOrDispatchAcrossANewerSameSessionTurn() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.sendMessage("Replacement", BusySendMode.INTERRUPT) + advanceUntilIdle() + val interruptId = sentRequestMethods.last { it.first == WsMethods.SESSION_INTERRUPT }.second + + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + mockEventsFlow.emit(WsEvent.MessageToken("new turn", sessionId)) + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult(interruptId, mapOf("status" to "interrupted"))) + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.isMainTurnBusy) + assertEquals( + "new turn", + viewModel.streamingState.value.streamingMessage + ?.content, + ) + assertEquals( + PendingSendState.QUEUED, + viewModel.uiState.value.pendingSends + .single { it.text == "Replacement" } + .state, + ) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) } + + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Replacement", any(), true) } + } + + @Test + fun attachmentInterruptUsesTurnCapturedAtClickAndQueuesIfCopyOutlivesIt() = + runTest { + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val sourceUri = mockk() + val snapshotUri = mockk() + val resolver = mockk() + lateinit var snapshotFile: java.io.File + mockkStatic(Uri::class) + every { Uri.parse("content://interrupt/slow") } returns sourceUri + every { Uri.fromFile(any()) } answers { + snapshotFile = firstArg() + snapshotUri + } + every { snapshotUri.toString() } returns "file://private-slow-copy" + every { Uri.parse("file://private-slow-copy") } returns snapshotUri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { + mockEventsFlow.tryEmit(WsEvent.MessageStart(sessionId)) + byteArrayOf(1, 2, 3).inputStream() + } + every { resolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() } + mockkConstructor(android.util.Base64OutputStream::class) + every { anyConstructed().write(any(), any(), any()) } returns + Unit + every { anyConstructed().close() } returns Unit + every { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } returns + CompletableDeferred(mapOf("ref_text" to "@file:slow.txt")) + + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.addAttachment("content://interrupt/slow", "slow.txt", "text/plain", 3) + assertTrue(viewModel.sendMessage("Captured turn", BusySendMode.INTERRUPT)) + advanceUntilIdle() + + val receipt = store.all().single() + assertEquals(PendingSendState.QUEUED, receipt.state) + assertEquals(BusySendMode.QUEUE, receipt.mode) + verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } + verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) } + + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + verify( + exactly = 1, + ) { HermesWsClient.sendMessage(sessionId, "@file:slow.txt\n\nCaptured turn", any(), true) } + } + + @Test + fun stopParksLocalQueueAndSendNowUnparksIt() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.sendMessage("Later", BusySendMode.QUEUE) + advanceUntilIdle() + val queuedId = + viewModel.uiState.value.pendingSends + .single() + .id + + viewModel.interruptSession() + advanceUntilIdle() + assertEquals( + PendingSendState.PARKED, + viewModel.uiState.value.pendingSends + .single() + .state, + ) + val stopId = sentRequestMethods.last { it.first == WsMethods.SESSION_INTERRUPT }.second + mockEventsFlow.emit(WsEvent.RpcResult(stopId, mapOf("status" to "interrupted"))) + advanceUntilIdle() + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Later", any(), any()) } + + viewModel.sendQueuedNow(queuedId) + advanceUntilIdle() + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Later", any(), true) } + } + + @Test + fun sendNowWaitsForEarlierAcceptedTurnToReachRestHistory() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + every { HermesWsClient.sendMessage(sessionId, "First", any(), true) } answers { + arg<((String) -> Unit)?>(2)?.invoke("first-queued-submit") + "first-queued-submit" + } + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.sendMessage("First", BusySendMode.QUEUE) + viewModel.sendMessage("Second", BusySendMode.QUEUE) + advanceUntilIdle() + + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult("first-queued-submit", mapOf("status" to "queued"))) + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + val second = + viewModel.uiState.value.pendingSends + .single { it.text == "Second" } + viewModel.sendQueuedNow(second.id) + advanceUntilIdle() + + assertEquals( + PendingSendState.QUEUED, + viewModel.uiState.value.pendingSends + .single { it.id == second.id } + .state, + ) + verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } + } + + @Test + fun manualRetryRecommitsAttachmentOnlyAfterPrivateStaging() = + runTest { + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val scope = + listOf( + AuthManager.getBaseUrl(), + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + val sourceUri = mockk() + val snapshotUri = mockk() + val resolver = mockk() + lateinit var snapshotFile: java.io.File + mockkStatic(Uri::class) + every { Uri.parse("content://retry/note") } returns sourceUri + every { Uri.fromFile(any()) } answers { + snapshotFile = firstArg() + snapshotUri + } + every { snapshotUri.toString() } returns "file://private-retry-copy" + every { Uri.parse("file://private-retry-copy") } returns snapshotUri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(7, 8, 9).inputStream() } + every { resolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() } + mockkConstructor(android.util.Base64OutputStream::class) + every { anyConstructed().write(any(), any(), any()) } returns + Unit + every { anyConstructed().close() } returns Unit + every { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } returns + CompletableDeferred(mapOf("ref_text" to "@file:note.txt")) + store.put( + PendingSend( + id = "legacy-retry", + scope = scope, + sessionId = sessionId, + text = "Retry attachment", + attachments = listOf(Attachment("content://retry/note", "note.txt", "text/plain", 3)), + mode = BusySendMode.QUEUE, + state = PendingSendState.REJECTED, + ), + ) + viewModel.refreshSettings() + advanceUntilIdle() + + viewModel.sendQueuedNow("legacy-retry") + viewModel.sendQueuedNow("legacy-retry") + assertEquals( + "content://retry/note", + store + .all() + .single() + .attachments + .single() + .uri, + ) + assertTrue(store.all().single().requiresAttachmentRecovery) + advanceUntilIdle() + + assertEquals( + "file://private-retry-copy", + store + .all() + .single() + .attachments + .single() + .uri, + ) + assertFalse(store.all().single().requiresAttachmentRecovery) + verify(exactly = 1) { + HermesWsClient.sendMessage(sessionId, "@file:note.txt\n\nRetry attachment", any(), false) + } + } + + @Test + fun manualRetryPreparationOversizePreservesOriginalReceipt() = + runTest { + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val scope = + listOf( + AuthManager.getBaseUrl(), + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + val sourceUri = mockk() + val snapshotUri = mockk() + val resolver = mockk() + mockkStatic(Uri::class) + every { Uri.parse("content://retry/growing") } returns sourceUri + every { Uri.fromFile(any()) } returns snapshotUri + every { snapshotUri.toString() } returns "file://retry-growing-copy" + every { Uri.parse("file://retry-growing-copy") } returns snapshotUri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(1).inputStream() } + every { resolver.openInputStream(snapshotUri) } answers { + object : java.io.InputStream() { + private var remaining = MAX_CHAT_ATTACHMENT_BYTES + 1 + + override fun read(): Int = if (remaining-- > 0) 0 else -1 + + override fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int { + if (remaining <= 0) return -1 + val count = minOf(length.toLong(), remaining).toInt() + remaining -= count + return count + } + } + } + mockkConstructor(android.util.Base64OutputStream::class) + every { anyConstructed().write(any(), any(), any()) } returns + Unit + every { anyConstructed().close() } returns Unit + store.put( + PendingSend( + id = "retry-growing", + scope = scope, + sessionId = sessionId, + text = "Keep recovery receipt", + attachments = + listOf( + Attachment("content://retry/growing", "growing.bin", "application/octet-stream", 1), + ), + mode = BusySendMode.QUEUE, + state = PendingSendState.REJECTED, + ), + ) + val original = store.all().single() + viewModel.refreshSettings() + advanceUntilIdle() + viewModel.sendQueuedNow(original.id) + advanceUntilIdle() + + assertEquals(listOf(original), store.all()) + assertTrue( + viewModel.uiState.value.errorMessage + ?.contains("too large") == true, + ) + verify(exactly = 0) { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Keep recovery receipt", any(), any()) } + } + + @Test + fun manualRetryOwnerChangePreservesExistingPrivateSnapshot() = + runTest { + var baseUrl = "http://test.local/" + every { AuthManager.getBaseUrl() } answers { baseUrl } + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val scope = + listOf( + baseUrl, + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + val snapshot = java.io.File(attachmentFilesDir, "chat-send/retry-existing/0") + snapshot.parentFile.mkdirs() + snapshot.writeBytes(byteArrayOf(1, 2, 3)) + val snapshotUri = mockk() + val resolver = mockk() + mockkStatic(Uri::class) + every { Uri.parse("file://retry-existing") } returns snapshotUri + every { Uri.fromFile(any()) } returns snapshotUri + every { snapshotUri.toString() } returns "file://retry-existing" + every { app.contentResolver } returns resolver + every { resolver.openInputStream(snapshotUri) } answers { + baseUrl = "http://other-profile.local/" + snapshot.inputStream() + } + mockkConstructor(android.util.Base64OutputStream::class) + every { anyConstructed().write(any(), any(), any()) } returns + Unit + every { anyConstructed().close() } returns Unit + val original = + PendingSend( + id = "retry-existing", + scope = scope, + sessionId = sessionId, + text = "Keep existing snapshot", + attachments = listOf(Attachment("file://retry-existing", "note.txt", "text/plain", 3)), + mode = BusySendMode.QUEUE, + state = PendingSendState.UNKNOWN, + ) + store.put(original) + viewModel.sendQueuedNow(original.id) + advanceUntilIdle() + + assertEquals(listOf(original), store.all()) + assertTrue("Rollback must retain the original receipt's private bytes", snapshot.exists()) + verify(exactly = 0) { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } + verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) } + } + + @Test + fun manualRetryUnreadablePreparationPreservesUnknownReceipt() = + runTest { + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val scope = + listOf( + AuthManager.getBaseUrl(), + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + val snapshot = java.io.File(attachmentFilesDir, "chat-send/retry-unreadable/0") + snapshot.parentFile.mkdirs() + snapshot.writeBytes(byteArrayOf(1)) + val snapshotUri = mockk() + val resolver = mockk() + mockkStatic(Uri::class) + every { Uri.parse("file://retry-unreadable") } returns snapshotUri + every { Uri.fromFile(any()) } returns snapshotUri + every { snapshotUri.toString() } returns "file://retry-unreadable" + every { app.contentResolver } returns resolver + every { resolver.openInputStream(snapshotUri) } returns null + val original = + PendingSend( + id = "retry-unreadable", + scope = scope, + sessionId = sessionId, + text = "Keep unknown receipt", + attachments = listOf(Attachment("file://retry-unreadable", "note.txt", "text/plain", 1)), + mode = BusySendMode.QUEUE, + state = PendingSendState.UNKNOWN, + ) + store.put(original) + viewModel.sendQueuedNow(original.id) + advanceUntilIdle() + + assertEquals(listOf(original), store.all()) + verify(exactly = 0) { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } + verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) } + } + + @Test + fun legacyContentUriReceiptIsQuarantinedUntilExplicitRecovery() = + runTest { + val store = ChatSendStore() + val scope = + listOf( + AuthManager.getBaseUrl(), + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + store.put( + PendingSend( + id = "legacy-auto-drain", + scope = scope, + sessionId = "session-123", + text = "Needs attachment recovery", + attachments = listOf(Attachment("content://legacy/lost", "lost.txt", "text/plain", 4)), + mode = BusySendMode.QUEUE, + state = PendingSendState.QUEUED, + ), + ) + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + + val receipt = + viewModel.uiState.value.pendingSends + .single() + assertEquals(PendingSendState.REJECTED, receipt.state) + assertTrue(receipt.requiresAttachmentRecovery) + assertEquals("Needs attachment recovery", receipt.text) + assertEquals("lost.txt", receipt.attachments.single().name) + + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Needs attachment recovery", any(), any()) } + } + + @Test + fun manualAttachmentRetryKeepsOriginalReceiptAcrossSessionChange() = + runTest { + stubSession456Rests(success = true) + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val scope = + listOf( + AuthManager.getBaseUrl(), + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + val sourceUri = mockk() + val resolver = mockk() + mockkStatic(Uri::class) + every { Uri.parse("content://retry/session") } returns sourceUri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { + viewModel.switchSession("session-456") + byteArrayOf(1, 2, 3).inputStream() + } + store.put( + PendingSend( + id = "retry-session", + scope = scope, + sessionId = sessionId, + text = "Keep old session receipt", + attachments = listOf(Attachment("content://retry/session", "session.txt", "text/plain", 3)), + mode = BusySendMode.QUEUE, + state = PendingSendState.UNKNOWN, + ), + ) + viewModel.refreshSettings() + + viewModel.sendQueuedNow("retry-session") + advanceUntilIdle() + + val preserved = store.all().single { it.id == "retry-session" } + assertEquals("content://retry/session", preserved.attachments.single().uri) + assertEquals(PendingSendState.REJECTED, preserved.state) + assertTrue(preserved.requiresAttachmentRecovery) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Keep old session receipt", any(), any()) } + } + + @Test + fun manualAttachmentRetryKeepsOriginalReceiptAcrossProfileScopeChange() = + runTest { + var baseUrl = "http://test.local/" + every { AuthManager.getBaseUrl() } answers { baseUrl } + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val scope = + listOf( + baseUrl, + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + val sourceUri = mockk() + val resolver = mockk() + mockkStatic(Uri::class) + every { Uri.parse("content://retry/profile") } returns sourceUri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { + baseUrl = "http://other-profile.local/" + byteArrayOf(1, 2, 3).inputStream() + } + store.put( + PendingSend( + id = "retry-profile", + scope = scope, + sessionId = sessionId, + text = "Keep old profile receipt", + attachments = listOf(Attachment("content://retry/profile", "profile.txt", "text/plain", 3)), + mode = BusySendMode.QUEUE, + state = PendingSendState.UNKNOWN, + ), + ) + viewModel.refreshSettings() + + viewModel.sendQueuedNow("retry-profile") + advanceUntilIdle() + + val preserved = store.all().single { it.id == "retry-profile" } + assertEquals(scope, preserved.scope) + assertEquals("content://retry/profile", preserved.attachments.single().uri) + assertEquals(PendingSendState.REJECTED, preserved.state) + assertTrue(preserved.requiresAttachmentRecovery) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Keep old profile receipt", any(), any()) } + } + + // ── Send ownership linearization regressions ──────────────────────────── + + @Test + fun manualAttachmentRetryContextChangeAfterValidationPreservesOriginalReceiptAndDoesNotDispatch() = + runTest { + var baseUrl = "http://test.local/" + var changeContextOnNextStoreEdit = false + every { AuthManager.getBaseUrl() } answers { baseUrl } + val prefs = mockk() + val editor = mockk() + every { prefs.getString("rows", null) } returns null + every { prefs.edit() } answers { + if (changeContextOnNextStoreEdit) { + changeContextOnNextStoreEdit = false + baseUrl = "http://other-profile.local/" + } + editor + } + every { editor.putString("rows", any()) } returns editor + every { editor.commit() } returns true + val store = ChatSendStore(prefs) + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val scope = + listOf( + baseUrl, + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + val sourceUri = mockk() + val snapshotUri = mockk() + val resolver = mockk() + lateinit var snapshotFile: java.io.File + mockkStatic(Uri::class) + every { Uri.parse("content://retry/post-validation") } returns sourceUri + every { Uri.fromFile(any()) } answers { + snapshotFile = firstArg() + snapshotUri + } + every { snapshotUri.toString() } returns "file://private-post-validation-copy" + every { Uri.parse("file://private-post-validation-copy") } returns snapshotUri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(1, 2, 3).inputStream() } + every { resolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() } + mockkConstructor(android.util.Base64OutputStream::class) + every { anyConstructed().write(any(), any(), any()) } returns + Unit + every { anyConstructed().close() } returns Unit + every { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } returns + CompletableDeferred(mapOf("ref_text" to "@file:post-validation.txt")) + store.put( + PendingSend( + id = "retry-post-validation", + scope = scope, + sessionId = sessionId, + text = "Keep post-validation receipt", + attachments = + listOf( + Attachment( + "content://retry/post-validation", + "post-validation.txt", + "text/plain", + 3, + ), + ), + mode = BusySendMode.QUEUE, + state = PendingSendState.REJECTED, + ), + ) + viewModel.refreshSettings() + advanceUntilIdle() + + // processPendingSendReservation has already validated the old owner when it + // opens the store transaction that would replace the recovery receipt. + changeContextOnNextStoreEdit = true + viewModel.sendQueuedNow("retry-post-validation") + advanceUntilIdle() + + val preserved = store.all().single { it.id == "retry-post-validation" } + assertEquals("http://other-profile.local/", baseUrl) + assertEquals(scope, preserved.scope) + assertEquals("content://retry/post-validation", preserved.attachments.single().uri) + assertEquals(PendingSendState.REJECTED, preserved.state) + assertTrue(preserved.requiresAttachmentRecovery) + verify(exactly = 0) { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } + verify(exactly = 0) { + HermesWsClient.sendMessage( + any(), + "@file:post-validation.txt\n\nKeep post-validation receipt", + any(), + any(), + ) + } + } + + @Test + fun queuedClaimContextChangeBeforeFinalValidationRollsBackToQueuedWithoutDispatch() = + runTest { + var baseUrl = "http://test.local/" + var armClaimCommit = false + var changeContextOnNextScopeRead = false + every { AuthManager.getBaseUrl() } answers { + if (changeContextOnNextScopeRead) { + changeContextOnNextScopeRead = false + baseUrl = "http://other-profile.local/" + } + baseUrl + } + val prefs = mockk() + val editor = mockk() + every { prefs.getString("rows", null) } returns null + every { prefs.edit() } returns editor + every { editor.putString("rows", any()) } returns editor + every { editor.commit() } answers { + if (armClaimCommit) { + armClaimCommit = false + changeContextOnNextScopeRead = true + } + true + } + val store = ChatSendStore(prefs) + val scope = + listOf( + baseUrl, + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + store.put( + PendingSend( + id = "queue-post-claim-context-change", + scope = scope, + sessionId = "session-123", + text = "Claimed then stale", + mode = BusySendMode.QUEUE, + state = PendingSendState.QUEUED, + ), + ) + + // The claim's durable commit arms the first scope read in publishPendingSends, + // after update() has installed SENDING but before drainPendingQueue's final check. + armClaimCommit = true + val (_, sessionId) = createViewModelWithSession(sendStore = store) + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + + val recoverable = store.all().single { it.id == "queue-post-claim-context-change" } + assertEquals("http://other-profile.local/", baseUrl) + assertEquals(PendingSendState.QUEUED, recoverable.state) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Claimed then stale", any(), any()) } + } + + @Test + fun manualAttachmentRetryContextChangeBeforeAttachmentRpcPreservesOriginalReceipt() = + runTest { + var baseUrl = "http://test.local/" + every { AuthManager.getBaseUrl() } answers { baseUrl } + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val scope = + listOf( + baseUrl, + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + val sourceUri = mockk() + val snapshotUri = mockk() + val resolver = mockk() + lateinit var snapshotFile: java.io.File + mockkStatic(Uri::class) + every { Uri.parse("content://retry/pre-attachment-rpc") } returns sourceUri + every { Uri.fromFile(any()) } answers { + snapshotFile = firstArg() + snapshotUri + } + every { snapshotUri.toString() } returns "file://private-pre-attachment-rpc" + every { Uri.parse("file://private-pre-attachment-rpc") } returns snapshotUri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(1, 2, 3).inputStream() } + every { resolver.openInputStream(snapshotUri) } answers { + baseUrl = "http://other-profile.local/" + snapshotFile.inputStream() + } + mockkConstructor(android.util.Base64OutputStream::class) + every { anyConstructed().write(any(), any(), any()) } returns + Unit + every { anyConstructed().close() } returns Unit + every { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } returns + CompletableDeferred(mapOf("ref_text" to "@file:pre-attachment-rpc.txt")) + store.put( + PendingSend( + id = "retry-pre-attachment-rpc", + scope = scope, + sessionId = sessionId, + text = "Keep pre-attachment receipt", + attachments = + listOf( + Attachment( + "content://retry/pre-attachment-rpc", + "pre-attachment-rpc.txt", + "text/plain", + 3, + ), + ), + mode = BusySendMode.QUEUE, + state = PendingSendState.REJECTED, + ), + ) + viewModel.refreshSettings() + advanceUntilIdle() + + viewModel.sendQueuedNow("retry-pre-attachment-rpc") + advanceUntilIdle() + + val preserved = store.all().single { it.id == "retry-pre-attachment-rpc" } + assertEquals(scope, preserved.scope) + assertEquals("content://retry/pre-attachment-rpc", preserved.attachments.single().uri) + assertEquals(PendingSendState.REJECTED, preserved.state) + assertTrue(preserved.requiresAttachmentRecovery) + verify(exactly = 0) { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } + verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) } + } + + @Test + fun acceptedReceiptCanBeRetriedOnlyByExplicitSendAgain() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + every { HermesWsClient.sendMessage(sessionId, "Possibly buffered", any(), false) } answers { + arg<((String) -> Unit)?>(2)?.invoke("buffered-submit") + "buffered-submit" + } + viewModel.sendMessage("Possibly buffered") + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult("buffered-submit", mapOf("status" to "streaming"))) + advanceUntilIdle() + val receipt = + viewModel.uiState.value.pendingSends + .single() + assertEquals(PendingSendState.ACCEPTED, receipt.state) + verify(exactly = 0) { HermesWsClient.sendMessage(sessionId, "Possibly buffered", any(), true) } + + viewModel.sendQueuedNow(receipt.id) + advanceUntilIdle() + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Possibly buffered", any(), true) } + } + + @Test + fun disconnectQuarantinesAcceptedReceiptWithoutAutomaticResend() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + every { HermesWsClient.sendMessage(sessionId, "Accepted before disconnect", any(), false) } answers { + arg<((String) -> Unit)?>(2)?.invoke("accepted-before-disconnect") + "accepted-before-disconnect" + } + viewModel.sendMessage("Accepted before disconnect") + advanceUntilIdle() + mockEventsFlow.emit( + WsEvent.RpcResult("accepted-before-disconnect", mapOf("status" to "streaming")), + ) + advanceUntilIdle() + assertEquals( + PendingSendState.ACCEPTED, + viewModel.uiState.value.pendingSends + .single() + .state, + ) + + mockConnectionStatus.value = ConnectionStatus.DISCONNECTED + advanceUntilIdle() + + assertEquals( + PendingSendState.UNKNOWN, + viewModel.uiState.value.pendingSends + .single() + .state, + ) + verify(exactly = 1) { + HermesWsClient.sendMessage(sessionId, "Accepted before disconnect", any(), false) + } + verify(exactly = 0) { + HermesWsClient.sendMessage(sessionId, "Accepted before disconnect", any(), true) + } + } + + @Test + fun sendNowPromotesTheSelectedQueuedMessageWhileIdle() = + runTest { + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val scope = + listOf( + AuthManager.getBaseUrl(), + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + store.put(PendingSend("first", scope, sessionId, "First queued", mode = BusySendMode.QUEUE)) + store.put(PendingSend("second", scope, sessionId, "Second queued", mode = BusySendMode.QUEUE)) + viewModel.refreshSettings() + advanceUntilIdle() + + viewModel.sendQueuedNow("second") + advanceUntilIdle() + + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Second queued", any(), true) } + verify(exactly = 0) { HermesWsClient.sendMessage(sessionId, "First queued", any(), any()) } + assertEquals(PendingSendState.QUEUED, store.all().single { it.id == "first" }.state) + } + + @Test + fun pendingQueueIsScopedByConnectionProfileAndStoredSession() = + runTest { + var connectionProfile = "first" + every { AuthManager.getSelectedProfileId() } answers { connectionProfile } + stubSession456Rests(success = true) + val sendStore = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = sendStore) + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.sendMessage("Only here", BusySendMode.QUEUE) + advanceUntilIdle() + assertEquals(1, viewModel.uiState.value.pendingSends.size) + + connectionProfile = "second" + viewModel.refreshSettings() + advanceUntilIdle() + assertTrue( + viewModel.uiState.value.pendingSends + .isEmpty(), + ) + connectionProfile = "first" + viewModel.refreshSettings() + advanceUntilIdle() + assertEquals(1, viewModel.uiState.value.pendingSends.size) + + viewModel.switchSession("session-456") + advanceUntilIdle() + assertTrue( + viewModel.uiState.value.pendingSends + .isEmpty(), + ) + } + + @Test + fun restoredQueueDrainsAfterResumeHydrationWhenAckArrivesFirst() = + runTest { + stubSession456Rests(success = true) + val response = + CompletableDeferred>() + coEvery { + ApiClient.hermesApi.getSessionMessages("session-456", any(), any(), any(), any()) + } coAnswers { response.await() } + val store = ChatSendStore() + val (viewModel, _) = createViewModelWithSession(sendStore = store) + val captured = captureSends() + val scope = + listOf( + AuthManager.getBaseUrl(), + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + store.put(PendingSend("restored", scope, "session-456", "After relaunch", mode = BusySendMode.QUEUE)) + + viewModel.switchSession("session-456") + runCurrent() + val resumeId = captured.last { it.first == WsMethods.SESSION_RESUME }.second + mockEventsFlow.emit(WsEvent.RpcResult(resumeId, mapOf("session_id" to "runtime-456", "running" to false))) + runCurrent() + assertFalse(viewModel.uiState.value.isSessionReady) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "After relaunch", any(), any()) } + + response.complete(retrofit2.Response.success(SessionMessagesResponse(messages = emptyList()))) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isSessionReady) + verify(exactly = 1) { HermesWsClient.sendMessage("runtime-456", "After relaunch", any(), true) } + } + + @Test + fun sessionSwitchCancelsObsoleteDrainAndDispatchesRestoredQueue() = + runTest { + stubSession456Rests(success = true) + val store = ChatSendStore() + val (viewModel, _) = createViewModelWithSession(sendStore = store) + val scope = + listOf( + AuthManager.getBaseUrl(), + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + // Model an active session-A staging job without real threads or blocking I/O. + // Reset must release ownership before session B becomes ready to drain. + val obsoleteDrain = Job() + ChatViewModel::class.java + .getDeclaredField("queueDrainJob") + .apply { isAccessible = true } + .set(viewModel, obsoleteDrain) + every { HermesWsClient.sendMessage("runtime-456", "Session B queued", any(), true) } answers { + arg<((String) -> Unit)?>(2)?.invoke("session-b-submit") + "session-b-submit" + } + store.put( + PendingSend( + "session-b-restored", + scope, + "session-456", + "Session B queued", + mode = BusySendMode.QUEUE, + ), + ) + viewModel.switchSession("session-456") + runCurrent() + val resumeId = sentRequestMethods.last { it.first == WsMethods.SESSION_RESUME }.second + mockEventsFlow.emit( + WsEvent.RpcResult(resumeId, mapOf("session_id" to "runtime-456", "running" to false)), + ) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isSessionReady) + assertTrue("Session switch must cancel the obsolete drain", obsoleteDrain.isCancelled) + verify(exactly = 1) { + HermesWsClient.sendMessage("runtime-456", "Session B queued", any(), true) + } + verify(exactly = 0) { + HermesWsClient.sendMessage(any(), "Session A queued", any(), any()) + } + } + + @Test + fun timeoutKeepsRecoverableReceiptAndLateAckDoesNotResend() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + val requestId = "timeout-request" + every { HermesWsClient.sendMessage(sessionId, "Maybe", any(), any()) } answers { + arg<((String) -> Unit)?>(2)?.invoke(requestId) + requestId + } + viewModel.sendMessage("Maybe") + advanceUntilIdle() + viewModel.expireOutgoingRequest(requestId) + advanceUntilIdle() + assertEquals( + PendingSendState.UNKNOWN, + viewModel.uiState.value.pendingSends + .single() + .state, + ) + assertFalse( + viewModel.uiState.value.messages + .any { it.content == "Maybe" }, + ) + + mockEventsFlow.emit(WsEvent.RpcResult(requestId, mapOf("status" to "streaming"))) + advanceUntilIdle() + assertEquals( + PendingSendState.ACCEPTED, + viewModel.uiState.value.pendingSends + .single() + .state, + ) + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Maybe", any(), any()) } + } + + @Test + fun restCompletionBeforePromptAckSettlesReceiptAndKeepsCompletionIdentity() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + val promptId = "late-prompt-ack" + every { HermesWsClient.sendMessage(sessionId, "Fast", any(), any()) } answers { + arg<((String) -> Unit)?>(2)?.invoke(promptId) + promptId + } + coEvery { + ApiClient.hermesApi.getSessionMessages(sessionId, any(), any(), any(), any()) + } returns + retrofit2.Response.success( + SessionMessagesResponse( + messages = + listOf( + SessionMessage( + id = 4, + role = "user", + content = JsonPrimitive("Fast"), + timestamp = JsonPrimitive(System.currentTimeMillis()), + ), + ), + pagination = PaginationInfo(order = "latest"), + ), + ) + + viewModel.sendMessage("Fast") + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + mockEventsFlow.emit(WsEvent.MessageComplete("Answer", sessionId, completionId = "completion-fast")) + advanceUntilIdle() + assertTrue( + viewModel.uiState.value.pendingSends + .isEmpty(), + ) + assertTrue( + viewModel.uiState.value.messages + .any { it.completionId == "completion-fast" }, + ) + + mockEventsFlow.emit(WsEvent.RpcResult(promptId, mapOf("status" to "streaming"))) + advanceUntilIdle() + assertTrue( + viewModel.uiState.value.pendingSends + .isEmpty(), + ) + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Fast", any(), any()) } + } + // ── Session switch ─────────────────────────────────────────────────────── @Test @@ -4982,17 +6864,24 @@ class ChatViewModelTest { viewModel.sendMessage("Here is an image") advanceUntilIdle() - // Error path is logged so we can diagnose the failed read. - verify { Log.e(any(), match { it.contains("Permission denied") }, any()) } - - // Graceful handling: the message is still sent even though the - // attachment bytes couldn't be read (no crash, no lost message). - val sent = + // Private staging failed before a durable receipt or outbound RPC existed. + assertFalse( viewModel.uiState.value.messages - .firstOrNull { it.id == sessionId } != null || - viewModel.uiState.value.messages - .any { it.content == "Here is an image" } - assertTrue("Message should still be sent despite attachment read failure", sent) + .any { it.content == "Here is an image" }, + ) + assertTrue( + viewModel.uiState.value.pendingSends + .isEmpty(), + ) + assertEquals("Here is an image", viewModel.uiState.value.composerTextToRestore) + assertEquals( + "content://dummy", + viewModel.uiState.value.pendingAttachments + .single() + .uri, + ) + verify(exactly = 0) { Log.e(any(), match { it.contains("Permission denied") }, any()) } + verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) } } /** @@ -5031,11 +6920,20 @@ class ChatViewModelTest { // The image bytes read via ContentResolver must succeed. mockkStatic(Uri::class) val mockUri = mockk() + val snapshotUri = mockk() + lateinit var snapshotFile: java.io.File every { Uri.parse("content://dummy") } returns mockUri + every { Uri.fromFile(any()) } answers { + snapshotFile = firstArg() + snapshotUri + } + every { snapshotUri.toString() } returns "file://private-image-copy" + every { Uri.parse("file://private-image-copy") } returns snapshotUri val contentResolver = mockk() every { app.contentResolver } returns contentResolver - every { contentResolver.openInputStream(any()) } returns + every { contentResolver.openInputStream(mockUri) } returns java.io.ByteArrayInputStream(byteArrayOf(1, 2, 3, 4)) + every { contentResolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() } viewModel.addAttachment("content://dummy", "test.png", "image/png", 1000) advanceUntilIdle() @@ -6925,7 +8823,7 @@ class ChatViewModelTest { vm.uiState.value.messages .any { it.content == "My super important long prompt" }, ) - assertTrue(vm.uiState.value.isAgentTyping) + assertTrue(vm.uiState.value.isSending) // But wsClient.sendMessage not dispatched yet because session_id was null assertTrue(sentPrompts.isEmpty()) @@ -7000,7 +8898,7 @@ class ChatViewModelTest { vm.sendMessage("Prompt before failure") advanceUntilIdle() - assertTrue(vm.uiState.value.isAgentTyping) + assertTrue(vm.uiState.value.isSending) val createReqId = "req-id-$reqCount" // Emit RPC error for SESSION_CREATE diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt index 3b0c064b..2b28ef68 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt @@ -94,6 +94,10 @@ class SlashCommandDispatchRpcTest { mockConnectionStatus.value = ConnectionStatus.DISCONNECTED every { AuthManager.getToken() } returns "test-token" + every { AuthManager.getPinnedModels() } returns emptyList() + every { AuthManager.getBaseUrl() } returns "http://test.local/" + every { AuthManager.getSelectedProfileId() } returns null + every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT every { AuthManager.isTypingEffectEnabled() } returns true every { AuthManager.getTypingEffectDelayMs() } returns 30 every { AuthManager.isMessageStatsEnabled() } returns false @@ -152,7 +156,15 @@ class SlashCommandDispatchRpcTest { } private suspend fun TestScope.createViewModelWithSession(): Pair { - val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher) + val vm = + ChatViewModel( + app, + false, + fakeRepo, + FakeSlashUsageStore(), + ioDispatcher = testDispatcher, + sendStore = ChatSendStore(), + ) advanceUntilIdle() mockConnectionStatus.value = ConnectionStatus.CONNECTED mockEventsFlow.emit(WsEvent.GatewayReady(null)) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeChatMessageDao.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeChatMessageDao.kt index c886e921..a7a3a8e3 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeChatMessageDao.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeChatMessageDao.kt @@ -83,6 +83,10 @@ class FakeChatMessageDao : ChatMessageDao { messages.values.removeAll { it.sessionId == sessionId } } + override suspend fun deleteUnconfirmedMessage(id: String) { + if (messages[id]?.restId == null) messages.remove(id) + } + /** Direct access for test setup — bypasses the suspend modifier. */ fun addMessageDirect(message: ChatMessageEntity) { val existing = messages[message.id] diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt index 9df61ed2..2d678c93 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt @@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.settings import com.m57.hermescontrol.data.config.ConnectionProfile import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator import com.m57.hermescontrol.theme.ThemePreference @@ -47,6 +48,17 @@ class SettingsViewModelTest { return vm } + @Test + fun busySendDefaultLoadsAndSaves() { + every { AuthManager.getBusySendMode() } returns BusySendMode.GUIDE + val viewModel = createViewModel() + assertEquals(BusySendMode.GUIDE, viewModel.uiState.value.busySendMode) + + viewModel.onBusySendModeChange(BusySendMode.QUEUE) + assertEquals(BusySendMode.QUEUE, viewModel.uiState.value.busySendMode) + verify(exactly = 1) { AuthManager.setBusySendMode(BusySendMode.QUEUE) } + } + @Before fun setUp() { Dispatchers.setMain(testDispatcher) @@ -71,6 +83,8 @@ class SettingsViewModelTest { every { AuthManager.isUseDynamicColors() } returns true every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT every { AuthManager.isTypingEffectEnabled() } returns true + every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT + every { AuthManager.setBusySendMode(any()) } returns Unit every { AuthManager.getTypingEffectDelayMs() } returns 30 every { AuthManager.getChatFontScale() } returns 1.0f every { AuthManager.isMessageStatsEnabled() } returns false