feat: render display_kind timeline markers as chips, not user bubbles (#904) (#909)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-15 14:06:51 +03:00
1 parent 85087c70c7
commit 4be1e965f2
16 files changed
+438 -11

No files matched your search

@@ -0,0 +1,100 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "aad5a6dec143dc657581201e01029748",
"entities": [
{
"tableName": "chat_messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `session_id` TEXT NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `reasoning_text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `tool_name` TEXT, `tool_call_id` TEXT NOT NULL, `tool_status` TEXT, `is_streaming` INTEGER NOT NULL, `display_kind` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sessionId",
"columnName": "session_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "reasoningText",
"columnName": "reasoning_text",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "toolName",
"columnName": "tool_name",
"affinity": "TEXT"
},
{
"fieldPath": "toolCallId",
"columnName": "tool_call_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toolStatus",
"columnName": "tool_status",
"affinity": "TEXT"
},
{
"fieldPath": "isStreaming",
"columnName": "is_streaming",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayKind",
"columnName": "display_kind",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_chat_messages_session_id_timestamp",
"unique": false,
"columnNames": [
"session_id",
"timestamp"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_session_id_timestamp` ON `${TABLE_NAME}` (`session_id`, `timestamp`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aad5a6dec143dc657581201e01029748')"
]
}
}
@@ -40,4 +40,6 @@ data class ChatMessageEntity(
val toolStatus: String? = null,
@ColumnInfo(name = "is_streaming")
val isStreaming: Boolean = false,
@ColumnInfo(name = "display_kind")
val displayKind: String? = null,
)
@@ -34,6 +34,7 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
"FAILED" -> ToolStatus.FAILED
else -> null
},
displayKind = displayKind,
)
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
@@ -48,4 +49,5 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
toolCallId = toolCallId,
toolStatus = toolStatus?.name,
isStreaming = isStreaming,
displayKind = displayKind,
)
@@ -11,7 +11,7 @@ import java.io.File
@Database(
entities = [ChatMessageEntity::class],
version = 5,
version = 6,
exportSchema = true,
)
abstract class HermesDatabase : RoomDatabase() {
@@ -68,13 +68,30 @@ abstract class HermesDatabase : RoomDatabase() {
}
}
val migration5to6 =
object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
// Issue #904: timeline markers (display_kind) keep
// their tag through the Room cache so a cached
// marker never degrades back into a user bubble.
db.execSQL(
"ALTER TABLE `chat_messages` ADD COLUMN `display_kind` TEXT",
)
}
}
instance ?: Room
.databaseBuilder(
context.applicationContext,
HermesDatabase::class.java,
"hermes_control.db",
).openHelperFactory(factory)
.addMigrations(migration2to3, migration3to4, migration4to5)
.addMigrations(
migration2to3,
migration3to4,
migration4to5,
migration5to6,
)
.fallbackToDestructiveMigration(false)
.build()
.also { instance = it }
@@ -39,6 +39,15 @@ data class SessionMessage(
val reasoning: JsonElement? = null,
val reasoning_text: JsonElement? = null,
val tool_call_id: String? = null,
/**
* Timeline-marker tag (backend NS-656 lineage, issue #904): markers like
* `model_switch` / `personality_switch` / `auto_continue` ride as
* role=user rows so strict providers accept them mid-conversation, but
* they are NOT user turns. Nullable/additive — old backends omit it.
*/
val display_kind: String? = null,
/** Display-only metadata for the marker (e.g. delegation result counts). */
val display_metadata: JsonElement? = null,
) {
val timestampText: String?
get() = (timestamp as? JsonPrimitive)?.content
@@ -59,6 +59,13 @@ data class ChatMessage(
* Transient — not persisted to SQLite.
*/
val progressPreview: String? = null,
/**
* Timeline-marker tag carried from the backend's `display_kind`
* (issue #904): `model_switch` / `personality_switch` / `auto_continue`
* ride as role=user rows but are NOT user input — the chat list renders
* them as centered timeline chips instead of user bubbles.
*/
val displayKind: String? = null,
)
/**
@@ -2919,6 +2919,7 @@ class ChatViewModel(
attachments = attachments,
timestamp = timestamp,
isStreaming = false,
displayKind = msg.display_kind,
),
)
}
@@ -256,10 +256,17 @@ fun FullBleedChatList(
val sysMessage = entry.message
item(key = "sys-${sysMessage.id}") {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
FullBleedSystemEvent(
message = sysMessage,
onRespondApproval = viewModel::respondToApproval,
)
if (sysMessage.displayKind != null) {
// Timeline marker (issue #904):
// model/personality switches and
// auto-continues render as a chip.
TimelineMarkerChip(message = sysMessage)
} else {
FullBleedSystemEvent(
message = sysMessage,
onRespondApproval = viewModel::respondToApproval,
)
}
}
}
entryIndex++
@@ -46,15 +46,20 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
}
messages.forEach { message ->
when (message.role) {
MessageRole.USER -> {
when {
// 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).
message.displayKind != null -> agentEntries += AgentEntry.SystemEvent(message)
message.role == MessageRole.USER -> {
flushAgent()
turns += ChatTurn.User(message)
}
MessageRole.ASSISTANT -> agentEntries += AgentEntry.Prose(message)
MessageRole.TOOL -> agentEntries += AgentEntry.ToolRow(message)
MessageRole.SYSTEM -> agentEntries += AgentEntry.SystemEvent(message)
MessageRole.ASSISTANT == message.role -> agentEntries += AgentEntry.Prose(message)
MessageRole.TOOL == message.role -> agentEntries += AgentEntry.ToolRow(message)
MessageRole.SYSTEM == message.role -> agentEntries += AgentEntry.SystemEvent(message)
}
}
flushAgent()
@@ -0,0 +1,91 @@
package com.m57.hermescontrol.ui.chat.fullbleed
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.ChatMessage
/**
* Friendly label resource for a `display_kind` timeline marker (issue #904).
* Returns `null` for kinds we don't have a label for — the caller falls back
* to the generic system-event rendering (raw content) instead of inventing
* copy for a marker we don't understand.
*/
internal fun timelineMarkerLabelRes(kind: String?): Int? =
when (kind) {
"model_switch" -> R.string.timeline_marker_model_switch
"personality_switch" -> R.string.timeline_marker_personality_switch
"auto_continue" -> R.string.timeline_marker_auto_continue
"async_delegation_complete" -> R.string.timeline_marker_delegation_complete
else -> null
}
/**
* Model name parsed from a model-switch marker's content, e.g.
* `[System: The active model for this chat has changed to gpt-5 via provider
* openai. ...]` → `gpt-5`. The marker text is model-facing scaffolding — the
* chip shows the human-meaningful name instead of the whole block. Interior
* dots are kept (`gpt-4.5`); a sentence-ending dot is stripped.
*/
internal fun markerModelFromContent(content: String): String? {
val raw = MARKER_MODEL_REGEX.find(content)?.groupValues?.getOrNull(1) ?: return null
val model = raw.removeSuffix(".").trim()
return model.takeIf { it.isNotBlank() }
}
private val MARKER_MODEL_REGEX = Regex("""changed to ([A-Za-z0-9_.\-]+)""")
/**
* Centered timeline chip for a `display_kind` marker (issue #904): model
* switches, personality switches and auto-continues render as a small
* divider-style chip instead of a fake user bubble. Known kinds get friendly
* copy; the model-switch chip names the model when the marker content
* carries it.
*/
@Composable
internal fun TimelineMarkerChip(
message: ChatMessage,
modifier: Modifier = Modifier,
) {
val labelRes = timelineMarkerLabelRes(message.displayKind) ?: return
val text =
if (message.displayKind == "model_switch") {
val model = markerModelFromContent(message.content)
if (model != null) {
stringResource(R.string.timeline_marker_model_switch_to, model)
} else {
stringResource(R.string.timeline_marker_model_switch)
}
} else {
stringResource(labelRes)
}
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.testTag("timeline_marker"),
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
)
}
}
}
+5
View File
@@ -968,6 +968,11 @@
<string name="memory_memories_facts">记忆与事实</string>
<string name="memory_recent_events">最近的 Agent 自我改进事件</string>
<string name="message_reasoning_steps">推理 · %1$s 步</string>
<string name="timeline_marker_model_switch">模型已切换</string>
<string name="timeline_marker_model_switch_to">模型已切换至 %1$s</string>
<string name="timeline_marker_personality_switch">人格已切换</string>
<string name="timeline_marker_auto_continue">回合已自动继续</string>
<string name="timeline_marker_delegation_complete">委派任务已完成</string>
<string name="message_your_response">你的回复</string>
<string name="moa_add_preset">添加预设</string>
<string name="moa_add_reference">添加参考模型</string>
+7
View File
@@ -1089,6 +1089,13 @@
<string name="memory_memories_facts">Memories &amp; Facts</string>
<string name="memory_recent_events">Recent Agent Self-Improvement Events</string>
<string name="message_reasoning_steps">Reasoning · %1$s steps</string>
<!-- Timeline markers (issue #904) -->
<string name="timeline_marker_model_switch">Model switched</string>
<string name="timeline_marker_model_switch_to">Model switched to %1$s</string>
<string name="timeline_marker_personality_switch">Personality switched</string>
<string name="timeline_marker_auto_continue">Turn auto-continued</string>
<string name="timeline_marker_delegation_complete">Delegated task completed</string>
<string name="message_your_response">Your response</string>
<string name="moa_add_preset">Add preset</string>
<string name="moa_add_reference">Add reference model</string>
@@ -161,4 +161,36 @@ class ChatMessageMapperTest {
assertEquals("r", roundTripped.reasoningText)
}
@Test
fun roundTripPreservesDisplayKind() {
val ui =
ChatMessage(
id = "msg-4",
role = MessageRole.USER,
content = "[System: ...changed to gpt-5...]",
displayKind = "model_switch",
timestamp = 4000L,
)
val roundTripped = ui.toEntity("s").toUiModel()
assertEquals("model_switch", roundTripped.displayKind)
assertEquals(MessageRole.USER, roundTripped.role)
}
@Test
fun roundTripWithoutDisplayKindStaysNull() {
val ui =
ChatMessage(
id = "msg-5",
role = MessageRole.USER,
content = "plain user text",
timestamp = 5000L,
)
val roundTripped = ui.toEntity("s").toUiModel()
assertNull(roundTripped.displayKind)
}
}
@@ -4,6 +4,7 @@ import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import org.junit.Assert.assertEquals
@@ -420,6 +421,49 @@ class ModelSerializationTest {
assertNull(response.disk?.free_mb)
}
@Test
fun testSessionMessageDeserialization_displayKindMarker() {
val jsonStr =
"""
{
"id": 42,
"role": "user",
"content": "[System: The active model for this chat has changed to gpt-5 via provider openai. From this point forward, use this runtime metadata when answering questions about what model/provider is active.]",
"timestamp": 1755260000000,
"display_kind": "model_switch",
"display_metadata": {
"delegation_id": "dlg-1",
"task_count": 3
}
}
""".trimIndent()
val message = json.decodeFromString<SessionMessage>(jsonStr)
assertEquals(42, message.id)
assertEquals("user", message.role)
assertEquals("model_switch", message.display_kind)
val metadata = message.display_metadata
assertNotNull(metadata)
val delegationId =
(metadata as? JsonObject)?.get("delegation_id") as? JsonPrimitive
assertEquals("dlg-1", delegationId?.content)
}
@Test
fun testSessionMessageDeserialization_missingDisplayKind() {
val jsonStr =
"""
{
"id": 7,
"role": "assistant",
"content": "hi"
}
""".trimIndent()
val message = json.decodeFromString<SessionMessage>(jsonStr)
assertEquals(7, message.id)
assertNull(message.display_kind)
assertNull(message.display_metadata)
}
@Test
fun testStatusResponseDeserialization_typeMismatchInGatewayPlatforms() {
val jsonStr =
@@ -81,6 +81,45 @@ class FullBleedTurnsTest {
assertEquals(listOf(entries(AgentEntry.SystemEvent(s))), result)
}
@Test
fun `display_kind marker is a SystemEvent entry not a user turn`() {
val marker =
msg("m1", MessageRole.USER, content = "[System: The active model has changed to gpt-5]")
.copy(displayKind = "model_switch")
val result = groupIntoTurns(listOf(marker))
assertEquals(listOf<ChatTurn>(entries(AgentEntry.SystemEvent(marker))), result)
}
@Test
fun `marker between assistant and user does not close the agent turn`() {
val a = msg("a1", MessageRole.ASSISTANT)
val marker =
msg("m1", MessageRole.USER, content = "marker")
.copy(displayKind = "personality_switch")
val u = msg("u1", MessageRole.USER)
val result = groupIntoTurns(listOf(a, marker, u))
assertEquals(
listOf(
entries(AgentEntry.Prose(a), AgentEntry.SystemEvent(marker)),
ChatTurn.User(u),
),
result,
)
}
@Test
fun `marker only closes nothing and trailing marker lands in agent turn`() {
val u = msg("u1", MessageRole.USER)
val marker =
msg("m1", MessageRole.USER, content = "marker")
.copy(displayKind = "auto_continue")
val result = groupIntoTurns(listOf(u, marker))
assertEquals(
listOf(ChatTurn.User(u), entries(AgentEntry.SystemEvent(marker))),
result,
)
}
@Test
fun `user message closes the current agent turn`() {
val a1 = msg("a1", MessageRole.ASSISTANT)
@@ -0,0 +1,59 @@
package com.m57.hermescontrol.ui.chat.fullbleed
import com.m57.hermescontrol.R
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class TimelineMarkerTest {
@Test
fun knownKindsMapToLabels() {
assertEquals(R.string.timeline_marker_model_switch, timelineMarkerLabelRes("model_switch"))
assertEquals(
R.string.timeline_marker_personality_switch,
timelineMarkerLabelRes("personality_switch"),
)
assertEquals(R.string.timeline_marker_auto_continue, timelineMarkerLabelRes("auto_continue"))
assertEquals(
R.string.timeline_marker_delegation_complete,
timelineMarkerLabelRes("async_delegation_complete"),
)
}
@Test
fun unknownAndNullKindsHaveNoLabel() {
assertNull(timelineMarkerLabelRes("hidden"))
assertNull(timelineMarkerLabelRes("some_future_kind"))
assertNull(timelineMarkerLabelRes(null))
assertNull(timelineMarkerLabelRes(""))
}
@Test
fun modelParsedFromMarkerContent() {
assertEquals(
"gpt-5",
markerModelFromContent(
"[System: The active model for this chat has changed to gpt-5 via provider openai. ...]",
),
)
assertEquals(
"deepseek-v4",
markerModelFromContent(
"The active model for this chat has changed to deepseek-v4. From this point forward...",
),
)
assertEquals(
"gpt-4.5",
markerModelFromContent(
"The active model for this chat has changed to gpt-4.5 via provider openai.",
),
)
}
@Test
fun modelParseHandlesMissingOrOddContent() {
assertNull(markerModelFromContent(""))
assertNull(markerModelFromContent("[System: personality switched]"))
assertNull(markerModelFromContent("changed to"))
}
}