feat(#1285): reconcile pending/streamed turns with persisted receipts (#1299)

This commit is contained in:
m57 authored and GitHub committed 2026-09-26 23:12:22 +03:00
1 parent 7ae97a1099
commit 5486856bd0
10 files changed
+392 -5

No files matched your search

@@ -316,7 +316,7 @@ class ChatPagingMergeTest {
val mapped = mapServerMessages("s", listOf(serverTool(42, "call-42")), 0, true, listOf(live, echo))
assertEquals(listOf(live.copy(restId = "rest-s-42")), mapped)
assertEquals(listOf(live.copy(restId = "rest-s-42", serverRowId = 42L)), mapped)
}
@Test
@@ -328,7 +328,10 @@ class ChatPagingMergeTest {
val mapped = mapServerMessages("s", listOf(serverTool(42), serverTool(43)), 0, true, cache)
assertEquals(
listOf(first.copy(restId = "rest-s-42"), second.copy(restId = "rest-s-43")),
listOf(
first.copy(restId = "rest-s-42", serverRowId = 42L),
second.copy(restId = "rest-s-43", serverRowId = 43L),
),
mapped,
)
assertEquals(2, mapped.map { it.id }.toSet().size)
@@ -0,0 +1,179 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.ws.WsEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** #1285: persisted-turn receipts are addresses, never negative acknowledgements. */
class ChatPersistedReceiptsTest {
@Test
fun parsesCompleteReceiptWithMixedNumberTypes() {
val turn =
parsePersistedTurn(
mapOf(
"persisted_turn" to
mapOf(
"row_ids" to listOf(41, 42L, 43.0),
"complete" to true,
"user_row_id" to 41,
"final_assistant_row_id" to 43L,
),
),
)
assertEquals(PersistedTurn(true, 41L, 43L), turn)
assertEquals(
43L,
parsePersistedTurn(mapOf("persisted_turn" to mapOf("final_assistant_row_id" to 43.0)))?.finalAssistantRowId,
)
}
@Test
fun missingIdsStayUnprovenAndMalformedValuesAreIgnored() {
val turn =
parsePersistedTurn(
mapOf(
"persisted_turn" to
mapOf("complete" to "true", "user_row_id" to true, "final_assistant_row_id" to 1.5),
),
)!!
assertFalse(turn.complete)
assertNull(positiveRowId("8"))
assertNull(positiveRowId(0))
assertNull(positiveRowId(-1L))
assertNull(turn.userRowId)
assertNull(turn.finalAssistantRowId)
assertNull(parsePersistedTurn(mapOf("text" to "done")))
assertNull(parsePersistedTurn(null))
}
@Test
fun rowIdMatchesUserEchoEvenWhenServerTextDiffers() {
val local = ChatMessage(id = "local", role = MessageRole.USER, content = "hi", serverRowId = 42)
val rest =
ChatMessage(id = "rest-s-42", role = MessageRole.USER, content = "hi\n\n--- rewritten", serverRowId = 42)
val merged = mergeTranscriptWithLive(listOf(rest), listOf(local), preserveLiveIds = true)
assertEquals(listOf("local"), merged.map { it.id })
assertEquals("rest-s-42", merged.single().restId)
assertEquals(42L, merged.single().serverRowId)
}
@Test
fun differentRowIdsKeepRepeatedTextAsSeparateTurns() {
val local = ChatMessage(id = "local", role = MessageRole.USER, content = "again", serverRowId = 43)
val older = ChatMessage(id = "rest-s-42", role = MessageRole.USER, content = "again", serverRowId = 42)
val merged = mergeTranscriptWithLive(listOf(older), listOf(local), preserveLiveIds = true)
assertEquals(setOf("local", "rest-s-42"), merged.map { it.id }.toSet())
}
@Test
fun missingRowIdFallsBackToContentMatching() {
val local = ChatMessage(id = "local", role = MessageRole.USER, content = "hello")
val rest = ChatMessage(id = "rest-s-9", role = MessageRole.USER, content = "hello", serverRowId = 9)
val merged = mergeTranscriptWithLive(listOf(rest), listOf(local), preserveLiveIds = true)
assertEquals(listOf("local"), merged.map { it.id })
assertEquals(9L, merged.single().serverRowId)
}
@Test
fun completeReceiptBindsFinalAssistantRow() {
val result =
complete(
"answer",
mapOf(
"row_ids" to listOf(1, 2),
"complete" to true,
"final_assistant_row_id" to 2,
),
)
assertEquals(
2L,
result.state.messages
.single()
.serverRowId,
)
}
@Test
fun partialReceiptStillBindsProvenFinalRowButKeepsLocalReply() {
val result =
complete("answer", mapOf("row_ids" to listOf(2), "complete" to false, "final_assistant_row_id" to 2))
assertEquals(
"answer",
result.state.messages
.single()
.content,
)
assertEquals(
2L,
result.state.messages
.single()
.serverRowId,
)
}
@Test
fun receiptWithoutFinalIdLeavesReplyUnbound() {
val result = complete("answer", mapOf("row_ids" to listOf(1), "complete" to false, "user_row_id" to 1))
assertEquals(
"answer",
result.state.messages
.single()
.content,
)
assertNull(
result.state.messages
.single()
.serverRowId,
)
}
@Test
fun strippedCommentaryPrefixDoesNotClaimFinalRow() {
val sealed = ChatMessage(id = "sealed", role = MessageRole.ASSISTANT, content = "Looking. ")
val result =
ChatWsEventReducer.reduce(
state = ChatUiState(currentSessionId = "session-1", messages = listOf(sealed)),
streamingState = StreamingState(sealedOrphanIds = listOf("sealed")),
event =
WsEvent.MessageComplete(
"Looking. Done",
"session-1",
rawPayload =
mapOf(
"persisted_turn" to
mapOf("row_ids" to listOf(5), "complete" to true, "final_assistant_row_id" to 5),
),
),
currentSessionId = "session-1",
)
val reply = result.state.messages.single { it.id != "sealed" }
assertEquals("Done", reply.content)
assertNull(reply.serverRowId)
assertTrue(result.state.messages.none { it.serverRowId == 5L })
}
private fun complete(
text: String,
receipt: Map<String, Any?>,
) = ChatWsEventReducer.reduce(
state = ChatUiState(currentSessionId = "session-1"),
streamingState = StreamingState(),
event = WsEvent.MessageComplete(text, "session-1", rawPayload = mapOf("persisted_turn" to receipt)),
currentSessionId = "session-1",
)
}
@@ -3516,6 +3516,112 @@ class ChatViewModelTest {
assertEquals(PendingSendState.UNKNOWN, sendStore.all().single().state)
}
@Test
fun completePersistedTurnRetiresOnlyTheReceiptBoundToItsUserRow() =
runTest {
val sendStore = ChatSendStore()
val (viewModel, sessionId) = createViewModelWithSession(sendStore = sendStore)
every { HermesWsClient.sendMessage(sessionId, "Durable", any(), any()) } answers {
arg<((String) -> Unit)?>(2)?.invoke("durable-submit")
"durable-submit"
}
viewModel.sendMessage("Durable")
advanceUntilIdle()
mockEventsFlow.emit(
WsEvent.RpcResult("durable-submit", mapOf("status" to "streaming", "user_row_id" to 77)),
)
advanceUntilIdle()
val receipt = sendStore.all().single()
assertEquals(77L, receipt.userRowId)
assertEquals(
77L,
viewModel.uiState.value.messages
.single { it.id == receipt.id }
.serverRowId,
)
// Partial and foreign receipts never retire the local turn.
mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
mockEventsFlow.emit(
WsEvent.MessageComplete(
"Reply",
sessionId,
rawPayload =
mapOf(
"persisted_turn" to
mapOf(
"row_ids" to listOf(77),
"complete" to false,
"user_row_id" to 77,
),
),
),
)
advanceUntilIdle()
assertEquals(listOf(receipt.id), sendStore.all().map { it.id })
mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
mockEventsFlow.emit(
WsEvent.MessageComplete(
"Reply",
sessionId,
rawPayload =
mapOf(
"persisted_turn" to mapOf("row_ids" to listOf(78), "complete" to true, "user_row_id" to 78),
),
),
)
advanceUntilIdle()
assertEquals(listOf(receipt.id), sendStore.all().map { it.id })
mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
mockEventsFlow.emit(
WsEvent.MessageComplete(
"Reply",
sessionId,
rawPayload =
mapOf(
"persisted_turn" to
mapOf(
"row_ids" to listOf(77, 79),
"complete" to true,
"user_row_id" to 77,
"final_assistant_row_id" to 79,
),
),
),
)
advanceUntilIdle()
assertTrue(sendStore.all().isEmpty())
assertTrue(
viewModel.uiState.value.messages
.any { it.id == receipt.id },
)
}
@Test
fun submitAckWithoutUserRowIdLeavesReceiptUnproven() =
runTest {
val sendStore = ChatSendStore()
val (viewModel, sessionId) = createViewModelWithSession(sendStore = sendStore)
every { HermesWsClient.sendMessage(sessionId, "Legacy", any(), any()) } answers {
arg<((String) -> Unit)?>(2)?.invoke("legacy-submit")
"legacy-submit"
}
viewModel.sendMessage("Legacy")
advanceUntilIdle()
mockEventsFlow.emit(WsEvent.RpcResult("legacy-submit", mapOf("status" to "streaming")))
advanceUntilIdle()
val receipt = sendStore.all().single()
assertNull(receipt.userRowId)
assertEquals(PendingSendState.ACCEPTED, receipt.state)
assertTrue(
viewModel.uiState.value.messages
.any { it.id == receipt.id && it.serverRowId == null },
)
}
@Test
fun busyGuideUsesSteerAndUnsupportedFallsBackToQueueWithoutInterrupt() =
runTest {