fix(#1427): keep accepted prompts visible during live turns (#1430)

This commit is contained in:
m57 authored and GitHub committed 2026-10-01 20:17:05 +03:00
1 parent 63105b7843
commit 1cef9e7ebb
13 files changed
+332 -14

No files matched your search

@@ -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()