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,
|
||||
@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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1089,6 +1089,13 @@
|
||||
<string name="memory_memories_facts">Memories & 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"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user