diff --git a/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/6.json b/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/6.json new file mode 100644 index 00000000..672ec4b8 --- /dev/null +++ b/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/6.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt index ad43b2f9..aa2e4c1e 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt @@ -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, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt index dc603ef0..a7f40f68 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt @@ -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, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt index 9b6c3982..bd366f91 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt @@ -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 } diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt index c03da55c..d256d743 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt index c1b6b81f..21577ca1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt @@ -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, ) /** diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 2cbae12e..b69454c5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -2919,6 +2919,7 @@ class ChatViewModel( attachments = attachments, timestamp = timestamp, isStreaming = false, + displayKind = msg.display_kind, ), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt index d126cc49..c406c49a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt @@ -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++ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt index a60e3346..06a2fcd6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt @@ -46,15 +46,20 @@ fun groupIntoTurns(messages: List): List { } 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() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TimelineMarkerChip.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TimelineMarkerChip.kt new file mode 100644 index 00000000..588dd3bb --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TimelineMarkerChip.kt @@ -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), + ) + } + } +} diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 0ce2665a..34b2470c 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -968,6 +968,11 @@ 记忆与事实 最近的 Agent 自我改进事件 推理 · %1$s 步 + 模型已切换 + 模型已切换至 %1$s + 人格已切换 + 回合已自动继续 + 委派任务已完成 你的回复 添加预设 添加参考模型 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 104f57ff..5413917f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1089,6 +1089,13 @@ Memories & Facts Recent Agent Self-Improvement Events Reasoning · %1$s steps + + + Model switched + Model switched to %1$s + Personality switched + Turn auto-continued + Delegated task completed Your response Add preset Add reference model diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt index 75ccc585..1498858f 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt @@ -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) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt index 8cad9d32..59ad5f36 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt @@ -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(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(jsonStr) + assertEquals(7, message.id) + assertNull(message.display_kind) + assertNull(message.display_metadata) + } + @Test fun testStatusResponseDeserialization_typeMismatchInGatewayPlatforms() { val jsonStr = diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedTurnsTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedTurnsTest.kt index 28595334..c6edd87b 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedTurnsTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedTurnsTest.kt @@ -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(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) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/fullbleed/TimelineMarkerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/fullbleed/TimelineMarkerTest.kt new file mode 100644 index 00000000..cbe3a915 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/fullbleed/TimelineMarkerTest.kt @@ -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")) + } +}