Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
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,
|
val toolStatus: String? = null,
|
||||||
@ColumnInfo(name = "is_streaming")
|
@ColumnInfo(name = "is_streaming")
|
||||||
val isStreaming: Boolean = false,
|
val isStreaming: Boolean = false,
|
||||||
|
@ColumnInfo(name = "display_kind")
|
||||||
|
val displayKind: String? = null,
|
||||||
)
|
)
|
||||||
@@ -34,6 +34,7 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
|
|||||||
"FAILED" -> ToolStatus.FAILED
|
"FAILED" -> ToolStatus.FAILED
|
||||||
else -> null
|
else -> null
|
||||||
},
|
},
|
||||||
|
displayKind = displayKind,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||||
@@ -48,4 +49,5 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
|||||||
toolCallId = toolCallId,
|
toolCallId = toolCallId,
|
||||||
toolStatus = toolStatus?.name,
|
toolStatus = toolStatus?.name,
|
||||||
isStreaming = isStreaming,
|
isStreaming = isStreaming,
|
||||||
|
displayKind = displayKind,
|
||||||
)
|
)
|
||||||
@@ -11,7 +11,7 @@ import java.io.File
|
|||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [ChatMessageEntity::class],
|
entities = [ChatMessageEntity::class],
|
||||||
version = 5,
|
version = 6,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
abstract class HermesDatabase : RoomDatabase() {
|
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
|
instance ?: Room
|
||||||
.databaseBuilder(
|
.databaseBuilder(
|
||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
HermesDatabase::class.java,
|
HermesDatabase::class.java,
|
||||||
"hermes_control.db",
|
"hermes_control.db",
|
||||||
).openHelperFactory(factory)
|
).openHelperFactory(factory)
|
||||||
.addMigrations(migration2to3, migration3to4, migration4to5)
|
.addMigrations(
|
||||||
|
migration2to3,
|
||||||
|
migration3to4,
|
||||||
|
migration4to5,
|
||||||
|
migration5to6,
|
||||||
|
)
|
||||||
.fallbackToDestructiveMigration(false)
|
.fallbackToDestructiveMigration(false)
|
||||||
.build()
|
.build()
|
||||||
.also { instance = it }
|
.also { instance = it }
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ data class SessionMessage(
|
|||||||
val reasoning: JsonElement? = null,
|
val reasoning: JsonElement? = null,
|
||||||
val reasoning_text: JsonElement? = null,
|
val reasoning_text: JsonElement? = null,
|
||||||
val tool_call_id: String? = 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?
|
val timestampText: String?
|
||||||
get() = (timestamp as? JsonPrimitive)?.content
|
get() = (timestamp as? JsonPrimitive)?.content
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ data class ChatMessage(
|
|||||||
* Transient — not persisted to SQLite.
|
* Transient — not persisted to SQLite.
|
||||||
*/
|
*/
|
||||||
val progressPreview: String? = null,
|
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,
|
attachments = attachments,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
|
displayKind = msg.display_kind,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,10 +256,17 @@ fun FullBleedChatList(
|
|||||||
val sysMessage = entry.message
|
val sysMessage = entry.message
|
||||||
item(key = "sys-${sysMessage.id}") {
|
item(key = "sys-${sysMessage.id}") {
|
||||||
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
||||||
FullBleedSystemEvent(
|
if (sysMessage.displayKind != null) {
|
||||||
message = sysMessage,
|
// Timeline marker (issue #904):
|
||||||
onRespondApproval = viewModel::respondToApproval,
|
// model/personality switches and
|
||||||
)
|
// auto-continues render as a chip.
|
||||||
|
TimelineMarkerChip(message = sysMessage)
|
||||||
|
} else {
|
||||||
|
FullBleedSystemEvent(
|
||||||
|
message = sysMessage,
|
||||||
|
onRespondApproval = viewModel::respondToApproval,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entryIndex++
|
entryIndex++
|
||||||
|
|||||||
@@ -46,15 +46,20 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
messages.forEach { message ->
|
messages.forEach { message ->
|
||||||
when (message.role) {
|
when {
|
||||||
MessageRole.USER -> {
|
// 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()
|
flushAgent()
|
||||||
turns += ChatTurn.User(message)
|
turns += ChatTurn.User(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
MessageRole.ASSISTANT -> agentEntries += AgentEntry.Prose(message)
|
MessageRole.ASSISTANT == message.role -> agentEntries += AgentEntry.Prose(message)
|
||||||
MessageRole.TOOL -> agentEntries += AgentEntry.ToolRow(message)
|
MessageRole.TOOL == message.role -> agentEntries += AgentEntry.ToolRow(message)
|
||||||
MessageRole.SYSTEM -> agentEntries += AgentEntry.SystemEvent(message)
|
MessageRole.SYSTEM == message.role -> agentEntries += AgentEntry.SystemEvent(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flushAgent()
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -968,6 +968,11 @@
|
|||||||
<string name="memory_memories_facts">记忆与事实</string>
|
<string name="memory_memories_facts">记忆与事实</string>
|
||||||
<string name="memory_recent_events">最近的 Agent 自我改进事件</string>
|
<string name="memory_recent_events">最近的 Agent 自我改进事件</string>
|
||||||
<string name="message_reasoning_steps">推理 · %1$s 步</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="message_your_response">你的回复</string>
|
||||||
<string name="moa_add_preset">添加预设</string>
|
<string name="moa_add_preset">添加预设</string>
|
||||||
<string name="moa_add_reference">添加参考模型</string>
|
<string name="moa_add_reference">添加参考模型</string>
|
||||||
|
|||||||
@@ -1089,6 +1089,13 @@
|
|||||||
<string name="memory_memories_facts">Memories & Facts</string>
|
<string name="memory_memories_facts">Memories & Facts</string>
|
||||||
<string name="memory_recent_events">Recent Agent Self-Improvement Events</string>
|
<string name="memory_recent_events">Recent Agent Self-Improvement Events</string>
|
||||||
<string name="message_reasoning_steps">Reasoning · %1$s steps</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="message_your_response">Your response</string>
|
||||||
<string name="moa_add_preset">Add preset</string>
|
<string name="moa_add_preset">Add preset</string>
|
||||||
<string name="moa_add_reference">Add reference model</string>
|
<string name="moa_add_reference">Add reference model</string>
|
||||||
|
|||||||
@@ -161,4 +161,36 @@ class ChatMessageMapperTest {
|
|||||||
|
|
||||||
assertEquals("r", roundTripped.reasoningText)
|
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.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.booleanOrNull
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -420,6 +421,49 @@ class ModelSerializationTest {
|
|||||||
assertNull(response.disk?.free_mb)
|
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
|
@Test
|
||||||
fun testStatusResponseDeserialization_typeMismatchInGatewayPlatforms() {
|
fun testStatusResponseDeserialization_typeMismatchInGatewayPlatforms() {
|
||||||
val jsonStr =
|
val jsonStr =
|
||||||
|
|||||||
@@ -81,6 +81,45 @@ class FullBleedTurnsTest {
|
|||||||
assertEquals(listOf(entries(AgentEntry.SystemEvent(s))), result)
|
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
|
@Test
|
||||||
fun `user message closes the current agent turn`() {
|
fun `user message closes the current agent turn`() {
|
||||||
val a1 = msg("a1", MessageRole.ASSISTANT)
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user