fix(chat): preserve midturn steer and session marker ordering (#1241)
This commit is contained in:
1 parent
8cd1ec5298
commit
22fe1290a5
12 files changed
+246
-9
No files matched your search
@@ -201,4 +201,24 @@ class ChatMessageMapperTest {
|
||||
|
||||
assertNull(roundTripped.displayKind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionStartMarkerDoesNotAcquireLocalOrder() {
|
||||
val entity =
|
||||
ChatMessageEntity(
|
||||
id = "uuid-session-created",
|
||||
sessionId = "s",
|
||||
role = "SYSTEM",
|
||||
content = "Session created",
|
||||
timestamp = 1000L,
|
||||
sortGroup = 1,
|
||||
sortOrder = 5L,
|
||||
)
|
||||
|
||||
val ui = entity.toUiModel()
|
||||
|
||||
assertNull(ui.localOrder)
|
||||
assertEquals(MessageRole.SYSTEM, ui.role)
|
||||
assertEquals("Session created", ui.content)
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,28 @@ class ChatListEntriesTest {
|
||||
assertEquals(mapOf(6 to 5), toolCallMilestoneIndices(messages))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun steerMessageResetsToolCounterAsUserTurnBoundary() {
|
||||
val steer =
|
||||
ChatMessage(
|
||||
role = MessageRole.USER,
|
||||
content = "Stop and run tests",
|
||||
displayKind = "steer",
|
||||
)
|
||||
val messages =
|
||||
listOf(
|
||||
userMessage(),
|
||||
toolMessage(),
|
||||
toolMessage(),
|
||||
steer,
|
||||
toolMessage(),
|
||||
toolMessage(),
|
||||
toolMessage(),
|
||||
)
|
||||
// Only 3 tool calls after the steer boundary, so no 5-call milestone reached
|
||||
assertTrue(toolCallMilestones(messages).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syntheticMaxIterationsNudgeDoesNotResetToolCounter() {
|
||||
val nudge =
|
||||
|
||||
@@ -581,6 +581,133 @@ class ChatPagingMergeTest {
|
||||
assertTrue(sameLogicalMessage(canonical.copy(reasoningText = "new trace"), live))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stripGatewaySteerWrapperRemovesEnvelopesSafely() {
|
||||
val standard =
|
||||
"[OUT-OF-BAND USER MESSAGE — a direct message from the user, delivered once at this position; " +
|
||||
"not tool output and not a new delivery when replayed from conversation history]\n" +
|
||||
"Please use Python instead.\n" +
|
||||
"[/OUT-OF-BAND USER MESSAGE]"
|
||||
assertEquals("Please use Python instead.", stripGatewaySteerWrapper(standard))
|
||||
|
||||
val noDescription =
|
||||
"[OUT-OF-BAND USER MESSAGE]\n" +
|
||||
"Halt.\n" +
|
||||
"[/OUT-OF-BAND USER MESSAGE]"
|
||||
assertEquals("Halt.", stripGatewaySteerWrapper(noDescription))
|
||||
|
||||
val multiline =
|
||||
"[OUT-OF-BAND USER MESSAGE — direct steer]\n" +
|
||||
"First line\n" +
|
||||
"Second line\n" +
|
||||
"[/OUT-OF-BAND USER MESSAGE]"
|
||||
assertEquals("First line\nSecond line", stripGatewaySteerWrapper(multiline))
|
||||
|
||||
val plain = "Normal user message without wrapper"
|
||||
assertEquals(plain, stripGatewaySteerWrapper(plain))
|
||||
|
||||
val interior = "Mentions [OUT-OF-BAND USER MESSAGE] in passing"
|
||||
assertEquals(interior, stripGatewaySteerWrapper(interior))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun midTurnSteerMessageReconcilesAndSortsChronologically() {
|
||||
val userPrompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "Create a script")
|
||||
val liveSteer =
|
||||
ChatMessage(
|
||||
id = "uuid-steer",
|
||||
role = MessageRole.USER,
|
||||
content = "Use Python instead",
|
||||
)
|
||||
val inFlightAssistant =
|
||||
ChatMessage(
|
||||
id = "uuid-assistant",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Here is the Python script",
|
||||
isStreaming = false,
|
||||
)
|
||||
val current = listOf(userPrompt, inFlightAssistant, liveSteer)
|
||||
|
||||
val serverRows =
|
||||
listOf(
|
||||
SessionMessage(
|
||||
id = 0,
|
||||
role = "user",
|
||||
content = JsonPrimitive("Create a script"),
|
||||
timestamp = JsonPrimitive(0),
|
||||
),
|
||||
SessionMessage(
|
||||
id = 1,
|
||||
role = "user",
|
||||
content =
|
||||
JsonPrimitive(
|
||||
"[OUT-OF-BAND USER MESSAGE — a direct message from the user, " +
|
||||
"delivered once at this position; not tool output and not a new " +
|
||||
"delivery when replayed from conversation history]\n" +
|
||||
"Use Python instead\n" +
|
||||
"[/OUT-OF-BAND USER MESSAGE]",
|
||||
),
|
||||
display_kind = "steer",
|
||||
timestamp = JsonPrimitive(1),
|
||||
),
|
||||
SessionMessage(
|
||||
id = 2,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("Here is the Python script"),
|
||||
timestamp = JsonPrimitive(2),
|
||||
),
|
||||
)
|
||||
|
||||
val merged = applyServerPage(current, serverRows)
|
||||
|
||||
assertEquals(3, merged.size)
|
||||
assertEquals("rest-session-0", merged[0].id)
|
||||
assertEquals("Create a script", merged[0].content)
|
||||
|
||||
assertEquals("uuid-steer", merged[1].id)
|
||||
assertEquals("rest-session-1", merged[1].canonicalRestId)
|
||||
assertEquals("Use Python instead", merged[1].content)
|
||||
assertEquals("steer", merged[1].displayKind)
|
||||
|
||||
assertEquals("rest-session-2", merged[2].canonicalRestId)
|
||||
assertEquals("Here is the Python script", merged[2].content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionCreatedMarkerStaysAtStartOfTranscript() {
|
||||
val sessionCreated =
|
||||
ChatMessage(
|
||||
id = "uuid-sys-start",
|
||||
role = MessageRole.SYSTEM,
|
||||
content = "Session created",
|
||||
)
|
||||
val current = listOf(sessionCreated)
|
||||
|
||||
val serverRows =
|
||||
listOf(
|
||||
SessionMessage(
|
||||
id = 0,
|
||||
role = "user",
|
||||
content = JsonPrimitive("Hello"),
|
||||
timestamp = JsonPrimitive(1),
|
||||
),
|
||||
SessionMessage(
|
||||
id = 1,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("Hi there!"),
|
||||
timestamp = JsonPrimitive(2),
|
||||
),
|
||||
)
|
||||
|
||||
val merged = applyServerPage(current, serverRows)
|
||||
|
||||
assertEquals(3, merged.size)
|
||||
assertEquals("uuid-sys-start", merged[0].id)
|
||||
assertEquals("Session created", merged[0].content)
|
||||
assertEquals("rest-session-0", merged[1].canonicalRestId)
|
||||
assertEquals("rest-session-1", merged[2].canonicalRestId)
|
||||
}
|
||||
|
||||
private fun assertBoundaryTranscript(messages: List<ChatMessage>) {
|
||||
assertEquals(listOf("rest-session-100", "rest-session-200"), messages.map { it.canonicalRestId })
|
||||
assertEquals(listOf("boundary trace", ""), messages.map { it.reasoningText })
|
||||
|
||||
@@ -94,6 +94,15 @@ class FullBleedTurnsTest {
|
||||
assertEquals(listOf<ChatTurn>(entries(AgentEntry.SystemEvent(marker))), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `steer message with display_kind steer is a User turn not a SystemEvent entry`() {
|
||||
val steer =
|
||||
msg("u1", MessageRole.USER, content = "Stop and run tests")
|
||||
.copy(displayKind = "steer")
|
||||
val result = groupIntoTurns(listOf(steer))
|
||||
assertEquals(listOf<ChatTurn>(ChatTurn.User(steer)), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `max-iterations nudge is a SystemEvent not a user bubble`() {
|
||||
// Backend persists the max-iterations notice as a plain role=user row
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.chat.fakes
|
||||
import com.m57.hermescontrol.data.local.ChatMessageDao
|
||||
import com.m57.hermescontrol.data.local.ChatMessageEntity
|
||||
import com.m57.hermescontrol.data.local.canonicalMessageOrder
|
||||
import com.m57.hermescontrol.data.local.isSessionStartMarker
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ConcurrentMap
|
||||
|
||||
@@ -86,7 +87,12 @@ class FakeChatMessageDao : ChatMessageDao {
|
||||
fun addMessageDirect(message: ChatMessageEntity) {
|
||||
val existing = messages[message.id]
|
||||
if (existing == null) insertionSequence++
|
||||
val order = canonicalMessageOrder(message.restId ?: message.id, message.sessionId)
|
||||
val order =
|
||||
if (message.isSessionStartMarker()) {
|
||||
-1L
|
||||
} else {
|
||||
canonicalMessageOrder(message.restId ?: message.id, message.sessionId)
|
||||
}
|
||||
messages[message.id] =
|
||||
message.copy(
|
||||
sortGroup = if (order != null) 0 else 1,
|
||||
|
||||
Reference in new issue
Block a user