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 index f80bd488..43761748 100644 --- 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 @@ -1,27 +1,42 @@ package com.m57.hermescontrol.ui.chat.components +import android.content.res.Configuration +import android.graphics.Bitmap +import androidx.activity.ComponentActivity 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.hasScrollAction +import androidx.compose.ui.test.isDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription 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.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeUp import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.BusySendMode +import com.m57.hermescontrol.ui.chat.ChatMessage +import com.m57.hermescontrol.ui.chat.MessageRole import com.m57.hermescontrol.ui.chat.PendingSend import com.m57.hermescontrol.ui.chat.PendingSendState +import com.m57.hermescontrol.ui.chat.UserBubble import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import java.io.File +import java.util.Locale @RunWith(AndroidJUnit4::class) class PendingSendRecoveryTest { @get:Rule - val compose = createComposeRule() + val compose = createAndroidComposeRule() private val uncertain = PendingSend( @@ -33,6 +48,16 @@ class PendingSendRecoveryTest { state = PendingSendState.UNKNOWN, ) + private fun imageAttachment(): Attachment { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val file = File(context.cacheDir, "photo.png") + val bitmap = Bitmap.createBitmap(32, 32, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(android.graphics.Color.rgb(20, 100, 160)) + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + bitmap.recycle() + return Attachment(file.toURI().toString(), file.name, "image/png", file.length()) + } + @Test fun uncertainRetryRequiresExplicitConfirmation() { var retried: String? = null @@ -82,6 +107,99 @@ class PendingSendRecoveryTest { compose.onNodeWithTag("pending_send_recovery").assertDoesNotExist() } + @Test + fun acceptedPromptShowsTextImageAndStatusWithoutRecoveryControl() { + val image = imageAttachment() + val accepted = uncertain.copy(state = PendingSendState.ACCEPTED, attachments = listOf(image)) + compose.setContent { + MaterialTheme { + androidx.compose.foundation.layout.Column { + UserBubble( + ChatMessage(accepted.id, MessageRole.USER, accepted.text, attachments = accepted.attachments), + pendingSendState = accepted.state, + ) + PendingSendRecovery(listOf(accepted), true, true, {}, {}) + } + } + } + compose.onNodeWithText(accepted.text).assertIsDisplayed() + compose.waitUntil(5_000) { + compose + .onNodeWithContentDescription("photo.png") + .fetchSemanticsNode() + .boundsInRoot.height > 0 + } + compose.onNodeWithContentDescription("photo.png").assertIsDisplayed() + compose.onNodeWithText("Accepted by server").assertIsDisplayed() + compose.onNodeWithTag("pending_send_recovery").assertDoesNotExist() + } + + @Test + fun recoveryIdentifiesImagesAndFilesAndCanOpenAnImage() { + val image = imageAttachment() + val file = Attachment("file:///private/note", "note.txt", "text/plain", 4) + var openedImage: String? = null + var openedFile: String? = null + compose.setContent { + MaterialTheme { + PendingSendRecovery( + listOf(uncertain.copy(attachments = listOf(image, file))), + false, + false, + {}, + {}, + onOpenAttachment = { openedFile = it.name }, + onImageClick = { openedImage = it.name }, + ) + } + } + compose.onNodeWithTag("pending_send_recovery").performClick() + compose.onNodeWithText("photo.png").assertIsDisplayed() + compose.waitUntil(5_000) { + compose + .onNodeWithContentDescription("photo.png") + .fetchSemanticsNode() + .boundsInRoot.height > 0 + } + compose.onNodeWithContentDescription("photo.png").assertIsDisplayed().performClick() + compose.runOnIdle { assertEquals("photo.png", openedImage) } + // The filename shares a lazy item with the image; scrollToIndex cannot reveal its lower children. + if (!compose.onNodeWithText("note.txt").isDisplayed()) { + compose.onNode(hasScrollAction()).performTouchInput { swipeUp() } + } + compose.onNodeWithText("note.txt").assertIsDisplayed().performClick() + compose.runOnIdle { assertEquals("note.txt", openedFile) } + } + + @Test + @Suppress("DEPRECATION") + fun chineseRecoveryCountAndDismissAreLocalized() { + // Native dialog windows resolve the Activity locale, not a synthetic LocalContext. + val resources = compose.activity.resources + val original = Configuration(resources.configuration) + val chinese = Configuration(original).apply { setLocale(Locale.SIMPLIFIED_CHINESE) } + try { + compose.runOnUiThread { resources.updateConfiguration(chinese, resources.displayMetrics) } + compose.setContent { + MaterialTheme { + PendingSendRecovery(listOf(uncertain), false, false, {}, {}) + } + } + compose.onNodeWithText("1 条待处理消息").assertIsDisplayed().performClick() + compose + .onNodeWithText("移除") + .performScrollTo() + .assertIsDisplayed() + .performClick() + compose + .onNodeWithText( + "要从本机队列中移除这条消息吗?这不会取消任务,也不会删除服务器已接收的消息。", + ).assertIsDisplayed() + } finally { + compose.runOnUiThread { resources.updateConfiguration(original, resources.displayMetrics) } + } + } + @Test fun longUncertainMessageKeepsConfirmationActionsVisible() { compose.setContent { 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 f45c4fd9..986f8acc 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 @@ -859,6 +859,8 @@ fun ChatScreen( mainTurnBusy = state.isMainTurnBusy, onSendAgain = viewModel::sendQueuedNow, onDiscard = viewModel::discardPendingSend, + onOpenAttachment = viewModel::openAttachment, + onImageClick = { viewingImage = 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 d060fc64..c9ec1b01 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 @@ -158,9 +158,12 @@ internal fun messagesWithoutUnconfirmedReceipts( val recoveryIds = pending .filter { - it.state == PendingSendState.ACCEPTED || it.state == PendingSendState.UNKNOWN || - it.state == PendingSendState.REJECTED + 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 } } + +/** #1427: normal submission/acceptance stays in the transcript, not in recovery. */ +internal val PendingSend.needsRecovery: Boolean + get() = state != PendingSendState.SENDING && state != PendingSendState.ACCEPTED 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 977283ad..46663be7 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 @@ -543,6 +543,7 @@ class ChatViewModel( private val sessionRequestById = ConcurrentHashMap() private val branchWholeRequests = ConcurrentHashMap() private val outgoingRequestById = ConcurrentHashMap() + private val acceptedTurnEpochById = ConcurrentHashMap() private val hardInterruptRequestById = ConcurrentHashMap() private val queuedStagingIds = ConcurrentHashMap.newKeySet() private val pendingSendReservationLock = Any() @@ -1702,6 +1703,17 @@ class ChatViewModel( } when { accepted -> { + // #1427: a gateway-queued prompt belongs to a future turn, not the + // completion currently on screen. Keep it live until that turn is verified. + val receipt = sendStore.all().firstOrNull { it.id == messageId } + acceptedTurnEpochById[messageId] = + if (status == "queued" || + (!mainTurnBusy && (receipt == null || lastMainCompletionAt < receipt.createdAt)) + ) { + mainTurnEpoch + 1 + } else { + mainTurnEpoch + } // #1285: the submit ack names the row written for THIS input. Absent = unproven. positiveRowId(resultMap?.get("user_row_id"))?.let { rowId -> sendStore.update(messageId) { it.copy(userRowId = rowId) } @@ -2631,12 +2643,14 @@ class ChatViewModel( state: PendingSendState, ) { sendStore.update(messageId) { it.copy(state = state) } + if (state != PendingSendState.ACCEPTED) acceptedTurnEpochById.remove(messageId) publishPendingSends() // #1427: settled/uncertain receipts remain recoverable, but cannot freeze later sends. if (state != PendingSendState.SENDING) drainPendingQueue() } private fun removePendingSend(messageId: String) { + acceptedTurnEpochById.remove(messageId) sendStore.remove(messageId) viewModelScope.launch(ioDispatcher) { deleteQueuedAttachmentSnapshot(messageId) } publishPendingSends() @@ -5648,6 +5662,17 @@ class ChatViewModel( val requestSequence = activeHydrationRequestSequence val valid = { isCurrentHydration(sessionId, generation, requestSequence) } val useLatest = latestPaging + // #1427: acceptance alone is normal live UX. Only a completed turn followed by + // successful history verification can move its still-unmatched receipts into recovery. + // Capture the rows before suspending so a later prompt/retry cannot be quarantined. + val completionAt = lastMainCompletionAt + val completedTurnEpoch = mainTurnEpoch + val unverifiedAccepted = + sendStore.all().filter { + it.scope == sendScope() && it.sessionId == sessionId && + it.state == PendingSendState.ACCEPTED && it.createdAt <= completionAt && + acceptedTurnEpochById[it.id]?.let { epoch -> epoch <= completedTurnEpoch } == true + } val nextOffset = if (useLatest) { 0 @@ -5682,6 +5707,13 @@ class ChatViewModel( ) } ?: return@launch persistHistoryPage(page, sessionId) + if (valid() && !mainTurnBusy && mainTurnEpoch == completedTurnEpoch) { + unverifiedAccepted.forEach { receipt -> + if (sendStore.all().any { it == receipt }) { + markPendingSend(receipt.id, PendingSendState.UNKNOWN) + } + } + } // Never derive the older cursor from displayed rows or reset it to this latest page. // Append-only growth shifts from-end offsets toward newer rows: the next older // request may overlap, but stable IDs remove echoes without skipping any history. diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt index c2de9bba..375726f0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt @@ -81,6 +81,7 @@ fun UserBubble( messageStatsEnabled: Boolean = false, showUserMessageTokens: Boolean = true, modifier: Modifier = Modifier, + pendingSendState: PendingSendState? = null, ) { val screenWidth = LocalConfiguration.current.screenWidthDp.dp val maxBubbleWidth = screenWidth * 0.80f @@ -182,6 +183,23 @@ fun UserBubble( canSave = canSaveAttachment, onImageClick = onImageClick, ) + // #1427: keep the live text and media visible with an honest delivery label. + if (pendingSendState == PendingSendState.SENDING || + pendingSendState == PendingSendState.ACCEPTED + ) { + Text( + stringResource( + if (pendingSendState == PendingSendState.SENDING) { + R.string.chat_pending_sending + } else { + R.string.chat_send_accepted + }, + ), + color = userBubbleTextColor, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.testTag("user_send_status_${message.id}"), + ) + } if (!message.isStreaming) { FlowRow( modifier = 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 index 7d87e5c3..22304c8d 100644 --- 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 @@ -27,8 +27,12 @@ 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.data.model.Attachment +import com.m57.hermescontrol.ui.chat.ImageViewerModel +import com.m57.hermescontrol.ui.chat.InlineAttachment import com.m57.hermescontrol.ui.chat.PendingSend import com.m57.hermescontrol.ui.chat.PendingSendState +import com.m57.hermescontrol.ui.chat.needsRecovery /** #1427: a compact recovery entry keeps uncertain delivery visible without a floating queue panel. */ @OptIn(ExperimentalMaterial3Api::class) @@ -39,8 +43,11 @@ fun PendingSendRecovery( mainTurnBusy: Boolean, onSendAgain: (String) -> Unit, onDiscard: (String) -> Unit, + onOpenAttachment: (Attachment) -> Unit = {}, + onImageClick: (ImageViewerModel) -> Unit = {}, ) { - if (sends.isEmpty()) return + val recoverySends = sends.filter { it.needsRecovery } + if (recoverySends.isEmpty()) return var open by remember { mutableStateOf(false) } var retryId by remember { mutableStateOf(null) } var discardId by remember { mutableStateOf(null) } @@ -53,7 +60,7 @@ fun PendingSendRecovery( }, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).testTag("pending_send_recovery"), ) { - Text(pluralStringResource(R.plurals.chat_pending_count, sends.size, sends.size)) + Text(pluralStringResource(R.plurals.chat_pending_count, recoverySends.size, recoverySends.size)) } if (open) { ModalBottomSheet(onDismissRequest = { open = false }) { @@ -63,7 +70,7 @@ fun PendingSendRecovery( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp)) { - items(sends, key = { it.id }) { send -> + items(recoverySends, key = { it.id }) { send -> Column( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), @@ -83,8 +90,20 @@ fun PendingSendRecovery( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - if (send.attachments.isNotEmpty()) { - Text(stringResource(R.string.chat_pending_attachments)) + send.attachments.forEach { attachment -> + if (attachment.isImage || attachment.isAudio || attachment.isVideo) { + Text(attachment.name, style = MaterialTheme.typography.bodyMedium) + } + InlineAttachment( + attachment = attachment, + textColor = MaterialTheme.colorScheme.onSurface, + onOpen = onOpenAttachment, + onSave = {}, + savingPath = null, + openingPath = null, + canSave = false, + onImageClick = onImageClick, + ) } Row { TextButton( @@ -125,7 +144,7 @@ fun PendingSendRecovery( } } } - sends.firstOrNull { it.id == retryId }?.let { send -> + recoverySends.firstOrNull { it.id == retryId }?.let { send -> AlertDialog( onDismissRequest = { retryId = null }, title = { Text(stringResource(R.string.chat_pending_send_again)) }, @@ -151,7 +170,7 @@ fun PendingSendRecovery( }, ) } - sends.firstOrNull { it.id == discardId }?.let { send -> + recoverySends.firstOrNull { it.id == discardId }?.let { send -> AlertDialog( onDismissRequest = { discardId = null }, title = { Text(stringResource(R.string.chat_pending_discard)) }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt index bf93897c..b2e13213 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt @@ -35,6 +35,7 @@ import com.m57.hermescontrol.theme.LocalChatFontScale import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatSearchState import com.m57.hermescontrol.ui.chat.ImageViewerModel +import com.m57.hermescontrol.ui.chat.PendingSendState import com.m57.hermescontrol.ui.chat.SearchTarget import com.m57.hermescontrol.ui.chat.ToolCallDivider import com.m57.hermescontrol.ui.chat.UserBubble @@ -311,6 +312,7 @@ fun FullBleedChatList( onImageClick = actions.onImageClick, messageStatsEnabled = messageStatsEnabled, showUserMessageTokens = showUserMessageTokens, + pendingSendState = transcript.pendingSendStates[userMessage.id], ) if (userMessage.reactions.isNotEmpty()) { MessageReactionChips( @@ -561,6 +563,7 @@ private fun renderUserBubble( onImageClick: (ImageViewerModel) -> Unit, messageStatsEnabled: Boolean, showUserMessageTokens: Boolean, + pendingSendState: PendingSendState?, ) { UserBubble( message = message, @@ -574,5 +577,6 @@ private fun renderUserBubble( onImageClick = onImageClick, messageStatsEnabled = messageStatsEnabled, showUserMessageTokens = showUserMessageTokens, + pendingSendState = pendingSendState, ) } 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 40baf6f3..708dd521 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 @@ -6,6 +6,7 @@ import com.m57.hermescontrol.ui.chat.ChatTimelineState import com.m57.hermescontrol.ui.chat.ChatUiState import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.ImageViewerModel +import com.m57.hermescontrol.ui.chat.PendingSendState import com.m57.hermescontrol.ui.chat.StreamingState import com.m57.hermescontrol.ui.chat.VaultCodePromptUi import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi @@ -37,6 +38,7 @@ data class TranscriptUiState( val isCompressing: Boolean, val compressionStatus: String?, val speakingMessageId: String?, + val pendingSendStates: Map = emptyMap(), ) { companion object { /** Resolve historical versus live mode at the state boundary, not in the screen call. */ @@ -76,6 +78,8 @@ data class TranscriptUiState( isCompressing = !historical && chat.isCompressing, compressionStatus = chat.compressionStatus.takeUnless { historical }, speakingMessageId = speakingMessageId, + pendingSendStates = + if (historical) emptyMap() else chat.pendingSends.associate { it.id to it.state }, ) } } diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 6944c427..e53c9b65 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1535,6 +1535,12 @@ 本条消息的发送方式 此选择仅生效一次,不会更改保存的默认值。 待发送消息 + 服务器已接收 + + %d 条待处理消息 + + 移除 + 要从本机队列中移除这条消息吗?这不会取消任务,也不会删除服务器已接收的消息。 已在本机排队 停止后已暂停 正在发送… diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9c972ab0..7a3be824 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -219,6 +219,7 @@ Send mode for this message This choice applies once; the saved default stays the same. Pending messages + Accepted by server %d pending message %d pending messages 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 006837e0..3dbac57f 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 @@ -210,7 +210,7 @@ class ChatSendStoreTest { 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 receipt = pending("send-1", "repeat", PendingSendState.UNKNOWN).copy(userRowId = 10) val merged = mergeTranscriptWithLive(listOf(server, reply), listOf(local), preserveLiveIds = true) assertEquals( @@ -228,4 +228,21 @@ class ChatSendStoreTest { assertEquals(listOf(queued, confirmed), messagesWithoutUnconfirmedReceipts(listOf(queued, confirmed), pending)) } + + @Test + fun normalSendingAndAcceptedPromptsAreNotRecoveryItems() { + val sends = PendingSendState.entries.map { pending(it.name, "prompt", it) } + + assertEquals( + setOf( + PendingSendState.QUEUED, + PendingSendState.PARKED, + PendingSendState.UNKNOWN, + PendingSendState.REJECTED, + ), + sends.filter { it.needsRecovery }.map { it.state }.toSet(), + ) + val live = sends.filterNot { it.needsRecovery }.map { ChatMessage(it.id, MessageRole.USER, it.text) } + assertEquals(live, messagesWithoutUnconfirmedReceipts(live, sends)) + } } 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 19705146..e2956a37 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 @@ -10540,6 +10540,62 @@ class ChatViewModelTest { assertTrue(vm.uiState.value.canInterrupt) } + @Test + fun normalAcceptanceKeepsPromptVisibleBeforeAndDuringItsTurn() = + runTest { + val store = ChatSendStore() + val (vm, session) = createViewModelWithSession(sendStore = store) + every { HermesWsClient.sendMessage(session, "live-prompt", any(), any()) } answers { + arg<((String) -> Unit)?>(2)?.invoke("live-submit") + "live-submit" + } + assertTrue(vm.sendMessage("live-prompt")) + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult("live-submit", mapOf("status" to "streaming", "user_row_id" to 10))) + advanceUntilIdle() + vm.syncCurrentSession() + advanceUntilIdle() + assertEquals(PendingSendState.ACCEPTED, store.all().single().state) + assertEquals( + "live-prompt", + vm.transcriptState.value.messages + .single { it.role == MessageRole.USER } + .content, + ) + mockEventsFlow.emit(WsEvent.MessageStart(session)) + mockEventsFlow.emit(WsEvent.MessageToken("Working", session)) + advanceUntilIdle() + assertEquals(PendingSendState.ACCEPTED, store.all().single().state) + assertTrue( + vm.transcriptState.value.messages + .any { it.content == "live-prompt" }, + ) + } + + @Test + fun gatewayQueuedAcceptanceStaysVisibleThroughPreviousTurnCompletion() = + runTest { + val store = ChatSendStore() + val (vm, session) = createViewModelWithSession(sendStore = store) + mockEventsFlow.emit(WsEvent.MessageStart(session)) + advanceUntilIdle() + assertTrue(vm.sendMessage("guide-next", BusySendMode.GUIDE)) + advanceUntilIdle() + val requestId = sentRequestMethods.last { it.first == WsMethods.SESSION_STEER }.second + mockEventsFlow.emit(WsEvent.RpcResult(requestId, mapOf("status" to "queued", "user_row_id" to 10))) + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.MessageComplete("Previous reply", session)) + advanceUntilIdle() + vm.syncCurrentSession() + advanceUntilIdle() + + assertEquals(PendingSendState.ACCEPTED, store.all().single().state) + assertTrue( + vm.transcriptState.value.messages + .any { it.content == "guide-next" }, + ) + } + @Test fun changedReceiptIdentityDoesNotBlockNewPromptsOrFalselyConfirmDelivery() = runTest { @@ -10580,7 +10636,7 @@ class ChatViewModelTest { .last() .content, ) - assertEquals(PendingSendState.ACCEPTED, store.all().single().state) + assertEquals(PendingSendState.UNKNOWN, store.all().single().state) assertTrue(vm.uiState.value.isSessionReady) assertTrue(vm.sendMessage("probe-second")) advanceUntilIdle() @@ -10592,7 +10648,7 @@ class ChatViewModelTest { 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(PendingSendState.UNKNOWN, store.all().single { it.text == "probe-first" }.state) assertEquals( 1, vm.transcriptState.value.messages diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiStateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiStateTest.kt index 5aef459d..ad031b43 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiStateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiStateTest.kt @@ -1,10 +1,14 @@ package com.m57.hermescontrol.ui.chat.fullbleed +import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatTimelineState import com.m57.hermescontrol.ui.chat.ChatUiState import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.MessageRole +import com.m57.hermescontrol.ui.chat.PendingSend +import com.m57.hermescontrol.ui.chat.PendingSendState import com.m57.hermescontrol.ui.chat.StreamingState import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -18,6 +22,40 @@ class TranscriptUiStateTest { private val historical = ChatMessage(id = "historical", role = MessageRole.USER, content = "older") private val clarify = ClarifyUi(text = "choose") + @Test + fun acceptedLivePromptKeepsItsTextAndImageUntilHistoryConfirmsIt() { + val image = Attachment("file:///private/photo", "photo.png", "image/png", 3) + val prompt = live.copy(attachments = listOf(image), serverRowId = 10) + val result = + TranscriptUiState.resolve( + chat = + ChatUiState( + messages = listOf(prompt), + isAgentTyping = true, + pendingSends = + listOf( + PendingSend( + prompt.id, + "scope", + "session", + prompt.content, + attachments = listOf(image), + mode = BusySendMode.CORRECT, + state = PendingSendState.ACCEPTED, + userRowId = 10, + ), + ), + ), + timeline = ChatTimelineState(), + streaming = StreamingState(), + savingAttachmentPath = null, + speakingMessageId = null, + ) + + assertEquals(listOf(prompt), result.messages) + assertEquals(listOf(image), result.messages.single().attachments) + } + @Test fun `live state preserves prompts streaming and paging actions`() { val stream = StreamingState()