Merge pull request #1431 from Hy4ri/codex/release-v2.0.2
chore: promote dev for v2.0.2
This commit is contained in:
13 files changed
+332
-14
No files matched your search
+120
-2
@@ -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<ComponentActivity>()
|
||||
|
||||
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 {
|
||||
|
||||
@@ -859,6 +859,8 @@ fun ChatScreen(
|
||||
mainTurnBusy = state.isMainTurnBusy,
|
||||
onSendAgain = viewModel::sendQueuedNow,
|
||||
onDiscard = viewModel::discardPendingSend,
|
||||
onOpenAttachment = viewModel::openAttachment,
|
||||
onImageClick = { viewingImage = it },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -543,6 +543,7 @@ class ChatViewModel(
|
||||
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
|
||||
private val branchWholeRequests = ConcurrentHashMap<String, PendingBranchRequest>()
|
||||
private val outgoingRequestById = ConcurrentHashMap<String, String>()
|
||||
private val acceptedTurnEpochById = ConcurrentHashMap<String, Long>()
|
||||
private val hardInterruptRequestById = ConcurrentHashMap<String, HardInterruptRequest>()
|
||||
private val queuedStagingIds = ConcurrentHashMap.newKeySet<String>()
|
||||
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.
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<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"),
|
||||
) {
|
||||
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)) },
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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<String, PendingSendState> = 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1535,6 +1535,12 @@
|
||||
<string name="chat_busy_override">本条消息的发送方式</string>
|
||||
<string name="chat_busy_override_once">此选择仅生效一次,不会更改保存的默认值。</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_parked">停止后已暂停</string>
|
||||
<string name="chat_pending_sending">正在发送…</string>
|
||||
|
||||
@@ -219,6 +219,7 @@
|
||||
<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>
|
||||
<string name="chat_send_accepted">Accepted by server</string>
|
||||
<plurals name="chat_pending_count">
|
||||
<item quantity="one">%d pending message</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 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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user