release: 1.29.4

release: 1.29.4
This commit is contained in:
m57 authored and GitHub committed 2026-09-23 15:51:22 +03:00
commit 45506b3c81
109 files changed
+11212 -1489

No files matched your search

+1
View File
@@ -10,6 +10,7 @@ plugins {
android {
namespace = "com.m57.hermescontrol"
sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas")
lint {
disable += "MissingTranslation"
@@ -0,0 +1,136 @@
{
"formatVersion": 1,
"database": {
"version": 9,
"identityHash": "d3d3b8cd95987e6070d2500269bd052f",
"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, `token_count` INTEGER, `tps` REAL, `completion_id` TEXT, `rest_id` TEXT, `sort_group` INTEGER NOT NULL DEFAULT 1, `sort_order` INTEGER NOT NULL DEFAULT 0, 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"
},
{
"fieldPath": "tokenCount",
"columnName": "token_count",
"affinity": "INTEGER"
},
{
"fieldPath": "tps",
"columnName": "tps",
"affinity": "REAL"
},
{
"fieldPath": "completionId",
"columnName": "completion_id",
"affinity": "TEXT"
},
{
"fieldPath": "restId",
"columnName": "rest_id",
"affinity": "TEXT"
},
{
"fieldPath": "sortGroup",
"columnName": "sort_group",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_chat_messages_session_id_sort_group_sort_order_id",
"unique": false,
"columnNames": [
"session_id",
"sort_group",
"sort_order",
"id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_session_id_sort_group_sort_order_id` ON `${TABLE_NAME}` (`session_id`, `sort_group`, `sort_order`, `id`)"
}
]
}
],
"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, 'd3d3b8cd95987e6070d2500269bd052f')"
]
}
}
@@ -0,0 +1,379 @@
package com.m57.hermescontrol.data.local
import android.content.Context
import androidx.room3.Room
import androidx.sqlite.execSQL
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatPersistenceRepository
import com.m57.hermescontrol.ui.chat.MessageRole
import kotlinx.coroutines.runBlocking
import net.zetetic.database.sqlcipher.driver.SQLCipherDriver
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.util.UUID
/** Executes the generated Room DAO against SQLCipher, rather than reproducing its SQL in a fake. */
@RunWith(AndroidJUnit4::class)
class ChatMessagePagingDeviceTest {
private lateinit var context: Context
private lateinit var database: HermesDatabase
private lateinit var repository: ChatPersistenceRepository
private val databaseName = "paging-regression-${UUID.randomUUID()}.db"
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
System.loadLibrary("sqlcipher")
openDatabase()
}
private fun openDatabase() {
database =
Room
.databaseBuilder(context, HermesDatabase::class.java, databaseName)
.setDriver(driver())
.addMigrations(HermesDatabase.MIGRATION_8_9)
.build()
repository = ChatPersistenceRepository(database.chatMessageDao())
}
@After
fun tearDown() {
database.close()
context.deleteDatabase(databaseName)
}
@Test
fun unpaddedRestIdsSelectNumericLatestPageAndWalkEveryRowExactlyOnce() =
runBlocking {
val rows = (1..350).map { restRow(it, timestamp = 10L) }
repository.persistMessages(rows, "session")
repository.persistMessage(restRow(999, timestamp = 10L).copy(id = "rest-other-999"), "other")
val latest = repository.loadPage("session", null, 150)
assertEquals(rows.takeLast(150).map { it.id }, latest.messages.map { it.id })
repository.persistMessage(restRow(351, timestamp = 1L), "session")
val middle = repository.loadPage("session", latest.cursor, 150)
val oldest = repository.loadPage("session", middle.cursor, 150)
assertEquals(rows.map { it.id }, (oldest.messages + middle.messages + latest.messages).map { it.id })
assertEquals(true, latest.hasOlder)
assertEquals(true, middle.hasOlder)
assertEquals(false, oldest.hasOlder)
assertEquals(
"rest-session-351",
repository
.loadPage("session", null, 1)
.messages
.single()
.id,
)
assertEquals(
"rest-other-999",
repository
.loadPage("other", null, 1)
.messages
.single()
.id,
)
}
@Test
fun nonMonotonicTimestampsKeepServerOrderInBothPageArrivalOrdersAfterReopen() =
runBlocking {
val rows =
listOf(9, 10, 100, 200).mapIndexed { index, id ->
restRow(id, listOf(900L, 100L, 800L, 200L)[index])
}
for (pages in listOf(listOf(rows.take(2), rows.drop(2)), listOf(rows.drop(2), rows.take(2)))) {
repository.clearMessagesForSession("session")
pages.forEach { repository.persistMessages(it, "session") }
database.close()
openDatabase()
val latest = repository.loadPage("session", null, 2)
val older = repository.loadPage("session", latest.cursor, 2)
assertEquals(rows.drop(2).map { it.id }, latest.messages.map { it.id })
assertEquals(rows.take(2).map { it.id }, older.messages.map { it.id })
assertEquals(false, older.hasOlder)
}
}
@Test
fun uuidOnlyRowsKeepInsertionOrderAfterUpdateAppendAndReopen() =
runBlocking {
val rows =
listOf("ffffffff", "00000000", "aaaaaaaa").mapIndexed { index, prefix ->
ChatMessage(
id = "$prefix-0000-4000-8000-000000000000",
role = MessageRole.USER,
content = "message-$index",
timestamp = listOf(900L, 100L, 800L)[index],
)
}
rows.forEach { repository.persistMessage(it, "local") }
repository.persistMessage(rows.first().copy(content = "edited", timestamp = 999L), "local")
database.close()
openDatabase()
val latest = repository.loadPage("local", null, 2)
repository.persistMessage(
rows.last().copy(id = "bbbbbbbb-0000-4000-8000-000000000000", content = "new", timestamp = 1L),
"local",
)
val older = repository.loadPage("local", latest.cursor, 2)
assertEquals(rows.drop(1).map { it.id }, latest.messages.map { it.id })
assertEquals(listOf(rows.first().id), older.messages.map { it.id })
assertEquals("edited", older.messages.single().content)
assertEquals(false, older.hasOlder)
assertEquals(
"new",
repository
.loadPage("local", null, 1)
.messages
.single()
.content,
)
}
@Test
fun migration8to9ValidatesRealSchemaAndPreservesRowsMetadataAndPaging() =
runBlocking {
val canonical =
listOf(9, 10, 100, 200).mapIndexed { index, id ->
restRow(id, listOf(900L, 100L, 800L, 200L)[index]).toEntity("session").copy(
reasoningText = "trace-$id",
toolName = "terminal",
toolCallId = "call-$id",
toolStatus = "COMPLETED",
isStreaming = true,
displayKind = "marker-$id",
tokenCount = id,
tps = 2.5,
completionId = "completion-$id",
)
}
val local =
listOf("ffffffff", "00000000", "aaaaaaaa").mapIndexed { index, prefix ->
ChatMessageEntity(
id = "$prefix-0000-4000-8000-000000000000",
sessionId = "local",
role = "USER",
content = "local-$index",
timestamp = 100L - index,
)
}
val malformed =
listOf(
"rest-session-",
"rest-session-10x",
"rest-other-50",
"rest-session-9223372036854775808",
).map {
ChatMessageEntity(
id = it,
sessionId = "session",
role = "USER",
content = it,
timestamp = 1L,
)
}
// REST pages can be inserted backwards; UUID order is physical insertion, not timestamp or text.
val seeded = canonical.asReversed() + local + malformed
createVersion8(seeded)
openDatabase()
val dao = database.chatMessageDao()
val restored = dao.getMessagesForSession("session") + dao.getMessagesForSession("local")
assertEquals(seeded.size, restored.size)
seeded.forEach { original ->
val row = restored.single { it.id == original.id }
assertEquals(original, row.copy(sortGroup = 1, sortOrder = 0))
}
assertEquals(canonical.map { it.id }, restored.filter { it.sortGroup == 0 }.map { it.id })
assertTrue(restored.filter { it.id in malformed.map { row -> row.id } }.all { it.sortGroup == 1 })
assertEquals(local.map { it.id }, dao.getMessagesForSession("local").map { it.id })
val first = repository.loadPage("session", null, 2)
var cursor = first.cursor
val pages = mutableListOf(first.messages)
var hasOlder = first.hasOlder
while (hasOlder) {
val page = repository.loadPage("session", cursor, 2)
pages += page.messages
cursor = page.cursor
hasOlder = page.hasOlder
}
assertEquals((canonical + malformed).map { it.id }, pages.asReversed().flatten().map { it.id })
repository.persistMessage(local.first().toUiModel().copy(content = "edited", timestamp = 9999L), "local")
database.close()
openDatabase()
assertEquals(local.map { it.id }, repository.loadPage("local", null, 3).messages.map { it.id })
assertEquals(
"edited",
repository
.loadPage("local", null, 3)
.messages
.first()
.content,
)
assertPagingIndexUsed()
}
@Test
fun migration8to9ValidatesEmptyDatabaseAndAllowsNewWrites() =
runBlocking {
createVersion8(emptyList())
openDatabase()
assertTrue(repository.loadPage("session", null, 1).messages.isEmpty())
repository.persistMessage(restRow(100, 1L), "session")
assertEquals(100L, database.chatMessageDao().getMessage("rest-session-100")?.sortOrder)
assertPagingIndexUsed()
}
@Test
fun confirmedAliasPreservesLivePayloadAndCanonicalOrderAcrossReopen() =
runBlocking {
val live =
ChatMessage(
id = "ffffffff-0000-4000-8000-000000000000",
role = MessageRole.ASSISTANT,
content = "newer WS payload",
reasoningText = "live trace",
completionId = "completion-100",
)
repository.persistMessage(live, "session")
repository.confirmIdentities(
listOf(live.copy(content = "stale snapshot", restId = "rest-session-100")),
"session",
)
// A later WS write with no alias must retain the confirmed cache identity.
repository.persistMessage(live.copy(content = "final payload"), "session")
repository.persistMessage(restRow(9, 9999L), "session")
database.close()
openDatabase()
val rows = repository.loadPage("session", null, 2).messages
assertEquals(listOf("rest-session-9", live.id), rows.map { it.id })
assertEquals("rest-session-100", rows.last().restId)
assertEquals("final payload", rows.last().content)
assertEquals("live trace", rows.last().reasoningText)
assertEquals("completion-100", rows.last().completionId)
}
@Test
fun confirmingLastLocalRowDoesNotReuseItsCursorForALaterInsertion() =
runBlocking {
val live =
ChatMessage(
id = "ffffffff-0000-4000-8000-000000000000",
role = MessageRole.ASSISTANT,
content = "first",
)
repository.persistMessage(live, "session")
val cursor = repository.loadPage("session", null, 1).cursor
repository.confirmIdentities(listOf(live.copy(restId = "rest-session-100")), "session")
val next = live.copy(id = "00000000-0000-4000-8000-000000000000", content = "second")
repository.persistMessage(next, "session")
assertFalse(repository.loadPage("session", cursor, 10).messages.any { it.id == next.id })
assertEquals(
next.id,
repository
.loadPage("session", null, 1)
.messages
.single()
.id,
)
}
private fun driver() = SQLCipherDriver("paging-test-only".toByteArray(), null, null)
/** Build the actual exported v8 schema, then let Room run AND validate its migration on open. */
private fun createVersion8(rows: List<ChatMessageEntity>) {
database.close()
context.deleteDatabase(databaseName)
val schema =
InstrumentationRegistry
.getInstrumentation()
.context.assets
.open("com.m57.hermescontrol.data.local.HermesDatabase/8.json")
.bufferedReader()
.use { JSONObject(it.readText()).getJSONObject("database") }
val file = context.getDatabasePath(databaseName)
file.parentFile?.mkdirs()
driver().open(file.absolutePath).use { connection ->
val entity = schema.getJSONArray("entities").getJSONObject(0)
connection.execSQL(
entity.getString("createSql").replace("\${TABLE_NAME}", "chat_messages"),
)
val indices = entity.getJSONArray("indices")
for (index in 0 until indices.length()) {
connection.execSQL(
indices
.getJSONObject(index)
.getString("createSql")
.replace("\${TABLE_NAME}", "chat_messages"),
)
}
val setup = schema.getJSONArray("setupQueries")
for (index in 0 until setup.length()) connection.execSQL(setup.getString(index))
connection
.prepare(
"INSERT INTO chat_messages (id, session_id, role, content, reasoning_text, timestamp, " +
"tool_name, tool_call_id, tool_status, is_streaming, display_kind, " +
"token_count, tps, completion_id) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
).use { insert ->
rows.forEach { row ->
insert.bindText(1, row.id)
insert.bindText(2, row.sessionId)
insert.bindText(3, row.role)
insert.bindText(4, row.content)
insert.bindText(5, row.reasoningText)
insert.bindLong(6, row.timestamp)
row.toolName?.let { insert.bindText(7, it) } ?: insert.bindNull(7)
insert.bindText(8, row.toolCallId)
row.toolStatus?.let { insert.bindText(9, it) } ?: insert.bindNull(9)
insert.bindLong(10, if (row.isStreaming) 1L else 0L)
row.displayKind?.let { insert.bindText(11, it) } ?: insert.bindNull(11)
row.tokenCount?.let { insert.bindLong(12, it.toLong()) } ?: insert.bindNull(12)
row.tps?.let { insert.bindDouble(13, it) } ?: insert.bindNull(13)
row.completionId?.let { insert.bindText(14, it) } ?: insert.bindNull(14)
insert.step()
insert.reset()
insert.clearBindings()
}
}
connection.execSQL("PRAGMA user_version = 8")
}
}
private fun assertPagingIndexUsed() {
database.close()
driver().open(context.getDatabasePath(databaseName).absolutePath).use { connection ->
for (bound in listOf("", "AND (sort_group, sort_order, id) < (1, 100, 'cursor')")) {
connection
.prepare(
"EXPLAIN QUERY PLAN SELECT * FROM chat_messages WHERE session_id = 'session' $bound " +
"ORDER BY sort_group DESC, sort_order DESC, id DESC LIMIT 151",
).use { query ->
val plan = buildList { while (query.step()) add(query.getText(3)) }.joinToString(" ")
assertTrue(plan, plan.contains("index_chat_messages_session_id_sort_group_sort_order_id"))
assertFalse(plan, plan.contains("TEMP B-TREE", ignoreCase = true))
}
}
}
}
private fun restRow(
id: Int,
timestamp: Long,
) = ChatMessage(
id = "rest-session-$id",
role = MessageRole.ASSISTANT,
content = "answer-$id",
timestamp = timestamp,
)
}
@@ -0,0 +1,47 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ChatBackgroundConnectionMenuItemTest {
@get:Rule
val compose = createComposeRule()
@Test
fun menuReflectsStateAndTogglesInBothDirections() {
var enabled by mutableStateOf(false)
compose.setContent {
ChatBackgroundConnectionMenuItem(
checked = enabled,
onToggle = { enabled = it },
)
}
compose.onNodeWithTag("chat_menu_keep_connected").assertIsDisplayed()
compose.onNodeWithTag("chat_menu_keep_connected_checkbox", useUnmergedTree = true).assertIsOff()
compose.onNodeWithTag("chat_menu_keep_connected").performClick()
compose.onNodeWithTag("chat_menu_keep_connected_checkbox", useUnmergedTree = true).assertIsOn()
compose.runOnIdle { assertEquals(true, enabled) }
// The next opening reads the externally changed value rather than a stale menu snapshot.
compose.runOnIdle { enabled = false }
compose.onNodeWithTag("chat_menu_keep_connected_checkbox", useUnmergedTree = true).assertIsOff()
compose.runOnIdle { enabled = true }
compose.onNodeWithTag("chat_menu_keep_connected_checkbox", useUnmergedTree = true).performClick()
compose.onNodeWithTag("chat_menu_keep_connected_checkbox", useUnmergedTree = true).assertIsOff()
compose.runOnIdle { assertEquals(false, enabled) }
}
}
@@ -59,6 +59,9 @@ class ChatScreenTest {
val mockViewModel = mockk<ChatViewModel>(relaxed = true)
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow()
every { mockViewModel.connectionOperationState } returns
MutableStateFlow(ConnectionOperationUiState()).asStateFlow()
// ActionProgressDialog collects the controller's StateFlow — a relaxed
// mock proxy would crash the cast inside collectAsStateWithLifecycle.
// A real controller (never started here) stays invisible.
@@ -105,6 +108,9 @@ class ChatScreenTest {
val mockViewModel = mockk<ChatViewModel>(relaxed = true)
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow()
every { mockViewModel.connectionOperationState } returns
MutableStateFlow(ConnectionOperationUiState()).asStateFlow()
every { mockViewModel.actionProgress } returns
ActionProgressController(scope = CoroutineScope(Dispatchers.Main))
@@ -0,0 +1,95 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.theme.HermesControlTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class FileToolBubbleTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun tappingReadFileBubbleShowsFileViewCardWithSyntaxViewer() {
val payload =
"""
{
"args": {"path": "/repo/src/Main.kt", "offset": 1, "limit": 2},
"result": {"content": "1|fun main() {\n2| println(\"hi\")\n3|}"}
}
""".trimIndent()
showBubble("read_file", payload)
composeRule.onNodeWithText("Read Main.kt L1-2", substring = true).performClick()
composeRule.onNodeWithTag("file_view_card", useUnmergedTree = true).assertExists()
composeRule.onNodeWithText("/repo/src/Main.kt", useUnmergedTree = true).assertExists()
composeRule.onNodeWithText("3 lines", useUnmergedTree = true).assertExists()
}
@Test
fun tappingWriteFileBubbleShowsFileViewCardWhenNoDiff() {
val payload =
"""
{
"args": {
"path": "/repo/src/Config.json",
"content": "{\n \"enabled\": true\n}"
},
"result": {"verified": true, "success": true}
}
""".trimIndent()
showBubble("write_file", payload)
composeRule.onNodeWithText("Config.json", substring = true).performClick()
composeRule.onNodeWithTag("file_view_card", useUnmergedTree = true).assertExists()
composeRule.onNodeWithText("/repo/src/Config.json", useUnmergedTree = true).assertExists()
composeRule.onNodeWithText("3 lines", useUnmergedTree = true).assertExists()
}
@Test
fun tappingWriteFileWithDiffShowsDiffViewCard() {
val payload =
"""
{
"args": {"path": "/repo/src/Utils.kt"},
"result": {
"inline_diff": "--- a/Utils.kt\n+++ b/Utils.kt\n@@ -1,1 +1,1 @@\n-old\n+new"
}
}
""".trimIndent()
showBubble("write_file", payload)
composeRule.onNodeWithText("Utils.kt", substring = true).performClick()
composeRule.onNodeWithTag("diff_view_card", useUnmergedTree = true).assertExists()
composeRule.onNodeWithText("Utils.kt", useUnmergedTree = true).assertExists()
}
private fun showBubble(
name: String,
content: String,
) {
composeRule.setContent {
HermesControlTheme {
ToolBubble(
ChatMessage(
role = MessageRole.TOOL,
content = content,
toolName = name,
toolStatus = ToolStatus.COMPLETED,
),
)
}
}
}
}
@@ -0,0 +1,57 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.theme.HermesControlTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class ToolDiscoveryBubbleTest {
@get:Rule val composeRule = createComposeRule()
@Test
fun tappingSearchBubbleShowsMatchedToolDetails() {
showBubble(
"tool_search",
"""{"name":"tool_search","args":{"queries":["process output"]},"result":{"results":[{"query":"process output","matches":["process_manage"]}],"tools":{"process_manage":{"description":"Poll a background process","source_name":"terminal","required":["action"]}}}}""",
)
composeRule.onNodeWithText("process output (1 match)", substring = true).performClick()
composeRule.onNodeWithText("Poll a background process", substring = true).assertExists()
}
@Test
fun tappingDescribeBubbleShowsSchema() {
showBubble(
"tool_describe",
"""{"name":"tool_describe","args":{"names":["process_manage"]},"result":{"tools":{"process_manage":{"description":"Poll a background process","parameters":{"type":"object","properties":{"action":{"type":"string","description":"Action to take"}},"required":["action"]}}}}}""",
)
composeRule.onNodeWithText("process_manage (1 tool)", substring = true).performClick()
composeRule.onNodeWithText("Action to take", substring = true).assertExists()
}
private fun showBubble(
name: String,
content: String,
) {
composeRule.setContent {
HermesControlTheme {
ToolBubble(
ChatMessage(
role = MessageRole.TOOL,
content = content,
toolName = name,
toolStatus = ToolStatus.COMPLETED,
),
)
}
}
}
}
@@ -0,0 +1,268 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.ConnectionEnvField
import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot
import com.m57.hermescontrol.data.model.ConnectionOperationTarget
import com.m57.hermescontrol.data.model.ConnectionTargetAction
import com.m57.hermescontrol.data.model.ConnectionTargetKind
import com.m57.hermescontrol.data.model.ConnectionTargetState
import com.m57.hermescontrol.ui.chat.ConnectionOperationUiState
import com.m57.hermescontrol.ui.chat.ConnectionPendingAction
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class ConnectionSetupSheetTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun fieldsKeepBackendOrder_defaultsAreSafe_andRequiredInputGatesConnect() {
var response: Triple<String, Map<String, String>, Boolean>? = null
val target =
target(
requiredEnv =
listOf(
env("REGION", required = false, defaultValue = "us-east"),
env(
"API_TOKEN",
required = true,
secret = true,
defaultValue = "issue1218-secret-canary",
),
),
)
composeTestRule.setContent {
ConnectionSetupContent(
state = state(target),
onRespond = { name, values, approved -> response = Triple(name, values, approved) },
onContinue = {},
onOpenBrowser = { _, _ -> },
)
}
val region = composeTestRule.onNodeWithTag("connection_env_REGION")
val token = composeTestRule.onNodeWithTag("connection_env_API_TOKEN")
region.assertIsDisplayed().assertTextContains("us-east")
token.assertIsDisplayed()
composeTestRule.onNodeWithText("issue1218-secret-canary").assertDoesNotExist()
assertTrue(region.fetchSemanticsNode().boundsInRoot.top < token.fetchSemanticsNode().boundsInRoot.top)
composeTestRule.onNodeWithTag("connection_setup_connect").assertIsNotEnabled()
token.performTextInput("typed-secret")
composeTestRule.onNodeWithTag("connection_setup_connect").assertIsEnabled().performClick()
assertEquals("github", response?.first)
assertEquals(mapOf("REGION" to "us-east", "API_TOKEN" to "typed-secret"), response?.second)
assertEquals(true, response?.third)
}
@Test
fun pendingAction_disablesMutatingControls() {
var calls = 0
val operation = operation(target())
composeTestRule.setContent {
ConnectionSetupContent(
state =
ConnectionOperationUiState(
operation = operation,
pendingAction = ConnectionPendingAction.Respond("op-a", "github", true, 1L),
),
onRespond = { _, _, _ -> calls++ },
onContinue = { calls++ },
onOpenBrowser = { _, _ -> calls++ },
)
}
composeTestRule.onNodeWithTag("connection_setup_connect").assertIsNotEnabled()
composeTestRule.onNodeWithTag("connection_setup_skip").assertIsNotEnabled()
composeTestRule.onNodeWithTag("connection_setup_continue").assertIsNotEnabled()
composeTestRule.onNodeWithTag("connection_setup_pending").assertIsDisplayed()
assertEquals(0, calls)
}
@Test
fun browserAction_usesTheActiveUnresolvedTarget() {
var opened: Pair<String, String>? = null
val connected = target(name = "already-done", state = ConnectionTargetState.CONNECTED)
val waiting =
target(
name = "slack",
state = ConnectionTargetState.INITIATED,
connectUrl = "https://example.com/oauth?state=abc",
)
composeTestRule.setContent {
ConnectionSetupContent(
state = ConnectionOperationUiState(operation = operation(connected, waiting)),
onRespond = { _, _, _ -> },
onContinue = {},
onOpenBrowser = { operationId, url -> opened = operationId to url },
)
}
composeTestRule.onNodeWithText("Set up slack").assertIsDisplayed()
composeTestRule.onNodeWithTag("connection_setup_open_browser").performClick()
assertEquals("op-a" to "https://example.com/oauth?state=abc", opened)
}
@Test
fun unsupportedTarget_degradesToSafeSkipAndContinueActions() {
var skipped = false
var continued = false
composeTestRule.setContent {
ConnectionSetupContent(
state = state(target(state = ConnectionTargetState.UNKNOWN)),
onRespond = { _, _, approved -> skipped = !approved },
onContinue = { continued = true },
onOpenBrowser = { _, _ -> },
)
}
composeTestRule.onNodeWithText("This setup step is not supported by this app version.").assertIsDisplayed()
composeTestRule.onNodeWithTag("connection_setup_skip").performClick()
composeTestRule.onNodeWithTag("connection_setup_continue").performClick()
assertTrue(skipped)
assertTrue(continued)
}
@Test
fun authorizedWithoutTools_showsDiscoveryErrorAndContinueOnly() {
composeTestRule.setContent {
ConnectionSetupContent(
state =
state(
target(
state = ConnectionTargetState.CONNECTED,
discoveryError = "No compatible tools were discovered",
),
),
onRespond = { _, _, _ -> },
onContinue = {},
onOpenBrowser = { _, _ -> },
)
}
composeTestRule.onNodeWithText("Authorized; tools unavailable").assertIsDisplayed()
composeTestRule.onNodeWithText("No compatible tools were discovered").assertIsDisplayed()
composeTestRule.onNodeWithTag("connection_setup_skip").assertDoesNotExist()
composeTestRule.onNodeWithTag("connection_setup_continue").assertIsDisplayed()
}
@Test
fun discoveryError_doesNotHideALaterPendingTarget() {
composeTestRule.setContent {
ConnectionSetupContent(
state =
ConnectionOperationUiState(
operation =
operation(
target(
name = "github",
state = ConnectionTargetState.CONNECTED,
discoveryError = "No compatible tools were discovered",
),
target(name = "slack"),
),
),
onRespond = { _, _, _ -> },
onContinue = {},
onOpenBrowser = { _, _ -> },
)
}
composeTestRule.onNodeWithText("Set up slack").assertIsDisplayed()
composeTestRule.onNodeWithText("Authorized; tools unavailable").assertDoesNotExist()
}
@Test
fun resolvedTarget_canStillSettleTheOperation() {
composeTestRule.setContent {
ConnectionSetupContent(
state =
state(
target(
state = ConnectionTargetState.CONNECTED,
tools = listOf("search", "fetch"),
),
),
onRespond = { _, _, _ -> },
onContinue = {},
onOpenBrowser = { _, _ -> },
)
}
composeTestRule.onNodeWithText("This connection step is complete").assertIsDisplayed()
composeTestRule.onNodeWithText("Tools available: 2").assertIsDisplayed()
composeTestRule.onNodeWithTag("connection_setup_continue").assertIsDisplayed()
composeTestRule.onNodeWithTag("connection_setup_skip").assertDoesNotExist()
}
private fun state(target: ConnectionOperationTarget): ConnectionOperationUiState =
ConnectionOperationUiState(operation = operation(target))
private fun operation(vararg targets: ConnectionOperationTarget): ConnectionOperationSnapshot =
ConnectionOperationSnapshot(
sessionId = "session-a",
opId = "op-a",
seq = 1L,
deadlineAt = 2_000_000_000.0,
timeoutSeconds = 300.0,
toolCallId = "tool-a",
settled = false,
settledBy = null,
targets = targets.toList(),
)
private fun target(
name: String = "github",
state: ConnectionTargetState = ConnectionTargetState.PENDING,
connectUrl: String? = null,
discoveryError: String? = null,
requiredEnv: List<ConnectionEnvField> = emptyList(),
tools: List<String> = emptyList(),
): ConnectionOperationTarget =
ConnectionOperationTarget(
name = name,
kind = ConnectionTargetKind.CONNECTOR,
action = ConnectionTargetAction.AUTHORIZE,
state = state,
detail = null,
instructions = null,
discoveryError = discoveryError,
connectUrl = connectUrl,
connectionId = null,
attempt = null,
requiredEnv = requiredEnv,
tools = tools,
hint = null,
)
private fun env(
name: String,
required: Boolean,
secret: Boolean = false,
defaultValue: String? = null,
): ConnectionEnvField =
ConnectionEnvField(
name = name,
required = required,
secret = secret,
defaultValue = defaultValue,
prompt = null,
)
}
@@ -0,0 +1,78 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class FileViewCardTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun shortFile_displaysPathLineCountAndFullContentWithoutExpandButton() {
val content = "fun hello() = println(\"world\")\nval x = 42"
var copiedText = ""
composeTestRule.setContent {
FileViewCard(
content = content,
filePath = "src/main/Test.kt",
onCopy = { copiedText = it },
)
}
composeTestRule.onNodeWithTag("file_view_card").assertIsDisplayed()
composeTestRule.onNodeWithText("src/main/Test.kt").assertIsDisplayed()
composeTestRule.onNodeWithText("2 lines").assertIsDisplayed()
composeTestRule.onNodeWithText("Show full file (2 lines)").assertDoesNotExist()
composeTestRule.onNodeWithTag("file_view_card").assertIsDisplayed()
}
@Test
fun longFile_showsExpandButtonAndTogglesFullFile() {
val lines = (1..25).map { "line $it: println($it)" }
val content = lines.joinToString("\n")
composeTestRule.setContent {
FileViewCard(
content = content,
filePath = "src/Big.kt",
)
}
composeTestRule.onNodeWithText("Show full file (25 lines)").assertIsDisplayed()
composeTestRule.onNodeWithText("Show full file (25 lines)").performClick()
composeTestRule.onNodeWithText("Collapse file").assertIsDisplayed()
composeTestRule.onNodeWithText("Collapse file").performClick()
composeTestRule.onNodeWithText("Show full file (25 lines)").assertIsDisplayed()
}
@Test
fun copyButton_invokesCallbackWithFullContent() {
val content = "1|fun main() = Unit"
var copied = ""
composeTestRule.setContent {
FileViewCard(
content = content,
filePath = "src/App.kt",
onCopy = { copied = it },
)
}
composeTestRule.onNodeWithText("src/App.kt").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Copy file content").performClick()
assertEquals(content, copied)
}
}
@@ -0,0 +1,252 @@
package com.m57.hermescontrol.ui.chat.fullbleed
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatSearchState
import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.StreamingState
import com.m57.hermescontrol.ui.chat.ToolStatus
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/** Real lazy layout regression for the custom23 history prepend jump. */
@RunWith(AndroidJUnit4::class)
class FullBleedChatAnchorTest {
@get:Rule
val compose = createComposeRule()
@Test
fun loadingOnly_atExactTop_keepsContentPixel() = checkAnchor(index = 0, offset = 0, prepend = false)
@Test
fun loadingOnly_atPartialTop_keepsContentPixel() = checkAnchor(index = 0, offset = 23, prepend = false)
@Test
fun prepend_atExactTop_keepsContentPixel() = checkAnchor(index = 0, offset = 0, prepend = true)
@Test
fun prepend_atPartialTop_keepsContentPixel() = checkAnchor(index = 0, offset = 23, prepend = true)
@Test
fun prepend_atInteriorRow_keepsContentPixel() = checkAnchor(index = 8, offset = 17, prepend = true)
@Test
fun prepend_mergingAgentTurn_keepsToolPixel() =
checkAnchor(index = 0, offset = 13, prepend = true, role = MessageRole.TOOL)
@Test
fun loadingOnly_doesNotCancelAnActiveHistoryScroll() =
checkAnchor(index = 8, offset = 17, prepend = false, holdScroll = true)
@Test
fun prepend_usesTheReadersPositionWhenThePageArrives() =
checkAnchor(index = 0, offset = 23, prepend = true, moveWhileLoading = true)
@Test
fun prepend_afterReaderReachesAgentStatus_keepsTailPixel() = checkTailAnchor("agent_status")
@Test
fun prepend_afterReaderReachesClarify_keepsTailPixel() = checkTailAnchor("clarify_bubble")
@Test
fun prepend_afterReaderReachesVaultUnlock_keepsTailPixel() = checkTailAnchor("vault_unlock_card")
@Test
fun prepend_afterReaderReachesVaultSave_keepsTailPixel() = checkTailAnchor("vault_save_login_card")
@Test
fun prepend_afterReaderReachesVaultCode_keepsTailPixel() = checkTailAnchor("vault_code_card")
@Test
fun prepend_duringActiveScroll_keepsGestureAndLiveTail_thenPublishesAtIdle() =
checkAnchor(index = 8, offset = 17, prepend = true, holdScroll = true)
@Test
fun prepend_deferredDuringScroll_doesNotSurviveSessionSwitch() =
checkAnchor(index = 8, offset = 17, prepend = true, holdScroll = true, switchSession = true)
private fun checkTailAnchor(key: String) = checkAnchor(index = 0, offset = 0, prepend = true, tailKey = key)
private fun checkAnchor(
index: Int,
offset: Int,
prepend: Boolean,
role: MessageRole = MessageRole.USER,
holdScroll: Boolean = false,
moveWhileLoading: Boolean = false,
tailKey: String? = null,
switchSession: Boolean = false,
) {
fun message(id: Int) =
ChatMessage(
id = "row-$id",
role = role,
content = "Message $id\nSecond line\nThird line",
toolName = if (role == MessageRole.TOOL) "terminal" else null,
toolStatus = if (role == MessageRole.TOOL) ToolStatus.COMPLETED else null,
)
val messages = mutableStateOf((150..189).map(::message))
val loading = mutableStateOf(false)
val session = mutableStateOf("session-one")
val streaming = mutableStateOf(StreamingState())
val tailKeys =
listOf("agent_status", "clarify_bubble", "vault_unlock_card", "vault_save_login_card", "vault_code_card")
val listState = LazyListState(index, offset)
val viewModel = mockk<ChatViewModel>(relaxed = true)
lateinit var scope: CoroutineScope
compose.setContent {
scope = rememberCoroutineScope()
val controller = rememberChatScrollController(listState, scope)
Box(Modifier.size(width = 320.dp, height = if (tailKey == null) 400.dp else 24.dp)) {
FullBleedChatList(
messages = messages.value,
streamingState = streaming.value,
isAgentTyping = tailKey != null,
searchState = ChatSearchState(),
typingEffectEnabled = false,
typingEffectDelayMs = 30,
isLoading = false,
isLoadingOlder = loading.value,
listState = listState,
scrollController = controller,
lastAnimatedMessageId = null,
onLastAnimatedMessageIdChange = {},
viewModel = viewModel,
pagingSessionId = session.value,
clarifyRequest = if (tailKey != null) ClarifyUi("Choose an option", listOf("Yes")) else null,
vaultUnlockPrompt = if (tailKey != null) VaultUnlockPromptUi(null, null) else null,
vaultSaveLoginPrompt = if (tailKey != null) VaultSaveLoginPromptUi(null, null) else null,
vaultCodePrompt = if (tailKey != null) VaultCodePromptUi(null, null) else null,
)
}
}
var before =
compose.runOnIdle {
listState.layoutInfo.visibleItemsInfo
.first { it.index == index }
.let { it.key to it.offset }
}
fun assertAnchor(stage: String) {
compose.runOnIdle {
val after = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == before.first }
assertNotNull("$stage: anchor ${before.first} left viewport", after)
assertEquals("$stage: anchor ${before.first} moved (pixels)", before.second, after!!.offset)
}
}
val scrollJob =
compose.runOnIdle {
if (holdScroll) {
scope.launch {
listState.scroll(MutatePriority.UserInput) { awaitCancellation() }
}
} else {
null
}
}
compose.runOnIdle {
if (holdScroll) assertTrue("scroll started", listState.isScrollInProgress)
loading.value = true
}
assertAnchor("loading started")
if (tailKey != null) {
compose.runOnIdle { scope.launch { listState.scrollToItem(40 + tailKeys.indexOf(tailKey)) } }
before =
compose.runOnIdle {
listState.layoutInfo.visibleItemsInfo
.first { it.index == listState.firstVisibleItemIndex }
.let { it.key to it.offset }
}
assertEquals("reader reached requested tail row", tailKey, before.first)
}
if (moveWhileLoading) {
compose.runOnIdle { scope.launch { listState.scrollToItem(index + 3, 19) } }
before =
compose.runOnIdle {
listState.layoutInfo.visibleItemsInfo
.first { it.index == listState.firstVisibleItemIndex }
.let { it.key to it.offset }
}
}
compose.runOnIdle {
if (prepend) messages.value = (0..149).map(::message) + messages.value
loading.value = false
}
assertAnchor(if (prepend) "page prepended" else "loading stopped without data")
compose.runOnIdle {
if (holdScroll) {
assertTrue("loading must not cancel the gesture", scrollJob!!.isActive)
assertTrue("scroll still in progress", listState.isScrollInProgress)
}
}
if (holdScroll && prepend) {
compose.runOnIdle {
assertEquals("older prefix waits for idle", 40, listState.layoutInfo.totalItemsCount)
messages.value = messages.value + message(190)
streaming.value =
StreamingState(streamingMessage = ChatMessage("stream", MessageRole.ASSISTANT, "Live answer"))
}
compose.runOnIdle {
assertEquals("new settled and streaming tail remain live", 42, listState.layoutInfo.totalItemsCount)
assertTrue("tail update must not cancel scrolling", scrollJob!!.isActive)
listState.dispatchRawDelta(18f)
}
before =
compose.runOnIdle {
listState.layoutInfo.visibleItemsInfo
.first { it.index == listState.firstVisibleItemIndex }
.let { it.key to it.offset }
}
if (switchSession) {
compose.runOnIdle {
messages.value = (200..239).map(::message)
streaming.value = StreamingState()
session.value = "session-two"
}
compose.runOnIdle {
assertEquals(
"session changes immediately",
40,
listState.layoutInfo.totalItemsCount,
)
}
}
}
compose.runOnIdle { scrollJob?.cancel() }
if (holdScroll && prepend) {
compose.runOnIdle {
assertEquals(
"no lost or stale pending rows",
if (switchSession) 40 else 192,
listState.layoutInfo.totalItemsCount,
)
}
if (!switchSession) assertAnchor("gesture ended after reader moved; prefix published")
}
}
}
@@ -34,12 +34,17 @@ class KanbanScreenTest {
KanbanFilterSheet(
assignees = emptyList(),
tenants = emptyList(),
workflowTemplateIds = emptyList(),
currentStepKeys = emptyList(),
selectedAssignee = null,
selectedTenant = null,
selectedWorkflowTemplateId = null,
selectedCurrentStepKey = null,
includeArchived = false,
groupRunning = false,
onSelectAssignee = {},
onSelectTenant = {},
onSelectWorkflowFilters = { _, _ -> },
onToggleIncludeArchived = { toggled = it },
onToggleGroupRunning = {},
onClearFilters = {},
@@ -0,0 +1,59 @@
package com.m57.hermescontrol.ui.plugins
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.theme.HermesControlTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class PluginCardTest {
@get:Rule val composeRule = createComposeRule()
@Test
fun inactiveUserPluginOffersEnableAndUninstallInsteadOfInstall() {
val viewModel = PluginsViewModel()
composeRule.setContent {
HermesControlTheme {
PluginCard(
plugin = PluginInfo(name = "demo", source = "user", runtimeStatus = "inactive", canRemove = true),
state = PluginsUiState(),
viewModel = viewModel,
onClick = {},
)
}
}
composeRule.onNodeWithText("Install", useUnmergedTree = true).assertDoesNotExist()
composeRule.onNodeWithText("Enable", useUnmergedTree = true).assertExists()
composeRule.onNodeWithText("Uninstall", useUnmergedTree = true).assertExists()
}
@Test
fun bundledPluginNeverShowsUninstall() {
val viewModel = PluginsViewModel()
composeRule.setContent {
HermesControlTheme {
PluginCard(
plugin =
PluginInfo(
name = "builtin",
source = "bundled",
runtimeStatus = "inactive",
canRemove = true,
),
state = PluginsUiState(),
viewModel = viewModel,
onClick = {},
)
}
}
composeRule.onNodeWithText("Uninstall", useUnmergedTree = true).assertDoesNotExist()
}
}
@@ -1,23 +1,83 @@
package com.m57.hermescontrol.data.local
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.Transaction
import androidx.room3.Upsert
@Dao
interface ChatMessageDao {
@Query("SELECT EXISTS(SELECT 1 FROM chat_messages WHERE session_id = :sessionId)")
suspend fun sessionExists(sessionId: String): Boolean
@Query("SELECT * FROM chat_messages WHERE session_id = :sessionId ORDER BY timestamp ASC")
@Query(
"SELECT * FROM chat_messages WHERE session_id = :sessionId " +
"ORDER BY sort_group, sort_order, id",
)
suspend fun getMessagesForSession(sessionId: String): List<ChatMessageEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(message: ChatMessageEntity)
@Query(
"SELECT * FROM chat_messages WHERE session_id = :sessionId " +
"ORDER BY sort_group DESC, sort_order DESC, id DESC LIMIT :limit",
)
suspend fun getLatestMessagePage(
sessionId: String,
limit: Int,
): List<ChatMessageEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(messages: List<ChatMessageEntity>)
@Query(
"SELECT * FROM chat_messages WHERE session_id = :sessionId " +
"AND (sort_group, sort_order, id) < (:beforeGroup, :beforeOrder, :beforeId) " +
"ORDER BY sort_group DESC, sort_order DESC, id DESC LIMIT :limit",
)
suspend fun getMessagePage(
sessionId: String,
beforeGroup: Int,
beforeOrder: Long,
beforeId: String,
limit: Int,
): List<ChatMessageEntity>
@Query("SELECT * FROM chat_messages WHERE id = :id")
suspend fun getMessage(id: String): ChatMessageEntity?
// Physical insertion sequence includes confirmed aliases, so confirmation cannot reuse a local cursor key.
@Query("SELECT COALESCE(MAX(rowid), 0) + 1 FROM chat_messages")
suspend fun nextLocalOrder(): Long
@Upsert
suspend fun writeMessage(message: ChatMessageEntity)
// Allocate once, in the same transaction as the write. Content/clock updates never move a local row.
@Transaction
suspend fun upsert(message: ChatMessageEntity) {
val existing = getMessage(message.id)
val restId = message.restId ?: existing?.restId
val canonicalOrder = canonicalMessageOrder(restId ?: message.id, message.sessionId)
writeMessage(
message.copy(
restId = restId,
completionId = message.completionId ?: existing?.completionId,
sortGroup = if (canonicalOrder != null) 0 else 1,
sortOrder = canonicalOrder ?: existing?.sortOrder ?: nextLocalOrder(),
),
)
}
@Transaction
suspend fun upsertAll(messages: List<ChatMessageEntity>) {
messages.forEach { upsert(it) }
}
// History confirms identity, but must not overwrite a newer WS-owned payload.
@Transaction
suspend fun confirmIdentity(message: ChatMessageEntity) {
val existing = getMessage(message.id)
upsert(
existing?.copy(restId = message.restId, completionId = existing.completionId ?: message.completionId)
?: message,
)
}
@Query("DELETE FROM chat_messages WHERE session_id = :sessionId")
suspend fun deleteMessagesForSession(sessionId: String)
@@ -10,13 +10,13 @@ import androidx.room3.PrimaryKey
* UI model but is stored independently so it survives process death.
*
* Messages are scoped by [sessionId] so switching sessions loads the right
* thread. The [timestamp] field preserves original ordering even if Room
* reorders internally.
* thread. [sortOrder] preserves numeric server order or local insertion order;
* [timestamp] is display metadata only.
*/
@Entity(
tableName = "chat_messages",
indices = [
Index(value = ["session_id", "timestamp"]),
Index(value = ["session_id", "sort_group", "sort_order", "id"]),
],
)
data class ChatMessageEntity(
@@ -48,4 +48,21 @@ data class ChatMessageEntity(
val tps: Double? = null,
@ColumnInfo(name = "completion_id")
val completionId: String? = null,
@ColumnInfo(name = "rest_id")
val restId: String? = null,
@ColumnInfo(name = "sort_group", defaultValue = "1")
val sortGroup: Int = 1,
@ColumnInfo(name = "sort_order", defaultValue = "0")
val sortOrder: Long = 0,
)
/** Only the exact session prefix and a nonnegative decimal suffix identify a canonical row. */
internal fun canonicalMessageOrder(
id: String,
sessionId: String,
): Long? {
val prefix = "rest-$sessionId-"
if (!id.startsWith(prefix)) return null
val suffix = id.removePrefix(prefix)
return suffix.takeIf { it.isNotEmpty() && it.all { char -> char in '0'..'9' } }?.toLongOrNull()
}
@@ -38,6 +38,8 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
tokenCount = tokenCount,
tps = tps,
completionId = completionId,
restId = restId,
localOrder = sortOrder.takeIf { sortGroup == 1 && it > 0 },
)
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
@@ -56,4 +58,5 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
tokenCount = tokenCount,
tps = tps,
completionId = completionId,
restId = restId,
)
@@ -14,7 +14,7 @@ import java.io.File
@Database(
entities = [ChatMessageEntity::class],
version = 8,
version = 9,
exportSchema = true,
)
abstract class HermesDatabase : RoomDatabase() {
@@ -90,6 +90,31 @@ abstract class HermesDatabase : RoomDatabase() {
}
}
val MIGRATION_8_9: Migration =
object : Migration(8, 9) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE chat_messages ADD COLUMN rest_id TEXT")
connection.execSQL("ALTER TABLE chat_messages ADD COLUMN sort_group INTEGER NOT NULL DEFAULT 1")
connection.execSQL("ALTER TABLE chat_messages ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0")
// v8 has no durable local sequence. Preserve its surviving physical insertion order.
connection.execSQL("UPDATE chat_messages SET sort_order = rowid")
val suffix = "substr(id, length(session_id) + 7)"
val digits = "ltrim($suffix, '0')"
connection.execSQL(
"UPDATE chat_messages SET sort_group = 0, sort_order = CAST($suffix AS INTEGER) " +
"WHERE substr(id, 1, length(session_id) + 6) = 'rest-' || session_id || '-' " +
"AND $suffix != '' AND $suffix NOT GLOB '*[^0-9]*' " +
"AND (length($digits) < 19 OR " +
"(length($digits) = 19 AND $digits <= '9223372036854775807'))",
)
connection.execSQL("DROP INDEX index_chat_messages_session_id_timestamp")
connection.execSQL(
"CREATE INDEX index_chat_messages_session_id_sort_group_sort_order_id " +
"ON chat_messages (session_id, sort_group, sort_order, id)",
)
}
}
suspend fun get(context: Context): HermesDatabase =
withContext(Dispatchers.IO) {
instance?.let { return@withContext it }
@@ -126,6 +151,7 @@ abstract class HermesDatabase : RoomDatabase() {
MIGRATION_5_6,
MIGRATION_6_7,
MIGRATION_7_8,
MIGRATION_8_9,
).fallbackToDestructiveMigration(false)
.build()
.also { instance = it }
@@ -0,0 +1,75 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.util.ConnectorUrlValidator
/** Backend-owned connector setup operation snapshot. Secrets are intentionally absent. */
data class ConnectionOperationSnapshot(
val sessionId: String?,
val opId: String,
val seq: Long,
val deadlineAt: Double,
val timeoutSeconds: Double?,
val toolCallId: String?,
val settled: Boolean,
val settledBy: String?,
val targets: List<ConnectionOperationTarget>,
)
data class ConnectionOperationTarget(
val name: String,
val kind: ConnectionTargetKind,
val action: ConnectionTargetAction,
val state: ConnectionTargetState,
val detail: String?,
val instructions: String?,
val discoveryError: String?,
val connectUrl: String?,
val connectionId: String?,
val attempt: String?,
val requiredEnv: List<ConnectionEnvField>,
val tools: List<String>,
val hint: String?,
) {
/** Safe validated browser URL. OAuth query parameters stay redacted from [toString]. */
val safeConnectUrl: String?
get() = connectUrl?.takeIf(ConnectorUrlValidator::isValidHttpsUrl)
override fun toString(): String =
"ConnectionOperationTarget(" +
"name=$name, kind=$kind, action=$action, state=$state, detail=$detail, " +
"instructions=$instructions, discoveryError=$discoveryError, " +
"connectUrl=${if (connectUrl == null) "null" else "[REDACTED]"}, " +
"connectionId=$connectionId, attempt=$attempt, requiredEnv=$requiredEnv, " +
"tools=$tools, hint=$hint)"
}
data class ConnectionEnvField(
val name: String,
val required: Boolean,
val secret: Boolean,
val defaultValue: String?,
val prompt: String?,
)
enum class ConnectionTargetKind { CONNECTOR, MCP, UNKNOWN }
enum class ConnectionTargetAction {
AUTHORIZE,
CONNECT,
ENABLE,
INSTALL,
RECONNECT,
UNKNOWN,
}
enum class ConnectionTargetState {
PENDING,
INITIATED,
CONNECTED,
SKIPPED,
FAILED,
EXPIRED,
UNAVAILABLE,
NOT_CONNECTED,
UNKNOWN,
}
@@ -22,8 +22,14 @@ data class KanbanDetailEvent(
@Serializable
data class KanbanRun(
val id: Long,
val taskId: String? = null,
val profile: String? = null,
val stepKey: String? = null,
val status: String,
val claimLock: String? = null,
val claimExpires: Long? = null,
val maxRuntimeSeconds: Int? = null,
val lastHeartbeatAt: Long? = null,
val outcome: String? = null,
val summary: String? = null,
val error: String? = null,
@@ -59,3 +65,71 @@ data class KanbanAttachment(
data class AttachmentUploadResponse(
val attachment: KanbanAttachment? = null,
)
@Serializable
data class AttachmentListResponse(
val attachments: List<KanbanAttachment> = emptyList(),
)
@Serializable
data class DeleteAttachmentResponse(
val ok: Boolean,
val id: Long,
)
@Serializable
data class KanbanRunResponse(
val run: KanbanRun,
)
@Serializable
data class KanbanRunInspection(
val runId: Long,
val alive: Boolean,
val pid: Int? = null,
val reason: String? = null,
val cpuPercent: Double? = null,
val memoryRssBytes: Long? = null,
val memoryVmsBytes: Long? = null,
val numThreads: Int? = null,
val numFds: Int? = null,
val status: String? = null,
val createTime: Double? = null,
val cmdline: List<String>? = null,
val error: String? = null,
)
@Serializable
data class TerminateRunBody(
val reason: String? = null,
)
@Serializable
data class TerminateRunResponse(
val ok: Boolean,
val runId: Long,
val taskId: String,
)
@Serializable
data class KanbanActiveWorker(
val runId: Long,
val taskId: String,
val taskTitle: String,
val taskStatus: String,
val taskAssignee: String? = null,
val profile: String? = null,
val workerPid: Int,
val startedAt: Long? = null,
val claimLock: String? = null,
val claimExpires: Long? = null,
val lastHeartbeatAt: Long? = null,
val maxRuntimeSeconds: Int? = null,
)
@Serializable
data class ActiveWorkersResponse(
val workers: List<KanbanActiveWorker> = emptyList(),
val count: Int = workers.size,
val checkedAt: Long,
)
@@ -48,6 +48,18 @@ data class TaskLinks(
val children: List<String> = emptyList(),
)
@Serializable
data class TaskLinkBody(
val parentId: String,
val childId: String,
)
@Serializable
data class TaskLinkResponse(
val ok: Boolean,
val gated: Boolean = false,
)
@Serializable
data class ChildTaskResult(
val id: String,
@@ -75,6 +87,9 @@ data class KanbanTask(
val startedAt: Long? = null,
val workerPid: Int? = null,
val lastHeartbeatAt: Long? = null,
val currentRunId: Long? = null,
val workflowTemplateId: String? = null,
val currentStepKey: String? = null,
) {
val description: String? get() = body
val assignedTo: String? get() = assignee
@@ -112,6 +127,10 @@ data class KanbanTaskFull(
val startedAt: Long? = null,
val workerPid: Int? = null,
val lastHeartbeatAt: Long? = null,
val currentRunId: Long? = null,
val workflowTemplateId: String? = null,
val currentStepKey: String? = null,
val maxRuntimeSeconds: Int? = null,
val result: String? = null,
val createdBy: String? = null,
val modelOverride: String? = null,
@@ -245,6 +264,19 @@ data class ReclaimTaskResponse(
val taskId: String,
)
@Serializable
data class SpecifyTaskBody(
val author: String? = null,
)
@Serializable
data class SpecifyTaskResponse(
val ok: Boolean,
val taskId: String,
val reason: String? = null,
val newTitle: String? = null,
)
@Serializable
data class TaskEstimate(
val ok: Boolean,
@@ -27,4 +27,5 @@ data class ModelInfoResponse(
val config_context_length: Long? = null,
/** Effective context window actually in use (max of auto/config + caps). */
val effective_context_length: Long? = null,
val capabilities: ModelCapabilities? = null,
)
@@ -26,8 +26,60 @@ data class ModelCapabilities(
val fast: Boolean? = null,
val reasoning: Boolean? = null,
val can_disable_reasoning: Boolean? = null,
val supports_tools: Boolean? = null,
val supports_vision: Boolean? = null,
val supports_reasoning: Boolean? = null,
val context_window: Long? = null,
val max_output_tokens: Long? = null,
val model_family: String? = null,
)
val ModelCapabilities.reasoningSupport: Boolean?
get() = supports_reasoning ?: reasoning
fun ModelCapabilities?.mergedWithCanonical(canonical: ModelCapabilities?): ModelCapabilities? {
if (canonical == null) return this
val catalog = this
return ModelCapabilities(
fast = catalog?.fast,
reasoning = catalog?.reasoning,
can_disable_reasoning = catalog?.can_disable_reasoning,
supports_tools = canonical.supports_tools ?: catalog?.supports_tools,
supports_vision = canonical.supports_vision ?: catalog?.supports_vision,
supports_reasoning = canonical.supports_reasoning ?: catalog?.supports_reasoning,
context_window = canonical.context_window ?: catalog?.context_window,
max_output_tokens = canonical.max_output_tokens ?: catalog?.max_output_tokens,
model_family = canonical.model_family ?: catalog?.model_family,
)
}
fun List<ModelProvider>.withModelInfoCapabilities(info: ModelInfoResponse?): List<ModelProvider> {
val providerSlug = info?.provider?.takeIf { it.isNotBlank() } ?: return this
val modelName = info.model?.takeIf { it.isNotBlank() } ?: return this
val canonical = info.capabilities ?: return this
var changed = false
val enriched =
map { provider ->
if (
provider.slug.equals(providerSlug, ignoreCase = true) &&
provider.models.orEmpty().contains(modelName)
) {
changed = true
provider.copy(
capabilities =
provider.capabilities.orEmpty() +
(
modelName to
(provider.capabilities?.get(modelName).mergedWithCanonical(canonical) ?: canonical)
),
)
} else {
provider
}
}
return if (changed) enriched else this
}
@Serializable
data class PinnedModel(
val providerSlug: String,
@@ -24,6 +24,9 @@ data class PluginInfo(
get() =
runtimeStatus.equals("enabled", ignoreCase = true) ||
runtimeStatus.equals("disabled", ignoreCase = true)
val removable: Boolean
get() = canRemove && (source == "user" || source == "git")
}
@Serializable
@@ -42,6 +42,12 @@ data class SessionMessage(
val reasoning: JsonElement? = null,
val reasoning_text: JsonElement? = null,
val tool_call_id: String? = null,
/**
* User-visible projection supplied by the backend for rows whose physical
* [content] is model-facing scaffolding (notably compaction summaries).
* Prefer this for rendering whenever present.
*/
val display_content: JsonElement? = null,
/**
* Timeline-marker tag (backend NS-656 lineage, issue #904): markers like
* `model_switch` / `personality_switch` / `auto_continue` ride as
@@ -85,6 +91,14 @@ data class SessionMessage(
else -> content.toString()
}
val displayContentText: String?
get() =
when (display_content) {
is JsonPrimitive -> display_content.content
null -> null
else -> display_content.toString()
}
val reasoningText: String
get() =
when (val r = reasoning ?: reasoning_text) {
@@ -0,0 +1,53 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/** Lightweight prompt index returned by GET /api/sessions/{id}/timeline. */
@Serializable
data class SessionTimelineResponse(
val session_id: String? = null,
val profile: String? = null,
val entries: List<SessionTimelineEntry> = emptyList(),
val pagination: SessionTimelinePagination = SessionTimelinePagination(),
)
@Serializable
data class SessionTimelineEntry(
/** Stable representative row id. Pass this exact value to /messages/around. */
val row_id: Int,
val preview: String = "",
val timestamp: JsonElement? = null,
)
@Serializable
data class SessionTimelinePagination(
val limit: Int? = null,
val after_row_id: Int? = null,
val returned: Int? = null,
val total: Int? = null,
val has_more: Boolean = false,
/** Backend cursor. It is not interchangeable with the last returned row_id. */
val next_cursor: Int? = null,
)
/** Bounded chronological display page returned by GET /api/sessions/{id}/messages/around. */
@Serializable
data class SessionMessagesAroundResponse(
val session_id: String? = null,
val profile: String? = null,
val messages: List<SessionMessage> = emptyList(),
val pagination: SessionMessagesAroundPagination = SessionMessagesAroundPagination(),
)
@Serializable
data class SessionMessagesAroundPagination(
val row_id: Int? = null,
val limit: Int? = null,
val returned: Int? = null,
val order: String? = null,
val offset: Int = 0,
val total: Int? = null,
val has_older: Boolean = false,
val has_newer: Boolean = false,
)
@@ -101,10 +101,12 @@ import com.m57.hermescontrol.data.model.ScanStatus
import com.m57.hermescontrol.data.model.SessionDetailResponse
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionListResponse
import com.m57.hermescontrol.data.model.SessionMessagesAroundResponse
import com.m57.hermescontrol.data.model.SessionMessagesResponse
import com.m57.hermescontrol.data.model.SessionRenameRequest
import com.m57.hermescontrol.data.model.SessionSearchResponse
import com.m57.hermescontrol.data.model.SessionStatsResponse
import com.m57.hermescontrol.data.model.SessionTimelineResponse
import com.m57.hermescontrol.data.model.SetActiveProfileRequest
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.SkillContentResponse
@@ -208,6 +210,22 @@ interface HermesApiService : KanbanApiService {
@Query("profile") profile: String? = null,
): Response<SessionMessagesResponse>
@GET("api/sessions/{id}/timeline")
suspend fun getSessionTimeline(
@Path("id", encoded = true) sessionId: String,
@Query("profile") profile: String? = null,
@Query("limit") limit: Int = 500,
@Query("after_row_id") afterRowId: Int = 0,
): Response<SessionTimelineResponse>
@GET("api/sessions/{id}/messages/around")
suspend fun getSessionMessagesAround(
@Path("id", encoded = true) sessionId: String,
@Query("row_id") rowId: Int,
@Query("profile") profile: String? = null,
@Query("limit") limit: Int = 120,
): Response<SessionMessagesAroundResponse>
@GET("api/sessions/stats")
suspend fun getSessionStats(): Response<SessionStatsResponse>
@@ -281,7 +299,9 @@ interface HermesApiService : KanbanApiService {
): Response<SessionInfo>
@GET("api/model/info")
suspend fun getModelInfo(): Response<ModelInfoResponse>
suspend fun getModelInfo(
@Query("profile") profile: String? = null,
): Response<ModelInfoResponse>
@GET("api/system/stats")
suspend fun getSystemStats(): Response<SystemStatsResponse>
@@ -645,11 +665,6 @@ interface HermesApiService : KanbanApiService {
@Body body: AgentPluginInstallBody,
): Response<Unit>
@DELETE("api/dashboard/agent-plugins/{name}")
suspend fun uninstallPlugin(
@Path("name", encoded = true) name: String,
): Response<Unit>
@POST("api/dashboard/agent-plugins/{name}/update")
suspend fun updatePlugin(
@Path("name", encoded = true) name: String,
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.model.ActiveWorkersResponse
import com.m57.hermescontrol.data.model.AttachmentListResponse
import com.m57.hermescontrol.data.model.AttachmentUploadResponse
import com.m57.hermescontrol.data.model.AutoDescribeResponse
import com.m57.hermescontrol.data.model.BoardExportResult
@@ -10,6 +12,7 @@ import com.m57.hermescontrol.data.model.CreateBoardBody
import com.m57.hermescontrol.data.model.CreateBoardResponse
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateTaskResponse
import com.m57.hermescontrol.data.model.DeleteAttachmentResponse
import com.m57.hermescontrol.data.model.DeleteBoardResponse
import com.m57.hermescontrol.data.model.DispatchResult
import com.m57.hermescontrol.data.model.ExportBoardBody
@@ -18,6 +21,8 @@ import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanProfilesResponse
import com.m57.hermescontrol.data.model.KanbanProjectsResponse
import com.m57.hermescontrol.data.model.KanbanRunInspection
import com.m57.hermescontrol.data.model.KanbanRunResponse
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.OrchestrationSettings
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
@@ -27,7 +32,13 @@ import com.m57.hermescontrol.data.model.ReclaimTaskBody
import com.m57.hermescontrol.data.model.ReclaimTaskResponse
import com.m57.hermescontrol.data.model.RenameBoardBody
import com.m57.hermescontrol.data.model.RenameBoardResponse
import com.m57.hermescontrol.data.model.SpecifyTaskBody
import com.m57.hermescontrol.data.model.SpecifyTaskResponse
import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.data.model.TaskLinkBody
import com.m57.hermescontrol.data.model.TaskLinkResponse
import com.m57.hermescontrol.data.model.TerminateRunBody
import com.m57.hermescontrol.data.model.TerminateRunResponse
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.model.WorkerLog
@@ -57,6 +68,8 @@ interface KanbanApiService {
@Query("board") board: String? = null,
@Query("include_archived") includeArchived: Boolean = false,
@Query("tenant") tenant: String? = null,
@Query("workflow_template_id") workflowTemplateId: String? = null,
@Query("current_step_key") currentStepKey: String? = null,
): Response<KanbanBoardResponse>
@POST("api/plugins/kanban/boards")
@@ -96,6 +109,8 @@ interface KanbanApiService {
suspend fun getTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
@Query("run_state_type") runStateType: String? = null,
@Query("run_state_name") runStateName: String? = null,
): Response<KanbanTaskDetailResponse>
@POST("api/plugins/kanban/tasks")
@@ -170,6 +185,12 @@ interface KanbanApiService {
@Part file: MultipartBody.Part,
): Response<AttachmentUploadResponse>
@GET("api/plugins/kanban/tasks/{id}/attachments")
suspend fun listAttachments(
@Path("id") taskId: String,
@Query("board") board: String? = null,
): Response<AttachmentListResponse>
@Streaming
@GET("api/plugins/kanban/attachments/{id}")
suspend fun downloadAttachment(
@@ -177,6 +198,49 @@ interface KanbanApiService {
@Query("board") board: String? = null,
): Response<ResponseBody>
@DELETE("api/plugins/kanban/attachments/{id}")
suspend fun deleteAttachment(
@Path("id") attachmentId: Long,
@Query("board") board: String? = null,
): Response<DeleteAttachmentResponse>
@GET("api/plugins/kanban/workers/active")
suspend fun getActiveWorkers(
@Query("board") board: String? = null,
): Response<ActiveWorkersResponse>
@GET("api/plugins/kanban/runs/{id}")
suspend fun getRun(
@Path("id") runId: Long,
@Query("board") board: String? = null,
): Response<KanbanRunResponse>
@GET("api/plugins/kanban/runs/{id}/inspect")
suspend fun inspectRun(
@Path("id") runId: Long,
@Query("board") board: String? = null,
): Response<KanbanRunInspection>
@POST("api/plugins/kanban/runs/{id}/terminate")
suspend fun terminateRun(
@Path("id") runId: Long,
@Query("board") board: String? = null,
@Body body: TerminateRunBody = TerminateRunBody(),
): Response<TerminateRunResponse>
@POST("api/plugins/kanban/tasks/{id}/specify")
suspend fun specifyTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
@Body body: SpecifyTaskBody = SpecifyTaskBody(),
): Response<SpecifyTaskResponse>
@POST("api/plugins/kanban/links")
suspend fun createTaskLink(
@Query("board") board: String? = null,
@Body body: TaskLinkBody,
): Response<TaskLinkResponse>
@GET("api/plugins/kanban/profiles")
suspend fun getKanbanProfiles(): Response<KanbanProfilesResponse>
@@ -18,18 +18,16 @@ import okhttp3.Response
* - If no profile is active, pass through unchanged (legacy "default" behavior).
* - If the URL already carries an explicit `profile=` query, leave it
* untouched — explicit beats global.
* - Only the endpoint families below honor `?profile=` on the backend;
* everything else (ops, pairing, profiles themselves) is machine-global
* or self-scoped and must NOT be rewritten. Cron, sessions and plugins
* are profile-scoped on the backend (`/api/cron/jobs?profile=`,
* `/api/sessions?profile=`, plugin REST) and ARE rewritten here so
* switching profiles actually switches what the app shows.
* - Only backend-confirmed profile-aware endpoints are rewritten. Ops is
* intentionally classified route-by-route instead of matching `/api/ops`
* wholesale, so future machine-global operations stay untouched.
*/
object ProfileScopeInterceptor : Interceptor {
private val PROFILE_SCOPED_PREFIXES =
listOf(
"/api/analytics",
"/api/config",
"/api/credentials/pool",
"/api/cron",
"/api/env",
"/api/gateway",
@@ -48,6 +46,28 @@ object ProfileScopeInterceptor : Interceptor {
"/api/skills",
"/api/status",
"/api/tools/toolsets",
"/api/webhooks",
)
/**
* Mobile-used Ops handlers that accept `?profile=` in Hermes Agent.
*
* Keep this explicit rather than adding `/api/ops`: these routes are
* profile-owned today, while the family itself is not a blanket scoping
* contract. See `hermes_cli/web_routers/ops.py` and `status.py`.
*/
private val PROFILE_SCOPED_OPS_PREFIXES =
listOf(
"/api/ops/backup",
"/api/ops/checkpoints",
"/api/ops/config-migrate",
"/api/ops/debug-share",
"/api/ops/doctor",
"/api/ops/dump",
"/api/ops/hooks",
"/api/ops/import-upload",
"/api/ops/prompt-size",
"/api/ops/security-audit",
)
/**
@@ -55,8 +75,14 @@ object ProfileScopeInterceptor : Interceptor {
* e.g. `/api/status` or `/api/status/health`, but NOT `/api/statusXYZ`
* or `/api/gatewayExtra` (Sourcery review, PR #540).
*/
private fun matchesPrefix(
path: String,
prefix: String,
): Boolean = path == prefix || path.startsWith("$prefix/")
private fun isProfileScopedPath(path: String): Boolean =
PROFILE_SCOPED_PREFIXES.any { prefix -> path == prefix || path.startsWith("$prefix/") }
PROFILE_SCOPED_PREFIXES.any { prefix -> matchesPrefix(path, prefix) } ||
PROFILE_SCOPED_OPS_PREFIXES.any { prefix -> matchesPrefix(path, prefix) }
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.repository
import com.m57.hermescontrol.data.model.ActiveWorkersResponse
import com.m57.hermescontrol.data.model.AttachmentListResponse
import com.m57.hermescontrol.data.model.AttachmentUploadResponse
import com.m57.hermescontrol.data.model.AutoDescribeResponse
import com.m57.hermescontrol.data.model.BoardExportResult
@@ -10,6 +12,7 @@ import com.m57.hermescontrol.data.model.CreateBoardBody
import com.m57.hermescontrol.data.model.CreateBoardResponse
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateTaskResponse
import com.m57.hermescontrol.data.model.DeleteAttachmentResponse
import com.m57.hermescontrol.data.model.DeleteBoardResponse
import com.m57.hermescontrol.data.model.DispatchResult
import com.m57.hermescontrol.data.model.ExportBoardBody
@@ -18,6 +21,8 @@ import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanProfilesResponse
import com.m57.hermescontrol.data.model.KanbanProjectsResponse
import com.m57.hermescontrol.data.model.KanbanRunInspection
import com.m57.hermescontrol.data.model.KanbanRunResponse
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.OrchestrationSettings
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
@@ -27,7 +32,13 @@ import com.m57.hermescontrol.data.model.ReclaimTaskBody
import com.m57.hermescontrol.data.model.ReclaimTaskResponse
import com.m57.hermescontrol.data.model.RenameBoardBody
import com.m57.hermescontrol.data.model.RenameBoardResponse
import com.m57.hermescontrol.data.model.SpecifyTaskBody
import com.m57.hermescontrol.data.model.SpecifyTaskResponse
import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.data.model.TaskLinkBody
import com.m57.hermescontrol.data.model.TaskLinkResponse
import com.m57.hermescontrol.data.model.TerminateRunBody
import com.m57.hermescontrol.data.model.TerminateRunResponse
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.model.WorkerLog
@@ -48,6 +59,8 @@ interface KanbanRepository {
board: String,
includeArchived: Boolean = false,
tenant: String? = null,
workflowTemplateId: String? = null,
currentStepKey: String? = null,
): NetworkResult<KanbanBoardResponse>
suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse>
@@ -72,6 +85,8 @@ interface KanbanRepository {
suspend fun getTask(
taskId: String,
board: String?,
runStateType: String? = null,
runStateName: String? = null,
): NetworkResult<KanbanTaskDetailResponse>
suspend fun createTask(
@@ -136,11 +151,49 @@ interface KanbanRepository {
file: MultipartBody.Part,
): NetworkResult<AttachmentUploadResponse>
suspend fun listAttachments(
taskId: String,
board: String,
): NetworkResult<AttachmentListResponse>
suspend fun deleteAttachment(
attachmentId: Long,
board: String,
): NetworkResult<DeleteAttachmentResponse>
suspend fun downloadAttachment(
attachmentId: Long,
board: String?,
): NetworkResult<ResponseBody>
suspend fun getActiveWorkers(board: String): NetworkResult<ActiveWorkersResponse>
suspend fun getRun(
runId: Long,
board: String,
): NetworkResult<KanbanRunResponse>
suspend fun inspectRun(
runId: Long,
board: String,
): NetworkResult<KanbanRunInspection>
suspend fun terminateRun(
runId: Long,
board: String,
): NetworkResult<TerminateRunResponse>
suspend fun specifyTask(
taskId: String,
board: String,
): NetworkResult<SpecifyTaskResponse>
suspend fun createTaskLink(
board: String,
parentId: String,
childId: String,
): NetworkResult<TaskLinkResponse>
suspend fun getProfiles(): NetworkResult<KanbanProfilesResponse>
suspend fun updateProfileDescription(
@@ -173,9 +226,11 @@ class KanbanRepositoryImpl(
board: String,
includeArchived: Boolean,
tenant: String?,
workflowTemplateId: String?,
currentStepKey: String?,
): NetworkResult<KanbanBoardResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getBoard(board = board, includeArchived = includeArchived, tenant = tenant) }
safeApiCall { apiProvider().getBoard(board, includeArchived, tenant, workflowTemplateId, currentStepKey) }
}
override suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse> =
@@ -215,9 +270,11 @@ class KanbanRepositoryImpl(
override suspend fun getTask(
taskId: String,
board: String?,
runStateType: String?,
runStateName: String?,
): NetworkResult<KanbanTaskDetailResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getTask(taskId = taskId, board = board) }
safeApiCall { apiProvider().getTask(taskId, board, runStateType, runStateName) }
}
override suspend fun createTask(
@@ -364,6 +421,18 @@ class KanbanRepositoryImpl(
safeApiCall { apiProvider().uploadAttachment(taskId = taskId, board = board, file = file) }
}
override suspend fun listAttachments(
taskId: String,
board: String,
): NetworkResult<AttachmentListResponse> =
withContext(ioDispatcher) { safeApiCall { apiProvider().listAttachments(taskId, board) } }
override suspend fun deleteAttachment(
attachmentId: Long,
board: String,
): NetworkResult<DeleteAttachmentResponse> =
withContext(ioDispatcher) { safeApiCall { apiProvider().deleteAttachment(attachmentId, board) } }
override suspend fun downloadAttachment(
attachmentId: Long,
board: String?,
@@ -372,6 +441,48 @@ class KanbanRepositoryImpl(
safeApiCall { apiProvider().downloadAttachment(attachmentId = attachmentId, board = board) }
}
override suspend fun getActiveWorkers(board: String): NetworkResult<ActiveWorkersResponse> =
withContext(ioDispatcher) { safeApiCall { apiProvider().getActiveWorkers(board) } }
override suspend fun getRun(
runId: Long,
board: String,
): NetworkResult<KanbanRunResponse> =
withContext(ioDispatcher) { safeApiCall { apiProvider().getRun(runId, board) } }
override suspend fun inspectRun(
runId: Long,
board: String,
): NetworkResult<KanbanRunInspection> =
withContext(ioDispatcher) { safeApiCall { apiProvider().inspectRun(runId, board) } }
override suspend fun terminateRun(
runId: Long,
board: String,
): NetworkResult<TerminateRunResponse> =
withContext(ioDispatcher) {
val result = safeApiCall { apiProvider().terminateRun(runId, board, TerminateRunBody(reason = "mobile")) }
if (result is NetworkResult.Success && result.data.ok) nudger.scheduleNudge(board)
result
}
override suspend fun specifyTask(
taskId: String,
board: String,
): NetworkResult<SpecifyTaskResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().specifyTask(taskId, board, SpecifyTaskBody(author = "mobile")) }
}
override suspend fun createTaskLink(
board: String,
parentId: String,
childId: String,
): NetworkResult<TaskLinkResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().createTaskLink(board, TaskLinkBody(parentId, childId)) }
}
override suspend fun getProfiles(): NetworkResult<KanbanProfilesResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getKanbanProfiles() }
@@ -0,0 +1,114 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ConnectionEnvField
import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot
import com.m57.hermescontrol.data.model.ConnectionOperationTarget
import com.m57.hermescontrol.data.model.ConnectionTargetAction
import com.m57.hermescontrol.data.model.ConnectionTargetKind
import com.m57.hermescontrol.data.model.ConnectionTargetState
/** Tolerant parser for backend-owned connector operation snapshots. */
object ConnectionOperationParser {
fun parse(
payload: Map<String, Any?>,
sessionId: String? = null,
): ConnectionOperationSnapshot? {
val opId = (payload["op_id"] as? String)?.trim() ?: return null
val seq = payload["seq"].toLongOrNull() ?: return null
val deadlineAt = payload["deadline_at"].toDoubleOrNull() ?: return null
val targets =
(payload["targets"] as? List<*>)
?.mapNotNull { parseTarget(it as? Map<*, *>) }
?: return null
if (opId.isBlank() || seq < 0L || !deadlineAt.isFinite() || deadlineAt <= 0.0 || targets.isEmpty()) {
return null
}
return ConnectionOperationSnapshot(
sessionId = (sessionId ?: payload["session_id"] as? String)?.takeIf { it.isNotBlank() },
opId = opId,
seq = seq,
deadlineAt = deadlineAt,
timeoutSeconds =
payload["timeout_seconds"]
.toDoubleOrNull()
?.takeIf { it.isFinite() && it > 0.0 },
toolCallId = payload["tool_call_id"] as? String,
settled = payload["settled"] as? Boolean ?: false,
settledBy = payload["settled_by"] as? String,
targets = targets,
)
}
private fun parseTarget(raw: Map<*, *>?): ConnectionOperationTarget? {
val map = raw ?: return null
val name = (map["name"] as? String)?.trim() ?: return null
if (name.isBlank()) return null
return ConnectionOperationTarget(
name = name,
kind =
enumValue(
map["kind"] as? String,
ConnectionTargetKind.values(),
ConnectionTargetKind.UNKNOWN,
),
action =
enumValue(
map["action"] as? String,
ConnectionTargetAction.values(),
ConnectionTargetAction.UNKNOWN,
),
state =
enumValue(
map["state"] as? String,
ConnectionTargetState.values(),
ConnectionTargetState.UNKNOWN,
),
detail = map["detail"] as? String,
instructions = map["instructions"] as? String,
discoveryError = map["discovery_error"] as? String,
connectUrl = map["connect_url"] as? String,
connectionId = map["connection_id"] as? String,
attempt = map["attempt"] as? String,
requiredEnv =
(map["required_env"] as? List<*>)
?.mapNotNull { parseEnv(it as? Map<*, *>) }
?: emptyList(),
tools = (map["tools"] as? List<*>)?.filterIsInstance<String>() ?: emptyList(),
hint = map["hint"] as? String,
)
}
private fun parseEnv(raw: Map<*, *>?): ConnectionEnvField? {
val map = raw ?: return null
val name = (map["name"] as? String)?.trim() ?: return null
if (name.isBlank()) return null
val secret = map["secret"] as? Boolean ?: false
return ConnectionEnvField(
name = name,
required = map["required"] as? Boolean ?: false,
secret = secret,
defaultValue = if (secret) null else map["default"] as? String,
prompt = map["prompt"] as? String,
)
}
private inline fun <reified T : Enum<T>> enumValue(
raw: String?,
values: Array<T>,
fallback: T,
): T = values.firstOrNull { it.name.equals(raw, ignoreCase = true) } ?: fallback
private fun Any?.toLongOrNull(): Long? =
when (this) {
is Number -> this.toLong().takeIf { this.toDouble() == it.toDouble() }
is String -> this.toLongOrNull()
else -> null
}
private fun Any?.toDoubleOrNull(): Double? =
when (this) {
is Number -> this.toDouble()
is String -> this.toDoubleOrNull()
else -> null
}
}
@@ -75,6 +75,19 @@ object EventParser {
val sessionId = params["session_id"] as? String ?: payload?.get("session_id") as? String
return when (eventType) {
"connection.request", "connection.update" -> {
val snapshot = payload?.let { ConnectionOperationParser.parse(it, sessionId) }
// Operation payloads may carry credential defaults and OAuth URLs.
// Never retain the raw frame when a malformed operation degrades to Unknown.
if (snapshot == null) {
WsEvent.Unknown("")
} else if (eventType == "connection.request") {
WsEvent.ConnectionRequest(snapshot)
} else {
WsEvent.ConnectionUpdate(snapshot)
}
}
"gateway.ready" -> {
WsEvent.GatewayReady(payload)
}
@@ -0,0 +1,31 @@
package com.m57.hermescontrol.data.ws
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromJsonElement
@Serializable
data class PluginRemovalResult(
val ok: Boolean,
val name: String? = null,
val error: String? = null,
)
/** Removes a user-installed plugin through the gateway's canonical plugin manager. */
class PluginRemovalRepository(
private val rpcRequest: suspend (String, Map<String, Any>) -> Any? = { method, params ->
val deferred = HermesWsClient.request(method, params)
try {
deferred.await()
} catch (e: CancellationException) {
deferred.cancel(e)
throw e
}
},
) {
suspend fun remove(name: String): PluginRemovalResult {
val result = rpcRequest(WsMethods.PLUGINS_MANAGE, mapOf("action" to "remove", "name" to name))
return Json.decodeFromJsonElement<PluginRemovalResult>(result.toJsonElement())
}
}
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot
import com.m57.hermescontrol.ui.chat.TodoItem
/** Parsed WebSocket events emitted by [HermesWsClient]. */
@@ -191,6 +192,14 @@ sealed class WsEvent {
val sessionId: String? = null,
) : WsEvent()
data class ConnectionRequest(
val snapshot: ConnectionOperationSnapshot,
) : WsEvent()
data class ConnectionUpdate(
val snapshot: ConnectionOperationSnapshot,
) : WsEvent()
// ── Status ───────────────────────────────────────────────────────────
data class StatusUpdate(
@@ -101,12 +101,18 @@ object WsMethods {
// ── Connectors (issue #1091) ───────────────────────────────────────────
const val CONNECTION_RESPOND = "connection.respond"
const val CONNECTORS_OPERATION_WAKE = "connectors.operation.wake"
/** List available third-party connectors and authorization state for a session. */
const val CONNECTORS_LIST = "connectors.list"
/** Initiate or restart authorization flow for one or more connectors. */
const val CONNECTORS_CONNECT = "connectors.connect"
// ── Plugins ───────────────────────────────────────────────────────────
const val PLUGINS_MANAGE = "plugins.manage"
// ── Billing / subscription (issue #628) ─────────────────────────────
// Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e).
// `credits.view` was REMOVED upstream; these replace it. `usage.bars`
@@ -171,6 +177,7 @@ object WsMethods {
CONFIG_SET, // methods_config_set.py: @method("config.set") + @_profile_scoped
"verification.status", // line 282
PROJECTS_LIST, // methods_projects.py: @_registry.profile_scoped
PLUGINS_MANAGE, // methods_tools.py: @_scoped_rpc
"pet.info", // line 1253
"pet.info.meta", // line 1279
"pet.cells", // line 1302
@@ -0,0 +1,269 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
sealed class ConnectionPendingAction {
abstract val opId: String
abstract val observedSeq: Long
data class Respond(
override val opId: String,
val target: String,
val approved: Boolean,
override val observedSeq: Long,
) : ConnectionPendingAction()
data class Continue(
override val opId: String,
override val observedSeq: Long,
) : ConnectionPendingAction()
data class Wake(
override val opId: String,
override val observedSeq: Long,
) : ConnectionPendingAction()
}
data class ConnectionOperationError(
val message: String,
val retryable: Boolean = true,
)
data class ConnectionOperationUiState(
val operation: ConnectionOperationSnapshot? = null,
val pendingAction: ConnectionPendingAction? = null,
val error: ConnectionOperationError? = null,
)
fun interface ConnectionOperationRequester {
suspend fun request(
method: String,
params: Map<String, Any>,
): Any?
}
internal class ConnectionResumeCheckpoint(
internal val generation: Long,
internal val operationId: String?,
internal val seq: Long?,
)
/** Session-scoped authoritative reducer for backend-owned connector operations. */
class ChatConnectionOperationDelegate(
private val requester: ConnectionOperationRequester,
) {
private val _state = MutableStateFlow(ConnectionOperationUiState())
val state: StateFlow<ConnectionOperationUiState> = _state.asStateFlow()
private var sessionId: String? = null
private var generation = 0L
private val settled = LinkedHashSet<String>()
@Synchronized
fun reset(newSessionId: String? = null) {
generation++
sessionId = newSessionId
settled.clear()
_state.value = ConnectionOperationUiState()
}
/** Rebinds the same stored chat to its current runtime session after create/resume/reconnect. */
@Synchronized
fun bindSession(newSessionId: String) {
if (newSessionId.isNotBlank()) sessionId = newSessionId
}
@Synchronized
internal fun resumeCheckpoint(): ConnectionResumeCheckpoint =
ConnectionResumeCheckpoint(
generation = generation,
operationId = _state.value.operation?.opId,
seq = _state.value.operation?.seq,
)
fun acceptRequest(snapshot: ConnectionOperationSnapshot): Boolean = accept(snapshot, allowReplacement = true)
fun acceptUpdate(snapshot: ConnectionOperationSnapshot): Boolean = accept(snapshot, allowReplacement = false)
@Synchronized
private fun accept(
snapshot: ConnectionOperationSnapshot,
allowReplacement: Boolean,
): Boolean {
if (!matchesSession(snapshot)) return false
if (settled.contains(snapshot.opId)) return false
val current = _state.value.operation
if (current != null && current.opId == snapshot.opId && snapshot.seq <= current.seq) return false
if (current != null && current.opId != snapshot.opId && !allowReplacement) return false
val pending = _state.value.pendingAction
val keepPending =
pending != null &&
pending.opId == snapshot.opId &&
snapshot.seq <= pending.observedSeq
_state.value =
ConnectionOperationUiState(
operation = snapshot,
pendingAction = pending.takeIf { keepPending },
)
if (snapshot.settled) markSettled(snapshot.opId)
return true
}
/** Applies a resume snapshot only if it cannot regress a live event received after resume began. */
@Synchronized
internal fun reconcileResume(
snapshot: ConnectionOperationSnapshot?,
checkpoint: ConnectionResumeCheckpoint,
): Boolean {
if (checkpoint.generation != generation) return false
val current = _state.value.operation
val unchanged = current?.opId == checkpoint.operationId && current?.seq == checkpoint.seq
if (snapshot == null) {
if (unchanged) {
_state.value = ConnectionOperationUiState()
return true
}
return false
}
if (!unchanged && current?.opId != snapshot.opId) return false
return accept(snapshot, allowReplacement = unchanged)
}
suspend fun respond(
target: String,
env: Map<String, String>,
approved: Boolean,
) {
val current = _state.value.operation ?: return
val targetSnapshot = current.targets.firstOrNull { it.name == target } ?: return
val safeEnv =
if (approved) {
val allowed = targetSnapshot.requiredEnv.mapTo(HashSet()) { it.name }
env.filterKeys(allowed::contains)
} else {
emptyMap()
}
val snapshot =
begin(
ConnectionPendingAction.Respond(
opId = current.opId,
target = target,
approved = approved,
observedSeq = current.seq,
),
) ?: return
val answer =
buildMap<String, Any> {
put("name", target)
put("status", if (approved) "approved" else "skipped")
if (safeEnv.isNotEmpty()) put("env", safeEnv)
}
dispatch(
method = WsMethods.CONNECTION_RESPOND,
params =
mapOf(
"session_id" to requireSessionId(),
"op_id" to snapshot.opId,
"result" to mapOf("targets" to listOf(answer)),
),
)
}
suspend fun continueOperation() {
val snapshot = begin(ConnectionPendingAction.Continue(currentOp(), currentSeq())) ?: return
dispatch(
method = WsMethods.CONNECTION_RESPOND,
params =
mapOf(
"session_id" to requireSessionId(),
"op_id" to snapshot.opId,
"result" to mapOf("settled_by" to "continue"),
),
)
}
suspend fun wake(expectedOpId: String) {
val current = _state.value.operation ?: return
if (current.opId != expectedOpId) return
val snapshot = begin(ConnectionPendingAction.Wake(current.opId, current.seq)) ?: return
dispatch(
method = WsMethods.CONNECTORS_OPERATION_WAKE,
params = mapOf("session_id" to requireSessionId(), "op_id" to snapshot.opId),
clearOnSuccess = true,
unknownOperationSettles = true,
)
}
private suspend fun dispatch(
method: String,
params: Map<String, Any>,
clearOnSuccess: Boolean = false,
unknownOperationSettles: Boolean = false,
) {
val action = _state.value.pendingAction
val actionGeneration = generation
try {
requester.request(method, params)
// Success only acknowledges receipt. Keep the exactly-once lock until
// a newer authoritative snapshot advances the operation sequence.
if (clearOnSuccess && generation == actionGeneration && _state.value.pendingAction == action) {
_state.value = _state.value.copy(pendingAction = null)
}
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
if (unknownOperationSettles && error is HermesWsClient.HermesRpcException && error.code == 4004) {
if (generation == actionGeneration && _state.value.pendingAction == action) {
action?.opId?.let(::markSettled)
}
} else if (generation == actionGeneration && _state.value.pendingAction == action) {
_state.value =
_state.value.copy(
pendingAction = null,
error = ConnectionOperationError("request_failed"),
)
}
}
}
@Synchronized
private fun begin(action: ConnectionPendingAction): ConnectionOperationSnapshot? {
val current = _state.value.operation ?: return null
if (sessionId.isNullOrBlank() ||
_state.value.pendingAction != null ||
current.opId != action.opId ||
current.seq != action.observedSeq
) {
return null
}
_state.value = _state.value.copy(pendingAction = action, error = null)
return current
}
private fun matchesSession(snapshot: ConnectionOperationSnapshot): Boolean =
!sessionId.isNullOrBlank() && snapshot.sessionId == sessionId
private fun requireSessionId(): String = checkNotNull(sessionId?.takeIf { it.isNotBlank() })
private fun currentOp(): String =
_state.value.operation
?.opId
.orEmpty()
private fun currentSeq(): Long = _state.value.operation?.seq ?: -1L
private fun markSettled(opId: String) {
settled += opId
while (settled.size > MAX_SETTLED_IDS) settled.remove(settled.first())
_state.value = ConnectionOperationUiState()
}
private companion object {
const val MAX_SETTLED_IDS = 32
}
}
@@ -87,8 +87,16 @@ data class ChatMessage(
val tps: Double? = null,
/** Stable completion identity correlating with reply notifications for read tracking. */
val completionId: String? = null,
/** Confirmed transcript identity (#859); [id] remains the stable live/render key. */
val restId: String? = null,
/** Cache insertion sequence for unconfirmed local rows; null before first persistence. */
val localOrder: Long? = null,
)
/** Cached REST rows already carry their canonical identity in the persisted primary key. */
internal val ChatMessage.canonicalRestId: String?
get() = restId ?: id.takeIf { it.startsWith("rest-") }
/**
* Single live transcript log entry for subagent execution.
*/
@@ -3,8 +3,11 @@ package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.model.ModelCapabilities
import com.m57.hermescontrol.data.model.ModelInfoResponse
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.mergedWithCanonical
import com.m57.hermescontrol.data.model.reasoningSupport
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
@@ -77,6 +80,9 @@ class ChatModelSwitchDelegate(
private var cachedModelOptionsScope: DataScope? = dataScopeFlow.value
private var modelOptionsJob: Job? = null
private var modelOptionsJobScope: DataScope? = null
private var modelInfoCapabilities: ModelCapabilities? = null
private var modelInfoModel: String? = null
private var modelInfoScope: DataScope? = null
fun attachScopeObserver(externalScope: CoroutineScope) {
externalScope.launch {
@@ -89,6 +95,9 @@ class ChatModelSwitchDelegate(
cachedModelOptions = emptyList()
cachedModelOptionsScope = newScope
fastRejectedModels.clear()
modelInfoCapabilities = null
modelInfoModel = null
modelInfoScope = null
if (modelOptionsJobScope != newScope) {
modelOptionsJob?.cancel()
@@ -331,6 +340,19 @@ class ChatModelSwitchDelegate(
it.copy(modelSwitchConfirmMessage = confirmMessage)
}
}
} else {
if (pending != null && pending.sequence == modelSwitchSequence) {
val warning = (map["warning"] as? String)?.trim()
if (!warning.isNullOrBlank()) {
val formattedWarning =
if (warning.startsWith("⚠") || warning.startsWith("⚠️")) {
warning
} else {
"⚠ $warning"
}
addAssistantMessage(formattedWarning)
}
}
}
} else if (key == "fast") {
val rawVal = map["value"] as? String
@@ -469,6 +491,9 @@ class ChatModelSwitchDelegate(
reasoningOpSequence++
reasoningJob?.cancel()
reasoningJob = null
modelInfoCapabilities = null
modelInfoModel = null
modelInfoScope = null
uiState.update {
it.copy(
pendingReasoningLevel = null,
@@ -492,7 +517,7 @@ class ChatModelSwitchDelegate(
// Capability validation
if (enforceCapabilities) {
val caps = uiState.value.currentModelCapabilities
if (caps?.reasoning == false) return
if (caps?.reasoningSupport == false) return
if (level == "none" && caps?.can_disable_reasoning == false) return
}
@@ -578,10 +603,26 @@ class ChatModelSwitchDelegate(
modelName: String,
): ModelCapabilities? {
if (cachedModelOptionsScope != dataScopeFlow.value) return null
return cachedModelOptions
.find { it.slug == providerSlug }
?.capabilities
?.get(modelName)
val catalog =
cachedModelOptions
.find { it.slug == providerSlug }
?.capabilities
?.get(modelName)
val infoMatches =
modelInfoScope == dataScopeFlow.value &&
(modelInfoModel == "$providerSlug/$modelName" || modelInfoModel?.substringAfter('/') == modelName)
val overlay = if (infoMatches) modelInfoCapabilities else null
return catalog.mergedWithCanonical(overlay)
}
fun applyModelInfo(
info: ModelInfoResponse,
scope: DataScope?,
) {
modelInfoCapabilities = info.capabilities
modelInfoModel = info.provider?.let { "$it/${info.model.orEmpty()}" }
modelInfoScope = scope
syncCurrentModelCapabilities()
}
fun getCurrentModelCapabilities(): ModelCapabilities? {
@@ -36,6 +36,50 @@ open class ChatPersistenceRepository(
suspend fun loadMessages(sessionId: String): List<ChatMessage> =
daoProvider().getMessagesForSession(sessionId).map { it.toUiModel() }
data class Cursor(
val group: Int,
val order: Long,
val id: String,
)
data class Page(
val messages: List<ChatMessage>,
val cursor: Cursor?,
val hasOlder: Boolean,
)
/** Read at most one page plus a lookahead row; never trim a full-session query. */
suspend fun loadPage(
sessionId: String,
before: Cursor?,
limit: Int,
): Page {
require(limit in 1..1_000)
val dao = daoProvider()
val rows =
if (before == null) {
dao.getLatestMessagePage(sessionId, limit + 1)
} else {
dao.getMessagePage(sessionId, before.group, before.order, before.id, limit + 1)
}
val page = rows.take(limit)
val oldest = page.lastOrNull()
return Page(
messages = page.asReversed().map { it.toUiModel() },
cursor = oldest?.let { Cursor(it.sortGroup, it.sortOrder, it.id) } ?: before,
hasOlder = rows.size > limit,
)
}
/** Record confirmed UUID aliases without replacing their newer locally persisted content. */
suspend fun confirmIdentities(
messages: List<ChatMessage>,
sessionId: String,
) {
val dao = daoProvider()
messages.forEach { dao.confirmIdentity(it.toEntity(sessionId)) }
}
/** Clear all cached messages for a session (e.g. after /undo rewind). */
suspend fun clearMessagesForSession(sessionId: String) {
daoProvider().deleteMessagesForSession(sessionId)
@@ -32,10 +32,12 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -59,7 +61,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -81,18 +82,22 @@ import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.model.reasoningSupport
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.components.ChatAppUpdateSection
import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner
import com.m57.hermescontrol.ui.chat.components.ChatHistoryWindowBanner
import com.m57.hermescontrol.ui.chat.components.ChatInputBar
import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
import com.m57.hermescontrol.ui.chat.components.ChatLoadingOverlay
import com.m57.hermescontrol.ui.chat.components.ChatResumeErrorOverlay
import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab
import com.m57.hermescontrol.ui.chat.components.ChatTimelineNoPrefetchStrategy
import com.m57.hermescontrol.ui.chat.components.ChatTimelineSheet
import com.m57.hermescontrol.ui.chat.components.ConnectionSetupSheet
import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet
import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
@@ -113,10 +118,9 @@ import com.m57.hermescontrol.ui.common.CredentialWarningBanner
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
import com.m57.hermescontrol.ui.settings.SettingsViewModel
import com.m57.hermescontrol.util.ConnectorUrlValidator
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
private const val SESSION_SYNC_INTERVAL_MS = 30_000L
@@ -152,17 +156,24 @@ fun ChatScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
val timelineState by viewModel.timelineState.collectAsStateWithLifecycle()
val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle()
val connectorsViewModel: ChatConnectorsViewModel = viewModel()
val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle()
val actionProgressState by viewModel.actionProgress.state.collectAsStateWithLifecycle()
val connectionOperationState by viewModel.connectionOperationState.collectAsStateWithLifecycle()
val settingsViewModel: SettingsViewModel = viewModel()
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
// Snapshot-backed search state — read directly so only the scopes that
// read its fields recompose on search changes (bar, matched bubbles).
val searchState = viewModel.searchState
val displayedMessages = timelineState.historyMessages ?: state.messages
val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current
var browserAuthInFlight by rememberSaveable { mutableStateOf(false) }
var browserAuthDeparted by rememberSaveable { mutableStateOf(false) }
var connectionBrowserOperationId by remember { mutableStateOf<String?>(null) }
var connectionBrowserDeparted by remember { mutableStateOf(false) }
DisposableEffect(lifecycleOwner) {
val observer =
@@ -172,15 +183,27 @@ fun ChatScreen(
if (browserAuthInFlight) {
browserAuthDeparted = true
}
if (connectionBrowserOperationId != null) {
connectionBrowserDeparted = true
}
connectorsViewModel.onPause()
}
Lifecycle.Event.ON_RESUME -> {
connectorsViewModel.onResume()
if (browserAuthInFlight && browserAuthDeparted) {
val legacyBrowserReturned = browserAuthInFlight && browserAuthDeparted
val operationId = connectionBrowserOperationId.takeIf { connectionBrowserDeparted }
if (legacyBrowserReturned || operationId != null) {
ExternalActivityLifecycleGuard.externalActivityReturned()
}
if (legacyBrowserReturned) {
browserAuthInFlight = false
browserAuthDeparted = false
ExternalActivityLifecycleGuard.externalActivityReturned()
}
if (operationId != null) {
connectionBrowserOperationId = null
connectionBrowserDeparted = false
viewModel.wakeConnectionOperation(operationId)
}
}
@@ -196,11 +219,18 @@ fun ChatScreen(
.firstOrNull()
val isChangingConfigs = activity?.isChangingConfigurations == true
if (!isChangingConfigs) {
val externalActivityOutstanding =
(browserAuthInFlight && browserAuthDeparted) ||
(connectionBrowserOperationId != null && connectionBrowserDeparted)
if (externalActivityOutstanding) {
ExternalActivityLifecycleGuard.externalActivityReturned()
}
if (browserAuthInFlight && browserAuthDeparted) {
browserAuthInFlight = false
browserAuthDeparted = false
ExternalActivityLifecycleGuard.externalActivityReturned()
}
connectionBrowserOperationId = null
connectionBrowserDeparted = false
connectorsViewModel.onPause()
connectorsViewModel.hide()
}
@@ -211,7 +241,6 @@ fun ChatScreen(
val listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy)
val scrollScope = rememberCoroutineScope()
val scrollController = rememberChatScrollController(listState, scrollScope)
var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) }
var showContextSheet by remember { mutableStateOf(false) }
var pendingSavePath by rememberSaveable { mutableStateOf<String?>(null) }
var pendingSaveName by rememberSaveable { mutableStateOf<String?>(null) }
@@ -265,38 +294,9 @@ fun ChatScreen(
}
}
// Arm + trigger older-message paging when the user scrolls near the top.
// Before prepending, capture the anchor (first visible message id + offset)
// so the same content stays under the reader's eye after the insert
// (issue #682). Restored in a LaunchedEffect once the page actually lands.
val pagingAnchor = remember { mutableStateOf<Pair<String, Int>?>(null) }
LaunchedEffect(listState, state.currentSessionId, state.hasOlderMessages, state.isLoadingOlder) {
if (!state.hasOlderMessages || state.isLoadingOlder) return@LaunchedEffect
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.collectLatest { firstVisibleIndex ->
if (firstVisibleIndex > 2) {
isOlderPagingArmed = true
} else if (isOlderPagingArmed || state.messages.size <= 3) {
val anchorId = state.messages.getOrNull(firstVisibleIndex)?.id
val offset = scrollController.captureAnchorOffset()
pagingAnchor.value = if (anchorId != null) anchorId to offset else null
viewModel.loadOlderMessages()
}
}
}
// After older history is prepended, restore the anchor message (by id, so
// streaming-token inserts during paging don't skew the index) plus offset.
LaunchedEffect(state.messages) {
val anchor = pagingAnchor.value ?: return@LaunchedEffect
val (anchorId, offset) = anchor
val index = state.messages.indexOfFirst { it.id == anchorId }
if (index >= 0) {
scrollController.scrollToItem(index, offset)
pagingAnchor.value = null
}
}
// FullBleedChatList preserves the live lazy row key and pixel offset on prepend.
// Never restore a message index captured before an asynchronous fetch: it is not
// a lazy row index and the reader may have moved in the meantime.
// Continuous bottom-follow tracking from LazyListState (issue #682).
LaunchedEffect(Unit) {
@@ -308,6 +308,7 @@ fun ChatScreen(
// (issue #682). Replaces the old item-count heuristic that ignored the
// streaming tail.
LaunchedEffect(
timelineState.isHistorical,
state.messages,
streamingState.streamingMessage,
streamingState.isThinking,
@@ -315,6 +316,10 @@ fun ChatScreen(
state.todos,
state.clarifyRequest,
) {
if (timelineState.isHistorical) {
scrollController.pauseFollowing()
return@LaunchedEffect
}
scrollController.onTailChanged(
tailKey =
tailContentKey(
@@ -327,9 +332,15 @@ fun ChatScreen(
messageCount = state.messages.size,
)
}
val showScrollToBottom by remember {
LaunchedEffect(timelineState.historyAnchorRowId) {
if (timelineState.isHistorical && displayedMessages.isNotEmpty()) {
scrollController.pauseFollowing()
listState.scrollToItem(0)
}
}
val showScrollToBottom by remember(timelineState.isHistorical, displayedMessages) {
derivedStateOf {
scrollController.showFab(state.messages.isNotEmpty())
!timelineState.isHistorical && scrollController.showFab(displayedMessages.isNotEmpty())
}
}
var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
@@ -439,7 +450,9 @@ fun ChatScreen(
!showSubagentInspectionSheet &&
state.btwState == null &&
viewingImage == null &&
!connectorsState.isVisible
!connectorsState.isVisible &&
!timelineState.isOpen &&
connectionOperationState.operation == null
// Lifecycle effects, permissions, session switching, auto-scroll, errors
ChatLifecycleEffects(
@@ -530,7 +543,10 @@ fun ChatScreen(
var showSessionMenu by remember { mutableStateOf(false) }
Box {
IconButton(
onClick = { showSessionMenu = true },
onClick = {
settingsViewModel.refreshKeepConnectedInBackground()
showSessionMenu = true
},
modifier = Modifier.testTag("chat_session_menu_button"),
) {
Icon(
@@ -542,6 +558,22 @@ fun ChatScreen(
expanded = showSessionMenu,
onDismissRequest = { showSessionMenu = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.chat_action_timeline)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.History,
contentDescription = null,
)
},
enabled = state.currentSessionId != null,
onClick = {
showSessionMenu = false
viewModel.openTimeline()
},
modifier = Modifier.testTag("chat_menu_timeline"),
)
// Chat search lives in this overflow menu (moved from the top bar)
DropdownMenuItem(
text = {
@@ -566,6 +598,7 @@ fun ChatScreen(
showSessionMenu = false
viewModel.toggleSearch()
},
enabled = !timelineState.isHistorical,
modifier = Modifier.testTag("chat_menu_search"),
)
@@ -583,6 +616,10 @@ fun ChatScreen(
},
modifier = Modifier.testTag("chat_menu_session_integrations"),
)
ChatBackgroundConnectionMenuItem(
checked = settingsState.keepConnectedInBackground,
onToggle = settingsViewModel::onKeepConnectedInBackgroundChange,
)
}
}
},
@@ -656,6 +693,17 @@ fun ChatScreen(
}
}
if (timelineState.isHistorical) {
ChatHistoryWindowBanner(
hasOlder = timelineState.historyHasOlder,
hasNewer = timelineState.historyHasNewer,
onReturnToLatest = {
viewModel.returnToLatestMessages()
scrollController.jumpToBottom()
},
)
}
Box(
modifier =
Modifier
@@ -692,35 +740,44 @@ fun ChatScreen(
// Full-bleed chat renderer (issue #866) — the single chat
// surface since the bubble renderer was removed.
FullBleedChatList(
messages = state.messages,
streamingState = streamingState,
isAgentTyping = state.isAgentTyping,
messages = displayedMessages,
streamingState = if (timelineState.isHistorical) StreamingState() else streamingState,
isAgentTyping = state.isAgentTyping && !timelineState.isHistorical,
searchState = searchState,
typingEffectEnabled = state.typingEffectEnabled,
typingEffectEnabled = state.typingEffectEnabled && !timelineState.isHistorical,
typingEffectDelayMs = state.typingEffectDelayMs,
messageStatsEnabled = state.messageStatsEnabled,
showUserMessageTokens = state.showUserMessageTokens,
showAssistantMessageTokens = state.showAssistantMessageTokens,
showTokensPerSecond = state.showTokensPerSecond,
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
isLoading = state.isLoading,
isLoadingOlder = state.isLoadingOlder,
isLoading = state.isLoading && !timelineState.isHistorical,
isLoadingOlder = state.isLoadingOlder && !timelineState.isHistorical,
hasOlderMessages = state.hasOlderMessages && !timelineState.isHistorical,
pagingSessionId =
state.currentSessionId?.let { currentSessionId ->
if (timelineState.isHistorical) {
currentSessionId + ":history:" + timelineState.historyAnchorRowId
} else {
currentSessionId
}
},
listState = listState,
scrollController = scrollController,
lastAnimatedMessageId = lastAnimatedMessageId,
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
viewModel = viewModel,
clarifyRequest = state.clarifyRequest,
clarifyRequest = state.clarifyRequest.takeUnless { timelineState.isHistorical },
onRespondClarify = viewModel::respondToClarify,
onRespondClarifyBatch = viewModel::respondToClarifyBatch,
onDismissClarify = viewModel::dismissClarify,
vaultUnlockPrompt = state.vaultUnlockPrompt,
vaultUnlockPrompt = state.vaultUnlockPrompt.takeUnless { timelineState.isHistorical },
onRespondVaultUnlock = viewModel::respondToVaultUnlock,
onDismissVaultUnlock = viewModel::dismissVaultUnlock,
vaultSaveLoginPrompt = state.vaultSaveLoginPrompt,
vaultSaveLoginPrompt = state.vaultSaveLoginPrompt.takeUnless { timelineState.isHistorical },
onRespondVaultSaveLogin = viewModel::respondToVaultSaveLogin,
onDismissVaultSaveLogin = viewModel::dismissVaultSaveLogin,
vaultCodePrompt = state.vaultCodePrompt,
vaultCodePrompt = state.vaultCodePrompt.takeUnless { timelineState.isHistorical },
onRespondVaultCode = viewModel::respondToVaultCode,
onDismissVaultCode = viewModel::dismissVaultCode,
onSaveAttachment = onSaveAttachment,
@@ -730,14 +787,18 @@ fun ChatScreen(
)
// Loading overlay
ChatLoadingOverlay(isLoading = state.isLoading && state.resumeError == null)
ChatLoadingOverlay(
isLoading = state.isLoading && state.resumeError == null && !timelineState.isHistorical,
)
// Resume-exhausted overlay — explicit error + Retry instead
// of an infinite spinner (desktop parity).
ChatResumeErrorOverlay(
errorMessage = state.resumeError,
onRetry = viewModel::retryResumeSession,
)
if (!timelineState.isHistorical) {
ChatResumeErrorOverlay(
errorMessage = state.resumeError,
onRetry = viewModel::retryResumeSession,
)
}
// Scroll-to-bottom FAB (issue #682): shows while follow is
// paused and renders the unseen-message badge.
@@ -749,10 +810,12 @@ fun ChatScreen(
// Reaction heartsanimation (purely cosmetic — fades out
// automatically after the ViewModel clears the state)
key(state.reactionTriggerId) {
ReactionHeartsOverlay(
reactionKind = state.reactionKind,
)
if (!timelineState.isHistorical) {
key(state.reactionTriggerId) {
ReactionHeartsOverlay(
reactionKind = state.reactionKind,
)
}
}
}
@@ -785,7 +848,7 @@ fun ChatScreen(
isListening = mediaLaunchers.isListening,
isAgentTyping = state.isAgentTyping,
isConnected = state.isConnected,
isSessionReady = state.isSessionReady,
isSessionReady = state.isSessionReady && !timelineState.isHistorical,
sessionPreparationFailed = state.resumeError != null,
commandCatalog = state.commandCatalog,
slashUsageCounts = state.slashUsageCounts,
@@ -813,7 +876,7 @@ fun ChatScreen(
onModelTap = { viewModel.openModelPicker() },
onReasoningTap = { level -> viewModel.setReasoningLevel(level) },
canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning,
supportsReasoning = state.currentModelCapabilities?.reasoning,
supportsReasoning = state.currentModelCapabilities?.reasoningSupport,
fastMode = state.fastMode,
fastSupported = state.currentModelCapabilities?.fast == true,
isFastModeChanging = state.isFastModeChanging,
@@ -821,6 +884,14 @@ fun ChatScreen(
)
}
ChatTimelineSheet(
state = timelineState,
onDismiss = viewModel::closeTimeline,
onJump = viewModel::jumpToTimelineEntry,
onLoadMore = viewModel::loadMoreTimeline,
onRetry = viewModel::retryTimeline,
)
if (showReloginDialog) {
ReloginDialog(
onDismiss = { showReloginDialog = false },
@@ -931,6 +1002,34 @@ fun ChatScreen(
)
}
if (connectionOperationState.operation != null) {
ConnectionSetupSheet(
state = connectionOperationState,
onRespond = viewModel::respondToConnection,
onContinue = viewModel::continueConnectionOperation,
onOpenBrowser = { operationId, url ->
if (ConnectorUrlValidator.isValidHttpsUrl(url)) {
try {
launchExternalActivity {
connectionBrowserOperationId = operationId
connectionBrowserDeparted = false
val intent =
Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
} catch (_: Exception) {
connectionBrowserOperationId = null
connectionBrowserDeparted = false
scrollScope.launch { snackbarHostState.showSnackbar(browserLaunchError) }
}
}
},
onDismiss = viewModel::continueConnectionOperation,
)
}
SessionIntegrationsSheet(
uiState = connectorsState,
sessionId = connectorsState.sessionId,
@@ -941,3 +1040,22 @@ fun ChatScreen(
)
}
}
@Composable
internal fun ChatBackgroundConnectionMenuItem(
checked: Boolean,
onToggle: (Boolean) -> Unit,
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.settings_keep_connected_in_background_title)) },
trailingIcon = {
Checkbox(
checked = checked,
onCheckedChange = onToggle,
modifier = Modifier.testTag("chat_menu_keep_connected_checkbox"),
)
},
onClick = { onToggle(!checked) },
modifier = Modifier.testTag("chat_menu_keep_connected"),
)
}
@@ -10,10 +10,9 @@ import com.m57.hermescontrol.notification.ReplyNotificationTracker
/**
* Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s.
*
* Verbatim extraction of ChatViewModel.mapServerMessages (corral 2 of the
* 2026-08 delegate refactor). Pure with respect to the VM: it reads the live
* message list supplied by the caller and one paging-mode flag — no state
* mutation, no I/O beyond URL construction.
* Reads the supplied transcript snapshot and recovers notification identity through
* ReplyNotificationTracker's synchronized lookup. Safe on the history dispatcher;
* it never mutates ViewModel state or dismisses a notification.
*/
internal fun mapServerMessages(
sessionId: String,
@@ -22,17 +21,14 @@ internal fun mapServerMessages(
latestPaging: Boolean,
liveMessages: List<ChatMessage>,
isPagingOlder: Boolean = false,
stableRowIds: Boolean = latestPaging,
context: android.content.Context? = null,
): List<ChatMessage> {
val existingReasoningMap =
liveMessages
.filter { it.reasoningText.isNotBlank() }
.associateBy { it.content }
val existingById = liveMessages.associateBy { it.canonicalRestId ?: it.id }
val liveByExactId =
liveMessages
.filter { !it.completionId.isNullOrBlank() }
.associateBy { it.id }
.associateBy { it.canonicalRestId ?: it.id }
val unmappedLiveWsAssistants =
if (isPagingOlder) {
@@ -40,112 +36,92 @@ internal fun mapServerMessages(
} else {
liveMessages
.filter {
it.role == MessageRole.ASSISTANT && !it.id.startsWith("rest-") &&
it.role == MessageRole.ASSISTANT && it.canonicalRestId == null &&
!it.completionId.isNullOrBlank()
}
}
val wsCompletionIdByRestIndex = mutableMapOf<Int, String>()
if (!isPagingOlder) {
if (unmappedLiveWsAssistants.isNotEmpty()) {
val remainingWs = unmappedLiveWsAssistants.toMutableList()
for (i in messages.indices.reversed()) {
if (remainingWs.isEmpty()) break
val m = messages[i]
val r =
when (m.role?.lowercase()) {
"user" -> MessageRole.USER
"system" -> MessageRole.SYSTEM
"tool" -> MessageRole.TOOL
else -> MessageRole.ASSISTANT
}
if (r != MessageRole.ASSISTANT) continue
val rawContent = m.contentText
// Reasoning-only or empty tool placeholder: skip, does not become visible prose
if (rawContent.isBlank() && !rawContent.contains("MEDIA:")) continue
val restId =
if (latestPaging) {
m.id?.let { "rest-$sessionId-$it" } ?: "rest-$sessionId-${offset + i}"
} else {
"rest-$sessionId-${offset + i}"
}
if (liveByExactId.containsKey(restId)) continue
val canonicalContent =
if (rawContent.contains("MEDIA:")) {
HostMediaExtractor.strip(rawContent).trim()
} else {
rawContent.trim()
}
val wsIdx = remainingWs.indexOfLast { it.content.trim() == canonicalContent }
if (wsIdx >= 0) {
remainingWs.removeAt(wsIdx).completionId?.let { compId ->
wsCompletionIdByRestIndex[i] = compId
}
}
}
fun restIdAt(index: Int): String =
if (stableRowIds) {
"rest-$sessionId-${requireNotNull(messages[index].id) { "Latest transcript row has no stable id" }}"
} else {
"rest-$sessionId-${offset + index}"
}
// REST-only recovery is fail-closed: only a durable server row ID may
// transfer notification identity after the WebSocket copy is gone.
val activeTarget = ReplyNotificationTracker.getActiveTarget(context)
if (
activeTarget != null &&
activeTarget.sessionId == sessionId &&
activeTarget.serverMessageId != null &&
wsCompletionIdByRestIndex.values.none { it == activeTarget.completionId }
) {
val wsCompletionIdByRestIndex = mutableMapOf<Int, String>()
if (!isPagingOlder) {
// #129: reserve durable identity before matching any repeated live prose.
// REST-only recovery remains fail-closed when the target row is absent.
val activeTarget = ReplyNotificationTracker.getActiveTarget(context)?.takeIf { it.sessionId == sessionId }
val durableTarget = activeTarget?.takeIf { it.serverMessageId != null }
if (durableTarget != null) {
val exactIndex =
messages.indexOfFirst { message ->
message.id == activeTarget.serverMessageId &&
message.id == durableTarget.serverMessageId &&
message.role.equals("assistant", ignoreCase = true)
}
if (exactIndex >= 0 && !liveByExactId.containsKey("rest-$sessionId-${activeTarget.serverMessageId}")) {
wsCompletionIdByRestIndex[exactIndex] = activeTarget.completionId
if (exactIndex >= 0 && !liveByExactId.containsKey(restIdAt(exactIndex))) {
wsCompletionIdByRestIndex[exactIndex] = durableTarget.completionId
}
}
val confirmedCompletions =
liveMessages.filter { it.canonicalRestId != null }.mapNotNull { it.completionId }.toSet()
val remainingWs =
unmappedLiveWsAssistants
.filter {
it.completionId !in confirmedCompletions && it.completionId != durableTarget?.completionId
}.toMutableList()
// #129: reserve known completions and live aliases. A metadata-free cached REST
// echo must remain eligible for its pending WS completion before older duplicates.
val reservedRestIds = liveByExactId.keys + liveMessages.mapNotNull { it.restId }
// Retain upstream's newest-to-newest, one-to-one WS echo matching.
for (i in messages.indices.reversed()) {
if (remainingWs.isEmpty()) break
if (i in wsCompletionIdByRestIndex || restIdAt(i) in reservedRestIds) continue
val m = messages[i]
if (m.role?.lowercase() in listOf("user", "system", "tool")) continue
val rawContent = m.displayContentText ?: m.contentText
if (rawContent.isBlank()) continue
val canonicalContent =
if (rawContent.contains("MEDIA:")) HostMediaExtractor.strip(rawContent).trim() else rawContent.trim()
val wsIdx = remainingWs.indexOfLast { it.content.trim() == canonicalContent }
if (wsIdx >= 0) {
remainingWs.removeAt(wsIdx).completionId?.let { wsCompletionIdByRestIndex[i] = it }
}
}
}
// Tool rows in the REST transcript carry NO tool name — the live WS
// stream was the only source of `toolName`. Match each REST tool row
// to its WS counterpart by RESULT CONTENT (not position — pagination
// and mixed cache state make positional mapping misalign, leaving
// the newest call with a null name → generic "tool" bubble). When a
// match is found the live message is reused wholesale (same id +
// toolName + rich payload), so persistence upserts the same row
// instead of accumulating a second `rest-` copy in Room. Issue #771.
val liveToolByResult = linkedMapOf<String, ChatMessage>()
liveMessages
.filter { it.role == MessageRole.TOOL }
.sortedBy { it.id.startsWith("rest-") } // prefer live WS copies
.forEach { msg ->
canonicalToolResultKey(msg.content)?.let { key ->
liveToolByResult.putIfAbsent(key, msg)
// Reasoning follows the same reserved identities as completions, never a reusable text lookup.
val assistants = liveMessages.filter { it.role == MessageRole.ASSISTANT }
val assistantsByRestId = assistants.groupBy { it.canonicalRestId }
val assistantsByCompletion = assistants.groupBy { it.completionId }
val reasoningSources = arrayOfNulls<ChatMessage>(messages.size)
messages.forEachIndexed { index, row ->
if (row.role?.lowercase() in listOf("user", "system", "tool")) return@forEachIndexed
val exact = assistantsByRestId[restIdAt(index)].orEmpty()
val completion = liveByExactId[restIdAt(index)]?.completionId ?: wsCompletionIdByRestIndex[index]
val candidates = exact + completion?.let { assistantsByCompletion[it] }.orEmpty()
reasoningSources[index] = candidates.firstOrNull { it.reasoningText.isNotBlank() } ?: candidates.firstOrNull()
}
if (!isPagingOlder) {
val remaining =
assistants.filter { it.canonicalRestId == null && it.completionId == null }.toMutableList()
for (index in messages.indices.reversed()) {
val row = messages[index]
val displayContent = row.displayContentText ?: row.contentText
if (reasoningSources[index] != null || displayContent.isBlank() ||
row.role?.lowercase() in listOf("user", "system", "tool") ||
liveByExactId[restIdAt(index)]?.completionId != null || index in wsCompletionIdByRestIndex
) {
continue
}
val content = HostMediaExtractor.strip(displayContent).trim()
val match = remaining.indexOfLast { it.content.trim() == content }
if (match >= 0) reasoningSources[index] = remaining.removeAt(match)
}
// Issue #842: REST transcript rows carry the gateway's `tool_call_id`
// — prefer matching live bubbles by that 1:1 identity. It works for
// EVERY tool shape, including MCP/web rows whose REST copy is raw
// `<untrusted_tool_result>` text with no JSON key to canonicalize.
val liveToolByCallId = linkedMapOf<String, ChatMessage>()
liveMessages
.filter { it.role == MessageRole.TOOL && it.toolCallId.isNotBlank() }
.sortedBy { it.id.startsWith("rest-") } // prefer live WS copies
.forEach { msg ->
liveToolByCallId.putIfAbsent(msg.toolCallId, msg)
}
}
val mapped = mutableListOf<ChatMessage>()
// The gateway stores a reasoning-model's thinking as its OWN assistant
// row (content = "", reasoning = trace) directly before the answer row.
// Rendering that as a standalone empty assistant bubble is the
// "reasoning box in a separate bubble" artifact — fold it into the
// next assistant message with content instead. Issue #771.
var pendingReasoning: String? = null
messages.forEachIndexed { index, msg ->
val role =
when (msg.role?.lowercase()) {
@@ -154,49 +130,41 @@ internal fun mapServerMessages(
"tool" -> MessageRole.TOOL
else -> MessageRole.ASSISTANT
}
val globalIndex = offset + index
// Issue #859: under newest-anchored paging use the server's
// AUTOINCREMENT row id as the stable key — from-end positions shift
// as the transcript grows and would collide across hydrations
// (distinctBy would silently drop the newest copy). Legacy paging
// keeps the absolute-position key its count-based sync math needs.
val restId =
if (latestPaging) {
msg.id?.let { "rest-$sessionId-$it" } ?: "rest-$sessionId-$globalIndex"
} else {
"rest-$sessionId-$globalIndex"
}
val restId = restIdAt(index)
val timestamp =
msg.timestampText
?.toDoubleOrNull()
?.times(1000)
?.toLong()
?: existingById[restId]?.timestamp
?: System.currentTimeMillis()
val rawContent = msg.contentText
val rawContent = msg.displayContentText ?: msg.contentText
val rowReasoning =
if (msg.reasoningText.isNotBlank()) {
msg.reasoningText
} else {
existingReasoningMap[rawContent]?.reasoningText.orEmpty()
msg.reasoningText.ifBlank {
if (role == MessageRole.ASSISTANT) {
reasoningSources[index]?.reasoningText.orEmpty()
} else {
existingById[restId]?.reasoningText.orEmpty()
}
}
// Empty assistant row — two cases stored by the gateway:
// 1. Reasoning-only: thinking-model split storage (content = "",
// reasoning = trace). Stash the trace and fold it into the
// next assistant message that has content (issue #771).
// 2. Tool-call placeholder: non-reasoning models emit content = ""
// with tool_calls metadata and no reasoning. These carry no
// user-visible text and must not render as empty bubbles
// (issue #956).
if (role == MessageRole.ASSISTANT && rawContent.isBlank()) {
if (rowReasoning.isNotBlank()) {
pendingReasoning = rowReasoning
}
return@forEachIndexed
}
// Retain canonical reasoning rows across page boundaries; hide only textless placeholders.
if (role == MessageRole.ASSISTANT && rawContent.isBlank() && rowReasoning.isBlank()) return@forEachIndexed
var finalContent = rawContent
// Context-reference preprocessing can persist model-facing attachment
// contents after the user's text. A cold reload / second device has no
// optimistic local bubble to hide that enriched REST representation,
// so project the producer-owned suffix out at hydration time too.
// Keep the actual @file:/@image: token here: unlike the dedupe path,
// REST-only hydration cannot prove that reference was not user-authored.
var finalContent =
if (role == MessageRole.USER) stripGatewayAttachedContext(rawContent) else rawContent
var attachments: List<Attachment>? = null
if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
val items = HostMediaExtractor.extract(rawContent)
@@ -225,34 +193,6 @@ internal fun mapServerMessages(
}
}
val finalReasoning =
if (rowReasoning.isNotBlank()) {
rowReasoning
} else if (role == MessageRole.ASSISTANT && pendingReasoning != null) {
pendingReasoning.also { pendingReasoning = null }
} else {
""
}
// Tool rows in the REST transcript carry no tool name. When the
// result payload matches a live WS tool message, reuse it whole —
// keeps the real name, the rich WS payload, AND the same id so
// Room upserts instead of accumulating a duplicate `rest-` row.
if (role == MessageRole.TOOL) {
// Prefer the gateway call id (1:1, works for every tool
// shape), then fall back to result-content matching.
liveToolByCallId[msg.toolCallId]?.let { live ->
mapped.add(live)
return@forEachIndexed
}
canonicalToolResultKey(rawContent)?.let { key ->
liveToolByResult[key]?.let { live ->
mapped.add(live)
return@forEachIndexed
}
}
}
val completionId =
if (role == MessageRole.ASSISTANT) {
liveByExactId[restId]?.completionId ?: wsCompletionIdByRestIndex[index]
@@ -265,7 +205,7 @@ internal fun mapServerMessages(
id = restId,
role = role,
content = finalContent,
reasoningText = finalReasoning,
reasoningText = rowReasoning,
toolCallId = msg.toolCallId,
attachments = attachments,
timestamp = timestamp,
@@ -277,17 +217,12 @@ internal fun mapServerMessages(
)
}
// A reasoning-only row with no following answer (interrupted turn):
// don't drop the trace — attach it to the last assistant message.
if (pendingReasoning != null) {
val lastAssistantIdx = mapped.indexOfLast { it.role == MessageRole.ASSISTANT }
if (lastAssistantIdx >= 0) {
val target = mapped[lastAssistantIdx]
if (target.reasoningText.isBlank()) {
mapped[lastAssistantIdx] = target.copy(reasoningText = pendingReasoning)
}
}
// REST echoes must not reserve a match before the richer WS copy of that tool.
val liveTools = liveMessages.filter { it.role == MessageRole.TOOL && !it.id.startsWith("rest-") }
val mappedTools = mapped.filter { it.role == MessageRole.TOOL }
val matches = matchTranscriptMessages(mappedTools, liveTools)
val toolsById = mappedTools.indices.associate { index -> mappedTools[index].id to matches[index] }
return mapped.map { message ->
toolsById[message.id]?.copy(restId = message.canonicalRestId) ?: message
}
return mapped
}
@@ -6,6 +6,38 @@ import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
private val ATTACHED_CONTEXT_MARKER_RE =
Regex("""(?:^|\n)--- Attached Context ---\s*\n""")
private val CONTEXT_REF_RE =
Regex(
"""@(file|folder|url|image|tool|terminal):(?:"[^"\n]+"|'[^'\n]+'|`[^`\n]+`|\S+)""",
)
/**
* Remove a gateway-generated `--- Attached Context ---` suffix while
* preserving the user-authored portion of the message.
*
* The marker itself is plain text a user can legitimately type, so do not
* treat it as a producer boundary unless the suffix also contains a context
* reference of the shape emitted by the gateway.
*
* Unlike [stripAttachmentRefLines], this intentionally keeps `@file:` /
* `@image:` references in the visible text. REST-only hydration has no local
* optimistic bubble proving those references were injected by Mobile, and
* users may have authored context references themselves.
*/
internal fun stripGatewayAttachedContext(content: String): String {
val marker = ATTACHED_CONTEXT_MARKER_RE.find(content) ?: return content
val attachedContext = content.substring(marker.range.last + 1)
if (!CONTEXT_REF_RE.containsMatchIn(attachedContext)) {
return content
}
return content.substring(0, marker.range.first).trimEnd()
}
/**
* Canonical comparison key for a tool message's result payload.
*
@@ -75,48 +107,120 @@ internal fun canonicalToolResultKey(content: String): String? {
internal fun sameLogicalMessage(
a: ChatMessage,
b: ChatMessage,
): Boolean {
if (a.role != b.role) return false
if (a.role == MessageRole.TOOL) {
// Issue #842: prefer the gateway's tool call id when both sides carry
// it — the REST transcript stores `tool_call_id` and the live WS
// bubble keeps it from `tool.start`. Content canonicalization cannot
// cover MCP/web tools: the REST side stores the payload as raw
// `<untrusted_tool_result>` text (not JSON), so it has no key at all.
if (a.toolCallId.isNotBlank() && b.toolCallId.isNotBlank()) {
return a.toolCallId == b.toolCallId
): Boolean = TranscriptComparison().same(a, b)
/** One operation owns this cache; even invalid JSON is parsed at most once per content. */
internal class TranscriptComparison(
private val canonicalize: (String) -> String? = ::canonicalToolResultKey,
) {
private val toolKeys = HashMap<String, String?>()
private val trimmed = HashMap<String, String>()
private val captions = HashMap<String, String>()
fun toolKey(content: String): String? {
if (!toolKeys.containsKey(content)) toolKeys[content] = canonicalize(content)
return toolKeys[content]
}
fun same(
a: ChatMessage,
b: ChatMessage,
): Boolean {
if (a.role != b.role) return false
val aRestId = a.canonicalRestId
val bRestId = b.canonicalRestId
if (aRestId != null && bRestId != null) return aRestId == bRestId
// #129: completion-bearing replies need confirmed identity, not repeated prose.
// Older pages deliberately do not acquire a live reply's completion in the mapper.
if (a.role == MessageRole.ASSISTANT && (a.completionId != null || b.completionId != null)) {
return a.completionId != null && a.completionId == b.completionId
}
val ka = canonicalToolResultKey(a.content)
val kb = canonicalToolResultKey(b.content)
return ka != null && ka == kb
if (a.role == MessageRole.ASSISTANT && a.content.isBlank() && b.content.isBlank() &&
(a.reasoningText.isNotBlank() || b.reasoningText.isNotBlank())
) {
return a.reasoningText.isNotBlank() && a.reasoningText == b.reasoningText
}
if (a.role == MessageRole.TOOL) {
if (a.toolCallId.isNotBlank() && b.toolCallId.isNotBlank()) return a.toolCallId == b.toolCallId
val key = toolKey(a.content)
return key != null && key == toolKey(b.content)
}
val ta = trimmed.getOrPut(a.content) { a.content.trim() }
val tb = trimmed.getOrPut(b.content) { b.content.trim() }
if (ta == tb) return true
if (a.role == MessageRole.USER &&
captions.getOrPut(ta) { stripAttachmentRefLines(ta) } ==
captions.getOrPut(tb) { stripAttachmentRefLines(tb) }
) {
return true
}
return ta.length >= 40 && tb.length >= 40 && (tb.startsWith(ta) || ta.startsWith(tb))
}
val ta = a.content.trim()
val tb = b.content.trim()
if (ta == tb) return true
// Attachment dedupe: when the user sends an image/file, the backend
// persists an ENRICHED copy of the prompt — the real caption plus
// `@image:`/`@file:` reference lines and a `[screenshot]` marker
// (tui_gateway _build_image_ref_message / run_agent flattening). The
// optimistic bubble carries the raw caption only, so the exact compare
// above fails and the REST sync renders the same message twice. Compare
// USER rows on the caption with injection lines stripped (both sides —
// stripping a clean string is a no-op). USER-only so assistant text that
// legitimately mentions these tokens is never collapsed.
if (a.role == MessageRole.USER) {
val ca = stripAttachmentRefLines(ta)
val cb = stripAttachmentRefLines(tb)
if (ca == cb) return true
}
/** Consume each occurrence once, reserving exact IDs before considering content echoes. */
internal fun matchTranscriptMessages(
incoming: List<ChatMessage>,
existing: List<ChatMessage>,
comparison: TranscriptComparison = TranscriptComparison(),
allowAssistantContentMatches: Boolean = true,
): List<ChatMessage?> {
val byId = existing.withIndex().associate { it.value.id to it.index }
val byRestId =
existing.withIndex().mapNotNull { (index, message) -> message.canonicalRestId?.let { it to index } }.toMap()
val used = BooleanArray(existing.size)
val matches = arrayOfNulls<ChatMessage>(incoming.size)
incoming.forEachIndexed { index, message ->
(byId[message.id] ?: message.canonicalRestId?.let { byRestId[it] })?.let { match ->
if (!used[match]) {
used[match] = true
matches[index] = existing[match]
}
}
}
// Issue #842: a seal race can leave the live orphan a few trailing tokens
// short of the streamed narration (the last delta was still in the
// throttled buffer when tool.start sealed the message). The backend
// persists the COMPLETE copy, so the orphan is a strict prefix of the
// REST row. Accept prefix-covering only for substantial texts (>=40
// chars) so a short reply can never be swallowed by a longer message
// that merely starts with it.
return ta.length >= 40 &&
tb.length >= 40 &&
(tb.startsWith(ta) || ta.startsWith(tb))
val byRole = existing.indices.groupBy { existing[it].role }
val byCall =
existing.indices
.filter { existing[it].toolCallId.isNotBlank() }
.groupBy { existing[it].toolCallId }
val byResult =
existing.indices
.filter { existing[it].role == MessageRole.TOOL }
.groupBy { comparison.toolKey(existing[it].content) }
val matchOrder =
incoming.indices.filter { incoming[it].role != MessageRole.ASSISTANT } +
incoming.indices.reversed().filter { incoming[it].role == MessageRole.ASSISTANT }
matchOrder.forEach { index ->
val message = incoming[index]
if (matches[index] != null) return@forEach
val candidates =
if (message.role == MessageRole.TOOL) {
val callMatches = byCall[message.toolCallId].orEmpty()
callMatches + byResult[comparison.toolKey(message.content)].orEmpty()
} else {
// Prefix matching scans only candidates with the same role.
byRole[message.role].orEmpty().let {
if (message.role == MessageRole.ASSISTANT) it.asReversed() else it
}
}
val match =
candidates.firstOrNull { candidate ->
val other = existing[candidate]
!used[candidate] &&
(
allowAssistantContentMatches || message.role != MessageRole.ASSISTANT ||
message.completionId != null || other.completionId != null
) &&
// Distinct IDs within the same source are separate occurrences, not echoes.
((message.canonicalRestId == null) != (other.canonicalRestId == null)) &&
comparison.same(message, other)
}
if (match != null) {
used[match] = true
matches[index] = existing[match]
}
}
return matches.toList()
}
/**
@@ -127,7 +231,7 @@ internal fun sameLogicalMessage(
* run_agent). Blank lines left behind are dropped too.
*/
internal fun stripAttachmentRefLines(content: String): String =
content
stripGatewayAttachedContext(content)
.lines()
.map { it.trim() }
.filterNot { line ->
@@ -144,55 +248,154 @@ internal fun stripAttachmentRefLines(content: String): String =
* same call twice. Drop the `rest-` copy whenever a WS copy of the same
* logical message exists (issue #771).
*/
internal fun dedupeCachedMessages(messages: List<ChatMessage>): List<ChatMessage> {
val rest = messages.filter { it.id.startsWith("rest-") }
if (rest.isEmpty()) return messages
val nonRest = messages.filterNot { it.id.startsWith("rest-") }
if (nonRest.isEmpty()) return messages
val keepRest = rest.filter { restMsg -> nonRest.none { sameLogicalMessage(it, restMsg) } }
return (nonRest + keepRest).sortedBy { it.timestamp }
internal fun dedupeCachedMessages(
messages: List<ChatMessage>,
confirmedOnly: Boolean = false,
): List<ChatMessage> {
val unique = messages.dedupeById()
val rest = unique.filter { it.id.startsWith("rest-") }
val live = unique.filterNot { it.id.startsWith("rest-") }
if (rest.isEmpty() || live.isEmpty()) return unique
val matches =
matchTranscriptMessages(rest, live).mapIndexed { index, match ->
match?.takeIf {
!confirmedOnly || rest[index].canonicalRestId == it.canonicalRestId ||
(rest[index].completionId != null && rest[index].completionId == it.completionId)
}
}
val echoes =
rest.indices
.filter { matches[it] != null }
.map { rest[it].id }
.toSet()
// #859: retain the REST identity when keeping a rich UUID row, including across cache pages.
val aliases =
rest.indices
.mapNotNull { index ->
matches[index]?.id?.let { it to rest[index] }
}.toMap()
return unique.filterNot { it.id in echoes }.map { message ->
aliases[message.id]?.let {
message.copy(restId = it.canonicalRestId, completionId = message.completionId ?: it.completionId)
} ?: message
}
}
/**
* A transcript reload must NOT yank live WS bubbles the server has not
* persisted yet. The gateway stores a tool row only once the tool
* COMPLETES server-side, so a reload that lands while a tool is running
* (app background/foreground mid-turn, reconnect re-resume, pull-refresh)
* returns a page without the tool row — replacing the list outright made
* the in-flight tool bubble vanish and left tool.complete with no RUNNING
* message to update (issue #771).
*
* Merge instead of replace: append any current message the REST page does
* not already cover (checked by id AND logical content via
* [sameLogicalMessage]) and keep chronological order.
*/
/** Cache pages enrich confirmed identities without changing keys already on screen (#859). */
internal fun mergeCachedTranscriptPage(
page: List<ChatMessage>,
current: List<ChatMessage>,
): List<ChatMessage> {
val currentById = current.associateBy { it.id }
// Legacy cached UUIDs may lack an alias. Restore a known alias before page-local matching
// so an older identical REST row cannot claim that UUID again.
val incoming =
dedupeCachedMessages(
page.map { message ->
currentById[message.id]?.restId?.let { message.copy(restId = it) } ?: message
},
)
val matches = matchTranscriptMessages(incoming, current)
val replacements =
incoming
.mapIndexedNotNull { index, message ->
val match = matches[index] ?: return@mapIndexedNotNull null
val rich = if (match.id.startsWith("rest-") && !message.id.startsWith("rest-")) message else match
match.id to
rich.copy(
id = match.id,
restId = message.canonicalRestId ?: match.canonicalRestId,
completionId = match.completionId ?: message.completionId,
)
}.toMap()
return (
current.map { replacements[it.id] ?: it } +
incoming.filterIndexed { index, _ -> matches[index] == null }
).dedupeById().inTranscriptOrder().reconcileReasoningRows()
}
/** Merge one page with the current snapshot without consuming repeated results more than once. */
internal fun mergeTranscriptWithLive(
restMessages: List<ChatMessage>,
currentMessages: List<ChatMessage>,
chronological: Boolean = true,
preserveLiveIds: Boolean = false,
): List<ChatMessage> {
val dedupedRest = restMessages.dedupeById()
val dedupedCurrent = currentMessages.dedupeById()
// User rows can carry live/cache-only metadata (for example whether the
// bubble continues the active turn). Keep that richer copy when REST
// returns the same logical row.
val currentUsers = dedupedCurrent.filter { it.role == MessageRole.USER }.toMutableList()
val mergedRest =
dedupedRest.map { rest ->
if (rest.role != MessageRole.USER) return@map rest
val matchIndex =
currentUsers.indexOfFirst { it.id == rest.id }.takeIf { it >= 0 }
?: currentUsers.indexOfFirst { sameLogicalMessage(rest, it) }
if (matchIndex >= 0) currentUsers.removeAt(matchIndex) else rest
val incoming = restMessages.dedupeById()
val current = currentMessages.dedupeById()
val matches = matchTranscriptMessages(incoming, current, allowAssistantContentMatches = chronological)
val consumed = matches.mapNotNull { it?.id }.toSet()
val merged =
incoming.mapIndexed { index, message ->
val match = matches[index]
// Keep local user metadata and stable IDs already used by the renderer.
when {
match?.role == MessageRole.USER -> {
match.copy(
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
)
}
preserveLiveIds && match != null && !match.id.startsWith("rest-") -> {
match.copy(
restId = message.canonicalRestId ?: match.canonicalRestId,
content = message.content,
timestamp = message.timestamp,
isStreaming = message.isStreaming,
reasoningText = message.reasoningText.ifBlank { match.reasoningText },
attachments = message.attachments ?: match.attachments,
toolName = message.toolName ?: match.toolName,
toolCallId = message.toolCallId.ifBlank { match.toolCallId },
toolStatus = message.toolStatus ?: match.toolStatus,
displayKind = message.displayKind ?: match.displayKind,
tokenCount = message.tokenCount ?: match.tokenCount,
completionId = message.completionId ?: match.completionId,
)
}
else -> {
message.copy(completionId = message.completionId ?: match?.completionId)
}
}
}
val restIds = dedupedRest.map { it.id }.toSet()
val liveTail =
dedupedCurrent.filter { old ->
old.id !in restIds && mergedRest.none { sameLogicalMessage(it, old) }
val transcript =
if (chronological) {
current.filterNot { it.id in consumed } + merged
} else {
merged + current.filterNot { it.id in consumed }
}
if (liveTail.isEmpty()) return mergedRest.dedupeById()
return (mergedRest + liveTail).dedupeById().sortedBy { it.timestamp }
// #129: a mapped completion can confirm a cached REST row while its rich UUID
// copy is also present. Fold that now-confirmed echo without changing the live key.
// The page has already consumed its content matches; consuming another would
// collapse a separate repeated occurrence. Only confirmed identities can fold here.
return dedupeCachedMessages(transcript.inTranscriptOrder(), confirmedOnly = true).reconcileReasoningRows()
}
/** Numeric canonical history first, then unconfirmed local insertion order; clocks never sort a transcript. */
private fun List<ChatMessage>.inTranscriptOrder(): List<ChatMessage> =
sortedWith(
compareBy<ChatMessage> { it.canonicalOrder == null }
.thenBy { it.canonicalOrder ?: it.localOrder ?: Long.MAX_VALUE },
)
private val ChatMessage.canonicalOrder: Long?
get() = if (localOrder != null && restId == null) null else canonicalRestId?.substringAfterLast('-')?.toLongOrNull()
/** Retain the canonical trace once, without copying it across a user/system/tool boundary. */
private fun List<ChatMessage>.reconcileReasoningRows(): List<ChatMessage> =
mapIndexed { index, message ->
val previous = getOrNull(index - 1)
if (previous?.role == MessageRole.ASSISTANT && previous.content.isBlank() &&
previous.canonicalRestId != null && previous.reasoningText.isNotBlank() &&
message.role == MessageRole.ASSISTANT && message.content.isNotBlank() &&
message.canonicalRestId != null && message.reasoningText == previous.reasoningText
) {
message.copy(reasoningText = "")
} else {
message
}
}
/** Merge a REST page without matching it against already-settled transcript rows. */
internal fun mergeIncrementalTranscriptPage(
restMessages: List<ChatMessage>,
@@ -202,7 +405,7 @@ internal fun mergeIncrementalTranscriptPage(
): List<ChatMessage> {
val settledEnd =
currentMessages.indexOfLast { message ->
serverMessageIndex(message.id, sessionId)?.let { it < offset } == true
serverMessageIndex(message.canonicalRestId ?: message.id, sessionId)?.let { it < offset } == true
}
return (
currentMessages.take(settledEnd + 1) +
File diff suppressed because it is too large. Load diff
@@ -176,6 +176,10 @@ object ChatWsEventReducer {
is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState)
is WsEvent.ConnectionRequest,
is WsEvent.ConnectionUpdate,
-> ReducerResult(state, streamingState)
is WsEvent.Unknown -> onUnknown(state, streamingState)
// SessionInfo is a no-op in the original code
@@ -75,7 +75,9 @@ import com.m57.hermescontrol.theme.CodeTerminalMuted
import com.m57.hermescontrol.theme.CodeTerminalText
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.components.CodeTerminalCard
import com.m57.hermescontrol.ui.chat.components.DiffViewCard
import com.m57.hermescontrol.ui.chat.components.FileViewCard
import com.m57.hermescontrol.ui.chat.components.highlightSyntax
import com.m57.hermescontrol.ui.chat.tool.ToolJson
import com.m57.hermescontrol.ui.chat.tool.ToolView
@@ -423,6 +425,11 @@ private fun ExpandedToolContent(
diffText = view.inlineDiff,
filePath = view.diffPath,
)
} else if (view.fileContent != null) {
FileViewCard(
content = view.fileContent,
filePath = view.filePath,
)
}
// ── Search hits ──
@@ -478,7 +485,7 @@ private fun ExpandedToolContent(
}
// ── Plain detail body ──
if (view.detail.isNotBlank() && view.inlineDiff == null) {
if (view.detail.isNotBlank() && view.inlineDiff == null && view.fileContent == null) {
Text(
text = view.detail,
modifier =
@@ -495,6 +502,13 @@ private fun ExpandedToolContent(
}
}
view.outputCut?.let { omitted ->
Text(
text = stringResource(R.string.chat_tool_output_omitted, omitted),
style = MaterialTheme.typography.labelSmall.copy(color = statusColors.warning),
)
}
// ── Duration footer ──
view.durationLabel?.let {
Text(
@@ -629,99 +643,49 @@ private fun ToolRawJsonView(
}
}
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
var copied by remember { mutableStateOf(false) }
LaunchedEffect(copied) {
if (copied) {
delay(2000)
copied = false
}
}
Surface(
modifier =
modifier
.fillMaxWidth()
.testTag("tool_raw_json"),
shape = RoundedCornerShape(8.dp),
color = CodeTerminalBg,
border = BorderStroke(1.dp, CodeTerminalBorder),
CodeTerminalCard(
textToCopy = displayText,
modifier = modifier,
testTag = "tool_raw_json",
title = "JSON",
copyContentDescription = stringResource(R.string.content_desc_copy),
headerActions = {
if (isFormatDifferent) {
Text(
text =
if (formatJson) {
stringResource(R.string.chat_tool_compact_json)
} else {
stringResource(R.string.chat_tool_format_json)
},
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
),
modifier =
Modifier
.testTag("tool_json_format_toggle")
.clickable(role = Role.Button) { formatJson = !formatJson }
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
},
) {
Column {
Row(
SelectionContainer {
Text(
text = highlighted,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "JSON",
style =
MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
color = CodeTerminalMuted,
),
)
Spacer(Modifier.weight(1f))
if (isFormatDifferent) {
Text(
text =
if (formatJson) {
stringResource(R.string.chat_tool_compact_json)
} else {
stringResource(R.string.chat_tool_format_json)
},
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
),
modifier =
Modifier
.testTag("tool_json_format_toggle")
.clickable(role = Role.Button) { formatJson = !formatJson }
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, displayText)))
}
copied = true
},
modifier = Modifier.size(28.dp),
) {
Icon(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
contentDescription =
if (copied) {
stringResource(R.string.content_desc_copied)
} else {
stringResource(R.string.content_desc_copy)
},
tint = CodeTerminalMuted,
modifier = Modifier.size(14.dp),
)
}
}
SelectionContainer {
Text(
text = highlighted,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 280.dp)
.verticalScroll(rememberScrollState())
.padding(horizontal = 10.dp, vertical = 6.dp),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = CodeTerminalText,
softWrap = true,
)
}
.heightIn(max = 280.dp)
.verticalScroll(rememberScrollState())
.padding(horizontal = 10.dp, vertical = 6.dp),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = CodeTerminalText,
softWrap = true,
)
}
}
}
@@ -0,0 +1,29 @@
package com.m57.hermescontrol.ui.chat.components
/** One older-page request per deliberate upward gesture, including an overscroll at index zero. */
internal class ChatHistoryPrefetch {
private var upwardPixels = 0f
private var requested = false
fun onScroll(
firstVisibleIndex: Int,
deltaY: Float,
userInput: Boolean,
canLoad: Boolean,
): Boolean {
if (userInput && deltaY < 0f) upwardPixels = 0f
if (deltaY <= 0f || !canLoad || requested) return false
// A fling retains the drag's intent until scrolling stops. Programmatic
// scrolling without an upward user gesture must never load history.
if (!userInput && upwardPixels == 0f) return false
upwardPixels += deltaY
if (firstVisibleIndex > 25 || upwardPixels < 24f) return false
requested = true
return true
}
fun endGesture() {
upwardPixels = 0f
requested = false
}
}
@@ -33,9 +33,9 @@ import kotlinx.coroutines.yield
* 5. **Unread indicator** — when follow is paused, [pendingCount] accumulates the
* number of tail updates; shows on the FAB. Tapping the FAB resumes following
* and clears the count.
* 6. **Paging anchor preservation** — capture the first visible item + offset
* with [captureAnchor] before prepending older history, then
* [restoreAnchor] after insertion so the same content stays under the eye.
* 6. **Paging anchors** — the full-bleed renderer preserves the live lazy row
* key and offset when older history is inserted; this controller owns no
* fetch-time anchor or animated compensation.
* 7. **Serialized scroll commands** — every scroll (send, FAB, session switch,
* search navigation, auto-follow) is launched from [scope] so animations
* don't compete.
@@ -158,11 +158,11 @@ class ChatScrollController(
* item count still hasn't caught up (rare, heavy layout), wait briefly
* via snapshotFlow on the layout info.
*/
private suspend fun scrollToBottomAwaitingLayout() {
private suspend fun scrollToBottomAwaitingLayout(animated: Boolean = false) {
// Yield twice: first for recomposition, second for layout.
yield()
yield()
listState.scrollToBottom(animated = false)
listState.scrollToBottom(animated = animated)
// If we're still not at the bottom after the first scroll (e.g. a
// new item was laid out between the scroll and now), do one more pass.
if (!listState.isAtBottom(bottomPixelTolerance)) {
@@ -171,7 +171,7 @@ class ChatScrollController(
snapshotFlow { listState.layoutInfo.totalItemsCount }
.first { it > 0 }
}
listState.scrollToBottom(animated = false)
listState.scrollToBottom(animated = animated)
}
}
@@ -182,37 +182,24 @@ class ChatScrollController(
scope.launch {
isProgrammaticScroll = true
try {
listState.scrollToBottom(animated = animated)
scrollToBottomAwaitingLayout(animated = animated)
} finally {
isProgrammaticScroll = false
}
}
}
/** An explicit history gesture must not be undone by a short list's bottom-follow. */
fun pauseFollowing() {
isFollowingBottom = false
}
/** FAB tap: resume following + clear unread. */
fun resumeFollowing() = jumpToBottom(animated = true)
/** True when the FAB should be visible (not following bottom + content exists). */
fun showFab(contentPresent: Boolean): Boolean = !isFollowingBottom && contentPresent
/**
* Current pixel scroll offset of the first visible item — captured before
* prepending older history so it can be restored after insertion.
*/
fun captureAnchorOffset(): Int = listState.firstVisibleItemScrollOffset
/**
* Restore the viewport to a specific item (resolved by id in the caller, so
* the restore is robust even if streaming tokens arrive during the page
* insert). Keeps the reader's eye on the same content after prepend.
*/
fun scrollToItem(
index: Int,
offset: Int,
) {
scope.launch { listState.scrollToItem(index, offset) }
}
/**
* Navigate to a search match (serialized through [scope]).
*
@@ -258,7 +245,7 @@ fun tailContentKey(
clarifyRequest: Any?,
): Any =
listOf(
messages.size,
messages.lastOrNull(),
streamingMessage?.hashCode() ?: 0,
isThinking,
subagentIndicators.size,
@@ -0,0 +1,220 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.ChatTimelineState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatTimelineSheet(
state: ChatTimelineState,
onDismiss: () -> Unit,
onJump: (Int) -> Unit,
onLoadMore: () -> Unit,
onRetry: () -> Unit,
) {
if (!state.isOpen) return
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
) {
Text(
text = stringResource(R.string.chat_timeline_title),
modifier = Modifier.padding(horizontal = 20.dp),
style = MaterialTheme.typography.titleLarge,
)
Text(
text = stringResource(R.string.chat_timeline_subtitle),
modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val error = state.windowErrorMessage ?: state.errorMessage
if (error != null) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = error,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
if (!state.isLoading && state.jumpingRowId == null) {
TextButton(onClick = onRetry) {
Text(
stringResource(
if (state.entries.isEmpty()) {
R.string.chat_timeline_retry
} else {
R.string.chat_timeline_refresh
},
),
)
}
}
}
}
when {
state.isLoading && state.entries.isEmpty() -> {
Box(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 160.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
state.entries.isEmpty() && state.errorMessage == null -> {
Text(
text = stringResource(R.string.chat_timeline_empty),
modifier = Modifier.padding(horizontal = 20.dp, vertical = 24.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
state.entries.isNotEmpty() -> {
LazyColumn(
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 520.dp),
) {
items(
items = state.entries,
key = { it.row_id },
) { entry ->
ListItem(
headlineContent = {
Text(
text =
entry.preview.ifBlank {
stringResource(R.string.chat_timeline_prompt_fallback)
},
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
},
trailingContent = {
if (state.jumpingRowId == entry.row_id) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
}
},
modifier =
Modifier.clickable(
enabled = state.jumpingRowId == null,
onClick = { onJump(entry.row_id) },
),
)
HorizontalDivider()
}
if (state.hasMore || (state.isLoading && state.entries.isNotEmpty())) {
item(key = "timeline-load-more") {
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else {
TextButton(onClick = onLoadMore) {
Text(stringResource(R.string.chat_timeline_load_more))
}
}
}
}
}
}
}
}
}
}
}
@Composable
fun ChatHistoryWindowBanner(
hasOlder: Boolean,
hasNewer: Boolean,
onReturnToLatest: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.chat_history_window_title),
style = MaterialTheme.typography.labelLarge,
)
val rangeMessage =
when {
hasOlder && hasNewer -> R.string.chat_history_window_more_both
hasNewer -> R.string.chat_history_window_more_newer
hasOlder -> R.string.chat_history_window_more_older
else -> null
}
if (rangeMessage != null) {
Text(
text = stringResource(rangeMessage),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f),
)
}
}
TextButton(onClick = onReturnToLatest) {
Text(stringResource(R.string.chat_history_window_latest))
}
}
}
}
@@ -0,0 +1,193 @@
package com.m57.hermescontrol.ui.chat.components
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.CodeTerminalBg
import com.m57.hermescontrol.theme.CodeTerminalBorder
import com.m57.hermescontrol.theme.CodeTerminalMuted
import com.m57.hermescontrol.theme.CodeTerminalText
import kotlinx.coroutines.delay
/**
* Shared container card for terminal-styled code, diff, and JSON viewers.
*
* Provides the dark terminal surface, consistent header row with leading icon/label,
* optional status badges/actions, animated copy-to-clipboard button, content body slot,
* and optional collapsible footer.
*/
@Composable
fun CodeTerminalCard(
textToCopy: String,
modifier: Modifier = Modifier,
testTag: String? = null,
title: String? = null,
icon: ImageVector? = null,
clipLabel: String? = null,
copyContentDescription: String = stringResource(R.string.content_desc_copy),
onCopy: ((String) -> Unit)? = null,
headerLeading: (@Composable RowScope.() -> Unit)? = null,
headerActions: (@Composable RowScope.() -> Unit)? = null,
totalLines: Int? = null,
collapsibleThreshold: Int = 16,
isExpanded: Boolean = false,
onToggleExpand: (() -> Unit)? = null,
expandLabel: String = "Show full",
collapseLabel: String = "Collapse",
content: @Composable () -> Unit,
) {
val context = LocalContext.current
var copied by remember { mutableStateOf(false) }
LaunchedEffect(copied) {
if (copied) {
delay(2000)
copied = false
}
}
Surface(
modifier =
modifier
.fillMaxWidth()
.then(if (testTag != null) Modifier.testTag(testTag) else Modifier),
shape = RoundedCornerShape(8.dp),
color = CodeTerminalBg,
border = BorderStroke(1.dp, CodeTerminalBorder),
) {
Column(modifier = Modifier.animateContentSize()) {
// Header bar
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
) {
if (headerLeading != null) {
headerLeading()
} else {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = CodeTerminalMuted,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(6.dp))
}
if (!title.isNullOrBlank()) {
Text(
text = title,
style =
MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
color = CodeTerminalText,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Spacer(Modifier.width(8.dp))
}
}
headerActions?.invoke(this)
}
IconButton(
onClick = {
val clipboard =
context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
clipboard?.setPrimaryClip(ClipData.newPlainText(clipLabel, textToCopy))
copied = true
onCopy?.invoke(textToCopy)
},
modifier = Modifier.size(28.dp),
) {
Icon(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
contentDescription =
if (copied) {
stringResource(R.string.content_desc_copied)
} else {
copyContentDescription
},
tint = CodeTerminalMuted,
modifier = Modifier.size(14.dp),
)
}
}
// Body
content()
// Expand / Collapse footer button
if (totalLines != null && totalLines > collapsibleThreshold && onToggleExpand != null) {
TextButton(
onClick = onToggleExpand,
modifier = Modifier.fillMaxWidth(),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = if (isExpanded) collapseLabel else expandLabel,
style = MaterialTheme.typography.labelSmall,
color = CodeTerminalMuted,
)
Spacer(Modifier.width(4.dp))
Icon(
imageVector = if (isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
tint = CodeTerminalMuted,
modifier = Modifier.size(16.dp),
)
}
}
}
}
}
}
@@ -0,0 +1,293 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
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.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.ConnectionOperationTarget
import com.m57.hermescontrol.data.model.ConnectionTargetState
import com.m57.hermescontrol.ui.chat.ConnectionOperationUiState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConnectionSetupSheet(
state: ConnectionOperationUiState,
onRespond: (String, Map<String, String>, Boolean) -> Unit,
onContinue: () -> Unit,
onOpenBrowser: (operationId: String, url: String) -> Unit,
onDismiss: () -> Unit,
) {
if (state.operation == null) return
ModalBottomSheet(
onDismissRequest = {
if (state.pendingAction == null) onDismiss()
},
modifier = Modifier.testTag("connection_setup_sheet"),
) {
ConnectionSetupContent(
state = state,
onRespond = onRespond,
onContinue = onContinue,
onOpenBrowser = onOpenBrowser,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
internal fun ConnectionSetupContent(
state: ConnectionOperationUiState,
onRespond: (String, Map<String, String>, Boolean) -> Unit,
onContinue: () -> Unit,
onOpenBrowser: (operationId: String, url: String) -> Unit,
modifier: Modifier = Modifier,
) {
val operation = state.operation ?: return
val target =
operation.targets.firstOrNull { !isTerminalTarget(it) }
?: operation.targets.firstOrNull { !it.discoveryError.isNullOrBlank() }
?: operation.targets.lastOrNull()
?: return
val targetIndex = operation.targets.indexOf(target)
val values =
remember(operation.opId, target.name) {
mutableStateMapOf<String, String>().apply {
target.requiredEnv.forEach { field ->
this[field.name] = if (field.secret) "" else field.defaultValue.orEmpty()
}
}
}
LaunchedEffect(target.requiredEnv) {
val currentNames = target.requiredEnv.mapTo(HashSet()) { it.name }
values.keys
.toList()
.filterNot(currentNames::contains)
.forEach(values::remove)
target.requiredEnv.forEach { field ->
values.putIfAbsent(field.name, if (field.secret) "" else field.defaultValue.orEmpty())
}
}
val missingRequired = target.requiredEnv.firstOrNull { it.required && values[it.name].orEmpty().isBlank() }
val pending = state.pendingAction != null
val safeUrl = target.safeConnectUrl
Column(
modifier =
modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = stringResource(R.string.connection_setup_target_title, target.name),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.testTag("connection_setup_title"),
)
Text(
text =
stringResource(
R.string.connection_setup_progress,
targetIndex + 1,
operation.targets.size,
),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
target.instructions?.takeIf { it.isNotBlank() }?.let { instructions ->
Text(instructions, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
when {
target.state == ConnectionTargetState.CONNECTED && !target.discoveryError.isNullOrBlank() -> {
Text(
text = stringResource(R.string.connection_setup_authorized_no_tools),
style = MaterialTheme.typography.titleMedium,
)
Text(target.discoveryError, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
isTerminalTarget(target) -> {
Text(
text = stringResource(R.string.connection_setup_target_resolved),
style = MaterialTheme.typography.titleMedium,
)
if (target.tools.isNotEmpty()) {
Text(
text = stringResource(R.string.connection_setup_tools_available, target.tools.size),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
target.detail?.takeIf { it.isNotBlank() }?.let { detail ->
Text(detail, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
target.state == ConnectionTargetState.INITIATED && safeUrl != null -> {
target.detail?.takeIf { it.isNotBlank() }?.let { detail ->
Text(detail, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Text(
text = safeUrl,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.testTag("connection_setup_browser_url"),
)
OutlinedButton(
onClick = { onOpenBrowser(operation.opId, safeUrl) },
enabled = !pending,
modifier =
Modifier
.fillMaxWidth()
.testTag("connection_setup_open_browser"),
) {
Text(stringResource(R.string.connection_setup_open_browser))
}
}
target.state == ConnectionTargetState.UNKNOWN ||
(target.state == ConnectionTargetState.INITIATED && target.connectUrl != null) -> {
Text(
text = stringResource(R.string.connection_setup_unsupported),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> {
target.requiredEnv.forEach { field ->
OutlinedTextField(
value = values[field.name].orEmpty(),
onValueChange = { values[field.name] = it },
enabled = !pending,
label = { Text(field.prompt ?: field.name) },
visualTransformation =
if (field.secret) PasswordVisualTransformation() else VisualTransformation.None,
singleLine = true,
modifier =
Modifier
.fillMaxWidth()
.testTag("connection_env_${field.name}"),
)
}
target.detail?.takeIf { it.isNotBlank() }?.let { detail ->
Text(
text = detail,
color =
if (target.state == ConnectionTargetState.FAILED) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
missingRequired?.let { field ->
Text(
text = stringResource(R.string.connection_setup_required, field.prompt ?: field.name),
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag("connection_setup_validation"),
)
}
Button(
onClick = {
val env = target.requiredEnv.associate { field -> field.name to values[field.name].orEmpty() }
onRespond(target.name, env, true)
},
enabled = !pending && missingRequired == null && target.state != ConnectionTargetState.UNKNOWN,
modifier =
Modifier
.fillMaxWidth()
.testTag("connection_setup_connect"),
) {
Text(stringResource(R.string.connection_setup_connect))
}
}
}
state.error?.let {
Text(
text = stringResource(R.string.connection_setup_request_failed),
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag("connection_setup_error"),
)
}
if (pending) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.testTag("connection_setup_pending"),
) {
CircularProgressIndicator()
Text(stringResource(R.string.connection_setup_pending))
}
}
ConnectionSetupActions(
target = target,
pending = pending,
onSkip = { onRespond(target.name, emptyMap(), false) },
onContinue = onContinue,
)
}
}
@Composable
private fun ConnectionSetupActions(
target: ConnectionOperationTarget,
pending: Boolean,
onSkip: () -> Unit,
onContinue: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (!isTerminalTarget(target)) {
OutlinedButton(
onClick = onSkip,
enabled = !pending,
modifier =
Modifier
.weight(1f)
.testTag("connection_setup_skip"),
) {
Text(stringResource(R.string.connection_setup_skip))
}
}
OutlinedButton(
onClick = onContinue,
enabled = !pending,
modifier =
Modifier
.weight(1f)
.testTag("connection_setup_continue"),
) {
Text(stringResource(R.string.connection_setup_continue))
}
}
}
private fun isTerminalTarget(target: ConnectionOperationTarget): Boolean =
target.state == ConnectionTargetState.CONNECTED ||
target.state == ConnectionTargetState.SKIPPED ||
target.state == ConnectionTargetState.EXPIRED ||
target.state == ConnectionTargetState.UNAVAILABLE
@@ -1,51 +1,33 @@
package com.m57.hermescontrol.ui.chat.components
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.theme.CodeDiffAddBg
@@ -54,11 +36,10 @@ import com.m57.hermescontrol.theme.CodeDiffDeleteBg
import com.m57.hermescontrol.theme.CodeDiffDeleteText
import com.m57.hermescontrol.theme.CodeDiffHunkBg
import com.m57.hermescontrol.theme.CodeDiffHunkText
import com.m57.hermescontrol.theme.CodeTerminalBg
import com.m57.hermescontrol.theme.CodeTerminalBorder
import com.m57.hermescontrol.theme.CodeTerminalMuted
import com.m57.hermescontrol.theme.CodeTerminalText
import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
enum class DiffLineType {
FILE_HEADER,
@@ -169,17 +150,8 @@ fun DiffViewCard(
onCopy: (String) -> Unit = {},
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val parsed = remember(diffText, filePath) { parseDiffText(diffText, filePath) }
var expanded by remember { mutableStateOf(false) }
var copied by remember { mutableStateOf(false) }
LaunchedEffect(copied) {
if (copied) {
delay(2000)
copied = false
}
}
val displayLines =
if (expanded || parsed.lines.size <= 16) {
@@ -188,166 +160,178 @@ fun DiffViewCard(
parsed.lines.take(16)
}
Surface(
modifier =
modifier
.fillMaxWidth()
.testTag("diff_view_card"),
shape = RoundedCornerShape(8.dp),
color = CodeTerminalBg,
border = BorderStroke(1.dp, CodeTerminalBorder),
CodeTerminalCard(
textToCopy = diffText,
modifier = modifier,
testTag = "diff_view_card",
title = parsed.filePath ?: "diff",
icon = Icons.AutoMirrored.Filled.InsertDriveFile,
clipLabel = "diff",
copyContentDescription = "Copy diff",
onCopy = onCopy,
headerActions = {
if (parsed.additionsCount > 0) {
Text(
text = "+${parsed.additionsCount}",
style =
MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
color = CodeDiffAddText,
),
)
Spacer(Modifier.width(6.dp))
}
if (parsed.deletionsCount > 0) {
Text(
text = "-${parsed.deletionsCount}",
style =
MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
color = CodeDiffDeleteText,
),
)
Spacer(Modifier.width(6.dp))
}
},
totalLines = parsed.lines.size,
isExpanded = expanded,
onToggleExpand = { expanded = !expanded },
expandLabel = "Show full diff (${parsed.lines.size} lines)",
collapseLabel = "Collapse diff",
) {
Column(modifier = Modifier.animateContentSize()) {
// Header bar
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
val verticalScrollModifier =
if (expanded) {
Modifier.heightIn(max = 400.dp).verticalScroll(rememberScrollState())
} else {
Modifier
}
Column(
modifier =
Modifier
.fillMaxWidth()
.then(verticalScrollModifier)
.horizontalScroll(rememberScrollState())
.width(IntrinsicSize.Max),
) {
displayLines.forEach { line ->
val (bgColor, textColor) =
when (line.type) {
DiffLineType.ADDED -> CodeDiffAddBg to CodeDiffAddText
DiffLineType.DELETED -> CodeDiffDeleteBg to CodeDiffDeleteText
DiffLineType.HUNK_HEADER -> CodeDiffHunkBg to CodeDiffHunkText
DiffLineType.FILE_HEADER -> Color.Transparent to CodeTerminalMuted
DiffLineType.CONTEXT -> Color.Transparent to CodeTerminalText
}
Box(
modifier =
Modifier
.fillMaxWidth()
.background(bgColor)
.padding(horizontal = 10.dp, vertical = 2.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.InsertDriveFile,
contentDescription = null,
tint = CodeTerminalMuted,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(6.dp))
Text(
text = parsed.filePath ?: "diff",
text = line.text,
style =
MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.Bold,
MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
color = CodeTerminalText,
fontSize = 11.sp,
color = textColor,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Spacer(Modifier.width(8.dp))
// Addition / Deletion badges
if (parsed.additionsCount > 0) {
Text(
text = "+${parsed.additionsCount}",
style =
MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
color = CodeDiffAddText,
),
)
Spacer(Modifier.width(6.dp))
}
if (parsed.deletionsCount > 0) {
Text(
text = "-${parsed.deletionsCount}",
style =
MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
color = CodeDiffDeleteText,
),
)
Spacer(Modifier.width(6.dp))
}
}
IconButton(
onClick = {
val clipboard =
context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
clipboard?.setPrimaryClip(ClipData.newPlainText("diff", diffText))
copied = true
onCopy(diffText)
},
modifier = Modifier.size(28.dp),
) {
Icon(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
contentDescription = if (copied) "Copied" else "Copy diff",
tint = CodeTerminalMuted,
modifier = Modifier.size(14.dp),
)
}
}
// Diff lines body
val verticalScrollModifier =
if (expanded) {
Modifier.heightIn(max = 400.dp).verticalScroll(rememberScrollState())
} else {
Modifier
}
Column(
modifier =
Modifier
.fillMaxWidth()
.then(verticalScrollModifier)
.horizontalScroll(rememberScrollState())
.width(IntrinsicSize.Max),
) {
displayLines.forEach { line ->
val (bgColor, textColor) =
when (line.type) {
DiffLineType.ADDED -> CodeDiffAddBg to CodeDiffAddText
DiffLineType.DELETED -> CodeDiffDeleteBg to CodeDiffDeleteText
DiffLineType.HUNK_HEADER -> CodeDiffHunkBg to CodeDiffHunkText
DiffLineType.FILE_HEADER -> Color.Transparent to CodeTerminalMuted
DiffLineType.CONTEXT -> Color.Transparent to CodeTerminalText
}
Box(
modifier =
Modifier
.fillMaxWidth()
.background(bgColor)
.padding(horizontal = 10.dp, vertical = 2.dp),
) {
Text(
text = line.text,
style =
MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = textColor,
),
)
}
}
}
// Expand / Collapse footer button for diffs > 16 lines
if (parsed.lines.size > 16) {
TextButton(
onClick = { expanded = !expanded },
modifier = Modifier.fillMaxWidth(),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text =
if (expanded) {
"Collapse diff"
} else {
"Show full diff (${parsed.lines.size} lines)"
},
style = MaterialTheme.typography.labelSmall,
color = CodeTerminalMuted,
)
Spacer(Modifier.width(4.dp))
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
tint = CodeTerminalMuted,
modifier = Modifier.size(16.dp),
)
}
}
}
}
}
}
/**
* Interactive file content view card for read_file & write_file tool outputs.
* Mirrors [DiffViewCard] with syntax highlighting and file path header.
*/
@Composable
fun FileViewCard(
content: String,
filePath: String? = null,
onCopy: (String) -> Unit = {},
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
val lines = remember(content) { content.lines() }
val displayText =
remember(content, expanded) {
if (expanded || lines.size <= 16) {
content
} else {
lines.take(16).joinToString("\n")
}
}
val highlighted by produceState(
initialValue = remember(displayText) { AnnotatedString(displayText) },
key1 = displayText,
) {
value =
withContext(Dispatchers.Default) {
highlightSyntax(displayText)
}
}
CodeTerminalCard(
textToCopy = content,
modifier = modifier,
testTag = "file_view_card",
title = filePath?.takeIf { it.isNotBlank() } ?: "file",
icon = Icons.AutoMirrored.Filled.InsertDriveFile,
clipLabel = "file",
copyContentDescription = "Copy file content",
onCopy = onCopy,
headerActions = {
if (lines.isNotEmpty()) {
Text(
text = "${lines.size} lines",
style =
MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
color = CodeTerminalMuted,
),
)
Spacer(Modifier.width(6.dp))
}
},
totalLines = lines.size,
isExpanded = expanded,
onToggleExpand = { expanded = !expanded },
expandLabel = "Show full file (${lines.size} lines)",
collapseLabel = "Collapse file",
) {
val verticalScrollModifier =
if (expanded) {
Modifier.heightIn(max = 400.dp).verticalScroll(rememberScrollState())
} else {
Modifier
}
SelectionContainer {
Box(
modifier =
Modifier
.fillMaxWidth()
.then(verticalScrollModifier)
.horizontalScroll(rememberScrollState())
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Text(
text = highlighted,
style =
MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = CodeTerminalText,
),
softWrap = false,
)
}
}
}
}
@@ -266,79 +266,35 @@ fun CodeBlockCard(
onCopy: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
Surface(
modifier =
modifier
.fillMaxWidth()
.testTag("code_block"),
shape = RoundedCornerShape(8.dp),
color = CodeTerminalBg,
border = BorderStroke(1.dp, CodeTerminalBorder),
val highlighted by produceState(
initialValue = remember(code) { AnnotatedString(code) },
key1 = code,
) {
Column {
// Header row: language badge (left) + copy button (right)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (!language.isNullOrBlank()) {
Text(
text = language.uppercase(),
style =
MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
color = CodeTerminalMuted,
),
)
}
Spacer(Modifier.weight(1f))
var copied by remember { mutableStateOf(false) }
LaunchedEffect(copied) {
if (copied) {
kotlinx.coroutines.delay(2000)
copied = false
}
}
IconButton(
onClick = {
copyToClipboard(context, code)
copied = true
onCopy(code)
},
modifier = Modifier.size(28.dp),
) {
Icon(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
contentDescription = if (copied) "Copied" else "Copy code",
tint = CodeTerminalMuted,
modifier = Modifier.size(14.dp),
)
}
value =
withContext(Dispatchers.Default) {
highlightSyntax(code)
}
// Code content with syntax highlighting
val highlighted by produceState(
initialValue = remember(code) { AnnotatedString(code) },
key1 = code,
) {
value =
withContext(Dispatchers.Default) {
highlightSyntax(code)
}
}
Text(
text = highlighted,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
color = CodeTerminalText,
softWrap = false,
modifier =
Modifier
.horizontalScroll(rememberScrollState())
.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
CodeTerminalCard(
textToCopy = code,
modifier = modifier,
testTag = "code_block",
title = language?.takeIf { it.isNotBlank() }?.uppercase(),
onCopy = onCopy,
copyContentDescription = "Copy code",
) {
Text(
text = highlighted,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
color = CodeTerminalText,
softWrap = false,
modifier =
Modifier
.horizontalScroll(rememberScrollState())
.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
@@ -12,9 +11,17 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Density
@@ -32,6 +39,7 @@ import com.m57.hermescontrol.ui.chat.ToolCallDivider
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
import com.m57.hermescontrol.ui.chat.components.ChatHistoryPrefetch
import com.m57.hermescontrol.ui.chat.components.ChatScrollController
import com.m57.hermescontrol.ui.chat.components.ClarifyBubble
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
@@ -93,6 +101,9 @@ fun FullBleedChatList(
savingAttachmentPath: String? = null,
openingAttachmentPath: String? = null,
onImageClick: (ImageViewerModel) -> Unit = {},
hasOlderMessages: Boolean = false,
pagingSessionId: String? = null,
onLoadOlder: () -> Unit = viewModel::loadOlderMessages,
) {
if (messages.isEmpty() && !isLoading && !isAgentTyping) {
Box(
@@ -105,19 +116,133 @@ fun FullBleedChatList(
)
}
} else {
val toolMilestones = remember(messages) { toolCallMilestones(messages) }
// Keep only the incoming older prefix out of layout during a drag/fling.
// The input remains authoritative: overlap updates, new messages and streaming
// stay live, and a session change resets the boundary immediately.
val previousFirstId = remember(listState, pagingSessionId) { mutableStateOf(messages.firstOrNull()?.id) }
val firstRenderedId = previousFirstId.value
val olderPrefixSize =
remember(messages, firstRenderedId) {
if (firstRenderedId != null && firstRenderedId != messages.firstOrNull()?.id) {
messages.indexOfFirst { it.id == firstRenderedId }.coerceAtLeast(0)
} else {
0
}
}
val hiddenPrefixSize = if (olderPrefixSize > 0 && listState.isScrollInProgress) olderPrefixSize else 0
val renderedMessages =
remember(messages, hiddenPrefixSize) {
if (hiddenPrefixSize > 0) messages.subList(hiddenPrefixSize, messages.size) else messages
}
val toolMilestones = remember(renderedMessages) { toolCallMilestones(renderedMessages) }
val settledTurns = remember(renderedMessages) { groupIntoTurns(renderedMessages) }
val settledIds = remember(renderedMessages) { renderedMessages.mapTo(HashSet()) { it.id } }
val turns =
remember(messages, streamingState.streamingMessage) {
groupIntoTurnsWithStreaming(messages, streamingState.streamingMessage)
remember(settledTurns, streamingState.streamingMessage) {
appendStreamingTurn(settledTurns, settledIds, streamingState.streamingMessage)
}
val agentStatus = deriveAgentStatus(isAgentTyping, streamingState, messages)
// messageId → LazyColumn item index. The lazy list has EXTRA items
// vs the message list (reasoning hoists, tool rows), so search-match
// scrolling must resolve the lazy index, not use the message index.
val lazyIndexById =
remember(turns, isLoadingOlder) {
messageIdToLazyIndex(turns, leadingItems = if (isLoadingOlder) 1 else 0)
val agentStatus = deriveAgentStatus(isAgentTyping, streamingState, renderedMessages)
// A single ordered definition supplies both emitted tail rows and anchor keys.
val tailItems =
buildMap<String, @Composable () -> Unit> {
agentStatus?.let { status ->
put("agent_status") {
AgentStatusIndicator(status = status)
}
}
// Clarify bubble — rendered at the very bottom
if (clarifyRequest != null) {
put("clarify_bubble") {
ClarifyBubble(
clarifyRequest = clarifyRequest,
onRespondSingle = { option -> onRespondClarify?.invoke(option) },
onRespondBatch = { answers -> onRespondClarifyBatch?.invoke(answers) },
onDismiss = { onDismissClarify?.invoke() },
)
}
}
if (vaultUnlockPrompt != null) {
put("vault_unlock_card") {
VaultUnlockCard(
prompt = vaultUnlockPrompt,
onConfirm = { password -> onRespondVaultUnlock?.invoke(password) },
onDismiss = { onDismissVaultUnlock?.invoke() },
)
}
}
if (vaultSaveLoginPrompt != null) {
put("vault_save_login_card") {
VaultSaveLoginCard(
prompt = vaultSaveLoginPrompt,
onConfirm = { identifier, password ->
onRespondVaultSaveLogin?.invoke(identifier, password)
},
onDismiss = { onDismissVaultSaveLogin?.invoke() },
)
}
}
if (vaultCodePrompt != null) {
put("vault_code_card") {
VaultCodeCard(
prompt = vaultCodePrompt,
onConfirm = { code -> onRespondVaultCode?.invoke(code) },
onDismiss = { onDismissVaultCode?.invoke() },
)
}
}
}
// custom23 prepend regression: a 150-row page can move the anchor beyond
// Compose's nearest-key lookup window. Capture the LIVE layout at insertion,
// not when fetching starts, and request its new index before the next measure.
// A staged prefix only reaches here at idle, so this cannot stop its drag/fling.
SideEffect {
val oldFirstId = previousFirstId.value
val firstId = renderedMessages.firstOrNull()?.id
if (oldFirstId != null && oldFirstId != firstId && renderedMessages.any { it.id == oldFirstId }) {
val firstVisibleIndex = listState.firstVisibleItemIndex
val anchor = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == firstVisibleIndex }
if (anchor != null) {
val newIndex = (fullBleedItemKeys(turns) + tailItems.keys).indexOf(anchor.key)
if (newIndex >= 0 && newIndex != anchor.index) {
listState.requestScrollToItem(newIndex, listState.firstVisibleItemScrollOffset)
}
}
}
previousFirstId.value = firstId
}
val prefetch = remember(pagingSessionId) { ChatHistoryPrefetch() }
val canLoad = rememberUpdatedState(hasOlderMessages && !isLoadingOlder && !isLoading)
val loadOlder = rememberUpdatedState(onLoadOlder)
val prefetchConnection =
remember(listState, prefetch, scrollController) {
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset {
if (prefetch.onScroll(
firstVisibleIndex = listState.firstVisibleItemIndex,
deltaY = available.y,
userInput = source == NestedScrollSource.UserInput,
canLoad = canLoad.value,
)
) {
scrollController.pauseFollowing()
loadOlder.value()
}
return Offset.Zero
}
}
}
LaunchedEffect(listState, prefetch) {
snapshotFlow { listState.isScrollInProgress }.collect { scrolling ->
if (!scrolling) prefetch.endGesture()
}
}
// Scroll the current search match into view, word-focused. Lives here
// (not in ChatLifecycleEffects) because only this composable knows
@@ -128,6 +253,7 @@ fun FullBleedChatList(
searchState.currentIndex,
searchState.matchIndices,
searchState.matchOffsets,
renderedMessages.firstOrNull()?.id,
) {
if (searchState.isActive &&
searchState.currentIndex >= 0 &&
@@ -135,6 +261,9 @@ fun FullBleedChatList(
) {
val messageIndex = searchState.matchIndices[searchState.currentIndex]
if (messageIndex < 0 || messageIndex >= messages.size) return@LaunchedEffect
// Search indices refer to incoming messages; resolve by id into the
// rendered rows. A hit in a staged prefix is retried when it becomes visible.
val lazyIndexById = messageIdToLazyIndex(turns)
val lazyIndex = lazyIndexById[messages[messageIndex].id] ?: return@LaunchedEffect
val contentOffset = searchState.matchOffsets.getOrElse(searchState.currentIndex) { 0 }
val contentLength = messages[messageIndex].content.length
@@ -153,202 +282,163 @@ fun FullBleedChatList(
}
CompositionLocalProvider(LocalDensity provides chatDensity) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
if (isLoadingOlder) {
item(key = "loading-older") {
Box(
modifier = Modifier.fillMaxWidth().padding(12.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
}
turns.forEach { turn ->
when (turn) {
is ChatTurn.User -> {
// Eager captures: item lambda reads these at
// composition time (lazy), so capture now.
val userMessage = turn.message
item(key = "user-${userMessage.id}") {
Column(modifier = Modifier.padding(bottom = 12.dp)) {
renderChatBubble(
message = userMessage,
searchQuery = if (searchState.isActive) searchState.query else "",
isCurrentMatch =
searchState.currentMatchId != null &&
searchState.currentMatchId == userMessage.id,
onOpenAttachment = viewModel::openAttachment,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = savingAttachmentPath,
openingAttachmentPath = openingAttachmentPath,
onImageClick = onImageClick,
messageStatsEnabled = messageStatsEnabled,
showUserMessageTokens = showUserMessageTokens,
)
}
}
}
is ChatTurn.Agent -> {
// Reasoning hoist: the turn's reasoning renders at the
// TOP of the turn — above tool rows — so thinking
// leads, then the tool work, then the answer. The
// matching prose entry renders without its own card.
val turnReasoning =
turn.entries
.filterIsInstance<AgentEntry.Prose>()
.firstOrNull { it.message.reasoningText.isNotBlank() }
if (turnReasoning != null) {
val reasoning = turnReasoning.message
item(key = "reasoning-${reasoning.id}") {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
ReasoningCard(
reasoningText = reasoning.reasoningText,
isStreaming = reasoning.isStreaming,
Box(Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().nestedScroll(prefetchConnection),
contentPadding = PaddingValues(vertical = 8.dp),
) {
turns.forEach { turn ->
when (turn) {
is ChatTurn.User -> {
// Eager captures: item lambda reads these at
// composition time (lazy), so capture now.
val userMessage = turn.message
item(key = "user-${userMessage.id}") {
Column(modifier = Modifier.padding(bottom = 12.dp)) {
renderChatBubble(
message = userMessage,
searchQuery = if (searchState.isActive) searchState.query else "",
isCurrentMatch =
searchState.currentMatchId != null &&
searchState.currentMatchId == userMessage.id,
onOpenAttachment = viewModel::openAttachment,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = savingAttachmentPath,
openingAttachmentPath = openingAttachmentPath,
onImageClick = onImageClick,
messageStatsEnabled = messageStatsEnabled,
showUserMessageTokens = showUserMessageTokens,
)
}
}
}
turn.entries.forEach { entry ->
when (entry) {
is AgentEntry.Prose -> {
val proseMessage = entry.message
val hoistedReasoning =
turnReasoning != null &&
proseMessage.id == turnReasoning.message.id
if (proseMessage.hasVisibleAgentContent()) {
item(key = "prose-${proseMessage.id}") {
Column(modifier = Modifier.padding(bottom = 12.dp)) {
if (proseMessage.isStreaming && typingEffectEnabled) {
StreamingFullBleedWithTypingEffect(
streaming = proseMessage,
typingDelayMs = typingEffectDelayMs,
showReasoning = !hoistedReasoning,
)
is ChatTurn.Agent -> {
// Reasoning hoist: the turn's reasoning renders at the
// TOP of the turn — above tool rows — so thinking
// leads, then the tool work, then the answer. The
// matching prose entry renders without its own card.
val turnReasoning =
turn.entries
.filterIsInstance<AgentEntry.Prose>()
.firstOrNull { it.message.reasoningText.isNotBlank() }
if (turnReasoning != null) {
val reasoning = turnReasoning.message
item(key = "reasoning-${reasoning.id}") {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
ReasoningCard(
reasoningText = reasoning.reasoningText,
isStreaming = reasoning.isStreaming,
)
}
}
}
turn.entries.forEach { entry ->
when (entry) {
is AgentEntry.Prose -> {
val proseMessage = entry.message
val hoistedReasoning =
turnReasoning != null &&
proseMessage.id == turnReasoning.message.id
if (!proseMessage.hasVisibleAgentContent() && !hoistedReasoning &&
proseMessage.reasoningText.isNotBlank()
) {
item(key = "reasoning-${proseMessage.id}") {
ReasoningCard(
reasoningText = proseMessage.reasoningText,
isStreaming = proseMessage.isStreaming,
)
}
}
if (proseMessage.hasVisibleAgentContent()) {
item(key = "prose-${proseMessage.id}") {
Column(modifier = Modifier.padding(bottom = 12.dp)) {
if (proseMessage.isStreaming && typingEffectEnabled) {
StreamingFullBleedWithTypingEffect(
streaming = proseMessage,
typingDelayMs = typingEffectDelayMs,
showReasoning = !hoistedReasoning,
)
} else {
FullBleedAgentMessage(
message = proseMessage,
// Highlight only bubbles that actually contain a match —
// the rest skip the highlight scan entirely.
searchQuery =
if (searchState.isActive &&
proseMessage.id in searchState.matchedIds
) {
searchState.query
} else {
""
},
isCurrentMatch =
searchState.currentMatchId != null &&
searchState.currentMatchId == proseMessage.id,
showReasoning = !hoistedReasoning,
onOpenAttachment = viewModel::openAttachment,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = savingAttachmentPath,
openingAttachmentPath = openingAttachmentPath,
canSaveAttachment = savingAttachmentPath == null,
onImageClick = onImageClick,
messageStatsEnabled = messageStatsEnabled,
showAssistantMessageTokens = showAssistantMessageTokens,
showTokensPerSecond = showTokensPerSecond,
)
}
}
}
}
}
is AgentEntry.ToolRow -> {
val toolMessage = entry.message
val milestone = toolMilestones[toolMessage.id]
item(key = "tool-${toolMessage.id}") {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
FullBleedToolRow(toolMessage)
milestone?.let { count ->
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
}
}
}
}
is AgentEntry.SystemEvent -> {
val sysMessage = entry.message
item(key = "sys-${sysMessage.id}") {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
if (sysMessage.displayKind != null) {
// Timeline marker (issue #904):
// model/personality switches and
// auto-continues render as a chip.
TimelineMarkerChip(message = sysMessage)
} else {
FullBleedAgentMessage(
message = proseMessage,
// Highlight only bubbles that actually contain a match —
// the rest skip the highlight scan entirely.
searchQuery =
if (searchState.isActive &&
proseMessage.id in searchState.matchedIds
) {
searchState.query
} else {
""
},
isCurrentMatch =
searchState.currentMatchId != null &&
searchState.currentMatchId == proseMessage.id,
showReasoning = !hoistedReasoning,
onOpenAttachment = viewModel::openAttachment,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = savingAttachmentPath,
openingAttachmentPath = openingAttachmentPath,
canSaveAttachment = savingAttachmentPath == null,
onImageClick = onImageClick,
messageStatsEnabled = messageStatsEnabled,
showAssistantMessageTokens = showAssistantMessageTokens,
showTokensPerSecond = showTokensPerSecond,
FullBleedSystemEvent(
message = sysMessage,
onRespondApproval = viewModel::respondToApproval,
)
}
}
}
}
}
is AgentEntry.ToolRow -> {
val toolMessage = entry.message
val milestone = toolMilestones[toolMessage.id]
item(key = "tool-${toolMessage.id}") {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
FullBleedToolRow(toolMessage)
milestone?.let { count ->
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
}
}
}
}
is AgentEntry.SystemEvent -> {
val sysMessage = entry.message
item(key = "sys-${sysMessage.id}") {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
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,
)
}
}
}
}
}
}
}
}
}
agentStatus?.let { status ->
item(key = "agent_status") {
AgentStatusIndicator(status = status)
tailItems.forEach { (key, content) ->
item(key = key) { content() }
}
}
// Clarify bubble — rendered at the very bottom
if (clarifyRequest != null) {
item(key = "clarify_bubble") {
ClarifyBubble(
clarifyRequest = clarifyRequest,
onRespondSingle = { option -> onRespondClarify?.invoke(option) },
onRespondBatch = { answers -> onRespondClarifyBatch?.invoke(answers) },
onDismiss = { onDismissClarify?.invoke() },
)
}
}
if (vaultUnlockPrompt != null) {
item(key = "vault_unlock_card") {
VaultUnlockCard(
prompt = vaultUnlockPrompt,
onConfirm = { password -> onRespondVaultUnlock?.invoke(password) },
onDismiss = { onDismissVaultUnlock?.invoke() },
)
}
}
if (vaultSaveLoginPrompt != null) {
item(key = "vault_save_login_card") {
VaultSaveLoginCard(
prompt = vaultSaveLoginPrompt,
onConfirm = { identifier, password ->
onRespondVaultSaveLogin?.invoke(identifier, password)
},
onDismiss = { onDismissVaultSaveLogin?.invoke() },
)
}
}
if (vaultCodePrompt != null) {
item(key = "vault_code_card") {
VaultCodeCard(
prompt = vaultCodePrompt,
onConfirm = { code -> onRespondVaultCode?.invoke(code) },
onDismiss = { onDismissVaultCode?.invoke() },
)
}
// Loading history must never become the list's first visible anchor.
if (isLoadingOlder) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.TopCenter).padding(12.dp).size(24.dp),
)
}
}
}
@@ -128,19 +128,22 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
fun groupIntoTurnsWithStreaming(
messages: List<ChatMessage>,
streamingMessage: ChatMessage?,
): List<ChatTurn> = appendStreamingTurn(groupIntoTurns(messages), messages.mapTo(HashSet()) { it.id }, streamingMessage)
/** Reuse every settled turn; only the last agent turn needs a changing streaming entry. */
internal fun appendStreamingTurn(
settled: List<ChatTurn>,
settledIds: Set<String>,
streamingMessage: ChatMessage?,
): List<ChatTurn> {
if (streamingMessage == null || messages.any { it.id == streamingMessage.id }) {
return groupIntoTurns(messages)
}
val turns = groupIntoTurns(messages).toMutableList()
if (streamingMessage == null || streamingMessage.id in settledIds) return settled
val prose = AgentEntry.Prose(streamingMessage)
val last = turns.lastOrNull()
if (last is ChatTurn.Agent) {
turns[turns.lastIndex] = ChatTurn.Agent(last.entries + prose)
val last = settled.lastOrNull()
return if (last is ChatTurn.Agent) {
settled.dropLast(1) + ChatTurn.Agent(last.entries + prose)
} else {
turns += ChatTurn.Agent(listOf(prose))
settled + ChatTurn.Agent(listOf(prose))
}
return turns
}
/**
@@ -156,8 +159,9 @@ fun groupIntoTurnsWithStreaming(
*
* Mirrors the item emission order in [FullBleedChatList] exactly:
* user turn → 1 item; agent turn → optional reasoning item, then one item per
* visible entry. [leadingItems] accounts for fixed items emitted before the
* turns (e.g. the `loading-older` spinner when paging).
* visible entry, including later reasoning-only rows at their own positions.
* [leadingItems] accounts for fixed items emitted before the
* turns. The history spinner is an overlay and consumes no index.
*
* @return messageId → LazyColumn item index of the message's content item
* (prose items for agent messages; user items for user messages).
@@ -165,48 +169,54 @@ fun groupIntoTurnsWithStreaming(
fun messageIdToLazyIndex(
turns: List<ChatTurn>,
leadingItems: Int = 0,
): Map<String, Int> {
val map = mutableMapOf<String, Int>()
var itemIndex = leadingItems
turns.forEach { turn ->
when (turn) {
is ChatTurn.User -> {
map[turn.message.id] = itemIndex
itemIndex++
): Map<String, Int> =
fullBleedItemKeys(turns)
.mapIndexedNotNull { index, key ->
if (key.startsWith("user-") || key.startsWith("prose-")) {
key.substringAfter('-') to index + leadingItems
} else {
null
}
}.toMap()
is ChatTurn.Agent -> {
val hasReasoning =
turn.entries
.filterIsInstance<AgentEntry.Prose>()
.any { it.message.reasoningText.isNotBlank() }
if (hasReasoning) {
// The reasoning-<id> hoist item occupies one slot.
itemIndex++
/** Lazy row identities, including hoisted reasoning and grouped tool/system entries. */
internal fun fullBleedItemKeys(turns: List<ChatTurn>): List<String> =
buildList {
turns.forEach { turn ->
when (turn) {
is ChatTurn.User -> {
add("user-${turn.message.id}")
}
turn.entries.forEach { entry ->
when (entry) {
is AgentEntry.Prose -> {
if (entry.message.hasVisibleAgentContent()) {
map[entry.message.id] = itemIndex
itemIndex++
is ChatTurn.Agent -> {
val hoisted =
turn.entries
.filterIsInstance<AgentEntry.Prose>()
.firstOrNull { it.message.reasoningText.isNotBlank() }
hoisted?.let { add("reasoning-${it.message.id}") }
turn.entries.forEach { entry ->
when (entry) {
is AgentEntry.Prose -> {
if (entry.message.hasVisibleAgentContent()) {
add("prose-${entry.message.id}")
} else if (entry != hoisted && entry.message.reasoningText.isNotBlank()) {
add("reasoning-${entry.message.id}")
}
}
}
is AgentEntry.ToolRow -> {
itemIndex++
}
is AgentEntry.ToolRow -> {
add("tool-${entry.message.id}")
}
is AgentEntry.SystemEvent -> {
itemIndex++
is AgentEntry.SystemEvent -> {
add("sys-${entry.message.id}")
}
}
}
}
}
}
}
return map
}
/**
* Resolve the message id of the CURRENT search match once, so per-item
@@ -41,6 +41,8 @@ internal interface ToolRenderer {
*/
internal data class ToolViewExtras(
val detailLabel: String? = null,
/** Process output characters omitted by the backend. */
val outputCut: Long? = null,
val stdout: String? = null,
val stderr: String? = null,
val exitCode: Int? = null,
@@ -48,6 +50,8 @@ internal data class ToolViewExtras(
val inlineDiff: String? = null,
val diffPath: String? = null,
val diffStats: DiffStats? = null,
val fileContent: String? = null,
val filePath: String? = null,
val searchHits: List<SearchHit>? = null,
val searchQuery: String? = null,
) {
@@ -28,6 +28,7 @@ import com.m57.hermescontrol.ui.chat.tool.render.SkillsListRenderer
import com.m57.hermescontrol.ui.chat.tool.render.TerminalRenderer
import com.m57.hermescontrol.ui.chat.tool.render.TextToSpeechRenderer
import com.m57.hermescontrol.ui.chat.tool.render.TodoRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ToolDescribeRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ToolSearchRenderer
import com.m57.hermescontrol.ui.chat.tool.render.VisionAnalyzeRenderer
import com.m57.hermescontrol.ui.chat.tool.render.WebExtractRenderer
@@ -91,6 +92,7 @@ internal object ToolRendererRegistry {
"skill_view" to SkillViewRenderer,
"skill_manage" to SkillManageRenderer,
"tool_search" to ToolSearchRenderer,
"tool_describe" to ToolDescribeRenderer,
// Media
"image_generate" to ImageGenerateRenderer,
"vision_analyze" to VisionAnalyzeRenderer,
@@ -35,6 +35,8 @@ data class ToolView(
val detail: String = "",
/** Optional section label above [detail] (e.g. "Search results"). */
val detailLabel: String? = null,
/** Process output characters omitted by the backend. */
val outputCut: Long? = null,
/** Compact count, e.g. "3 results". */
val countLabel: String? = null,
/** Human duration, e.g. "1.2s". */
@@ -52,6 +54,9 @@ data class ToolView(
/** File-edit tools: the file the diff applies to. */
val diffPath: String? = null,
val diffStats: DiffStats? = null,
/** File contents shown in a syntax-highlighted code card. */
val fileContent: String? = null,
val filePath: String? = null,
/** Image-producing tools: renderable URL (data: or http(s) image). */
val imageUrl: String? = null,
/** web_search: parsed result rows. */
@@ -106,6 +106,7 @@ object ToolViewBuilder {
subtitle = subtitle,
detail = clampedDetail,
detailLabel = extras.detailLabel,
outputCut = extras.outputCut,
countLabel = countLabel?.let { (count, noun) -> "$count ${ToolCounts.pluralizeNoun(noun, count)}" },
durationLabel = ToolJson.durationLabel(call.result),
stdout = clampedStdout,
@@ -115,6 +116,8 @@ object ToolViewBuilder {
inlineDiff = clampedDiff,
diffPath = extras.diffPath,
diffStats = extras.diffStats,
fileContent = extras.fileContent?.let { ToolJson.clampForDisplay(it) },
filePath = extras.filePath,
imageUrl = imageUrlFor(call.args, call.result),
searchHits = extras.searchHits,
searchQuery = extras.searchQuery,
@@ -153,7 +156,7 @@ object ToolViewBuilder {
): String? {
val candidate =
ToolJson
.firstString(result, listOf("image_url", "url", "path", "image_path"))
.firstString(result, listOf("image_url", "image", "url", "path", "image_path"))
.ifEmpty { ToolJson.firstString(args, listOf("image_url", "url", "path")) }
if (candidate.isEmpty()) {
@@ -158,6 +158,21 @@ internal object ReadFileRenderer : ToolRenderer {
return ToolJson.firstString(call.result, listOf("content", "text", "data", "body"))
}
override fun extras(
call: ToolCall,
status: ToolViewStatus,
): ToolViewExtras {
val content = ToolJson.firstString(call.result, listOf("content", "text", "data", "body"))
return if (content.isEmpty()) {
ToolViewExtras.NONE
} else {
ToolViewExtras(
fileContent = content,
filePath = ToolJson.firstString(call.args, listOf("path", "file", "filepath")),
)
}
}
}
/**
@@ -196,7 +211,22 @@ internal object FileEditRenderer : ToolRenderer {
val inlineDiff = FileEditSupport.inlineDiffFromResult(call.rawResult)
if (inlineDiff.isEmpty()) {
return ToolViewExtras.NONE
val content =
if (call.name == "write_file" && status == ToolViewStatus.SUCCESS && call.rawResult != null) {
ToolJson
.firstString(call.args, listOf("content"))
.ifEmpty { ToolJson.firstString(call.result, listOf("content", "text", "body")) }
} else {
""
}
return if (content.isEmpty()) {
ToolViewExtras.NONE
} else {
ToolViewExtras(
fileContent = content,
filePath = FileEditSupport.editPath(call.args, call.result),
)
}
}
return ToolViewExtras(
@@ -118,21 +118,91 @@ internal object SkillManageRenderer : ToolRenderer {
/** `tool_search`: on-demand tool discovery matches. */
internal object ToolSearchRenderer : ToolRenderer {
override fun subtitle(call: ToolCall): String {
val query = ToolJson.firstString(call.args, listOf("query"))
val matches = (call.result?.get("matches") as? JsonArray)?.size
val query =
ToolJson
.firstString(call.args, listOf("query"))
.ifEmpty { (call.args?.get("queries") as? JsonArray)?.joinToString(", ") { text(it) } ?: "" }
val matches =
(call.result?.get("results") as? JsonArray)?.sumOf { row ->
(ToolJson.parseMaybeObject(row)?.get("matches") as? JsonArray)?.size ?: 0
} ?: (call.result?.get("matches") as? JsonArray)?.size
return if (matches != null) "$query ($matches matches)" else query
return if (matches != null) "$query ($matches ${if (matches == 1) "match" else "matches"})" else query
}
override fun detail(call: ToolCall): String {
val tools = call.result?.get("tools") as? JsonObject
val matches =
(call.result?.get("results") as? JsonArray)
?.flatMap { row -> (ToolJson.parseMaybeObject(row)?.get("matches") as? JsonArray).orEmpty() }
?: (call.result?.get("matches") as? JsonArray).orEmpty()
return matches
.mapNotNull { match ->
val item = ToolJson.parseMaybeObject(match)
val name = item?.let { ToolJson.firstString(it, listOf("name")) } ?: text(match)
if (name.isEmpty()) return@mapNotNull null
val metadata = tools?.get(name) as? JsonObject
val desc =
ToolJson
.firstString(metadata, listOf("description"))
.ifEmpty { ToolJson.firstString(item, listOf("description")) }
val source = ToolJson.firstString(metadata, listOf("source_name"))
val required = (metadata?.get("required") as? JsonArray)?.joinToString(", ") { text(it) }.orEmpty()
buildString {
append("🔧 $name")
if (source.isNotEmpty()) append(" · $source")
if (desc.isNotEmpty()) append("\n $desc")
if (required.isNotEmpty()) append("\n Required: $required")
}
}.distinct()
.joinToString("\n\n")
.ifEmpty { "No matching tools" }
}
}
/** `tool_describe`: readable schema summaries instead of a collapsed JSON object. */
internal object ToolDescribeRenderer : ToolRenderer {
override fun subtitle(call: ToolCall): String {
val names = (call.args?.get("names") as? JsonArray)?.joinToString(", ") { text(it) }.orEmpty()
val count = (call.result?.get("tools") as? JsonObject)?.size
return if (count != null) "$names ($count ${if (count == 1) "tool" else "tools"})" else names
}
override fun detail(call: ToolCall): String =
(call.result?.get("matches") as? JsonArray)
?.mapNotNull { el ->
val m = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
val name = ToolJson.firstString(m, listOf("name"))
val desc = ToolJson.firstString(m, listOf("description"))
val lines = mutableListOf("🔧 $name")
if (desc.isNotEmpty()) lines += " $desc"
lines.joinToString("\n")
}?.joinToString("\n\n")
?: "No matching tools"
(call.result?.get("tools") as? JsonObject)
?.entries
?.joinToString("\n\n") { (name, value) ->
val tool = ToolJson.parseMaybeObject(value)
val desc = ToolJson.firstString(tool, listOf("description"))
val schema = tool?.get("parameters") as? JsonObject
val properties = schema?.get("properties") as? JsonObject
val required = (schema?.get("required") as? JsonArray)?.map(::text).orEmpty().toSet()
buildString {
append("🔧 $name")
if (desc.isNotEmpty()) append("\n $desc")
if (!properties.isNullOrEmpty()) {
append("\n Parameters: ")
append(
properties.entries.joinToString("; ") { (paramName, value) ->
val param = ToolJson.parseMaybeObject(value)
val type = ToolJson.firstString(param, listOf("type"))
val help = ToolJson.firstString(param, listOf("description"))
val default = param?.get("default")
buildString {
append(paramName)
if (type.isNotEmpty()) append(" ($type)")
if (paramName in required) append(" *")
if (help.isNotEmpty()) append(" — $help")
if (default != null) append(" [default: ${text(default)}]")
}
},
)
} else if (required.isNotEmpty()) {
append("\n Required: ${required.joinToString(", ")}")
}
}
}?.ifEmpty { "No tool descriptions returned" } ?: "No tool descriptions returned"
}
private fun text(value: kotlinx.serialization.json.JsonElement): String =
(value as? JsonPrimitive)?.content ?: value.toString()
@@ -3,9 +3,12 @@ package com.m57.hermescontrol.ui.chat.tool.render
import com.m57.hermescontrol.ui.chat.tool.ToolCall
import com.m57.hermescontrol.ui.chat.tool.ToolJson
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
import com.m57.hermescontrol.ui.chat.tool.ToolViewExtras
import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.longOrNull
/** `cronjob`: job listings with schedules, or the mutated job's key fields. */
internal object CronjobRenderer : ToolRenderer {
@@ -214,6 +217,15 @@ internal object SessionSearchRenderer : ToolRenderer {
/** `process`: background session listings and lifecycle acks. */
internal object ProcessRenderer : ToolRenderer {
override fun extras(
call: ToolCall,
status: ToolViewStatus,
): ToolViewExtras {
if (status == ToolViewStatus.RUNNING) return ToolViewExtras.NONE
val count = (call.result?.get("output_cut") as? JsonPrimitive)?.takeUnless { it.isString }?.longOrNull
return ToolViewExtras(outputCut = count?.takeIf { it > 0 })
}
override fun subtitle(call: ToolCall): String {
val action = ToolJson.firstString(call.args, listOf("action"))
val procId = ToolJson.firstString(call.args, listOf("session_id"))
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -119,10 +120,22 @@ fun KanbanScreen(
var selectedAssignee by remember { mutableStateOf<String?>(null) }
var selectedTenant by remember { mutableStateOf<String?>(null) }
var showFilterSheet by remember { mutableStateOf(false) }
var showWorkersDialog by remember { mutableStateOf(false) }
val filteredTasks =
remember(query, state.tasks, selectedAssignee, selectedTenant) {
state.tasks.filter { task ->
remember(
query,
state.tasks,
selectedAssignee,
selectedTenant,
state.selectedWorkflowTemplateId,
state.selectedCurrentStepKey,
) {
filterKanbanTasks(
state.tasks,
state.selectedWorkflowTemplateId,
state.selectedCurrentStepKey,
).filter { task ->
val matchesQuery =
query.isBlank() ||
task.title.contains(query, ignoreCase = true) ||
@@ -151,6 +164,7 @@ fun KanbanScreen(
var isMultiSelectMode by remember { mutableStateOf(false) }
var selectedTaskIds by remember { mutableStateOf(setOf<String>()) }
var showBulkMoveDialog by remember { mutableStateOf(false) }
var showBulkCompleteDialog by remember { mutableStateOf(false) }
var showBulkAssignDialog by remember { mutableStateOf(false) }
var showBulkDeleteDialog by remember { mutableStateOf(false) }
@@ -330,6 +344,14 @@ fun KanbanScreen(
},
)
if (state.selectedBoard != null) {
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_active_workers)) },
onClick = {
showBoardMenu = false
showWorkersDialog = true
viewModel.loadActiveWorkers()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_board_settings)) },
onClick = {
@@ -916,10 +938,23 @@ fun KanbanScreen(
tenants = tenants,
selectedAssignee = selectedAssignee,
selectedTenant = selectedTenant,
workflowTemplateIds =
state.tasks
.mapNotNull { it.workflowTemplateId }
.distinct()
.sorted(),
currentStepKeys =
state.tasks
.mapNotNull { it.currentStepKey }
.distinct()
.sorted(),
selectedWorkflowTemplateId = state.selectedWorkflowTemplateId,
selectedCurrentStepKey = state.selectedCurrentStepKey,
includeArchived = state.includeArchived,
groupRunning = state.groupRunning,
onSelectAssignee = { selectedAssignee = it },
onSelectTenant = { selectedTenant = it },
onSelectWorkflowFilters = viewModel::setWorkflowFilters,
onToggleIncludeArchived = viewModel::setIncludeArchived,
onToggleGroupRunning = viewModel::setGroupRunning,
onClearFilters = {
@@ -927,6 +962,7 @@ fun KanbanScreen(
selectedTenant = null
viewModel.setIncludeArchived(false)
viewModel.setGroupRunning(false)
viewModel.setWorkflowFilters(null, null)
},
onDismiss = { showFilterSheet = false },
)
@@ -1052,9 +1088,13 @@ fun KanbanScreen(
writableMoveColumns.forEach { col ->
TextButton(
onClick = {
viewModel.bulkMove(selectedTaskIds.toList(), col.name) { failed ->
selectedTaskIds = failed
if (failed.isEmpty()) isMultiSelectMode = false
if (col.name.equals("done", ignoreCase = true)) {
showBulkCompleteDialog = true
} else {
viewModel.bulkMove(selectedTaskIds.toList(), col.name) { failed ->
selectedTaskIds = failed
if (failed.isEmpty()) isMultiSelectMode = false
}
}
showBulkMoveDialog = false
},
@@ -1074,6 +1114,24 @@ fun KanbanScreen(
)
}
if (showBulkCompleteDialog) {
BulkCompleteTasksDialog(
taskCount = selectedTaskIds.size,
onConfirm = { summary ->
viewModel.bulkMove(
taskIds = selectedTaskIds.toList(),
targetStatus = "done",
summary = summary,
) { failed ->
selectedTaskIds = failed
if (failed.isEmpty()) isMultiSelectMode = false
}
showBulkCompleteDialog = false
},
onDismiss = { showBulkCompleteDialog = false },
)
}
if (showBulkAssignDialog) {
AlertDialog(
onDismissRequest = { showBulkAssignDialog = false },
@@ -1268,6 +1326,46 @@ fun KanbanScreen(
)
}
if (showWorkersDialog) {
AlertDialog(
onDismissRequest = { showWorkersDialog = false },
title = { Text(stringResource(R.string.kanban_active_workers)) },
text = {
if (state.isLoadingWorkers) {
CircularProgressIndicator()
} else if (state.activeWorkers.isEmpty()) {
Text(stringResource(R.string.kanban_no_active_workers))
} else {
LazyColumn(modifier = Modifier.heightIn(max = 320.dp)) {
items(state.activeWorkers, key = { it.runId }) { worker ->
Text(
"${worker.taskTitle} · ${worker.profile ?: worker.taskAssignee.orEmpty()}",
modifier =
Modifier
.fillMaxWidth()
.clickable {
val board = state.selectedBoard ?: return@clickable
showWorkersDialog = false
NavigationController.navigateTo(
KanbanTaskDetailKey(
boardSlug = board.id,
taskId = worker.taskId,
),
)
}.padding(vertical = 8.dp),
)
}
}
}
},
confirmButton = {
TextButton(onClick = { showWorkersDialog = false }) {
Text(stringResource(R.string.action_close))
}
},
)
}
if (showDeleteBoardDialog && state.selectedBoard != null) {
AlertDialog(
onDismissRequest = { showDeleteBoardDialog = false },
@@ -1431,6 +1529,43 @@ private fun CompleteTaskDialog(
)
}
@Composable
private fun BulkCompleteTasksDialog(
taskCount: Int,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var summary by remember { mutableStateOf("") }
val trimmedSummary = summary.trim()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.kanban_bulk_complete_title, taskCount)) },
text = {
OutlinedTextField(
value = summary,
onValueChange = { summary = it },
label = { Text(stringResource(R.string.kanban_complete_summary_label)) },
placeholder = { Text(stringResource(R.string.kanban_complete_summary_hint)) },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
)
},
confirmButton = {
Button(
onClick = { onConfirm(trimmedSummary) },
enabled = trimmedSummary.isNotEmpty(),
) {
Text(stringResource(R.string.kanban_action_complete))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
private fun KanbanTaskAction.labelRes(): Int =
when (this) {
KanbanTaskAction.TRIAGE -> R.string.kanban_action_triage
@@ -31,6 +31,7 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.RestartAlt
@@ -62,9 +63,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
@@ -75,12 +78,14 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.KanbanTaskDetailKey
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.KanbanAttachment
import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.KanbanTaskFull
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.data.model.TaskLinks
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.MarkdownText
import com.m57.hermescontrol.ui.common.ErrorState
@@ -89,6 +94,13 @@ import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.kanban.components.KanbanModelOverrideEditor
import com.m57.hermescontrol.util.StreamingUriRequestBody
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -110,6 +122,7 @@ fun KanbanTaskScreen(
stringResource(R.string.kanban_tab_files),
)
var showDeleteDialog by remember { mutableStateOf(false) }
var runToTerminate by remember { mutableStateOf<Long?>(null) }
LaunchedEffect(boardSlug, taskId) {
viewModel.loadTask(boardSlug, taskId)
@@ -208,6 +221,10 @@ fun KanbanTaskScreen(
onReclaim = {
viewModel.reclaim(boardSlug, taskId)
},
isSpecifying = state.isSpecifying,
isLinking = state.isLinking,
onSpecify = { viewModel.specifyTask(boardSlug, taskId) },
onAddParent = { viewModel.createParentLink(boardSlug, taskId, it) },
)
}
@@ -229,6 +246,10 @@ fun KanbanTaskScreen(
TaskRunsTab(
runs = detail.runs,
workerLog = state.workerLog,
runStateType = state.runStateType,
runStateName = state.runStateName,
onFilter = { type, name -> viewModel.setRunFilter(boardSlug, taskId, type, name) },
onInspect = { viewModel.inspectRun(boardSlug, taskId, it) },
)
}
@@ -242,9 +263,10 @@ fun KanbanTaskScreen(
TaskFilesTab(
attachments = detail.attachments ?: emptyList(),
isUploading = state.isUploadingAttachment,
onUpload = { filename, mimeType, bytes ->
viewModel.uploadAttachment(boardSlug, taskId, filename, mimeType, bytes)
},
isDeleting = state.isDeletingAttachment,
board = boardSlug,
taskId = taskId,
viewModel = viewModel,
)
}
}
@@ -276,6 +298,68 @@ fun KanbanTaskScreen(
},
)
}
val selectedRun = state.selectedRun
if (selectedRun != null) {
AlertDialog(
onDismissRequest = viewModel::clearRunInspection,
title = { Text(stringResource(R.string.kanban_run_details)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
MetaRow(stringResource(R.string.kanban_run_number, selectedRun.id), selectedRun.status)
selectedRun.profile?.let { MetaRow(stringResource(R.string.kanban_assignee), it) }
selectedRun.stepKey?.let { MetaRow("Step", it) }
selectedRun.workerPid?.let { MetaRow("PID", it.toString()) }
selectedRun.summary?.let { Text(it) }
selectedRun.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
state.runInspection?.let { inspection ->
Text(
if (inspection.alive) {
inspection.status ?: selectedRun.status
} else {
inspection.reason ?: stringResource(R.string.kanban_run_inspection_unavailable)
},
)
inspection.cpuPercent?.let { MetaRow("CPU", "$it%") }
inspection.memoryRssBytes?.let { MetaRow("Memory", "${it / 1024} KiB") }
inspection.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
}
},
confirmButton = {
if (selectedRun.endedAt == null && selectedRun.status == "running" &&
state.detail?.task?.status == "running"
) {
TextButton(onClick = { runToTerminate = selectedRun.id }, enabled = !state.isTerminatingRun) {
Text(stringResource(R.string.kanban_terminate_run))
}
}
},
dismissButton = {
TextButton(onClick = viewModel::clearRunInspection) { Text(stringResource(R.string.action_cancel)) }
},
)
}
if (runToTerminate != null) {
AlertDialog(
onDismissRequest = { runToTerminate = null },
title = { Text(stringResource(R.string.kanban_terminate_run)) },
text = { Text(stringResource(R.string.kanban_terminate_run_confirm)) },
confirmButton = {
Button(
onClick = {
runToTerminate?.let { viewModel.terminateRun(boardSlug, taskId, it) }
runToTerminate = null
},
enabled = !state.isTerminatingRun,
) { Text(stringResource(R.string.kanban_terminate_run)) }
},
dismissButton = {
TextButton(onClick = { runToTerminate = null }) { Text(stringResource(R.string.action_cancel)) }
},
)
}
}
@Composable
@@ -293,11 +377,16 @@ private fun TaskOverviewTab(
onNavigateToTask: (String) -> Unit,
onSaveDescription: (String) -> Unit,
onReclaim: () -> Unit,
isSpecifying: Boolean,
isLinking: Boolean,
onSpecify: () -> Unit,
onAddParent: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var isEditingDescription by remember { mutableStateOf(false) }
var descriptionDraft by remember(task.body) { mutableStateOf(task.body ?: "") }
var assigneeMenuExpanded by remember { mutableStateOf(false) }
var parentDraft by remember { mutableStateOf("") }
val statusColors = LocalHermesStatusColors.current
val currentModelOverride =
@@ -430,6 +519,8 @@ private fun TaskOverviewTab(
task.tenant?.let { MetaRow(label = "Tenant", value = it) }
task.workspaceKind?.let { MetaRow(label = "Workspace", value = "$it ${task.workspacePath ?: ""}") }
task.workerPid?.let { MetaRow(label = "Worker PID", value = it.toString()) }
task.workflowTemplateId?.let { MetaRow(label = "Workflow", value = it) }
task.currentStepKey?.let { MetaRow(label = "Step", value = it) }
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider()
@@ -446,7 +537,7 @@ private fun TaskOverviewTab(
}
// Dependencies Section
if (links.parents.isNotEmpty() || links.children.isNotEmpty()) {
run {
Card(
shape = RoundedCornerShape(8.dp),
modifier =
@@ -461,6 +552,22 @@ private fun TaskOverviewTab(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = parentDraft,
onValueChange = { parentDraft = it },
label = { Text(stringResource(R.string.kanban_parent_id)) },
modifier = Modifier.weight(1f),
singleLine = true,
)
TextButton(
onClick = {
onAddParent(parentDraft)
parentDraft = ""
},
enabled = parentDraft.isNotBlank() && !isLinking,
) { Text(stringResource(R.string.kanban_add_parent)) }
}
if (links.parents.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
Text(
@@ -506,6 +613,13 @@ private fun TaskOverviewTab(
}
// Effort Estimation Section
if (task.status == "triage") {
OutlinedButton(
onClick = onSpecify,
enabled = !isSpecifying,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) { Text(stringResource(R.string.kanban_specify_task)) }
}
Card(
shape = RoundedCornerShape(8.dp),
modifier =
@@ -778,8 +892,15 @@ private fun formatRunDuration(durationSeconds: Long): String =
private fun TaskRunsTab(
runs: List<com.m57.hermescontrol.data.model.KanbanRun>,
workerLog: com.m57.hermescontrol.data.model.WorkerLog?,
runStateType: String?,
runStateName: String?,
onFilter: (String?, String?) -> Unit,
onInspect: (Long) -> Unit,
modifier: Modifier = Modifier,
) {
var filterType by remember(runStateType) { mutableStateOf(runStateType ?: "status") }
var filterName by remember(runStateName) { mutableStateOf(runStateName ?: "") }
var filterMenuExpanded by remember { mutableStateOf(false) }
LazyColumn(
modifier = modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -790,6 +911,34 @@ private fun TaskRunsTab(
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Box {
TextButton(onClick = { filterMenuExpanded = true }) {
Text(if (filterType == "status") "Status" else "Outcome")
}
DropdownMenu(expanded = filterMenuExpanded, onDismissRequest = { filterMenuExpanded = false }) {
listOf("status", "outcome").forEach { type ->
DropdownMenuItem(text = { Text(type) }, onClick = {
filterType = type
filterMenuExpanded = false
})
}
}
}
OutlinedTextField(
value = filterName,
onValueChange = { filterName = it },
modifier = Modifier.weight(1f),
label = { Text(stringResource(R.string.kanban_run_state_filter)) },
singleLine = true,
)
TextButton(onClick = { onFilter(filterType, filterName.trim()) }, enabled = filterName.isNotBlank()) {
Text(stringResource(R.string.kanban_filter))
}
}
if (runStateName != null) {
TextButton(onClick = { onFilter(null, null) }) { Text(stringResource(R.string.kanban_all_runs)) }
}
}
if (runs.isEmpty()) {
@@ -858,6 +1007,9 @@ private fun TaskRunsTab(
style = MaterialTheme.typography.bodySmall,
)
}
OutlinedButton(onClick = { onInspect(run.id) }) {
Text(stringResource(R.string.kanban_inspect_run))
}
}
}
}
@@ -1058,31 +1210,107 @@ private fun MetaRow(
private fun TaskFilesTab(
attachments: List<com.m57.hermescontrol.data.model.KanbanAttachment>,
isUploading: Boolean,
onUpload: (filename: String, mimeType: String, bytes: ByteArray) -> Unit,
isDeleting: Boolean,
board: String,
taskId: String,
viewModel: KanbanTaskViewModel,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
) { uri: Uri? ->
if (uri != null) {
var filename = "attachment"
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1 && cursor.moveToFirst()) {
filename = cursor.getString(nameIndex) ?: "attachment"
val configuration = LocalConfiguration.current
val resources = remember(context, configuration) { context.createConfigurationContext(configuration).resources }
val scope = rememberCoroutineScope()
var attachmentToDelete by remember { mutableStateOf<KanbanAttachment?>(null) }
var attachmentToDownload by remember { mutableStateOf<KanbanAttachment?>(null) }
val downloadLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
val selected = attachmentToDownload
attachmentToDownload = null
if (uri != null && selected != null) {
scope.launch {
when (val result = viewModel.downloadAttachment(board, selected.id)) {
is NetworkResult.Success -> {
try {
withContext(Dispatchers.IO) {
result.data.use { body ->
context.contentResolver.openOutputStream(uri)?.use { output ->
body.byteStream().use { input -> input.copyTo(output) }
} ?: error("Could not open the selected destination")
}
}
viewModel.showToast(resources.getString(R.string.kanban_attachment_saved))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
viewModel.showToast(
resources.getString(R.string.kanban_attachment_error, e.message ?: "Unknown error"),
)
}
}
is NetworkResult.Failure -> {
viewModel.showToast(result.error.message)
}
}
}
val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream"
val bytes =
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: byteArrayOf()
if (bytes.isNotEmpty()) {
onUpload(filename, mimeType, bytes)
}
}
}
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
if (uri != null) {
scope.launch {
try {
val part =
withContext(Dispatchers.IO) {
var filename = "attachment"
var contentLength = -1L
context.contentResolver
.query(
uri,
arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE),
null,
null,
null,
)?.use { c ->
if (c.moveToFirst()) {
c.getColumnIndex(OpenableColumns.DISPLAY_NAME).takeIf { it >= 0 }?.let {
filename = c.getString(it) ?: filename
}
c
.getColumnIndex(
OpenableColumns.SIZE,
).takeIf { it >= 0 && !c.isNull(it) }
?.let {
contentLength = c.getLong(it)
}
}
}
val mime = context.contentResolver.getType(uri) ?: "application/octet-stream"
val body =
StreamingUriRequestBody(
context.contentResolver,
uri,
mime.toMediaTypeOrNull(),
contentLength,
)
MultipartBody.Part.createFormData("file", filename, body)
}
viewModel.uploadAttachment(board, taskId, part)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
viewModel.showToast(
resources.getString(
R.string.kanban_attachment_error,
e.message ?: "Unknown error",
),
)
}
}
}
}
LaunchedEffect(board, taskId) { viewModel.refreshAttachments(board, taskId) }
Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
Row(
@@ -1096,7 +1324,7 @@ private fun TaskFilesTab(
fontWeight = FontWeight.Bold,
)
Button(
onClick = { launcher.launch("*/*") },
onClick = { launcher.launch(arrayOf("*/*")) },
enabled = !isUploading,
) {
if (isUploading) {
@@ -1136,22 +1364,55 @@ private fun TaskFilesTab(
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = att.filename.ifBlank { "File #${att.id}" },
att.filename.ifBlank {
"File #${att.id}"
},
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
att.size?.let {
Text(
text = "${it / 1024} KB",
"${it / 1024} KB",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
IconButton(onClick = {
attachmentToDownload = att
downloadLauncher.launch(att.filename)
}) {
Icon(
Icons.Filled.Download,
contentDescription = stringResource(R.string.kanban_download_attachment),
)
}
IconButton(onClick = { attachmentToDelete = att }, enabled = !isDeleting) {
Icon(
Icons.Filled.Delete,
contentDescription = stringResource(R.string.kanban_delete_attachment),
)
}
}
}
}
}
}
}
attachmentToDelete?.let { attachment ->
AlertDialog(
onDismissRequest = { attachmentToDelete = null },
title = { Text(stringResource(R.string.kanban_delete_attachment)) },
text = { Text(stringResource(R.string.kanban_delete_attachment_confirm)) },
confirmButton = {
TextButton(onClick = {
attachmentToDelete = null
viewModel.deleteAttachment(board, taskId, attachment.id)
}) { Text(stringResource(R.string.kanban_delete_attachment)) }
},
dismissButton = {
TextButton(onClick = { attachmentToDelete = null }) { Text(stringResource(R.string.action_cancel)) }
},
)
}
}
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.KanbanRun
import com.m57.hermescontrol.data.model.KanbanRunInspection
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
@@ -25,9 +27,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.ResponseBody
data class KanbanTaskUiState(
val isLoading: Boolean = false,
@@ -47,6 +48,15 @@ data class KanbanTaskUiState(
val isReassigning: Boolean = false,
val isUpdatingModel: Boolean = false,
val isUploadingAttachment: Boolean = false,
val isDeletingAttachment: Boolean = false,
val isSpecifying: Boolean = false,
val isLinking: Boolean = false,
val isInspectingRun: Boolean = false,
val isTerminatingRun: Boolean = false,
val selectedRun: KanbanRun? = null,
val runInspection: KanbanRunInspection? = null,
val runStateType: String? = null,
val runStateName: String? = null,
)
class KanbanTaskViewModel(
@@ -61,6 +71,7 @@ class KanbanTaskViewModel(
private var eventsClient: KanbanEventsClient? = null
private var eventsBoard: String? = null
private var activeTaskId: String? = null
private var activeBoard: String? = null
private var currentLoadGen: Int = 0
private var reloadJob: Job? = null
private var logPollJob: Job? = null
@@ -74,7 +85,18 @@ class KanbanTaskViewModel(
reloadJob?.cancel()
stopLogPolling()
detailPollJob?.cancel()
if (activeBoard != board || activeTaskId != taskId) {
_uiState.update {
it.copy(
runStateType = null,
runStateName = null,
selectedRun = null,
runInspection = null,
)
}
}
activeTaskId = taskId
activeBoard = board
_uiState.update {
it.copy(
isLoading = true,
@@ -90,7 +112,15 @@ class KanbanTaskViewModel(
startDetailPolling(board, taskId)
viewModelScope.launch {
when (val result = repository.getTask(taskId = taskId, board = board)) {
when (
val result =
repository.getTask(
taskId,
board,
_uiState.value.runStateType,
_uiState.value.runStateName,
)
) {
is NetworkResult.Success -> {
if (gen != currentLoadGen) return@launch
val taskData = result.data
@@ -254,11 +284,13 @@ class KanbanTaskViewModel(
taskId: String,
) {
val gen = currentLoadGen
val result = repository.getTask(taskId = taskId, board = board)
if (gen != currentLoadGen || taskId != activeTaskId) return
val result = repository.getTask(taskId, board, _uiState.value.runStateType, _uiState.value.runStateName)
if (gen != currentLoadGen || taskId != activeTaskId || board != activeBoard) return
if (result is NetworkResult.Success) {
val taskData = result.data
_uiState.update { it.copy(detail = taskData) }
_uiState.update { state ->
state.copy(detail = taskData.copy(attachments = taskData.attachments ?: state.detail?.attachments))
}
if (taskData.task.status.equals("running", ignoreCase = true)) {
if (logPollJob == null) startLogPolling(board, taskId)
} else {
@@ -521,18 +553,6 @@ class KanbanTaskViewModel(
}
}
fun uploadAttachment(
board: String,
taskId: String,
filename: String,
mimeType: String,
bytes: ByteArray,
) {
val requestBody = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
val part = MultipartBody.Part.createFormData("file", filename, requestBody)
uploadAttachment(board, taskId, part)
}
fun uploadAttachment(
board: String,
taskId: String,
@@ -548,7 +568,10 @@ class KanbanTaskViewModel(
toastMessage = "Attachment uploaded",
)
}
loadTask(board, taskId)
if (activeTaskId == taskId && activeBoard == board) {
loadTaskSilently(board, taskId)
refreshAttachments(board, taskId)
}
}
is NetworkResult.Failure -> {
@@ -563,6 +586,217 @@ class KanbanTaskViewModel(
}
}
fun refreshAttachments(
board: String,
taskId: String,
) {
viewModelScope.launch {
when (val result = repository.listAttachments(taskId, board)) {
is NetworkResult.Success -> {
_uiState.update { state ->
if (activeTaskId != taskId || activeBoard != board) {
state
} else {
state.copy(detail = state.detail?.copy(attachments = result.data.attachments))
}
}
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = result.error.message) }
}
}
}
}
fun deleteAttachment(
board: String,
taskId: String,
attachmentId: Long,
) {
if (_uiState.value.isDeletingAttachment) return
_uiState.update { it.copy(isDeletingAttachment = true) }
viewModelScope.launch {
when (val result = repository.deleteAttachment(attachmentId, board)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isDeletingAttachment = false,
toastMessage = if (result.data.ok) "Attachment deleted" else "Attachment was not deleted",
)
}
if (result.data.ok) refreshAttachments(board, taskId)
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(isDeletingAttachment = false, toastMessage = result.error.message)
}
}
}
}
}
suspend fun downloadAttachment(
board: String,
attachmentId: Long,
): NetworkResult<ResponseBody> = repository.downloadAttachment(attachmentId, board)
fun showToast(message: String) {
_uiState.update { it.copy(toastMessage = message) }
}
fun inspectRun(
board: String,
taskId: String,
runId: Long,
) {
if (_uiState.value.isInspectingRun) return
_uiState.update { it.copy(isInspectingRun = true, selectedRun = null, runInspection = null) }
viewModelScope.launch {
val run = repository.getRun(runId, board)
val inspection = if (run is NetworkResult.Success) repository.inspectRun(runId, board) else null
if (activeTaskId != null && (activeTaskId != taskId || activeBoard != board)) return@launch
_uiState.update {
it.copy(
isInspectingRun = false,
selectedRun = (run as? NetworkResult.Success)?.data?.run,
runInspection = (inspection as? NetworkResult.Success)?.data,
toastMessage =
(run as? NetworkResult.Failure)?.error?.message
?: (inspection as? NetworkResult.Failure)?.error?.message,
)
}
}
}
fun clearRunInspection() {
_uiState.update { it.copy(selectedRun = null, runInspection = null) }
}
fun terminateRun(
board: String,
taskId: String,
runId: Long,
) {
if (_uiState.value.isTerminatingRun) return
_uiState.update { it.copy(isTerminatingRun = true) }
viewModelScope.launch {
when (val result = repository.terminateRun(runId, board)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isTerminatingRun = false,
toastMessage = if (result.data.ok) "Run terminated" else "Run was not terminated",
)
}
if (result.data.ok && result.data.taskId == taskId &&
activeTaskId == taskId && activeBoard == board
) {
val latestRun = repository.getRun(runId, board)
_uiState.update { it.copy(selectedRun = (latestRun as? NetworkResult.Success)?.data?.run) }
loadTaskSilently(board, taskId)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(isTerminatingRun = false, toastMessage = result.error.message)
}
}
}
}
}
fun specifyTask(
board: String,
taskId: String,
) {
if (_uiState.value.isSpecifying) return
_uiState.update { it.copy(isSpecifying = true) }
viewModelScope.launch {
when (val result = repository.specifyTask(taskId, board)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isSpecifying = false,
toastMessage =
if (result.data.ok) {
"Task specified"
} else {
result.data.reason
?: "Specification failed"
},
)
}
if (result.data.ok && activeTaskId == taskId &&
activeBoard == board
) {
loadTaskSilently(board, taskId)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(isSpecifying = false, toastMessage = result.error.message)
}
}
}
}
}
fun createParentLink(
board: String,
childId: String,
parentId: String,
) {
if (parentId.isBlank() || _uiState.value.isLinking) return
_uiState.update { it.copy(isLinking = true) }
viewModelScope.launch {
when (val result = repository.createTaskLink(board, parentId.trim(), childId)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isLinking = false,
toastMessage =
when {
!result.data.ok -> "Parent link was not created"
result.data.gated -> "Linked; task gated by parent"
else -> "Parent linked"
},
)
}
if (result.data.ok) {
if (activeTaskId == childId && activeBoard == board) {
loadTaskSilently(board, childId)
} else if (activeTaskId == null) {
loadTask(board, childId)
}
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(isLinking = false, toastMessage = result.error.message)
}
}
}
}
}
fun setRunFilter(
board: String,
taskId: String,
type: String?,
name: String?,
) {
if ((type == null) != (name == null) || type !in setOf(null, "status", "outcome")) return
_uiState.update { it.copy(runStateType = type, runStateName = name) }
if (activeBoard == board && activeTaskId == taskId) {
viewModelScope.launch { loadTaskSilently(board, taskId) }
}
}
public override fun onCleared() {
stopLogPolling()
detailPollJob?.cancel()
@@ -0,0 +1,30 @@
package com.m57.hermescontrol.ui.kanban
/** The dashboard currently sends dependency refusals as a 409 string detail, not JSON fields. */
data class KanbanTransitionRefusal(
val blockingParentIds: List<String>,
val blockingParentStatuses: List<String>,
)
fun kanbanTransitionRefusal(message: String): KanbanTransitionRefusal? {
val (parentList, pattern) =
when {
message.contains("blocked by parent(s) not done") -> {
message.substringAfter("blocked by parent(s) not done") to Regex("\\(([^(),\\s]+), status=([^()]*)\\)")
}
message.contains("unsatisfied parent dependencies:") -> {
message.substringAfter("unsatisfied parent dependencies:").substringBefore(';') to
Regex("([^,;()\\s]+) \\(([^()]*)\\)")
}
else -> {
return null
}
}
val blockers =
pattern.findAll(parentList).map { match -> match.groupValues[1] to match.groupValues[2] }.toList()
return blockers.takeIf { it.isNotEmpty() }?.let {
KanbanTransitionRefusal(it.map { pair -> pair.first }, it.map { pair -> pair.second })
}
}
@@ -12,6 +12,7 @@ import com.m57.hermescontrol.data.model.CreateBoardBody
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.ExportBoardBody
import com.m57.hermescontrol.data.model.ImportBoardBody
import com.m57.hermescontrol.data.model.KanbanActiveWorker
import com.m57.hermescontrol.data.model.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanColumn
@@ -26,6 +27,7 @@ import com.m57.hermescontrol.data.model.RenameBoardBody
import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.repository.KanbanRepository
@@ -64,8 +66,22 @@ data class KanbanUiState(
val columnCollapseOverrides: Map<String, Boolean> = emptyMap(),
val errorMessage: String? = null,
val toastMessage: String? = null,
val selectedWorkflowTemplateId: String? = null,
val selectedCurrentStepKey: String? = null,
val activeWorkers: List<KanbanActiveWorker> = emptyList(),
val isLoadingWorkers: Boolean = false,
)
fun filterKanbanTasks(
tasks: List<KanbanTask>,
workflowTemplateId: String?,
currentStepKey: String?,
): List<KanbanTask> =
tasks.filter { task ->
(workflowTemplateId == null || task.workflowTemplateId.equals(workflowTemplateId, ignoreCase = true)) &&
(currentStepKey == null || task.currentStepKey.equals(currentStepKey, ignoreCase = true))
}
/**
* Task actions mirroring the desktop kanban's transition-gated buttons.
* Only moves the backend PATCH route accepts are offered: never `running`
@@ -250,7 +266,15 @@ class KanbanViewModel(
preferences.setSelectedBoard(endpoint, board.id)
val gen = ++currentLoadGen
_uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) }
_uiState.update {
it.copy(
selectedBoard = board,
isLoading = true,
errorMessage = null,
activeWorkers = emptyList(),
isLoadingWorkers = false,
)
}
viewModelScope.launch {
loadBoardIntoState(board, gen)
connectEvents(board)
@@ -272,6 +296,54 @@ class KanbanViewModel(
_uiState.update { it.copy(groupRunning = group) }
}
fun setWorkflowFilters(
workflowTemplateId: String?,
currentStepKey: String?,
) {
_uiState.update {
it.copy(
selectedWorkflowTemplateId = workflowTemplateId,
selectedCurrentStepKey = currentStepKey,
)
}
_uiState.value.selectedBoard?.let { board ->
val gen = ++currentLoadGen
viewModelScope.launch { loadBoardIntoState(board, gen) }
}
}
fun loadActiveWorkers() {
val board = _uiState.value.selectedBoard ?: return
_uiState.update { it.copy(isLoadingWorkers = true) }
viewModelScope.launch {
when (val result = repository.getActiveWorkers(board.id)) {
is NetworkResult.Success -> {
_uiState.update {
if (it.selectedBoard?.id == board.id) {
it.copy(activeWorkers = result.data.workers, isLoadingWorkers = false)
} else {
it
}
}
}
is NetworkResult.Failure -> {
_uiState.update {
if (it.selectedBoard?.id == board.id) {
it.copy(
isLoadingWorkers = false,
activeWorkers = emptyList(),
toastMessage = "Failed to load active workers: ${result.error.message}",
)
} else {
it
}
}
}
}
}
}
fun toggleColumnCollapse(
columnName: String,
boardHasWork: Boolean,
@@ -618,12 +690,26 @@ class KanbanViewModel(
fun bulkMove(
taskIds: List<String>,
targetStatus: String,
summary: String? = null,
onComplete: ((failedIds: Set<String>) -> Unit)? = null,
) {
val board = _uiState.value.selectedBoard ?: return
val completionSummary = summary?.trim()?.takeIf { it.isNotEmpty() }
if (targetStatus.equals("done", ignoreCase = true) && completionSummary == null) {
_uiState.update { it.copy(toastMessage = "Completion summary is required") }
onComplete?.invoke(taskIds.toSet())
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, status = targetStatus))) {
val body =
BulkTasksBody(
ids = taskIds,
status = targetStatus,
summary = completionSummary,
result = completionSummary,
)
when (val res = repository.bulkTasks(board.id, body)) {
is NetworkResult.Success -> {
val results = res.data.results
val succeeded = results.filter { it.ok }.map { it.id }.toSet()
@@ -846,10 +932,22 @@ class KanbanViewModel(
operatingTaskIds = state.operatingTaskIds - task.id,
)
}
reloadBoardSilently()
}
is NetworkResult.Failure -> {
revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}")
val refusal =
(result.error as? NetworkError.Http)
?.takeIf { it.code == 409 }
?.let { kanbanTransitionRefusal(it.message) }
val message =
if (refusal != null) {
"Move blocked by parents ${refusal.blockingParentIds.joinToString()} " +
"(${refusal.blockingParentStatuses.joinToString()}): ${result.error.message}"
} else {
"Move failed: ${result.error.message}"
}
revertTaskMove(task.id, originalStatus, message)
}
}
}
@@ -931,6 +1029,8 @@ class KanbanViewModel(
repository.getBoard(
board = board.id,
includeArchived = _uiState.value.includeArchived,
workflowTemplateId = _uiState.value.selectedWorkflowTemplateId,
currentStepKey = _uiState.value.selectedCurrentStepKey,
)
if (gen != currentLoadGen) return@launch
if (result is NetworkResult.Success) {
@@ -996,6 +1096,8 @@ class KanbanViewModel(
repository.getBoard(
board = board.id,
includeArchived = _uiState.value.includeArchived,
workflowTemplateId = _uiState.value.selectedWorkflowTemplateId,
currentStepKey = _uiState.value.selectedCurrentStepKey,
)
applyBoardResult(board, result, gen)
}
@@ -34,10 +34,15 @@ fun KanbanFilterSheet(
tenants: List<String>,
selectedAssignee: String?,
selectedTenant: String?,
workflowTemplateIds: List<String>,
currentStepKeys: List<String>,
selectedWorkflowTemplateId: String?,
selectedCurrentStepKey: String?,
includeArchived: Boolean,
groupRunning: Boolean,
onSelectAssignee: (String?) -> Unit,
onSelectTenant: (String?) -> Unit,
onSelectWorkflowFilters: (String?, String?) -> Unit,
onToggleIncludeArchived: (Boolean) -> Unit,
onToggleGroupRunning: (Boolean) -> Unit,
onClearFilters: () -> Unit,
@@ -105,6 +110,29 @@ fun KanbanFilterSheet(
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
"Workflow template",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
FilterChoiceRow("All templates", selectedWorkflowTemplateId == null) {
onSelectWorkflowFilters(null, selectedCurrentStepKey)
}
workflowTemplateIds.forEach { value ->
FilterChoiceRow(value, selectedWorkflowTemplateId == value) {
onSelectWorkflowFilters(value, selectedCurrentStepKey)
}
}
Text("Current step", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
FilterChoiceRow("All steps", selectedCurrentStepKey == null) {
onSelectWorkflowFilters(selectedWorkflowTemplateId, null)
}
currentStepKeys.forEach { value ->
FilterChoiceRow(value, selectedCurrentStepKey == value) {
onSelectWorkflowFilters(selectedWorkflowTemplateId, value)
}
}
// Assignee Filter
Text(
text = stringResource(R.string.kanban_assignee),
@@ -192,3 +220,18 @@ fun KanbanFilterSheet(
}
}
}
@Composable
private fun FilterChoiceRow(
label: String,
selected: Boolean,
onClick: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 4.dp),
) {
RadioButton(selected = selected, onClick = onClick)
Text(label, style = MaterialTheme.typography.bodyMedium)
}
}
@@ -32,6 +32,7 @@ import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.reasoningSupport
import com.m57.hermescontrol.ui.kanban.KanbanModelOverride
import com.m57.hermescontrol.ui.kanban.supportedKanbanReasoningEfforts
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
@@ -129,7 +130,7 @@ fun KanbanModelOverrideEditor(
.firstOrNull { it.slug == override.provider }
?.capabilities
?.get(override.model)
val reasoningSupported = selectedCapabilities?.reasoning != false
val reasoningSupported = selectedCapabilities?.reasoningSupport != false
val canDisableReasoning = selectedCapabilities?.can_disable_reasoning != false
val supportedEfforts = supportedKanbanReasoningEfforts(reasoningSupported, canDisableReasoning)
Row(
@@ -180,7 +181,7 @@ fun KanbanModelOverrideEditor(
override.copy(
provider = provider,
model = model,
effort = if (capabilities?.reasoning == false) "" else override.effort,
effort = if (capabilities?.reasoningSupport == false) "" else override.effort,
),
)
showPickerDialog = false
@@ -67,6 +67,7 @@ import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.model.components.MoaConfigDialog
import com.m57.hermescontrol.ui.model.components.ModelCapabilitySummary
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
@OptIn(ExperimentalMaterial3Api::class)
@@ -874,19 +875,7 @@ private fun ProviderCard(
// the row vertically instead of pushing
// the check/pin buttons off-screen.
)
val hint =
when {
caps?.reasoning == false -> "no reasoning"
caps?.can_disable_reasoning == false -> "reasoning always on"
else -> null
}
if (hint != null) {
Text(
text = hint,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
ModelCapabilitySummary(caps)
}
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -3,20 +3,25 @@ package com.m57.hermescontrol.ui.model
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.model.AuxiliaryTaskAssignment
import com.m57.hermescontrol.data.model.MoaConfigResponse
import com.m57.hermescontrol.data.model.ModelAssignmentRequest
import com.m57.hermescontrol.data.model.ModelInfoResponse
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
import com.m57.hermescontrol.data.model.withModelInfoCapabilities
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.ModelCatalogStore
import com.m57.hermescontrol.data.ws.ModelOptionsRepository
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -45,6 +50,7 @@ data class ModelUiState(
val isLoading: Boolean = false,
val catalogLoading: Boolean = false,
val providers: List<ModelProvider> = emptyList(),
val modelInfo: ModelInfoResponse? = null,
val activeProfile: ProfileInfo? = null,
val errorMessage: String? = null,
val toastMessage: String? = null,
@@ -71,12 +77,19 @@ data class ModelUiState(
class ModelViewModel(
private val catalogStore: ModelCatalogStore = ModelCatalogStore.shared,
private val getCurrentScope: () -> DataScope? = { AuthManager.currentDataScope() },
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val getModelInfoCall: suspend (String?) -> NetworkResult<ModelInfoResponse> = { profile ->
safeApiCall { ApiClient.hermesApi.getModelInfo(profile) }
},
) : ViewModel(),
ToastHost {
constructor(repository: ModelOptionsRepository) : this(ModelCatalogStore(repository = repository))
private val _uiState = MutableStateFlow(ModelUiState())
val uiState: StateFlow<ModelUiState> = _uiState.asStateFlow()
private var modelInfoJob: Job? = null
private var modelInfoScope: DataScope? = null
init {
_uiState.update { it.copy(pinnedModels = AuthManager.getPinnedModels()) }
@@ -84,9 +97,18 @@ class ModelViewModel(
catalogStore.state.collect { catalogState ->
// Empty state is meaningful: it is how ModelCatalogStore
// invalidates the previous DataScope. Never ignore it.
val observedScope = catalogState.scope ?: getCurrentScope()
val scopeChanged = modelInfoScope != observedScope
if (scopeChanged) {
modelInfoJob?.cancel()
modelInfoJob = null
modelInfoScope = observedScope
}
_uiState.update {
val modelInfo = if (scopeChanged) null else it.modelInfo
it.copy(
providers = catalogState.providers,
providers = catalogState.providers.withModelInfoCapabilities(modelInfo),
modelInfo = modelInfo,
catalogLoading = catalogState.isRefreshing,
)
}
@@ -96,6 +118,7 @@ class ModelViewModel(
fun loadAll(refresh: Boolean = false) {
_uiState.update { it.copy(isLoading = true, catalogLoading = true, errorMessage = null) }
loadModelCapabilities()
viewModelScope.launch {
// Phase 1: Launch fast lightweight calls first (profiles, aux, moa)
val activeProfileDeferred =
@@ -199,6 +222,7 @@ class ModelViewModel(
/** Fast refresh after model assignments/updates without re-fetching full provider catalog. */
fun loadQuick() {
loadModelCapabilities(clearExisting = true)
viewModelScope.launch {
val activeProfileDeferred =
async(Dispatchers.IO) {
@@ -254,6 +278,45 @@ class ModelViewModel(
}
}
fun loadModelCapabilities(clearExisting: Boolean = false) {
val requestScope = getCurrentScope()
if (modelInfoScope != requestScope) {
modelInfoJob?.cancel()
modelInfoScope = requestScope
_uiState.update {
it.copy(
providers = catalogStore.state.value.providers,
modelInfo = null,
)
}
} else if (clearExisting) {
_uiState.update {
it.copy(
providers = catalogStore.state.value.providers,
modelInfo = null,
)
}
}
modelInfoJob?.cancel()
if (requestScope == null) return
modelInfoJob =
viewModelScope.launch(ioDispatcher) {
val result = getModelInfoCall(requestScope.activeProfileId)
if (getCurrentScope() != requestScope) return@launch
if (result is NetworkResult.Success) {
_uiState.update {
it.copy(
providers =
catalogStore.state.value.providers
.withModelInfoCapabilities(result.data),
modelInfo = result.data,
)
}
}
}
}
/** Open the main model picker dialog. */
fun openMainModelPicker() {
_uiState.update { it.copy(showMainModelPicker = true) }
@@ -0,0 +1,36 @@
package com.m57.hermescontrol.ui.model.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.data.model.ModelCapabilities
import com.m57.hermescontrol.data.model.reasoningSupport
@Composable
fun ModelCapabilitySummary(capabilities: ModelCapabilities?) {
if (capabilities == null) return
val labels =
buildList {
capabilities.supports_tools?.let { add(if (it) "Tools" else "No tools") }
capabilities.supports_vision?.let { add(if (it) "Vision" else "No vision") }
capabilities.reasoningSupport?.let { add(if (it) "Reasoning" else "no reasoning") }
if (capabilities.reasoningSupport == true && capabilities.can_disable_reasoning == false) {
add("reasoning always on")
}
capabilities.context_window?.let { add("${it / 1000}K ctx") }
capabilities.max_output_tokens?.let { add("${it / 1000}K output") }
capabilities.model_family?.takeIf { it.isNotBlank() }?.let { add(it) }
}
if (labels.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = labels.joinToString(" · "),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -40,6 +40,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.ModelCapabilities
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.ui.common.LoadingState
@@ -213,8 +214,7 @@ fun ModelPickerDialog(
null
},
onClick = { onSelect(pinned.providerSlug, pinned.modelName) },
canDisableReasoning = caps?.can_disable_reasoning,
supportsReasoning = caps?.reasoning,
capabilities = caps,
)
}
}
@@ -294,8 +294,7 @@ fun ModelPickerDialog(
null
},
onClick = { onSelect(provider.slug, model) },
canDisableReasoning = caps?.can_disable_reasoning,
supportsReasoning = caps?.reasoning,
capabilities = caps,
)
}
}
@@ -315,8 +314,7 @@ private fun ModelItemCard(
onPinToggle: (() -> Unit)?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
canDisableReasoning: Boolean? = null,
supportsReasoning: Boolean? = null,
capabilities: ModelCapabilities? = null,
) {
Surface(
modifier =
@@ -348,19 +346,7 @@ private fun ModelItemCard(
// No maxLines/ellipsis: a long model name wraps so the full
// name stays readable while the pin button keeps its spot.
)
val hint =
when {
supportsReasoning == false -> "no reasoning"
canDisableReasoning == false -> "reasoning always on"
else -> null
}
if (hint != null) {
Text(
text = hint,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
ModelCapabilitySummary(capabilities)
}
if (onPinToggle != null) {
IconButton(
@@ -486,7 +486,7 @@ private fun InstallSection(
}
@Composable
private fun PluginCard(
internal fun PluginCard(
plugin: PluginInfo,
state: PluginsUiState,
viewModel: PluginsViewModel,
@@ -542,12 +542,7 @@ private fun PluginCard(
// Status badges
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val statusColor =
when {
plugin.enabled -> statusColors.success
plugin.installed -> statusColors.warning
else -> statusColors.error
}
val statusColor = if (plugin.enabled) statusColors.success else statusColors.warning
Text(
text = plugin.runtimeStatus ?: "inactive",
style = MaterialTheme.typography.labelSmall,
@@ -591,64 +586,63 @@ private fun PluginCard(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
if (plugin.installed) {
// Visibility toggle (only for plugins with dashboard manifest)
if (plugin.hasDashboardManifest) {
IconButton(
onClick = { viewModel.togglePluginVisibility(plugin) },
enabled = !busy,
modifier = Modifier.size(32.dp),
) {
Icon(
if (plugin.userHidden) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription =
if (plugin.userHidden) {
stringResource(R.string.plugins_show_in_sidebar)
} else {
stringResource(R.string.plugins_hide_from_sidebar)
},
modifier = Modifier.size(18.dp),
)
}
// Visibility toggle (only for active plugins with dashboard manifests)
if (plugin.installed && plugin.hasDashboardManifest) {
IconButton(
onClick = { viewModel.togglePluginVisibility(plugin) },
enabled = !busy,
modifier = Modifier.size(32.dp),
) {
Icon(
if (plugin.userHidden) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription =
if (plugin.userHidden) {
stringResource(R.string.plugins_show_in_sidebar)
} else {
stringResource(R.string.plugins_hide_from_sidebar)
},
modifier = Modifier.size(18.dp),
)
}
Spacer(modifier = Modifier.width(4.dp))
}
// Existing update affordance for active Git installs.
if (plugin.installed && plugin.canUpdateGit) {
OutlinedButton(
onClick = { viewModel.updatePlugin(plugin.name) },
enabled = !busy,
) {
Text(stringResource(R.string.plugins_action_update))
}
Spacer(modifier = Modifier.width(8.dp))
}
// Backend-provided removability, independent of runtime enabled state.
if (plugin.removable) {
OutlinedButton(
onClick = { viewModel.requestRemovePlugin(plugin.name) },
enabled = !busy,
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Icon(
Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.plugins_action_uninstall))
}
// Update button
if (plugin.canUpdateGit) {
OutlinedButton(
onClick = { viewModel.updatePlugin(plugin.name) },
enabled = !busy,
) {
Text(stringResource(R.string.plugins_action_update))
}
Spacer(modifier = Modifier.width(8.dp))
}
// Remove button with confirmation
if (plugin.canRemove) {
OutlinedButton(
onClick = { viewModel.requestRemovePlugin(plugin.name) },
enabled = !busy,
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Icon(
Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.plugins_action_uninstall))
}
}
} else {
}
if (!plugin.installed) {
Button(
onClick = { viewModel.activatePlugin(plugin) },
enabled = !busy,
) {
Text(stringResource(R.string.plugins_action_install))
Text(stringResource(R.string.plugins_action_enable))
}
}
}
@@ -14,10 +14,12 @@ import com.m57.hermescontrol.data.model.ProviderOption
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.PluginRemovalRepository
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchAction
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -72,15 +74,18 @@ data class PluginsUiState(
}
}
class PluginsViewModel :
ViewModel(),
class PluginsViewModel(
private val pluginRemoval: PluginRemovalRepository = PluginRemovalRepository(),
) : ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(PluginsUiState())
val uiState: StateFlow<PluginsUiState> = _uiState.asStateFlow()
private val pluginsCache = SwrCache<String, PluginsHubResponse>()
private var scopeGeneration = 0
fun clearScopeOwnedState() {
scopeGeneration++
_uiState.update {
it.copy(
isLoading = false,
@@ -333,26 +338,24 @@ class PluginsViewModel :
}
fun activatePlugin(plugin: PluginInfo) {
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.enablePlugin(plugin.name) }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Plugin enabled successfully") }
loadPlugins(forceRefresh = true)
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to enable plugin: ${result.error.message}") }
}
}
}
if (_uiState.value.rowBusy != null) return
safeLaunchAction(
onStart = { setRowBusy(plugin.name) },
apiCall = { safeApiCall { ApiClient.hermesApi.enablePlugin(plugin.name) } },
onSuccess = {
_uiState.update { it.copy(toastMessage = "Plugin enabled successfully") }
loadPlugins(forceRefresh = true)
},
onError = { error ->
_uiState.update { it.copy(toastMessage = "Failed to enable plugin: $error") }
},
onComplete = { clearRowBusy(plugin.name) },
)
}
/** Show confirmation dialog for removing a plugin */
fun requestRemovePlugin(name: String) {
if (_uiState.value.plugins.none { it.name == name && it.removable }) return
_uiState.update { it.copy(removeConfirmPlugin = name) }
}
@@ -363,24 +366,85 @@ class PluginsViewModel :
fun confirmRemovePlugin() {
val name = _uiState.value.removeConfirmPlugin ?: return
if (_uiState.value.plugins.none { it.name == name && it.removable } || _uiState.value.rowBusy != null) {
cancelRemovePlugin()
return
}
_uiState.update { it.copy(removeConfirmPlugin = null) }
val generation = scopeGeneration
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
setRowBusy(name)
viewModelScope.launch {
setRowBusy(name)
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.uninstallPlugin(name) }
try {
val result = pluginRemoval.remove(name)
if (generation != scopeGeneration ||
(requestScope != null && AuthManager.currentDataScope() != requestScope)
) {
return@launch
}
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Plugin uninstalled successfully") }
clearRowBusy(name)
loadPlugins(forceRefresh = true)
if (!result.ok) {
_uiState.update {
it.copy(
toastMessage = "Failed to uninstall plugin: ${result.error ?: "Gateway refused removal"}",
)
}
return@launch
}
val refreshed = safeApiCall { ApiClient.hermesApi.getPlugins() }
if (generation != scopeGeneration ||
(requestScope != null && AuthManager.currentDataScope() != requestScope)
) {
return@launch
}
when (refreshed) {
is NetworkResult.Success -> {
val data = refreshed.data
requestScope?.let { pluginsCache.put(it.scopedKey("default"), data) }
_uiState.update { state ->
state.copy(
plugins = data.plugins,
orphanPlugins =
data.orphanDashboardPlugins.map { orphan ->
PluginInfo(
name = orphan.name ?: "unknown",
description =
orphan.description ?: orphan.label,
)
},
memoryProvider = data.providers?.memoryProvider ?: "",
memoryOptions = data.providers?.memoryOptions.orEmpty(),
contextEngine = data.providers?.contextEngine ?: "compressor",
contextOptions = data.providers?.contextOptions.orEmpty(),
toastMessage =
if (data.plugins.none { it.name == name }) {
"Plugin uninstalled successfully"
} else {
"Removal returned success, but $name still appears in the plugin list"
},
)
}
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to uninstall plugin: ${result.error.message}") }
clearRowBusy(name)
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
toastMessage = "Removal sent; could not verify plugin list: ${refreshed.error.message}",
)
}
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
if (generation == scopeGeneration) {
_uiState.update {
it.copy(
toastMessage = "Failed to uninstall plugin: ${e.message ?: "Gateway unavailable"}",
)
}
}
} finally {
if (generation == scopeGeneration) clearRowBusy(name)
}
}
}
@@ -376,6 +376,12 @@ class SettingsViewModel(
AuthManager.setKeepConnectedInBackground(enabled)
}
/** Reconcile the chat shortcut with changes made through Settings or another screen. */
fun refreshKeepConnectedInBackground() {
val enabled = AuthManager.isKeepConnectedInBackground()
_uiState.update { it.copy(keepConnectedInBackground = enabled) }
}
/** Clear all auth credentials — logs out and returns to landing screen. */
fun logout() {
AuthManager.setToken(null)
+42
View File
@@ -165,6 +165,7 @@
<string name="chat_input_placeholder_type_message">اكتب رسالة\u2026</string>
<string name="chat_tool_execution_error">خطأ في التنفيذ: %1$s</string>
<string name="chat_tool_exit_code">رمز الخروج: %1$d</string>
<string name="chat_tool_output_omitted">تم حذف %1$d حرف من مخرجات العملية</string>
<string name="chat_tool_fallback">أداة</string>
<string name="chat_tool_show_raw">عرض ملف JSON الأولي</string>
<string name="chat_tool_show_parsed">عرض المخرجات المعالجة</string>
@@ -941,6 +942,7 @@
<string name="plugins_no_desc">لا يوجد وصف مقدم.</string>
<string name="plugins_action_update">تحديث</string>
<string name="plugins_action_uninstall">إلغاء التثبيت</string>
<string name="plugins_action_enable">تفعيل</string>
<string name="plugins_action_install">تثبيت</string>
<string name="plugins_action_remove">إزالة</string>
<string name="plugins_install_heading">تثبيت إضافة</string>
@@ -1493,6 +1495,33 @@
<string name="session_integrations_close">إغلاق</string>
<string name="session_integrations_menu_item">تكاملات الجلسة</string>
<!-- Kanban task detail -->
<string name="kanban_specify_task">تحديد المهمة</string>
<string name="kanban_specifying">جارٍ التحديد…</string>
<string name="kanban_specify_success">تم تحديد المهمة بنجاح</string>
<string name="kanban_specify_failed">تعذر تحديد المهمة: %1$s</string>
<string name="kanban_add_parent">إضافة مهمة أصل</string>
<string name="kanban_parent_id">معرّف المهمة الأصل</string>
<string name="kanban_link_gated">لا يمكن إنشاء الرابط قبل تحديد المهمة</string>
<string name="kanban_link_created">تم إنشاء رابط المهمة</string>
<string name="kanban_run_details">تفاصيل التشغيل</string>
<string name="kanban_inspect_run">فحص التشغيل</string>
<string name="kanban_terminate_run">إنهاء التشغيل</string>
<string name="kanban_terminate_run_confirm">هل تريد إنهاء هذا التشغيل؟</string>
<string name="kanban_run_terminated">تم إنهاء التشغيل</string>
<string name="kanban_run_inactive">التشغيل غير نشط</string>
<string name="kanban_run_inspection_unavailable">فحص التشغيل غير متاح</string>
<string name="kanban_run_state_filter">حالة التشغيل</string>
<string name="kanban_all_runs">كل عمليات التشغيل</string>
<string name="kanban_delete_attachment">حذف المرفق</string>
<string name="kanban_delete_attachment_confirm">هل تريد حذف هذا المرفق؟</string>
<string name="kanban_download_attachment">تنزيل المرفق</string>
<string name="kanban_attachment_saved">تم حفظ المرفق</string>
<string name="kanban_attachment_deleted">تم حذف المرفق</string>
<string name="kanban_attachment_error">خطأ في المرفق: %1$s</string>
<string name="kanban_active_workers">العاملون النشطون</string>
<string name="kanban_no_active_workers">لا يوجد عاملون نشطون</string>
<!-- Media Player -->
<string name="media_player_play">تشغيل</string>
<string name="media_player_pause">إيقاف مؤقت</string>
@@ -1514,4 +1543,17 @@
<string name="media_player_play_video">تشغيل الفيديو</string>
<string name="chat_session_preparing">جارٍ تجهيز الجلسة…</string>
<string name="chat_session_not_ready">الجلسة غير جاهزة — أعد المحاولة للمتابعة</string>
<string name="connection_setup_target_title">إعداد %1$s</string>
<string name="connection_setup_progress">%1$d من %2$d</string>
<string name="connection_setup_authorized_no_tools">تم التفويض، لكن الأدوات غير متاحة</string>
<string name="connection_setup_target_resolved">اكتملت خطوة الاتصال هذه</string>
<string name="connection_setup_tools_available">الأدوات المتاحة: %1$d</string>
<string name="connection_setup_unsupported">خطوة الإعداد هذه غير مدعومة في إصدار التطبيق الحالي.</string>
<string name="connection_setup_connect">اتصال</string>
<string name="connection_setup_skip">تخطي</string>
<string name="connection_setup_continue">متابعة</string>
<string name="connection_setup_open_browser">فتح في المتصفح</string>
<string name="connection_setup_required">%1$s مطلوب</string>
<string name="connection_setup_request_failed">تعذر إرسال رد الإعداد. حاول مرة أخرى.</string>
<string name="connection_setup_pending">بانتظار Hermes…</string>
</resources>
+42
View File
@@ -162,6 +162,7 @@
<string name="chat_input_placeholder_type_message">메시지 입력…</string>
<string name="chat_tool_execution_error">실행 오류: %1$s</string>
<string name="chat_tool_exit_code">종료 코드: %1$d</string>
<string name="chat_tool_output_omitted">프로세스 출력에서 %1$d자 생략됨</string>
<string name="chat_tool_fallback">도구</string>
<string name="chat_tool_show_raw">Raw JSON 보기</string>
<string name="chat_tool_show_parsed">파싱된 결과 보기</string>
@@ -793,6 +794,7 @@
<string name="plugins_no_desc">설명이 없습니다.</string>
<string name="plugins_action_update">업데이트</string>
<string name="plugins_action_uninstall">제거</string>
<string name="plugins_action_enable">활성화</string>
<string name="plugins_action_install">설치</string>
<string name="plugins_action_remove">제거</string>
<string name="plugins_install_heading">플러그인 설치</string>
@@ -1197,4 +1199,44 @@
<string name="media_player_play_video">비디오 재생</string>
<string name="chat_session_preparing">세션 준비 중…</string>
<string name="chat_session_not_ready">세션이 준비되지 않았습니다. 다시 시도하세요</string>
<string name="connection_setup_target_title">%1$s 설정</string>
<string name="connection_setup_progress">%2$d개 중 %1$d개</string>
<string name="connection_setup_authorized_no_tools">인증되었지만 도구를 사용할 수 없습니다</string>
<string name="connection_setup_target_resolved">이 연결 단계가 완료되었습니다</string>
<string name="connection_setup_tools_available">사용 가능한 도구: %1$d</string>
<string name="connection_setup_unsupported">현재 앱 버전에서는 이 설정 단계를 지원하지 않습니다.</string>
<string name="connection_setup_connect">연결</string>
<string name="connection_setup_skip">건너뛰기</string>
<string name="connection_setup_continue">계속</string>
<string name="connection_setup_open_browser">브라우저에서 열기</string>
<string name="connection_setup_required">%1$s은(는) 필수입니다</string>
<string name="connection_setup_request_failed">설정 응답을 보낼 수 없습니다. 다시 시도하세요.</string>
<string name="connection_setup_pending">Hermes를 기다리는 중…</string>
<!-- Kanban task detail -->
<string name="kanban_specify_task">작업 지정</string>
<string name="kanban_specifying">지정 중…</string>
<string name="kanban_specify_success">작업이 지정되었습니다</string>
<string name="kanban_specify_failed">작업 지정 실패: %1$s</string>
<string name="kanban_add_parent">상위 작업 추가</string>
<string name="kanban_parent_id">상위 작업 ID</string>
<string name="kanban_link_gated">작업을 지정하기 전에는 연결할 수 없습니다</string>
<string name="kanban_link_created">작업 링크가 생성되었습니다</string>
<string name="kanban_run_details">실행 상세 정보</string>
<string name="kanban_inspect_run">실행 검사</string>
<string name="kanban_terminate_run">실행 종료</string>
<string name="kanban_terminate_run_confirm">이 실행을 종료하시겠습니까?</string>
<string name="kanban_run_terminated">실행이 종료되었습니다</string>
<string name="kanban_run_inactive">실행이 비활성 상태입니다</string>
<string name="kanban_run_inspection_unavailable">실행 검사를 사용할 수 없습니다</string>
<string name="kanban_run_state_filter">실행 상태</string>
<string name="kanban_all_runs">모든 실행</string>
<string name="kanban_delete_attachment">첨부 파일 삭제</string>
<string name="kanban_delete_attachment_confirm">이 첨부 파일을 삭제하시겠습니까?</string>
<string name="kanban_download_attachment">첨부 파일 다운로드</string>
<string name="kanban_attachment_saved">첨부 파일이 저장되었습니다</string>
<string name="kanban_attachment_deleted">첨부 파일이 삭제되었습니다</string>
<string name="kanban_attachment_error">첨부 파일 오류: %1$s</string>
<string name="kanban_active_workers">활성 작업자</string>
<string name="kanban_no_active_workers">활성 작업자가 없습니다</string>
</resources>
+42
View File
@@ -165,6 +165,7 @@
<string name="chat_input_placeholder_type_message">输入消息…</string>
<string name="chat_tool_execution_error">执行错误:%1$s</string>
<string name="chat_tool_exit_code">退出码:%1$d</string>
<string name="chat_tool_output_omitted">进程输出省略了 %1$d 个字符</string>
<string name="chat_tool_fallback">工具</string>
<string name="chat_tool_show_raw">显示原始 JSON</string>
<string name="chat_tool_show_parsed">显示解析输出</string>
@@ -908,6 +909,7 @@
<string name="plugins_no_desc">无描述。</string>
<string name="plugins_action_update">更新</string>
<string name="plugins_action_uninstall">卸载</string>
<string name="plugins_action_enable">启用</string>
<string name="plugins_action_install">安装</string>
<string name="plugins_action_remove">移除</string>
<string name="plugins_install_heading">安装插件</string>
@@ -1467,4 +1469,44 @@
<string name="media_player_play_video">播放视频</string>
<string name="chat_session_preparing">正在准备会话…</string>
<string name="chat_session_not_ready">会话未就绪,请重试以继续</string>
<!-- Kanban task detail -->
<string name="kanban_specify_task">指定任务</string>
<string name="kanban_specifying">正在指定…</string>
<string name="kanban_specify_success">任务指定成功</string>
<string name="kanban_specify_failed">指定任务失败:%1$s</string>
<string name="kanban_add_parent">添加父任务</string>
<string name="kanban_parent_id">父任务 ID</string>
<string name="kanban_link_gated">指定任务前无法创建链接</string>
<string name="kanban_link_created">任务链接已创建</string>
<string name="kanban_run_details">运行详情</string>
<string name="kanban_inspect_run">检查运行</string>
<string name="kanban_terminate_run">终止运行</string>
<string name="kanban_terminate_run_confirm">确定要终止此运行吗?</string>
<string name="kanban_run_terminated">运行已终止</string>
<string name="kanban_run_inactive">运行未处于活动状态</string>
<string name="kanban_run_inspection_unavailable">无法检查运行</string>
<string name="kanban_run_state_filter">运行状态</string>
<string name="kanban_all_runs">所有运行</string>
<string name="kanban_delete_attachment">删除附件</string>
<string name="kanban_delete_attachment_confirm">确定要删除此附件吗?</string>
<string name="kanban_download_attachment">下载附件</string>
<string name="kanban_attachment_saved">附件已保存</string>
<string name="kanban_attachment_deleted">附件已删除</string>
<string name="kanban_attachment_error">附件错误:%1$s</string>
<string name="kanban_active_workers">活跃工作进程</string>
<string name="kanban_no_active_workers">没有活跃工作进程</string>
<string name="connection_setup_target_title">设置%1$s</string>
<string name="connection_setup_progress">第%1$d项,共%2$d项</string>
<string name="connection_setup_authorized_no_tools">已授权,但工具不可用</string>
<string name="connection_setup_target_resolved">此连接步骤已完成</string>
<string name="connection_setup_tools_available">可用工具:%1$d</string>
<string name="connection_setup_unsupported">当前应用版本不支持此设置步骤。</string>
<string name="connection_setup_connect">连接</string>
<string name="connection_setup_skip">跳过</string>
<string name="connection_setup_continue">继续</string>
<string name="connection_setup_open_browser">在浏览器中打开</string>
<string name="connection_setup_required">%1$s为必填项</string>
<string name="connection_setup_request_failed">无法发送设置响应,请重试。</string>
<string name="connection_setup_pending">正在等待 Hermes…</string>
</resources>
+56
View File
@@ -161,6 +161,19 @@
<string name="bot_chat_never_resets_notice">Bot chats are one continuous conversation — compacting instead. For a throwaway session, create a standard chat.</string>
<string name="chat_action_search">Search</string>
<string name="chat_action_close_search">Close search</string>
<string name="chat_action_timeline">Timeline</string>
<string name="chat_timeline_title">Conversation timeline</string>
<string name="chat_timeline_subtitle">Jump to a prompt without loading the full transcript.</string>
<string name="chat_timeline_empty">No prompts yet.</string>
<string name="chat_timeline_prompt_fallback">Earlier prompt</string>
<string name="chat_timeline_load_more">Load more</string>
<string name="chat_timeline_retry">Retry</string>
<string name="chat_timeline_refresh">Refresh</string>
<string name="chat_history_window_title">Viewing earlier messages</string>
<string name="chat_history_window_latest">Back to latest</string>
<string name="chat_history_window_more_older">Earlier messages exist before this window.</string>
<string name="chat_history_window_more_newer">Newer messages exist beyond this bounded window.</string>
<string name="chat_history_window_more_both">More messages exist before and after this bounded window.</string>
<string name="chat_action_reconnect">Reconnect</string>
<string name="chat_action_relogin">Re-login</string>
<string name="chat_relogin_title">Re-login to Hermes</string>
@@ -183,6 +196,7 @@
<string name="chat_input_placeholder_type_message">Type a message\u2026</string>
<string name="chat_tool_execution_error">Execution Error: %1$s</string>
<string name="chat_tool_exit_code">Exit Code: %1$d</string>
<string name="chat_tool_output_omitted">%1$d characters omitted from process output</string>
<string name="chat_tool_fallback">Tool</string>
<string name="chat_tool_show_raw">Show Raw JSON</string>
<string name="chat_tool_show_parsed">Show Parsed Output</string>
@@ -443,6 +457,7 @@
<string name="kanban_bulk_unassign">Unassign</string>
<string name="kanban_bulk_delete">Delete</string>
<string name="kanban_bulk_delete_confirm">Delete %1$d selected tasks? This cannot be undone.</string>
<string name="kanban_bulk_complete_title">Complete %1$d tasks</string>
<string name="kanban_export_board">Export Board</string>
<string name="kanban_import_board">Import Board</string>
<string name="kanban_runtime_working">Working</string>
@@ -1006,6 +1021,7 @@
<string name="plugins_no_desc">No description provided.</string>
<string name="plugins_action_update">Update</string>
<string name="plugins_action_uninstall">Uninstall</string>
<string name="plugins_action_enable">Enable</string>
<string name="plugins_action_install">Install</string>
<string name="plugins_action_remove">Remove</string>
<string name="plugins_install_heading">Install Plugin</string>
@@ -1681,4 +1697,44 @@
<string name="media_player_play_video">Play video</string>
<string name="chat_session_preparing">Preparing session…</string>
<string name="chat_session_not_ready">Session not ready — retry to continue</string>
<string name="connection_setup_target_title">Set up %1$s</string>
<string name="connection_setup_progress">%1$d of %2$d</string>
<string name="connection_setup_authorized_no_tools">Authorized; tools unavailable</string>
<string name="connection_setup_target_resolved">This connection step is complete</string>
<string name="connection_setup_tools_available">Tools available: %1$d</string>
<string name="connection_setup_unsupported">This setup step is not supported by this app version.</string>
<string name="connection_setup_connect">Connect</string>
<string name="connection_setup_skip">Skip</string>
<string name="connection_setup_continue">Continue</string>
<string name="connection_setup_open_browser">Open in browser</string>
<string name="connection_setup_required">%1$s is required</string>
<string name="connection_setup_request_failed">Couldn’t send the setup response. Try again.</string>
<string name="connection_setup_pending">Waiting for Hermes…</string>
<!-- Kanban task detail -->
<string name="kanban_specify_task">Specify task</string>
<string name="kanban_specifying">Specifying…</string>
<string name="kanban_specify_success">Task specified successfully</string>
<string name="kanban_specify_failed">Failed to specify task: %1$s</string>
<string name="kanban_add_parent">Add parent</string>
<string name="kanban_parent_id">Parent task ID</string>
<string name="kanban_link_gated">Linking is unavailable until the task is specified</string>
<string name="kanban_link_created">Task link created</string>
<string name="kanban_run_details">Run details</string>
<string name="kanban_inspect_run">Inspect run</string>
<string name="kanban_terminate_run">Terminate run</string>
<string name="kanban_terminate_run_confirm">Terminate this run?</string>
<string name="kanban_run_terminated">Run terminated</string>
<string name="kanban_run_inactive">Run is inactive</string>
<string name="kanban_run_inspection_unavailable">Run inspection is unavailable</string>
<string name="kanban_run_state_filter">Run state</string>
<string name="kanban_all_runs">All runs</string>
<string name="kanban_delete_attachment">Delete attachment</string>
<string name="kanban_delete_attachment_confirm">Delete this attachment?</string>
<string name="kanban_download_attachment">Download attachment</string>
<string name="kanban_attachment_saved">Attachment saved</string>
<string name="kanban_attachment_deleted">Attachment deleted</string>
<string name="kanban_attachment_error">Attachment error: %1$s</string>
<string name="kanban_active_workers">Active workers</string>
<string name="kanban_no_active_workers">No active workers</string>
</resources>
@@ -1256,6 +1256,17 @@ class E2eIntegrationTest {
com.m57.hermescontrol.data.model
.UpdateTaskResponse(task = task.copy(status = "ready")),
)
coEvery { mockApiService.getBoard("board-1", any(), any()) } returns
Response.success(
KanbanBoardResponse(
listOf(
KanbanColumn("todo", emptyList()),
KanbanColumn("ready", listOf(task.copy(status = "ready"))),
),
null,
null,
),
)
viewModel.moveTask(viewModel.uiState.value.tasks[0], KanbanTaskAction.READY)
advanceUntilIdle()
assertEquals(
@@ -314,4 +314,57 @@ class KanbanSerializationTest {
assertTrue(unassignJson.contains("\"assignee\":\"\""))
assertTrue(unassignJson.contains("\"reclaim_first\":true"))
}
@Test
fun testRunAndWorkerWireShapesRetainScopeAndOptionalInspectionState() {
val run =
json
.decodeFromString<KanbanRunResponse>(
"""{"run":{"id":7,"task_id":"t_1","profile":"builder","step_key":"implement", "status":"running","worker_pid":42,"started_at":100,"max_runtime_seconds":3600,"last_heartbeat_at":110}}""",
).run
assertEquals("t_1", run.taskId)
assertEquals("implement", run.stepKey)
assertEquals(3600, run.maxRuntimeSeconds)
assertNull(run.endedAt)
val workers =
json.decodeFromString<ActiveWorkersResponse>(
"""{"workers":[{"run_id":7,"task_id":"t_1","task_title":"Build", "task_status":"running","worker_pid":42}],"count":1,"checked_at":123}""",
)
assertEquals(7L, workers.workers.single().runId)
assertEquals(123L, workers.checkedAt)
val unavailable =
json.decodeFromString<KanbanRunInspection>(
"""{"run_id":7,"alive":false,"reason":"psutil not available"}""",
)
assertFalse(unavailable.alive)
assertEquals("psutil not available", unavailable.reason)
assertNull(unavailable.cpuPercent)
}
@Test
fun testBoardTaskWorkflowFieldsAndNullableAttachments() {
val task =
json.decodeFromString<KanbanTask>(
"""{"id":"t_1","title":"Build","status":"running","workflow_template_id":"release","current_step_key":"verify","current_run_id":7}""",
)
assertEquals("release", task.workflowTemplateId)
assertEquals("verify", task.currentStepKey)
assertEquals(7L, task.currentRunId)
val detail =
json.decodeFromString<KanbanTaskDetailResponse>(
"""{"task":{"id":"t_1","title":"Build","status":"running"},"attachments":null,"runs":[]}""",
)
assertNull(detail.attachments)
assertTrue(detail.runs.isEmpty())
}
@Test
fun testTaskLinkPayloadUsesBackendSnakeCase() {
val wire = json.encodeToString(TaskLinkBody(parentId = "t_parent", childId = "t_child"))
assertTrue(wire.contains("\"parent_id\":\"t_parent\""))
assertTrue(wire.contains("\"child_id\":\"t_child\""))
}
}
@@ -0,0 +1,130 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.decodeFromString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ModelCapabilitiesSerializationTest {
private val json = OkHttpProvider.json
@Test
fun fullCapabilities_decodeAllNullableFields() {
val value =
json.decodeFromString<ModelInfoResponse>(
"""
{"model":"gpt-5","provider":"openai","capabilities":{
"supports_tools":false,"supports_vision":true,"supports_reasoning":true,
"context_window":128000,"max_output_tokens":4096,"model_family":"gpt-5"
}}
""".trimIndent(),
)
assertEquals(false, value.capabilities?.supports_tools)
assertEquals(true, value.capabilities?.supports_vision)
assertEquals(true, value.capabilities?.reasoningSupport)
assertEquals(128000L, value.capabilities?.context_window)
assertEquals(4096L, value.capabilities?.max_output_tokens)
assertEquals("gpt-5", value.capabilities?.model_family)
}
@Test
fun partialMissingAndNullCapabilities_preserveUnknown() {
val partial = json.decodeFromString<ModelInfoResponse>("""{"capabilities":{"supports_vision":false}}""")
val missing = json.decodeFromString<ModelInfoResponse>("{}")
val explicitNull = json.decodeFromString<ModelInfoResponse>("""{"capabilities":null}""")
assertEquals(false, partial.capabilities?.supports_vision)
assertNull(partial.capabilities?.supports_tools)
assertNull(partial.capabilities?.reasoningSupport)
assertNull(missing.capabilities)
assertNull(explicitNull.capabilities)
}
@Test
fun canonicalReasoning_takesPrecedenceOverLegacyField() {
val value =
json.decodeFromString<ModelOptionsResponse>(
"""
{"providers":[{"slug":"test","name":"Test","capabilities":{
"model":{"reasoning":true,"supports_reasoning":false}
}}]}
""".trimIndent(),
)
assertEquals(
false,
value.providers
.single()
.capabilities
?.get("model")
?.reasoningSupport,
)
}
@Test
fun modelInfoCapabilities_enrichOnlyMatchingCatalogModel() {
val catalog =
listOf(
ModelProvider(
slug = "openai",
name = "OpenAI",
models = listOf("gpt-5", "gpt-4o"),
capabilities =
mapOf(
"gpt-5" to ModelCapabilities(fast = true, reasoning = true),
"gpt-4o" to ModelCapabilities(fast = false, reasoning = false),
),
),
)
val info =
ModelInfoResponse(
model = "gpt-5",
provider = "openai",
capabilities =
ModelCapabilities(
supports_tools = false,
supports_vision = true,
supports_reasoning = true,
context_window = 128000,
max_output_tokens = 4096,
model_family = "gpt-5",
),
)
val enriched = catalog.withModelInfoCapabilities(info)
val active = enriched.single().capabilities?.get("gpt-5")
assertEquals(true, active?.fast)
assertEquals(false, active?.supports_tools)
assertEquals(true, active?.supports_vision)
assertEquals(128000L, active?.context_window)
assertEquals("gpt-5", active?.model_family)
assertEquals(
ModelCapabilities(fast = false, reasoning = false),
enriched.single().capabilities?.get("gpt-4o"),
)
}
@Test
fun modelInfoCapabilities_doNotBleedIntoDifferentProviderOrModel() {
val catalog =
listOf(
ModelProvider(
slug = "openai",
name = "OpenAI",
models = listOf("gpt-5"),
capabilities = mapOf("gpt-5" to ModelCapabilities(reasoning = true)),
),
)
val otherProfileInfo =
ModelInfoResponse(
model = "claude-sonnet-4-5",
provider = "anthropic",
capabilities = ModelCapabilities(supports_vision = true),
)
assertEquals(catalog, catalog.withModelInfoCapabilities(otherProfileInfo))
}
}
@@ -9,6 +9,7 @@ import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
@@ -468,6 +469,135 @@ class HermesApiServiceMockWebServerTest {
)
}
@Test
fun getSessionTimeline_sendsCursorAndParsesFinalPageWithUnknownFields() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""
{
"session_id": "desktop/session",
"profile": "work",
"future_top_level_field": "ignored",
"entries": [
{
"row_id": 91,
"preview": "First compacted prompt",
"timestamp": 1718000000,
"future_entry_field": {"anything": true}
}
],
"pagination": {
"limit": 500,
"after_row_id": 77,
"returned": 1,
"total": 1,
"has_more": false,
"next_cursor": null,
"future_pagination_field": "ignored"
}
}
""".trimIndent(),
),
)
val response =
api.getSessionTimeline(
sessionId = "desktop/session",
profile = "work",
limit = 500,
afterRowId = 77,
)
assertTrue(response.isSuccessful)
val body = response.body()
assertNotNull(body)
assertEquals(91, body!!.entries.single().row_id)
assertEquals("First compacted prompt", body.entries.single().preview)
assertFalse(body.pagination.has_more)
assertNull(body.pagination.next_cursor)
assertEquals(
"/api/sessions/desktop/session/timeline?profile=work&limit=500&after_row_id=77",
mockServer.takeRequest().path,
)
}
@Test
fun getSessionMessagesAround_sendsStableRowAndPreservesChronologicalOrder() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""
{
"session_id": "desktop/session",
"profile": "work",
"messages": [
{
"id": 42,
"role": "user",
"content": "[model-facing compaction summary]",
"display_content": "target"
},
{"id": 43, "role": "assistant", "content": "answer"}
],
"pagination": {
"row_id": 42,
"limit": 120,
"returned": 2,
"order": "oldest",
"offset": 7,
"total": 100,
"has_older": true,
"has_newer": true,
"future_field": 123
}
}
""".trimIndent(),
),
)
val response =
api.getSessionMessagesAround(
sessionId = "desktop/session",
rowId = 42,
profile = "work",
limit = 120,
)
assertTrue(response.isSuccessful)
val body = response.body()
assertNotNull(body)
assertEquals(listOf(42, 43), body!!.messages.map { it.id })
assertEquals("target", body.messages.first().displayContentText)
assertEquals("oldest", body.pagination.order)
assertEquals(7, body.pagination.offset)
assertEquals(true, body.pagination.has_older)
assertEquals(true, body.pagination.has_newer)
assertEquals(
"/api/sessions/desktop/session/messages/around?row_id=42&profile=work&limit=120",
mockServer.takeRequest().path,
)
}
@Test
fun getSessionMessagesAround_missingPromptReturns404() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(404)
.setBody("""{"detail":"Prompt not found"}"""),
)
val response = api.getSessionMessagesAround(sessionId = "session-1", rowId = 999)
assertFalse(response.isSuccessful)
assertEquals(404, response.code())
}
@Test
fun getOAuthProviders_parsesList() =
runBlocking {
@@ -7,6 +7,9 @@ import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
import com.m57.hermescontrol.data.model.ReassignTaskBody
import com.m57.hermescontrol.data.model.ReclaimTaskBody
import com.m57.hermescontrol.data.model.RenameBoardBody
import com.m57.hermescontrol.data.model.SpecifyTaskBody
import com.m57.hermescontrol.data.model.TaskLinkBody
import com.m57.hermescontrol.data.model.TerminateRunBody
import com.m57.hermescontrol.data.model.UpdateTaskBody
import kotlinx.coroutines.runBlocking
import okhttp3.MediaType.Companion.toMediaType
@@ -308,4 +311,155 @@ class KanbanApiServiceTest {
assertEquals("POST", nudgeReq.method)
assertEquals("/api/plugins/kanban/dispatch?board=dev", nudgeReq.path)
}
@Test
fun testBoardAndTaskFiltersAreExplicitlyScoped() =
runBlocking {
mockServer.enqueue(MockResponse().setResponseCode(200).setBody("""{ "columns": [] }"""))
api.getBoard(
board = "dev",
includeArchived = false,
tenant = null,
workflowTemplateId = "release",
currentStepKey = "verify",
)
val boardRequest = mockServer.takeRequest()
assertEquals("GET", boardRequest.method)
assertTrue(boardRequest.path?.contains("board=dev") == true)
assertTrue(boardRequest.path?.contains("workflow_template_id=release") == true)
assertTrue(boardRequest.path?.contains("current_step_key=verify") == true)
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "task": { "id": "t_1", "title": "Task", "status": "done" } }"""),
)
api.getTask(
taskId = "t_1",
board = "dev",
runStateType = "outcome",
runStateName = "completed",
)
val taskRequest = mockServer.takeRequest()
assertEquals("GET", taskRequest.method)
assertTrue(taskRequest.path?.contains("board=dev") == true)
assertTrue(taskRequest.path?.contains("run_state_type=outcome") == true)
assertTrue(taskRequest.path?.contains("run_state_name=completed") == true)
}
@Test
fun testAttachmentListAndDeleteAreBoardScoped() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "attachments": [{ "id": 9, "filename": "proof.txt" }] }"""),
)
val listed = api.listAttachments(taskId = "t_1", board = "dev")
assertEquals(
"proof.txt",
listed
.body()
?.attachments
?.single()
?.filename,
)
assertEquals("/api/plugins/kanban/tasks/t_1/attachments?board=dev", mockServer.takeRequest().path)
mockServer.enqueue(MockResponse().setResponseCode(200).setBody("""{ "ok": true, "id": 9 }"""))
val deleted = api.deleteAttachment(attachmentId = 9L, board = "dev")
assertTrue(deleted.body()?.ok == true)
val request = mockServer.takeRequest()
assertEquals("DELETE", request.method)
assertEquals("/api/plugins/kanban/attachments/9?board=dev", request.path)
}
@Test
fun testWorkerRunAndSpecificationOperationsAreBoardScoped() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""{ "workers": [{ "run_id": 7, "task_id": "t_1", "task_title": "Task", "task_status": "running", "worker_pid": 42 }], "count": 1, "checked_at": 123 }""",
),
)
val workers = api.getActiveWorkers(board = "dev")
assertEquals(
7L,
workers
.body()
?.workers
?.single()
?.runId,
)
assertEquals("/api/plugins/kanban/workers/active?board=dev", mockServer.takeRequest().path)
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "run": { "id": 7, "task_id": "t_1", "status": "running", "started_at": 100 } }"""),
)
val run = api.getRun(runId = 7L, board = "dev")
assertEquals("t_1", run.body()?.run?.taskId)
assertEquals("/api/plugins/kanban/runs/7?board=dev", mockServer.takeRequest().path)
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "run_id": 7, "alive": true, "pid": 42, "cpu_percent": 1.5 }"""),
)
val inspection = api.inspectRun(runId = 7L, board = "dev")
assertTrue(inspection.body()?.alive == true)
assertEquals("/api/plugins/kanban/runs/7/inspect?board=dev", mockServer.takeRequest().path)
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "ok": true, "run_id": 7, "task_id": "t_1" }"""),
)
val terminated =
api.terminateRun(
runId = 7L,
board = "dev",
body = TerminateRunBody(reason = "Operator requested stop"),
)
assertTrue(terminated.body()?.ok == true)
val terminateRequest = mockServer.takeRequest()
assertEquals("POST", terminateRequest.method)
assertEquals("/api/plugins/kanban/runs/7/terminate?board=dev", terminateRequest.path)
assertTrue(terminateRequest.body.readUtf8().contains("Operator requested stop"))
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "ok": true, "task_id": "t_1", "new_title": "Specified task" }"""),
)
val specified = api.specifyTask("t_1", "dev", SpecifyTaskBody(author = "mobile"))
assertEquals("Specified task", specified.body()?.newTitle)
assertEquals("/api/plugins/kanban/tasks/t_1/specify?board=dev", mockServer.takeRequest().path)
}
@Test
fun testTaskLinkConsumesGatingResult() =
runBlocking {
mockServer.enqueue(MockResponse().setResponseCode(200).setBody("""{ "ok": true, "gated": true }"""))
val response =
api.createTaskLink(
board = "dev",
body = TaskLinkBody(parentId = "parent", childId = "child"),
)
assertTrue(response.body()?.ok == true)
assertTrue(response.body()?.gated == true)
val request = mockServer.takeRequest()
assertEquals("POST", request.method)
assertEquals("/api/plugins/kanban/links?board=dev", request.path)
}
}
@@ -4,8 +4,10 @@ import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
@@ -49,6 +51,21 @@ class ProfileScopeInterceptorTest {
return server.takeRequest().requestUrl!!
}
private fun requestedUrl(
client: OkHttpClient,
path: String,
method: String = "GET",
): HttpUrl {
server.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
val builder = Request.Builder().url(server.url(path))
when (method) {
"DELETE" -> builder.delete()
else -> builder.get()
}
client.newCall(builder.build()).execute().close()
return server.takeRequest().requestUrl!!
}
@Test
fun scopedEndpoint_appendsProfile() {
val client = clientFor("work")
@@ -131,6 +148,100 @@ class ProfileScopeInterceptorTest {
}
}
@Test
fun newlyScopedReads_followActiveProfileAcrossSwitches() {
val client = clientFor("alpha")
assertEquals("alpha", requestedUrl(client, "api/webhooks").queryParameter("profile"))
AuthManager.setActiveProfileId("beta")
assertEquals("beta", requestedUrl(client, "api/credentials/pool").queryParameter("profile"))
AuthManager.setActiveProfileId("alpha")
assertEquals("alpha", requestedUrl(client, "api/ops/hooks").queryParameter("profile"))
}
@Test
fun newlyScopedMutations_followActiveProfileAcrossSwitches() {
val client = clientFor("alpha")
assertEquals(
"alpha",
requestedUrl(client, "api/webhooks/build-events", method = "DELETE").queryParameter("profile"),
)
AuthManager.setActiveProfileId("beta")
assertEquals(
"beta",
requestedUrl(client, "api/credentials/pool/openai/1", method = "DELETE").queryParameter("profile"),
)
AuthManager.setActiveProfileId("alpha")
assertEquals(
"alpha",
requestedUrl(client, "api/ops/hooks", method = "DELETE").queryParameter("profile"),
)
}
@Test
fun mobileUsedOpsRoutes_areScopedWithoutBlanketOpsMatch() {
val client = clientFor("work")
val paths =
listOf(
"api/ops/backup",
"api/ops/backup/download",
"api/ops/doctor",
"api/ops/security-audit",
"api/ops/prompt-size",
"api/ops/dump",
"api/ops/config-migrate",
"api/ops/import-upload",
"api/ops/debug-share",
"api/ops/checkpoints",
"api/ops/checkpoints/prune",
"api/ops/hooks",
)
for (path in paths) {
val url = requestedUrl(client, path)
assertEquals("work", url.queryParameter("profile"))
}
val unclassified = requestedUrl(client, "api/ops/machine-global")
assertNull(unclassified.queryParameter("profile"))
}
@Test
fun destructiveWebhookDelete_avoidsMultiplexUnnamedProfile400() {
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
if (request.requestUrl?.queryParameter("profile").isNullOrBlank()) {
MockResponse().setResponseCode(400)
} else {
MockResponse().setResponseCode(200)
}
}
val unscopedClient = clientFor(null)
val unscopedRequest =
Request
.Builder()
.url(server.url("api/webhooks/build-events"))
.delete()
.build()
unscopedClient.newCall(unscopedRequest).execute().use { response ->
assertEquals(400, response.code)
}
val scopedClient = clientFor("beta")
val scopedRequest =
Request
.Builder()
.url(server.url("api/webhooks/build-events"))
.delete()
.build()
scopedClient.newCall(scopedRequest).execute().use { response ->
assertEquals(200, response.code)
}
}
@Test
fun lookalikePath_notScoped() {
// Sourcery review (PR #540): `startsWith` must not match non-segment
@@ -2,9 +2,13 @@ package com.m57.hermescontrol.data.repository
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateTaskResponse
import com.m57.hermescontrol.data.model.DeleteAttachmentResponse
import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.model.SpecifyTaskResponse
import com.m57.hermescontrol.data.model.TaskLinkResponse
import com.m57.hermescontrol.data.model.TerminateRunResponse
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.remote.KanbanApiService
@@ -107,4 +111,28 @@ class KanbanRepositoryTest {
val result = repository.getBoard("missing")
assertTrue(result is NetworkResult.Failure)
}
@Test
fun testNewTaskOperationsCarryBoardAndTypedResults() =
runTest(testDispatcher) {
coEvery { mockApi.deleteAttachment(9L, "ops") } returns
Response.success(DeleteAttachmentResponse(ok = true, id = 9L))
coEvery { mockApi.terminateRun(7L, "ops", any()) } returns
Response.success(TerminateRunResponse(ok = true, runId = 7L, taskId = "t_1"))
coEvery { mockApi.specifyTask("t_1", "ops", any()) } returns
Response.success(SpecifyTaskResponse(ok = false, taskId = "t_1", reason = "Not configured"))
coEvery { mockApi.createTaskLink("ops", any()) } returns
Response.success(TaskLinkResponse(ok = true, gated = true))
assertTrue((repository.deleteAttachment(9L, "ops") as NetworkResult.Success).data.ok)
assertEquals("t_1", (repository.terminateRun(7L, "ops") as NetworkResult.Success).data.taskId)
assertEquals("Not configured", (repository.specifyTask("t_1", "ops") as NetworkResult.Success).data.reason)
assertTrue((repository.createTaskLink("ops", "parent", "t_1") as NetworkResult.Success).data.gated)
coVerify { mockApi.deleteAttachment(9L, "ops") }
coVerify { mockApi.terminateRun(7L, "ops", any()) }
coVerify { mockApi.specifyTask("t_1", "ops", any()) }
coVerify {
mockApi.createTaskLink("ops", match { it.parentId == "parent" && it.childId == "t_1" })
}
}
}
@@ -0,0 +1,83 @@
package com.m57.hermescontrol.data.ws
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ConnectionOperationEventParserTest {
@Test
fun connectionRequest_usesEnvelopeSessionAndPayloadSequence() {
val event =
EventParser.parseParams(
mapOf(
"type" to "connection.request",
"session_id" to "runtime-session",
"seq" to 999,
"payload" to operationPayload(seq = 4L),
),
)
assertTrue(event is WsEvent.ConnectionRequest)
val snapshot = (event as WsEvent.ConnectionRequest).snapshot
assertEquals("runtime-session", snapshot.sessionId)
assertEquals(4L, snapshot.seq)
}
@Test
fun connectionUpdate_parsesFullSettledSnapshot() {
val event =
EventParser.parseParams(
mapOf(
"type" to "connection.update",
"session_id" to "runtime-session",
"payload" to
operationPayload(seq = 5L) +
mapOf(
"settled" to true,
"settled_by" to "all_resolved",
"future_field" to "ignored",
),
),
)
assertTrue(event is WsEvent.ConnectionUpdate)
val snapshot = (event as WsEvent.ConnectionUpdate).snapshot
assertTrue(snapshot.settled)
assertEquals("all_resolved", snapshot.settledBy)
}
@Test
fun malformedOperation_degradesWithoutRetainingRawFrame() {
val event =
EventParser.parseParams(
params =
mapOf(
"type" to "connection.request",
"session_id" to "runtime-session",
"payload" to operationPayload(seq = 1L) - "op_id",
),
rawJson = "issue1218-secret-canary",
)
assertTrue(event is WsEvent.Unknown)
assertEquals("", (event as WsEvent.Unknown).raw)
}
private fun operationPayload(seq: Long): Map<String, Any?> =
mapOf(
"op_id" to "op-1",
"seq" to seq,
"deadline_at" to 2_000_000_000.0,
"timeout_seconds" to 120.0,
"tool_call_id" to "tool-1",
"targets" to
listOf(
mapOf(
"name" to "github",
"kind" to "connector",
"action" to "authorize",
"state" to "pending",
),
),
)
}
@@ -0,0 +1,169 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ConnectionTargetAction
import com.m57.hermescontrol.data.model.ConnectionTargetKind
import com.m57.hermescontrol.data.model.ConnectionTargetState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ConnectionOperationParserTest {
@Test
fun parseCompleteSnapshot_preservesBackendOrderAndMetadata() {
val snapshot = ConnectionOperationParser.parse(completePayload(), "runtime-session")
assertNotNull(snapshot)
snapshot!!
assertEquals("runtime-session", snapshot.sessionId)
assertEquals("op-1218", snapshot.opId)
assertEquals(7L, snapshot.seq)
assertEquals(2, snapshot.targets.size)
val target = snapshot.targets.first()
assertEquals("reports", target.name)
assertEquals(ConnectionTargetKind.MCP, target.kind)
assertEquals(ConnectionTargetAction.INSTALL, target.action)
assertEquals(ConnectionTargetState.PENDING, target.state)
assertEquals("install-2", target.attempt)
assertEquals("Use a workspace token", target.hint)
assertEquals(listOf("REGION", "API_TOKEN"), target.requiredEnv.map { it.name })
assertEquals("us-east", target.requiredEnv[0].defaultValue)
assertNull(target.requiredEnv[1].defaultValue)
assertTrue(target.requiredEnv[1].secret)
assertEquals(listOf("search", "export"), target.tools)
}
@Test
fun unknownFieldsAndEnumValues_degradeWithoutDroppingUsableTarget() {
val payload =
completePayload().toMutableMap().apply {
put("future_operation_field", mapOf("nested" to true))
put(
"targets",
listOf(
targetPayload().toMutableMap().apply {
put("kind", "future-kind")
put("action", "future-action")
put("state", "future-state")
put("future_target_field", "ignored")
},
),
)
}
val target = ConnectionOperationParser.parse(payload, "runtime-session")!!.targets.single()
assertEquals(ConnectionTargetKind.UNKNOWN, target.kind)
assertEquals(ConnectionTargetAction.UNKNOWN, target.action)
assertEquals(ConnectionTargetState.UNKNOWN, target.state)
}
@Test
fun malformedIdentityOrDeadline_isRejected() {
assertNull(ConnectionOperationParser.parse(completePayload() - "op_id", "runtime-session"))
assertNull(ConnectionOperationParser.parse(completePayload() - "seq", "runtime-session"))
assertNull(ConnectionOperationParser.parse(completePayload() - "deadline_at", "runtime-session"))
assertNull(ConnectionOperationParser.parse(completePayload() + ("seq" to 1.5), "runtime-session"))
assertNull(
ConnectionOperationParser.parse(completePayload() + ("deadline_at" to Double.NaN), "runtime-session"),
)
assertNull(
ConnectionOperationParser.parse(completePayload() + ("targets" to emptyList<Any>()), "runtime-session"),
)
}
@Test
fun secretDefaultsAndOAuthUrls_areNeverExposedByModels() {
val canary = "issue1218-secret-canary"
val payload =
completePayload() +
(
"targets" to
listOf(
targetPayload() +
(
"connect_url" to
"https://example.com/oauth?token=$canary"
) +
(
"required_env" to
listOf(
mapOf(
"name" to "API_TOKEN",
"required" to true,
"secret" to true,
"default" to canary,
),
)
),
)
)
val snapshot = ConnectionOperationParser.parse(payload, "runtime-session")!!
assertNull(
snapshot.targets
.single()
.requiredEnv
.single()
.defaultValue,
)
assertFalse(snapshot.toString().contains(canary))
assertTrue(snapshot.toString().contains("[REDACTED]"))
}
private fun completePayload(): Map<String, Any?> =
mapOf(
"op_id" to "op-1218",
"seq" to 7L,
"deadline_at" to 2_000_000_000.0,
"timeout_seconds" to 300.0,
"tool_call_id" to "tool-1",
"settled" to false,
"targets" to
listOf(
targetPayload(),
mapOf(
"name" to "github",
"kind" to "connector",
"action" to "authorize",
"state" to "initiated",
),
),
)
private fun targetPayload(): Map<String, Any?> =
mapOf(
"name" to "reports",
"kind" to "mcp",
"action" to "install",
"state" to "pending",
"detail" to "Credentials required",
"instructions" to "Enter the service credentials",
"discovery_error" to null,
"connect_url" to null,
"connection_id" to "vendor-account-1",
"attempt" to "install-2",
"hint" to "Use a workspace token",
"required_env" to
listOf(
mapOf(
"name" to "REGION",
"required" to false,
"secret" to false,
"default" to "us-east",
"prompt" to "Region",
),
mapOf(
"name" to "API_TOKEN",
"required" to true,
"secret" to true,
"default" to "must-not-be-retained",
"prompt" to "API token",
),
),
"tools" to listOf("search", "export"),
)
}
@@ -0,0 +1,48 @@
package com.m57.hermescontrol.data.ws
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PluginRemovalRepositoryTest {
@Test
fun `remove sends scoped gateway action and decodes success`() =
runTest {
var method = ""
var params: Map<String, Any> = emptyMap()
val repository =
PluginRemovalRepository { requestedMethod, requestedParams ->
method = requestedMethod
params = requestedParams
Json.parseToJsonElement("""{"ok":true,"name":"demo"}""")
}
val result = repository.remove("demo")
assertEquals(WsMethods.PLUGINS_MANAGE, method)
assertTrue(WsMethods.PLUGINS_MANAGE in WsMethods.PROFILE_SCOPED_METHODS)
assertEquals(mapOf("action" to "remove", "name" to "demo"), params)
assertTrue(result.ok)
assertEquals("demo", result.name)
}
@Test
fun `refusal is a typed failure and missing ok cannot masquerade as success`() =
runTest {
val refused = PluginRemovalRepository { _, _ -> mapOf("ok" to false, "error" to "bundled plugin") }
assertFalse(refused.remove("builtin").ok)
assertEquals("bundled plugin", refused.remove("builtin").error)
val malformed = PluginRemovalRepository { _, _ -> mapOf("name" to "demo") }
try {
malformed.remove("demo")
throw AssertionError("Missing ok must fail decoding")
} catch (_: SerializationException) {
// A partial response is not removal confirmation.
}
}
}
@@ -0,0 +1,314 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.ConnectionEnvField
import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot
import com.m57.hermescontrol.data.model.ConnectionOperationTarget
import com.m57.hermescontrol.data.model.ConnectionTargetAction
import com.m57.hermescontrol.data.model.ConnectionTargetKind
import com.m57.hermescontrol.data.model.ConnectionTargetState
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ChatConnectionOperationDelegateTest {
@Test
fun requestAndUpdate_areStrictlyOrderedAndCorrelated() {
val delegate = delegate()
delegate.reset("session-a")
assertTrue(delegate.acceptRequest(snapshot(opId = "op-a", seq = 1L)))
assertFalse(delegate.acceptUpdate(snapshot(opId = "op-a", seq = 1L)))
assertFalse(delegate.acceptUpdate(snapshot(opId = "op-old", seq = 99L)))
assertEquals(
"op-a",
delegate.state.value.operation
?.opId,
)
assertTrue(delegate.acceptUpdate(snapshot(opId = "op-a", seq = 2L)))
assertEquals(
2L,
delegate.state.value.operation
?.seq,
)
assertTrue(delegate.acceptRequest(snapshot(opId = "op-b", seq = 1L)))
assertFalse(delegate.acceptUpdate(snapshot(opId = "op-a", seq = 3L)))
assertEquals(
"op-b",
delegate.state.value.operation
?.opId,
)
}
@Test
fun wrongSessionAndSettledOperation_areNeverReopened() {
val delegate = delegate()
delegate.reset("session-a")
assertFalse(delegate.acceptRequest(snapshot(sessionId = "session-b")))
assertTrue(delegate.acceptRequest(snapshot(opId = "op-a", seq = 1L)))
assertTrue(delegate.acceptUpdate(snapshot(opId = "op-a", seq = 2L, settled = true)))
assertNull(delegate.state.value.operation)
assertFalse(delegate.acceptRequest(snapshot(opId = "op-a", seq = 3L)))
}
@Test
fun resumeReconciliation_cannotRegressOrClearNewerLiveRequest() {
val delegate = delegate()
delegate.reset("session-a")
delegate.acceptRequest(snapshot(opId = "op-a", seq = 2L))
val checkpoint = delegate.resumeCheckpoint()
delegate.acceptRequest(snapshot(opId = "op-b", seq = 1L))
assertFalse(delegate.reconcileResume(null, checkpoint))
assertFalse(delegate.reconcileResume(snapshot(opId = "op-a", seq = 2L), checkpoint))
assertEquals(
"op-b",
delegate.state.value.operation
?.opId,
)
}
@Test
fun absentResumeSnapshot_clearsOnlyUnchangedOperation() {
val delegate = delegate()
delegate.reset("session-a")
delegate.acceptRequest(snapshot(opId = "op-a", seq = 2L))
val checkpoint = delegate.resumeCheckpoint()
assertTrue(delegate.reconcileResume(null, checkpoint))
assertNull(delegate.state.value.operation)
}
@Test
fun approvePayload_isExactAndSecretsNeverEnterUiState() =
runTest {
val requester = RecordingRequester()
val delegate = delegate(requester)
delegate.reset("session-a")
delegate.acceptRequest(snapshot(requiredEnv = listOf(envField("TOKEN", secret = true))))
val canary = "issue1218-secret-canary"
delegate.respond(
target = "github",
env = mapOf("TOKEN" to canary, "UNDECLARED" to "drop-me"),
approved = true,
)
assertEquals(1, requester.calls.size)
val call = requester.calls.single()
assertEquals(WsMethods.CONNECTION_RESPOND, call.first)
assertEquals("session-a", call.second["session_id"])
assertEquals("op-a", call.second["op_id"])
@Suppress("UNCHECKED_CAST")
val result = call.second["result"] as Map<String, Any>
@Suppress("UNCHECKED_CAST")
val answer = (result["targets"] as List<Map<String, Any>>).single()
assertEquals("github", answer["name"])
assertEquals("approved", answer["status"])
assertEquals(mapOf("TOKEN" to canary), answer["env"])
assertFalse(
delegate.state.value
.toString()
.contains(canary),
)
assertTrue(delegate.state.value.pendingAction is ConnectionPendingAction.Respond)
}
@Test
fun rpcSuccessKeepsExactlyOnceLock_untilNewerSnapshotArrives() =
runTest {
val requester = RecordingRequester()
val delegate = delegate(requester)
delegate.reset("session-a")
delegate.acceptRequest(snapshot(seq = 1L))
delegate.respond("github", emptyMap(), approved = false)
delegate.respond("github", emptyMap(), approved = false)
assertEquals(1, requester.calls.size)
assertEquals(
ConnectionTargetState.PENDING,
delegate.state.value.operation
?.targets
?.single()
?.state,
)
assertTrue(delegate.state.value.pendingAction is ConnectionPendingAction.Respond)
delegate.acceptUpdate(snapshot(seq = 2L, state = ConnectionTargetState.PENDING))
assertNull(delegate.state.value.pendingAction)
delegate.respond("github", emptyMap(), approved = false)
assertEquals(2, requester.calls.size)
}
@Test
fun skipAndContinuePayloads_matchBackendContract() =
runTest {
val skipRequester = RecordingRequester()
val skipDelegate = delegate(skipRequester)
skipDelegate.reset("session-a")
skipDelegate.acceptRequest(snapshot())
skipDelegate.respond("github", mapOf("TOKEN" to "must-drop"), approved = false)
@Suppress("UNCHECKED_CAST")
val skipResult = skipRequester.calls.single().second["result"] as Map<String, Any>
@Suppress("UNCHECKED_CAST")
val skipAnswer = (skipResult["targets"] as List<Map<String, Any>>).single()
assertEquals(mapOf("name" to "github", "status" to "skipped"), skipAnswer)
val continueRequester = RecordingRequester()
val continueDelegate = delegate(continueRequester)
continueDelegate.reset("session-a")
continueDelegate.acceptRequest(snapshot())
continueDelegate.continueOperation()
assertEquals(
mapOf("settled_by" to "continue"),
continueRequester.calls.single().second["result"],
)
}
@Test
fun rpcFailureUnlocksWithSanitizedError() =
runTest {
val requester = RecordingRequester(failure = IllegalStateException("secret backend detail"))
val delegate = delegate(requester)
delegate.reset("session-a")
delegate.acceptRequest(snapshot())
delegate.respond("github", emptyMap(), approved = false)
assertNull(delegate.state.value.pendingAction)
assertEquals(
"request_failed",
delegate.state.value.error
?.message,
)
assertFalse(
delegate.state.value
.toString()
.contains("secret backend detail"),
)
}
@Test
fun lateOldGenerationFailure_cannotMutateResetState() =
runTest {
val gate = CompletableDeferred<Unit>()
val requester =
ConnectionOperationRequester { _, _ ->
gate.await()
throw IllegalStateException("old failure")
}
val delegate = ChatConnectionOperationDelegate(requester)
delegate.reset("session-a")
delegate.acceptRequest(snapshot())
val action = launch { delegate.respond("github", emptyMap(), approved = false) }
runCurrent()
delegate.reset("session-b")
gate.complete(Unit)
action.join()
assertNull(delegate.state.value.operation)
assertNull(delegate.state.value.error)
}
@Test
fun wakeUnknownOperation_isHarmlessSettledRace() =
runTest {
val requester =
RecordingRequester(
failure = HermesWsClient.HermesRpcException("gone", code = 4004),
)
val delegate = delegate(requester)
delegate.reset("session-a")
delegate.acceptRequest(snapshot())
delegate.wake("op-a")
assertNull(delegate.state.value.operation)
assertNull(delegate.state.value.error)
}
private fun delegate(requester: RecordingRequester = RecordingRequester()): ChatConnectionOperationDelegate =
ChatConnectionOperationDelegate(requester)
private fun snapshot(
sessionId: String = "session-a",
opId: String = "op-a",
seq: Long = 1L,
state: ConnectionTargetState = ConnectionTargetState.PENDING,
settled: Boolean = false,
requiredEnv: List<ConnectionEnvField> = emptyList(),
): ConnectionOperationSnapshot =
ConnectionOperationSnapshot(
sessionId = sessionId,
opId = opId,
seq = seq,
deadlineAt = 2_000_000_000.0,
timeoutSeconds = 300.0,
toolCallId = "tool-a",
settled = settled,
settledBy = if (settled) "all_resolved" else null,
targets =
listOf(
ConnectionOperationTarget(
name = "github",
kind = ConnectionTargetKind.CONNECTOR,
action = ConnectionTargetAction.AUTHORIZE,
state = state,
detail = null,
instructions = null,
discoveryError = null,
connectUrl = null,
connectionId = null,
attempt = null,
requiredEnv = requiredEnv,
tools = emptyList(),
hint = null,
),
),
)
private fun envField(
name: String,
secret: Boolean,
): ConnectionEnvField =
ConnectionEnvField(
name = name,
required = true,
secret = secret,
defaultValue = null,
prompt = null,
)
private class RecordingRequester(
private val failure: Throwable? = null,
) : ConnectionOperationRequester {
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
override suspend fun request(
method: String,
params: Map<String, Any>,
): Any? {
calls += method to params
failure?.let { throw it }
return Unit
}
}
}
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.model.ModelCapabilities
import com.m57.hermescontrol.data.model.ModelInfoResponse
import com.m57.hermescontrol.data.model.ModelOptionsResponse
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
@@ -11,6 +12,7 @@ import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
@@ -175,6 +177,41 @@ class ChatModelSwitchDelegateTest {
scopeObserverJob.cancel()
}
@Test
fun modelInfoCapabilities_mergeWithCatalogAndClearOnScopeSwitch() =
testScope.runTest {
val scopeObserverJob = kotlinx.coroutines.Job()
val observerScope = kotlinx.coroutines.CoroutineScope(testDispatcher + scopeObserverJob)
delegate.attachScopeObserver(observerScope)
delegate.preloadModelOptions()
advanceUntilIdle()
uiState.update { it.copy(currentSessionModel = "openai/gpt-4o") }
delegate.applyModelInfo(
ModelInfoResponse(
provider = "openai",
model = "gpt-4o",
capabilities =
ModelCapabilities(
supports_tools = false,
supports_vision = true,
supports_reasoning = true,
),
),
initialDataScope,
)
assertEquals(true, uiState.value.currentModelCapabilities?.fast)
assertEquals(false, uiState.value.currentModelCapabilities?.supports_tools)
assertEquals(true, uiState.value.currentModelCapabilities?.supports_vision)
dataScope.value = initialDataScope.copy(activeProfileId = "work")
runCurrent()
assertNull(uiState.value.currentModelCapabilities)
scopeObserverJob.cancel()
}
@Test
fun closeAndReopenDuringPreload_doesNotDuplicateOrReopenAfterDismiss() =
testScope.runTest {
@@ -610,4 +647,95 @@ class ChatModelSwitchDelegateTest {
delegate.dismissModelSwitchConfirm()
assertEquals("anthropic/claude-3", uiState.value.currentSessionModel)
}
@Test
fun handleConfigSetResult_modelWarningWithoutConfirm_addsAssistantMessage() =
testScope.runTest {
delegate.handleModelSwitch("/model gpt-69-sol --provider openai-codex --session")
advanceUntilIdle()
val warningText =
"Note: `gpt-69-sol` was not found in the OpenAI Codex model listing. " +
"Similar models: `gpt-5.6-sol`"
delegate.handleConfigSetResult(
id = "req-1",
result =
mapOf(
"key" to "model",
"value" to "gpt-69-sol",
"confirm_required" to false,
"warning" to warningText,
),
)
assertEquals(1, assistantMessages.size)
assertEquals("⚠ $warningText", assistantMessages.first())
}
@Test
fun handleConfigSetResult_modelWarningWithExistingWarningEmoji_doesNotDuplicateEmoji() =
testScope.runTest {
delegate.handleModelSwitch("/model gpt-69-sol --provider openai-codex --session")
advanceUntilIdle()
val warningText = "⚠ Note: Already prefixed"
delegate.handleConfigSetResult(
id = "req-1",
result =
mapOf(
"key" to "model",
"value" to "gpt-69-sol",
"confirm_required" to false,
"warning" to warningText,
),
)
assertEquals(listOf(warningText), assistantMessages)
}
@Test
fun handleConfigSetResult_staleSequenceWarning_isIgnored() =
testScope.runTest {
// First switch
delegate.handleModelSwitch("/model gpt-4o --provider openai --session")
advanceUntilIdle()
// Second switch supersedes first
delegate.handleModelSwitch("/model claude-3-5-sonnet --provider anthropic --session")
advanceUntilIdle()
// First switch response arrives with warning
delegate.handleConfigSetResult(
id = "req-1",
result =
mapOf(
"key" to "model",
"value" to "gpt-4o",
"confirm_required" to false,
"warning" to "Stale warning",
),
)
assertTrue(assistantMessages.isEmpty())
}
@Test
fun handleConfigSetResult_blankWarning_doesNotAddAssistantMessage() =
testScope.runTest {
delegate.handleModelSwitch("/model gpt-4o --provider openai --session")
advanceUntilIdle()
delegate.handleConfigSetResult(
id = "req-1",
result =
mapOf(
"key" to "model",
"value" to "gpt-4o",
"confirm_required" to false,
"warning" to " ",
),
)
assertTrue(assistantMessages.isEmpty())
}
}
@@ -0,0 +1,617 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.ui.chat.fakes.FakeChatMessageDao
import com.m57.hermescontrol.ui.chat.fullbleed.fullBleedItemKeys
import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurns
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatPagingMergeTest {
@Test
fun confirmedLiveOccurrenceCannotConsumeEarlierIdenticalPage() {
for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL)) {
val content = if (role == MessageRole.TOOL) "{\"output\":\"ok\"}" else "continue"
val live = ChatMessage(id = "uuid-new", role = role, content = content, timestamp = 2L)
val newest = live.copy(id = "rest-s-20")
val earlier = live.copy(id = "rest-s-10", timestamp = 1L)
val hydrated = mergeTranscriptWithLive(listOf(newest), listOf(live), preserveLiveIds = true)
val paged =
mergeTranscriptWithLive(
listOf(earlier),
hydrated,
chronological = false,
preserveLiveIds = true,
)
assertEquals("Distinct $role occurrences must survive across pages", 2, paged.size)
assertEquals(listOf("rest-s-10", "uuid-new"), paged.map { it.id })
val refreshed = mergeTranscriptWithLive(listOf(newest), paged, preserveLiveIds = true)
assertEquals(2, refreshed.size)
assertEquals("uuid-new", refreshed.last().id)
}
}
@Test
fun mapperAndMergeKeepConfirmedOccurrencesMetadataAndKeysAcrossPages() {
for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL)) {
val content = if (role == MessageRole.TOOL) """{"output":"ok"}""" else "continue"
val live =
ChatMessage(
id = "uuid-new",
role = role,
content = content,
timestamp = 100L,
reasoningText = "live reasoning",
toolName = "terminal",
toolStatus = ToolStatus.COMPLETED,
attachments =
listOf(
Attachment(uri = "content://image", name = "image", mimeType = "image/png", size = 1),
),
finishTimestamp = 200L,
tps = 5.0,
)
fun page(
id: Int,
current: List<ChatMessage>,
): List<ChatMessage> =
mapServerMessages(
"s",
listOf(SessionMessage(id = id, role = role.name.lowercase(), content = JsonPrimitive(content))),
0,
true,
current,
)
val hydrated = mergeTranscriptWithLive(page(20, listOf(live)), listOf(live), preserveLiveIds = true)
val confirmed = hydrated.single()
assertEquals("rest-s-20", confirmed.restId)
assertEquals(live.id, confirmed.id)
assertEquals(live.reasoningText, confirmed.reasoningText)
assertEquals(live.attachments, confirmed.attachments)
assertEquals(live.finishTimestamp, confirmed.finishTimestamp)
assertEquals(live.tps, confirmed.tps)
assertEquals(live.toolStatus, confirmed.toolStatus)
val refreshedPage = page(20, hydrated)
assertEquals(confirmed.timestamp, refreshedPage.single().timestamp)
// Missing server timestamps must not put this older occurrence after the UUID alias.
val paged =
mergeTranscriptWithLive(page(10, hydrated), hydrated, chronological = false, preserveLiveIds = true)
assertEquals(listOf("rest-s-10", "uuid-new"), paged.map { it.id })
assertEquals("", paged.first().reasoningText)
val refreshed =
mergeTranscriptWithLive(page(10, paged), paged, chronological = false, preserveLiveIds = true)
assertEquals(paged, refreshed)
assertEquals("rest-s-20", refreshed.last().restId)
}
}
@Test
fun cacheAliasesRejectEarlierOccurrencesInEitherPageOrder() {
for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL)) {
val content = if (role == MessageRole.TOOL) """{"output":"ok"}""" else "continue"
val live = ChatMessage(id = "uuid", role = role, content = content, timestamp = 20L)
val echo = live.copy(id = "rest-s-20")
val earlier = live.copy(id = "rest-s-10", timestamp = 10L)
for (firstPage in listOf(listOf(live, echo), listOf(echo, live))) {
val cached = dedupeCachedMessages(firstPage)
assertEquals(listOf(live.copy(restId = echo.id)), cached)
val nextPage = dedupeCachedMessages(listOf(earlier) + cached)
assertEquals(listOf(earlier, live.copy(restId = echo.id)), nextPage)
assertEquals(nextPage, dedupeCachedMessages(listOf(echo) + nextPage))
}
}
}
@Test
fun cachedPagesPreserveDisplayedKeyAndEnrichAliasInEitherArrivalOrder() {
val live = tool("uuid", timestamp = 20L).copy(toolName = "terminal")
val echo = tool("rest-s-20", timestamp = 20L)
val earlier = tool("rest-s-10", timestamp = 10L)
for ((first, second) in listOf(live to echo, echo to live)) {
val merged = mergeCachedTranscriptPage(listOf(second), listOf(first))
assertEquals(listOf(live.copy(id = first.id, restId = echo.id)), merged)
val older = mergeCachedTranscriptPage(listOf(earlier), merged)
assertEquals(listOf(earlier.id, first.id), older.map { it.id })
assertEquals(older, mergeCachedTranscriptPage(listOf(echo, live), older))
}
assertEquals(
listOf(earlier, live.copy(restId = echo.id)),
mergeCachedTranscriptPage(listOf(earlier, live), listOf(live.copy(restId = echo.id))),
)
// A page-local dedup must also transfer its alias to an already displayed UUID.
assertEquals(
listOf(live.copy(restId = echo.id)),
mergeCachedTranscriptPage(listOf(echo, live), listOf(live)),
)
}
@Test
fun exactAliasIsReservedBeforeUnconfirmedContentAndSurvivesContentChanges() {
for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL)) {
val content = if (role == MessageRole.TOOL) """{"output":"ok"}""" else "continue"
val live = ChatMessage(id = "uuid", role = role, content = content, restId = "rest-s-20")
val unconfirmed = live.copy(id = "uuid-next", restId = null)
val older = live.copy(id = "rest-s-10", restId = null)
val exact = live.copy(id = "rest-s-20", restId = null, content = "updated by server")
val matches = matchTranscriptMessages(listOf(older, exact), listOf(live, unconfirmed))
assertEquals(listOf(unconfirmed, live), matches)
assertTrue(!sameLogicalMessage(older, live))
}
}
@Test
fun olderRowsUseAliasBoundariesEvenWhenLocalTimestampsDisagree() {
val first = tool("uuid-first", timestamp = 100L).copy(restId = "rest-s-4")
val last = tool("uuid-last", timestamp = 1L).copy(restId = "rest-s-10")
val page = listOf(1, 5, 7).map { tool("rest-s-$it", timestamp = 50L) }
val merged = mergeTranscriptWithLive(page, listOf(first, last), chronological = false, preserveLiveIds = true)
assertEquals(listOf("rest-s-1", "uuid-first", "rest-s-5", "rest-s-7", "uuid-last"), merged.map { it.id })
}
private fun tool(
id: String,
callId: String = "",
timestamp: Long = 1L,
) = ChatMessage(
id = id,
role = MessageRole.TOOL,
content = """{"output":"ok","exit_code":0}""",
toolCallId = callId,
toolStatus = ToolStatus.COMPLETED,
timestamp = timestamp,
)
@Test
fun merge_oneRestEchoCannotConsumeTwoIdenticalLiveResults() {
val first = tool("ws-first", timestamp = 1L)
val second = tool("ws-second", timestamp = 2L)
val echo = tool("rest-session-1", timestamp = 1L)
val merged = mergeTranscriptWithLive(listOf(echo), listOf(first, second))
assertEquals("A result echo may consume at most one live occurrence", 2, merged.size)
assertTrue(merged.any { it.id == second.id })
}
@Test
fun cache_oneLiveEchoCannotConsumeTwoPersistedResults() {
val first = tool("rest-session-1", timestamp = 1L)
val second = tool("rest-session-2", timestamp = 2L)
val echo = tool("ws-first", timestamp = 1L)
val cached = dedupeCachedMessages(listOf(first, echo, second))
assertEquals(2, cached.size)
assertTrue(cached.any { it.id == second.id })
}
@Test
fun merge_identicalPayloadsWithDifferentCallIdsStayDistinct() {
val first = tool("rest-session-1", callId = "call-first")
val second = tool("ws-second", callId = "call-second")
val merged = mergeTranscriptWithLive(listOf(first), listOf(second))
assertEquals(setOf(first.id, second.id), merged.map { it.id }.toSet())
}
@Test
fun merge_largeSettledHistoryRetainsEveryStableId() {
val settled = (1..2_000).map { index -> tool("rest-session-$index", callId = "call-$index") }
val overlappingPage = settled.takeLast(150)
val live = tool("ws-new", callId = "call-new", timestamp = 2L)
val merged = mergeTranscriptWithLive(overlappingPage, settled + live)
assertEquals(2_001, merged.size)
assertEquals((settled + live).map { it.id }.toSet(), merged.map { it.id }.toSet())
}
@Test
fun olderOverlapUpdatesInPlaceWithoutMovingExistingRows() {
val current = (1..10).map { tool("rest-s-$it", callId = "call-$it", timestamp = 1L) }
val page = current.slice(4..5).map { it.copy(toolName = "terminal") }
val merged = mergeTranscriptWithLive(page, current, chronological = false)
assertEquals(current.map { it.id }, merged.map { it.id })
assertEquals("terminal", merged[4].toolName)
assertEquals("terminal", merged[5].toolName)
}
@Test
fun olderDisjointAndGapRowsUseServerOrderWhenTimestampsAreEqual() {
val current = listOf(4, 6, 10).map { tool("rest-s-$it", callId = "call-$it", timestamp = 1L) }
val page = listOf(1, 3, 5, 6, 7).map { tool("rest-s-$it", callId = "call-$it", timestamp = 1L) }
val merged = mergeTranscriptWithLive(page, current, chronological = false)
assertEquals(listOf(1, 3, 4, 5, 6, 7, 10).map { "rest-s-$it" }, merged.map { it.id })
}
private fun serverTool(
id: Int,
callId: String = "",
) = com.m57.hermescontrol.data.model.SessionMessage(
id = id,
role = "tool",
content = kotlinx.serialization.json.JsonPrimitive("""{"output":"ok","exit_code":0}"""),
tool_call_id = callId,
)
@Test
fun mapper_mixedRoomEchoPrefersRichWsToolOverExactRestId() {
val live =
tool("uuid-1", callId = "call-42").copy(
toolName = "terminal",
content = """{"name":"terminal","result":{"output":"ok","exit_code":0}}""",
)
val echo = tool("rest-s-42", callId = "call-42")
val mapped = mapServerMessages("s", listOf(serverTool(42, "call-42")), 0, true, listOf(live, echo))
assertEquals(listOf(live.copy(restId = "rest-s-42")), mapped)
}
@Test
fun mapper_mixedEchoesConsumeIdenticalResultsOnlyOncePerOccurrence() {
val first = tool("uuid-1").copy(toolName = "terminal")
val second = tool("uuid-2").copy(toolName = "terminal")
val cache = listOf(first, second, tool("rest-s-42"), tool("rest-s-43"))
val mapped = mapServerMessages("s", listOf(serverTool(42), serverTool(43)), 0, true, cache)
assertEquals(
listOf(first.copy(restId = "rest-s-42"), second.copy(restId = "rest-s-43")),
mapped,
)
assertEquals(2, mapped.map { it.id }.toSet().size)
}
@Test
fun mapper_differentCallIdsCannotReuseIdenticalLiveResult() {
val live = tool("uuid-1", callId = "call-41").copy(toolName = "terminal")
val echo = tool("rest-s-42", callId = "call-42")
val mapped = mapServerMessages("s", listOf(serverTool(42, "call-42")), 0, true, listOf(live, echo))
assertEquals("rest-s-42", mapped.single().id)
assertEquals("call-42", mapped.single().toolCallId)
assertEquals(null, mapped.single().toolName)
}
@Test
fun cacheAndRestMergesKeepNumericOrderDespiteNonMonotonicTimestamps() {
val rows =
listOf(9, 10, 100, 200).mapIndexed { index, id ->
ChatMessage(
id = "rest-session-$id",
role = MessageRole.ASSISTANT,
content = "answer-$id",
timestamp = listOf(900L, 100L, 800L, 200L)[index],
)
}
for (cache in listOf(false, true)) {
for (newestFirst in listOf(false, true)) {
val first = if (newestFirst) rows.drop(2) else rows.take(2)
val second = if (newestFirst) rows.take(2) else rows.drop(2)
val merged =
if (cache) {
mergeCachedTranscriptPage(second, mergeCachedTranscriptPage(first, emptyList()))
} else {
mergeTranscriptWithLive(
second,
mergeTranscriptWithLive(first, emptyList(), preserveLiveIds = true),
chronological = !newestFirst,
preserveLiveIds = true,
)
}
assertEquals("cache=$cache newestFirst=$newestFirst", rows.map { it.id }, merged.map { it.id })
}
}
}
@Test
fun mapperAssignsOneLiveTraceOnlyToNewestEqualContentRestRow() {
for (completion in listOf(null, "completion-200")) {
val live =
ChatMessage(
id = "uuid",
role = MessageRole.ASSISTANT,
content = "Done",
reasoningText = "newest trace",
completionId = completion,
)
val mapped =
mapServerMessages(
"session",
listOf(assistantRow(100), assistantRow(200)),
0,
true,
listOf(live),
)
assertEquals(listOf("", "newest trace"), mapped.map { it.reasoningText })
assertEquals(listOf(null, completion), mapped.map { it.completionId })
val merged = mergeTranscriptWithLive(mapped, listOf(live), preserveLiveIds = true)
assertEquals(listOf("rest-session-100", "uuid"), merged.map { it.id })
assertEquals(listOf("", "newest trace"), merged.map { it.reasoningText })
assertEquals("rest-session-200", merged.last().canonicalRestId)
}
}
@Test
fun mapperReservesCanonicalAndCompletionReasoningBeforeContentFallback() {
val first =
ChatMessage(
id = "uuid-first",
role = MessageRole.ASSISTANT,
content = "Done",
reasoningText = "first trace",
completionId = "completion-100",
)
val second = first.copy(id = "uuid-second", reasoningText = "second trace", completionId = "completion-200")
for (canonicalFirst in listOf(false, true)) {
val live = listOf(if (canonicalFirst) first.copy(restId = "rest-session-100") else first, second)
val mapped =
mapServerMessages(
"session",
listOf(assistantRow(100), assistantRow(200)),
0,
true,
live,
)
assertEquals(listOf("first trace", "second trace"), mapped.map { it.reasoningText })
assertEquals(listOf("completion-100", "completion-200"), mapped.map { it.completionId })
}
}
@Test
fun mapperUsesNewestReasoningCandidateWhenNoCompletionIdentityExists() {
val first =
ChatMessage(id = "uuid-first", role = MessageRole.ASSISTANT, content = "Done", reasoningText = "old")
val second = first.copy(id = "uuid-second", reasoningText = "new")
val mapped = mapServerMessages("session", listOf(assistantRow(200)), 0, true, listOf(first, second))
assertEquals("new", mapped.single().reasoningText)
}
@Test
fun olderPagesCannotStealLiveReasoningInEitherArrivalOrder() {
val live =
ChatMessage(
id = "uuid",
role = MessageRole.ASSISTANT,
content = "Done",
reasoningText = "newest trace",
completionId = "completion-200",
timestamp = 200_000L,
)
for (newestFirst in listOf(false, true)) {
val order = if (newestFirst) listOf(200, 100) else listOf(100, 200)
var current = listOf(live)
order.forEach { id ->
current = applyServerPage(current, listOf(assistantRow(id)), older = id == 100)
assertEquals("A trace may appear only once after page $id", 1, current.count { it.reasoningText != "" })
if (id ==
100
) {
assertEquals("", current.single { it.canonicalRestId == "rest-session-100" }.reasoningText)
}
}
assertEquals(listOf("rest-session-100", "uuid"), current.map { it.id })
assertEquals(listOf("", "newest trace"), current.map { it.reasoningText })
assertEquals(listOf(null, "completion-200"), current.map { it.completionId })
assertEquals("rest-session-200", current.last().canonicalRestId)
}
// Older paging also forbids text-only reasoning fallback without a completion id.
val older =
mapServerMessages(
"session",
listOf(assistantRow(100)),
150,
true,
listOf(live.copy(completionId = null)),
isPagingOlder = true,
)
assertEquals("", older.single().reasoningText)
}
@Test
fun reasoningOnlyBoundaryRetainsCanonicalRowBeforeAnswerArrives() {
val previous = assistantRow(90, content = "Previous answer")
val reasoning = assistantRow(100, content = "", reasoning = "boundary trace")
val mapped = mapServerMessages("session", listOf(previous, reasoning), 0, true, emptyList())
assertEquals(listOf("rest-session-90", "rest-session-100"), mapped.map { it.canonicalRestId })
assertEquals("", mapped.first().reasoningText)
assertEquals("boundary trace", mapped.last().reasoningText)
assertEquals("", mapped.last().content)
}
@Test
fun splitReasoningAndAnswerSurviveBothArrivalOrdersRefreshAndPagedCacheReload() =
runTest {
val reasoning = assistantRow(100, content = "", reasoning = "boundary trace")
val answer = assistantRow(200)
for (answerFirst in listOf(false, true)) {
val dao = FakeChatMessageDao()
val repository = ChatPersistenceRepository(dao)
var current = emptyList<ChatMessage>()
val pages = if (answerFirst) listOf(answer, reasoning) else listOf(reasoning, answer)
pages.forEach { row ->
val mapped =
mapServerMessages(
"session",
listOf(row),
0,
true,
current,
isPagingOlder = answerFirst && row.id == 100,
)
current =
mergeTranscriptWithLive(
mapped,
current,
chronological = !(answerFirst && row.id == 100),
preserveLiveIds = true,
)
// Same input that persistHistoryPage writes, including the boundary page before its neighbor.
repository.persistMessages(
mapped.map { it.copy(id = requireNotNull(it.canonicalRestId)) },
"session",
)
}
assertBoundaryTranscript(current)
val refreshed = applyServerPage(current, listOf(reasoning, answer))
assertEquals(current, refreshed)
val latest = repository.loadPage("session", null, 1)
val older = repository.loadPage("session", latest.cursor, 1)
assertEquals(true, latest.hasOlder)
assertEquals(false, older.hasOlder)
for ((first, second) in listOf(latest.messages to older.messages, older.messages to latest.messages)) {
assertBoundaryTranscript(
mergeCachedTranscriptPage(second, mergeCachedTranscriptPage(first, emptyList())),
)
}
assertEquals(0, dao.fullSessionReads)
assertEquals(listOf(2, 2), dao.pageLimits)
}
}
@Test
fun reasoningDoesNotCrossUserOrToolBoundaryInEitherArrivalOrder() {
for (role in listOf("user", "tool")) {
val reasoning = assistantRow(100, content = "", reasoning = "before $role")
val boundary =
SessionMessage(
id = 150,
role = role,
content = JsonPrimitive("boundary"),
timestamp = JsonPrimitive(150),
tool_call_id = if (role == "tool") "call-150" else null,
)
val answer = assistantRow(200)
for (newestFirst in listOf(false, true)) {
val first = if (newestFirst) listOf(boundary, answer) else listOf(reasoning)
val second = if (newestFirst) listOf(reasoning) else listOf(boundary, answer)
val merged = applyServerPage(applyServerPage(emptyList(), first), second, older = newestFirst)
assertEquals(
listOf(100, 150, 200).map { "rest-session-$it" },
merged.map { it.canonicalRestId },
)
assertEquals(listOf("before $role", "", ""), merged.map { it.reasoningText })
// Mapping all rows together must have the same ownership as separate pages.
assertEquals(merged, applyServerPage(merged, listOf(reasoning, boundary, answer)))
}
}
}
@Test
fun multipleReasoningOnlyRowsRemainVisibleAcrossToolSteps() {
val rows =
listOf(
assistantRow(100, content = "", reasoning = "first trace"),
SessionMessage(
id = 150,
role = "tool",
content = JsonPrimitive("result"),
timestamp = JsonPrimitive(150),
),
assistantRow(200, content = "", reasoning = "second trace"),
assistantRow(300),
)
val mapped = applyServerPage(emptyList(), rows)
assertEquals(listOf("first trace", "second trace"), mapped.map { it.reasoningText }.filter { it.isNotBlank() })
assertEquals(
listOf("reasoning-rest-session-100", "reasoning-rest-session-200"),
fullBleedItemKeys(groupIntoTurns(mapped)).filter { it.startsWith("reasoning-") },
)
assertEquals(1, fullBleedItemKeys(groupIntoTurns(mapped)).count { it.startsWith("prose-") })
}
@Test
fun canonicalReasoningAndMatchingLiveAnswerRenderTraceOnlyOnceInEitherArrivalOrder() {
val live =
ChatMessage(
id = "uuid-answer",
role = MessageRole.ASSISTANT,
content = "Done",
reasoningText = "boundary trace",
completionId = "completion-200",
timestamp = 200_000L,
)
val reasoning = assistantRow(100, content = "", reasoning = "boundary trace")
val answer = assistantRow(200)
for (answerFirst in listOf(false, true)) {
val pages = if (answerFirst) listOf(answer, reasoning) else listOf(reasoning, answer)
var current = listOf(live)
pages.forEach { row ->
current = applyServerPage(current, listOf(row), older = row.id == 100)
}
assertEquals(listOf("rest-session-100", "uuid-answer"), current.map { it.id })
assertEquals(listOf("boundary trace", ""), current.map { it.reasoningText })
assertEquals("completion-200", current.last().completionId)
assertEquals("rest-session-200", current.last().canonicalRestId)
assertEquals(
listOf("reasoning-rest-session-100", "prose-uuid-answer"),
fullBleedItemKeys(groupIntoTurns(current)),
)
}
}
@Test
fun differentReasoningOnlyRowsCannotMatchByEmptyContent() {
val live =
ChatMessage(
id = "uuid-thinking",
role = MessageRole.ASSISTANT,
content = "",
reasoningText = "new trace",
)
val canonical = live.copy(id = "rest-session-100", reasoningText = "old trace")
assertTrue(!sameLogicalMessage(canonical, live))
assertEquals(2, mergeTranscriptWithLive(listOf(canonical), listOf(live), preserveLiveIds = true).size)
assertTrue(sameLogicalMessage(canonical.copy(reasoningText = "new trace"), live))
}
private fun assertBoundaryTranscript(messages: List<ChatMessage>) {
assertEquals(listOf("rest-session-100", "rest-session-200"), messages.map { it.canonicalRestId })
assertEquals(listOf("boundary trace", ""), messages.map { it.reasoningText })
assertEquals(listOf("", "Done"), messages.map { it.content })
assertEquals(
listOf("reasoning-rest-session-100", "prose-rest-session-200"),
fullBleedItemKeys(groupIntoTurns(messages)),
)
}
private fun assistantRow(
id: Int,
content: String = "Done",
reasoning: String = "",
) = SessionMessage(
id = id,
role = "assistant",
content = JsonPrimitive(content),
reasoning = JsonPrimitive(reasoning),
timestamp = JsonPrimitive(id),
)
private fun applyServerPage(
current: List<ChatMessage>,
rows: List<SessionMessage>,
older: Boolean = false,
): List<ChatMessage> =
mergeTranscriptWithLive(
mapServerMessages("session", rows, 0, true, current, isPagingOlder = older),
current,
chronological = !older,
preserveLiveIds = true,
)
}
@@ -46,6 +46,36 @@ class ChatPersistenceRepositoryTest {
org.junit.Assert.assertSame(failure, runCatching { failing.loadMessages("s") }.exceptionOrNull())
}
@Test
fun pagedReadWaitsForLazyProviderAndRetainsCompletionIdentity() =
runTest {
val ready = kotlinx.coroutines.CompletableDeferred<com.m57.hermescontrol.data.local.ChatMessageDao>()
var calls = 0
val lazyRepository =
ChatPersistenceRepository {
calls++
ready.await()
}
assertEquals(0, calls)
val pending = async { lazyRepository.loadPage("s", null, 150) }
kotlinx.coroutines.yield()
assertEquals(1, calls)
org.junit.Assert.assertFalse(pending.isCompleted)
repository.persistMessage(
ChatMessage(id = "rest-s-1", role = MessageRole.ASSISTANT, content = "Done", completionId = "comp"),
"s",
)
ready.complete(dao)
val row = pending.await().messages.single()
assertEquals("rest-s-1", row.canonicalRestId)
assertEquals("comp", row.completionId)
assertEquals(0, dao.fullSessionReads)
assertEquals(listOf(151), dao.pageLimits)
val failure = IllegalStateException("database unavailable")
val failing = ChatPersistenceRepository { throw failure }
org.junit.Assert.assertSame(failure, runCatching { failing.loadPage("s", null, 150) }.exceptionOrNull())
}
@Before
fun setup() {
dao = FakeChatMessageDao()
@@ -146,4 +176,123 @@ class ChatPersistenceRepositoryTest {
assertEquals("msg-B1", loadedB[0].id)
assertEquals("msg-B2", loadedB[1].id)
}
@Test
fun loadPage_usesSeparateInitialAndCursorQueries() =
runTest {
val tracedDao = io.mockk.spyk(dao)
val pagedRepository = ChatPersistenceRepository(tracedDao)
(1..3).forEach { index ->
pagedRepository.persistMessage(
ChatMessage(id = "row-$index", role = MessageRole.USER, content = "$index", timestamp = 10L),
"session",
)
}
val first = pagedRepository.loadPage("session", before = null, limit = 2)
val second = pagedRepository.loadPage("session", before = first.cursor, limit = 2)
assertEquals(listOf("row-2", "row-3"), first.messages.map { it.id })
assertEquals(listOf("row-1"), second.messages.map { it.id })
assertEquals(true, first.hasOlder)
assertEquals(false, second.hasOlder)
io.mockk.coVerify(exactly = 1) { tracedDao.getLatestMessagePage("session", 3) }
io.mockk.coVerify(exactly = 1) { tracedDao.getMessagePage("session", 1, 2L, "row-2", 3) }
io.mockk.coVerify(exactly = 0) { tracedDao.getMessagesForSession(any()) }
}
@Test
fun loadPage_equalTimestampsAndLiveInsertKeepCursorDeterministicAndSessionsIsolated() =
runTest {
val ids = (1..350).map { "rest-session-$it" }
repository.persistMessages(
ids.map { ChatMessage(id = it, role = MessageRole.USER, content = it, timestamp = 10L) },
"session",
)
val first = repository.loadPage("session", before = null, limit = 150)
repository.persistMessage(
ChatMessage(id = "new", role = MessageRole.USER, content = "new", timestamp = 20L),
"session",
)
repository.persistMessage(
ChatMessage(id = "other", role = MessageRole.USER, content = "other", timestamp = 10L),
"other-session",
)
val second = repository.loadPage("session", before = first.cursor, limit = 150)
val third = repository.loadPage("session", before = second.cursor, limit = 150)
assertEquals(ids.takeLast(150), first.messages.map { it.id })
assertEquals(ids, (third.messages + second.messages + first.messages).map { it.id })
assertEquals(false, third.hasOlder)
assertEquals(listOf(151, 151, 151), dao.pageLimits)
assertEquals(0, dao.fullSessionReads)
assertEquals(ids.toSet() + "new", dao.idsForSession("session"))
assertEquals(setOf("other"), dao.idsForSession("other-session"))
}
@Test
fun loadPage_nonMonotonicTimestampsFollowServerIdsInEitherArrivalOrder() =
runTest {
val rows =
listOf(9, 10, 100, 200).mapIndexed { index, id ->
ChatMessage(
id = "rest-session-$id",
role = MessageRole.ASSISTANT,
content = "answer-$id",
timestamp = listOf(900L, 100L, 800L, 200L)[index],
)
}
for (pages in listOf(listOf(rows.take(2), rows.drop(2)), listOf(rows.drop(2), rows.take(2)))) {
repository.clearMessagesForSession("session")
pages.forEach { repository.persistMessages(it, "session") }
val latest = repository.loadPage("session", null, 2)
val older = repository.loadPage("session", latest.cursor, 2)
assertEquals(rows.drop(2).map { it.id }, latest.messages.map { it.id })
assertEquals(rows.take(2).map { it.id }, older.messages.map { it.id })
assertEquals(true, latest.hasOlder)
assertEquals(false, older.hasOlder)
}
assertEquals(0, dao.fullSessionReads)
assertEquals(listOf(3, 3, 3, 3), dao.pageLimits)
}
@Test
fun loadPage_uuidOnlyRowsKeepInsertionOrderAcrossUpdatesAndLiveAppend() =
runTest {
val rows =
listOf("ffffffff", "00000000", "aaaaaaaa").mapIndexed { index, prefix ->
ChatMessage(
id = "$prefix-0000-4000-8000-000000000000",
role = MessageRole.USER,
content = "message-$index",
timestamp = listOf(900L, 100L, 800L)[index],
)
}
rows.forEach { repository.persistMessage(it, "local") }
repository.persistMessage(rows.first().copy(content = "edited", timestamp = 999L), "local")
val latest = repository.loadPage("local", null, 2)
repository.persistMessage(
ChatMessage(
id = "bbbbbbbb-0000-4000-8000-000000000000",
role = MessageRole.USER,
content = "appended after cursor",
timestamp = 1L,
),
"local",
)
val older = repository.loadPage("local", latest.cursor, 2)
assertEquals(rows.drop(1).map { it.id }, latest.messages.map { it.id })
assertEquals(listOf(rows.first().id), older.messages.map { it.id })
assertEquals("edited", older.messages.single().content)
assertEquals(false, older.hasOlder)
assertEquals(
"appended after cursor",
repository
.loadPage("local", null, 1)
.messages
.single()
.content,
)
assertEquals(0, dao.fullSessionReads)
}
}
@@ -4,7 +4,6 @@ import com.m57.hermescontrol.data.ws.WsEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -193,7 +192,7 @@ class ChatToolDedupeTest {
assertEquals(3, deduped.size)
// WS copies win; rest- copies of the same logical message are dropped
assertTrue(deduped.contains(wsTool))
assertTrue(deduped.contains(wsTool.copy(restId = restTool.id)))
assertTrue(deduped.none { it.id == "rest-s-1" })
assertTrue(deduped.none { it.id == "rest-s-2" })
// rest- user kept when no WS copy exists
@@ -279,7 +278,7 @@ class ChatToolDedupeTest {
val merged = mergeTranscriptWithLive(listOf(rest), listOf(live))
// Preserves cache/live-only user flags such as continuesActiveTurn.
assertSame(live, merged.single())
assertEquals(live.copy(restId = rest.id), merged.single())
}
@Test
@@ -551,7 +550,7 @@ class ChatToolDedupeTest {
val merged = mergeIncrementalTranscriptPage(incoming, current, "s", 1)
assertEquals(listOf("retry", "retry"), merged.map { it.content })
assertSame(live, merged.last())
assertEquals(live.copy(restId = "rest-s-1"), merged.last())
}
// ── Issue #842: MCP/web tool rows (raw `<untrusted_tool_result>` text) ──
@@ -654,6 +653,6 @@ The following content was retrieved from an external source.
)
val deduped = dedupeCachedMessages(listOf(wsTool, restTool))
assertEquals(1, deduped.size)
assertEquals(wsTool, deduped.single())
assertEquals(wsTool.copy(restId = restTool.id), deduped.single())
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,39 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.fullbleed.fullBleedItemKeys
import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurns
import com.m57.hermescontrol.ui.chat.fullbleed.messageIdToLazyIndex
import org.junit.Assert.assertEquals
import org.junit.Test
class FullBleedItemKeysTest {
@Test
fun keysIncludeReasoningToolsAndSystemButSkipEmptyProse() {
val turns =
groupIntoTurns(
listOf(
ChatMessage("u", MessageRole.USER, "question"),
ChatMessage("empty", MessageRole.ASSISTANT, ""),
ChatMessage("t", MessageRole.TOOL, "output"),
ChatMessage("a", MessageRole.ASSISTANT, "answer", reasoningText = "thinking"),
ChatMessage("s", MessageRole.SYSTEM, "event"),
),
)
assertEquals(listOf("user-u", "reasoning-a", "tool-t", "prose-a", "sys-s"), fullBleedItemKeys(turns))
assertEquals(mapOf("u" to 0, "a" to 3), messageIdToLazyIndex(turns))
assertEquals(mapOf("u" to 2, "a" to 5), messageIdToLazyIndex(turns, leadingItems = 2))
}
@Test
fun toolAnchorSurvivesPrependThatExtendsItsAgentTurn() {
val tool = ChatMessage("live-tool", MessageRole.TOOL, "output")
val older =
(0..149).map {
ChatMessage("old-$it", MessageRole.ASSISTANT, "answer", reasoningText = "thinking")
}
assertEquals(listOf("tool-live-tool"), fullBleedItemKeys(groupIntoTurns(listOf(tool))))
val keys = fullBleedItemKeys(groupIntoTurns(older + tool))
assertEquals("reasoning-old-0", keys.first())
assertEquals(151, keys.indexOf("tool-live-tool"))
}
}
@@ -104,6 +104,172 @@ class ReadNotificationReviewRegressionTest {
assertEquals(listOf("old-completion", "new-completion"), mapped.map { it.completionId })
}
@Test
fun pagingMergePreservesBothIdentitiesAndDoesNotConsumeOlderRepeatedReply() {
val live = ChatMessage(id = "ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "comp")
val newest = listOf(SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done")))
val mapped = mapServerMessages("session", newest, 0, true, listOf(live))
val hydrated = mergeTranscriptWithLive(mapped, listOf(live), preserveLiveIds = true)
assertEquals("ws", hydrated.single().id)
assertEquals("rest-session-200", hydrated.single().restId)
assertEquals("comp", hydrated.single().completionId)
val older = listOf(SessionMessage(id = 100, role = "assistant", content = JsonPrimitive("Done")))
val page = mapServerMessages("session", older, 150, true, hydrated, isPagingOlder = true)
val merged = mergeTranscriptWithLive(page, hydrated, chronological = false, preserveLiveIds = true)
assertEquals(listOf("rest-session-100", "ws"), merged.map { it.id })
assertEquals(listOf(null, "comp"), merged.map { it.completionId })
assertEquals("rest-session-200", merged.last().restId)
// A later refresh of ONLY the old row must not reassign the confirmed UUID.
assertNull(mapServerMessages("session", older, 0, true, hydrated).single().completionId)
assertEquals("comp", mapServerMessages("session", newest, 0, true, hydrated).single().completionId)
}
@Test
fun olderPageAndCacheMergeCannotClaimUnconfirmedNotificationByText() {
val live = ChatMessage(id = "ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "comp")
val older = listOf(SessionMessage(id = 100, role = "assistant", content = JsonPrimitive("Done")))
val page = mapServerMessages("session", older, 150, true, listOf(live), isPagingOlder = true)
val merged = mergeTranscriptWithLive(page, listOf(live), chronological = false, preserveLiveIds = true)
assertEquals(2, merged.size)
assertNull(merged.single { it.id == "rest-session-100" }.completionId)
assertNull(merged.single { it.id == "ws" }.restId)
assertEquals(2, mergeCachedTranscriptPage(page, listOf(live)).size)
}
@Test
fun repeatedCompletionMatchesStayOnTheSameLiveKeysAfterMerge() {
val live =
listOf(
ChatMessage(id = "old-ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "old"),
ChatMessage(id = "new-ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "new"),
)
val page =
mapServerMessages(
"session",
listOf(SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done"))),
0,
true,
live,
)
val merged = mergeTranscriptWithLive(page, live, preserveLiveIds = true)
assertEquals("rest-session-200", merged.single { it.id == "new-ws" }.restId)
assertNull(merged.single { it.id == "old-ws" }.restId)
assertEquals("new", merged.single { it.id == "new-ws" }.completionId)
}
@Test
fun durableTargetWinsOverRepeatedLiveTextEvenWithLegacyPositions() {
ReplyNotificationTracker.setTargetForTest(
ReplyNotificationTarget("default", "session", "comp", 1L, serverMessageId = 100),
)
val live = ChatMessage(id = "ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "comp")
val rows =
listOf(
SessionMessage(id = 100, role = "assistant", content = JsonPrimitive("Done")),
SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done")),
)
for (latest in listOf(true, false)) {
val mapped = mapServerMessages("session", rows, 10, latest, listOf(live), context = context)
assertEquals(listOf("comp", null), mapped.map { it.completionId })
val merged = mergeTranscriptWithLive(mapped, listOf(live), preserveLiveIds = true)
assertEquals(if (latest) "rest-session-100" else "rest-session-10", merged.single { it.id == "ws" }.restId)
assertNull(mapServerMessages("session", rows.takeLast(1), 11, latest, listOf(live)).single().completionId)
}
}
@Test
fun exactCacheAndRestOverlapsRetainCompletionWhenPreferredCopyHasNone() {
val live =
ChatMessage(
id = "ws",
role = MessageRole.ASSISTANT,
content = "Done",
restId = "rest-session-200",
completionId = "comp",
)
val rest = live.copy(id = "rest-session-200", restId = null, completionId = null)
assertEquals("comp", dedupeCachedMessages(listOf(live, rest)).single().completionId)
assertEquals("comp", mergeCachedTranscriptPage(listOf(rest), listOf(live)).single().completionId)
assertEquals("comp", mergeTranscriptWithLive(listOf(rest), listOf(live)).single().completionId)
val restored = rest.copy(completionId = "comp").toEntity("session").toUiModel()
val richWithoutCompletion = live.copy(completionId = null)
val merged = mergeCachedTranscriptPage(listOf(restored), listOf(richWithoutCompletion)).single()
assertEquals("ws", merged.id)
assertEquals("rest-session-200", merged.restId)
assertEquals("comp", merged.completionId)
}
@Test
fun durableHydrationFoldsMixedCacheEchoIntoRichLiveReply() {
ReplyNotificationTracker.setTargetForTest(
ReplyNotificationTarget("default", "session", "comp", 1L, serverMessageId = 200),
)
val live = ChatMessage(id = "ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "comp")
val echo = live.copy(id = "rest-session-200", completionId = null)
val current = listOf(echo, live)
val mapped =
mapServerMessages(
"session",
listOf(SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done"))),
0,
true,
current,
)
val merged = mergeTranscriptWithLive(mapped, current, preserveLiveIds = true).single()
assertEquals("ws", merged.id)
assertEquals("rest-session-200", merged.restId)
assertEquals("comp", merged.completionId)
}
@Test
fun cachedNewestDuplicateWithoutDurableIdKeepsCompletionOnNewestLiveReply() {
// #129 review: a metadata-free cached echo must not redirect the newest WS completion.
ReplyNotificationTracker.setTargetForTest(
ReplyNotificationTarget("default", "session", "comp", 1L, serverMessageId = null),
)
val cachedNewest =
ChatMessage(
id = "rest-session-200",
role = MessageRole.ASSISTANT,
content = "Done",
completionId = null,
)
val live =
ChatMessage(
id = "ws",
role = MessageRole.ASSISTANT,
content = "Done",
completionId = "comp",
restId = null,
)
val current = listOf(cachedNewest, live)
val history =
listOf(
SessionMessage(id = 100, role = "assistant", content = JsonPrimitive("Done")),
SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done")),
)
val mapped =
mapServerMessages(
sessionId = "session",
messages = history,
offset = 0,
latestPaging = true,
liveMessages = current,
isPagingOlder = false,
context = context,
)
val merged = mergeTranscriptWithLive(mapped, current, preserveLiveIds = true)
assertNull(mapped.single { it.id == "rest-session-100" }.completionId)
assertEquals("comp", mapped.single { it.id == "rest-session-200" }.completionId)
assertEquals(2, merged.size)
assertEquals(setOf("rest-session-100", "ws"), merged.map { it.id }.toSet())
assertEquals(2, merged.count { it.content == "Done" })
assertNull(merged.single { it.id == "rest-session-100" }.completionId)
assertEquals("rest-session-200", merged.single { it.id == "ws" }.restId)
assertEquals("comp", merged.single { it.id == "ws" }.completionId)
}
@Test
fun viewingOldCachedTextMustNotClearNewReplyNotification() {
postTarget("Done")
@@ -383,7 +549,11 @@ class ReadNotificationReviewRegressionTest {
SessionMessage(id = 2, role = "assistant", content = JsonPrimitive("The result MEDIA:/opt/pic.png")),
)
val mapped = mapServerMessages("session", history, 0, true, live, isPagingOlder = false)
assertEquals("comp-reasoning-media", mapped.single().completionId)
assertEquals(listOf("rest-session-1", "rest-session-2"), mapped.map { it.canonicalRestId })
assertEquals("thinking step", mapped.first().reasoningText)
assertEquals("", mapped.first().content)
assertNull(mapped.first().completionId)
assertEquals("comp-reasoning-media", mapped.last().completionId)
}
@Test
@@ -53,11 +53,13 @@ class SlashCommandDispatchRpcTest {
private lateinit var app: Application
private lateinit var fakeRepo: FakeChatPersistenceRepository
private var reqCount = 0
private var sessionCreateRequestId: String? = null
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
reqCount = 0
sessionCreateRequestId = null
mockkStatic(android.util.Log::class)
every { android.util.Log.d(any(), any()) } returns 0
@@ -114,6 +116,9 @@ class SlashCommandDispatchRpcTest {
every { HermesWsClient.send(any(), any(), any()) } answers {
reqCount++
val id = "req-id-$reqCount"
if (arg<String>(0) == WsMethods.SESSION_CREATE) {
sessionCreateRequestId = id
}
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
@@ -152,8 +157,8 @@ class SlashCommandDispatchRpcTest {
mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
// req-id-3 = session.create (after loadSessions + fetchCommandCatalog)
mockEventsFlow.emit(WsEvent.RpcResult("req-id-3", mapOf("session_id" to "session-xyz")))
val createRequestId = requireNotNull(sessionCreateRequestId) { "session.create was not sent" }
mockEventsFlow.emit(WsEvent.RpcResult(createRequestId, mapOf("session_id" to "session-xyz")))
advanceUntilIdle()
return Pair(vm, "session-xyz")
}
@@ -0,0 +1,71 @@
package com.m57.hermescontrol.ui.chat.components
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatHistoryPrefetchTest {
@Test
fun shortUpwardDragCanReachMovementThresholdDuringFling() {
val policy = ChatHistoryPrefetch()
assertFalse(policy.onScroll(30, 5f, userInput = true, canLoad = true))
assertTrue(policy.onScroll(25, 30f, userInput = false, canLoad = true))
assertFalse(policy.onScroll(10, 30f, userInput = false, canLoad = true))
}
@Test
fun upwardFlingCanReachPrefetchThresholdAfterDragEnds() {
val policy = ChatHistoryPrefetch()
assertFalse(policy.onScroll(70, 30f, userInput = true, canLoad = true))
assertFalse(policy.onScroll(40, 40f, userInput = false, canLoad = true))
assertTrue(policy.onScroll(25, 40f, userInput = false, canLoad = true))
assertFalse(policy.onScroll(10, 40f, userInput = false, canLoad = true))
policy.endGesture()
assertFalse(policy.onScroll(5, 40f, userInput = false, canLoad = true))
}
@Test
fun reversingDirectionDisarmsPreviousUpwardIntent() {
val policy = ChatHistoryPrefetch()
assertFalse(policy.onScroll(70, 30f, userInput = true, canLoad = true))
assertFalse(policy.onScroll(50, -40f, userInput = true, canLoad = true))
assertFalse(policy.onScroll(25, 40f, userInput = false, canLoad = true))
}
@Test
fun initialTopAndProgrammaticMovementDoNotFetch() {
val policy = ChatHistoryPrefetch()
assertFalse(policy.onScroll(0, 0f, userInput = true, canLoad = true))
assertFalse(policy.onScroll(0, 100f, userInput = false, canLoad = true))
assertFalse(policy.onScroll(0, -100f, userInput = true, canLoad = true))
}
@Test
fun prefetchStartsTwentyFiveRowsBeforeTopAfterMeaningfulMovement() {
val policy = ChatHistoryPrefetch()
assertFalse(policy.onScroll(30, 30f, userInput = true, canLoad = true))
assertTrue(policy.onScroll(25, 1f, userInput = true, canLoad = true))
}
@Test
fun initialTopNeedsDeliberateGestureAndRequestsOnlyOnce() {
val policy = ChatHistoryPrefetch()
assertFalse(policy.onScroll(0, 10f, userInput = true, canLoad = true))
assertTrue(policy.onScroll(0, 15f, userInput = true, canLoad = true))
repeat(100) {
assertFalse(policy.onScroll(0, 50f, userInput = true, canLoad = true))
}
policy.endGesture()
assertTrue(policy.onScroll(0, 25f, userInput = true, canLoad = true))
}
@Test
fun loadingAndExhaustionSuppressRequestsAndNextGestureCanRetry() {
val policy = ChatHistoryPrefetch()
assertFalse(policy.onScroll(20, 50f, userInput = true, canLoad = false))
policy.endGesture()
assertTrue(policy.onScroll(20, 25f, userInput = true, canLoad = true))
policy.endGesture()
assertTrue(policy.onScroll(20, 25f, userInput = true, canLoad = true))
}
}
@@ -2,29 +2,80 @@ package com.m57.hermescontrol.ui.chat.fakes
import com.m57.hermescontrol.data.local.ChatMessageDao
import com.m57.hermescontrol.data.local.ChatMessageEntity
import com.m57.hermescontrol.data.local.canonicalMessageOrder
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentMap
/**
* In-memory [ChatMessageDao] for use in tests.
* Uses [ConcurrentHashMap] for thread safety — no manual synchronization needed.
* Paging mirrors the DAO's numeric cursor; writes use its default order allocation methods.
*/
class FakeChatMessageDao : ChatMessageDao {
private val messages: ConcurrentMap<String, ChatMessageEntity> = ConcurrentHashMap()
private val ordering = compareBy<ChatMessageEntity> { it.sortGroup }.thenBy { it.sortOrder }.thenBy { it.id }
private var insertionSequence = 0L
var fullSessionReads = 0
private set
var beforeRead: suspend () -> Unit = {}
override suspend fun sessionExists(sessionId: String): Boolean = messages.values.any { it.sessionId == sessionId }
override suspend fun getMessagesForSession(sessionId: String): List<ChatMessageEntity> =
messages.values
override suspend fun getMessagesForSession(sessionId: String): List<ChatMessageEntity> {
fullSessionReads++
beforeRead()
return messages.values
.filter { it.sessionId == sessionId }
.sortedBy { it.timestamp }
override suspend fun upsert(message: ChatMessageEntity) {
messages[message.id] = message
.sortedWith(ordering)
}
override suspend fun upsertAll(messageList: List<ChatMessageEntity>) {
messageList.forEach { messages[it.id] = it }
val pageLimits = mutableListOf<Int>()
override suspend fun getLatestMessagePage(
sessionId: String,
limit: Int,
): List<ChatMessageEntity> {
pageLimits += limit
beforeRead()
return messages.values
.filter { it.sessionId == sessionId }
.sortedWith(ordering.reversed())
.take(limit)
}
override suspend fun getMessagePage(
sessionId: String,
beforeGroup: Int,
beforeOrder: Long,
beforeId: String,
limit: Int,
): List<ChatMessageEntity> {
pageLimits += limit
beforeRead()
return messages.values
.filter {
it.sessionId == sessionId &&
(
it.sortGroup < beforeGroup ||
(
it.sortGroup == beforeGroup &&
(it.sortOrder < beforeOrder || (it.sortOrder == beforeOrder && it.id < beforeId))
)
)
}.sortedWith(ordering.reversed())
.take(limit)
}
override suspend fun getMessage(id: String): ChatMessageEntity? = messages[id]
override suspend fun nextLocalOrder(): Long = insertionSequence + 1
override suspend fun writeMessage(message: ChatMessageEntity) {
if (message.id !in messages) insertionSequence++
messages[message.id] = message
}
override suspend fun deleteMessagesForSession(sessionId: String) {
@@ -33,14 +84,25 @@ class FakeChatMessageDao : ChatMessageDao {
/** Direct access for test setup — bypasses the suspend modifier. */
fun addMessageDirect(message: ChatMessageEntity) {
messages[message.id] = message
val existing = messages[message.id]
if (existing == null) insertionSequence++
val order = canonicalMessageOrder(message.restId ?: message.id, message.sessionId)
messages[message.id] =
message.copy(
sortGroup = if (order != null) 0 else 1,
sortOrder = order ?: existing?.sortOrder ?: insertionSequence,
)
}
/** Reset all stored messages. */
fun clear() {
messages.clear()
insertionSequence = 0L
}
fun idsForSession(sessionId: String): Set<String> =
messages.values.filter { it.sessionId == sessionId }.mapTo(mutableSetOf()) { it.id }
/** Returns the number of stored messages. */
fun count(): Int = messages.size
}
@@ -81,6 +81,41 @@ class ToolViewBuilderTest {
assertEquals("Read a.kt L10-14", view.title)
assertEquals("/repo/src/a.kt", view.subtitle)
assertEquals("10|a\n11|b", view.fileContent)
assertEquals("/repo/src/a.kt", view.filePath)
assertEquals("10|a\n11|b", view.detail)
}
@Test
fun `read_file missing content does not show a code viewer`() {
val view = build("read_file", """{"path":"/repo/missing.kt"}""", """{"error":"not found"}""")
assertNull(view.fileContent)
}
@Test
fun `write_file shows written content when no diff is returned`() {
val view =
build(
"write_file",
"""{"path":"/repo/new.kt","content":"fun main() = Unit"}""",
"""{"success":true,"verified":true}""",
)
assertEquals("fun main() = Unit", view.fileContent)
assertEquals("/repo/new.kt", view.filePath)
assertNull(view.inlineDiff)
}
@Test
fun `write_file diff wins over content and failed writes show no written content`() {
val args = """{"path":"/repo/a.kt","content":"new"}"""
val diff = build("write_file", args, """{"inline_diff":"--- a/a.kt\n+++ b/a.kt\n-old\n+new"}""")
val failed = build("write_file", args, """{"success":false,"error":"denied"}""", isError = true)
assertNotNull(diff.inlineDiff)
assertNull(diff.fileContent)
assertNull(failed.fileContent)
}
@Test
@@ -308,6 +343,35 @@ class ToolViewBuilderTest {
assertEquals("poll: proc-1", view.subtitle)
assertTrue(view.detail.contains("Status: running"))
assertTrue(view.detail.contains("listening on port 8080"))
assertEquals(null, view.outputCut)
}
@Test
fun `process output reports backend omitted character count for each action`() {
listOf("poll", "wait", "log", "kill").forEach { action ->
val outputKey = if (action == "poll") "output_preview" else "output"
val view =
build(
"process_manage",
"""{"action":"$action","session_id":"proc-1"}""",
"""{"status":"exited","$outputKey":"partial output","output_cut":42}""",
)
assertTrue(view.detail.contains("partial output"))
assertEquals(42L, view.outputCut)
}
}
@Test
fun `process output without positive numeric omission has no notice`() {
listOf("{}", """{"output_cut":0}""", """{"output_cut":"unknown"}""").forEach { metadata ->
val fields = metadata.removePrefix("{").removeSuffix("}")
val result = """{"output":"complete"${if (fields.isNotEmpty()) ",$fields" else ""}}"""
val view = build("process", """{"action":"wait"}""", result)
assertEquals("complete", view.detail)
assertEquals(null, view.outputCut)
}
}
@Test
@@ -592,8 +592,27 @@ class ToolViewCharacterizationTest {
assertEquals("https://img.example/cat.png (image)", view.subtitle)
assertEquals("🖼️ a cat\n\n🔗 https://img.example/cat.png", view.detail)
// Pinned: imageUrlFor does not read the "image" key, so no thumbnail.
assertNull(view.imageUrl)
assertEquals("https://img.example/cat.png", view.imageUrl)
}
@Test
fun `image_generate data image reaches inline image view`() {
val view = build("image_generate", """{"prompt":"a cat"}""", """{"image":"data:image/png;base64,AAA"}""")
assertEquals("data:image/png;base64,AAA", view.imageUrl)
}
@Test
fun `image_generate http image reaches inline image view`() {
val view = build("image_generate", "{}", """{"image":"http://img.example/cat.webp"}""")
assertEquals("http://img.example/cat.webp", view.imageUrl)
}
@Test
fun `image_generate local path and non-image url do not reach inline image view`() {
assertNull(build("image_generate", "{}", """{"image":"/tmp/cat.png"}""").imageUrl)
assertNull(build("image_generate", "{}", """{"image":"https://img.example/page"}""").imageUrl)
}
@Test
@@ -739,6 +758,41 @@ class ToolViewCharacterizationTest {
assertEquals("🔧 read_file\n Reads\n\n🔧 write_file", view.detail)
}
@Test
fun `tool_search renders queries and named tool metadata from discovery response`() {
val view =
build(
"tool_search",
"""{"queries":["read background process output poll"]}""",
"""{"queries":["read background process output poll"],"total_available":119,"results":[{"query":"read background process output poll","matches":["process_manage","close_terminal"]}],"tools":{"process_manage":{"source":"plugin","source_name":"terminal","description":"Poll or wait on a process","required":["action"]},"close_terminal":{"source":"plugin","source_name":"desktop_ui","description":"Hide a terminal tab","required":["process_id"]}}}""",
)
assertEquals("read background process output poll (2 matches)", view.subtitle)
assertEquals(
"🔧 process_manage · terminal\n Poll or wait on a process\n Required: action\n\n" +
"🔧 close_terminal · desktop_ui\n Hide a terminal tab\n Required: process_id",
view.detail,
)
}
@Test
fun `tool_describe renders tool parameters and required fields`() {
val view =
build(
"tool_describe",
"""{"names":["mcp__agentmail__list_inboxes"]}""",
"""{"tools":{"mcp__agentmail__list_inboxes":{"description":"List email inboxes, paginated.","parameters":{"type":"object","properties":{"limit":{"type":"number","description":"Max number of items to return","default":10},"pageToken":{"type":"string","description":"Page token for pagination"}},"required":[]}}}}""",
)
assertEquals("mcp__agentmail__list_inboxes (1 tool)", view.subtitle)
assertEquals(
"🔧 mcp__agentmail__list_inboxes\n List email inboxes, paginated.\n" +
" Parameters: limit (number) — Max number of items to return [default: 10]; " +
"pageToken (string) — Page token for pagination",
view.detail,
)
}
@Test
fun `read_terminal shows line window and terminal block`() {
val view =
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more