Merge pull request #1429 from Hy4ri/codex/release-v2.0.1

chore(release): promote dev to main for v2.0.1
This commit is contained in:
m57
2026-10-01 17:08:17 +03:00
committed by GitHub
9 changed files with 491 additions and 28 deletions
@@ -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.ContextDetailSheet
import com.m57.hermescontrol.ui.chat.components.ContextUsageChip import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
import com.m57.hermescontrol.ui.chat.components.MediaViewerDialog 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.ReactionHeartsOverlay
import com.m57.hermescontrol.ui.chat.components.ReloginDialog import com.m57.hermescontrol.ui.chat.components.ReloginDialog
import com.m57.hermescontrol.ui.chat.components.ReplyErrorCard 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 // Snapshot-backed search state — read directly so only the scopes that
// read its fields recompose on search changes (bar, matched bubbles). // read its fields recompose on search changes (bar, matched bubbles).
val searchState = viewModel.searchState val searchState = viewModel.searchState
val displayedMessages = timelineState.historyMessages ?: state.messages val displayedMessages = transcriptState.messages
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.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( ChatInputBar(
inputFieldValue = inputFieldValue, inputFieldValue = inputFieldValue,
onInputChange = { inputFieldValue = it }, onInputChange = { inputFieldValue = it },
@@ -149,3 +149,18 @@ class ChatSendStore(
rows = next 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.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -81,6 +82,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@@ -2630,6 +2632,8 @@ class ChatViewModel(
) { ) {
sendStore.update(messageId) { it.copy(state = state) } sendStore.update(messageId) { it.copy(state = state) }
publishPendingSends() publishPendingSends()
// #1427: settled/uncertain receipts remain recoverable, but cannot freeze later sends.
if (state != PendingSendState.SENDING) drainPendingQueue()
} }
private fun removePendingSend(messageId: String) { private fun removePendingSend(messageId: String) {
@@ -2672,7 +2676,7 @@ class ChatViewModel(
_uiState.update { it.copy(errorMessage = reason, isAgentTyping = mainTurnBusy) } _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 @Synchronized
private fun drainPendingQueue() { private fun drainPendingQueue() {
val sessionId = _uiState.value.currentSessionId ?: return val sessionId = _uiState.value.currentSessionId ?: return
@@ -2686,11 +2690,7 @@ class ChatViewModel(
if (queueDrainJob?.isActive == true) return if (queueDrainJob?.isActive == true) return
if (hasPendingSendReservation(scope, sessionId)) return if (hasPendingSendReservation(scope, sessionId)) return
val rows = sendStore.all().filter { it.scope == scope && it.sessionId == sessionId } val rows = sendStore.all().filter { it.scope == scope && it.sessionId == sessionId }
if (rows.any { if (rows.any { it.state == PendingSendState.SENDING }) {
it.state == PendingSendState.SENDING || it.state == PendingSendState.UNKNOWN ||
it.state == PendingSendState.ACCEPTED
}
) {
return return
} }
val next = rows.firstOrNull { it.state == PendingSendState.QUEUED } ?: return val next = rows.firstOrNull { it.state == PendingSendState.QUEUED } ?: return
@@ -2736,6 +2736,13 @@ class ChatViewModel(
"Could not retain queued attachment" "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. */ /** A manual promotion is the only path that may retry an uncertain send. */
@Synchronized @Synchronized
fun sendQueuedNow(id: String) { fun sendQueuedNow(id: String) {
val row = sendStore.all().firstOrNull { it.id == id } ?: return val row = sendStore.all().firstOrNull { it.id == id } ?: return
if (row.scope != sendScope() || row.sessionId != _uiState.value.currentSessionId || if (row.scope != sendScope() || row.sessionId != _uiState.value.currentSessionId ||
runtimeSessionId == null runtimeSessionId == null || !_uiState.value.isSessionReady ||
wsClient.connectionStatus.value != ConnectionStatus.CONNECTED
) { ) {
return return
} }
@@ -2822,7 +2845,7 @@ class ChatViewModel(
val others = val others =
sendStore.all().any { sendStore.all().any {
it.id != id && it.scope == row.scope && it.sessionId == row.sessionId && 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) { if (others) {
_uiState.update { it.copy(errorMessage = "Wait for the current send to settle") } _uiState.update { it.copy(errorMessage = "Wait for the current send to settle") }
@@ -2942,12 +2965,7 @@ class ChatViewModel(
val hasOutstandingDelivery = val hasOutstandingDelivery =
sendStore.all().any { sendStore.all().any {
it.scope == sendScope() && it.sessionId == _uiState.value.currentSessionId && it.scope == sendScope() && it.sessionId == _uiState.value.currentSessionId &&
it.state in it.state == PendingSendState.SENDING
setOf(
PendingSendState.SENDING,
PendingSendState.ACCEPTED,
PendingSendState.UNKNOWN,
)
} || hasPendingSendReservation(sendScope(), _uiState.value.currentSessionId) } || hasPendingSendReservation(sendScope(), _uiState.value.currentSessionId)
val busy = wasStreaming || hasOutstandingDelivery val busy = wasStreaming || hasOutstandingDelivery
val selectedMode = modeOverride ?: _uiState.value.busySendMode val selectedMode = modeOverride ?: _uiState.value.busySendMode
@@ -3825,8 +3843,7 @@ class ChatViewModel(
val busy = val busy =
mainTurnBusy || mainTurnBusy ||
sendStore.all().any { sendStore.all().any {
it.scope == sendScope() && it.sessionId == storageId && it.scope == sendScope() && it.sessionId == storageId && it.state == PendingSendState.SENDING
it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN)
} }
try { try {
sendStore.put( 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.VaultCodePromptUi
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi 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. */ /** The transcript's resolved, read-only state; the list never receives the whole chat ViewModel. */
data class TranscriptUiState( data class TranscriptUiState(
@@ -48,7 +49,8 @@ data class TranscriptUiState(
): TranscriptUiState { ): TranscriptUiState {
val historical = timeline.isHistorical val historical = timeline.isHistorical
return TranscriptUiState( return TranscriptUiState(
messages = timeline.historyMessages ?: chat.messages, messages =
timeline.historyMessages ?: messagesWithoutUnconfirmedReceipts(chat.messages, chat.pendingSends),
streamingState = if (historical) StreamingState() else streaming, streamingState = if (historical) StreamingState() else streaming,
isAgentTyping = !historical && chat.isAgentTyping, isAgentTyping = !historical && chat.isAgentTyping,
typingEffectEnabled = !historical && chat.typingEffectEnabled, 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">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_busy_override_once">This choice applies once; the saved default stays the same.</string>
<string name="chat_pending_sends">Pending messages</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_queued">Queued on this device</string>
<string name="chat_pending_parked">Parked after Stop</string> <string name="chat_pending_parked">Parked after Stop</string>
<string name="chat_pending_sending">Sending\u2026</string> <string name="chat_pending_sending">Sending\u2026</string>
@@ -204,4 +204,28 @@ class ChatSendStoreTest {
assertEquals(PendingSendState.UNKNOWN, restored.state) assertEquals(PendingSendState.UNKNOWN, restored.state)
assertEquals(0, restored.attempts) 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 @Test
fun interruptWithUnknownDeliveryQueuesWithoutInterrupting() = fun interruptWithUnknownDeliverySubmitsWithoutReplayingEarlierPrompt() =
runTest { runTest {
val (viewModel, sessionId) = createViewModelWithSession() val (viewModel, sessionId) = createViewModelWithSession()
every { HermesWsClient.sendMessage(sessionId, "Uncertain", any(), false) } answers { every { HermesWsClient.sendMessage(sessionId, "Uncertain", any(), false) } answers {
@@ -3863,8 +3863,8 @@ class ChatViewModelTest {
val replacement = val replacement =
viewModel.uiState.value.pendingSends viewModel.uiState.value.pendingSends
.single { it.text == "Replacement" } .single { it.text == "Replacement" }
assertEquals(BusySendMode.QUEUE, replacement.mode) assertEquals(BusySendMode.CORRECT, replacement.mode)
assertEquals(PendingSendState.QUEUED, replacement.state) assertEquals(PendingSendState.SENDING, replacement.state)
assertEquals( assertEquals(
PendingSendState.UNKNOWN, PendingSendState.UNKNOWN,
viewModel.uiState.value.pendingSends viewModel.uiState.value.pendingSends
@@ -3872,11 +3872,12 @@ class ChatViewModelTest {
.state, .state,
) )
verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } 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 @Test
fun interruptWithAcceptedDeliveryQueuesWithoutInterrupting() = fun interruptWithAcceptedDeliverySubmitsWithoutReplayingEarlierPrompt() =
runTest { runTest {
val (viewModel, sessionId) = createViewModelWithSession() val (viewModel, sessionId) = createViewModelWithSession()
every { HermesWsClient.sendMessage(sessionId, "Accepted", any(), false) } answers { every { HermesWsClient.sendMessage(sessionId, "Accepted", any(), false) } answers {
@@ -3895,8 +3896,8 @@ class ChatViewModelTest {
val replacement = val replacement =
viewModel.uiState.value.pendingSends viewModel.uiState.value.pendingSends
.single { it.text == "Replacement" } .single { it.text == "Replacement" }
assertEquals(BusySendMode.QUEUE, replacement.mode) assertEquals(BusySendMode.CORRECT, replacement.mode)
assertEquals(PendingSendState.QUEUED, replacement.state) assertEquals(PendingSendState.SENDING, replacement.state)
assertEquals( assertEquals(
PendingSendState.ACCEPTED, PendingSendState.ACCEPTED,
viewModel.uiState.value.pendingSends viewModel.uiState.value.pendingSends
@@ -3904,7 +3905,8 @@ class ChatViewModelTest {
.state, .state,
) )
verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } 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 @Test
@@ -4149,7 +4151,7 @@ class ChatViewModelTest {
} }
@Test @Test
fun sendNowWaitsForEarlierAcceptedTurnToReachRestHistory() = fun acceptedReceiptReleasesNextQueuedSubmissionWithoutWaitingForHistory() =
runTest { runTest {
val (viewModel, sessionId) = createViewModelWithSession() val (viewModel, sessionId) = createViewModelWithSession()
every { HermesWsClient.sendMessage(sessionId, "First", any(), true) } answers { every { HermesWsClient.sendMessage(sessionId, "First", any(), true) } answers {
@@ -4174,12 +4176,13 @@ class ChatViewModelTest {
advanceUntilIdle() advanceUntilIdle()
assertEquals( assertEquals(
PendingSendState.QUEUED, PendingSendState.SENDING,
viewModel.uiState.value.pendingSends viewModel.uiState.value.pendingSends
.single { it.id == second.id } .single { it.id == second.id }
.state, .state,
) )
verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Second", any(), true) }
} }
@Test @Test
@@ -10536,4 +10539,107 @@ class ChatViewModelTest {
advanceUntilIdle() advanceUntilIdle()
assertTrue(vm.uiState.value.canInterrupt) 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()) }
}
} }