refactor(chat): tag local feedback with displayKind and refine logical message matching (#1241)

This commit is contained in:
M57 committed 2026-09-24 02:38:31 +03:00
1 parent c9bbadf5d5
commit 9ae7b6a520
5 files changed
+85 -25

No files matched your search

@@ -169,9 +169,21 @@ internal class TranscriptComparison(
val key = toolKey(a.content)
return key != null && key == toolKey(b.content)
}
// Rows generated only by Mobile never have a canonical REST counterpart.
// Do not let coincidentally identical server text claim their identity.
if (a.role == MessageRole.ASSISTANT &&
(a.displayKind == "local_feedback" || b.displayKind == "local_feedback")
) {
return false
}
if (a.role == MessageRole.USER &&
(a.displayKind == "clarify_response" || b.displayKind == "clarify_response")
) {
return false
}
val ta = trimmed.getOrPut(a.content) { a.content.trim() }
val tb = trimmed.getOrPut(b.content) { b.content.trim() }
if (ta.startsWith("/") || tb.startsWith("/")) return false
if (a.role == MessageRole.USER && (ta.startsWith("/") || tb.startsWith("/"))) return false
if (ta == tb) return true
if (a.role == MessageRole.USER &&
captions.getOrPut(ta) { stripAttachmentRefLines(ta) } ==
@@ -458,27 +470,7 @@ private fun List<ChatMessage>.inTranscriptOrder(
internal fun ChatMessage.isPermanentlyLocal(): Boolean =
role == MessageRole.SYSTEM ||
(role == MessageRole.USER && (content.startsWith("/") || displayKind == "clarify_response")) ||
(role == MessageRole.ASSISTANT && canonicalRestId == null && isLocalFeedbackRow(this))
private fun isLocalFeedbackRow(message: ChatMessage): Boolean =
message.localOrder != null || isLocalFeedbackContent(message.content)
private fun isLocalFeedbackContent(content: String): Boolean =
content.endsWith(" is not supported on mobile") ||
content.startsWith("usage: /queue") ||
content.startsWith("reasoning: ") ||
content.startsWith("Could not read reasoning status:") ||
content == "Reasoning is not supported for the current model." ||
content == "Reasoning cannot be disabled for this model (always on)." ||
content.startsWith("Could not change reasoning:") ||
content == "Reasoning controls require an active session." ||
content == "No active session. Use `/new` to create one." ||
content == "No active session to undo." ||
content == "No active session for side questions. Start a chat first." ||
content.startsWith("Bot chats are one continuous conversation — compacting instead.") ||
content.startsWith("⚠ ") ||
(content.startsWith("/") && content.contains(": ")) ||
content == "Failed to undo."
(role == MessageRole.ASSISTANT && displayKind == "local_feedback")
internal fun ChatMessage.isSessionStartMarker(): Boolean =
role == MessageRole.SYSTEM && (content == "Session created" || content == "Session branched")
@@ -2655,7 +2655,12 @@ class ChatViewModel(
}
private fun addAssistantMessage(text: String) {
val msg = ChatMessage(role = MessageRole.ASSISTANT, content = text)
val msg =
ChatMessage(
role = MessageRole.ASSISTANT,
content = text,
displayKind = "local_feedback",
)
_uiState.update { it.copy(messages = it.messages + msg) }
// Persist — OUTSIDE update{}
@@ -58,7 +58,10 @@ private fun ChatMessage.isSyntheticSystemRow(): Boolean =
content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
internal fun ChatMessage.isTimelineMarker(): Boolean =
displayKind != null && displayKind != "steer" && displayKind != "clarify_response"
displayKind != null &&
displayKind != "steer" &&
displayKind != "clarify_response" &&
displayKind != "local_feedback"
/**
* Split a flat message list into turns for the full-bleed renderer.
@@ -581,6 +581,51 @@ class ChatPagingMergeTest {
assertTrue(sameLogicalMessage(canonical.copy(reasoningText = "new trace"), live))
}
@Test
fun assistantReplyBeginningWithSlashStillMatchesCanonicalEcho() {
val live =
ChatMessage(
id = "uuid-answer",
role = MessageRole.ASSISTANT,
content = "/help is the command you're looking for.",
)
val canonical =
live.copy(
id = "rest-session-10",
restId = null,
)
assertTrue(sameLogicalMessage(canonical, live))
}
@Test
fun permanentlyLocalRowsDoNotMatchCanonicalRowsByContent() {
val localFeedback =
ChatMessage(
id = "uuid-feedback",
role = MessageRole.ASSISTANT,
content = "Command completed successfully",
displayKind = "local_feedback",
)
val canonicalAssistant =
ChatMessage(
id = "rest-session-10",
role = MessageRole.ASSISTANT,
content = "Command completed successfully",
)
val clarify =
ChatMessage(
id = "uuid-clarify",
role = MessageRole.USER,
content = "yes",
displayKind = "clarify_response",
)
val canonicalUser = ChatMessage(id = "rest-session-11", role = MessageRole.USER, content = "yes")
assertTrue(!sameLogicalMessage(canonicalAssistant, localFeedback))
assertTrue(!sameLogicalMessage(canonicalUser, clarify))
}
@Test
fun stripGatewaySteerWrapperRemovesEnvelopesSafely() {
val standard =
@@ -809,7 +854,12 @@ class ChatPagingMergeTest {
val answer = ChatMessage(id = "rest-session-1", role = MessageRole.ASSISTANT, content = "I am Hermes.")
val command = ChatMessage(id = "uuid-cmd", role = MessageRole.USER, content = "/reasoning")
val output =
ChatMessage(id = "uuid-output", role = MessageRole.ASSISTANT, content = "reasoning: low · display on")
ChatMessage(
id = "uuid-output",
role = MessageRole.ASSISTANT,
content = "Command completed successfully",
displayKind = "local_feedback",
)
val current = listOf(prompt, answer, command, output)
@@ -103,6 +103,16 @@ class FullBleedTurnsTest {
assertEquals(listOf<ChatTurn>(ChatTurn.User(steer)), result)
}
@Test
fun `local feedback display kind remains assistant prose`() {
val feedback =
msg("a1", MessageRole.ASSISTANT, content = "Command completed successfully")
.copy(displayKind = "local_feedback")
val result = groupIntoTurns(listOf(feedback))
assertEquals(listOf<ChatTurn>(entries(AgentEntry.Prose(feedback))), 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