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:
9 files changed
+491
-28
No files matched your search
+103
@@ -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,
|
||||
|
||||
@@ -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()) }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user