This commit is contained in:
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 {
|
||||
|
||||
Reference in new issue
Block a user