diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendRecoveryTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendRecoveryTest.kt new file mode 100644 index 00000000..f80bd488 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendRecoveryTest.kt @@ -0,0 +1,103 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +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 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.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PendingSendRecoveryTest { + @get:Rule + val compose = createComposeRule() + + private val uncertain = + PendingSend( + id = "uncertain", + scope = "test", + sessionId = "session", + text = "Possibly delivered", + mode = BusySendMode.QUEUE, + state = PendingSendState.UNKNOWN, + ) + + @Test + fun uncertainRetryRequiresExplicitConfirmation() { + var retried: String? = null + compose.setContent { + MaterialTheme { + PendingSendRecovery(listOf(uncertain), true, false, { retried = it }, {}) + } + } + compose.onNodeWithTag("pending_send_recovery").assertIsDisplayed().performClick() + compose.onNodeWithText("Possibly delivered").assertIsDisplayed() + compose.onNodeWithTag("pending_retry_uncertain").performClick() + compose.runOnIdle { assertNull(retried) } + compose.onNodeWithText("Cancel").assertIsDisplayed().performClick() + compose.runOnIdle { assertNull(retried) } + compose.onNodeWithTag("pending_retry_uncertain").performClick() + compose.onNodeWithTag("pending_retry_confirm").performClick() + compose.runOnIdle { assertEquals("uncertain", retried) } + } + + @Test + fun disconnectedSessionCanInspectAndDismissButCannotRetry() { + var dismissed: String? = null + compose.setContent { + MaterialTheme { + PendingSendRecovery(listOf(uncertain), false, false, {}, { dismissed = it }) + } + } + compose.onNodeWithTag("pending_send_recovery").performClick() + compose.onNodeWithTag("pending_retry_uncertain").assertIsNotEnabled() + compose.onNodeWithTag("pending_discard_uncertain").performClick() + compose.runOnIdle { assertNull(dismissed) } + compose + .onNodeWithText( + "Remove this message from the device queue? This does not cancel work or delete messages already received by the server.", + ).assertIsDisplayed() + compose.onNodeWithTag("pending_discard_confirm").performClick() + compose.runOnIdle { assertEquals("uncertain", dismissed) } + } + + @Test + fun emptyOutboxDoesNotAddAComposerControl() { + compose.setContent { + MaterialTheme { + PendingSendRecovery(emptyList(), true, false, {}, {}) + } + } + compose.onNodeWithTag("pending_send_recovery").assertDoesNotExist() + } + + @Test + fun longUncertainMessageKeepsConfirmationActionsVisible() { + compose.setContent { + MaterialTheme { + PendingSendRecovery( + listOf(uncertain.copy(text = "Long diagnostic text ".repeat(200))), + true, + false, + {}, + {}, + ) + } + } + compose.onNodeWithTag("pending_send_recovery").performClick() + compose.onNodeWithTag("pending_retry_uncertain").performScrollTo().performClick() + compose.onNodeWithTag("pending_retry_confirm").assertIsDisplayed() + compose.onNodeWithText("Cancel").assertIsDisplayed() + } +} 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 b0b53a94..f45c4fd9 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 @@ -107,6 +107,7 @@ import com.m57.hermescontrol.ui.chat.components.ConnectionSetupSheet import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet import com.m57.hermescontrol.ui.chat.components.ContextUsageChip import com.m57.hermescontrol.ui.chat.components.MediaViewerDialog +import com.m57.hermescontrol.ui.chat.components.PendingSendRecovery import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay import com.m57.hermescontrol.ui.chat.components.ReloginDialog import com.m57.hermescontrol.ui.chat.components.ReplyErrorCard @@ -180,7 +181,7 @@ 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 displayedMessages = transcriptState.messages val lifecycleOwner = LocalLifecycleOwner.current val context = LocalContext.current @@ -851,6 +852,16 @@ fun ChatScreen( }, ) + if (!timelineState.isHistorical) { + PendingSendRecovery( + sends = state.pendingSends, + canSend = state.isConnected && state.isSessionReady, + mainTurnBusy = state.isMainTurnBusy, + onSendAgain = viewModel::sendQueuedNow, + onDiscard = viewModel::discardPendingSend, + ) + } + ChatInputBar( inputFieldValue = inputFieldValue, onInputChange = { inputFieldValue = it }, 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 index 4b659f0e..d060fc64 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSendStore.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSendStore.kt @@ -149,3 +149,18 @@ class ChatSendStore( rows = next } } + +/** #1427: uncertain receipt bubbles live in recovery UI, not after their server transcript counterpart. */ +internal fun messagesWithoutUnconfirmedReceipts( + messages: List, + pending: List, +): List { + val recoveryIds = + pending + .filter { + it.state == PendingSendState.ACCEPTED || it.state == PendingSendState.UNKNOWN || + it.state == PendingSendState.REJECTED + }.mapTo(mutableSetOf()) { it.id } + if (recoveryIds.isEmpty()) return messages + return messages.filterNot { it.id in recoveryIds && it.canonicalRestId == null } +} 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 1cb6e707..977283ad 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 @@ -69,6 +69,7 @@ import com.m57.hermescontrol.ui.common.ActionProgressController import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow @@ -81,6 +82,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -2630,6 +2632,8 @@ class ChatViewModel( ) { sendStore.update(messageId) { it.copy(state = state) } publishPendingSends() + // #1427: settled/uncertain receipts remain recoverable, but cannot freeze later sends. + if (state != PendingSendState.SENDING) drainPendingQueue() } private fun removePendingSend(messageId: String) { @@ -2672,7 +2676,7 @@ class ChatViewModel( _uiState.update { it.copy(errorMessage = reason, isAgentTyping = mainTurnBusy) } } - /** Send one locally queued turn only after the previous receipt has reached REST history. */ + /** #1427: serialize submissions, not durable-history reconciliation; never replay uncertain rows. */ @Synchronized private fun drainPendingQueue() { val sessionId = _uiState.value.currentSessionId ?: return @@ -2686,11 +2690,7 @@ class ChatViewModel( 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 - } - ) { + if (rows.any { it.state == PendingSendState.SENDING }) { return } val next = rows.firstOrNull { it.state == PendingSendState.QUEUED } ?: return @@ -2736,6 +2736,13 @@ class ChatViewModel( "Could not retain queued attachment" }, ) + } finally { + if (queueDrainJob === currentCoroutineContext()[Job]) { + queueDrainJob = null + // Preparation can restore the same known-unsent row. Leave it for explicit recovery. + val restored = sendStore.all().any { it.id == next.id && it.state == PendingSendState.QUEUED } + if (currentCoroutineContext().isActive && !restored) drainPendingQueue() + } } } } @@ -2794,12 +2801,28 @@ class ChatViewModel( } } + /** #1427: explicit recovery can dismiss an uncertain receipt without guessing delivery or resending it. */ + @Synchronized + fun discardPendingSend(id: String) { + val row = sendStore.all().firstOrNull { it.id == id } ?: return + if (row.scope != sendScope() || row.sessionId != _uiState.value.currentSessionId || + row.state == PendingSendState.SENDING || + synchronized(pendingSendReservationLock) { pendingSendReservations.any { it.pending.id == id } } + ) { + return + } + removeUnconfirmedBubble(id) + removePendingSend(id) + 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 + runtimeSessionId == null || !_uiState.value.isSessionReady || + wsClient.connectionStatus.value != ConnectionStatus.CONNECTED ) { return } @@ -2822,7 +2845,7 @@ class ChatViewModel( 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) + it.state == PendingSendState.SENDING } if (others) { _uiState.update { it.copy(errorMessage = "Wait for the current send to settle") } @@ -2942,12 +2965,7 @@ class ChatViewModel( val hasOutstandingDelivery = sendStore.all().any { it.scope == sendScope() && it.sessionId == _uiState.value.currentSessionId && - it.state in - setOf( - PendingSendState.SENDING, - PendingSendState.ACCEPTED, - PendingSendState.UNKNOWN, - ) + it.state == PendingSendState.SENDING } || hasPendingSendReservation(sendScope(), _uiState.value.currentSessionId) val busy = wasStreaming || hasOutstandingDelivery val selectedMode = modeOverride ?: _uiState.value.busySendMode @@ -3825,8 +3843,7 @@ class ChatViewModel( val busy = mainTurnBusy || sendStore.all().any { - it.scope == sendScope() && it.sessionId == storageId && - it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN) + it.scope == sendScope() && it.sessionId == storageId && it.state == PendingSendState.SENDING } try { sendStore.put( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendRecovery.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendRecovery.kt new file mode 100644 index 00000000..7d87e5c3 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendRecovery.kt @@ -0,0 +1,179 @@ +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.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource +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 + +/** #1427: a compact recovery entry keeps uncertain delivery visible without a floating queue panel. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PendingSendRecovery( + sends: List, + canSend: Boolean, + mainTurnBusy: Boolean, + onSendAgain: (String) -> Unit, + onDiscard: (String) -> Unit, +) { + if (sends.isEmpty()) return + var open by remember { mutableStateOf(false) } + var retryId by remember { mutableStateOf(null) } + var discardId by remember { mutableStateOf(null) } + val keyboard = LocalSoftwareKeyboardController.current + val submissionInFlight = sends.any { it.state == PendingSendState.SENDING } + TextButton( + onClick = { + keyboard?.hide() + open = true + }, + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).testTag("pending_send_recovery"), + ) { + Text(pluralStringResource(R.plurals.chat_pending_count, sends.size, sends.size)) + } + if (open) { + ModalBottomSheet(onDismissRequest = { open = false }) { + Text( + stringResource(R.string.chat_pending_sends), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp)) { + items(sends, key = { it.id }) { send -> + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(send.text, style = MaterialTheme.typography.bodyMedium) + Text( + stringResource( + when (send.state) { + PendingSendState.QUEUED -> R.string.chat_pending_queued + PendingSendState.PARKED -> R.string.chat_pending_parked + PendingSendState.SENDING -> R.string.chat_pending_sending + PendingSendState.ACCEPTED -> R.string.chat_pending_accepted + PendingSendState.UNKNOWN -> R.string.chat_pending_unknown + PendingSendState.REJECTED -> R.string.chat_pending_rejected + }, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (send.attachments.isNotEmpty()) { + Text(stringResource(R.string.chat_pending_attachments)) + } + Row { + TextButton( + onClick = { + if (mainTurnBusy || send.state == PendingSendState.ACCEPTED || + send.state == PendingSendState.UNKNOWN + ) { + retryId = send.id + } else { + onSendAgain(send.id) + } + }, + enabled = canSend && !submissionInFlight && !send.requiresAttachmentRecovery, + modifier = Modifier.heightIn(min = 48.dp).testTag("pending_retry_${send.id}"), + ) { + Text( + stringResource( + if (send.state == PendingSendState.ACCEPTED || + send.state == PendingSendState.UNKNOWN + ) { + R.string.chat_pending_send_again + } else { + R.string.chat_pending_send_now + }, + ), + ) + } + TextButton( + onClick = { discardId = send.id }, + enabled = send.state != PendingSendState.SENDING, + modifier = Modifier.heightIn(min = 48.dp).testTag("pending_discard_${send.id}"), + ) { + Text(stringResource(R.string.chat_pending_discard)) + } + } + } + } + } + } + } + sends.firstOrNull { it.id == retryId }?.let { send -> + AlertDialog( + onDismissRequest = { retryId = null }, + title = { Text(stringResource(R.string.chat_pending_send_again)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(send.text, maxLines = 6, overflow = TextOverflow.Ellipsis) + if (send.state == PendingSendState.ACCEPTED || send.state == PendingSendState.UNKNOWN) { + Text(stringResource(R.string.chat_pending_unknown)) + } + if (mainTurnBusy) Text(stringResource(R.string.chat_pending_send_now_warning)) + } + }, + confirmButton = { + TextButton(onClick = { + retryId = null + onSendAgain(send.id) + }, enabled = canSend && !submissionInFlight, modifier = Modifier.testTag("pending_retry_confirm")) { + Text(stringResource(R.string.chat_pending_send_again)) + } + }, + dismissButton = { + TextButton(onClick = { retryId = null }) { Text(stringResource(R.string.system_confirm_cancel)) } + }, + ) + } + sends.firstOrNull { it.id == discardId }?.let { send -> + AlertDialog( + onDismissRequest = { discardId = null }, + title = { Text(stringResource(R.string.chat_pending_discard)) }, + text = { Text(stringResource(R.string.chat_pending_discard_warning)) }, + confirmButton = { + TextButton( + onClick = { + discardId = null + onDiscard(send.id) + }, + enabled = send.state != PendingSendState.SENDING, + modifier = + Modifier.testTag( + "pending_discard_confirm", + ), + ) { + Text(stringResource(R.string.chat_pending_discard)) + } + }, + dismissButton = { + TextButton(onClick = { discardId = null }) { Text(stringResource(R.string.system_confirm_cancel)) } + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiState.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiState.kt index f02686fa..40baf6f3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiState.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiState.kt @@ -10,6 +10,7 @@ import com.m57.hermescontrol.ui.chat.StreamingState import com.m57.hermescontrol.ui.chat.VaultCodePromptUi import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi +import com.m57.hermescontrol.ui.chat.messagesWithoutUnconfirmedReceipts /** The transcript's resolved, read-only state; the list never receives the whole chat ViewModel. */ data class TranscriptUiState( @@ -48,7 +49,8 @@ data class TranscriptUiState( ): TranscriptUiState { val historical = timeline.isHistorical return TranscriptUiState( - messages = timeline.historyMessages ?: chat.messages, + messages = + timeline.historyMessages ?: messagesWithoutUnconfirmedReceipts(chat.messages, chat.pendingSends), streamingState = if (historical) StreamingState() else streaming, isAgentTyping = !historical && chat.isAgentTyping, typingEffectEnabled = !historical && chat.typingEffectEnabled, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 213cfc57..9c972ab0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -219,6 +219,12 @@ Send mode for this message This choice applies once; the saved default stays the same. Pending messages + + %d pending message + %d pending messages + + Dismiss + Remove this message from the device queue? This does not cancel work or delete messages already received by the server. Queued on this device Parked after Stop Sending\u2026 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 index 89d3e122..006837e0 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSendStoreTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSendStoreTest.kt @@ -204,4 +204,28 @@ class ChatSendStoreTest { assertEquals(PendingSendState.UNKNOWN, restored.state) assertEquals(0, restored.attempts) } + + @Test + fun uncertainReceiptGhostsAreHiddenWithoutConfirmingChangedServerIdentity() { + val local = ChatMessage("send-1", MessageRole.USER, "repeat", serverRowId = 10) + val server = ChatMessage("rest-session-20", MessageRole.USER, "repeat", serverRowId = 20) + val reply = ChatMessage("rest-session-21", MessageRole.ASSISTANT, "done", serverRowId = 21) + val receipt = pending("send-1", "repeat").copy(userRowId = 10) + val merged = mergeTranscriptWithLive(listOf(server, reply), listOf(local), preserveLiveIds = true) + + assertEquals( + listOf(server.id, reply.id), + messagesWithoutUnconfirmedReceipts(merged, listOf(receipt)).map { it.id }, + ) + assertTrue(pendingSendIdsConfirmedByDurableAliases(merged, listOf(receipt)).isEmpty()) + } + + @Test + fun queuedBubblesAndConfirmedAliasesStayInTheTranscript() { + val queued = ChatMessage("queued", MessageRole.USER, "later") + val confirmed = ChatMessage("confirmed", MessageRole.USER, "delivered", restId = "rest-session-10") + val pending = listOf(pending("queued", "later", PendingSendState.QUEUED), pending("confirmed", "delivered")) + + assertEquals(listOf(queued, confirmed), messagesWithoutUnconfirmedReceipts(listOf(queued, confirmed), pending)) + } } 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 65c97925..19705146 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 @@ -3844,7 +3844,7 @@ class ChatViewModelTest { } @Test - fun interruptWithUnknownDeliveryQueuesWithoutInterrupting() = + fun interruptWithUnknownDeliverySubmitsWithoutReplayingEarlierPrompt() = runTest { val (viewModel, sessionId) = createViewModelWithSession() every { HermesWsClient.sendMessage(sessionId, "Uncertain", any(), false) } answers { @@ -3863,8 +3863,8 @@ class ChatViewModelTest { val replacement = viewModel.uiState.value.pendingSends .single { it.text == "Replacement" } - assertEquals(BusySendMode.QUEUE, replacement.mode) - assertEquals(PendingSendState.QUEUED, replacement.state) + assertEquals(BusySendMode.CORRECT, replacement.mode) + assertEquals(PendingSendState.SENDING, replacement.state) assertEquals( PendingSendState.UNKNOWN, viewModel.uiState.value.pendingSends @@ -3872,11 +3872,12 @@ class ChatViewModelTest { .state, ) verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } - verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) } + verify(exactly = 1) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) } + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Uncertain", any(), any()) } } @Test - fun interruptWithAcceptedDeliveryQueuesWithoutInterrupting() = + fun interruptWithAcceptedDeliverySubmitsWithoutReplayingEarlierPrompt() = runTest { val (viewModel, sessionId) = createViewModelWithSession() every { HermesWsClient.sendMessage(sessionId, "Accepted", any(), false) } answers { @@ -3895,8 +3896,8 @@ class ChatViewModelTest { val replacement = viewModel.uiState.value.pendingSends .single { it.text == "Replacement" } - assertEquals(BusySendMode.QUEUE, replacement.mode) - assertEquals(PendingSendState.QUEUED, replacement.state) + assertEquals(BusySendMode.CORRECT, replacement.mode) + assertEquals(PendingSendState.SENDING, replacement.state) assertEquals( PendingSendState.ACCEPTED, viewModel.uiState.value.pendingSends @@ -3904,7 +3905,8 @@ class ChatViewModelTest { .state, ) verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } - verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) } + verify(exactly = 1) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) } + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Accepted", any(), any()) } } @Test @@ -4149,7 +4151,7 @@ class ChatViewModelTest { } @Test - fun sendNowWaitsForEarlierAcceptedTurnToReachRestHistory() = + fun acceptedReceiptReleasesNextQueuedSubmissionWithoutWaitingForHistory() = runTest { val (viewModel, sessionId) = createViewModelWithSession() every { HermesWsClient.sendMessage(sessionId, "First", any(), true) } answers { @@ -4174,12 +4176,13 @@ class ChatViewModelTest { advanceUntilIdle() assertEquals( - PendingSendState.QUEUED, + PendingSendState.SENDING, viewModel.uiState.value.pendingSends .single { it.id == second.id } .state, ) verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Second", any(), true) } } @Test @@ -10536,4 +10539,107 @@ class ChatViewModelTest { advanceUntilIdle() assertTrue(vm.uiState.value.canInterrupt) } + + @Test + fun changedReceiptIdentityDoesNotBlockNewPromptsOrFalselyConfirmDelivery() = + runTest { + val store = ChatSendStore() + val (vm, session) = createViewModelWithSession(sendStore = store) + every { HermesWsClient.sendMessage(session, "probe-first", any(), any()) } answers { + arg<((String) -> Unit)?>(2)?.invoke("probe-submit") + "probe-submit" + } + assertTrue(vm.sendMessage("probe-first")) + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult("probe-submit", mapOf("status" to "streaming", "user_row_id" to 10))) + advanceUntilIdle() + coEvery { ApiClient.hermesApi.getSessionMessages(session, any(), any(), any(), any()) } returns + Response.success( + SessionMessagesResponse( + messages = + listOf( + SessionMessage(id = 20, role = "user", content = JsonPrimitive("probe-first")), + SessionMessage(id = 21, role = "assistant", content = JsonPrimitive("probe-reply")), + ), + pagination = PaginationInfo(order = "latest"), + ), + ) + mockEventsFlow.emit(WsEvent.MessageStart(session)) + mockEventsFlow.emit(WsEvent.MessageComplete("probe-reply", session)) + advanceUntilIdle() + vm.syncCurrentSession() + advanceUntilIdle() + assertEquals( + 2, + vm.uiState.value.messages + .count { it.role == MessageRole.USER && it.content == "probe-first" }, + ) + assertEquals( + "probe-first", + vm.uiState.value.messages + .last() + .content, + ) + assertEquals(PendingSendState.ACCEPTED, store.all().single().state) + assertTrue(vm.uiState.value.isSessionReady) + assertTrue(vm.sendMessage("probe-second")) + advanceUntilIdle() + assertTrue( + vm.uiState.value.messages + .any { it.content == "probe-second" }, + ) + assertEquals(PendingSendState.SENDING, store.all().single { it.text == "probe-second" }.state) + assertNull(vm.uiState.value.errorMessage) + verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-second", any(), any()) } + verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-first", any(), any()) } + assertEquals(PendingSendState.ACCEPTED, store.all().single { it.text == "probe-first" }.state) + assertEquals( + 1, + vm.transcriptState.value.messages + .count { it.content == "probe-first" }, + ) + } + + @Test + fun timeoutReleasesQueuedPromptWithoutReplayingUncertainSend() = + runTest { + val store = ChatSendStore() + val (vm, session) = createViewModelWithSession(sendStore = store) + every { HermesWsClient.sendMessage(session, "uncertain-first", any(), any()) } answers { + arg<((String) -> Unit)?>(2)?.invoke("uncertain-submit") + "uncertain-submit" + } + assertTrue(vm.sendMessage("uncertain-first")) + advanceUntilIdle() + assertTrue(vm.sendMessage("queued-second")) + advanceUntilIdle() + assertEquals(PendingSendState.QUEUED, store.all().single { it.text == "queued-second" }.state) + vm.expireOutgoingRequest("uncertain-submit") + advanceUntilIdle() + + assertEquals(PendingSendState.UNKNOWN, store.all().single { it.text == "uncertain-first" }.state) + verify(exactly = 1) { HermesWsClient.sendMessage(session, "queued-second", any(), true) } + verify(exactly = 1) { HermesWsClient.sendMessage(session, "uncertain-first", any(), any()) } + } + + @Test + fun dismissUnknownReceiptDoesNotResendAndCannotDismissInFlight() = + runTest { + val store = ChatSendStore() + val (vm, session) = createViewModelWithSession(sendStore = store) + assertTrue(vm.sendMessage("dismiss-first")) + advanceUntilIdle() + val receipt = store.all().single() + vm.discardPendingSend(receipt.id) + assertEquals(PendingSendState.SENDING, store.all().single().state) + store.update(receipt.id) { it.copy(state = PendingSendState.UNKNOWN) } + vm.discardPendingSend(receipt.id) + advanceUntilIdle() + assertTrue(store.all().isEmpty()) + assertTrue( + vm.uiState.value.messages + .none { it.id == receipt.id }, + ) + verify(exactly = 1) { HermesWsClient.sendMessage(session, "dismiss-first", any(), any()) } + } }