release: 1.29.4
release: 1.29.4
This commit is contained in:
109 files changed
+11212
-1489
No files matched your search
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
+379
@@ -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,
|
||||
)
|
||||
}
|
||||
+47
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+268
@@ -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)
|
||||
}
|
||||
}
|
||||
+252
@@ -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
|
||||
}
|
||||
}
|
||||
+13
-26
@@ -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)
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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\""))
|
||||
}
|
||||
}
|
||||
+130
@@ -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))
|
||||
}
|
||||
}
|
||||
+130
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
+314
@@ -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"))
|
||||
}
|
||||
}
|
||||
+171
-1
@@ -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
|
||||
|
||||
+56
-2
@@ -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
Reference in new issue
Block a user