fix(chat): preserve midturn steer and session marker ordering (#1241)

This commit is contained in:
M57 committed 2026-09-23 17:30:47 +03:00
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,