Merge pull request #1431 from Hy4ri/codex/release-v2.0.2

chore: promote dev for v2.0.2
This commit is contained in:
m57 authored and GitHub committed 2026-10-01 20:30:07 +03:00
commit 62fcf07e1b
13 files changed
+332 -14

No files matched your search

@@ -1,27 +1,42 @@
package com.m57.hermescontrol.ui.chat.components 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.material3.MaterialTheme
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled 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.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo 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.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.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.PendingSend
import com.m57.hermescontrol.ui.chat.PendingSendState import com.m57.hermescontrol.ui.chat.PendingSendState
import com.m57.hermescontrol.ui.chat.UserBubble
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.io.File
import java.util.Locale
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class PendingSendRecoveryTest { class PendingSendRecoveryTest {
@get:Rule @get:Rule
val compose = createComposeRule() val compose = createAndroidComposeRule<ComponentActivity>()
private val uncertain = private val uncertain =
PendingSend( PendingSend(
@@ -33,6 +48,16 @@ class PendingSendRecoveryTest {
state = PendingSendState.UNKNOWN, 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 @Test
fun uncertainRetryRequiresExplicitConfirmation() { fun uncertainRetryRequiresExplicitConfirmation() {
var retried: String? = null var retried: String? = null
@@ -82,6 +107,99 @@ class PendingSendRecoveryTest {
compose.onNodeWithTag("pending_send_recovery").assertDoesNotExist() 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 @Test
fun longUncertainMessageKeepsConfirmationActionsVisible() { fun longUncertainMessageKeepsConfirmationActionsVisible() {
compose.setContent { compose.setContent {
@@ -859,6 +859,8 @@ fun ChatScreen(
mainTurnBusy = state.isMainTurnBusy, mainTurnBusy = state.isMainTurnBusy,
onSendAgain = viewModel::sendQueuedNow, onSendAgain = viewModel::sendQueuedNow,
onDiscard = viewModel::discardPendingSend, onDiscard = viewModel::discardPendingSend,
onOpenAttachment = viewModel::openAttachment,
onImageClick = { viewingImage = it },
) )
} }
@@ -158,9 +158,12 @@ internal fun messagesWithoutUnconfirmedReceipts(
val recoveryIds = val recoveryIds =
pending pending
.filter { .filter {
it.state == PendingSendState.ACCEPTED || it.state == PendingSendState.UNKNOWN || it.state == PendingSendState.UNKNOWN || it.state == PendingSendState.REJECTED
it.state == PendingSendState.REJECTED
}.mapTo(mutableSetOf()) { it.id } }.mapTo(mutableSetOf()) { it.id }
if (recoveryIds.isEmpty()) return messages if (recoveryIds.isEmpty()) return messages
return messages.filterNot { it.id in recoveryIds && it.canonicalRestId == null } 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
@@ -543,6 +543,7 @@ class ChatViewModel(
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>() private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
private val branchWholeRequests = ConcurrentHashMap<String, PendingBranchRequest>() private val branchWholeRequests = ConcurrentHashMap<String, PendingBranchRequest>()
private val outgoingRequestById = ConcurrentHashMap<String, String>() private val outgoingRequestById = ConcurrentHashMap<String, String>()
private val acceptedTurnEpochById = ConcurrentHashMap<String, Long>()
private val hardInterruptRequestById = ConcurrentHashMap<String, HardInterruptRequest>() private val hardInterruptRequestById = ConcurrentHashMap<String, HardInterruptRequest>()
private val queuedStagingIds = ConcurrentHashMap.newKeySet<String>() private val queuedStagingIds = ConcurrentHashMap.newKeySet<String>()
private val pendingSendReservationLock = Any() private val pendingSendReservationLock = Any()
@@ -1702,6 +1703,17 @@ class ChatViewModel(
} }
when { when {
accepted -> { 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. // #1285: the submit ack names the row written for THIS input. Absent = unproven.
positiveRowId(resultMap?.get("user_row_id"))?.let { rowId -> positiveRowId(resultMap?.get("user_row_id"))?.let { rowId ->
sendStore.update(messageId) { it.copy(userRowId = rowId) } sendStore.update(messageId) { it.copy(userRowId = rowId) }
@@ -2631,12 +2643,14 @@ class ChatViewModel(
state: PendingSendState, state: PendingSendState,
) { ) {
sendStore.update(messageId) { it.copy(state = state) } sendStore.update(messageId) { it.copy(state = state) }
if (state != PendingSendState.ACCEPTED) acceptedTurnEpochById.remove(messageId)
publishPendingSends() publishPendingSends()
// #1427: settled/uncertain receipts remain recoverable, but cannot freeze later sends. // #1427: settled/uncertain receipts remain recoverable, but cannot freeze later sends.
if (state != PendingSendState.SENDING) drainPendingQueue() if (state != PendingSendState.SENDING) drainPendingQueue()
} }
private fun removePendingSend(messageId: String) { private fun removePendingSend(messageId: String) {
acceptedTurnEpochById.remove(messageId)
sendStore.remove(messageId) sendStore.remove(messageId)
viewModelScope.launch(ioDispatcher) { deleteQueuedAttachmentSnapshot(messageId) } viewModelScope.launch(ioDispatcher) { deleteQueuedAttachmentSnapshot(messageId) }
publishPendingSends() publishPendingSends()
@@ -5648,6 +5662,17 @@ class ChatViewModel(
val requestSequence = activeHydrationRequestSequence val requestSequence = activeHydrationRequestSequence
val valid = { isCurrentHydration(sessionId, generation, requestSequence) } val valid = { isCurrentHydration(sessionId, generation, requestSequence) }
val useLatest = latestPaging 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 = val nextOffset =
if (useLatest) { if (useLatest) {
0 0
@@ -5682,6 +5707,13 @@ class ChatViewModel(
) )
} ?: return@launch } ?: return@launch
persistHistoryPage(page, sessionId) 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. // 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 // 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. // request may overlap, but stable IDs remove echoes without skipping any history.
@@ -81,6 +81,7 @@ fun UserBubble(
messageStatsEnabled: Boolean = false, messageStatsEnabled: Boolean = false,
showUserMessageTokens: Boolean = true, showUserMessageTokens: Boolean = true,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
pendingSendState: PendingSendState? = null,
) { ) {
val screenWidth = LocalConfiguration.current.screenWidthDp.dp val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val maxBubbleWidth = screenWidth * 0.80f val maxBubbleWidth = screenWidth * 0.80f
@@ -182,6 +183,23 @@ fun UserBubble(
canSave = canSaveAttachment, canSave = canSaveAttachment,
onImageClick = onImageClick, 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) { if (!message.isStreaming) {
FlowRow( FlowRow(
modifier = modifier =
@@ -27,8 +27,12 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R 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.PendingSend
import com.m57.hermescontrol.ui.chat.PendingSendState 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. */ /** #1427: a compact recovery entry keeps uncertain delivery visible without a floating queue panel. */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -39,8 +43,11 @@ fun PendingSendRecovery(
mainTurnBusy: Boolean, mainTurnBusy: Boolean,
onSendAgain: (String) -> Unit, onSendAgain: (String) -> Unit,
onDiscard: (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 open by remember { mutableStateOf(false) }
var retryId by remember { mutableStateOf<String?>(null) } var retryId by remember { mutableStateOf<String?>(null) }
var discardId by remember { mutableStateOf<String?>(null) } var discardId by remember { mutableStateOf<String?>(null) }
@@ -53,7 +60,7 @@ fun PendingSendRecovery(
}, },
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).testTag("pending_send_recovery"), 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) { if (open) {
ModalBottomSheet(onDismissRequest = { open = false }) { ModalBottomSheet(onDismissRequest = { open = false }) {
@@ -63,7 +70,7 @@ fun PendingSendRecovery(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) )
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp)) { LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp)) {
items(sends, key = { it.id }) { send -> items(recoverySends, key = { it.id }) { send ->
Column( Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -83,8 +90,20 @@ fun PendingSendRecovery(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
if (send.attachments.isNotEmpty()) { send.attachments.forEach { attachment ->
Text(stringResource(R.string.chat_pending_attachments)) 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 { Row {
TextButton( TextButton(
@@ -125,7 +144,7 @@ fun PendingSendRecovery(
} }
} }
} }
sends.firstOrNull { it.id == retryId }?.let { send -> recoverySends.firstOrNull { it.id == retryId }?.let { send ->
AlertDialog( AlertDialog(
onDismissRequest = { retryId = null }, onDismissRequest = { retryId = null },
title = { Text(stringResource(R.string.chat_pending_send_again)) }, 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( AlertDialog(
onDismissRequest = { discardId = null }, onDismissRequest = { discardId = null },
title = { Text(stringResource(R.string.chat_pending_discard)) }, title = { Text(stringResource(R.string.chat_pending_discard)) },
@@ -35,6 +35,7 @@ import com.m57.hermescontrol.theme.LocalChatFontScale
import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatSearchState import com.m57.hermescontrol.ui.chat.ChatSearchState
import com.m57.hermescontrol.ui.chat.ImageViewerModel 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.SearchTarget
import com.m57.hermescontrol.ui.chat.ToolCallDivider import com.m57.hermescontrol.ui.chat.ToolCallDivider
import com.m57.hermescontrol.ui.chat.UserBubble import com.m57.hermescontrol.ui.chat.UserBubble
@@ -311,6 +312,7 @@ fun FullBleedChatList(
onImageClick = actions.onImageClick, onImageClick = actions.onImageClick,
messageStatsEnabled = messageStatsEnabled, messageStatsEnabled = messageStatsEnabled,
showUserMessageTokens = showUserMessageTokens, showUserMessageTokens = showUserMessageTokens,
pendingSendState = transcript.pendingSendStates[userMessage.id],
) )
if (userMessage.reactions.isNotEmpty()) { if (userMessage.reactions.isNotEmpty()) {
MessageReactionChips( MessageReactionChips(
@@ -561,6 +563,7 @@ private fun renderUserBubble(
onImageClick: (ImageViewerModel) -> Unit, onImageClick: (ImageViewerModel) -> Unit,
messageStatsEnabled: Boolean, messageStatsEnabled: Boolean,
showUserMessageTokens: Boolean, showUserMessageTokens: Boolean,
pendingSendState: PendingSendState?,
) { ) {
UserBubble( UserBubble(
message = message, message = message,
@@ -574,5 +577,6 @@ private fun renderUserBubble(
onImageClick = onImageClick, onImageClick = onImageClick,
messageStatsEnabled = messageStatsEnabled, messageStatsEnabled = messageStatsEnabled,
showUserMessageTokens = showUserMessageTokens, showUserMessageTokens = showUserMessageTokens,
pendingSendState = pendingSendState,
) )
} }
@@ -6,6 +6,7 @@ import com.m57.hermescontrol.ui.chat.ChatTimelineState
import com.m57.hermescontrol.ui.chat.ChatUiState import com.m57.hermescontrol.ui.chat.ChatUiState
import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.ImageViewerModel 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.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
@@ -37,6 +38,7 @@ data class TranscriptUiState(
val isCompressing: Boolean, val isCompressing: Boolean,
val compressionStatus: String?, val compressionStatus: String?,
val speakingMessageId: String?, val speakingMessageId: String?,
val pendingSendStates: Map<String, PendingSendState> = emptyMap(),
) { ) {
companion object { companion object {
/** Resolve historical versus live mode at the state boundary, not in the screen call. */ /** 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, isCompressing = !historical && chat.isCompressing,
compressionStatus = chat.compressionStatus.takeUnless { historical }, compressionStatus = chat.compressionStatus.takeUnless { historical },
speakingMessageId = speakingMessageId, speakingMessageId = speakingMessageId,
pendingSendStates =
if (historical) emptyMap() else chat.pendingSends.associate { it.id to it.state },
) )
} }
} }
+6
View File
@@ -1535,6 +1535,12 @@
<string name="chat_busy_override">本条消息的发送方式</string> <string name="chat_busy_override">本条消息的发送方式</string>
<string name="chat_busy_override_once">此选择仅生效一次,不会更改保存的默认值。</string> <string name="chat_busy_override_once">此选择仅生效一次,不会更改保存的默认值。</string>
<string name="chat_pending_sends">待发送消息</string> <string name="chat_pending_sends">待发送消息</string>
<string name="chat_send_accepted">服务器已接收</string>
<plurals name="chat_pending_count">
<item quantity="other">%d 条待处理消息</item>
</plurals>
<string name="chat_pending_discard">移除</string>
<string name="chat_pending_discard_warning">要从本机队列中移除这条消息吗?这不会取消任务,也不会删除服务器已接收的消息。</string>
<string name="chat_pending_queued">已在本机排队</string> <string name="chat_pending_queued">已在本机排队</string>
<string name="chat_pending_parked">停止后已暂停</string> <string name="chat_pending_parked">停止后已暂停</string>
<string name="chat_pending_sending">正在发送…</string> <string name="chat_pending_sending">正在发送…</string>
+1
View File
@@ -219,6 +219,7 @@
<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>
<string name="chat_send_accepted">Accepted by server</string>
<plurals name="chat_pending_count"> <plurals name="chat_pending_count">
<item quantity="one">%d pending message</item> <item quantity="one">%d pending message</item>
<item quantity="other">%d pending messages</item> <item quantity="other">%d pending messages</item>
@@ -210,7 +210,7 @@ class ChatSendStoreTest {
val local = ChatMessage("send-1", MessageRole.USER, "repeat", serverRowId = 10) val local = ChatMessage("send-1", MessageRole.USER, "repeat", serverRowId = 10)
val server = ChatMessage("rest-session-20", MessageRole.USER, "repeat", serverRowId = 20) val server = ChatMessage("rest-session-20", MessageRole.USER, "repeat", serverRowId = 20)
val reply = ChatMessage("rest-session-21", MessageRole.ASSISTANT, "done", serverRowId = 21) 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) val merged = mergeTranscriptWithLive(listOf(server, reply), listOf(local), preserveLiveIds = true)
assertEquals( assertEquals(
@@ -228,4 +228,21 @@ class ChatSendStoreTest {
assertEquals(listOf(queued, confirmed), messagesWithoutUnconfirmedReceipts(listOf(queued, confirmed), pending)) 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))
}
} }
@@ -10540,6 +10540,62 @@ class ChatViewModelTest {
assertTrue(vm.uiState.value.canInterrupt) 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 @Test
fun changedReceiptIdentityDoesNotBlockNewPromptsOrFalselyConfirmDelivery() = fun changedReceiptIdentityDoesNotBlockNewPromptsOrFalselyConfirmDelivery() =
runTest { runTest {
@@ -10580,7 +10636,7 @@ class ChatViewModelTest {
.last() .last()
.content, .content,
) )
assertEquals(PendingSendState.ACCEPTED, store.all().single().state) assertEquals(PendingSendState.UNKNOWN, store.all().single().state)
assertTrue(vm.uiState.value.isSessionReady) assertTrue(vm.uiState.value.isSessionReady)
assertTrue(vm.sendMessage("probe-second")) assertTrue(vm.sendMessage("probe-second"))
advanceUntilIdle() advanceUntilIdle()
@@ -10592,7 +10648,7 @@ class ChatViewModelTest {
assertNull(vm.uiState.value.errorMessage) assertNull(vm.uiState.value.errorMessage)
verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-second", any(), any()) } verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-second", any(), any()) }
verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-first", 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( assertEquals(
1, 1,
vm.transcriptState.value.messages vm.transcriptState.value.messages
@@ -1,10 +1,14 @@
package com.m57.hermescontrol.ui.chat.fullbleed 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.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatTimelineState import com.m57.hermescontrol.ui.chat.ChatTimelineState
import com.m57.hermescontrol.ui.chat.ChatUiState import com.m57.hermescontrol.ui.chat.ChatUiState
import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.MessageRole 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 com.m57.hermescontrol.ui.chat.StreamingState
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
@@ -18,6 +22,40 @@ class TranscriptUiStateTest {
private val historical = ChatMessage(id = "historical", role = MessageRole.USER, content = "older") private val historical = ChatMessage(id = "historical", role = MessageRole.USER, content = "older")
private val clarify = ClarifyUi(text = "choose") 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 @Test
fun `live state preserves prompts streaming and paging actions`() { fun `live state preserves prompts streaming and paging actions`() {
val stream = StreamingState() val stream = StreamingState()