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

@@ -53,7 +53,12 @@ interface ChatMessageDao {
suspend fun upsert(message: ChatMessageEntity) {
val existing = getMessage(message.id)
val restId = message.restId ?: existing?.restId
val canonicalOrder = canonicalMessageOrder(restId ?: message.id, message.sessionId)
val canonicalOrder =
if (message.isSessionStartMarker()) {
-1L
} else {
canonicalMessageOrder(restId ?: message.id, message.sessionId)
}
writeMessage(
message.copy(
restId = restId,
@@ -56,6 +56,9 @@ data class ChatMessageEntity(
val sortOrder: Long = 0,
)
internal fun ChatMessageEntity.isSessionStartMarker(): Boolean =
role == "SYSTEM" && (content == "Session created" || content == "Session branched")
/** Only the exact session prefix and a nonnegative decimal suffix identify a canonical row. */
internal fun canonicalMessageOrder(
id: String,
@@ -39,7 +39,7 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
tps = tps,
completionId = completionId,
restId = restId,
localOrder = sortOrder.takeIf { sortGroup == 1 && it > 0 },
localOrder = sortOrder.takeIf { sortGroup == 1 && it > 0 && !isSessionStartMarker() },
)
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
@@ -36,7 +36,7 @@ private const val MAX_ITERATIONS_SYSTEM_MARKER =
*/
internal fun ChatMessage.isUserTurnBoundary(): Boolean =
role == MessageRole.USER &&
displayKind == null &&
(displayKind == null || displayKind == "steer") &&
!content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
/**
@@ -164,7 +164,11 @@ internal fun mapServerMessages(
// Keep the actual @file:/@image: token here: unlike the dedupe path,
// REST-only hydration cannot prove that reference was not user-authored.
var finalContent =
if (role == MessageRole.USER) stripGatewayAttachedContext(rawContent) else rawContent
if (role == MessageRole.USER) {
stripGatewayAttachedContext(stripGatewaySteerWrapper(rawContent))
} else {
rawContent
}
var attachments: List<Attachment>? = null
if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
val items = HostMediaExtractor.extract(rawContent)
@@ -14,6 +14,30 @@ private val CONTEXT_REF_RE =
"""@(file|folder|url|image|tool|terminal):(?:"[^"\n]+"|'[^'\n]+'|`[^`\n]+`|\S+)""",
)
private val OUT_OF_BAND_START_RE =
Regex("""\[OUT-OF-BAND USER MESSAGE[^\n\]]*\]\s*""", RegexOption.IGNORE_CASE)
private val OUT_OF_BAND_END_RE =
Regex("""\s*\[/OUT-OF-BAND USER MESSAGE\]""", RegexOption.IGNORE_CASE)
/**
* Remove a gateway-generated `[OUT-OF-BAND USER MESSAGE ...]` wrapper from
* mid-turn steering messages while preserving the user-authored prompt.
*/
internal fun stripGatewaySteerWrapper(content: String): String {
val trimmed = content.trimStart()
val startMatch = OUT_OF_BAND_START_RE.find(trimmed) ?: return content
if (startMatch.range.first != 0) return content
val afterStart = trimmed.substring(startMatch.range.last + 1)
val endMatch = OUT_OF_BAND_END_RE.find(afterStart)
val unwrapped =
if (endMatch != null) {
afterStart.substring(0, endMatch.range.first)
} else {
afterStart
}
return unwrapped.trim()
}
/**
* Remove a gateway-generated `--- Attached Context ---` suffix while
* preserving the user-authored portion of the message.
@@ -231,7 +255,7 @@ internal fun matchTranscriptMessages(
* run_agent). Blank lines left behind are dropped too.
*/
internal fun stripAttachmentRefLines(content: String): String =
stripGatewayAttachedContext(content)
stripGatewayAttachedContext(stripGatewaySteerWrapper(content))
.lines()
.map { it.trim() }
.filterNot { line ->
@@ -276,7 +300,11 @@ internal fun dedupeCachedMessages(
}.toMap()
return unique.filterNot { it.id in echoes }.map { message ->
aliases[message.id]?.let {
message.copy(restId = it.canonicalRestId, completionId = message.completionId ?: it.completionId)
message.copy(
restId = it.canonicalRestId,
completionId = message.completionId ?: it.completionId,
displayKind = message.displayKind ?: it.displayKind,
)
} ?: message
}
}
@@ -306,6 +334,7 @@ internal fun mergeCachedTranscriptPage(
id = match.id,
restId = message.canonicalRestId ?: match.canonicalRestId,
completionId = match.completionId ?: message.completionId,
displayKind = message.displayKind ?: match.displayKind,
)
}.toMap()
return (
@@ -333,6 +362,7 @@ internal fun mergeTranscriptWithLive(
match?.role == MessageRole.USER -> {
match.copy(
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
displayKind = message.displayKind ?: match.displayKind,
)
}
@@ -378,8 +408,16 @@ private fun List<ChatMessage>.inTranscriptOrder(): List<ChatMessage> =
.thenBy { it.canonicalOrder ?: it.localOrder ?: Long.MAX_VALUE },
)
internal fun ChatMessage.isSessionStartMarker(): Boolean =
role == MessageRole.SYSTEM && (content == "Session created" || content == "Session branched")
private val ChatMessage.canonicalOrder: Long?
get() = if (localOrder != null && restId == null) null else canonicalRestId?.substringAfterLast('-')?.toLongOrNull()
get() =
when {
isSessionStartMarker() -> -1L
localOrder != null && restId == null -> null
else -> canonicalRestId?.substringAfterLast('-')?.toLongOrNull()
}
/** Retain the canonical trace once, without copying it across a user/system/tool boundary. */
private fun List<ChatMessage>.reconcileReasoningRows(): List<ChatMessage> =
@@ -57,6 +57,8 @@ private fun ChatMessage.isSyntheticSystemRow(): Boolean =
displayKind == null &&
content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
internal fun ChatMessage.isTimelineMarker(): Boolean = displayKind != null && displayKind != "steer"
/**
* Split a flat message list into turns for the full-bleed renderer.
*
@@ -80,11 +82,12 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
// Timeline markers (display_kind) ride as role=user rows but are
// NOT user turns — group them as system-style timeline entries so
// they render as centered chips, not fake user bubbles (issue #904).
// Steer messages (display_kind == "steer") are genuine user turns.
// The backend's max-iterations nudge is a role=user row with NO
// display_kind (it's stripped on persistence); detect it by its
// stable content prefix and route it as a system event too, tagging
// it so the timeline chip can name it.
message.displayKind != null -> {
message.isTimelineMarker() -> {
agentEntries += AgentEntry.SystemEvent(message)
}