release: 1.29.4
release: 1.29.4
This commit is contained in:
109 files changed
+10847
-1124
No files matched your search
@@ -10,6 +10,7 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.m57.hermescontrol"
|
namespace = "com.m57.hermescontrol"
|
||||||
|
sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas")
|
||||||
|
|
||||||
lint {
|
lint {
|
||||||
disable += "MissingTranslation"
|
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)
|
val mockViewModel = mockk<ChatViewModel>(relaxed = true)
|
||||||
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
|
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
|
||||||
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).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
|
// ActionProgressDialog collects the controller's StateFlow — a relaxed
|
||||||
// mock proxy would crash the cast inside collectAsStateWithLifecycle.
|
// mock proxy would crash the cast inside collectAsStateWithLifecycle.
|
||||||
// A real controller (never started here) stays invisible.
|
// A real controller (never started here) stays invisible.
|
||||||
@@ -105,6 +108,9 @@ class ChatScreenTest {
|
|||||||
val mockViewModel = mockk<ChatViewModel>(relaxed = true)
|
val mockViewModel = mockk<ChatViewModel>(relaxed = true)
|
||||||
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
|
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
|
||||||
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).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
|
every { mockViewModel.actionProgress } returns
|
||||||
ActionProgressController(scope = CoroutineScope(Dispatchers.Main))
|
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(
|
KanbanFilterSheet(
|
||||||
assignees = emptyList(),
|
assignees = emptyList(),
|
||||||
tenants = emptyList(),
|
tenants = emptyList(),
|
||||||
|
workflowTemplateIds = emptyList(),
|
||||||
|
currentStepKeys = emptyList(),
|
||||||
selectedAssignee = null,
|
selectedAssignee = null,
|
||||||
selectedTenant = null,
|
selectedTenant = null,
|
||||||
|
selectedWorkflowTemplateId = null,
|
||||||
|
selectedCurrentStepKey = null,
|
||||||
includeArchived = false,
|
includeArchived = false,
|
||||||
groupRunning = false,
|
groupRunning = false,
|
||||||
onSelectAssignee = {},
|
onSelectAssignee = {},
|
||||||
onSelectTenant = {},
|
onSelectTenant = {},
|
||||||
|
onSelectWorkflowFilters = { _, _ -> },
|
||||||
onToggleIncludeArchived = { toggled = it },
|
onToggleIncludeArchived = { toggled = it },
|
||||||
onToggleGroupRunning = {},
|
onToggleGroupRunning = {},
|
||||||
onClearFilters = {},
|
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
|
package com.m57.hermescontrol.data.local
|
||||||
|
|
||||||
import androidx.room3.Dao
|
import androidx.room3.Dao
|
||||||
import androidx.room3.Insert
|
|
||||||
import androidx.room3.OnConflictStrategy
|
|
||||||
import androidx.room3.Query
|
import androidx.room3.Query
|
||||||
|
import androidx.room3.Transaction
|
||||||
|
import androidx.room3.Upsert
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface ChatMessageDao {
|
interface ChatMessageDao {
|
||||||
@Query("SELECT EXISTS(SELECT 1 FROM chat_messages WHERE session_id = :sessionId)")
|
@Query("SELECT EXISTS(SELECT 1 FROM chat_messages WHERE session_id = :sessionId)")
|
||||||
suspend fun sessionExists(sessionId: String): Boolean
|
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>
|
suspend fun getMessagesForSession(sessionId: String): List<ChatMessageEntity>
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Query(
|
||||||
suspend fun upsert(message: ChatMessageEntity)
|
"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)
|
@Query(
|
||||||
suspend fun upsertAll(messages: List<ChatMessageEntity>)
|
"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")
|
@Query("DELETE FROM chat_messages WHERE session_id = :sessionId")
|
||||||
suspend fun deleteMessagesForSession(sessionId: String)
|
suspend fun deleteMessagesForSession(sessionId: String)
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ import androidx.room3.PrimaryKey
|
|||||||
* UI model but is stored independently so it survives process death.
|
* UI model but is stored independently so it survives process death.
|
||||||
*
|
*
|
||||||
* Messages are scoped by [sessionId] so switching sessions loads the right
|
* Messages are scoped by [sessionId] so switching sessions loads the right
|
||||||
* thread. The [timestamp] field preserves original ordering even if Room
|
* thread. [sortOrder] preserves numeric server order or local insertion order;
|
||||||
* reorders internally.
|
* [timestamp] is display metadata only.
|
||||||
*/
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "chat_messages",
|
tableName = "chat_messages",
|
||||||
indices = [
|
indices = [
|
||||||
Index(value = ["session_id", "timestamp"]),
|
Index(value = ["session_id", "sort_group", "sort_order", "id"]),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
data class ChatMessageEntity(
|
data class ChatMessageEntity(
|
||||||
@@ -48,4 +48,21 @@ data class ChatMessageEntity(
|
|||||||
val tps: Double? = null,
|
val tps: Double? = null,
|
||||||
@ColumnInfo(name = "completion_id")
|
@ColumnInfo(name = "completion_id")
|
||||||
val completionId: String? = null,
|
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,
|
tokenCount = tokenCount,
|
||||||
tps = tps,
|
tps = tps,
|
||||||
completionId = completionId,
|
completionId = completionId,
|
||||||
|
restId = restId,
|
||||||
|
localOrder = sortOrder.takeIf { sortGroup == 1 && it > 0 },
|
||||||
)
|
)
|
||||||
|
|
||||||
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||||
@@ -56,4 +58,5 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
|||||||
tokenCount = tokenCount,
|
tokenCount = tokenCount,
|
||||||
tps = tps,
|
tps = tps,
|
||||||
completionId = completionId,
|
completionId = completionId,
|
||||||
|
restId = restId,
|
||||||
)
|
)
|
||||||
@@ -14,7 +14,7 @@ import java.io.File
|
|||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [ChatMessageEntity::class],
|
entities = [ChatMessageEntity::class],
|
||||||
version = 8,
|
version = 9,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
abstract class HermesDatabase : RoomDatabase() {
|
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 =
|
suspend fun get(context: Context): HermesDatabase =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
instance?.let { return@withContext it }
|
instance?.let { return@withContext it }
|
||||||
@@ -126,6 +151,7 @@ abstract class HermesDatabase : RoomDatabase() {
|
|||||||
MIGRATION_5_6,
|
MIGRATION_5_6,
|
||||||
MIGRATION_6_7,
|
MIGRATION_6_7,
|
||||||
MIGRATION_7_8,
|
MIGRATION_7_8,
|
||||||
|
MIGRATION_8_9,
|
||||||
).fallbackToDestructiveMigration(false)
|
).fallbackToDestructiveMigration(false)
|
||||||
.build()
|
.build()
|
||||||
.also { instance = it }
|
.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
|
@Serializable
|
||||||
data class KanbanRun(
|
data class KanbanRun(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
|
val taskId: String? = null,
|
||||||
val profile: String? = null,
|
val profile: String? = null,
|
||||||
|
val stepKey: String? = null,
|
||||||
val status: String,
|
val status: String,
|
||||||
|
val claimLock: String? = null,
|
||||||
|
val claimExpires: Long? = null,
|
||||||
|
val maxRuntimeSeconds: Int? = null,
|
||||||
|
val lastHeartbeatAt: Long? = null,
|
||||||
val outcome: String? = null,
|
val outcome: String? = null,
|
||||||
val summary: String? = null,
|
val summary: String? = null,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
@@ -59,3 +65,71 @@ data class KanbanAttachment(
|
|||||||
data class AttachmentUploadResponse(
|
data class AttachmentUploadResponse(
|
||||||
val attachment: KanbanAttachment? = null,
|
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(),
|
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
|
@Serializable
|
||||||
data class ChildTaskResult(
|
data class ChildTaskResult(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -75,6 +87,9 @@ data class KanbanTask(
|
|||||||
val startedAt: Long? = null,
|
val startedAt: Long? = null,
|
||||||
val workerPid: Int? = null,
|
val workerPid: Int? = null,
|
||||||
val lastHeartbeatAt: Long? = null,
|
val lastHeartbeatAt: Long? = null,
|
||||||
|
val currentRunId: Long? = null,
|
||||||
|
val workflowTemplateId: String? = null,
|
||||||
|
val currentStepKey: String? = null,
|
||||||
) {
|
) {
|
||||||
val description: String? get() = body
|
val description: String? get() = body
|
||||||
val assignedTo: String? get() = assignee
|
val assignedTo: String? get() = assignee
|
||||||
@@ -112,6 +127,10 @@ data class KanbanTaskFull(
|
|||||||
val startedAt: Long? = null,
|
val startedAt: Long? = null,
|
||||||
val workerPid: Int? = null,
|
val workerPid: Int? = null,
|
||||||
val lastHeartbeatAt: Long? = 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 result: String? = null,
|
||||||
val createdBy: String? = null,
|
val createdBy: String? = null,
|
||||||
val modelOverride: String? = null,
|
val modelOverride: String? = null,
|
||||||
@@ -245,6 +264,19 @@ data class ReclaimTaskResponse(
|
|||||||
val taskId: String,
|
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
|
@Serializable
|
||||||
data class TaskEstimate(
|
data class TaskEstimate(
|
||||||
val ok: Boolean,
|
val ok: Boolean,
|
||||||
|
|||||||
@@ -27,4 +27,5 @@ data class ModelInfoResponse(
|
|||||||
val config_context_length: Long? = null,
|
val config_context_length: Long? = null,
|
||||||
/** Effective context window actually in use (max of auto/config + caps). */
|
/** Effective context window actually in use (max of auto/config + caps). */
|
||||||
val effective_context_length: Long? = null,
|
val effective_context_length: Long? = null,
|
||||||
|
val capabilities: ModelCapabilities? = null,
|
||||||
)
|
)
|
||||||
@@ -26,8 +26,60 @@ data class ModelCapabilities(
|
|||||||
val fast: Boolean? = null,
|
val fast: Boolean? = null,
|
||||||
val reasoning: Boolean? = null,
|
val reasoning: Boolean? = null,
|
||||||
val can_disable_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
|
@Serializable
|
||||||
data class PinnedModel(
|
data class PinnedModel(
|
||||||
val providerSlug: String,
|
val providerSlug: String,
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ data class PluginInfo(
|
|||||||
get() =
|
get() =
|
||||||
runtimeStatus.equals("enabled", ignoreCase = true) ||
|
runtimeStatus.equals("enabled", ignoreCase = true) ||
|
||||||
runtimeStatus.equals("disabled", ignoreCase = true)
|
runtimeStatus.equals("disabled", ignoreCase = true)
|
||||||
|
|
||||||
|
val removable: Boolean
|
||||||
|
get() = canRemove && (source == "user" || source == "git")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ data class SessionMessage(
|
|||||||
val reasoning: JsonElement? = null,
|
val reasoning: JsonElement? = null,
|
||||||
val reasoning_text: JsonElement? = null,
|
val reasoning_text: JsonElement? = null,
|
||||||
val tool_call_id: String? = null,
|
val tool_call_id: String? = null,
|
||||||
|
/**
|
||||||
|
* 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
|
* Timeline-marker tag (backend NS-656 lineage, issue #904): markers like
|
||||||
* `model_switch` / `personality_switch` / `auto_continue` ride as
|
* `model_switch` / `personality_switch` / `auto_continue` ride as
|
||||||
@@ -85,6 +91,14 @@ data class SessionMessage(
|
|||||||
else -> content.toString()
|
else -> content.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val displayContentText: String?
|
||||||
|
get() =
|
||||||
|
when (display_content) {
|
||||||
|
is JsonPrimitive -> display_content.content
|
||||||
|
null -> null
|
||||||
|
else -> display_content.toString()
|
||||||
|
}
|
||||||
|
|
||||||
val reasoningText: String
|
val reasoningText: String
|
||||||
get() =
|
get() =
|
||||||
when (val r = reasoning ?: reasoning_text) {
|
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.SessionDetailResponse
|
||||||
import com.m57.hermescontrol.data.model.SessionInfo
|
import com.m57.hermescontrol.data.model.SessionInfo
|
||||||
import com.m57.hermescontrol.data.model.SessionListResponse
|
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.SessionMessagesResponse
|
||||||
import com.m57.hermescontrol.data.model.SessionRenameRequest
|
import com.m57.hermescontrol.data.model.SessionRenameRequest
|
||||||
import com.m57.hermescontrol.data.model.SessionSearchResponse
|
import com.m57.hermescontrol.data.model.SessionSearchResponse
|
||||||
import com.m57.hermescontrol.data.model.SessionStatsResponse
|
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.SetActiveProfileRequest
|
||||||
import com.m57.hermescontrol.data.model.Skill
|
import com.m57.hermescontrol.data.model.Skill
|
||||||
import com.m57.hermescontrol.data.model.SkillContentResponse
|
import com.m57.hermescontrol.data.model.SkillContentResponse
|
||||||
@@ -208,6 +210,22 @@ interface HermesApiService : KanbanApiService {
|
|||||||
@Query("profile") profile: String? = null,
|
@Query("profile") profile: String? = null,
|
||||||
): Response<SessionMessagesResponse>
|
): 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")
|
@GET("api/sessions/stats")
|
||||||
suspend fun getSessionStats(): Response<SessionStatsResponse>
|
suspend fun getSessionStats(): Response<SessionStatsResponse>
|
||||||
|
|
||||||
@@ -281,7 +299,9 @@ interface HermesApiService : KanbanApiService {
|
|||||||
): Response<SessionInfo>
|
): Response<SessionInfo>
|
||||||
|
|
||||||
@GET("api/model/info")
|
@GET("api/model/info")
|
||||||
suspend fun getModelInfo(): Response<ModelInfoResponse>
|
suspend fun getModelInfo(
|
||||||
|
@Query("profile") profile: String? = null,
|
||||||
|
): Response<ModelInfoResponse>
|
||||||
|
|
||||||
@GET("api/system/stats")
|
@GET("api/system/stats")
|
||||||
suspend fun getSystemStats(): Response<SystemStatsResponse>
|
suspend fun getSystemStats(): Response<SystemStatsResponse>
|
||||||
@@ -645,11 +665,6 @@ interface HermesApiService : KanbanApiService {
|
|||||||
@Body body: AgentPluginInstallBody,
|
@Body body: AgentPluginInstallBody,
|
||||||
): Response<Unit>
|
): 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")
|
@POST("api/dashboard/agent-plugins/{name}/update")
|
||||||
suspend fun updatePlugin(
|
suspend fun updatePlugin(
|
||||||
@Path("name", encoded = true) name: String,
|
@Path("name", encoded = true) name: String,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.m57.hermescontrol.data.remote
|
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.AttachmentUploadResponse
|
||||||
import com.m57.hermescontrol.data.model.AutoDescribeResponse
|
import com.m57.hermescontrol.data.model.AutoDescribeResponse
|
||||||
import com.m57.hermescontrol.data.model.BoardExportResult
|
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.CreateBoardResponse
|
||||||
import com.m57.hermescontrol.data.model.CreateTaskBody
|
import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||||
import com.m57.hermescontrol.data.model.CreateTaskResponse
|
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.DeleteBoardResponse
|
||||||
import com.m57.hermescontrol.data.model.DispatchResult
|
import com.m57.hermescontrol.data.model.DispatchResult
|
||||||
import com.m57.hermescontrol.data.model.ExportBoardBody
|
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.KanbanBoardsResponse
|
||||||
import com.m57.hermescontrol.data.model.KanbanProfilesResponse
|
import com.m57.hermescontrol.data.model.KanbanProfilesResponse
|
||||||
import com.m57.hermescontrol.data.model.KanbanProjectsResponse
|
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.KanbanTaskDetailResponse
|
||||||
import com.m57.hermescontrol.data.model.OrchestrationSettings
|
import com.m57.hermescontrol.data.model.OrchestrationSettings
|
||||||
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
|
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.ReclaimTaskResponse
|
||||||
import com.m57.hermescontrol.data.model.RenameBoardBody
|
import com.m57.hermescontrol.data.model.RenameBoardBody
|
||||||
import com.m57.hermescontrol.data.model.RenameBoardResponse
|
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.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.UpdateTaskBody
|
||||||
import com.m57.hermescontrol.data.model.UpdateTaskResponse
|
import com.m57.hermescontrol.data.model.UpdateTaskResponse
|
||||||
import com.m57.hermescontrol.data.model.WorkerLog
|
import com.m57.hermescontrol.data.model.WorkerLog
|
||||||
@@ -57,6 +68,8 @@ interface KanbanApiService {
|
|||||||
@Query("board") board: String? = null,
|
@Query("board") board: String? = null,
|
||||||
@Query("include_archived") includeArchived: Boolean = false,
|
@Query("include_archived") includeArchived: Boolean = false,
|
||||||
@Query("tenant") tenant: String? = null,
|
@Query("tenant") tenant: String? = null,
|
||||||
|
@Query("workflow_template_id") workflowTemplateId: String? = null,
|
||||||
|
@Query("current_step_key") currentStepKey: String? = null,
|
||||||
): Response<KanbanBoardResponse>
|
): Response<KanbanBoardResponse>
|
||||||
|
|
||||||
@POST("api/plugins/kanban/boards")
|
@POST("api/plugins/kanban/boards")
|
||||||
@@ -96,6 +109,8 @@ interface KanbanApiService {
|
|||||||
suspend fun getTask(
|
suspend fun getTask(
|
||||||
@Path("id") taskId: String,
|
@Path("id") taskId: String,
|
||||||
@Query("board") board: String? = null,
|
@Query("board") board: String? = null,
|
||||||
|
@Query("run_state_type") runStateType: String? = null,
|
||||||
|
@Query("run_state_name") runStateName: String? = null,
|
||||||
): Response<KanbanTaskDetailResponse>
|
): Response<KanbanTaskDetailResponse>
|
||||||
|
|
||||||
@POST("api/plugins/kanban/tasks")
|
@POST("api/plugins/kanban/tasks")
|
||||||
@@ -170,6 +185,12 @@ interface KanbanApiService {
|
|||||||
@Part file: MultipartBody.Part,
|
@Part file: MultipartBody.Part,
|
||||||
): Response<AttachmentUploadResponse>
|
): Response<AttachmentUploadResponse>
|
||||||
|
|
||||||
|
@GET("api/plugins/kanban/tasks/{id}/attachments")
|
||||||
|
suspend fun listAttachments(
|
||||||
|
@Path("id") taskId: String,
|
||||||
|
@Query("board") board: String? = null,
|
||||||
|
): Response<AttachmentListResponse>
|
||||||
|
|
||||||
@Streaming
|
@Streaming
|
||||||
@GET("api/plugins/kanban/attachments/{id}")
|
@GET("api/plugins/kanban/attachments/{id}")
|
||||||
suspend fun downloadAttachment(
|
suspend fun downloadAttachment(
|
||||||
@@ -177,6 +198,49 @@ interface KanbanApiService {
|
|||||||
@Query("board") board: String? = null,
|
@Query("board") board: String? = null,
|
||||||
): Response<ResponseBody>
|
): 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")
|
@GET("api/plugins/kanban/profiles")
|
||||||
suspend fun getKanbanProfiles(): Response<KanbanProfilesResponse>
|
suspend fun getKanbanProfiles(): Response<KanbanProfilesResponse>
|
||||||
|
|
||||||
|
|||||||
@@ -18,18 +18,16 @@ import okhttp3.Response
|
|||||||
* - If no profile is active, pass through unchanged (legacy "default" behavior).
|
* - If no profile is active, pass through unchanged (legacy "default" behavior).
|
||||||
* - If the URL already carries an explicit `profile=` query, leave it
|
* - If the URL already carries an explicit `profile=` query, leave it
|
||||||
* untouched — explicit beats global.
|
* untouched — explicit beats global.
|
||||||
* - Only the endpoint families below honor `?profile=` on the backend;
|
* - Only backend-confirmed profile-aware endpoints are rewritten. Ops is
|
||||||
* everything else (ops, pairing, profiles themselves) is machine-global
|
* intentionally classified route-by-route instead of matching `/api/ops`
|
||||||
* or self-scoped and must NOT be rewritten. Cron, sessions and plugins
|
* wholesale, so future machine-global operations stay untouched.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
object ProfileScopeInterceptor : Interceptor {
|
object ProfileScopeInterceptor : Interceptor {
|
||||||
private val PROFILE_SCOPED_PREFIXES =
|
private val PROFILE_SCOPED_PREFIXES =
|
||||||
listOf(
|
listOf(
|
||||||
"/api/analytics",
|
"/api/analytics",
|
||||||
"/api/config",
|
"/api/config",
|
||||||
|
"/api/credentials/pool",
|
||||||
"/api/cron",
|
"/api/cron",
|
||||||
"/api/env",
|
"/api/env",
|
||||||
"/api/gateway",
|
"/api/gateway",
|
||||||
@@ -48,6 +46,28 @@ object ProfileScopeInterceptor : Interceptor {
|
|||||||
"/api/skills",
|
"/api/skills",
|
||||||
"/api/status",
|
"/api/status",
|
||||||
"/api/tools/toolsets",
|
"/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`
|
* e.g. `/api/status` or `/api/status/health`, but NOT `/api/statusXYZ`
|
||||||
* or `/api/gatewayExtra` (Sourcery review, PR #540).
|
* 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 =
|
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 {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val request = chain.request()
|
val request = chain.request()
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.m57.hermescontrol.data.repository
|
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.AttachmentUploadResponse
|
||||||
import com.m57.hermescontrol.data.model.AutoDescribeResponse
|
import com.m57.hermescontrol.data.model.AutoDescribeResponse
|
||||||
import com.m57.hermescontrol.data.model.BoardExportResult
|
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.CreateBoardResponse
|
||||||
import com.m57.hermescontrol.data.model.CreateTaskBody
|
import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||||
import com.m57.hermescontrol.data.model.CreateTaskResponse
|
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.DeleteBoardResponse
|
||||||
import com.m57.hermescontrol.data.model.DispatchResult
|
import com.m57.hermescontrol.data.model.DispatchResult
|
||||||
import com.m57.hermescontrol.data.model.ExportBoardBody
|
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.KanbanBoardsResponse
|
||||||
import com.m57.hermescontrol.data.model.KanbanProfilesResponse
|
import com.m57.hermescontrol.data.model.KanbanProfilesResponse
|
||||||
import com.m57.hermescontrol.data.model.KanbanProjectsResponse
|
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.KanbanTaskDetailResponse
|
||||||
import com.m57.hermescontrol.data.model.OrchestrationSettings
|
import com.m57.hermescontrol.data.model.OrchestrationSettings
|
||||||
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
|
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.ReclaimTaskResponse
|
||||||
import com.m57.hermescontrol.data.model.RenameBoardBody
|
import com.m57.hermescontrol.data.model.RenameBoardBody
|
||||||
import com.m57.hermescontrol.data.model.RenameBoardResponse
|
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.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.UpdateTaskBody
|
||||||
import com.m57.hermescontrol.data.model.UpdateTaskResponse
|
import com.m57.hermescontrol.data.model.UpdateTaskResponse
|
||||||
import com.m57.hermescontrol.data.model.WorkerLog
|
import com.m57.hermescontrol.data.model.WorkerLog
|
||||||
@@ -48,6 +59,8 @@ interface KanbanRepository {
|
|||||||
board: String,
|
board: String,
|
||||||
includeArchived: Boolean = false,
|
includeArchived: Boolean = false,
|
||||||
tenant: String? = null,
|
tenant: String? = null,
|
||||||
|
workflowTemplateId: String? = null,
|
||||||
|
currentStepKey: String? = null,
|
||||||
): NetworkResult<KanbanBoardResponse>
|
): NetworkResult<KanbanBoardResponse>
|
||||||
|
|
||||||
suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse>
|
suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse>
|
||||||
@@ -72,6 +85,8 @@ interface KanbanRepository {
|
|||||||
suspend fun getTask(
|
suspend fun getTask(
|
||||||
taskId: String,
|
taskId: String,
|
||||||
board: String?,
|
board: String?,
|
||||||
|
runStateType: String? = null,
|
||||||
|
runStateName: String? = null,
|
||||||
): NetworkResult<KanbanTaskDetailResponse>
|
): NetworkResult<KanbanTaskDetailResponse>
|
||||||
|
|
||||||
suspend fun createTask(
|
suspend fun createTask(
|
||||||
@@ -136,11 +151,49 @@ interface KanbanRepository {
|
|||||||
file: MultipartBody.Part,
|
file: MultipartBody.Part,
|
||||||
): NetworkResult<AttachmentUploadResponse>
|
): NetworkResult<AttachmentUploadResponse>
|
||||||
|
|
||||||
|
suspend fun listAttachments(
|
||||||
|
taskId: String,
|
||||||
|
board: String,
|
||||||
|
): NetworkResult<AttachmentListResponse>
|
||||||
|
|
||||||
|
suspend fun deleteAttachment(
|
||||||
|
attachmentId: Long,
|
||||||
|
board: String,
|
||||||
|
): NetworkResult<DeleteAttachmentResponse>
|
||||||
|
|
||||||
suspend fun downloadAttachment(
|
suspend fun downloadAttachment(
|
||||||
attachmentId: Long,
|
attachmentId: Long,
|
||||||
board: String?,
|
board: String?,
|
||||||
): NetworkResult<ResponseBody>
|
): 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 getProfiles(): NetworkResult<KanbanProfilesResponse>
|
||||||
|
|
||||||
suspend fun updateProfileDescription(
|
suspend fun updateProfileDescription(
|
||||||
@@ -173,9 +226,11 @@ class KanbanRepositoryImpl(
|
|||||||
board: String,
|
board: String,
|
||||||
includeArchived: Boolean,
|
includeArchived: Boolean,
|
||||||
tenant: String?,
|
tenant: String?,
|
||||||
|
workflowTemplateId: String?,
|
||||||
|
currentStepKey: String?,
|
||||||
): NetworkResult<KanbanBoardResponse> =
|
): NetworkResult<KanbanBoardResponse> =
|
||||||
withContext(ioDispatcher) {
|
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> =
|
override suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse> =
|
||||||
@@ -215,9 +270,11 @@ class KanbanRepositoryImpl(
|
|||||||
override suspend fun getTask(
|
override suspend fun getTask(
|
||||||
taskId: String,
|
taskId: String,
|
||||||
board: String?,
|
board: String?,
|
||||||
|
runStateType: String?,
|
||||||
|
runStateName: String?,
|
||||||
): NetworkResult<KanbanTaskDetailResponse> =
|
): NetworkResult<KanbanTaskDetailResponse> =
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
safeApiCall { apiProvider().getTask(taskId = taskId, board = board) }
|
safeApiCall { apiProvider().getTask(taskId, board, runStateType, runStateName) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createTask(
|
override suspend fun createTask(
|
||||||
@@ -364,6 +421,18 @@ class KanbanRepositoryImpl(
|
|||||||
safeApiCall { apiProvider().uploadAttachment(taskId = taskId, board = board, file = file) }
|
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(
|
override suspend fun downloadAttachment(
|
||||||
attachmentId: Long,
|
attachmentId: Long,
|
||||||
board: String?,
|
board: String?,
|
||||||
@@ -372,6 +441,48 @@ class KanbanRepositoryImpl(
|
|||||||
safeApiCall { apiProvider().downloadAttachment(attachmentId = attachmentId, board = board) }
|
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> =
|
override suspend fun getProfiles(): NetworkResult<KanbanProfilesResponse> =
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
safeApiCall { apiProvider().getKanbanProfiles() }
|
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
|
val sessionId = params["session_id"] as? String ?: payload?.get("session_id") as? String
|
||||||
|
|
||||||
return when (eventType) {
|
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" -> {
|
"gateway.ready" -> {
|
||||||
WsEvent.GatewayReady(payload)
|
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
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot
|
||||||
import com.m57.hermescontrol.ui.chat.TodoItem
|
import com.m57.hermescontrol.ui.chat.TodoItem
|
||||||
|
|
||||||
/** Parsed WebSocket events emitted by [HermesWsClient]. */
|
/** Parsed WebSocket events emitted by [HermesWsClient]. */
|
||||||
@@ -191,6 +192,14 @@ sealed class WsEvent {
|
|||||||
val sessionId: String? = null,
|
val sessionId: String? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
|
data class ConnectionRequest(
|
||||||
|
val snapshot: ConnectionOperationSnapshot,
|
||||||
|
) : WsEvent()
|
||||||
|
|
||||||
|
data class ConnectionUpdate(
|
||||||
|
val snapshot: ConnectionOperationSnapshot,
|
||||||
|
) : WsEvent()
|
||||||
|
|
||||||
// ── Status ───────────────────────────────────────────────────────────
|
// ── Status ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class StatusUpdate(
|
data class StatusUpdate(
|
||||||
|
|||||||
@@ -101,12 +101,18 @@ object WsMethods {
|
|||||||
|
|
||||||
// ── Connectors (issue #1091) ───────────────────────────────────────────
|
// ── 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. */
|
/** List available third-party connectors and authorization state for a session. */
|
||||||
const val CONNECTORS_LIST = "connectors.list"
|
const val CONNECTORS_LIST = "connectors.list"
|
||||||
|
|
||||||
/** Initiate or restart authorization flow for one or more connectors. */
|
/** Initiate or restart authorization flow for one or more connectors. */
|
||||||
const val CONNECTORS_CONNECT = "connectors.connect"
|
const val CONNECTORS_CONNECT = "connectors.connect"
|
||||||
|
|
||||||
|
// ── Plugins ───────────────────────────────────────────────────────────
|
||||||
|
const val PLUGINS_MANAGE = "plugins.manage"
|
||||||
|
|
||||||
// ── Billing / subscription (issue #628) ─────────────────────────────
|
// ── Billing / subscription (issue #628) ─────────────────────────────
|
||||||
// Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e).
|
// Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e).
|
||||||
// `credits.view` was REMOVED upstream; these replace it. `usage.bars`
|
// `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
|
CONFIG_SET, // methods_config_set.py: @method("config.set") + @_profile_scoped
|
||||||
"verification.status", // line 282
|
"verification.status", // line 282
|
||||||
PROJECTS_LIST, // methods_projects.py: @_registry.profile_scoped
|
PROJECTS_LIST, // methods_projects.py: @_registry.profile_scoped
|
||||||
|
PLUGINS_MANAGE, // methods_tools.py: @_scoped_rpc
|
||||||
"pet.info", // line 1253
|
"pet.info", // line 1253
|
||||||
"pet.info.meta", // line 1279
|
"pet.info.meta", // line 1279
|
||||||
"pet.cells", // line 1302
|
"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,
|
val tps: Double? = null,
|
||||||
/** Stable completion identity correlating with reply notifications for read tracking. */
|
/** Stable completion identity correlating with reply notifications for read tracking. */
|
||||||
val completionId: String? = null,
|
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.
|
* 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.AuthManager
|
||||||
import com.m57.hermescontrol.data.local.DataScope
|
import com.m57.hermescontrol.data.local.DataScope
|
||||||
import com.m57.hermescontrol.data.model.ModelCapabilities
|
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.ModelProvider
|
||||||
import com.m57.hermescontrol.data.model.PinnedModel
|
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.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||||
@@ -77,6 +80,9 @@ class ChatModelSwitchDelegate(
|
|||||||
private var cachedModelOptionsScope: DataScope? = dataScopeFlow.value
|
private var cachedModelOptionsScope: DataScope? = dataScopeFlow.value
|
||||||
private var modelOptionsJob: Job? = null
|
private var modelOptionsJob: Job? = null
|
||||||
private var modelOptionsJobScope: DataScope? = 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) {
|
fun attachScopeObserver(externalScope: CoroutineScope) {
|
||||||
externalScope.launch {
|
externalScope.launch {
|
||||||
@@ -89,6 +95,9 @@ class ChatModelSwitchDelegate(
|
|||||||
cachedModelOptions = emptyList()
|
cachedModelOptions = emptyList()
|
||||||
cachedModelOptionsScope = newScope
|
cachedModelOptionsScope = newScope
|
||||||
fastRejectedModels.clear()
|
fastRejectedModels.clear()
|
||||||
|
modelInfoCapabilities = null
|
||||||
|
modelInfoModel = null
|
||||||
|
modelInfoScope = null
|
||||||
|
|
||||||
if (modelOptionsJobScope != newScope) {
|
if (modelOptionsJobScope != newScope) {
|
||||||
modelOptionsJob?.cancel()
|
modelOptionsJob?.cancel()
|
||||||
@@ -331,6 +340,19 @@ class ChatModelSwitchDelegate(
|
|||||||
it.copy(modelSwitchConfirmMessage = confirmMessage)
|
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") {
|
} else if (key == "fast") {
|
||||||
val rawVal = map["value"] as? String
|
val rawVal = map["value"] as? String
|
||||||
@@ -469,6 +491,9 @@ class ChatModelSwitchDelegate(
|
|||||||
reasoningOpSequence++
|
reasoningOpSequence++
|
||||||
reasoningJob?.cancel()
|
reasoningJob?.cancel()
|
||||||
reasoningJob = null
|
reasoningJob = null
|
||||||
|
modelInfoCapabilities = null
|
||||||
|
modelInfoModel = null
|
||||||
|
modelInfoScope = null
|
||||||
uiState.update {
|
uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
pendingReasoningLevel = null,
|
pendingReasoningLevel = null,
|
||||||
@@ -492,7 +517,7 @@ class ChatModelSwitchDelegate(
|
|||||||
// Capability validation
|
// Capability validation
|
||||||
if (enforceCapabilities) {
|
if (enforceCapabilities) {
|
||||||
val caps = uiState.value.currentModelCapabilities
|
val caps = uiState.value.currentModelCapabilities
|
||||||
if (caps?.reasoning == false) return
|
if (caps?.reasoningSupport == false) return
|
||||||
if (level == "none" && caps?.can_disable_reasoning == false) return
|
if (level == "none" && caps?.can_disable_reasoning == false) return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,10 +603,26 @@ class ChatModelSwitchDelegate(
|
|||||||
modelName: String,
|
modelName: String,
|
||||||
): ModelCapabilities? {
|
): ModelCapabilities? {
|
||||||
if (cachedModelOptionsScope != dataScopeFlow.value) return null
|
if (cachedModelOptionsScope != dataScopeFlow.value) return null
|
||||||
return cachedModelOptions
|
val catalog =
|
||||||
|
cachedModelOptions
|
||||||
.find { it.slug == providerSlug }
|
.find { it.slug == providerSlug }
|
||||||
?.capabilities
|
?.capabilities
|
||||||
?.get(modelName)
|
?.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? {
|
fun getCurrentModelCapabilities(): ModelCapabilities? {
|
||||||
|
|||||||
@@ -36,6 +36,50 @@ open class ChatPersistenceRepository(
|
|||||||
suspend fun loadMessages(sessionId: String): List<ChatMessage> =
|
suspend fun loadMessages(sessionId: String): List<ChatMessage> =
|
||||||
daoProvider().getMessagesForSession(sessionId).map { it.toUiModel() }
|
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). */
|
/** Clear all cached messages for a session (e.g. after /undo rewind). */
|
||||||
suspend fun clearMessagesForSession(sessionId: String) {
|
suspend fun clearMessagesForSession(sessionId: String) {
|
||||||
daoProvider().deleteMessagesForSession(sessionId)
|
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.Add
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Extension
|
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.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -59,7 +61,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -81,18 +82,22 @@ import com.m57.hermescontrol.NavigationController
|
|||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
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.ConnectionStatus
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.notification.NotificationHelper
|
import com.m57.hermescontrol.notification.NotificationHelper
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatAppUpdateSection
|
import com.m57.hermescontrol.ui.chat.components.ChatAppUpdateSection
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner
|
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.ChatInputBar
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
|
import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatLoadingOverlay
|
import com.m57.hermescontrol.ui.chat.components.ChatLoadingOverlay
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatResumeErrorOverlay
|
import com.m57.hermescontrol.ui.chat.components.ChatResumeErrorOverlay
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab
|
import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatTimelineNoPrefetchStrategy
|
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.ContextDetailSheet
|
||||||
import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
|
import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
|
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.HermesScaffold
|
||||||
import com.m57.hermescontrol.ui.common.NavIcon
|
import com.m57.hermescontrol.ui.common.NavIcon
|
||||||
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
|
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
|
||||||
|
import com.m57.hermescontrol.ui.settings.SettingsViewModel
|
||||||
import com.m57.hermescontrol.util.ConnectorUrlValidator
|
import com.m57.hermescontrol.util.ConnectorUrlValidator
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private const val SESSION_SYNC_INTERVAL_MS = 30_000L
|
private const val SESSION_SYNC_INTERVAL_MS = 30_000L
|
||||||
@@ -152,17 +156,24 @@ fun ChatScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
|
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
|
||||||
|
val timelineState by viewModel.timelineState.collectAsStateWithLifecycle()
|
||||||
val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle()
|
val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle()
|
||||||
val connectorsViewModel: ChatConnectorsViewModel = viewModel()
|
val connectorsViewModel: ChatConnectorsViewModel = viewModel()
|
||||||
val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle()
|
val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val actionProgressState by viewModel.actionProgress.state.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
|
// Snapshot-backed search state — read directly so only the scopes that
|
||||||
// read its fields recompose on search changes (bar, matched bubbles).
|
// read its fields recompose on search changes (bar, matched bubbles).
|
||||||
val searchState = viewModel.searchState
|
val searchState = viewModel.searchState
|
||||||
|
val displayedMessages = timelineState.historyMessages ?: state.messages
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var browserAuthInFlight by rememberSaveable { mutableStateOf(false) }
|
var browserAuthInFlight by rememberSaveable { mutableStateOf(false) }
|
||||||
var browserAuthDeparted 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) {
|
DisposableEffect(lifecycleOwner) {
|
||||||
val observer =
|
val observer =
|
||||||
@@ -172,15 +183,27 @@ fun ChatScreen(
|
|||||||
if (browserAuthInFlight) {
|
if (browserAuthInFlight) {
|
||||||
browserAuthDeparted = true
|
browserAuthDeparted = true
|
||||||
}
|
}
|
||||||
|
if (connectionBrowserOperationId != null) {
|
||||||
|
connectionBrowserDeparted = true
|
||||||
|
}
|
||||||
connectorsViewModel.onPause()
|
connectorsViewModel.onPause()
|
||||||
}
|
}
|
||||||
|
|
||||||
Lifecycle.Event.ON_RESUME -> {
|
Lifecycle.Event.ON_RESUME -> {
|
||||||
connectorsViewModel.onResume()
|
connectorsViewModel.onResume()
|
||||||
if (browserAuthInFlight && browserAuthDeparted) {
|
val legacyBrowserReturned = browserAuthInFlight && browserAuthDeparted
|
||||||
|
val operationId = connectionBrowserOperationId.takeIf { connectionBrowserDeparted }
|
||||||
|
if (legacyBrowserReturned || operationId != null) {
|
||||||
|
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||||
|
}
|
||||||
|
if (legacyBrowserReturned) {
|
||||||
browserAuthInFlight = false
|
browserAuthInFlight = false
|
||||||
browserAuthDeparted = false
|
browserAuthDeparted = false
|
||||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
}
|
||||||
|
if (operationId != null) {
|
||||||
|
connectionBrowserOperationId = null
|
||||||
|
connectionBrowserDeparted = false
|
||||||
|
viewModel.wakeConnectionOperation(operationId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,11 +219,18 @@ fun ChatScreen(
|
|||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
val isChangingConfigs = activity?.isChangingConfigurations == true
|
val isChangingConfigs = activity?.isChangingConfigurations == true
|
||||||
if (!isChangingConfigs) {
|
if (!isChangingConfigs) {
|
||||||
|
val externalActivityOutstanding =
|
||||||
|
(browserAuthInFlight && browserAuthDeparted) ||
|
||||||
|
(connectionBrowserOperationId != null && connectionBrowserDeparted)
|
||||||
|
if (externalActivityOutstanding) {
|
||||||
|
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||||
|
}
|
||||||
if (browserAuthInFlight && browserAuthDeparted) {
|
if (browserAuthInFlight && browserAuthDeparted) {
|
||||||
browserAuthInFlight = false
|
browserAuthInFlight = false
|
||||||
browserAuthDeparted = false
|
browserAuthDeparted = false
|
||||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
|
||||||
}
|
}
|
||||||
|
connectionBrowserOperationId = null
|
||||||
|
connectionBrowserDeparted = false
|
||||||
connectorsViewModel.onPause()
|
connectorsViewModel.onPause()
|
||||||
connectorsViewModel.hide()
|
connectorsViewModel.hide()
|
||||||
}
|
}
|
||||||
@@ -211,7 +241,6 @@ fun ChatScreen(
|
|||||||
val listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy)
|
val listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy)
|
||||||
val scrollScope = rememberCoroutineScope()
|
val scrollScope = rememberCoroutineScope()
|
||||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||||
var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) }
|
|
||||||
var showContextSheet by remember { mutableStateOf(false) }
|
var showContextSheet by remember { mutableStateOf(false) }
|
||||||
var pendingSavePath by rememberSaveable { mutableStateOf<String?>(null) }
|
var pendingSavePath by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var pendingSaveName 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.
|
// FullBleedChatList preserves the live lazy row key and pixel offset on prepend.
|
||||||
// Before prepending, capture the anchor (first visible message id + offset)
|
// Never restore a message index captured before an asynchronous fetch: it is not
|
||||||
// so the same content stays under the reader's eye after the insert
|
// a lazy row index and the reader may have moved in the meantime.
|
||||||
// (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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Continuous bottom-follow tracking from LazyListState (issue #682).
|
// Continuous bottom-follow tracking from LazyListState (issue #682).
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@@ -308,6 +308,7 @@ fun ChatScreen(
|
|||||||
// (issue #682). Replaces the old item-count heuristic that ignored the
|
// (issue #682). Replaces the old item-count heuristic that ignored the
|
||||||
// streaming tail.
|
// streaming tail.
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
|
timelineState.isHistorical,
|
||||||
state.messages,
|
state.messages,
|
||||||
streamingState.streamingMessage,
|
streamingState.streamingMessage,
|
||||||
streamingState.isThinking,
|
streamingState.isThinking,
|
||||||
@@ -315,6 +316,10 @@ fun ChatScreen(
|
|||||||
state.todos,
|
state.todos,
|
||||||
state.clarifyRequest,
|
state.clarifyRequest,
|
||||||
) {
|
) {
|
||||||
|
if (timelineState.isHistorical) {
|
||||||
|
scrollController.pauseFollowing()
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
scrollController.onTailChanged(
|
scrollController.onTailChanged(
|
||||||
tailKey =
|
tailKey =
|
||||||
tailContentKey(
|
tailContentKey(
|
||||||
@@ -327,9 +332,15 @@ fun ChatScreen(
|
|||||||
messageCount = state.messages.size,
|
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 {
|
derivedStateOf {
|
||||||
scrollController.showFab(state.messages.isNotEmpty())
|
!timelineState.isHistorical && scrollController.showFab(displayedMessages.isNotEmpty())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||||
@@ -439,7 +450,9 @@ fun ChatScreen(
|
|||||||
!showSubagentInspectionSheet &&
|
!showSubagentInspectionSheet &&
|
||||||
state.btwState == null &&
|
state.btwState == null &&
|
||||||
viewingImage == null &&
|
viewingImage == null &&
|
||||||
!connectorsState.isVisible
|
!connectorsState.isVisible &&
|
||||||
|
!timelineState.isOpen &&
|
||||||
|
connectionOperationState.operation == null
|
||||||
|
|
||||||
// Lifecycle effects, permissions, session switching, auto-scroll, errors
|
// Lifecycle effects, permissions, session switching, auto-scroll, errors
|
||||||
ChatLifecycleEffects(
|
ChatLifecycleEffects(
|
||||||
@@ -530,7 +543,10 @@ fun ChatScreen(
|
|||||||
var showSessionMenu by remember { mutableStateOf(false) }
|
var showSessionMenu by remember { mutableStateOf(false) }
|
||||||
Box {
|
Box {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { showSessionMenu = true },
|
onClick = {
|
||||||
|
settingsViewModel.refreshKeepConnectedInBackground()
|
||||||
|
showSessionMenu = true
|
||||||
|
},
|
||||||
modifier = Modifier.testTag("chat_session_menu_button"),
|
modifier = Modifier.testTag("chat_session_menu_button"),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -542,6 +558,22 @@ fun ChatScreen(
|
|||||||
expanded = showSessionMenu,
|
expanded = showSessionMenu,
|
||||||
onDismissRequest = { showSessionMenu = false },
|
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)
|
// Chat search lives in this overflow menu (moved from the top bar)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = {
|
text = {
|
||||||
@@ -566,6 +598,7 @@ fun ChatScreen(
|
|||||||
showSessionMenu = false
|
showSessionMenu = false
|
||||||
viewModel.toggleSearch()
|
viewModel.toggleSearch()
|
||||||
},
|
},
|
||||||
|
enabled = !timelineState.isHistorical,
|
||||||
modifier = Modifier.testTag("chat_menu_search"),
|
modifier = Modifier.testTag("chat_menu_search"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -583,6 +616,10 @@ fun ChatScreen(
|
|||||||
},
|
},
|
||||||
modifier = Modifier.testTag("chat_menu_session_integrations"),
|
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(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -692,35 +740,44 @@ fun ChatScreen(
|
|||||||
// Full-bleed chat renderer (issue #866) — the single chat
|
// Full-bleed chat renderer (issue #866) — the single chat
|
||||||
// surface since the bubble renderer was removed.
|
// surface since the bubble renderer was removed.
|
||||||
FullBleedChatList(
|
FullBleedChatList(
|
||||||
messages = state.messages,
|
messages = displayedMessages,
|
||||||
streamingState = streamingState,
|
streamingState = if (timelineState.isHistorical) StreamingState() else streamingState,
|
||||||
isAgentTyping = state.isAgentTyping,
|
isAgentTyping = state.isAgentTyping && !timelineState.isHistorical,
|
||||||
searchState = searchState,
|
searchState = searchState,
|
||||||
typingEffectEnabled = state.typingEffectEnabled,
|
typingEffectEnabled = state.typingEffectEnabled && !timelineState.isHistorical,
|
||||||
typingEffectDelayMs = state.typingEffectDelayMs,
|
typingEffectDelayMs = state.typingEffectDelayMs,
|
||||||
messageStatsEnabled = state.messageStatsEnabled,
|
messageStatsEnabled = state.messageStatsEnabled,
|
||||||
showUserMessageTokens = state.showUserMessageTokens,
|
showUserMessageTokens = state.showUserMessageTokens,
|
||||||
showAssistantMessageTokens = state.showAssistantMessageTokens,
|
showAssistantMessageTokens = state.showAssistantMessageTokens,
|
||||||
showTokensPerSecond = state.showTokensPerSecond,
|
showTokensPerSecond = state.showTokensPerSecond,
|
||||||
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
|
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
|
||||||
isLoading = state.isLoading,
|
isLoading = state.isLoading && !timelineState.isHistorical,
|
||||||
isLoadingOlder = state.isLoadingOlder,
|
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,
|
listState = listState,
|
||||||
scrollController = scrollController,
|
scrollController = scrollController,
|
||||||
lastAnimatedMessageId = lastAnimatedMessageId,
|
lastAnimatedMessageId = lastAnimatedMessageId,
|
||||||
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
|
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
clarifyRequest = state.clarifyRequest,
|
clarifyRequest = state.clarifyRequest.takeUnless { timelineState.isHistorical },
|
||||||
onRespondClarify = viewModel::respondToClarify,
|
onRespondClarify = viewModel::respondToClarify,
|
||||||
onRespondClarifyBatch = viewModel::respondToClarifyBatch,
|
onRespondClarifyBatch = viewModel::respondToClarifyBatch,
|
||||||
onDismissClarify = viewModel::dismissClarify,
|
onDismissClarify = viewModel::dismissClarify,
|
||||||
vaultUnlockPrompt = state.vaultUnlockPrompt,
|
vaultUnlockPrompt = state.vaultUnlockPrompt.takeUnless { timelineState.isHistorical },
|
||||||
onRespondVaultUnlock = viewModel::respondToVaultUnlock,
|
onRespondVaultUnlock = viewModel::respondToVaultUnlock,
|
||||||
onDismissVaultUnlock = viewModel::dismissVaultUnlock,
|
onDismissVaultUnlock = viewModel::dismissVaultUnlock,
|
||||||
vaultSaveLoginPrompt = state.vaultSaveLoginPrompt,
|
vaultSaveLoginPrompt = state.vaultSaveLoginPrompt.takeUnless { timelineState.isHistorical },
|
||||||
onRespondVaultSaveLogin = viewModel::respondToVaultSaveLogin,
|
onRespondVaultSaveLogin = viewModel::respondToVaultSaveLogin,
|
||||||
onDismissVaultSaveLogin = viewModel::dismissVaultSaveLogin,
|
onDismissVaultSaveLogin = viewModel::dismissVaultSaveLogin,
|
||||||
vaultCodePrompt = state.vaultCodePrompt,
|
vaultCodePrompt = state.vaultCodePrompt.takeUnless { timelineState.isHistorical },
|
||||||
onRespondVaultCode = viewModel::respondToVaultCode,
|
onRespondVaultCode = viewModel::respondToVaultCode,
|
||||||
onDismissVaultCode = viewModel::dismissVaultCode,
|
onDismissVaultCode = viewModel::dismissVaultCode,
|
||||||
onSaveAttachment = onSaveAttachment,
|
onSaveAttachment = onSaveAttachment,
|
||||||
@@ -730,14 +787,18 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Loading overlay
|
// 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
|
// Resume-exhausted overlay — explicit error + Retry instead
|
||||||
// of an infinite spinner (desktop parity).
|
// of an infinite spinner (desktop parity).
|
||||||
|
if (!timelineState.isHistorical) {
|
||||||
ChatResumeErrorOverlay(
|
ChatResumeErrorOverlay(
|
||||||
errorMessage = state.resumeError,
|
errorMessage = state.resumeError,
|
||||||
onRetry = viewModel::retryResumeSession,
|
onRetry = viewModel::retryResumeSession,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Scroll-to-bottom FAB (issue #682): shows while follow is
|
// Scroll-to-bottom FAB (issue #682): shows while follow is
|
||||||
// paused and renders the unseen-message badge.
|
// paused and renders the unseen-message badge.
|
||||||
@@ -749,12 +810,14 @@ fun ChatScreen(
|
|||||||
|
|
||||||
// Reaction heartsanimation (purely cosmetic — fades out
|
// Reaction heartsanimation (purely cosmetic — fades out
|
||||||
// automatically after the ViewModel clears the state)
|
// automatically after the ViewModel clears the state)
|
||||||
|
if (!timelineState.isHistorical) {
|
||||||
key(state.reactionTriggerId) {
|
key(state.reactionTriggerId) {
|
||||||
ReactionHeartsOverlay(
|
ReactionHeartsOverlay(
|
||||||
reactionKind = state.reactionKind,
|
reactionKind = state.reactionKind,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Context-window meter (used / full) — sits above the composer so it
|
// Context-window meter (used / full) — sits above the composer so it
|
||||||
// stays visible while the session is active, grouped with the model
|
// stays visible while the session is active, grouped with the model
|
||||||
@@ -785,7 +848,7 @@ fun ChatScreen(
|
|||||||
isListening = mediaLaunchers.isListening,
|
isListening = mediaLaunchers.isListening,
|
||||||
isAgentTyping = state.isAgentTyping,
|
isAgentTyping = state.isAgentTyping,
|
||||||
isConnected = state.isConnected,
|
isConnected = state.isConnected,
|
||||||
isSessionReady = state.isSessionReady,
|
isSessionReady = state.isSessionReady && !timelineState.isHistorical,
|
||||||
sessionPreparationFailed = state.resumeError != null,
|
sessionPreparationFailed = state.resumeError != null,
|
||||||
commandCatalog = state.commandCatalog,
|
commandCatalog = state.commandCatalog,
|
||||||
slashUsageCounts = state.slashUsageCounts,
|
slashUsageCounts = state.slashUsageCounts,
|
||||||
@@ -813,7 +876,7 @@ fun ChatScreen(
|
|||||||
onModelTap = { viewModel.openModelPicker() },
|
onModelTap = { viewModel.openModelPicker() },
|
||||||
onReasoningTap = { level -> viewModel.setReasoningLevel(level) },
|
onReasoningTap = { level -> viewModel.setReasoningLevel(level) },
|
||||||
canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning,
|
canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning,
|
||||||
supportsReasoning = state.currentModelCapabilities?.reasoning,
|
supportsReasoning = state.currentModelCapabilities?.reasoningSupport,
|
||||||
fastMode = state.fastMode,
|
fastMode = state.fastMode,
|
||||||
fastSupported = state.currentModelCapabilities?.fast == true,
|
fastSupported = state.currentModelCapabilities?.fast == true,
|
||||||
isFastModeChanging = state.isFastModeChanging,
|
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) {
|
if (showReloginDialog) {
|
||||||
ReloginDialog(
|
ReloginDialog(
|
||||||
onDismiss = { showReloginDialog = false },
|
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(
|
SessionIntegrationsSheet(
|
||||||
uiState = connectorsState,
|
uiState = connectorsState,
|
||||||
sessionId = connectorsState.sessionId,
|
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.
|
* Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s.
|
||||||
*
|
*
|
||||||
* Verbatim extraction of ChatViewModel.mapServerMessages (corral 2 of the
|
* Reads the supplied transcript snapshot and recovers notification identity through
|
||||||
* 2026-08 delegate refactor). Pure with respect to the VM: it reads the live
|
* ReplyNotificationTracker's synchronized lookup. Safe on the history dispatcher;
|
||||||
* message list supplied by the caller and one paging-mode flag — no state
|
* it never mutates ViewModel state or dismisses a notification.
|
||||||
* mutation, no I/O beyond URL construction.
|
|
||||||
*/
|
*/
|
||||||
internal fun mapServerMessages(
|
internal fun mapServerMessages(
|
||||||
sessionId: String,
|
sessionId: String,
|
||||||
@@ -22,17 +21,14 @@ internal fun mapServerMessages(
|
|||||||
latestPaging: Boolean,
|
latestPaging: Boolean,
|
||||||
liveMessages: List<ChatMessage>,
|
liveMessages: List<ChatMessage>,
|
||||||
isPagingOlder: Boolean = false,
|
isPagingOlder: Boolean = false,
|
||||||
|
stableRowIds: Boolean = latestPaging,
|
||||||
context: android.content.Context? = null,
|
context: android.content.Context? = null,
|
||||||
): List<ChatMessage> {
|
): List<ChatMessage> {
|
||||||
val existingReasoningMap =
|
val existingById = liveMessages.associateBy { it.canonicalRestId ?: it.id }
|
||||||
liveMessages
|
|
||||||
.filter { it.reasoningText.isNotBlank() }
|
|
||||||
.associateBy { it.content }
|
|
||||||
|
|
||||||
val liveByExactId =
|
val liveByExactId =
|
||||||
liveMessages
|
liveMessages
|
||||||
.filter { !it.completionId.isNullOrBlank() }
|
.filter { !it.completionId.isNullOrBlank() }
|
||||||
.associateBy { it.id }
|
.associateBy { it.canonicalRestId ?: it.id }
|
||||||
|
|
||||||
val unmappedLiveWsAssistants =
|
val unmappedLiveWsAssistants =
|
||||||
if (isPagingOlder) {
|
if (isPagingOlder) {
|
||||||
@@ -40,112 +36,92 @@ internal fun mapServerMessages(
|
|||||||
} else {
|
} else {
|
||||||
liveMessages
|
liveMessages
|
||||||
.filter {
|
.filter {
|
||||||
it.role == MessageRole.ASSISTANT && !it.id.startsWith("rest-") &&
|
it.role == MessageRole.ASSISTANT && it.canonicalRestId == null &&
|
||||||
!it.completionId.isNullOrBlank()
|
!it.completionId.isNullOrBlank()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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}"
|
||||||
|
}
|
||||||
|
|
||||||
val wsCompletionIdByRestIndex = mutableMapOf<Int, String>()
|
val wsCompletionIdByRestIndex = mutableMapOf<Int, String>()
|
||||||
if (!isPagingOlder) {
|
if (!isPagingOlder) {
|
||||||
if (unmappedLiveWsAssistants.isNotEmpty()) {
|
// #129: reserve durable identity before matching any repeated live prose.
|
||||||
val remainingWs = unmappedLiveWsAssistants.toMutableList()
|
// REST-only recovery remains fail-closed when the target row is absent.
|
||||||
for (i in messages.indices.reversed()) {
|
val activeTarget = ReplyNotificationTracker.getActiveTarget(context)?.takeIf { it.sessionId == sessionId }
|
||||||
if (remainingWs.isEmpty()) break
|
val durableTarget = activeTarget?.takeIf { it.serverMessageId != null }
|
||||||
val m = messages[i]
|
if (durableTarget != null) {
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 exactIndex =
|
val exactIndex =
|
||||||
messages.indexOfFirst { message ->
|
messages.indexOfFirst { message ->
|
||||||
message.id == activeTarget.serverMessageId &&
|
message.id == durableTarget.serverMessageId &&
|
||||||
message.role.equals("assistant", ignoreCase = true)
|
message.role.equals("assistant", ignoreCase = true)
|
||||||
}
|
}
|
||||||
if (exactIndex >= 0 && !liveByExactId.containsKey("rest-$sessionId-${activeTarget.serverMessageId}")) {
|
if (exactIndex >= 0 && !liveByExactId.containsKey(restIdAt(exactIndex))) {
|
||||||
wsCompletionIdByRestIndex[exactIndex] = activeTarget.completionId
|
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
|
// Reasoning follows the same reserved identities as completions, never a reusable text lookup.
|
||||||
// stream was the only source of `toolName`. Match each REST tool row
|
val assistants = liveMessages.filter { it.role == MessageRole.ASSISTANT }
|
||||||
// to its WS counterpart by RESULT CONTENT (not position — pagination
|
val assistantsByRestId = assistants.groupBy { it.canonicalRestId }
|
||||||
// and mixed cache state make positional mapping misalign, leaving
|
val assistantsByCompletion = assistants.groupBy { it.completionId }
|
||||||
// the newest call with a null name → generic "tool" bubble). When a
|
val reasoningSources = arrayOfNulls<ChatMessage>(messages.size)
|
||||||
// match is found the live message is reused wholesale (same id +
|
messages.forEachIndexed { index, row ->
|
||||||
// toolName + rich payload), so persistence upserts the same row
|
if (row.role?.lowercase() in listOf("user", "system", "tool")) return@forEachIndexed
|
||||||
// instead of accumulating a second `rest-` copy in Room. Issue #771.
|
val exact = assistantsByRestId[restIdAt(index)].orEmpty()
|
||||||
val liveToolByResult = linkedMapOf<String, ChatMessage>()
|
val completion = liveByExactId[restIdAt(index)]?.completionId ?: wsCompletionIdByRestIndex[index]
|
||||||
liveMessages
|
val candidates = exact + completion?.let { assistantsByCompletion[it] }.orEmpty()
|
||||||
.filter { it.role == MessageRole.TOOL }
|
reasoningSources[index] = candidates.firstOrNull { it.reasoningText.isNotBlank() } ?: candidates.firstOrNull()
|
||||||
.sortedBy { it.id.startsWith("rest-") } // prefer live WS copies
|
|
||||||
.forEach { msg ->
|
|
||||||
canonicalToolResultKey(msg.content)?.let { key ->
|
|
||||||
liveToolByResult.putIfAbsent(key, msg)
|
|
||||||
}
|
}
|
||||||
|
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>()
|
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 ->
|
messages.forEachIndexed { index, msg ->
|
||||||
val role =
|
val role =
|
||||||
when (msg.role?.lowercase()) {
|
when (msg.role?.lowercase()) {
|
||||||
@@ -154,49 +130,41 @@ internal fun mapServerMessages(
|
|||||||
"tool" -> MessageRole.TOOL
|
"tool" -> MessageRole.TOOL
|
||||||
else -> MessageRole.ASSISTANT
|
else -> MessageRole.ASSISTANT
|
||||||
}
|
}
|
||||||
val globalIndex = offset + index
|
|
||||||
// Issue #859: under newest-anchored paging use the server's
|
// Issue #859: under newest-anchored paging use the server's
|
||||||
// AUTOINCREMENT row id as the stable key — from-end positions shift
|
// AUTOINCREMENT row id as the stable key — from-end positions shift
|
||||||
// as the transcript grows and would collide across hydrations
|
// as the transcript grows and would collide across hydrations
|
||||||
// (distinctBy would silently drop the newest copy). Legacy paging
|
// (distinctBy would silently drop the newest copy). Legacy paging
|
||||||
// keeps the absolute-position key its count-based sync math needs.
|
// keeps the absolute-position key its count-based sync math needs.
|
||||||
val restId =
|
val restId = restIdAt(index)
|
||||||
if (latestPaging) {
|
|
||||||
msg.id?.let { "rest-$sessionId-$it" } ?: "rest-$sessionId-$globalIndex"
|
|
||||||
} else {
|
|
||||||
"rest-$sessionId-$globalIndex"
|
|
||||||
}
|
|
||||||
val timestamp =
|
val timestamp =
|
||||||
msg.timestampText
|
msg.timestampText
|
||||||
?.toDoubleOrNull()
|
?.toDoubleOrNull()
|
||||||
?.times(1000)
|
?.times(1000)
|
||||||
?.toLong()
|
?.toLong()
|
||||||
|
?: existingById[restId]?.timestamp
|
||||||
?: System.currentTimeMillis()
|
?: System.currentTimeMillis()
|
||||||
|
|
||||||
val rawContent = msg.contentText
|
val rawContent = msg.displayContentText ?: msg.contentText
|
||||||
val rowReasoning =
|
val rowReasoning =
|
||||||
if (msg.reasoningText.isNotBlank()) {
|
msg.reasoningText.ifBlank {
|
||||||
msg.reasoningText
|
if (role == MessageRole.ASSISTANT) {
|
||||||
|
reasoningSources[index]?.reasoningText.orEmpty()
|
||||||
} else {
|
} else {
|
||||||
existingReasoningMap[rawContent]?.reasoningText.orEmpty()
|
existingById[restId]?.reasoningText.orEmpty()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty assistant row — two cases stored by the gateway:
|
// Retain canonical reasoning rows across page boundaries; hide only textless placeholders.
|
||||||
// 1. Reasoning-only: thinking-model split storage (content = "",
|
if (role == MessageRole.ASSISTANT && rawContent.isBlank() && rowReasoning.isBlank()) return@forEachIndexed
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
var attachments: List<Attachment>? = null
|
||||||
if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
|
if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
|
||||||
val items = HostMediaExtractor.extract(rawContent)
|
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 =
|
val completionId =
|
||||||
if (role == MessageRole.ASSISTANT) {
|
if (role == MessageRole.ASSISTANT) {
|
||||||
liveByExactId[restId]?.completionId ?: wsCompletionIdByRestIndex[index]
|
liveByExactId[restId]?.completionId ?: wsCompletionIdByRestIndex[index]
|
||||||
@@ -265,7 +205,7 @@ internal fun mapServerMessages(
|
|||||||
id = restId,
|
id = restId,
|
||||||
role = role,
|
role = role,
|
||||||
content = finalContent,
|
content = finalContent,
|
||||||
reasoningText = finalReasoning,
|
reasoningText = rowReasoning,
|
||||||
toolCallId = msg.toolCallId,
|
toolCallId = msg.toolCallId,
|
||||||
attachments = attachments,
|
attachments = attachments,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
@@ -277,17 +217,12 @@ internal fun mapServerMessages(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A reasoning-only row with no following answer (interrupted turn):
|
// REST echoes must not reserve a match before the richer WS copy of that tool.
|
||||||
// don't drop the trace — attach it to the last assistant message.
|
val liveTools = liveMessages.filter { it.role == MessageRole.TOOL && !it.id.startsWith("rest-") }
|
||||||
if (pendingReasoning != null) {
|
val mappedTools = mapped.filter { it.role == MessageRole.TOOL }
|
||||||
val lastAssistantIdx = mapped.indexOfLast { it.role == MessageRole.ASSISTANT }
|
val matches = matchTranscriptMessages(mappedTools, liveTools)
|
||||||
if (lastAssistantIdx >= 0) {
|
val toolsById = mappedTools.indices.associate { index -> mappedTools[index].id to matches[index] }
|
||||||
val target = mapped[lastAssistantIdx]
|
return mapped.map { message ->
|
||||||
if (target.reasoningText.isBlank()) {
|
toolsById[message.id]?.copy(restId = message.canonicalRestId) ?: message
|
||||||
mapped[lastAssistantIdx] = target.copy(reasoningText = pendingReasoning)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return mapped
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,38 @@ import kotlinx.serialization.json.JsonElement
|
|||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
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.
|
* Canonical comparison key for a tool message's result payload.
|
||||||
*
|
*
|
||||||
@@ -75,48 +107,120 @@ internal fun canonicalToolResultKey(content: String): String? {
|
|||||||
internal fun sameLogicalMessage(
|
internal fun sameLogicalMessage(
|
||||||
a: ChatMessage,
|
a: ChatMessage,
|
||||||
b: ChatMessage,
|
b: ChatMessage,
|
||||||
|
): 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 {
|
): Boolean {
|
||||||
if (a.role != b.role) return false
|
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
|
||||||
|
}
|
||||||
|
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.role == MessageRole.TOOL) {
|
||||||
// Issue #842: prefer the gateway's tool call id when both sides carry
|
if (a.toolCallId.isNotBlank() && b.toolCallId.isNotBlank()) return a.toolCallId == b.toolCallId
|
||||||
// it — the REST transcript stores `tool_call_id` and the live WS
|
val key = toolKey(a.content)
|
||||||
// bubble keeps it from `tool.start`. Content canonicalization cannot
|
return key != null && key == toolKey(b.content)
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
val ka = canonicalToolResultKey(a.content)
|
val ta = trimmed.getOrPut(a.content) { a.content.trim() }
|
||||||
val kb = canonicalToolResultKey(b.content)
|
val tb = trimmed.getOrPut(b.content) { b.content.trim() }
|
||||||
return ka != null && ka == kb
|
|
||||||
}
|
|
||||||
val ta = a.content.trim()
|
|
||||||
val tb = b.content.trim()
|
|
||||||
if (ta == tb) return true
|
if (ta == tb) return true
|
||||||
// Attachment dedupe: when the user sends an image/file, the backend
|
if (a.role == MessageRole.USER &&
|
||||||
// persists an ENRICHED copy of the prompt — the real caption plus
|
captions.getOrPut(ta) { stripAttachmentRefLines(ta) } ==
|
||||||
// `@image:`/`@file:` reference lines and a `[screenshot]` marker
|
captions.getOrPut(tb) { stripAttachmentRefLines(tb) }
|
||||||
// (tui_gateway _build_image_ref_message / run_agent flattening). The
|
) {
|
||||||
// optimistic bubble carries the raw caption only, so the exact compare
|
return true
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
// Issue #842: a seal race can leave the live orphan a few trailing tokens
|
return ta.length >= 40 && tb.length >= 40 && (tb.startsWith(ta) || ta.startsWith(tb))
|
||||||
// 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
|
/** Consume each occurrence once, reserving exact IDs before considering content echoes. */
|
||||||
// chars) so a short reply can never be swallowed by a longer message
|
internal fun matchTranscriptMessages(
|
||||||
// that merely starts with it.
|
incoming: List<ChatMessage>,
|
||||||
return ta.length >= 40 &&
|
existing: List<ChatMessage>,
|
||||||
tb.length >= 40 &&
|
comparison: TranscriptComparison = TranscriptComparison(),
|
||||||
(tb.startsWith(ta) || ta.startsWith(tb))
|
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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.
|
* run_agent). Blank lines left behind are dropped too.
|
||||||
*/
|
*/
|
||||||
internal fun stripAttachmentRefLines(content: String): String =
|
internal fun stripAttachmentRefLines(content: String): String =
|
||||||
content
|
stripGatewayAttachedContext(content)
|
||||||
.lines()
|
.lines()
|
||||||
.map { it.trim() }
|
.map { it.trim() }
|
||||||
.filterNot { line ->
|
.filterNot { line ->
|
||||||
@@ -144,53 +248,152 @@ internal fun stripAttachmentRefLines(content: String): String =
|
|||||||
* same call twice. Drop the `rest-` copy whenever a WS copy of the same
|
* same call twice. Drop the `rest-` copy whenever a WS copy of the same
|
||||||
* logical message exists (issue #771).
|
* logical message exists (issue #771).
|
||||||
*/
|
*/
|
||||||
internal fun dedupeCachedMessages(messages: List<ChatMessage>): List<ChatMessage> {
|
internal fun dedupeCachedMessages(
|
||||||
val rest = messages.filter { it.id.startsWith("rest-") }
|
messages: List<ChatMessage>,
|
||||||
if (rest.isEmpty()) return messages
|
confirmedOnly: Boolean = false,
|
||||||
val nonRest = messages.filterNot { it.id.startsWith("rest-") }
|
): List<ChatMessage> {
|
||||||
if (nonRest.isEmpty()) return messages
|
val unique = messages.dedupeById()
|
||||||
val keepRest = rest.filter { restMsg -> nonRest.none { sameLogicalMessage(it, restMsg) } }
|
val rest = unique.filter { it.id.startsWith("rest-") }
|
||||||
return (nonRest + keepRest).sortedBy { it.timestamp }
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Cache pages enrich confirmed identities without changing keys already on screen (#859). */
|
||||||
* A transcript reload must NOT yank live WS bubbles the server has not
|
internal fun mergeCachedTranscriptPage(
|
||||||
* persisted yet. The gateway stores a tool row only once the tool
|
page: List<ChatMessage>,
|
||||||
* COMPLETES server-side, so a reload that lands while a tool is running
|
current: List<ChatMessage>,
|
||||||
* (app background/foreground mid-turn, reconnect re-resume, pull-refresh)
|
): List<ChatMessage> {
|
||||||
* returns a page without the tool row — replacing the list outright made
|
val currentById = current.associateBy { it.id }
|
||||||
* the in-flight tool bubble vanish and left tool.complete with no RUNNING
|
// Legacy cached UUIDs may lack an alias. Restore a known alias before page-local matching
|
||||||
* message to update (issue #771).
|
// so an older identical REST row cannot claim that UUID again.
|
||||||
*
|
val incoming =
|
||||||
* Merge instead of replace: append any current message the REST page does
|
dedupeCachedMessages(
|
||||||
* not already cover (checked by id AND logical content via
|
page.map { message ->
|
||||||
* [sameLogicalMessage]) and keep chronological order.
|
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(
|
internal fun mergeTranscriptWithLive(
|
||||||
restMessages: List<ChatMessage>,
|
restMessages: List<ChatMessage>,
|
||||||
currentMessages: List<ChatMessage>,
|
currentMessages: List<ChatMessage>,
|
||||||
|
chronological: Boolean = true,
|
||||||
|
preserveLiveIds: Boolean = false,
|
||||||
): List<ChatMessage> {
|
): List<ChatMessage> {
|
||||||
val dedupedRest = restMessages.dedupeById()
|
val incoming = restMessages.dedupeById()
|
||||||
val dedupedCurrent = currentMessages.dedupeById()
|
val current = currentMessages.dedupeById()
|
||||||
// User rows can carry live/cache-only metadata (for example whether the
|
val matches = matchTranscriptMessages(incoming, current, allowAssistantContentMatches = chronological)
|
||||||
// bubble continues the active turn). Keep that richer copy when REST
|
val consumed = matches.mapNotNull { it?.id }.toSet()
|
||||||
// returns the same logical row.
|
val merged =
|
||||||
val currentUsers = dedupedCurrent.filter { it.role == MessageRole.USER }.toMutableList()
|
incoming.mapIndexed { index, message ->
|
||||||
val mergedRest =
|
val match = matches[index]
|
||||||
dedupedRest.map { rest ->
|
// Keep local user metadata and stable IDs already used by the renderer.
|
||||||
if (rest.role != MessageRole.USER) return@map rest
|
when {
|
||||||
val matchIndex =
|
match?.role == MessageRole.USER -> {
|
||||||
currentUsers.indexOfFirst { it.id == rest.id }.takeIf { it >= 0 }
|
match.copy(
|
||||||
?: currentUsers.indexOfFirst { sameLogicalMessage(rest, it) }
|
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
|
||||||
if (matchIndex >= 0) currentUsers.removeAt(matchIndex) else rest
|
)
|
||||||
}
|
}
|
||||||
val restIds = dedupedRest.map { it.id }.toSet()
|
|
||||||
val liveTail =
|
preserveLiveIds && match != null && !match.id.startsWith("rest-") -> {
|
||||||
dedupedCurrent.filter { old ->
|
match.copy(
|
||||||
old.id !in restIds && mergedRest.none { sameLogicalMessage(it, old) }
|
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 transcript =
|
||||||
|
if (chronological) {
|
||||||
|
current.filterNot { it.id in consumed } + merged
|
||||||
|
} else {
|
||||||
|
merged + current.filterNot { it.id in consumed }
|
||||||
|
}
|
||||||
|
// #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
|
||||||
}
|
}
|
||||||
if (liveTail.isEmpty()) return mergedRest.dedupeById()
|
|
||||||
return (mergedRest + liveTail).dedupeById().sortedBy { it.timestamp }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Merge a REST page without matching it against already-settled transcript rows. */
|
/** Merge a REST page without matching it against already-settled transcript rows. */
|
||||||
@@ -202,7 +405,7 @@ internal fun mergeIncrementalTranscriptPage(
|
|||||||
): List<ChatMessage> {
|
): List<ChatMessage> {
|
||||||
val settledEnd =
|
val settledEnd =
|
||||||
currentMessages.indexOfLast { message ->
|
currentMessages.indexOfLast { message ->
|
||||||
serverMessageIndex(message.id, sessionId)?.let { it < offset } == true
|
serverMessageIndex(message.canonicalRestId ?: message.id, sessionId)?.let { it < offset } == true
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
currentMessages.take(settledEnd + 1) +
|
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.StatusUpdate -> onStatusUpdate(state, streamingState)
|
||||||
|
|
||||||
|
is WsEvent.ConnectionRequest,
|
||||||
|
is WsEvent.ConnectionUpdate,
|
||||||
|
-> ReducerResult(state, streamingState)
|
||||||
|
|
||||||
is WsEvent.Unknown -> onUnknown(state, streamingState)
|
is WsEvent.Unknown -> onUnknown(state, streamingState)
|
||||||
|
|
||||||
// SessionInfo is a no-op in the original code
|
// 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.CodeTerminalText
|
||||||
import com.m57.hermescontrol.theme.HermesStatusColors
|
import com.m57.hermescontrol.theme.HermesStatusColors
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
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.DiffViewCard
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.FileViewCard
|
||||||
import com.m57.hermescontrol.ui.chat.components.highlightSyntax
|
import com.m57.hermescontrol.ui.chat.components.highlightSyntax
|
||||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||||
import com.m57.hermescontrol.ui.chat.tool.ToolView
|
import com.m57.hermescontrol.ui.chat.tool.ToolView
|
||||||
@@ -423,6 +425,11 @@ private fun ExpandedToolContent(
|
|||||||
diffText = view.inlineDiff,
|
diffText = view.inlineDiff,
|
||||||
filePath = view.diffPath,
|
filePath = view.diffPath,
|
||||||
)
|
)
|
||||||
|
} else if (view.fileContent != null) {
|
||||||
|
FileViewCard(
|
||||||
|
content = view.fileContent,
|
||||||
|
filePath = view.filePath,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Search hits ──
|
// ── Search hits ──
|
||||||
@@ -478,7 +485,7 @@ private fun ExpandedToolContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Plain detail body ──
|
// ── Plain detail body ──
|
||||||
if (view.detail.isNotBlank() && view.inlineDiff == null) {
|
if (view.detail.isNotBlank() && view.inlineDiff == null && view.fileContent == null) {
|
||||||
Text(
|
Text(
|
||||||
text = view.detail,
|
text = view.detail,
|
||||||
modifier =
|
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 ──
|
// ── Duration footer ──
|
||||||
view.durationLabel?.let {
|
view.durationLabel?.let {
|
||||||
Text(
|
Text(
|
||||||
@@ -629,42 +643,13 @@ private fun ToolRawJsonView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val clipboard = LocalClipboard.current
|
CodeTerminalCard(
|
||||||
val scope = rememberCoroutineScope()
|
textToCopy = displayText,
|
||||||
var copied by remember { mutableStateOf(false) }
|
modifier = modifier,
|
||||||
LaunchedEffect(copied) {
|
testTag = "tool_raw_json",
|
||||||
if (copied) {
|
title = "JSON",
|
||||||
delay(2000)
|
copyContentDescription = stringResource(R.string.content_desc_copy),
|
||||||
copied = false
|
headerActions = {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
modifier =
|
|
||||||
modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.testTag("tool_raw_json"),
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
color = CodeTerminalBg,
|
|
||||||
border = BorderStroke(1.dp, CodeTerminalBorder),
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
Row(
|
|
||||||
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) {
|
if (isFormatDifferent) {
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
@@ -685,28 +670,8 @@ private fun ToolRawJsonView(
|
|||||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
.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 {
|
SelectionContainer {
|
||||||
Text(
|
Text(
|
||||||
text = highlighted,
|
text = highlighted,
|
||||||
@@ -724,7 +689,6 @@ private fun ToolRawJsonView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CopyButton(
|
private fun CopyButton(
|
||||||
|
|||||||
@@ -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
|
* 5. **Unread indicator** — when follow is paused, [pendingCount] accumulates the
|
||||||
* number of tail updates; shows on the FAB. Tapping the FAB resumes following
|
* number of tail updates; shows on the FAB. Tapping the FAB resumes following
|
||||||
* and clears the count.
|
* and clears the count.
|
||||||
* 6. **Paging anchor preservation** — capture the first visible item + offset
|
* 6. **Paging anchors** — the full-bleed renderer preserves the live lazy row
|
||||||
* with [captureAnchor] before prepending older history, then
|
* key and offset when older history is inserted; this controller owns no
|
||||||
* [restoreAnchor] after insertion so the same content stays under the eye.
|
* fetch-time anchor or animated compensation.
|
||||||
* 7. **Serialized scroll commands** — every scroll (send, FAB, session switch,
|
* 7. **Serialized scroll commands** — every scroll (send, FAB, session switch,
|
||||||
* search navigation, auto-follow) is launched from [scope] so animations
|
* search navigation, auto-follow) is launched from [scope] so animations
|
||||||
* don't compete.
|
* don't compete.
|
||||||
@@ -158,11 +158,11 @@ class ChatScrollController(
|
|||||||
* item count still hasn't caught up (rare, heavy layout), wait briefly
|
* item count still hasn't caught up (rare, heavy layout), wait briefly
|
||||||
* via snapshotFlow on the layout info.
|
* 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 twice: first for recomposition, second for layout.
|
||||||
yield()
|
yield()
|
||||||
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
|
// 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.
|
// new item was laid out between the scroll and now), do one more pass.
|
||||||
if (!listState.isAtBottom(bottomPixelTolerance)) {
|
if (!listState.isAtBottom(bottomPixelTolerance)) {
|
||||||
@@ -171,7 +171,7 @@ class ChatScrollController(
|
|||||||
snapshotFlow { listState.layoutInfo.totalItemsCount }
|
snapshotFlow { listState.layoutInfo.totalItemsCount }
|
||||||
.first { it > 0 }
|
.first { it > 0 }
|
||||||
}
|
}
|
||||||
listState.scrollToBottom(animated = false)
|
listState.scrollToBottom(animated = animated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,37 +182,24 @@ class ChatScrollController(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
isProgrammaticScroll = true
|
isProgrammaticScroll = true
|
||||||
try {
|
try {
|
||||||
listState.scrollToBottom(animated = animated)
|
scrollToBottomAwaitingLayout(animated = animated)
|
||||||
} finally {
|
} finally {
|
||||||
isProgrammaticScroll = false
|
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. */
|
/** FAB tap: resume following + clear unread. */
|
||||||
fun resumeFollowing() = jumpToBottom(animated = true)
|
fun resumeFollowing() = jumpToBottom(animated = true)
|
||||||
|
|
||||||
/** True when the FAB should be visible (not following bottom + content exists). */
|
/** True when the FAB should be visible (not following bottom + content exists). */
|
||||||
fun showFab(contentPresent: Boolean): Boolean = !isFollowingBottom && contentPresent
|
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]).
|
* Navigate to a search match (serialized through [scope]).
|
||||||
*
|
*
|
||||||
@@ -258,7 +245,7 @@ fun tailContentKey(
|
|||||||
clarifyRequest: Any?,
|
clarifyRequest: Any?,
|
||||||
): Any =
|
): Any =
|
||||||
listOf(
|
listOf(
|
||||||
messages.size,
|
messages.lastOrNull(),
|
||||||
streamingMessage?.hashCode() ?: 0,
|
streamingMessage?.hashCode() ?: 0,
|
||||||
isThinking,
|
isThinking,
|
||||||
subagentIndicators.size,
|
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
|
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.background
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
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.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.m57.hermescontrol.theme.CodeDiffAddBg
|
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.CodeDiffDeleteText
|
||||||
import com.m57.hermescontrol.theme.CodeDiffHunkBg
|
import com.m57.hermescontrol.theme.CodeDiffHunkBg
|
||||||
import com.m57.hermescontrol.theme.CodeDiffHunkText
|
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.CodeTerminalMuted
|
||||||
import com.m57.hermescontrol.theme.CodeTerminalText
|
import com.m57.hermescontrol.theme.CodeTerminalText
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
enum class DiffLineType {
|
enum class DiffLineType {
|
||||||
FILE_HEADER,
|
FILE_HEADER,
|
||||||
@@ -169,17 +150,8 @@ fun DiffViewCard(
|
|||||||
onCopy: (String) -> Unit = {},
|
onCopy: (String) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
val parsed = remember(diffText, filePath) { parseDiffText(diffText, filePath) }
|
val parsed = remember(diffText, filePath) { parseDiffText(diffText, filePath) }
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
var copied by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
LaunchedEffect(copied) {
|
|
||||||
if (copied) {
|
|
||||||
delay(2000)
|
|
||||||
copied = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val displayLines =
|
val displayLines =
|
||||||
if (expanded || parsed.lines.size <= 16) {
|
if (expanded || parsed.lines.size <= 16) {
|
||||||
@@ -188,50 +160,16 @@ fun DiffViewCard(
|
|||||||
parsed.lines.take(16)
|
parsed.lines.take(16)
|
||||||
}
|
}
|
||||||
|
|
||||||
Surface(
|
CodeTerminalCard(
|
||||||
modifier =
|
textToCopy = diffText,
|
||||||
modifier
|
modifier = modifier,
|
||||||
.fillMaxWidth()
|
testTag = "diff_view_card",
|
||||||
.testTag("diff_view_card"),
|
title = parsed.filePath ?: "diff",
|
||||||
shape = RoundedCornerShape(8.dp),
|
icon = Icons.AutoMirrored.Filled.InsertDriveFile,
|
||||||
color = CodeTerminalBg,
|
clipLabel = "diff",
|
||||||
border = BorderStroke(1.dp, CodeTerminalBorder),
|
copyContentDescription = "Copy diff",
|
||||||
) {
|
onCopy = onCopy,
|
||||||
Column(modifier = Modifier.animateContentSize()) {
|
headerActions = {
|
||||||
// Header bar
|
|
||||||
Row(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
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",
|
|
||||||
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))
|
|
||||||
|
|
||||||
// Addition / Deletion badges
|
|
||||||
if (parsed.additionsCount > 0) {
|
if (parsed.additionsCount > 0) {
|
||||||
Text(
|
Text(
|
||||||
text = "+${parsed.additionsCount}",
|
text = "+${parsed.additionsCount}",
|
||||||
@@ -254,28 +192,13 @@ fun DiffViewCard(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.width(6.dp))
|
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),
|
totalLines = parsed.lines.size,
|
||||||
|
isExpanded = expanded,
|
||||||
|
onToggleExpand = { expanded = !expanded },
|
||||||
|
expandLabel = "Show full diff (${parsed.lines.size} lines)",
|
||||||
|
collapseLabel = "Collapse diff",
|
||||||
) {
|
) {
|
||||||
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 =
|
val verticalScrollModifier =
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
Modifier.heightIn(max = 400.dp).verticalScroll(rememberScrollState())
|
Modifier.heightIn(max = 400.dp).verticalScroll(rememberScrollState())
|
||||||
@@ -320,34 +243,95 @@ fun DiffViewCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Expand / Collapse footer button for diffs > 16 lines
|
/**
|
||||||
if (parsed.lines.size > 16) {
|
* Interactive file content view card for read_file & write_file tool outputs.
|
||||||
TextButton(
|
* Mirrors [DiffViewCard] with syntax highlighting and file path header.
|
||||||
onClick = { expanded = !expanded },
|
*/
|
||||||
modifier = Modifier.fillMaxWidth(),
|
@Composable
|
||||||
|
fun FileViewCard(
|
||||||
|
content: String,
|
||||||
|
filePath: String? = null,
|
||||||
|
onCopy: (String) -> Unit = {},
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
var expanded by remember { mutableStateOf(false) }
|
||||||
Text(
|
val lines = remember(content) { content.lines() }
|
||||||
text =
|
val displayText =
|
||||||
if (expanded) {
|
remember(content, expanded) {
|
||||||
"Collapse diff"
|
if (expanded || lines.size <= 16) {
|
||||||
|
content
|
||||||
} else {
|
} else {
|
||||||
"Show full diff (${parsed.lines.size} lines)"
|
lines.take(16).joinToString("\n")
|
||||||
},
|
}
|
||||||
style = MaterialTheme.typography.labelSmall,
|
}
|
||||||
|
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,
|
color = CodeTerminalMuted,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
Icon(
|
}
|
||||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
},
|
||||||
contentDescription = null,
|
totalLines = lines.size,
|
||||||
tint = CodeTerminalMuted,
|
isExpanded = expanded,
|
||||||
modifier = Modifier.size(16.dp),
|
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,58 +266,6 @@ fun CodeBlockCard(
|
|||||||
onCopy: (String) -> Unit,
|
onCopy: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
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),
|
|
||||||
) {
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Code content with syntax highlighting
|
|
||||||
val highlighted by produceState(
|
val highlighted by produceState(
|
||||||
initialValue = remember(code) { AnnotatedString(code) },
|
initialValue = remember(code) { AnnotatedString(code) },
|
||||||
key1 = code,
|
key1 = code,
|
||||||
@@ -327,6 +275,15 @@ fun CodeBlockCard(
|
|||||||
highlightSyntax(code)
|
highlightSyntax(code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CodeTerminalCard(
|
||||||
|
textToCopy = code,
|
||||||
|
modifier = modifier,
|
||||||
|
testTag = "code_block",
|
||||||
|
title = language?.takeIf { it.isNotBlank() }?.uppercase(),
|
||||||
|
onCopy = onCopy,
|
||||||
|
copyContentDescription = "Copy code",
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = highlighted,
|
text = highlighted,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
@@ -340,7 +297,6 @@ fun CodeBlockCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Hoisted token patterns for syntax highlighting — compiled once.
|
// Hoisted token patterns for syntax highlighting — compiled once.
|
||||||
private val HIGHLIGHT_TOKENS =
|
private val HIGHLIGHT_TOKENS =
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -12,9 +11,17 @@ import androidx.compose.material3.CircularProgressIndicator
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.Density
|
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.VaultCodePromptUi
|
||||||
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
||||||
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
|
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.ChatScrollController
|
||||||
import com.m57.hermescontrol.ui.chat.components.ClarifyBubble
|
import com.m57.hermescontrol.ui.chat.components.ClarifyBubble
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||||
@@ -93,6 +101,9 @@ fun FullBleedChatList(
|
|||||||
savingAttachmentPath: String? = null,
|
savingAttachmentPath: String? = null,
|
||||||
openingAttachmentPath: String? = null,
|
openingAttachmentPath: String? = null,
|
||||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
|
hasOlderMessages: Boolean = false,
|
||||||
|
pagingSessionId: String? = null,
|
||||||
|
onLoadOlder: () -> Unit = viewModel::loadOlderMessages,
|
||||||
) {
|
) {
|
||||||
if (messages.isEmpty() && !isLoading && !isAgentTyping) {
|
if (messages.isEmpty() && !isLoading && !isAgentTyping) {
|
||||||
Box(
|
Box(
|
||||||
@@ -105,18 +116,132 @@ fun FullBleedChatList(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} 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 =
|
val turns =
|
||||||
remember(messages, streamingState.streamingMessage) {
|
remember(settledTurns, streamingState.streamingMessage) {
|
||||||
groupIntoTurnsWithStreaming(messages, streamingState.streamingMessage)
|
appendStreamingTurn(settledTurns, settledIds, streamingState.streamingMessage)
|
||||||
|
}
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll the current search match into view, word-focused. Lives here
|
// Scroll the current search match into view, word-focused. Lives here
|
||||||
@@ -128,6 +253,7 @@ fun FullBleedChatList(
|
|||||||
searchState.currentIndex,
|
searchState.currentIndex,
|
||||||
searchState.matchIndices,
|
searchState.matchIndices,
|
||||||
searchState.matchOffsets,
|
searchState.matchOffsets,
|
||||||
|
renderedMessages.firstOrNull()?.id,
|
||||||
) {
|
) {
|
||||||
if (searchState.isActive &&
|
if (searchState.isActive &&
|
||||||
searchState.currentIndex >= 0 &&
|
searchState.currentIndex >= 0 &&
|
||||||
@@ -135,6 +261,9 @@ fun FullBleedChatList(
|
|||||||
) {
|
) {
|
||||||
val messageIndex = searchState.matchIndices[searchState.currentIndex]
|
val messageIndex = searchState.matchIndices[searchState.currentIndex]
|
||||||
if (messageIndex < 0 || messageIndex >= messages.size) return@LaunchedEffect
|
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 lazyIndex = lazyIndexById[messages[messageIndex].id] ?: return@LaunchedEffect
|
||||||
val contentOffset = searchState.matchOffsets.getOrElse(searchState.currentIndex) { 0 }
|
val contentOffset = searchState.matchOffsets.getOrElse(searchState.currentIndex) { 0 }
|
||||||
val contentLength = messages[messageIndex].content.length
|
val contentLength = messages[messageIndex].content.length
|
||||||
@@ -153,22 +282,12 @@ fun FullBleedChatList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
CompositionLocalProvider(LocalDensity provides chatDensity) {
|
CompositionLocalProvider(LocalDensity provides chatDensity) {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize().nestedScroll(prefetchConnection),
|
||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
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 ->
|
turns.forEach { turn ->
|
||||||
when (turn) {
|
when (turn) {
|
||||||
is ChatTurn.User -> {
|
is ChatTurn.User -> {
|
||||||
@@ -222,6 +341,16 @@ fun FullBleedChatList(
|
|||||||
val hoistedReasoning =
|
val hoistedReasoning =
|
||||||
turnReasoning != null &&
|
turnReasoning != null &&
|
||||||
proseMessage.id == turnReasoning.message.id
|
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()) {
|
if (proseMessage.hasVisibleAgentContent()) {
|
||||||
item(key = "prose-${proseMessage.id}") {
|
item(key = "prose-${proseMessage.id}") {
|
||||||
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||||
@@ -301,56 +430,17 @@ fun FullBleedChatList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
agentStatus?.let { status ->
|
tailItems.forEach { (key, content) ->
|
||||||
item(key = "agent_status") {
|
item(key = key) { content() }
|
||||||
AgentStatusIndicator(status = status)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Loading history must never become the list's first visible anchor.
|
||||||
// Clarify bubble — rendered at the very bottom
|
if (isLoadingOlder) {
|
||||||
if (clarifyRequest != null) {
|
CircularProgressIndicator(
|
||||||
item(key = "clarify_bubble") {
|
modifier = Modifier.align(Alignment.TopCenter).padding(12.dp).size(24.dp),
|
||||||
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() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,19 +128,22 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
|
|||||||
fun groupIntoTurnsWithStreaming(
|
fun groupIntoTurnsWithStreaming(
|
||||||
messages: List<ChatMessage>,
|
messages: List<ChatMessage>,
|
||||||
streamingMessage: 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> {
|
): List<ChatTurn> {
|
||||||
if (streamingMessage == null || messages.any { it.id == streamingMessage.id }) {
|
if (streamingMessage == null || streamingMessage.id in settledIds) return settled
|
||||||
return groupIntoTurns(messages)
|
|
||||||
}
|
|
||||||
val turns = groupIntoTurns(messages).toMutableList()
|
|
||||||
val prose = AgentEntry.Prose(streamingMessage)
|
val prose = AgentEntry.Prose(streamingMessage)
|
||||||
val last = turns.lastOrNull()
|
val last = settled.lastOrNull()
|
||||||
if (last is ChatTurn.Agent) {
|
return if (last is ChatTurn.Agent) {
|
||||||
turns[turns.lastIndex] = ChatTurn.Agent(last.entries + prose)
|
settled.dropLast(1) + ChatTurn.Agent(last.entries + prose)
|
||||||
} else {
|
} 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:
|
* Mirrors the item emission order in [FullBleedChatList] exactly:
|
||||||
* user turn → 1 item; agent turn → optional reasoning item, then one item per
|
* user turn → 1 item; agent turn → optional reasoning item, then one item per
|
||||||
* visible entry. [leadingItems] accounts for fixed items emitted before the
|
* visible entry, including later reasoning-only rows at their own positions.
|
||||||
* turns (e.g. the `loading-older` spinner when paging).
|
* [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
|
* @return messageId → LazyColumn item index of the message's content item
|
||||||
* (prose items for agent messages; user items for user messages).
|
* (prose items for agent messages; user items for user messages).
|
||||||
@@ -165,47 +169,53 @@ fun groupIntoTurnsWithStreaming(
|
|||||||
fun messageIdToLazyIndex(
|
fun messageIdToLazyIndex(
|
||||||
turns: List<ChatTurn>,
|
turns: List<ChatTurn>,
|
||||||
leadingItems: Int = 0,
|
leadingItems: Int = 0,
|
||||||
): Map<String, Int> {
|
): Map<String, Int> =
|
||||||
val map = mutableMapOf<String, Int>()
|
fullBleedItemKeys(turns)
|
||||||
var itemIndex = leadingItems
|
.mapIndexedNotNull { index, key ->
|
||||||
|
if (key.startsWith("user-") || key.startsWith("prose-")) {
|
||||||
|
key.substringAfter('-') to index + leadingItems
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.toMap()
|
||||||
|
|
||||||
|
/** Lazy row identities, including hoisted reasoning and grouped tool/system entries. */
|
||||||
|
internal fun fullBleedItemKeys(turns: List<ChatTurn>): List<String> =
|
||||||
|
buildList {
|
||||||
turns.forEach { turn ->
|
turns.forEach { turn ->
|
||||||
when (turn) {
|
when (turn) {
|
||||||
is ChatTurn.User -> {
|
is ChatTurn.User -> {
|
||||||
map[turn.message.id] = itemIndex
|
add("user-${turn.message.id}")
|
||||||
itemIndex++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is ChatTurn.Agent -> {
|
is ChatTurn.Agent -> {
|
||||||
val hasReasoning =
|
val hoisted =
|
||||||
turn.entries
|
turn.entries
|
||||||
.filterIsInstance<AgentEntry.Prose>()
|
.filterIsInstance<AgentEntry.Prose>()
|
||||||
.any { it.message.reasoningText.isNotBlank() }
|
.firstOrNull { it.message.reasoningText.isNotBlank() }
|
||||||
if (hasReasoning) {
|
hoisted?.let { add("reasoning-${it.message.id}") }
|
||||||
// The reasoning-<id> hoist item occupies one slot.
|
|
||||||
itemIndex++
|
|
||||||
}
|
|
||||||
turn.entries.forEach { entry ->
|
turn.entries.forEach { entry ->
|
||||||
when (entry) {
|
when (entry) {
|
||||||
is AgentEntry.Prose -> {
|
is AgentEntry.Prose -> {
|
||||||
if (entry.message.hasVisibleAgentContent()) {
|
if (entry.message.hasVisibleAgentContent()) {
|
||||||
map[entry.message.id] = itemIndex
|
add("prose-${entry.message.id}")
|
||||||
itemIndex++
|
} else if (entry != hoisted && entry.message.reasoningText.isNotBlank()) {
|
||||||
|
add("reasoning-${entry.message.id}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is AgentEntry.ToolRow -> {
|
is AgentEntry.ToolRow -> {
|
||||||
itemIndex++
|
add("tool-${entry.message.id}")
|
||||||
}
|
}
|
||||||
|
|
||||||
is AgentEntry.SystemEvent -> {
|
is AgentEntry.SystemEvent -> {
|
||||||
itemIndex++
|
add("sys-${entry.message.id}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ internal interface ToolRenderer {
|
|||||||
*/
|
*/
|
||||||
internal data class ToolViewExtras(
|
internal data class ToolViewExtras(
|
||||||
val detailLabel: String? = null,
|
val detailLabel: String? = null,
|
||||||
|
/** Process output characters omitted by the backend. */
|
||||||
|
val outputCut: Long? = null,
|
||||||
val stdout: String? = null,
|
val stdout: String? = null,
|
||||||
val stderr: String? = null,
|
val stderr: String? = null,
|
||||||
val exitCode: Int? = null,
|
val exitCode: Int? = null,
|
||||||
@@ -48,6 +50,8 @@ internal data class ToolViewExtras(
|
|||||||
val inlineDiff: String? = null,
|
val inlineDiff: String? = null,
|
||||||
val diffPath: String? = null,
|
val diffPath: String? = null,
|
||||||
val diffStats: DiffStats? = null,
|
val diffStats: DiffStats? = null,
|
||||||
|
val fileContent: String? = null,
|
||||||
|
val filePath: String? = null,
|
||||||
val searchHits: List<SearchHit>? = null,
|
val searchHits: List<SearchHit>? = null,
|
||||||
val searchQuery: String? = 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.TerminalRenderer
|
||||||
import com.m57.hermescontrol.ui.chat.tool.render.TextToSpeechRenderer
|
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.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.ToolSearchRenderer
|
||||||
import com.m57.hermescontrol.ui.chat.tool.render.VisionAnalyzeRenderer
|
import com.m57.hermescontrol.ui.chat.tool.render.VisionAnalyzeRenderer
|
||||||
import com.m57.hermescontrol.ui.chat.tool.render.WebExtractRenderer
|
import com.m57.hermescontrol.ui.chat.tool.render.WebExtractRenderer
|
||||||
@@ -91,6 +92,7 @@ internal object ToolRendererRegistry {
|
|||||||
"skill_view" to SkillViewRenderer,
|
"skill_view" to SkillViewRenderer,
|
||||||
"skill_manage" to SkillManageRenderer,
|
"skill_manage" to SkillManageRenderer,
|
||||||
"tool_search" to ToolSearchRenderer,
|
"tool_search" to ToolSearchRenderer,
|
||||||
|
"tool_describe" to ToolDescribeRenderer,
|
||||||
// Media
|
// Media
|
||||||
"image_generate" to ImageGenerateRenderer,
|
"image_generate" to ImageGenerateRenderer,
|
||||||
"vision_analyze" to VisionAnalyzeRenderer,
|
"vision_analyze" to VisionAnalyzeRenderer,
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ data class ToolView(
|
|||||||
val detail: String = "",
|
val detail: String = "",
|
||||||
/** Optional section label above [detail] (e.g. "Search results"). */
|
/** Optional section label above [detail] (e.g. "Search results"). */
|
||||||
val detailLabel: String? = null,
|
val detailLabel: String? = null,
|
||||||
|
/** Process output characters omitted by the backend. */
|
||||||
|
val outputCut: Long? = null,
|
||||||
/** Compact count, e.g. "3 results". */
|
/** Compact count, e.g. "3 results". */
|
||||||
val countLabel: String? = null,
|
val countLabel: String? = null,
|
||||||
/** Human duration, e.g. "1.2s". */
|
/** Human duration, e.g. "1.2s". */
|
||||||
@@ -52,6 +54,9 @@ data class ToolView(
|
|||||||
/** File-edit tools: the file the diff applies to. */
|
/** File-edit tools: the file the diff applies to. */
|
||||||
val diffPath: String? = null,
|
val diffPath: String? = null,
|
||||||
val diffStats: DiffStats? = 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). */
|
/** Image-producing tools: renderable URL (data: or http(s) image). */
|
||||||
val imageUrl: String? = null,
|
val imageUrl: String? = null,
|
||||||
/** web_search: parsed result rows. */
|
/** web_search: parsed result rows. */
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ object ToolViewBuilder {
|
|||||||
subtitle = subtitle,
|
subtitle = subtitle,
|
||||||
detail = clampedDetail,
|
detail = clampedDetail,
|
||||||
detailLabel = extras.detailLabel,
|
detailLabel = extras.detailLabel,
|
||||||
|
outputCut = extras.outputCut,
|
||||||
countLabel = countLabel?.let { (count, noun) -> "$count ${ToolCounts.pluralizeNoun(noun, count)}" },
|
countLabel = countLabel?.let { (count, noun) -> "$count ${ToolCounts.pluralizeNoun(noun, count)}" },
|
||||||
durationLabel = ToolJson.durationLabel(call.result),
|
durationLabel = ToolJson.durationLabel(call.result),
|
||||||
stdout = clampedStdout,
|
stdout = clampedStdout,
|
||||||
@@ -115,6 +116,8 @@ object ToolViewBuilder {
|
|||||||
inlineDiff = clampedDiff,
|
inlineDiff = clampedDiff,
|
||||||
diffPath = extras.diffPath,
|
diffPath = extras.diffPath,
|
||||||
diffStats = extras.diffStats,
|
diffStats = extras.diffStats,
|
||||||
|
fileContent = extras.fileContent?.let { ToolJson.clampForDisplay(it) },
|
||||||
|
filePath = extras.filePath,
|
||||||
imageUrl = imageUrlFor(call.args, call.result),
|
imageUrl = imageUrlFor(call.args, call.result),
|
||||||
searchHits = extras.searchHits,
|
searchHits = extras.searchHits,
|
||||||
searchQuery = extras.searchQuery,
|
searchQuery = extras.searchQuery,
|
||||||
@@ -153,7 +156,7 @@ object ToolViewBuilder {
|
|||||||
): String? {
|
): String? {
|
||||||
val candidate =
|
val candidate =
|
||||||
ToolJson
|
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")) }
|
.ifEmpty { ToolJson.firstString(args, listOf("image_url", "url", "path")) }
|
||||||
|
|
||||||
if (candidate.isEmpty()) {
|
if (candidate.isEmpty()) {
|
||||||
|
|||||||
@@ -158,6 +158,21 @@ internal object ReadFileRenderer : ToolRenderer {
|
|||||||
|
|
||||||
return ToolJson.firstString(call.result, listOf("content", "text", "data", "body"))
|
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)
|
val inlineDiff = FileEditSupport.inlineDiffFromResult(call.rawResult)
|
||||||
|
|
||||||
if (inlineDiff.isEmpty()) {
|
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(
|
return ToolViewExtras(
|
||||||
|
|||||||
@@ -118,21 +118,91 @@ internal object SkillManageRenderer : ToolRenderer {
|
|||||||
/** `tool_search`: on-demand tool discovery matches. */
|
/** `tool_search`: on-demand tool discovery matches. */
|
||||||
internal object ToolSearchRenderer : ToolRenderer {
|
internal object ToolSearchRenderer : ToolRenderer {
|
||||||
override fun subtitle(call: ToolCall): String {
|
override fun subtitle(call: ToolCall): String {
|
||||||
val query = ToolJson.firstString(call.args, listOf("query"))
|
val query =
|
||||||
val matches = (call.result?.get("matches") as? JsonArray)?.size
|
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 =
|
override fun detail(call: ToolCall): String =
|
||||||
(call.result?.get("matches") as? JsonArray)
|
(call.result?.get("tools") as? JsonObject)
|
||||||
?.mapNotNull { el ->
|
?.entries
|
||||||
val m = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
?.joinToString("\n\n") { (name, value) ->
|
||||||
val name = ToolJson.firstString(m, listOf("name"))
|
val tool = ToolJson.parseMaybeObject(value)
|
||||||
val desc = ToolJson.firstString(m, listOf("description"))
|
val desc = ToolJson.firstString(tool, listOf("description"))
|
||||||
val lines = mutableListOf("🔧 $name")
|
val schema = tool?.get("parameters") as? JsonObject
|
||||||
if (desc.isNotEmpty()) lines += " $desc"
|
val properties = schema?.get("properties") as? JsonObject
|
||||||
lines.joinToString("\n")
|
val required = (schema?.get("required") as? JsonArray)?.map(::text).orEmpty().toSet()
|
||||||
}?.joinToString("\n\n")
|
buildString {
|
||||||
?: "No matching tools"
|
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.ToolCall
|
||||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
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.JsonArray
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.longOrNull
|
||||||
|
|
||||||
/** `cronjob`: job listings with schedules, or the mutated job's key fields. */
|
/** `cronjob`: job listings with schedules, or the mutated job's key fields. */
|
||||||
internal object CronjobRenderer : ToolRenderer {
|
internal object CronjobRenderer : ToolRenderer {
|
||||||
@@ -214,6 +217,15 @@ internal object SessionSearchRenderer : ToolRenderer {
|
|||||||
|
|
||||||
/** `process`: background session listings and lifecycle acks. */
|
/** `process`: background session listings and lifecycle acks. */
|
||||||
internal object ProcessRenderer : ToolRenderer {
|
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 {
|
override fun subtitle(call: ToolCall): String {
|
||||||
val action = ToolJson.firstString(call.args, listOf("action"))
|
val action = ToolJson.firstString(call.args, listOf("action"))
|
||||||
val procId = ToolJson.firstString(call.args, listOf("session_id"))
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
@@ -119,10 +120,22 @@ fun KanbanScreen(
|
|||||||
var selectedAssignee by remember { mutableStateOf<String?>(null) }
|
var selectedAssignee by remember { mutableStateOf<String?>(null) }
|
||||||
var selectedTenant by remember { mutableStateOf<String?>(null) }
|
var selectedTenant by remember { mutableStateOf<String?>(null) }
|
||||||
var showFilterSheet by remember { mutableStateOf(false) }
|
var showFilterSheet by remember { mutableStateOf(false) }
|
||||||
|
var showWorkersDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val filteredTasks =
|
val filteredTasks =
|
||||||
remember(query, state.tasks, selectedAssignee, selectedTenant) {
|
remember(
|
||||||
state.tasks.filter { task ->
|
query,
|
||||||
|
state.tasks,
|
||||||
|
selectedAssignee,
|
||||||
|
selectedTenant,
|
||||||
|
state.selectedWorkflowTemplateId,
|
||||||
|
state.selectedCurrentStepKey,
|
||||||
|
) {
|
||||||
|
filterKanbanTasks(
|
||||||
|
state.tasks,
|
||||||
|
state.selectedWorkflowTemplateId,
|
||||||
|
state.selectedCurrentStepKey,
|
||||||
|
).filter { task ->
|
||||||
val matchesQuery =
|
val matchesQuery =
|
||||||
query.isBlank() ||
|
query.isBlank() ||
|
||||||
task.title.contains(query, ignoreCase = true) ||
|
task.title.contains(query, ignoreCase = true) ||
|
||||||
@@ -151,6 +164,7 @@ fun KanbanScreen(
|
|||||||
var isMultiSelectMode by remember { mutableStateOf(false) }
|
var isMultiSelectMode by remember { mutableStateOf(false) }
|
||||||
var selectedTaskIds by remember { mutableStateOf(setOf<String>()) }
|
var selectedTaskIds by remember { mutableStateOf(setOf<String>()) }
|
||||||
var showBulkMoveDialog by remember { mutableStateOf(false) }
|
var showBulkMoveDialog by remember { mutableStateOf(false) }
|
||||||
|
var showBulkCompleteDialog by remember { mutableStateOf(false) }
|
||||||
var showBulkAssignDialog by remember { mutableStateOf(false) }
|
var showBulkAssignDialog by remember { mutableStateOf(false) }
|
||||||
var showBulkDeleteDialog by remember { mutableStateOf(false) }
|
var showBulkDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -330,6 +344,14 @@ fun KanbanScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (state.selectedBoard != null) {
|
if (state.selectedBoard != null) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.kanban_active_workers)) },
|
||||||
|
onClick = {
|
||||||
|
showBoardMenu = false
|
||||||
|
showWorkersDialog = true
|
||||||
|
viewModel.loadActiveWorkers()
|
||||||
|
},
|
||||||
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.kanban_board_settings)) },
|
text = { Text(stringResource(R.string.kanban_board_settings)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -916,10 +938,23 @@ fun KanbanScreen(
|
|||||||
tenants = tenants,
|
tenants = tenants,
|
||||||
selectedAssignee = selectedAssignee,
|
selectedAssignee = selectedAssignee,
|
||||||
selectedTenant = selectedTenant,
|
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,
|
includeArchived = state.includeArchived,
|
||||||
groupRunning = state.groupRunning,
|
groupRunning = state.groupRunning,
|
||||||
onSelectAssignee = { selectedAssignee = it },
|
onSelectAssignee = { selectedAssignee = it },
|
||||||
onSelectTenant = { selectedTenant = it },
|
onSelectTenant = { selectedTenant = it },
|
||||||
|
onSelectWorkflowFilters = viewModel::setWorkflowFilters,
|
||||||
onToggleIncludeArchived = viewModel::setIncludeArchived,
|
onToggleIncludeArchived = viewModel::setIncludeArchived,
|
||||||
onToggleGroupRunning = viewModel::setGroupRunning,
|
onToggleGroupRunning = viewModel::setGroupRunning,
|
||||||
onClearFilters = {
|
onClearFilters = {
|
||||||
@@ -927,6 +962,7 @@ fun KanbanScreen(
|
|||||||
selectedTenant = null
|
selectedTenant = null
|
||||||
viewModel.setIncludeArchived(false)
|
viewModel.setIncludeArchived(false)
|
||||||
viewModel.setGroupRunning(false)
|
viewModel.setGroupRunning(false)
|
||||||
|
viewModel.setWorkflowFilters(null, null)
|
||||||
},
|
},
|
||||||
onDismiss = { showFilterSheet = false },
|
onDismiss = { showFilterSheet = false },
|
||||||
)
|
)
|
||||||
@@ -1052,10 +1088,14 @@ fun KanbanScreen(
|
|||||||
writableMoveColumns.forEach { col ->
|
writableMoveColumns.forEach { col ->
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
|
if (col.name.equals("done", ignoreCase = true)) {
|
||||||
|
showBulkCompleteDialog = true
|
||||||
|
} else {
|
||||||
viewModel.bulkMove(selectedTaskIds.toList(), col.name) { failed ->
|
viewModel.bulkMove(selectedTaskIds.toList(), col.name) { failed ->
|
||||||
selectedTaskIds = failed
|
selectedTaskIds = failed
|
||||||
if (failed.isEmpty()) isMultiSelectMode = false
|
if (failed.isEmpty()) isMultiSelectMode = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
showBulkMoveDialog = false
|
showBulkMoveDialog = false
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -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) {
|
if (showBulkAssignDialog) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showBulkAssignDialog = false },
|
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) {
|
if (showDeleteBoardDialog && state.selectedBoard != null) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showDeleteBoardDialog = false },
|
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 =
|
private fun KanbanTaskAction.labelRes(): Int =
|
||||||
when (this) {
|
when (this) {
|
||||||
KanbanTaskAction.TRIAGE -> R.string.kanban_action_triage
|
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.Close
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Description
|
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.Edit
|
||||||
import androidx.compose.material.icons.filled.History
|
import androidx.compose.material.icons.filled.History
|
||||||
import androidx.compose.material.icons.filled.RestartAlt
|
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.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
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.KanbanTaskDetailKey
|
||||||
import com.m57.hermescontrol.NavigationController
|
import com.m57.hermescontrol.NavigationController
|
||||||
import com.m57.hermescontrol.R
|
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.KanbanProfile
|
||||||
import com.m57.hermescontrol.data.model.KanbanTaskFull
|
import com.m57.hermescontrol.data.model.KanbanTaskFull
|
||||||
import com.m57.hermescontrol.data.model.ModelProvider
|
import com.m57.hermescontrol.data.model.ModelProvider
|
||||||
import com.m57.hermescontrol.data.model.PinnedModel
|
import com.m57.hermescontrol.data.model.PinnedModel
|
||||||
import com.m57.hermescontrol.data.model.TaskEstimate
|
import com.m57.hermescontrol.data.model.TaskEstimate
|
||||||
import com.m57.hermescontrol.data.model.TaskLinks
|
import com.m57.hermescontrol.data.model.TaskLinks
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.ui.chat.MarkdownText
|
import com.m57.hermescontrol.ui.chat.MarkdownText
|
||||||
import com.m57.hermescontrol.ui.common.ErrorState
|
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.SkeletonListState
|
||||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||||
import com.m57.hermescontrol.ui.kanban.components.KanbanModelOverrideEditor
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -110,6 +122,7 @@ fun KanbanTaskScreen(
|
|||||||
stringResource(R.string.kanban_tab_files),
|
stringResource(R.string.kanban_tab_files),
|
||||||
)
|
)
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
var runToTerminate by remember { mutableStateOf<Long?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(boardSlug, taskId) {
|
LaunchedEffect(boardSlug, taskId) {
|
||||||
viewModel.loadTask(boardSlug, taskId)
|
viewModel.loadTask(boardSlug, taskId)
|
||||||
@@ -208,6 +221,10 @@ fun KanbanTaskScreen(
|
|||||||
onReclaim = {
|
onReclaim = {
|
||||||
viewModel.reclaim(boardSlug, taskId)
|
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(
|
TaskRunsTab(
|
||||||
runs = detail.runs,
|
runs = detail.runs,
|
||||||
workerLog = state.workerLog,
|
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(
|
TaskFilesTab(
|
||||||
attachments = detail.attachments ?: emptyList(),
|
attachments = detail.attachments ?: emptyList(),
|
||||||
isUploading = state.isUploadingAttachment,
|
isUploading = state.isUploadingAttachment,
|
||||||
onUpload = { filename, mimeType, bytes ->
|
isDeleting = state.isDeletingAttachment,
|
||||||
viewModel.uploadAttachment(boardSlug, taskId, filename, mimeType, bytes)
|
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
|
@Composable
|
||||||
@@ -293,11 +377,16 @@ private fun TaskOverviewTab(
|
|||||||
onNavigateToTask: (String) -> Unit,
|
onNavigateToTask: (String) -> Unit,
|
||||||
onSaveDescription: (String) -> Unit,
|
onSaveDescription: (String) -> Unit,
|
||||||
onReclaim: () -> Unit,
|
onReclaim: () -> Unit,
|
||||||
|
isSpecifying: Boolean,
|
||||||
|
isLinking: Boolean,
|
||||||
|
onSpecify: () -> Unit,
|
||||||
|
onAddParent: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var isEditingDescription by remember { mutableStateOf(false) }
|
var isEditingDescription by remember { mutableStateOf(false) }
|
||||||
var descriptionDraft by remember(task.body) { mutableStateOf(task.body ?: "") }
|
var descriptionDraft by remember(task.body) { mutableStateOf(task.body ?: "") }
|
||||||
var assigneeMenuExpanded by remember { mutableStateOf(false) }
|
var assigneeMenuExpanded by remember { mutableStateOf(false) }
|
||||||
|
var parentDraft by remember { mutableStateOf("") }
|
||||||
val statusColors = LocalHermesStatusColors.current
|
val statusColors = LocalHermesStatusColors.current
|
||||||
|
|
||||||
val currentModelOverride =
|
val currentModelOverride =
|
||||||
@@ -430,6 +519,8 @@ private fun TaskOverviewTab(
|
|||||||
task.tenant?.let { MetaRow(label = "Tenant", value = it) }
|
task.tenant?.let { MetaRow(label = "Tenant", value = it) }
|
||||||
task.workspaceKind?.let { MetaRow(label = "Workspace", value = "$it ${task.workspacePath ?: ""}") }
|
task.workspaceKind?.let { MetaRow(label = "Workspace", value = "$it ${task.workspacePath ?: ""}") }
|
||||||
task.workerPid?.let { MetaRow(label = "Worker PID", value = it.toString()) }
|
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))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
@@ -446,7 +537,7 @@ private fun TaskOverviewTab(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Dependencies Section
|
// Dependencies Section
|
||||||
if (links.parents.isNotEmpty() || links.children.isNotEmpty()) {
|
run {
|
||||||
Card(
|
Card(
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
modifier =
|
modifier =
|
||||||
@@ -461,6 +552,22 @@ private fun TaskOverviewTab(
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
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()) {
|
if (links.parents.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -506,6 +613,13 @@ private fun TaskOverviewTab(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Effort Estimation Section
|
// 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(
|
Card(
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
modifier =
|
modifier =
|
||||||
@@ -778,8 +892,15 @@ private fun formatRunDuration(durationSeconds: Long): String =
|
|||||||
private fun TaskRunsTab(
|
private fun TaskRunsTab(
|
||||||
runs: List<com.m57.hermescontrol.data.model.KanbanRun>,
|
runs: List<com.m57.hermescontrol.data.model.KanbanRun>,
|
||||||
workerLog: com.m57.hermescontrol.data.model.WorkerLog?,
|
workerLog: com.m57.hermescontrol.data.model.WorkerLog?,
|
||||||
|
runStateType: String?,
|
||||||
|
runStateName: String?,
|
||||||
|
onFilter: (String?, String?) -> Unit,
|
||||||
|
onInspect: (Long) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
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(
|
LazyColumn(
|
||||||
modifier = modifier.fillMaxSize().padding(16.dp),
|
modifier = modifier.fillMaxSize().padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
@@ -790,6 +911,34 @@ private fun TaskRunsTab(
|
|||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
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()) {
|
if (runs.isEmpty()) {
|
||||||
@@ -858,6 +1007,9 @@ private fun TaskRunsTab(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
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(
|
private fun TaskFilesTab(
|
||||||
attachments: List<com.m57.hermescontrol.data.model.KanbanAttachment>,
|
attachments: List<com.m57.hermescontrol.data.model.KanbanAttachment>,
|
||||||
isUploading: Boolean,
|
isUploading: Boolean,
|
||||||
onUpload: (filename: String, mimeType: String, bytes: ByteArray) -> Unit,
|
isDeleting: Boolean,
|
||||||
|
board: String,
|
||||||
|
taskId: String,
|
||||||
|
viewModel: KanbanTaskViewModel,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
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 launcher =
|
val launcher =
|
||||||
rememberLauncherForActivityResult(
|
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
|
||||||
contract = ActivityResultContracts.GetContent(),
|
|
||||||
) { uri: Uri? ->
|
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val part =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
var filename = "attachment"
|
var filename = "attachment"
|
||||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
var contentLength = -1L
|
||||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
context.contentResolver
|
||||||
if (nameIndex != -1 && cursor.moveToFirst()) {
|
.query(
|
||||||
filename = cursor.getString(nameIndex) ?: "attachment"
|
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
|
||||||
val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream"
|
.getColumnIndex(
|
||||||
val bytes =
|
OpenableColumns.SIZE,
|
||||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
).takeIf { it >= 0 && !c.isNull(it) }
|
||||||
?: byteArrayOf()
|
?.let {
|
||||||
if (bytes.isNotEmpty()) {
|
contentLength = c.getLong(it)
|
||||||
onUpload(filename, mimeType, bytes)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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)) {
|
Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
|
||||||
Row(
|
Row(
|
||||||
@@ -1096,7 +1324,7 @@ private fun TaskFilesTab(
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
Button(
|
Button(
|
||||||
onClick = { launcher.launch("*/*") },
|
onClick = { launcher.launch(arrayOf("*/*")) },
|
||||||
enabled = !isUploading,
|
enabled = !isUploading,
|
||||||
) {
|
) {
|
||||||
if (isUploading) {
|
if (isUploading) {
|
||||||
@@ -1136,22 +1364,55 @@ private fun TaskFilesTab(
|
|||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = att.filename.ifBlank { "File #${att.id}" },
|
att.filename.ifBlank {
|
||||||
|
"File #${att.id}"
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
att.size?.let {
|
att.size?.let {
|
||||||
Text(
|
Text(
|
||||||
text = "${it / 1024} KB",
|
"${it / 1024} KB",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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 androidx.lifecycle.viewModelScope
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.model.KanbanProfile
|
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.KanbanTaskDetailResponse
|
||||||
import com.m57.hermescontrol.data.model.ModelProvider
|
import com.m57.hermescontrol.data.model.ModelProvider
|
||||||
import com.m57.hermescontrol.data.model.PinnedModel
|
import com.m57.hermescontrol.data.model.PinnedModel
|
||||||
@@ -25,9 +27,8 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
||||||
import okhttp3.MultipartBody
|
import okhttp3.MultipartBody
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.ResponseBody
|
||||||
|
|
||||||
data class KanbanTaskUiState(
|
data class KanbanTaskUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
@@ -47,6 +48,15 @@ data class KanbanTaskUiState(
|
|||||||
val isReassigning: Boolean = false,
|
val isReassigning: Boolean = false,
|
||||||
val isUpdatingModel: Boolean = false,
|
val isUpdatingModel: Boolean = false,
|
||||||
val isUploadingAttachment: 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(
|
class KanbanTaskViewModel(
|
||||||
@@ -61,6 +71,7 @@ class KanbanTaskViewModel(
|
|||||||
private var eventsClient: KanbanEventsClient? = null
|
private var eventsClient: KanbanEventsClient? = null
|
||||||
private var eventsBoard: String? = null
|
private var eventsBoard: String? = null
|
||||||
private var activeTaskId: String? = null
|
private var activeTaskId: String? = null
|
||||||
|
private var activeBoard: String? = null
|
||||||
private var currentLoadGen: Int = 0
|
private var currentLoadGen: Int = 0
|
||||||
private var reloadJob: Job? = null
|
private var reloadJob: Job? = null
|
||||||
private var logPollJob: Job? = null
|
private var logPollJob: Job? = null
|
||||||
@@ -74,7 +85,18 @@ class KanbanTaskViewModel(
|
|||||||
reloadJob?.cancel()
|
reloadJob?.cancel()
|
||||||
stopLogPolling()
|
stopLogPolling()
|
||||||
detailPollJob?.cancel()
|
detailPollJob?.cancel()
|
||||||
|
if (activeBoard != board || activeTaskId != taskId) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
runStateType = null,
|
||||||
|
runStateName = null,
|
||||||
|
selectedRun = null,
|
||||||
|
runInspection = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
activeTaskId = taskId
|
activeTaskId = taskId
|
||||||
|
activeBoard = board
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = true,
|
isLoading = true,
|
||||||
@@ -90,7 +112,15 @@ class KanbanTaskViewModel(
|
|||||||
startDetailPolling(board, taskId)
|
startDetailPolling(board, taskId)
|
||||||
|
|
||||||
viewModelScope.launch {
|
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 -> {
|
is NetworkResult.Success -> {
|
||||||
if (gen != currentLoadGen) return@launch
|
if (gen != currentLoadGen) return@launch
|
||||||
val taskData = result.data
|
val taskData = result.data
|
||||||
@@ -254,11 +284,13 @@ class KanbanTaskViewModel(
|
|||||||
taskId: String,
|
taskId: String,
|
||||||
) {
|
) {
|
||||||
val gen = currentLoadGen
|
val gen = currentLoadGen
|
||||||
val result = repository.getTask(taskId = taskId, board = board)
|
val result = repository.getTask(taskId, board, _uiState.value.runStateType, _uiState.value.runStateName)
|
||||||
if (gen != currentLoadGen || taskId != activeTaskId) return
|
if (gen != currentLoadGen || taskId != activeTaskId || board != activeBoard) return
|
||||||
if (result is NetworkResult.Success) {
|
if (result is NetworkResult.Success) {
|
||||||
val taskData = result.data
|
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 (taskData.task.status.equals("running", ignoreCase = true)) {
|
||||||
if (logPollJob == null) startLogPolling(board, taskId)
|
if (logPollJob == null) startLogPolling(board, taskId)
|
||||||
} else {
|
} 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(
|
fun uploadAttachment(
|
||||||
board: String,
|
board: String,
|
||||||
taskId: String,
|
taskId: String,
|
||||||
@@ -548,7 +568,10 @@ class KanbanTaskViewModel(
|
|||||||
toastMessage = "Attachment uploaded",
|
toastMessage = "Attachment uploaded",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
loadTask(board, taskId)
|
if (activeTaskId == taskId && activeBoard == board) {
|
||||||
|
loadTaskSilently(board, taskId)
|
||||||
|
refreshAttachments(board, taskId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
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() {
|
public override fun onCleared() {
|
||||||
stopLogPolling()
|
stopLogPolling()
|
||||||
detailPollJob?.cancel()
|
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.CreateTaskBody
|
||||||
import com.m57.hermescontrol.data.model.ExportBoardBody
|
import com.m57.hermescontrol.data.model.ExportBoardBody
|
||||||
import com.m57.hermescontrol.data.model.ImportBoardBody
|
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.KanbanBoard
|
||||||
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||||
import com.m57.hermescontrol.data.model.KanbanColumn
|
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.TaskEstimate
|
||||||
import com.m57.hermescontrol.data.model.UpdateTaskBody
|
import com.m57.hermescontrol.data.model.UpdateTaskBody
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
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.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||||
import com.m57.hermescontrol.data.repository.KanbanRepository
|
import com.m57.hermescontrol.data.repository.KanbanRepository
|
||||||
@@ -64,8 +66,22 @@ data class KanbanUiState(
|
|||||||
val columnCollapseOverrides: Map<String, Boolean> = emptyMap(),
|
val columnCollapseOverrides: Map<String, Boolean> = emptyMap(),
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val toastMessage: 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.
|
* Task actions mirroring the desktop kanban's transition-gated buttons.
|
||||||
* Only moves the backend PATCH route accepts are offered: never `running`
|
* Only moves the backend PATCH route accepts are offered: never `running`
|
||||||
@@ -250,7 +266,15 @@ class KanbanViewModel(
|
|||||||
preferences.setSelectedBoard(endpoint, board.id)
|
preferences.setSelectedBoard(endpoint, board.id)
|
||||||
|
|
||||||
val gen = ++currentLoadGen
|
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 {
|
viewModelScope.launch {
|
||||||
loadBoardIntoState(board, gen)
|
loadBoardIntoState(board, gen)
|
||||||
connectEvents(board)
|
connectEvents(board)
|
||||||
@@ -272,6 +296,54 @@ class KanbanViewModel(
|
|||||||
_uiState.update { it.copy(groupRunning = group) }
|
_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(
|
fun toggleColumnCollapse(
|
||||||
columnName: String,
|
columnName: String,
|
||||||
boardHasWork: Boolean,
|
boardHasWork: Boolean,
|
||||||
@@ -618,12 +690,26 @@ class KanbanViewModel(
|
|||||||
fun bulkMove(
|
fun bulkMove(
|
||||||
taskIds: List<String>,
|
taskIds: List<String>,
|
||||||
targetStatus: String,
|
targetStatus: String,
|
||||||
|
summary: String? = null,
|
||||||
onComplete: ((failedIds: Set<String>) -> Unit)? = null,
|
onComplete: ((failedIds: Set<String>) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val board = _uiState.value.selectedBoard ?: return
|
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 {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true) }
|
_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 -> {
|
is NetworkResult.Success -> {
|
||||||
val results = res.data.results
|
val results = res.data.results
|
||||||
val succeeded = results.filter { it.ok }.map { it.id }.toSet()
|
val succeeded = results.filter { it.ok }.map { it.id }.toSet()
|
||||||
@@ -846,10 +932,22 @@ class KanbanViewModel(
|
|||||||
operatingTaskIds = state.operatingTaskIds - task.id,
|
operatingTaskIds = state.operatingTaskIds - task.id,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
reloadBoardSilently()
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
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(
|
repository.getBoard(
|
||||||
board = board.id,
|
board = board.id,
|
||||||
includeArchived = _uiState.value.includeArchived,
|
includeArchived = _uiState.value.includeArchived,
|
||||||
|
workflowTemplateId = _uiState.value.selectedWorkflowTemplateId,
|
||||||
|
currentStepKey = _uiState.value.selectedCurrentStepKey,
|
||||||
)
|
)
|
||||||
if (gen != currentLoadGen) return@launch
|
if (gen != currentLoadGen) return@launch
|
||||||
if (result is NetworkResult.Success) {
|
if (result is NetworkResult.Success) {
|
||||||
@@ -996,6 +1096,8 @@ class KanbanViewModel(
|
|||||||
repository.getBoard(
|
repository.getBoard(
|
||||||
board = board.id,
|
board = board.id,
|
||||||
includeArchived = _uiState.value.includeArchived,
|
includeArchived = _uiState.value.includeArchived,
|
||||||
|
workflowTemplateId = _uiState.value.selectedWorkflowTemplateId,
|
||||||
|
currentStepKey = _uiState.value.selectedCurrentStepKey,
|
||||||
)
|
)
|
||||||
applyBoardResult(board, result, gen)
|
applyBoardResult(board, result, gen)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,10 +34,15 @@ fun KanbanFilterSheet(
|
|||||||
tenants: List<String>,
|
tenants: List<String>,
|
||||||
selectedAssignee: String?,
|
selectedAssignee: String?,
|
||||||
selectedTenant: String?,
|
selectedTenant: String?,
|
||||||
|
workflowTemplateIds: List<String>,
|
||||||
|
currentStepKeys: List<String>,
|
||||||
|
selectedWorkflowTemplateId: String?,
|
||||||
|
selectedCurrentStepKey: String?,
|
||||||
includeArchived: Boolean,
|
includeArchived: Boolean,
|
||||||
groupRunning: Boolean,
|
groupRunning: Boolean,
|
||||||
onSelectAssignee: (String?) -> Unit,
|
onSelectAssignee: (String?) -> Unit,
|
||||||
onSelectTenant: (String?) -> Unit,
|
onSelectTenant: (String?) -> Unit,
|
||||||
|
onSelectWorkflowFilters: (String?, String?) -> Unit,
|
||||||
onToggleIncludeArchived: (Boolean) -> Unit,
|
onToggleIncludeArchived: (Boolean) -> Unit,
|
||||||
onToggleGroupRunning: (Boolean) -> Unit,
|
onToggleGroupRunning: (Boolean) -> Unit,
|
||||||
onClearFilters: () -> Unit,
|
onClearFilters: () -> Unit,
|
||||||
@@ -105,6 +110,29 @@ fun KanbanFilterSheet(
|
|||||||
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
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
|
// Assignee Filter
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.kanban_assignee),
|
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.R
|
||||||
import com.m57.hermescontrol.data.model.ModelProvider
|
import com.m57.hermescontrol.data.model.ModelProvider
|
||||||
import com.m57.hermescontrol.data.model.PinnedModel
|
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.KanbanModelOverride
|
||||||
import com.m57.hermescontrol.ui.kanban.supportedKanbanReasoningEfforts
|
import com.m57.hermescontrol.ui.kanban.supportedKanbanReasoningEfforts
|
||||||
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
|
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
|
||||||
@@ -129,7 +130,7 @@ fun KanbanModelOverrideEditor(
|
|||||||
.firstOrNull { it.slug == override.provider }
|
.firstOrNull { it.slug == override.provider }
|
||||||
?.capabilities
|
?.capabilities
|
||||||
?.get(override.model)
|
?.get(override.model)
|
||||||
val reasoningSupported = selectedCapabilities?.reasoning != false
|
val reasoningSupported = selectedCapabilities?.reasoningSupport != false
|
||||||
val canDisableReasoning = selectedCapabilities?.can_disable_reasoning != false
|
val canDisableReasoning = selectedCapabilities?.can_disable_reasoning != false
|
||||||
val supportedEfforts = supportedKanbanReasoningEfforts(reasoningSupported, canDisableReasoning)
|
val supportedEfforts = supportedKanbanReasoningEfforts(reasoningSupported, canDisableReasoning)
|
||||||
Row(
|
Row(
|
||||||
@@ -180,7 +181,7 @@ fun KanbanModelOverrideEditor(
|
|||||||
override.copy(
|
override.copy(
|
||||||
provider = provider,
|
provider = provider,
|
||||||
model = model,
|
model = model,
|
||||||
effort = if (capabilities?.reasoning == false) "" else override.effort,
|
effort = if (capabilities?.reasoningSupport == false) "" else override.effort,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
showPickerDialog = false
|
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.SkeletonListState
|
||||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||||
import com.m57.hermescontrol.ui.model.components.MoaConfigDialog
|
import com.m57.hermescontrol.ui.model.components.MoaConfigDialog
|
||||||
|
import com.m57.hermescontrol.ui.model.components.ModelCapabilitySummary
|
||||||
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
|
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -874,19 +875,7 @@ private fun ProviderCard(
|
|||||||
// the row vertically instead of pushing
|
// the row vertically instead of pushing
|
||||||
// the check/pin buttons off-screen.
|
// the check/pin buttons off-screen.
|
||||||
)
|
)
|
||||||
val hint =
|
ModelCapabilitySummary(caps)
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|||||||
@@ -3,20 +3,25 @@ package com.m57.hermescontrol.ui.model
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
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.AuxiliaryTaskAssignment
|
||||||
import com.m57.hermescontrol.data.model.MoaConfigResponse
|
import com.m57.hermescontrol.data.model.MoaConfigResponse
|
||||||
import com.m57.hermescontrol.data.model.ModelAssignmentRequest
|
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.ModelProvider
|
||||||
import com.m57.hermescontrol.data.model.PinnedModel
|
import com.m57.hermescontrol.data.model.PinnedModel
|
||||||
import com.m57.hermescontrol.data.model.ProfileInfo
|
import com.m57.hermescontrol.data.model.ProfileInfo
|
||||||
import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
|
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.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||||
import com.m57.hermescontrol.data.ws.ModelCatalogStore
|
import com.m57.hermescontrol.data.ws.ModelCatalogStore
|
||||||
import com.m57.hermescontrol.data.ws.ModelOptionsRepository
|
import com.m57.hermescontrol.data.ws.ModelOptionsRepository
|
||||||
import com.m57.hermescontrol.ui.common.ToastHost
|
import com.m57.hermescontrol.ui.common.ToastHost
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -45,6 +50,7 @@ data class ModelUiState(
|
|||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val catalogLoading: Boolean = false,
|
val catalogLoading: Boolean = false,
|
||||||
val providers: List<ModelProvider> = emptyList(),
|
val providers: List<ModelProvider> = emptyList(),
|
||||||
|
val modelInfo: ModelInfoResponse? = null,
|
||||||
val activeProfile: ProfileInfo? = null,
|
val activeProfile: ProfileInfo? = null,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val toastMessage: String? = null,
|
val toastMessage: String? = null,
|
||||||
@@ -71,12 +77,19 @@ data class ModelUiState(
|
|||||||
|
|
||||||
class ModelViewModel(
|
class ModelViewModel(
|
||||||
private val catalogStore: ModelCatalogStore = ModelCatalogStore.shared,
|
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(),
|
) : ViewModel(),
|
||||||
ToastHost {
|
ToastHost {
|
||||||
constructor(repository: ModelOptionsRepository) : this(ModelCatalogStore(repository = repository))
|
constructor(repository: ModelOptionsRepository) : this(ModelCatalogStore(repository = repository))
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ModelUiState())
|
private val _uiState = MutableStateFlow(ModelUiState())
|
||||||
val uiState: StateFlow<ModelUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ModelUiState> = _uiState.asStateFlow()
|
||||||
|
private var modelInfoJob: Job? = null
|
||||||
|
private var modelInfoScope: DataScope? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
_uiState.update { it.copy(pinnedModels = AuthManager.getPinnedModels()) }
|
_uiState.update { it.copy(pinnedModels = AuthManager.getPinnedModels()) }
|
||||||
@@ -84,9 +97,18 @@ class ModelViewModel(
|
|||||||
catalogStore.state.collect { catalogState ->
|
catalogStore.state.collect { catalogState ->
|
||||||
// Empty state is meaningful: it is how ModelCatalogStore
|
// Empty state is meaningful: it is how ModelCatalogStore
|
||||||
// invalidates the previous DataScope. Never ignore it.
|
// 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 {
|
_uiState.update {
|
||||||
|
val modelInfo = if (scopeChanged) null else it.modelInfo
|
||||||
it.copy(
|
it.copy(
|
||||||
providers = catalogState.providers,
|
providers = catalogState.providers.withModelInfoCapabilities(modelInfo),
|
||||||
|
modelInfo = modelInfo,
|
||||||
catalogLoading = catalogState.isRefreshing,
|
catalogLoading = catalogState.isRefreshing,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -96,6 +118,7 @@ class ModelViewModel(
|
|||||||
|
|
||||||
fun loadAll(refresh: Boolean = false) {
|
fun loadAll(refresh: Boolean = false) {
|
||||||
_uiState.update { it.copy(isLoading = true, catalogLoading = true, errorMessage = null) }
|
_uiState.update { it.copy(isLoading = true, catalogLoading = true, errorMessage = null) }
|
||||||
|
loadModelCapabilities()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Phase 1: Launch fast lightweight calls first (profiles, aux, moa)
|
// Phase 1: Launch fast lightweight calls first (profiles, aux, moa)
|
||||||
val activeProfileDeferred =
|
val activeProfileDeferred =
|
||||||
@@ -199,6 +222,7 @@ class ModelViewModel(
|
|||||||
|
|
||||||
/** Fast refresh after model assignments/updates without re-fetching full provider catalog. */
|
/** Fast refresh after model assignments/updates without re-fetching full provider catalog. */
|
||||||
fun loadQuick() {
|
fun loadQuick() {
|
||||||
|
loadModelCapabilities(clearExisting = true)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val activeProfileDeferred =
|
val activeProfileDeferred =
|
||||||
async(Dispatchers.IO) {
|
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. */
|
/** Open the main model picker dialog. */
|
||||||
fun openMainModelPicker() {
|
fun openMainModelPicker() {
|
||||||
_uiState.update { it.copy(showMainModelPicker = true) }
|
_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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
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.ModelProvider
|
||||||
import com.m57.hermescontrol.data.model.PinnedModel
|
import com.m57.hermescontrol.data.model.PinnedModel
|
||||||
import com.m57.hermescontrol.ui.common.LoadingState
|
import com.m57.hermescontrol.ui.common.LoadingState
|
||||||
@@ -213,8 +214,7 @@ fun ModelPickerDialog(
|
|||||||
null
|
null
|
||||||
},
|
},
|
||||||
onClick = { onSelect(pinned.providerSlug, pinned.modelName) },
|
onClick = { onSelect(pinned.providerSlug, pinned.modelName) },
|
||||||
canDisableReasoning = caps?.can_disable_reasoning,
|
capabilities = caps,
|
||||||
supportsReasoning = caps?.reasoning,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,8 +294,7 @@ fun ModelPickerDialog(
|
|||||||
null
|
null
|
||||||
},
|
},
|
||||||
onClick = { onSelect(provider.slug, model) },
|
onClick = { onSelect(provider.slug, model) },
|
||||||
canDisableReasoning = caps?.can_disable_reasoning,
|
capabilities = caps,
|
||||||
supportsReasoning = caps?.reasoning,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,8 +314,7 @@ private fun ModelItemCard(
|
|||||||
onPinToggle: (() -> Unit)?,
|
onPinToggle: (() -> Unit)?,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
canDisableReasoning: Boolean? = null,
|
capabilities: ModelCapabilities? = null,
|
||||||
supportsReasoning: Boolean? = null,
|
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -348,19 +346,7 @@ private fun ModelItemCard(
|
|||||||
// No maxLines/ellipsis: a long model name wraps so the full
|
// No maxLines/ellipsis: a long model name wraps so the full
|
||||||
// name stays readable while the pin button keeps its spot.
|
// name stays readable while the pin button keeps its spot.
|
||||||
)
|
)
|
||||||
val hint =
|
ModelCapabilitySummary(capabilities)
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (onPinToggle != null) {
|
if (onPinToggle != null) {
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
@@ -486,7 +486,7 @@ private fun InstallSection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PluginCard(
|
internal fun PluginCard(
|
||||||
plugin: PluginInfo,
|
plugin: PluginInfo,
|
||||||
state: PluginsUiState,
|
state: PluginsUiState,
|
||||||
viewModel: PluginsViewModel,
|
viewModel: PluginsViewModel,
|
||||||
@@ -542,12 +542,7 @@ private fun PluginCard(
|
|||||||
|
|
||||||
// Status badges
|
// Status badges
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
val statusColor =
|
val statusColor = if (plugin.enabled) statusColors.success else statusColors.warning
|
||||||
when {
|
|
||||||
plugin.enabled -> statusColors.success
|
|
||||||
plugin.installed -> statusColors.warning
|
|
||||||
else -> statusColors.error
|
|
||||||
}
|
|
||||||
Text(
|
Text(
|
||||||
text = plugin.runtimeStatus ?: "inactive",
|
text = plugin.runtimeStatus ?: "inactive",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
@@ -591,9 +586,8 @@ private fun PluginCard(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.End,
|
horizontalArrangement = Arrangement.End,
|
||||||
) {
|
) {
|
||||||
if (plugin.installed) {
|
// Visibility toggle (only for active plugins with dashboard manifests)
|
||||||
// Visibility toggle (only for plugins with dashboard manifest)
|
if (plugin.installed && plugin.hasDashboardManifest) {
|
||||||
if (plugin.hasDashboardManifest) {
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { viewModel.togglePluginVisibility(plugin) },
|
onClick = { viewModel.togglePluginVisibility(plugin) },
|
||||||
enabled = !busy,
|
enabled = !busy,
|
||||||
@@ -613,8 +607,8 @@ private fun PluginCard(
|
|||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update button
|
// Existing update affordance for active Git installs.
|
||||||
if (plugin.canUpdateGit) {
|
if (plugin.installed && plugin.canUpdateGit) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { viewModel.updatePlugin(plugin.name) },
|
onClick = { viewModel.updatePlugin(plugin.name) },
|
||||||
enabled = !busy,
|
enabled = !busy,
|
||||||
@@ -624,8 +618,8 @@ private fun PluginCard(
|
|||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove button with confirmation
|
// Backend-provided removability, independent of runtime enabled state.
|
||||||
if (plugin.canRemove) {
|
if (plugin.removable) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { viewModel.requestRemovePlugin(plugin.name) },
|
onClick = { viewModel.requestRemovePlugin(plugin.name) },
|
||||||
enabled = !busy,
|
enabled = !busy,
|
||||||
@@ -643,12 +637,12 @@ private fun PluginCard(
|
|||||||
Text(stringResource(R.string.plugins_action_uninstall))
|
Text(stringResource(R.string.plugins_action_uninstall))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
if (!plugin.installed) {
|
||||||
Button(
|
Button(
|
||||||
onClick = { viewModel.activatePlugin(plugin) },
|
onClick = { viewModel.activatePlugin(plugin) },
|
||||||
enabled = !busy,
|
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.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
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.ToastHost
|
||||||
import com.m57.hermescontrol.ui.common.safeLaunchAction
|
import com.m57.hermescontrol.ui.common.safeLaunchAction
|
||||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -72,15 +74,18 @@ data class PluginsUiState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class PluginsViewModel :
|
class PluginsViewModel(
|
||||||
ViewModel(),
|
private val pluginRemoval: PluginRemovalRepository = PluginRemovalRepository(),
|
||||||
|
) : ViewModel(),
|
||||||
ToastHost {
|
ToastHost {
|
||||||
private val _uiState = MutableStateFlow(PluginsUiState())
|
private val _uiState = MutableStateFlow(PluginsUiState())
|
||||||
val uiState: StateFlow<PluginsUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<PluginsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
private val pluginsCache = SwrCache<String, PluginsHubResponse>()
|
private val pluginsCache = SwrCache<String, PluginsHubResponse>()
|
||||||
|
private var scopeGeneration = 0
|
||||||
|
|
||||||
fun clearScopeOwnedState() {
|
fun clearScopeOwnedState() {
|
||||||
|
scopeGeneration++
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -333,26 +338,24 @@ class PluginsViewModel :
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun activatePlugin(plugin: PluginInfo) {
|
fun activatePlugin(plugin: PluginInfo) {
|
||||||
viewModelScope.launch {
|
if (_uiState.value.rowBusy != null) return
|
||||||
val result =
|
safeLaunchAction(
|
||||||
withContext(Dispatchers.IO) {
|
onStart = { setRowBusy(plugin.name) },
|
||||||
safeApiCall { ApiClient.hermesApi.enablePlugin(plugin.name) }
|
apiCall = { safeApiCall { ApiClient.hermesApi.enablePlugin(plugin.name) } },
|
||||||
}
|
onSuccess = {
|
||||||
when (result) {
|
|
||||||
is NetworkResult.Success -> {
|
|
||||||
_uiState.update { it.copy(toastMessage = "Plugin enabled successfully") }
|
_uiState.update { it.copy(toastMessage = "Plugin enabled successfully") }
|
||||||
loadPlugins(forceRefresh = true)
|
loadPlugins(forceRefresh = true)
|
||||||
}
|
},
|
||||||
|
onError = { error ->
|
||||||
is NetworkResult.Failure -> {
|
_uiState.update { it.copy(toastMessage = "Failed to enable plugin: $error") }
|
||||||
_uiState.update { it.copy(toastMessage = "Failed to enable plugin: ${result.error.message}") }
|
},
|
||||||
}
|
onComplete = { clearRowBusy(plugin.name) },
|
||||||
}
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Show confirmation dialog for removing a plugin */
|
/** Show confirmation dialog for removing a plugin */
|
||||||
fun requestRemovePlugin(name: String) {
|
fun requestRemovePlugin(name: String) {
|
||||||
|
if (_uiState.value.plugins.none { it.name == name && it.removable }) return
|
||||||
_uiState.update { it.copy(removeConfirmPlugin = name) }
|
_uiState.update { it.copy(removeConfirmPlugin = name) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,26 +366,87 @@ class PluginsViewModel :
|
|||||||
|
|
||||||
fun confirmRemovePlugin() {
|
fun confirmRemovePlugin() {
|
||||||
val name = _uiState.value.removeConfirmPlugin ?: return
|
val name = _uiState.value.removeConfirmPlugin ?: return
|
||||||
_uiState.update { it.copy(removeConfirmPlugin = null) }
|
if (_uiState.value.plugins.none { it.name == name && it.removable } || _uiState.value.rowBusy != null) {
|
||||||
viewModelScope.launch {
|
cancelRemovePlugin()
|
||||||
setRowBusy(name)
|
return
|
||||||
val result =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
safeApiCall { ApiClient.hermesApi.uninstallPlugin(name) }
|
|
||||||
}
|
}
|
||||||
when (result) {
|
_uiState.update { it.copy(removeConfirmPlugin = null) }
|
||||||
|
val generation = scopeGeneration
|
||||||
|
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||||
|
setRowBusy(name)
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val result = pluginRemoval.remove(name)
|
||||||
|
if (generation != scopeGeneration ||
|
||||||
|
(requestScope != null && AuthManager.currentDataScope() != requestScope)
|
||||||
|
) {
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
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 -> {
|
is NetworkResult.Success -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Plugin uninstalled successfully") }
|
val data = refreshed.data
|
||||||
clearRowBusy(name)
|
requestScope?.let { pluginsCache.put(it.scopedKey("default"), data) }
|
||||||
loadPlugins(forceRefresh = true)
|
_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 -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Failed to uninstall plugin: ${result.error.message}") }
|
_uiState.update {
|
||||||
clearRowBusy(name)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updatePlugin(name: String) {
|
fun updatePlugin(name: String) {
|
||||||
|
|||||||
@@ -376,6 +376,12 @@ class SettingsViewModel(
|
|||||||
AuthManager.setKeepConnectedInBackground(enabled)
|
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. */
|
/** Clear all auth credentials — logs out and returns to landing screen. */
|
||||||
fun logout() {
|
fun logout() {
|
||||||
AuthManager.setToken(null)
|
AuthManager.setToken(null)
|
||||||
|
|||||||
@@ -165,6 +165,7 @@
|
|||||||
<string name="chat_input_placeholder_type_message">اكتب رسالة\u2026</string>
|
<string name="chat_input_placeholder_type_message">اكتب رسالة\u2026</string>
|
||||||
<string name="chat_tool_execution_error">خطأ في التنفيذ: %1$s</string>
|
<string name="chat_tool_execution_error">خطأ في التنفيذ: %1$s</string>
|
||||||
<string name="chat_tool_exit_code">رمز الخروج: %1$d</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_fallback">أداة</string>
|
||||||
<string name="chat_tool_show_raw">عرض ملف JSON الأولي</string>
|
<string name="chat_tool_show_raw">عرض ملف JSON الأولي</string>
|
||||||
<string name="chat_tool_show_parsed">عرض المخرجات المعالجة</string>
|
<string name="chat_tool_show_parsed">عرض المخرجات المعالجة</string>
|
||||||
@@ -941,6 +942,7 @@
|
|||||||
<string name="plugins_no_desc">لا يوجد وصف مقدم.</string>
|
<string name="plugins_no_desc">لا يوجد وصف مقدم.</string>
|
||||||
<string name="plugins_action_update">تحديث</string>
|
<string name="plugins_action_update">تحديث</string>
|
||||||
<string name="plugins_action_uninstall">إلغاء التثبيت</string>
|
<string name="plugins_action_uninstall">إلغاء التثبيت</string>
|
||||||
|
<string name="plugins_action_enable">تفعيل</string>
|
||||||
<string name="plugins_action_install">تثبيت</string>
|
<string name="plugins_action_install">تثبيت</string>
|
||||||
<string name="plugins_action_remove">إزالة</string>
|
<string name="plugins_action_remove">إزالة</string>
|
||||||
<string name="plugins_install_heading">تثبيت إضافة</string>
|
<string name="plugins_install_heading">تثبيت إضافة</string>
|
||||||
@@ -1493,6 +1495,33 @@
|
|||||||
<string name="session_integrations_close">إغلاق</string>
|
<string name="session_integrations_close">إغلاق</string>
|
||||||
<string name="session_integrations_menu_item">تكاملات الجلسة</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 -->
|
<!-- Media Player -->
|
||||||
<string name="media_player_play">تشغيل</string>
|
<string name="media_player_play">تشغيل</string>
|
||||||
<string name="media_player_pause">إيقاف مؤقت</string>
|
<string name="media_player_pause">إيقاف مؤقت</string>
|
||||||
@@ -1514,4 +1543,17 @@
|
|||||||
<string name="media_player_play_video">تشغيل الفيديو</string>
|
<string name="media_player_play_video">تشغيل الفيديو</string>
|
||||||
<string name="chat_session_preparing">جارٍ تجهيز الجلسة…</string>
|
<string name="chat_session_preparing">جارٍ تجهيز الجلسة…</string>
|
||||||
<string name="chat_session_not_ready">الجلسة غير جاهزة — أعد المحاولة للمتابعة</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>
|
</resources>
|
||||||
@@ -162,6 +162,7 @@
|
|||||||
<string name="chat_input_placeholder_type_message">메시지 입력…</string>
|
<string name="chat_input_placeholder_type_message">메시지 입력…</string>
|
||||||
<string name="chat_tool_execution_error">실행 오류: %1$s</string>
|
<string name="chat_tool_execution_error">실행 오류: %1$s</string>
|
||||||
<string name="chat_tool_exit_code">종료 코드: %1$d</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_fallback">도구</string>
|
||||||
<string name="chat_tool_show_raw">Raw JSON 보기</string>
|
<string name="chat_tool_show_raw">Raw JSON 보기</string>
|
||||||
<string name="chat_tool_show_parsed">파싱된 결과 보기</string>
|
<string name="chat_tool_show_parsed">파싱된 결과 보기</string>
|
||||||
@@ -793,6 +794,7 @@
|
|||||||
<string name="plugins_no_desc">설명이 없습니다.</string>
|
<string name="plugins_no_desc">설명이 없습니다.</string>
|
||||||
<string name="plugins_action_update">업데이트</string>
|
<string name="plugins_action_update">업데이트</string>
|
||||||
<string name="plugins_action_uninstall">제거</string>
|
<string name="plugins_action_uninstall">제거</string>
|
||||||
|
<string name="plugins_action_enable">활성화</string>
|
||||||
<string name="plugins_action_install">설치</string>
|
<string name="plugins_action_install">설치</string>
|
||||||
<string name="plugins_action_remove">제거</string>
|
<string name="plugins_action_remove">제거</string>
|
||||||
<string name="plugins_install_heading">플러그인 설치</string>
|
<string name="plugins_install_heading">플러그인 설치</string>
|
||||||
@@ -1197,4 +1199,44 @@
|
|||||||
<string name="media_player_play_video">비디오 재생</string>
|
<string name="media_player_play_video">비디오 재생</string>
|
||||||
<string name="chat_session_preparing">세션 준비 중…</string>
|
<string name="chat_session_preparing">세션 준비 중…</string>
|
||||||
<string name="chat_session_not_ready">세션이 준비되지 않았습니다. 다시 시도하세요</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>
|
</resources>
|
||||||
@@ -165,6 +165,7 @@
|
|||||||
<string name="chat_input_placeholder_type_message">输入消息…</string>
|
<string name="chat_input_placeholder_type_message">输入消息…</string>
|
||||||
<string name="chat_tool_execution_error">执行错误:%1$s</string>
|
<string name="chat_tool_execution_error">执行错误:%1$s</string>
|
||||||
<string name="chat_tool_exit_code">退出码:%1$d</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_fallback">工具</string>
|
||||||
<string name="chat_tool_show_raw">显示原始 JSON</string>
|
<string name="chat_tool_show_raw">显示原始 JSON</string>
|
||||||
<string name="chat_tool_show_parsed">显示解析输出</string>
|
<string name="chat_tool_show_parsed">显示解析输出</string>
|
||||||
@@ -908,6 +909,7 @@
|
|||||||
<string name="plugins_no_desc">无描述。</string>
|
<string name="plugins_no_desc">无描述。</string>
|
||||||
<string name="plugins_action_update">更新</string>
|
<string name="plugins_action_update">更新</string>
|
||||||
<string name="plugins_action_uninstall">卸载</string>
|
<string name="plugins_action_uninstall">卸载</string>
|
||||||
|
<string name="plugins_action_enable">启用</string>
|
||||||
<string name="plugins_action_install">安装</string>
|
<string name="plugins_action_install">安装</string>
|
||||||
<string name="plugins_action_remove">移除</string>
|
<string name="plugins_action_remove">移除</string>
|
||||||
<string name="plugins_install_heading">安装插件</string>
|
<string name="plugins_install_heading">安装插件</string>
|
||||||
@@ -1467,4 +1469,44 @@
|
|||||||
<string name="media_player_play_video">播放视频</string>
|
<string name="media_player_play_video">播放视频</string>
|
||||||
<string name="chat_session_preparing">正在准备会话…</string>
|
<string name="chat_session_preparing">正在准备会话…</string>
|
||||||
<string name="chat_session_not_ready">会话未就绪,请重试以继续</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>
|
</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="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_search">Search</string>
|
||||||
<string name="chat_action_close_search">Close 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_reconnect">Reconnect</string>
|
||||||
<string name="chat_action_relogin">Re-login</string>
|
<string name="chat_action_relogin">Re-login</string>
|
||||||
<string name="chat_relogin_title">Re-login to Hermes</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_input_placeholder_type_message">Type a message\u2026</string>
|
||||||
<string name="chat_tool_execution_error">Execution Error: %1$s</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_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_fallback">Tool</string>
|
||||||
<string name="chat_tool_show_raw">Show Raw JSON</string>
|
<string name="chat_tool_show_raw">Show Raw JSON</string>
|
||||||
<string name="chat_tool_show_parsed">Show Parsed Output</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_unassign">Unassign</string>
|
||||||
<string name="kanban_bulk_delete">Delete</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_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_export_board">Export Board</string>
|
||||||
<string name="kanban_import_board">Import Board</string>
|
<string name="kanban_import_board">Import Board</string>
|
||||||
<string name="kanban_runtime_working">Working</string>
|
<string name="kanban_runtime_working">Working</string>
|
||||||
@@ -1006,6 +1021,7 @@
|
|||||||
<string name="plugins_no_desc">No description provided.</string>
|
<string name="plugins_no_desc">No description provided.</string>
|
||||||
<string name="plugins_action_update">Update</string>
|
<string name="plugins_action_update">Update</string>
|
||||||
<string name="plugins_action_uninstall">Uninstall</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_install">Install</string>
|
||||||
<string name="plugins_action_remove">Remove</string>
|
<string name="plugins_action_remove">Remove</string>
|
||||||
<string name="plugins_install_heading">Install Plugin</string>
|
<string name="plugins_install_heading">Install Plugin</string>
|
||||||
@@ -1681,4 +1697,44 @@
|
|||||||
<string name="media_player_play_video">Play video</string>
|
<string name="media_player_play_video">Play video</string>
|
||||||
<string name="chat_session_preparing">Preparing session…</string>
|
<string name="chat_session_preparing">Preparing session…</string>
|
||||||
<string name="chat_session_not_ready">Session not ready — retry to continue</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>
|
</resources>
|
||||||
@@ -1256,6 +1256,17 @@ class E2eIntegrationTest {
|
|||||||
com.m57.hermescontrol.data.model
|
com.m57.hermescontrol.data.model
|
||||||
.UpdateTaskResponse(task = task.copy(status = "ready")),
|
.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)
|
viewModel.moveTask(viewModel.uiState.value.tasks[0], KanbanTaskAction.READY)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertEquals(
|
assertEquals(
|
||||||
|
|||||||
@@ -314,4 +314,57 @@ class KanbanSerializationTest {
|
|||||||
assertTrue(unassignJson.contains("\"assignee\":\"\""))
|
assertTrue(unassignJson.contains("\"assignee\":\"\""))
|
||||||
assertTrue(unassignJson.contains("\"reclaim_first\":true"))
|
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 okhttp3.mockwebserver.MockWebServer
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
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
|
@Test
|
||||||
fun getOAuthProviders_parsesList() =
|
fun getOAuthProviders_parsesList() =
|
||||||
runBlocking {
|
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.ReassignTaskBody
|
||||||
import com.m57.hermescontrol.data.model.ReclaimTaskBody
|
import com.m57.hermescontrol.data.model.ReclaimTaskBody
|
||||||
import com.m57.hermescontrol.data.model.RenameBoardBody
|
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 com.m57.hermescontrol.data.model.UpdateTaskBody
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
@@ -308,4 +311,155 @@ class KanbanApiServiceTest {
|
|||||||
assertEquals("POST", nudgeReq.method)
|
assertEquals("POST", nudgeReq.method)
|
||||||
assertEquals("/api/plugins/kanban/dispatch?board=dev", nudgeReq.path)
|
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.HttpUrl
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.mockwebserver.Dispatcher
|
||||||
import okhttp3.mockwebserver.MockResponse
|
import okhttp3.mockwebserver.MockResponse
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import okhttp3.mockwebserver.RecordedRequest
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
@@ -49,6 +51,21 @@ class ProfileScopeInterceptorTest {
|
|||||||
return server.takeRequest().requestUrl!!
|
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
|
@Test
|
||||||
fun scopedEndpoint_appendsProfile() {
|
fun scopedEndpoint_appendsProfile() {
|
||||||
val client = clientFor("work")
|
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
|
@Test
|
||||||
fun lookalikePath_notScoped() {
|
fun lookalikePath_notScoped() {
|
||||||
// Sourcery review (PR #540): `startsWith` must not match non-segment
|
// 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.CreateTaskBody
|
||||||
import com.m57.hermescontrol.data.model.CreateTaskResponse
|
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.KanbanBoardResponse
|
||||||
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
||||||
import com.m57.hermescontrol.data.model.KanbanTask
|
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.UpdateTaskBody
|
||||||
import com.m57.hermescontrol.data.model.UpdateTaskResponse
|
import com.m57.hermescontrol.data.model.UpdateTaskResponse
|
||||||
import com.m57.hermescontrol.data.remote.KanbanApiService
|
import com.m57.hermescontrol.data.remote.KanbanApiService
|
||||||
@@ -107,4 +111,28 @@ class KanbanRepositoryTest {
|
|||||||
val result = repository.getBoard("missing")
|
val result = repository.getBoard("missing")
|
||||||
assertTrue(result is NetworkResult.Failure)
|
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.local.DataScope
|
||||||
import com.m57.hermescontrol.data.model.ModelCapabilities
|
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.ModelOptionsResponse
|
||||||
import com.m57.hermescontrol.data.model.ModelProvider
|
import com.m57.hermescontrol.data.model.ModelProvider
|
||||||
import com.m57.hermescontrol.data.model.PinnedModel
|
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.CompletableDeferred
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
@@ -175,6 +177,41 @@ class ChatModelSwitchDelegateTest {
|
|||||||
scopeObserverJob.cancel()
|
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
|
@Test
|
||||||
fun closeAndReopenDuringPreload_doesNotDuplicateOrReopenAfterDismiss() =
|
fun closeAndReopenDuringPreload_doesNotDuplicateOrReopenAfterDismiss() =
|
||||||
testScope.runTest {
|
testScope.runTest {
|
||||||
@@ -610,4 +647,95 @@ class ChatModelSwitchDelegateTest {
|
|||||||
delegate.dismissModelSwitchConfirm()
|
delegate.dismissModelSwitchConfirm()
|
||||||
assertEquals("anthropic/claude-3", uiState.value.currentSessionModel)
|
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())
|
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
|
@Before
|
||||||
fun setup() {
|
fun setup() {
|
||||||
dao = FakeChatMessageDao()
|
dao = FakeChatMessageDao()
|
||||||
@@ -146,4 +176,123 @@ class ChatPersistenceRepositoryTest {
|
|||||||
assertEquals("msg-B1", loadedB[0].id)
|
assertEquals("msg-B1", loadedB[0].id)
|
||||||
assertEquals("msg-B2", loadedB[1].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.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertSame
|
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@@ -193,7 +192,7 @@ class ChatToolDedupeTest {
|
|||||||
|
|
||||||
assertEquals(3, deduped.size)
|
assertEquals(3, deduped.size)
|
||||||
// WS copies win; rest- copies of the same logical message are dropped
|
// 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-1" })
|
||||||
assertTrue(deduped.none { it.id == "rest-s-2" })
|
assertTrue(deduped.none { it.id == "rest-s-2" })
|
||||||
// rest- user kept when no WS copy exists
|
// rest- user kept when no WS copy exists
|
||||||
@@ -279,7 +278,7 @@ class ChatToolDedupeTest {
|
|||||||
val merged = mergeTranscriptWithLive(listOf(rest), listOf(live))
|
val merged = mergeTranscriptWithLive(listOf(rest), listOf(live))
|
||||||
|
|
||||||
// Preserves cache/live-only user flags such as continuesActiveTurn.
|
// Preserves cache/live-only user flags such as continuesActiveTurn.
|
||||||
assertSame(live, merged.single())
|
assertEquals(live.copy(restId = rest.id), merged.single())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -551,7 +550,7 @@ class ChatToolDedupeTest {
|
|||||||
val merged = mergeIncrementalTranscriptPage(incoming, current, "s", 1)
|
val merged = mergeIncrementalTranscriptPage(incoming, current, "s", 1)
|
||||||
|
|
||||||
assertEquals(listOf("retry", "retry"), merged.map { it.content })
|
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) ──
|
// ── 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))
|
val deduped = dedupeCachedMessages(listOf(wsTool, restTool))
|
||||||
assertEquals(1, deduped.size)
|
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 })
|
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
|
@Test
|
||||||
fun viewingOldCachedTextMustNotClearNewReplyNotification() {
|
fun viewingOldCachedTextMustNotClearNewReplyNotification() {
|
||||||
postTarget("Done")
|
postTarget("Done")
|
||||||
@@ -383,7 +549,11 @@ class ReadNotificationReviewRegressionTest {
|
|||||||
SessionMessage(id = 2, role = "assistant", content = JsonPrimitive("The result MEDIA:/opt/pic.png")),
|
SessionMessage(id = 2, role = "assistant", content = JsonPrimitive("The result MEDIA:/opt/pic.png")),
|
||||||
)
|
)
|
||||||
val mapped = mapServerMessages("session", history, 0, true, live, isPagingOlder = false)
|
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
|
@Test
|
||||||
|
|||||||
@@ -53,11 +53,13 @@ class SlashCommandDispatchRpcTest {
|
|||||||
private lateinit var app: Application
|
private lateinit var app: Application
|
||||||
private lateinit var fakeRepo: FakeChatPersistenceRepository
|
private lateinit var fakeRepo: FakeChatPersistenceRepository
|
||||||
private var reqCount = 0
|
private var reqCount = 0
|
||||||
|
private var sessionCreateRequestId: String? = null
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
Dispatchers.setMain(testDispatcher)
|
Dispatchers.setMain(testDispatcher)
|
||||||
reqCount = 0
|
reqCount = 0
|
||||||
|
sessionCreateRequestId = null
|
||||||
|
|
||||||
mockkStatic(android.util.Log::class)
|
mockkStatic(android.util.Log::class)
|
||||||
every { android.util.Log.d(any(), any()) } returns 0
|
every { android.util.Log.d(any(), any()) } returns 0
|
||||||
@@ -114,6 +116,9 @@ class SlashCommandDispatchRpcTest {
|
|||||||
every { HermesWsClient.send(any(), any(), any()) } answers {
|
every { HermesWsClient.send(any(), any(), any()) } answers {
|
||||||
reqCount++
|
reqCount++
|
||||||
val id = "req-id-$reqCount"
|
val id = "req-id-$reqCount"
|
||||||
|
if (arg<String>(0) == WsMethods.SESSION_CREATE) {
|
||||||
|
sessionCreateRequestId = id
|
||||||
|
}
|
||||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
@@ -152,8 +157,8 @@ class SlashCommandDispatchRpcTest {
|
|||||||
mockConnectionStatus.value = ConnectionStatus.CONNECTED
|
mockConnectionStatus.value = ConnectionStatus.CONNECTED
|
||||||
mockEventsFlow.emit(WsEvent.GatewayReady(null))
|
mockEventsFlow.emit(WsEvent.GatewayReady(null))
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
// req-id-3 = session.create (after loadSessions + fetchCommandCatalog)
|
val createRequestId = requireNotNull(sessionCreateRequestId) { "session.create was not sent" }
|
||||||
mockEventsFlow.emit(WsEvent.RpcResult("req-id-3", mapOf("session_id" to "session-xyz")))
|
mockEventsFlow.emit(WsEvent.RpcResult(createRequestId, mapOf("session_id" to "session-xyz")))
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
return Pair(vm, "session-xyz")
|
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.ChatMessageDao
|
||||||
import com.m57.hermescontrol.data.local.ChatMessageEntity
|
import com.m57.hermescontrol.data.local.ChatMessageEntity
|
||||||
|
import com.m57.hermescontrol.data.local.canonicalMessageOrder
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.ConcurrentMap
|
import java.util.concurrent.ConcurrentMap
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory [ChatMessageDao] for use in tests.
|
* 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 {
|
class FakeChatMessageDao : ChatMessageDao {
|
||||||
private val messages: ConcurrentMap<String, ChatMessageEntity> = ConcurrentHashMap()
|
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 sessionExists(sessionId: String): Boolean = messages.values.any { it.sessionId == sessionId }
|
||||||
|
|
||||||
override suspend fun getMessagesForSession(sessionId: String): List<ChatMessageEntity> =
|
override suspend fun getMessagesForSession(sessionId: String): List<ChatMessageEntity> {
|
||||||
messages.values
|
fullSessionReads++
|
||||||
|
beforeRead()
|
||||||
|
return messages.values
|
||||||
.filter { it.sessionId == sessionId }
|
.filter { it.sessionId == sessionId }
|
||||||
.sortedBy { it.timestamp }
|
.sortedWith(ordering)
|
||||||
|
|
||||||
override suspend fun upsert(message: ChatMessageEntity) {
|
|
||||||
messages[message.id] = message
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun upsertAll(messageList: List<ChatMessageEntity>) {
|
val pageLimits = mutableListOf<Int>()
|
||||||
messageList.forEach { messages[it.id] = it }
|
|
||||||
|
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) {
|
override suspend fun deleteMessagesForSession(sessionId: String) {
|
||||||
@@ -33,14 +84,25 @@ class FakeChatMessageDao : ChatMessageDao {
|
|||||||
|
|
||||||
/** Direct access for test setup — bypasses the suspend modifier. */
|
/** Direct access for test setup — bypasses the suspend modifier. */
|
||||||
fun addMessageDirect(message: ChatMessageEntity) {
|
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. */
|
/** Reset all stored messages. */
|
||||||
fun clear() {
|
fun clear() {
|
||||||
messages.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. */
|
/** Returns the number of stored messages. */
|
||||||
fun count(): Int = messages.size
|
fun count(): Int = messages.size
|
||||||
}
|
}
|
||||||
@@ -81,6 +81,41 @@ class ToolViewBuilderTest {
|
|||||||
|
|
||||||
assertEquals("Read a.kt L10-14", view.title)
|
assertEquals("Read a.kt L10-14", view.title)
|
||||||
assertEquals("/repo/src/a.kt", view.subtitle)
|
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
|
@Test
|
||||||
@@ -308,6 +343,35 @@ class ToolViewBuilderTest {
|
|||||||
assertEquals("poll: proc-1", view.subtitle)
|
assertEquals("poll: proc-1", view.subtitle)
|
||||||
assertTrue(view.detail.contains("Status: running"))
|
assertTrue(view.detail.contains("Status: running"))
|
||||||
assertTrue(view.detail.contains("listening on port 8080"))
|
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
|
@Test
|
||||||
|
|||||||
+56
-2
@@ -592,8 +592,27 @@ class ToolViewCharacterizationTest {
|
|||||||
|
|
||||||
assertEquals("https://img.example/cat.png (image)", view.subtitle)
|
assertEquals("https://img.example/cat.png (image)", view.subtitle)
|
||||||
assertEquals("🖼️ a cat\n\n🔗 https://img.example/cat.png", view.detail)
|
assertEquals("🖼️ a cat\n\n🔗 https://img.example/cat.png", view.detail)
|
||||||
// Pinned: imageUrlFor does not read the "image" key, so no thumbnail.
|
assertEquals("https://img.example/cat.png", view.imageUrl)
|
||||||
assertNull(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
|
@Test
|
||||||
@@ -739,6 +758,41 @@ class ToolViewCharacterizationTest {
|
|||||||
assertEquals("🔧 read_file\n Reads\n\n🔧 write_file", view.detail)
|
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
|
@Test
|
||||||
fun `read_terminal shows line window and terminal block`() {
|
fun `read_terminal shows line window and terminal block`() {
|
||||||
val view =
|
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