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
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 },
)
}
}
+6
View File
@@ -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>
+1
View File
@@ -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()