fix(#1427): recover sends past unresolved receipts (#1428)

This commit is contained in:
m57 authored and GitHub committed 2026-10-01 16:52:39 +03:00
1 parent 4294d7edff
commit 63105b7843
9 files changed
+491 -28

No files matched your search

@@ -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()
}
}
@@ -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 },
@@ -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<ChatMessage>,
pending: List<PendingSend>,
): List<ChatMessage> {
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 }
}
@@ -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(
@@ -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<PendingSend>,
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<String?>(null) }
var discardId by remember { mutableStateOf<String?>(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)) }
},
)
}
}
@@ -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,
+6
View File
@@ -219,6 +219,12 @@
<string name="chat_busy_override">Send mode for this message</string>
<string name="chat_busy_override_once">This choice applies once; the saved default stays the same.</string>
<string name="chat_pending_sends">Pending messages</string>
<plurals name="chat_pending_count">
<item quantity="one">%d pending message</item>
<item quantity="other">%d pending messages</item>
</plurals>
<string name="chat_pending_discard">Dismiss</string>
<string name="chat_pending_discard_warning">Remove this message from the device queue? This does not cancel work or delete messages already received by the server.</string>
<string name="chat_pending_queued">Queued on this device</string>
<string name="chat_pending_parked">Parked after Stop</string>
<string name="chat_pending_sending">Sending\u2026</string>
@@ -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))
}
}
@@ -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()) }
}
}