chore: upgrade ktlint to 1.8.0 and format codebase (#1024)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-31 12:27:40 +03:00
1 parent 3f3b853758
commit cfe3e736fd
66 files changed
+1287 -541

No files matched your search

+2 -2
View File
@@ -52,13 +52,13 @@ jobs:
uses: actions/cache@v6 uses: actions/cache@v6
with: with:
path: ~/.local/bin/ktlint path: ~/.local/bin/ktlint
key: ktlint-1.2.1 key: ktlint-1.8.0
- name: Install ktlint - name: Install ktlint
if: steps.cache-ktlint.outputs.cache-hit != 'true' if: steps.cache-ktlint.outputs.cache-hit != 'true'
run: | run: |
mkdir -p ~/.local/bin mkdir -p ~/.local/bin
curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.2.1/ktlint curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.8.0/ktlint
chmod +x ktlint chmod +x ktlint
mv ktlint ~/.local/bin/ mv ktlint ~/.local/bin/
+3 -3
View File
@@ -36,7 +36,7 @@ If you have a local Android SDK (`ANDROID_HOME` set), these work:
```bash ```bash
# Download the matching binary # Download the matching binary
curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.2.1/ktlint curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.8.0/ktlint
chmod +x ktlint chmod +x ktlint
./ktlint <file> # check one file ./ktlint <file> # check one file
./ktlint --format <file> # auto-fix ./ktlint --format <file> # auto-fix
@@ -48,7 +48,7 @@ chmod +x ktlint
| Job | Purpose | | Job | Purpose |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `ktlint` | ktlint 1.2.1 style check + `checkColorLiterals` (hardcoded Color guard, issue #622) | | `ktlint` | ktlint 1.8.0 style check + `checkColorLiterals` (hardcoded Color guard, issue #622) |
| `android-lint` | Android Lint | | `android-lint` | Android Lint |
| `unit-tests` | JUnit | | `unit-tests` | JUnit |
| `build` | assembleDebug (gated by the 3 above) | | `build` | assembleDebug (gated by the 3 above) |
@@ -68,7 +68,7 @@ Requires `permissions: contents: write` on the `build-release` job.
## Code Style ## Code Style
- **ktlint 1.2.1** is enforced in CI. Run `./ktlint --format` before pushing. - **ktlint 1.8.0** is enforced in CI. Run `./ktlint --format` before pushing.
- Import ordering is the #1 CI failure: ktlint enforces ASCII-lexicographic order - Import ordering is the #1 CI failure: ktlint enforces ASCII-lexicographic order
(uppercase before lowercase: `LaunchedEffect` before `collectAsState`). (uppercase before lowercase: `LaunchedEffect` before `collectAsState`).
- `const val` must use SCREAMING_SNAKE_CASE. - `const val` must use SCREAMING_SNAKE_CASE.
+1 -1
View File
@@ -73,7 +73,7 @@ We enforce Kotlin coding conventions and Jetpack Compose best practices.
### Kotlin Formatting ### Kotlin Formatting
- Code formatting is checked and enforced by **ktlint 1.2.1** in CI. The authoritative command is the Gradle task (not the standalone binary): - Code formatting is checked and enforced by **ktlint 1.8.0** in CI. The authoritative command is the Gradle task (not the standalone binary):
```bash ```bash
./gradlew ktlintCheck # check (CI-equivalent) ./gradlew ktlintCheck # check (CI-equivalent)
./gradlew ktlintFormat # auto-fix, then re-check with ktlintCheck ./gradlew ktlintFormat # auto-fix, then re-check with ktlintCheck
+1 -1
View File
@@ -151,7 +151,7 @@ app/src/main/java/com/m57/hermescontrol/
- **Theming:** 6 built-in presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Neon Noir) + Material You dynamic colors - **Theming:** 6 built-in presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Neon Noir) + Material You dynamic colors
- **Image Loading:** Coil 2.7.0 - **Image Loading:** Coil 2.7.0
- **Testing:** JUnit 5, MockK, Turbine, Espresso, Compose UI testing - **Testing:** JUnit 5, MockK, Turbine, Espresso, Compose UI testing
- **Formatting:** `ktlint` 1.2.1 style rules (checked automatically in CI) - **Formatting:** `ktlint` 1.8.0 style rules (checked automatically in CI)
--- ---
@@ -21,7 +21,9 @@ import com.m57.hermescontrol.MainActivity
* the launch works on every supported API level (the framework's * the launch works on every supported API level (the framework's
* [AssistState] default forwards to it below API 29). * [AssistState] default forwards to it below API 29).
*/ */
class AssistantSession(context: Context) : VoiceInteractionSession(context) { class AssistantSession(
context: Context,
) : VoiceInteractionSession(context) {
private var launchHandled = false private var launchHandled = false
override fun onShow( override fun onShow(
@@ -239,13 +239,14 @@ data class GroupChatRoomMeta(
) { ) {
val memberNames: List<String> val memberNames: List<String>
get() = get() =
members?.mapNotNull { el -> members
when (el) { ?.mapNotNull { el ->
is JsonPrimitive -> el.content when (el) {
is JsonObject -> el["name"]?.jsonPrimitive?.content ?: el["handle"]?.jsonPrimitive?.content is JsonPrimitive -> el.content
else -> null is JsonObject -> el["name"]?.jsonPrimitive?.content ?: el["handle"]?.jsonPrimitive?.content
} else -> null
}.orEmpty() }
}.orEmpty()
} }
@Serializable @Serializable
@@ -76,7 +76,9 @@ object GatewayFileClient {
token: String, token: String,
path: String, path: String,
): String? { ): String? {
val kind = com.m57.hermescontrol.ui.chat.mediaKindForPath(path) val kind =
com.m57.hermescontrol.ui.chat
.mediaKindForPath(path)
val endpoint = val endpoint =
if (kind == com.m57.hermescontrol.ui.chat.MediaKind.AUDIO || if (kind == com.m57.hermescontrol.ui.chat.MediaKind.AUDIO ||
kind == com.m57.hermescontrol.ui.chat.MediaKind.VIDEO kind == com.m57.hermescontrol.ui.chat.MediaKind.VIDEO
@@ -23,11 +23,21 @@ fun isLocalNetworkHost(host: String): Boolean {
val a = raw[0].toInt() and 0xFF val a = raw[0].toInt() and 0xFF
val b = raw[1].toInt() and 0xFF val b = raw[1].toInt() and 0xFF
return when { return when {
a == 10 -> true // 10.0.0.0/8 a == 10 -> true
a == 172 && b in 16..31 -> true // 172.16.0.0/12
a == 192 && b == 168 -> true // 192.168.0.0/16 // 10.0.0.0/8
a == 169 && b == 254 -> true // 169.254.0.0/16 link-local a == 172 && b in 16..31 -> true
a == 100 && b in 64..127 -> true // 100.64.0.0/10 CGNAT
// 172.16.0.0/12
a == 192 && b == 168 -> true
// 192.168.0.0/16
a == 169 && b == 254 -> true
// 169.254.0.0/16 link-local
a == 100 && b in 64..127 -> true
// 100.64.0.0/10 CGNAT
else -> false else -> false
} }
} }
@@ -13,7 +13,9 @@ sealed interface AppUpdateState {
data object Checking : AppUpdateState data object Checking : AppUpdateState
/** Latest release equals the installed version. */ /** Latest release equals the installed version. */
data class UpToDate(val latestTag: String) : AppUpdateState data class UpToDate(
val latestTag: String,
) : AppUpdateState
/** A newer release with an APK asset exists. */ /** A newer release with an APK asset exists. */
data class UpdateAvailable( data class UpdateAvailable(
@@ -24,15 +26,21 @@ sealed interface AppUpdateState {
) : AppUpdateState ) : AppUpdateState
/** APK download in progress; [progress] is 0..1. */ /** APK download in progress; [progress] is 0..1. */
data class Downloading(val progress: Float) : AppUpdateState data class Downloading(
val progress: Float,
) : AppUpdateState
/** Download finished and the system package installer was launched. */ /** Download finished and the system package installer was launched. */
data class Installing(val latestTag: String) : AppUpdateState data class Installing(
val latestTag: String,
) : AppUpdateState
/** Install-from-unknown-sources not granted for this app yet. */ /** Install-from-unknown-sources not granted for this app yet. */
data object NeedsUnknownSourcesPermission : AppUpdateState data object NeedsUnknownSourcesPermission : AppUpdateState
data class Error(val message: String) : AppUpdateState data class Error(
val message: String,
) : AppUpdateState
} }
/** The release tag a state carries, when it carries one. */ /** The release tag a state carries, when it carries one. */
@@ -256,12 +256,15 @@ object HermesWsClient {
is WsEvent.ThinkingDelta, is WsEvent.ThinkingDelta,
is WsEvent.ReasoningDelta, is WsEvent.ReasoningDelta,
is WsEvent.ToolStart, is WsEvent.ToolStart,
-> pendingReply = true -> {
pendingReply = true
}
is WsEvent.MessageComplete -> { is WsEvent.MessageComplete -> {
pendingReply = false pendingReply = false
disconnectIfIdleInBackground() disconnectIfIdleInBackground()
} }
else -> {} else -> {}
} }
} }
@@ -992,7 +995,9 @@ object HermesWsClient {
disconnectIfIdleInBackground() disconnectIfIdleInBackground()
} }
else -> Unit else -> {
Unit
}
} }
// tryEmit on a DROP_OLDEST flow only returns false when the // tryEmit on a DROP_OLDEST flow only returns false when the
// buffer is full AND no subscriber is draining; with extraBuffer=512 // buffer is full AND no subscriber is draining; with extraBuffer=512
@@ -44,12 +44,30 @@ data class JsonRpcError(
fun Any?.toJsonElement(): JsonElement = fun Any?.toJsonElement(): JsonElement =
when (this) { when (this) {
null -> JsonNull null -> {
is JsonElement -> this JsonNull
is Boolean -> JsonPrimitive(this) }
is Int -> JsonPrimitive(this)
is Long -> JsonPrimitive(this) is JsonElement -> {
is Float -> JsonPrimitive(this) this
}
is Boolean -> {
JsonPrimitive(this)
}
is Int -> {
JsonPrimitive(this)
}
is Long -> {
JsonPrimitive(this)
}
is Float -> {
JsonPrimitive(this)
}
is Double -> { is Double -> {
// Preserve integer semantics: 177.0 → JsonPrimitive(177) not "177.0" // Preserve integer semantics: 177.0 → JsonPrimitive(177) not "177.0"
if (this % 1.0 == 0.0 && this >= Int.MIN_VALUE.toDouble() && this <= Int.MAX_VALUE.toDouble()) { if (this % 1.0 == 0.0 && this >= Int.MIN_VALUE.toDouble() && this <= Int.MAX_VALUE.toDouble()) {
@@ -60,11 +78,26 @@ fun Any?.toJsonElement(): JsonElement =
JsonPrimitive(this) JsonPrimitive(this)
} }
} }
is Number -> JsonPrimitive(this)
is String -> JsonPrimitive(this) is Number -> {
is Map<*, *> -> JsonObject(this.map { it.key.toString() to it.value.toJsonElement() }.toMap()) JsonPrimitive(this)
is List<*> -> JsonArray(this.map { it.toJsonElement() }) }
else -> JsonPrimitive(this.toString())
is String -> {
JsonPrimitive(this)
}
is Map<*, *> -> {
JsonObject(this.map { it.key.toString() to it.value.toJsonElement() }.toMap())
}
is List<*> -> {
JsonArray(this.map { it.toJsonElement() })
}
else -> {
JsonPrimitive(this.toString())
}
} }
fun JsonElement.toAny(): Any? = fun JsonElement.toAny(): Any? =
@@ -117,7 +117,7 @@ active preset via `LocalHermesStatusColors`.
## Conventions ## Conventions
- ktlint 1.2.1 is enforced in CI. Run `./gradlew ktlintCheck` before committing - ktlint 1.8.0 is enforced in CI. Run `./gradlew ktlintCheck` before committing
(the gradle task is the gate, not the standalone `ktlint` binary). (the gradle task is the gate, not the standalone `ktlint` binary).
- Import order is ASCII-lexicographic (uppercase before lowercase). - Import order is ASCII-lexicographic (uppercase before lowercase).
- Every change goes through a PR — never push directly to `main`. - Every change goes through a PR — never push directly to `main`.
@@ -62,7 +62,12 @@ data class BotsUiState(
profiles.find { it.is_default == true || it.name == "default" } profiles.find { it.is_default == true || it.name == "default" }
?: profiles.firstOrNull() ?: profiles.firstOrNull()
val syncSnapshot = defaultProfile?.groupChatSyncSnapshot() val syncSnapshot = defaultProfile?.groupChatSyncSnapshot()
val deletedKeys = syncSnapshot?.deleted?.keys.orEmpty().map { it.lowercase() } val deletedKeys =
syncSnapshot
?.deleted
?.keys
.orEmpty()
.map { it.lowercase() }
val groupNameMap = linkedMapOf<String, String>() val groupNameMap = linkedMapOf<String, String>()
@@ -101,61 +106,63 @@ data class BotsUiState(
} }
} }
return groupNameMap.values.map { gName -> return groupNameMap.values
val lower = gName.lowercase() .map { gName ->
val matchingRoom = val lower = gName.lowercase()
snapshotRooms[gName] val matchingRoom =
?: snapshotRooms["name:$gName"] snapshotRooms[gName]
?: snapshotRooms["id:$gName"] ?: snapshotRooms["name:$gName"]
?: snapshotRooms.values.find { it.name.equals(gName, ignoreCase = true) } ?: snapshotRooms["id:$gName"]
?: snapshotRooms.values.find { it.name.equals(gName, ignoreCase = true) }
val roomMemberNames = matchingRoom?.memberNames.orEmpty().map { it.lowercase() } val roomMemberNames = matchingRoom?.memberNames.orEmpty().map { it.lowercase() }
val matchedProfiles = val matchedProfiles =
profiles.filter { profile -> profiles
val botGroups = profile.botMeta()?.allGroups.orEmpty() .filter { profile ->
botGroups.any { it.equals(gName, ignoreCase = true) } || val botGroups = profile.botMeta()?.allGroups.orEmpty()
roomMemberNames.contains(profile.name.lowercase()) || botGroups.any { it.equals(gName, ignoreCase = true) } ||
roomMemberNames.contains(profile.effectiveTitle.lowercase()) roomMemberNames.contains(profile.name.lowercase()) ||
}.toMutableList() roomMemberNames.contains(profile.effectiveTitle.lowercase())
}.toMutableList()
// If room listed members (e.g. remote bots or bots not in local profiles), ensure they are seated // If room listed members (e.g. remote bots or bots not in local profiles), ensure they are seated
if (matchingRoom != null && roomMemberNames.isNotEmpty()) { if (matchingRoom != null && roomMemberNames.isNotEmpty()) {
for (mName in matchingRoom.memberNames) { for (mName in matchingRoom.memberNames) {
val exists = val exists =
matchedProfiles.any { matchedProfiles.any {
it.name.equals(mName, ignoreCase = true) || it.name.equals(mName, ignoreCase = true) ||
it.effectiveTitle.equals(mName, ignoreCase = true) it.effectiveTitle.equals(mName, ignoreCase = true)
}
if (!exists) {
val local = profiles.find { it.name.equals(mName, ignoreCase = true) }
matchedProfiles.add(local ?: ProfileInfo(name = mName))
} }
if (!exists) {
val local = profiles.find { it.name.equals(mName, ignoreCase = true) }
matchedProfiles.add(local ?: ProfileInfo(name = mName))
} }
} }
}
var groupMembers: List<ProfileInfo> = matchedProfiles var groupMembers: List<ProfileInfo> = matchedProfiles
// Fallback: If group name was composed from bot names (e.g. "default, scoutbot") // Fallback: If group name was composed from bot names (e.g. "default, scoutbot")
if (groupMembers.isEmpty() && gName.contains(",")) { if (groupMembers.isEmpty() && gName.contains(",")) {
val targetNames = gName.split(",").map { it.trim().lowercase() } val targetNames = gName.split(",").map { it.trim().lowercase() }
groupMembers = profiles.filter { targetNames.contains(it.name.lowercase()) } groupMembers = profiles.filter { targetNames.contains(it.name.lowercase()) }
} }
val lastAt = val lastAt =
matchingRoom?.log?.lastOrNull()?.at matchingRoom?.log?.lastOrNull()?.at
?: matchingRoom?.updatedAt ?: matchingRoom?.updatedAt
?: 0L ?: 0L
GroupInfo( GroupInfo(
name = gName, name = gName,
members = groupMembers, members = groupMembers,
lastActivity = lastAt, lastActivity = lastAt,
)
}.sortedWith(
compareByDescending<GroupInfo> { it.lastActivity }
.thenBy { it.name },
) )
}.sortedWith(
compareByDescending<GroupInfo> { it.lastActivity }
.thenBy { it.name },
)
} }
val displayGroups: List<GroupInfo> val displayGroups: List<GroupInfo>
@@ -193,15 +200,13 @@ data class BotsUiState(
profile.name.lowercase().contains(query) || profile.name.lowercase().contains(query) ||
profile.effectiveTitle.lowercase().contains(query) || profile.effectiveTitle.lowercase().contains(query) ||
profile.effectiveDescription.lowercase().contains(query) profile.effectiveDescription.lowercase().contains(query)
} }.sortedWith(
.sortedWith(
compareByDescending<ProfileInfo> { it.name == activeProfileName } compareByDescending<ProfileInfo> { it.name == activeProfileName }
.thenByDescending { .thenByDescending {
it.canonical_session?.last_active it.canonical_session?.last_active
?: it.last_session?.last_active ?: it.last_session?.last_active
?: 0.0 ?: 0.0
} }.thenBy { it.name },
.thenBy { it.name },
) )
} }
} }
@@ -464,13 +469,14 @@ class BotsViewModel(
rooms = updatedRooms, rooms = updatedRooms,
deleted = updatedDeleted, deleted = updatedDeleted,
) )
HermesWsClient.request( HermesWsClient
WsMethods.PROFILES_CONFIGURE, .request(
mapOf( WsMethods.PROFILES_CONFIGURE,
"name" to defaultProfile.name, mapOf(
"ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()), "name" to defaultProfile.name,
), "ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()),
).await() ),
).await()
} }
} catch (_: Exception) { } catch (_: Exception) {
} }
@@ -528,13 +534,14 @@ class BotsViewModel(
rooms = updatedRooms, rooms = updatedRooms,
deleted = deletedMap, deleted = deletedMap,
) )
HermesWsClient.request( HermesWsClient
WsMethods.PROFILES_CONFIGURE, .request(
mapOf( WsMethods.PROFILES_CONFIGURE,
"name" to defaultProfile.name, mapOf(
"ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()), "name" to defaultProfile.name,
), "ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()),
).await() ),
).await()
} }
} }
} catch (_: Exception) { } catch (_: Exception) {
@@ -100,8 +100,7 @@ fun CreateGroupChatDialog(
} else { } else {
selectedBots + bot.name selectedBots + bot.name
} }
} }.padding(vertical = 4.dp),
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Checkbox( Checkbox(
@@ -303,12 +303,13 @@ class GroupChatViewModel(
val roomMemberNames = matchingRoom?.memberNames.orEmpty().map { it.lowercase() } val roomMemberNames = matchingRoom?.memberNames.orEmpty().map { it.lowercase() }
val matchedProfiles = val matchedProfiles =
allProfiles.filter { allProfiles
val botGroups = it.botMeta()?.allGroups.orEmpty() .filter {
botGroups.any { g -> g.equals(groupName, ignoreCase = true) } || val botGroups = it.botMeta()?.allGroups.orEmpty()
roomMemberNames.contains(it.name.lowercase()) || botGroups.any { g -> g.equals(groupName, ignoreCase = true) } ||
roomMemberNames.contains(it.effectiveTitle.lowercase()) roomMemberNames.contains(it.name.lowercase()) ||
}.toMutableList() roomMemberNames.contains(it.effectiveTitle.lowercase())
}.toMutableList()
// If room listed members (e.g. remote bots or bots not in local profiles), ensure they are seated // If room listed members (e.g. remote bots or bots not in local profiles), ensure they are seated
if (matchingRoom != null && roomMemberNames.isNotEmpty()) { if (matchingRoom != null && roomMemberNames.isNotEmpty()) {
@@ -350,7 +351,11 @@ class GroupChatViewModel(
initialMessages = initialMessages =
syncedLog.map { entry -> syncedLog.map { entry ->
val isUser = entry.from?.kind != "member" val isUser = entry.from?.kind != "member"
val senderName = entry.from?.name.orEmpty().ifBlank { if (isUser) "user" else "bot" } val senderName =
entry.from
?.name
.orEmpty()
.ifBlank { if (isUser) "user" else "bot" }
val memberProfile = val memberProfile =
allProfiles.find { allProfiles.find {
it.name.equals(senderName, ignoreCase = true) || it.name.equals(senderName, ignoreCase = true) ||
@@ -465,13 +470,14 @@ class GroupChatViewModel(
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap()) val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
HermesWsClient.request( HermesWsClient
WsMethods.PROFILES_CONFIGURE, .request(
mapOf( WsMethods.PROFILES_CONFIGURE,
"name" to defaultProfile.name, mapOf(
"ui_meta" to uiMetaPayload, "name" to defaultProfile.name,
), "ui_meta" to uiMetaPayload,
).await() ),
).await()
} catch (e: Exception) { } catch (e: Exception) {
Log.w("GroupChatViewModel", "updateGroupLimits failed: ${e.message}") Log.w("GroupChatViewModel", "updateGroupLimits failed: ${e.message}")
} }
@@ -559,13 +565,14 @@ class GroupChatViewModel(
val newSnapshot = existingSnapshot.copy(version = 3, updatedAt = now, rooms = updatedRooms) val newSnapshot = existingSnapshot.copy(version = 3, updatedAt = now, rooms = updatedRooms)
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap()) val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
HermesWsClient.request( HermesWsClient
WsMethods.PROFILES_CONFIGURE, .request(
mapOf( WsMethods.PROFILES_CONFIGURE,
"name" to defaultProfile.name, mapOf(
"ui_meta" to uiMetaPayload, "name" to defaultProfile.name,
), "ui_meta" to uiMetaPayload,
).await() ),
).await()
return true return true
} catch (e: Exception) { } catch (e: Exception) {
@@ -629,10 +636,11 @@ class GroupChatViewModel(
storedId?.let { inFlightTurns[it] = turnDeferred } storedId?.let { inFlightTurns[it] = turnDeferred }
try { try {
HermesWsClient.request( HermesWsClient
WsMethods.PROMPT_SUBMIT, .request(
mapOf("session_id" to runtimeId, "text" to prompt), WsMethods.PROMPT_SUBMIT,
).await() mapOf("session_id" to runtimeId, "text" to prompt),
).await()
} catch (submitErr: Exception) { } catch (submitErr: Exception) {
Log.w( Log.w(
"GroupChatViewModel", "GroupChatViewModel",
@@ -662,10 +670,11 @@ class GroupChatViewModel(
storedId?.let { inFlightTurns[it] = turnDeferred } storedId?.let { inFlightTurns[it] = turnDeferred }
try { try {
HermesWsClient.request( HermesWsClient
WsMethods.PROMPT_SUBMIT, .request(
mapOf("session_id" to runtimeId, "text" to prompt), WsMethods.PROMPT_SUBMIT,
).await() mapOf("session_id" to runtimeId, "text" to prompt),
).await()
} catch (retryErr: Exception) { } catch (retryErr: Exception) {
Log.e("GroupChatViewModel", "Retry prompt.submit failed for ${bot.name}: ${retryErr.message}") Log.e("GroupChatViewModel", "Retry prompt.submit failed for ${bot.name}: ${retryErr.message}")
return null return null
@@ -880,7 +889,10 @@ class GroupChatViewModel(
val bot = remainingInitial.removeAt(0) val bot = remainingInitial.removeAt(0)
val reply = executeBotTurn(bot, epoch, members) val reply = executeBotTurn(bot, epoch, members)
val nonSystemSize = _uiState.value.messages.filter { !it.isSystem }.size val nonSystemSize =
_uiState.value.messages
.filter { !it.isSystem }
.size
memberWatermarks[bot.name] = nonSystemSize memberWatermarks[bot.name] = nonSystemSize
if (reply != null) { if (reply != null) {
@@ -931,7 +943,10 @@ class GroupChatViewModel(
val bot = continuationResponders.removeAt(0) val bot = continuationResponders.removeAt(0)
val reply = executeBotTurn(bot, epoch, members) val reply = executeBotTurn(bot, epoch, members)
val nonSystemSize = _uiState.value.messages.filter { !it.isSystem }.size val nonSystemSize =
_uiState.value.messages
.filter { !it.isSystem }
.size
memberWatermarks[bot.name] = nonSystemSize memberWatermarks[bot.name] = nonSystemSize
if (reply != null) { if (reply != null) {
@@ -1055,13 +1070,14 @@ class GroupChatViewModel(
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap()) val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
HermesWsClient.request( HermesWsClient
WsMethods.PROFILES_CONFIGURE, .request(
mapOf( WsMethods.PROFILES_CONFIGURE,
"name" to defaultProfile.name, mapOf(
"ui_meta" to uiMetaPayload, "name" to defaultProfile.name,
), "ui_meta" to uiMetaPayload,
).await() ),
).await()
} catch (e: Exception) { } catch (e: Exception) {
Log.w("GroupChatViewModel", "persistSyncSnapshot failed: ${e.message}") Log.w("GroupChatViewModel", "persistSyncSnapshot failed: ${e.message}")
} }
@@ -38,14 +38,20 @@ fun toolCallMilestones(messages: List<ChatMessage>): Map<Int, Int> {
var toolCount = 0 var toolCount = 0
messages.forEachIndexed { index, message -> messages.forEachIndexed { index, message ->
when (message.role) { when (message.role) {
MessageRole.USER -> toolCount = 0 MessageRole.USER -> {
toolCount = 0
}
MessageRole.TOOL -> { MessageRole.TOOL -> {
toolCount += 1 toolCount += 1
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) { if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
milestones[index] = toolCount milestones[index] = toolCount
} }
} }
else -> Unit
else -> {
Unit
}
} }
} }
return milestones return milestones
@@ -180,15 +180,27 @@ class ChatMediaDelegate(
} }
} }
is GatewayFileResult.NotFound -> showOpenError("File not found on gateway: ${attachment.name}") is GatewayFileResult.NotFound -> {
is GatewayFileResult.Forbidden -> showOpenError("Access denied: ${attachment.name}") showOpenError("File not found on gateway: ${attachment.name}")
is GatewayFileResult.TooLarge -> showOpenError("File too large to save: ${attachment.name}") }
is GatewayFileResult.Unauthorized ->
is GatewayFileResult.Forbidden -> {
showOpenError("Access denied: ${attachment.name}")
}
is GatewayFileResult.TooLarge -> {
showOpenError("File too large to save: ${attachment.name}")
}
is GatewayFileResult.Unauthorized -> {
showOpenError( showOpenError(
"Session expired — reconnect to save: ${attachment.name}", "Session expired — reconnect to save: ${attachment.name}",
) )
is GatewayFileResult.Failure -> }
is GatewayFileResult.Failure -> {
showOpenError("Could not save ${attachment.name}: ${result.throwable.message}") showOpenError("Could not save ${attachment.name}: ${result.throwable.message}")
}
} }
} finally { } finally {
uiState.update { uiState.update {
@@ -611,12 +611,13 @@ fun ChatScreen(
onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() }, onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() },
onOpenSettings = { onOpenSettings = {
val intent = val intent =
android.content.Intent( android.content
android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, .Intent(
android.net.Uri.parse("package:${context.packageName}"), android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
).apply { android.net.Uri.parse("package:${context.packageName}"),
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) ).apply {
} addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
try { try {
context.startActivity(intent) context.startActivity(intent)
} catch (e: Exception) { } catch (e: Exception) {
@@ -79,10 +79,14 @@ internal fun canonicalToolResultKey(content: String): String? {
fun canon(e: kotlinx.serialization.json.JsonElement): String = fun canon(e: kotlinx.serialization.json.JsonElement): String =
when (e) { when (e) {
is kotlinx.serialization.json.JsonObject -> is kotlinx.serialization.json.JsonObject -> {
e.entries.sortedBy { it.key }.joinToString("|") { "${it.key}=${canon(it.value)}" } e.entries.sortedBy { it.key }.joinToString("|") { "${it.key}=${canon(it.value)}" }
is kotlinx.serialization.json.JsonArray -> }
is kotlinx.serialization.json.JsonArray -> {
e.joinToString(",") { canon(it) } e.joinToString(",") { canon(it) }
}
is kotlinx.serialization.json.JsonPrimitive -> { is kotlinx.serialization.json.JsonPrimitive -> {
// Canonicalize ALL numbers through double, collapsing int/float // Canonicalize ALL numbers through double, collapsing int/float
// spellings of the same value (0 vs 0.0 → "i0", 0.5 → "d0.5"). // spellings of the same value (0 vs 0.0 → "i0", 0.5 → "d0.5").
@@ -96,9 +100,13 @@ internal fun canonicalToolResultKey(content: String): String? {
} }
} }
return when (element) { return when (element) {
is kotlinx.serialization.json.JsonObject -> is kotlinx.serialization.json.JsonObject -> {
element["result"]?.let { canon(it) } ?: canon(element) element["result"]?.let { canon(it) } ?: canon(element)
else -> canon(element) }
else -> {
canon(element)
}
} }
} }
@@ -178,8 +186,7 @@ internal fun stripAttachmentRefLines(content: String): String =
line.startsWith("@image:") || line.startsWith("@image:") ||
line.startsWith("@file:") || line.startsWith("@file:") ||
line == "[screenshot]" line == "[screenshot]"
} }.joinToString("\n")
.joinToString("\n")
.trim() .trim()
/** /**
@@ -764,7 +771,9 @@ class ChatViewModel(
streamingController.flushPendingTokens() streamingController.flushPendingTokens()
} }
else -> Unit else -> {
Unit
}
} }
// First, let the reducer compute the new state and any effects // First, let the reducer compute the new state and any effects
@@ -2168,7 +2177,10 @@ class ChatViewModel(
sessionHasServerPresence = true sessionHasServerPresence = true
sessionGoneRecoveryInFlight = false sessionGoneRecoveryInFlight = false
pendingGoneSessionNotice = false pendingGoneSessionNotice = false
val title = _uiState.value.sessions.find { it.id == sessionId }?.title ?: "Hermes" val title =
_uiState.value.sessions
.find { it.id == sessionId }
?.title ?: "Hermes"
val generation = resetSessionState(sessionId, title, isLoading = true) val generation = resetSessionState(sessionId, title, isLoading = true)
viewModelScope.launch { viewModelScope.launch {
// Warm-cache fast-path (desktop parity): paint the cached Room // Warm-cache fast-path (desktop parity): paint the cached Room
@@ -9,9 +9,7 @@ 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
private fun List<ChatMessage>.upsertById(message: ChatMessage): List<ChatMessage> { private fun List<ChatMessage>.upsertById(message: ChatMessage): List<ChatMessage> = (this + message).dedupeById()
return (this + message).dedupeById()
}
/** /**
* Strips this turn's sealed-orphan prefix from the final message text. * Strips this turn's sealed-orphan prefix from the final message text.
@@ -943,15 +941,16 @@ fun extractTodosFromJson(content: String): List<TodoItem>? {
?: ((obj["parameters"] as? JsonObject)?.get("todos") as? JsonArray) ?: ((obj["parameters"] as? JsonObject)?.get("todos") as? JsonArray)
?: return null ?: return null
todosArray.mapNotNull { item -> todosArray
val itemObj = item as? JsonObject ?: return@mapNotNull null .mapNotNull { item ->
val id = (itemObj["id"] as? JsonPrimitive)?.content ?: return@mapNotNull null val itemObj = item as? JsonObject ?: return@mapNotNull null
val contentStr = val id = (itemObj["id"] as? JsonPrimitive)?.content ?: return@mapNotNull null
(itemObj["content"] as? JsonPrimitive)?.content val contentStr =
?: (itemObj["text"] as? JsonPrimitive)?.content ?: "" (itemObj["content"] as? JsonPrimitive)?.content
val status = (itemObj["status"] as? JsonPrimitive)?.content ?: "pending" ?: (itemObj["text"] as? JsonPrimitive)?.content ?: ""
TodoItem(id = id, content = contentStr, status = status) val status = (itemObj["status"] as? JsonPrimitive)?.content ?: "pending"
}.takeIf { it.isNotEmpty() } TodoItem(id = id, content = contentStr, status = status)
}.takeIf { it.isNotEmpty() }
} catch (_: Exception) { } catch (_: Exception) {
null null
} }
@@ -580,9 +580,13 @@ private fun tableTextAlign(align: TableAlign?): TextAlign? =
} }
internal sealed interface InlineMathSegment { internal sealed interface InlineMathSegment {
data class Text(val value: String) : InlineMathSegment data class Text(
val value: String,
) : InlineMathSegment
data class Math(val latex: String) : InlineMathSegment data class Math(
val latex: String,
) : InlineMathSegment
} }
/** Splits `$…$` without treating escaped dollars or inline-code contents as math. */ /** Splits `$…$` without treating escaped dollars or inline-code contents as math. */
@@ -26,11 +26,26 @@ class SlashCommandDispatcher {
val cmd = parts[0].lowercase() val cmd = parts[0].lowercase()
return when (cmd) { return when (cmd) {
"/stop", "/interrupt" -> SlashResult.Interrupt "/stop", "/interrupt" -> {
"/new" -> SlashResult.NewSession SlashResult.Interrupt
"/fork", "/branch" -> SlashResult.SessionBranch }
"/model" -> SlashResult.ModelSwitch
"/update" -> SlashResult.Update "/new" -> {
SlashResult.NewSession
}
"/fork", "/branch" -> {
SlashResult.SessionBranch
}
"/model" -> {
SlashResult.ModelSwitch
}
"/update" -> {
SlashResult.Update
}
"/queue", "/q" -> { "/queue", "/q" -> {
val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim() val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim()
// Bubble shows the queued TEXT (prefix stripped) so the // Bubble shows the queued TEXT (prefix stripped) so the
@@ -41,8 +56,14 @@ class SlashCommandDispatcher {
// text so the usage message has something to sit under. // text so the usage message has something to sit under.
SlashResult.QueuePrompt(displayContent = arg.ifBlank { command }) SlashResult.QueuePrompt(displayContent = arg.ifBlank { command })
} }
"/resume", "/history" -> SlashResult.OpenHistory
else -> SlashResult.RpcDispatch "/resume", "/history" -> {
SlashResult.OpenHistory
}
else -> {
SlashResult.RpcDispatch
}
} }
} }
} }
@@ -105,5 +126,7 @@ sealed class SlashResult {
* argument). Matching the server echo verbatim lets the transcript sync * argument). Matching the server echo verbatim lets the transcript sync
* dedupe the two copies of the same logical message. * dedupe the two copies of the same logical message.
*/ */
data class QueuePrompt(val displayContent: String) : SlashResult() data class QueuePrompt(
val displayContent: String,
) : SlashResult()
} }
@@ -236,8 +236,7 @@ fun ChatInputBar(
onInputChange( onInputChange(
ChatInputPolicy.applyMention(inputFieldValue, bot.name), ChatInputPolicy.applyMention(inputFieldValue, bot.name),
) )
} }.padding(horizontal = 12.dp, vertical = 8.dp),
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
BotAvatar( BotAvatar(
@@ -107,7 +107,8 @@ fun parseDiffText(
line.startsWith("--- ") || line.startsWith("+++ ") -> { line.startsWith("--- ") || line.startsWith("+++ ") -> {
if (extractedPath == null || extractedPath == defaultPath) { if (extractedPath == null || extractedPath == defaultPath) {
val candidate = val candidate =
line.drop(4) line
.drop(4)
.trim() .trim()
.removePrefix("a/") .removePrefix("a/")
.removePrefix("b/") .removePrefix("b/")
@@ -137,8 +137,7 @@ fun ReasoningCard(
clipboard.setPrimaryClip(ClipData.newPlainText(null, reasoningText)) clipboard.setPrimaryClip(ClipData.newPlainText(null, reasoningText))
copied = true copied = true
}, },
) ).testTag("reasoning_card"),
.testTag("reasoning_card"),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
@@ -194,15 +194,22 @@ private fun TodoInspectionCard(todo: TodoItem) {
val statusColors = LocalHermesStatusColors.current val statusColors = LocalHermesStatusColors.current
val (statusIcon, statusTint) = val (statusIcon, statusTint) =
when { when {
todo.isCompleted -> todo.isCompleted -> {
Icons.Filled.CheckCircle to statusColors.success Icons.Filled.CheckCircle to statusColors.success
todo.isInProgress -> }
todo.isInProgress -> {
Icons.Filled.Autorenew to MaterialTheme.colorScheme.primary Icons.Filled.Autorenew to MaterialTheme.colorScheme.primary
todo.isCancelled -> }
todo.isCancelled -> {
Icons.Filled.Cancel to statusColors.warning Icons.Filled.Cancel to statusColors.warning
else -> }
else -> {
Icons.Filled.RadioButtonUnchecked to Icons.Filled.RadioButtonUnchecked to
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
}
} }
Icon( Icon(
imageVector = statusIcon, imageVector = statusIcon,
@@ -242,12 +249,17 @@ private fun InspectionItemCard(indicator: SubagentIndicator) {
val statusColors = LocalHermesStatusColors.current val statusColors = LocalHermesStatusColors.current
val (statusIcon, statusTint) = val (statusIcon, statusTint) =
when { when {
indicator.isComplete -> indicator.isComplete -> {
Icons.Filled.CheckCircle to statusColors.success Icons.Filled.CheckCircle to statusColors.success
indicator.isFailed -> }
indicator.isFailed -> {
Icons.Filled.Cancel to statusColors.error Icons.Filled.Cancel to statusColors.error
else -> }
else -> {
Icons.Filled.Autorenew to MaterialTheme.colorScheme.primary Icons.Filled.Autorenew to MaterialTheme.colorScheme.primary
}
} }
Icon( Icon(
imageVector = statusIcon, imageVector = statusIcon,
@@ -13,18 +13,28 @@ import com.m57.hermescontrol.ui.chat.MessageRole
*/ */
sealed interface ChatTurn { sealed interface ChatTurn {
/** User message — always its own turn (bubble anchor). */ /** User message — always its own turn (bubble anchor). */
data class User(val message: ChatMessage) : ChatTurn data class User(
val message: ChatMessage,
) : ChatTurn
/** One agent turn: prose, tool rows, and system events in order. */ /** One agent turn: prose, tool rows, and system events in order. */
data class Agent(val entries: List<AgentEntry>) : ChatTurn data class Agent(
val entries: List<AgentEntry>,
) : ChatTurn
} }
sealed interface AgentEntry { sealed interface AgentEntry {
data class Prose(val message: ChatMessage) : AgentEntry data class Prose(
val message: ChatMessage,
) : AgentEntry
data class ToolRow(val message: ChatMessage) : AgentEntry data class ToolRow(
val message: ChatMessage,
) : AgentEntry
data class SystemEvent(val message: ChatMessage) : AgentEntry data class SystemEvent(
val message: ChatMessage,
) : AgentEntry
} }
/** /**
@@ -71,21 +81,31 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
// display_kind (it's stripped on persistence); detect it by its // display_kind (it's stripped on persistence); detect it by its
// stable content prefix and route it as a system event too, tagging // stable content prefix and route it as a system event too, tagging
// it so the timeline chip can name it. // it so the timeline chip can name it.
message.displayKind != null -> message.displayKind != null -> {
agentEntries += AgentEntry.SystemEvent(message) agentEntries += AgentEntry.SystemEvent(message)
}
message.isSyntheticSystemRow() -> message.isSyntheticSystemRow() -> {
agentEntries += agentEntries +=
AgentEntry.SystemEvent(message.copy(displayKind = "max_iterations_reached")) AgentEntry.SystemEvent(message.copy(displayKind = "max_iterations_reached"))
}
message.role == MessageRole.USER -> { message.role == MessageRole.USER -> {
flushAgent() flushAgent()
turns += ChatTurn.User(message) turns += ChatTurn.User(message)
} }
MessageRole.ASSISTANT == message.role -> agentEntries += AgentEntry.Prose(message) MessageRole.ASSISTANT == message.role -> {
MessageRole.TOOL == message.role -> agentEntries += AgentEntry.ToolRow(message) agentEntries += AgentEntry.Prose(message)
MessageRole.SYSTEM == message.role -> agentEntries += AgentEntry.SystemEvent(message) }
MessageRole.TOOL == message.role -> {
agentEntries += AgentEntry.ToolRow(message)
}
MessageRole.SYSTEM == message.role -> {
agentEntries += AgentEntry.SystemEvent(message)
}
} }
} }
flushAgent() flushAgent()
@@ -167,8 +187,13 @@ fun messageIdToLazyIndex(
itemIndex++ itemIndex++
} }
is AgentEntry.ToolRow -> itemIndex++ is AgentEntry.ToolRow -> {
is AgentEntry.SystemEvent -> itemIndex++ itemIndex++
}
is AgentEntry.SystemEvent -> {
itemIndex++
}
} }
} }
} }
@@ -218,8 +218,7 @@ object ToolResultSummary {
.mapNotNull { k -> .mapNotNull { k ->
val s = summarizeScalar(record[k]) val s = summarizeScalar(record[k])
if (s.isEmpty()) null else "${titleCase(k)}: $s" if (s.isEmpty()) null else "${titleCase(k)}: $s"
} }.take(2)
.take(2)
return if (pairs.isNotEmpty()) pairs.joinToString(" · ") else pluralize(record.keys.size, "field") return if (pairs.isNotEmpty()) pairs.joinToString(" · ") else pluralize(record.keys.size, "field")
} }
@@ -474,12 +473,13 @@ object ToolResultSummary {
if (v is JsonArray) { if (v is JsonArray) {
return clipBlock( return clipBlock(
v.mapNotNull { v
valueErrorText(it).takeIf { .mapNotNull {
s -> valueErrorText(it).takeIf { s ->
s.isNotEmpty() s.isNotEmpty()
} }
}.take(3).joinToString("; "), }.take(3)
.joinToString("; "),
700, 700,
12, 12,
) )
@@ -110,7 +110,10 @@ object ToolViewBuilder {
private fun parseMaybeObject(value: JsonElement?): JsonObject? = private fun parseMaybeObject(value: JsonElement?): JsonObject? =
when (value) { when (value) {
is JsonObject -> value is JsonObject -> {
value
}
is JsonPrimitive -> { is JsonPrimitive -> {
if (!value.isString || value.content.trim().isEmpty()) { if (!value.isString || value.content.trim().isEmpty()) {
null null
@@ -122,7 +125,10 @@ object ToolViewBuilder {
} }
} }
} }
else -> null
else -> {
null
}
} }
private fun unwrapToolPayload(value: JsonElement?): JsonElement? { private fun unwrapToolPayload(value: JsonElement?): JsonElement? {
@@ -459,7 +465,13 @@ object ToolViewBuilder {
val lines = val lines =
content content
.split("\n") .split("\n")
.mapNotNull { line -> Regex("^(\\d+)\\|").find(line)?.groupValues?.get(1)?.toIntOrNull() } .mapNotNull { line ->
Regex("^(\\d+)\\|")
.find(line)
?.groupValues
?.get(1)
?.toIntOrNull()
}
if (lines.isNotEmpty()) { if (lines.isNotEmpty()) {
lineLabel = lineLabel =
@@ -659,13 +671,11 @@ object ToolViewBuilder {
Regex( Regex(
"""\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b""", """\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b""",
RegexOption.IGNORE_CASE, RegexOption.IGNORE_CASE,
) ).find(t)
.find(t)
?: Regex( ?: Regex(
"""\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b""", """\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b""",
RegexOption.IGNORE_CASE, RegexOption.IGNORE_CASE,
) ).find(t)
.find(t)
val n = unitMatch?.groupValues?.get(1)?.toIntOrNull() ?: return null val n = unitMatch?.groupValues?.get(1)?.toIntOrNull() ?: return null
val noun = unitMatch.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: fallbackNoun val noun = unitMatch.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: fallbackNoun
@@ -715,12 +725,11 @@ object ToolViewBuilder {
return if (payload === record) emptyList() else collectResultItems(payload) return if (payload === record) emptyList() else collectResultItems(payload)
} }
private fun cleanVisibleText(text: String): String { private fun cleanVisibleText(text: String): String =
return text text
.replace(Regex("`{3,}"), "") .replace(Regex("`{3,}"), "")
.replace(Regex("(?<=[\\p{L}\\p{N})\\].,!?:;\"'”’])\\[(?:\\d+(?:\\s*,\\s*\\d+)*)\\](?!\\()"), "") .replace(Regex("(?<=[\\p{L}\\p{N})\\].,!?:;\"'”’])\\[(?:\\d+(?:\\s*,\\s*\\d+)*)\\](?!\\()"), "")
.replace(Regex("\\[([^\\]]+)\\]\\(([^)\\s]+)\\)")) { m -> "${m.groupValues[1]} ${m.groupValues[2]}" } .replace(Regex("\\[([^\\]]+)\\]\\(([^)\\s]+)\\)")) { m -> "${m.groupValues[1]} ${m.groupValues[2]}" }
}
private fun extractSearchResults( private fun extractSearchResults(
result: JsonElement?, result: JsonElement?,
@@ -735,8 +744,7 @@ object ToolViewBuilder {
url = firstString(r, listOf("url", "href", "link")), url = firstString(r, listOf("url", "href", "link")),
snippet = cleanVisibleText(firstString(r, listOf("snippet", "description", "body"))), snippet = cleanVisibleText(firstString(r, listOf("snippet", "description", "body"))),
) )
} }.filter { it.title.isNotEmpty() || it.url.isNotEmpty() }
.filter { it.title.isNotEmpty() || it.url.isNotEmpty() }
.take(limit) .take(limit)
} }
@@ -913,12 +921,24 @@ object ToolViewBuilder {
private fun pendingTitle( private fun pendingTitle(
toolName: String, toolName: String,
args: JsonObject?, args: JsonObject?,
): String { ): String =
return when (toolName) { when (toolName) {
"web_extract" -> "Reading ${targetHostname(toolName, args, null)}" "web_extract" -> {
"browser_navigate" -> "Opening ${targetHostname(toolName, args, null)}" "Reading ${targetHostname(toolName, args, null)}"
"web_search" -> "Searching \"${compactPreview(searchQueryFor(args), 48)}\"" }
"read_file" -> "Reading ${readFileDisplayTarget(args, null)}"
"browser_navigate" -> {
"Opening ${targetHostname(toolName, args, null)}"
}
"web_search" -> {
"Searching \"${compactPreview(searchQueryFor(args), 48)}\""
}
"read_file" -> {
"Reading ${readFileDisplayTarget(args, null)}"
}
"terminal", "execute_code" -> { "terminal", "execute_code" -> {
val command = shellCommand(args) val command = shellCommand(args)
val verb = if (toolName == "execute_code") "Running code" else "Running" val verb = if (toolName == "execute_code") "Running code" else "Running"
@@ -931,21 +951,38 @@ object ToolViewBuilder {
titleForTool(toolName) titleForTool(toolName)
} }
} }
"memory" -> "Saving"
else -> titleForTool(toolName) "memory" -> {
"Saving"
}
else -> {
titleForTool(toolName)
}
} }
}
private fun doneTitle( private fun doneTitle(
toolName: String, toolName: String,
args: JsonObject?, args: JsonObject?,
result: JsonObject?, result: JsonObject?,
): String { ): String =
return when (toolName) { when (toolName) {
"web_extract" -> "Read ${targetHostname(toolName, args, result)}" "web_extract" -> {
"browser_navigate" -> "Opened ${targetHostname(toolName, args, result)}" "Read ${targetHostname(toolName, args, result)}"
"web_search" -> "Searched \"${compactPreview(searchQueryFor(args), 48)}\"" }
"read_file" -> "Read ${readFileDisplayTarget(args, result)}"
"browser_navigate" -> {
"Opened ${targetHostname(toolName, args, result)}"
}
"web_search" -> {
"Searched \"${compactPreview(searchQueryFor(args), 48)}\""
}
"read_file" -> {
"Read ${readFileDisplayTarget(args, result)}"
}
"terminal", "execute_code" -> { "terminal", "execute_code" -> {
val command = shellCommand(args) val command = shellCommand(args)
val verb = if (toolName == "execute_code") "Ran code" else "Ran" val verb = if (toolName == "execute_code") "Ran code" else "Ran"
@@ -958,6 +995,7 @@ object ToolViewBuilder {
titleForTool(toolName) titleForTool(toolName)
} }
} }
"memory" -> { "memory" -> {
val action = firstString(args, listOf("action")).lowercase() val action = firstString(args, listOf("action")).lowercase()
val target = firstString(args, listOf("target")) val target = firstString(args, listOf("target"))
@@ -973,27 +1011,35 @@ object ToolViewBuilder {
// pre-engine mobile summary did. // pre-engine mobile summary did.
if (target.isNotEmpty()) "$base ($target)" else base if (target.isNotEmpty()) "$base ($target)" else base
} }
"cronjob" -> "Cron ${firstString(args, listOf("action")).ifEmpty { "manage" }}"
"cronjob" -> {
"Cron ${firstString(args, listOf("action")).ifEmpty { "manage" }}"
}
"edit_file", "patch", "write_file" -> { "edit_file", "patch", "write_file" -> {
val path = fileEditPath(args, result) val path = fileEditPath(args, result)
if (path.isNotEmpty()) fileEditBasename(path) else titleForTool(toolName) if (path.isNotEmpty()) fileEditBasename(path) else titleForTool(toolName)
} }
else -> titleForTool(toolName)
else -> {
titleForTool(toolName)
}
} }
}
private fun errorTitle( private fun errorTitle(
toolName: String, toolName: String,
result: JsonObject?, result: JsonObject?,
): String { ): String =
return when (toolName) { when (toolName) {
"browser_navigate" -> { "browser_navigate" -> {
val url = findFirstUrl(result).ifEmpty { result?.get("url")?.toString() ?: "" } val url = findFirstUrl(result).ifEmpty { result?.get("url")?.toString() ?: "" }
if (url.isNotEmpty()) "Failed to open ${hostnameOf(url)}" else titleForTool(toolName) if (url.isNotEmpty()) "Failed to open ${hostnameOf(url)}" else titleForTool(toolName)
} }
else -> titleForTool(toolName)
else -> {
titleForTool(toolName)
}
} }
}
private fun targetHostname( private fun targetHostname(
toolName: String, toolName: String,
@@ -1033,6 +1079,7 @@ object ToolViewBuilder {
if (url.isNotEmpty()) hostnameOf(url) else "Navigated in browser" if (url.isNotEmpty()) hostnameOf(url) else "Navigated in browser"
} }
"browser_snapshot" -> { "browser_snapshot" -> {
val snapshot = firstString(result, listOf("snapshot")) val snapshot = firstString(result, listOf("snapshot"))
if (snapshot.isNotEmpty()) { if (snapshot.isNotEmpty()) {
@@ -1043,6 +1090,7 @@ object ToolViewBuilder {
"Captured a browser accessibility snapshot" "Captured a browser accessibility snapshot"
} }
} }
"browser_click" -> { "browser_click" -> {
val clicked = val clicked =
firstString( firstString(
@@ -1058,21 +1106,24 @@ object ToolViewBuilder {
"Clicked $clicked" "Clicked $clicked"
} }
} }
"browser_fill", "browser_type" -> { "browser_fill", "browser_type" -> {
val field = firstString(args, listOf("label", "field", "ref", "target")) val field = firstString(args, listOf("label", "field", "ref", "target"))
val value = firstString(args, listOf("value", "text")) val value = firstString(args, listOf("value", "text"))
listOf( listOf(
field.takeIf { it.isNotEmpty() }?.let { "Field: $it" }, field.takeIf { it.isNotEmpty() }?.let { "Field: $it" },
value.takeIf { it.isNotEmpty() }?.let { "Value: ${compactPreview(it, 42)}" }, value.takeIf { it.isNotEmpty() }?.let { "Value: ${compactPreview(it, 42)}" },
) ).filterNotNull()
.filterNotNull()
.joinToString(" · ") .joinToString(" · ")
.ifEmpty { "Filled page input" } .ifEmpty { "Filled page input" }
} }
"web_search" ->
"web_search" -> {
searchQueryFor( searchQueryFor(
args, args,
).takeIf { it.isNotEmpty() }?.let { "Query: $it" } ?: "Queried web sources" ).takeIf { it.isNotEmpty() }?.let { "Query: $it" } ?: "Queried web sources"
}
"terminal", "execute_code" -> { "terminal", "execute_code" -> {
val output = shellOutput(result) val output = shellOutput(result)
val firstMeaningfulLine = val firstMeaningfulLine =
@@ -1089,6 +1140,7 @@ object ToolViewBuilder {
"" ""
} }
} }
"read_file", "edit_file", "patch", "write_file" -> { "read_file", "edit_file", "patch", "write_file" -> {
val path = val path =
if (isFileEditTool(toolName)) { if (isFileEditTool(toolName)) {
@@ -1103,6 +1155,7 @@ object ToolViewBuilder {
fallbackDetailText(args, rawResult) fallbackDetailText(args, rawResult)
} }
} }
"web_extract" -> { "web_extract" -> {
val url = val url =
firstString(args, listOf("url")) firstString(args, listOf("url"))
@@ -1111,11 +1164,27 @@ object ToolViewBuilder {
if (url.isNotEmpty()) hostnameOf(url) else "Fetched webpage" if (url.isNotEmpty()) hostnameOf(url) else "Fetched webpage"
} }
"memory" -> firstString(result, listOf("message", "error"))
"cronjob" -> cronjobSubtitle(args, result) "memory" -> {
"todo" -> todoSubtitle(result) firstString(result, listOf("message", "error"))
"fact_store" -> factStoreSubtitle(args, result) }
"session_search" -> sessionSearchSubtitle(result)
"cronjob" -> {
cronjobSubtitle(args, result)
}
"todo" -> {
todoSubtitle(result)
}
"fact_store" -> {
factStoreSubtitle(args, result)
}
"session_search" -> {
sessionSearchSubtitle(result)
}
"skills_list" -> { "skills_list" -> {
val category = firstString(args, listOf("category")) val category = firstString(args, listOf("category"))
val count = val count =
@@ -1128,17 +1197,23 @@ object ToolViewBuilder {
"No skills found" "No skills found"
} }
} }
"skill_view" -> firstString(args, listOf("name"))
"skill_view" -> {
firstString(args, listOf("name"))
}
"skill_manage" -> { "skill_manage" -> {
val action = firstString(args, listOf("action")) val action = firstString(args, listOf("action"))
val name = firstString(args, listOf("name")) val name = firstString(args, listOf("name"))
if (name.isNotEmpty()) "Skill $action: $name" else "Skill $action" if (name.isNotEmpty()) "Skill $action: $name" else "Skill $action"
} }
"process" -> { "process" -> {
val action = firstString(args, listOf("action")) val action = firstString(args, listOf("action"))
val procId = firstString(args, listOf("session_id")) val procId = firstString(args, listOf("session_id"))
if (procId.isNotEmpty()) "$action: $procId" else action if (procId.isNotEmpty()) "$action: $procId" else action
} }
"x_search" -> { "x_search" -> {
val query = firstString(args, listOf("query")) val query = firstString(args, listOf("query"))
val degraded = val degraded =
@@ -1149,12 +1224,17 @@ object ToolViewBuilder {
)?.let { !it.isString && it.content == "true" } == true )?.let { !it.isString && it.content == "true" } == true
if (query.isNotEmpty()) "$query${if (degraded) " (no citations)" else ""}" else "Queried X" if (query.isNotEmpty()) "$query${if (degraded) " (no citations)" else ""}" else "Queried X"
} }
"vision_analyze" -> firstString(args, listOf("image_url")).take(60)
"vision_analyze" -> {
firstString(args, listOf("image_url")).take(60)
}
"tool_search" -> { "tool_search" -> {
val query = firstString(args, listOf("query")) val query = firstString(args, listOf("query"))
val matches = (result?.get("matches") as? JsonArray)?.size val matches = (result?.get("matches") as? JsonArray)?.size
if (matches != null) "$query ($matches matches)" else query if (matches != null) "$query ($matches matches)" else query
} }
"image_generate" -> { "image_generate" -> {
val imageUrl = firstString(result, listOf("image")).ifEmpty { firstString(args, listOf("image_url")) } val imageUrl = firstString(result, listOf("image")).ifEmpty { firstString(args, listOf("image_url")) }
val modality = firstString(result, listOf("modality")) val modality = firstString(result, listOf("modality"))
@@ -1162,6 +1242,7 @@ object ToolViewBuilder {
.filterNotNull() .filterNotNull()
.joinToString(" ") .joinToString(" ")
} }
"project_list" -> { "project_list" -> {
val projects = (result?.get("projects") as? JsonArray)?.size val projects = (result?.get("projects") as? JsonArray)?.size
val active = firstString(result, listOf("active_id")) val active = firstString(result, listOf("active_id"))
@@ -1171,11 +1252,13 @@ object ToolViewBuilder {
"Projects" "Projects"
} }
} }
"project_create", "project_switch" -> { "project_create", "project_switch" -> {
val actionLabel = if (toolName == "project_create") "Created" else "Switched to" val actionLabel = if (toolName == "project_create") "Created" else "Switched to"
val name = firstString(result, listOf("name")) val name = firstString(result, listOf("name"))
if (name.isNotEmpty()) "$actionLabel $name" else actionLabel if (name.isNotEmpty()) "$actionLabel $name" else actionLabel
} }
"read_terminal" -> { "read_terminal" -> {
val error = firstString(result, listOf("error")) val error = firstString(result, listOf("error"))
if (error.isNotEmpty()) { if (error.isNotEmpty()) {
@@ -1193,7 +1276,11 @@ object ToolViewBuilder {
} }
} }
} }
"computer_use" -> computerUseSubtitle(args, result)
"computer_use" -> {
computerUseSubtitle(args, result)
}
else -> { else -> {
val summary = ToolResultSummary.formatToolResultSummary(rawResult) val summary = ToolResultSummary.formatToolResultSummary(rawResult)
val firstLine = summary.split("\n").firstOrNull()?.takeIf { it.isNotEmpty() } ?: "" val firstLine = summary.split("\n").firstOrNull()?.takeIf { it.isNotEmpty() } ?: ""
@@ -1304,9 +1391,13 @@ object ToolViewBuilder {
60, 60,
)}" )}"
} ?: ""}" } ?: ""}"
"scroll" -> "${messages?.size ?: 0} messages (scroll)" "scroll" -> "${messages?.size ?: 0} messages (scroll)"
"read" -> "${msgCount ?: messages?.size ?: 0} messages${if (truncated) " (truncated)" else ""}" "read" -> "${msgCount ?: messages?.size ?: 0} messages${if (truncated) " (truncated)" else ""}"
"browse" -> "${count ?: 0} recent sessions" "browse" -> "${count ?: 0} recent sessions"
else -> "session_search" else -> "session_search"
} }
} }
@@ -1369,6 +1460,7 @@ object ToolViewBuilder {
fallbackDetailText(rawArgs, rawResult) fallbackDetailText(rawArgs, rawResult)
} }
} }
"terminal", "execute_code" -> { "terminal", "execute_code" -> {
val output = shellOutput(result) val output = shellOutput(result)
if (output.isNotEmpty()) { if (output.isNotEmpty()) {
@@ -1381,6 +1473,7 @@ object ToolViewBuilder {
"" ""
} }
} }
"web_extract" -> { "web_extract" -> {
val direct = firstString(result, listOf("content", "text", "markdown", "body", "summary", "message")) val direct = firstString(result, listOf("content", "text", "markdown", "body", "summary", "message"))
if (direct.isNotEmpty()) { if (direct.isNotEmpty()) {
@@ -1393,20 +1486,24 @@ object ToolViewBuilder {
.mapNotNull { item -> .mapNotNull { item ->
val row = parseMaybeObject(item) ?: return@mapNotNull null val row = parseMaybeObject(item) ?: return@mapNotNull null
firstString(row, listOf("content", "text", "markdown", "body")) firstString(row, listOf("content", "text", "markdown", "body"))
} }.filter { it.isNotEmpty() }
.filter { it.isNotEmpty() }
.joinToString("\n\n---\n\n") .joinToString("\n\n---\n\n")
} }
fallbackDetailText(rawArgs, rawResult) fallbackDetailText(rawArgs, rawResult)
} }
"read_file" -> { "read_file" -> {
if (rawResult == null) { if (rawResult == null) {
return "" return ""
} }
firstString(result, listOf("content", "text", "data", "body")) firstString(result, listOf("content", "text", "data", "body"))
} }
"memory" -> firstString(result, listOf("message", "error"))
"memory" -> {
firstString(result, listOf("message", "error"))
}
"edit_file", "patch", "write_file" -> { "edit_file", "patch", "write_file" -> {
if (inlineDiffFromResult(rawResult).isNotEmpty()) { if (inlineDiffFromResult(rawResult).isNotEmpty()) {
"" ""
@@ -1416,11 +1513,27 @@ object ToolViewBuilder {
} }
} }
} }
"web_search" -> fallbackDetailText(rawArgs, rawResult)
"cronjob" -> cronjobDetail(args, result) "web_search" -> {
"todo" -> todoDetail(result) ?: "" fallbackDetailText(rawArgs, rawResult)
"fact_store" -> factStoreDetail(result) ?: "" }
"session_search" -> sessionSearchDetail(result) ?: ""
"cronjob" -> {
cronjobDetail(args, result)
}
"todo" -> {
todoDetail(result) ?: ""
}
"fact_store" -> {
factStoreDetail(result) ?: ""
}
"session_search" -> {
sessionSearchDetail(result) ?: ""
}
"skills_list" -> { "skills_list" -> {
val skills = val skills =
(result?.get("skills") as? JsonArray) (result?.get("skills") as? JsonArray)
@@ -1435,10 +1548,10 @@ object ToolViewBuilder {
if (desc.isNotEmpty()) lines += " $desc" if (desc.isNotEmpty()) lines += " $desc"
if (cat.isNotEmpty()) lines += " [$cat]" if (cat.isNotEmpty()) lines += " [$cat]"
lines.joinToString("\n") lines.joinToString("\n")
} }?.joinToString("\n\n")
?.joinToString("\n\n")
?: "No skills found" ?: "No skills found"
} }
"skill_view" -> { "skill_view" -> {
val content = firstString(result, listOf("content")) val content = firstString(result, listOf("content"))
val linkedFiles = result?.get("linked_files") as? JsonObject val linkedFiles = result?.get("linked_files") as? JsonObject
@@ -1456,12 +1569,19 @@ object ToolViewBuilder {
?.joinToString("\n") { (k, v) -> ?.joinToString("\n") { (k, v) ->
val paths = val paths =
when (v) { when (v) {
is JsonArray -> is JsonArray -> {
v.joinToString( v.joinToString(
", ", ", ",
) { (it as? JsonPrimitive)?.content ?: it.toString() } ) { (it as? JsonPrimitive)?.content ?: it.toString() }
is JsonPrimitive -> v.content }
else -> v.toString()
is JsonPrimitive -> {
v.content
}
else -> {
v.toString()
}
} }
" 📎 $k: $paths" " 📎 $k: $paths"
} }
@@ -1475,6 +1595,7 @@ object ToolViewBuilder {
if (content.isEmpty() && linkedFiles == null) append("No content available") if (content.isEmpty() && linkedFiles == null) append("No content available")
} }
} }
"skill_manage" -> { "skill_manage" -> {
val error = firstString(result, listOf("error")) val error = firstString(result, listOf("error"))
if (error.isNotEmpty()) { if (error.isNotEmpty()) {
@@ -1494,8 +1615,15 @@ object ToolViewBuilder {
} }
} }
} }
"process" -> processDetail(args, result)
"x_search" -> xSearchDetail(result) "process" -> {
processDetail(args, result)
}
"x_search" -> {
xSearchDetail(result)
}
"vision_analyze" -> { "vision_analyze" -> {
val error = firstString(result, listOf("error")) val error = firstString(result, listOf("error"))
val description = firstString(result, listOf("description")) val description = firstString(result, listOf("description"))
@@ -1507,6 +1635,7 @@ object ToolViewBuilder {
else -> "No description available" else -> "No description available"
} }
} }
"tool_search" -> { "tool_search" -> {
val matches = result?.get("matches") as? JsonArray val matches = result?.get("matches") as? JsonArray
matches matches
@@ -1517,10 +1646,10 @@ object ToolViewBuilder {
val lines = mutableListOf("🔧 $name") val lines = mutableListOf("🔧 $name")
if (desc.isNotEmpty()) lines += " $desc" if (desc.isNotEmpty()) lines += " $desc"
lines.joinToString("\n") lines.joinToString("\n")
} }?.joinToString("\n\n")
?.joinToString("\n\n")
?: "No matching tools" ?: "No matching tools"
} }
"image_generate" -> { "image_generate" -> {
val error = firstString(result, listOf("error")) val error = firstString(result, listOf("error"))
val imageUrl = firstString(result, listOf("image")) val imageUrl = firstString(result, listOf("image"))
@@ -1539,6 +1668,7 @@ object ToolViewBuilder {
} }
} }
} }
"project_list" -> { "project_list" -> {
val projects = result?.get("projects") as? JsonArray val projects = result?.get("projects") as? JsonArray
projects projects
@@ -1555,10 +1685,10 @@ object ToolViewBuilder {
if (path.isNotEmpty()) lines += " 📍 $path" if (path.isNotEmpty()) lines += " 📍 $path"
if (isActive) lines += " ✅ ACTIVE PROJECT" if (isActive) lines += " ✅ ACTIVE PROJECT"
lines.joinToString("\n") lines.joinToString("\n")
} }?.joinToString("\n\n")
?.joinToString("\n\n")
?: "" ?: ""
} }
"project_create", "project_switch" -> { "project_create", "project_switch" -> {
val error = firstString(result, listOf("error")) val error = firstString(result, listOf("error"))
val actionLabel = if (toolName == "project_create") "Created" else "Switched to" val actionLabel = if (toolName == "project_create") "Created" else "Switched to"
@@ -1577,9 +1707,18 @@ object ToolViewBuilder {
} }
} }
} }
"read_terminal" -> readTerminalDetail(result)
"computer_use" -> computerUseDetail(args, result) "read_terminal" -> {
else -> fallbackDetailText(rawArgs, rawResult) readTerminalDetail(result)
}
"computer_use" -> {
computerUseDetail(args, result)
}
else -> {
fallbackDetailText(rawArgs, rawResult)
}
} }
} }
@@ -1601,8 +1740,7 @@ object ToolViewBuilder {
val name = firstString(row, listOf("name", "id")).ifEmpty { "job" } val name = firstString(row, listOf("name", "id")).ifEmpty { "job" }
val sched = firstString(row, listOf("schedule_display", "schedule")) val sched = firstString(row, listOf("schedule_display", "schedule"))
if (sched.isNotEmpty()) "- $name · $sched" else "- $name" if (sched.isNotEmpty()) "- $name · $sched" else "- $name"
} }.joinToString("\n")
.joinToString("\n")
} }
val rows = val rows =
@@ -1639,8 +1777,7 @@ object ToolViewBuilder {
else -> "[ ]" else -> "[ ]"
} }
"$marker $id. $content" "$marker $id. $content"
} }.joinToString("\n")
.joinToString("\n")
.takeIf { it.isNotEmpty() } .takeIf { it.isNotEmpty() }
} }
@@ -1669,8 +1806,7 @@ object ToolViewBuilder {
} else { } else {
"#$fid $content" "#$fid $content"
} }
} }.joinToString("\n")
.joinToString("\n")
.takeIf { it.isNotEmpty() } .takeIf { it.isNotEmpty() }
} }
@@ -1700,8 +1836,7 @@ object ToolViewBuilder {
snippet.takeIf { it.isNotEmpty() }?.let { "\n ┃ ${it.take(200).replace("\n", " ")}" } ?: "" snippet.takeIf { it.isNotEmpty() }?.let { "\n ┃ ${it.take(200).replace("\n", " ")}" } ?: ""
"━━━ #${idx + 1} $header$sourceTag$titleLine$modelLine$matchedLine$countLine$snippetLine" "━━━ #${idx + 1} $header$sourceTag$titleLine$modelLine$matchedLine$countLine$snippetLine"
} }?.joinToString("\n")
?.joinToString("\n")
?.takeIf { it.isNotEmpty() } ?.takeIf { it.isNotEmpty() }
val anchorId = intValue(result?.get("around_message_id")) val anchorId = intValue(result?.get("around_message_id"))
@@ -1727,8 +1862,7 @@ object ToolViewBuilder {
val cleanContent = content.take(300).replace("\n", " ") val cleanContent = content.take(300).replace("\n", " ")
"[$msgId] $roleEmoji $role$namePart$anchor\n $cleanContent" "[$msgId] $roleEmoji $role$namePart$anchor\n $cleanContent"
} }?.joinToString("\n")
?.joinToString("\n")
?.takeIf { it.isNotEmpty() } ?.takeIf { it.isNotEmpty() }
return formattedResults ?: formattedMessages return formattedResults ?: formattedMessages
@@ -1756,8 +1890,7 @@ object ToolViewBuilder {
if (pStatus.isNotEmpty()) parts += " Status: $pStatus" if (pStatus.isNotEmpty()) parts += " Status: $pStatus"
if (running != null) parts += " Running: $running" if (running != null) parts += " Running: $running"
parts.joinToString("\n") parts.joinToString("\n")
} }.joinToString("\n\n")
.joinToString("\n\n")
} }
val output = firstString(result, listOf("output")) val output = firstString(result, listOf("output"))
@@ -1782,7 +1915,10 @@ object ToolViewBuilder {
val degraded = (result?.get("degraded") as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true val degraded = (result?.get("degraded") as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true
return when { return when {
error.isNotEmpty() -> "❌ $error" error.isNotEmpty() -> {
"❌ $error"
}
answer.isNotEmpty() -> { answer.isNotEmpty() -> {
val lines = mutableListOf(answer) val lines = mutableListOf(answer)
if (citations != null && citations.isNotEmpty()) { if (citations != null && citations.isNotEmpty()) {
@@ -1798,7 +1934,10 @@ object ToolViewBuilder {
if (degraded) lines += "\n⚠️ No citations — answer based on model's knowledge" if (degraded) lines += "\n⚠️ No citations — answer based on model's knowledge"
lines.joinToString("\n") lines.joinToString("\n")
} }
else -> "No results"
else -> {
"No results"
}
} }
} }
@@ -1845,8 +1984,7 @@ object ToolViewBuilder {
.mapNotNull { el -> .mapNotNull { el ->
val e = parseMaybeObject(el) ?: return@mapNotNull null val e = parseMaybeObject(el) ?: return@mapNotNull null
if (firstString(e, listOf("type")) == "text") firstString(e, listOf("text")) else null if (firstString(e, listOf("type")) == "text") firstString(e, listOf("text")) else null
} }.joinToString("\n")
.joinToString("\n")
} else { } else {
"" ""
} }
@@ -1866,12 +2004,13 @@ object ToolViewBuilder {
val body = val body =
buildString { buildString {
val textPart = val textPart =
multimodalText.ifEmpty { multimodalText
firstString( .ifEmpty {
result, firstString(
listOf("text_summary"), result,
) listOf("text_summary"),
}.ifEmpty { textSummary } )
}.ifEmpty { textSummary }
if (textPart.isNotEmpty()) { if (textPart.isNotEmpty()) {
append(textPart) append(textPart)
if (textPart == multimodalText && textSummary.isNotEmpty() && multimodalText != textSummary) { if (textPart == multimodalText && textSummary.isNotEmpty() && multimodalText != textSummary) {
@@ -107,21 +107,25 @@ fun ActionProgressDialog(
Text( Text(
text = text =
when (state.phase) { when (state.phase) {
ActionProgressPhase.STARTING -> ActionProgressPhase.STARTING -> {
stringResource(R.string.action_progress_starting) stringResource(R.string.action_progress_starting)
}
ActionProgressPhase.RUNNING -> ActionProgressPhase.RUNNING -> {
stringResource(R.string.action_progress_running) stringResource(R.string.action_progress_running)
}
ActionProgressPhase.SUCCEEDED -> ActionProgressPhase.SUCCEEDED -> {
stringResource(R.string.action_progress_succeeded) stringResource(R.string.action_progress_succeeded)
}
ActionProgressPhase.FAILED -> ActionProgressPhase.FAILED -> {
if (exitCode != null) { if (exitCode != null) {
stringResource(R.string.action_progress_failed_exit, exitCode) stringResource(R.string.action_progress_failed_exit, exitCode)
} else { } else {
stringResource(R.string.action_progress_failed) stringResource(R.string.action_progress_failed)
} }
}
}, },
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
@@ -87,7 +87,8 @@ fun BotAvatar(
if (imageUrl != null) { if (imageUrl != null) {
AsyncImage( AsyncImage(
model = model =
ImageRequest.Builder(LocalContext.current) ImageRequest
.Builder(LocalContext.current)
.data(imageUrl) .data(imageUrl)
.crossfade(true) .crossfade(true)
.build(), .build(),
@@ -137,21 +138,20 @@ fun BotAvatar(
fun resolveAvatarShape( fun resolveAvatarShape(
shapeKey: String?, shapeKey: String?,
size: Dp, size: Dp,
): Shape { ): Shape =
return when (shapeKey?.lowercase()?.trim()) { when (shapeKey?.lowercase()?.trim()) {
"square", "box", "boxy" -> RoundedCornerShape(size * 0.15f) "square", "box", "boxy" -> RoundedCornerShape(size * 0.15f)
"rounded", "nub", "organic" -> RoundedCornerShape(size * 0.32f) "rounded", "nub", "organic" -> RoundedCornerShape(size * 0.32f)
"hexagon", "cut", "diamond", "cloud", "sun" -> CutCornerShape(size * 0.25f) "hexagon", "cut", "diamond", "cloud", "sun" -> CutCornerShape(size * 0.25f)
"circle", "round" -> CircleShape "circle", "round" -> CircleShape
else -> CircleShape else -> CircleShape
} }
}
/** /**
* Maps known icon keys to Material [ImageVector] icons. * Maps known icon keys to Material [ImageVector] icons.
*/ */
fun resolveAvatarIcon(iconKey: String?): ImageVector? { fun resolveAvatarIcon(iconKey: String?): ImageVector? =
return when (iconKey?.lowercase()?.trim()) { when (iconKey?.lowercase()?.trim()) {
"code", "dev", "terminal" -> Icons.Filled.Code "code", "dev", "terminal" -> Icons.Filled.Code
"build", "tool", "tools" -> Icons.Filled.Build "build", "tool", "tools" -> Icons.Filled.Build
"psychology", "brain", "ai", "research", "intel" -> Icons.Filled.Psychology "psychology", "brain", "ai", "research", "intel" -> Icons.Filled.Psychology
@@ -162,7 +162,6 @@ fun resolveAvatarIcon(iconKey: String?): ImageVector? {
"robot", "bot", "smart_toy" -> Icons.Filled.SmartToy "robot", "bot", "smart_toy" -> Icons.Filled.SmartToy
else -> null else -> null
} }
}
/** /**
* Extract 1-2 uppercase letters from name for initials fallback. * Extract 1-2 uppercase letters from name for initials fallback.
@@ -16,41 +16,42 @@ import com.m57.hermescontrol.theme.IconDefaultFill
* original path data (viewBox 0 -960 960 960, translated into compose space). * original path data (viewBox 0 -960 960 960, translated into compose space).
*/ */
val NeurologyIcon: ImageVector by lazy { val NeurologyIcon: ImageVector by lazy {
ImageVector.Builder( ImageVector
name = "Neurology", .Builder(
defaultWidth = 24.dp, name = "Neurology",
defaultHeight = 24.dp, defaultWidth = 24.dp,
viewportWidth = 960f, defaultHeight = 24.dp,
viewportHeight = 960f, viewportWidth = 960f,
).apply { viewportHeight = 960f,
addGroup( ).apply {
name = "translate", addGroup(
rotate = 0f, name = "translate",
pivotX = 0f, rotate = 0f,
pivotY = 0f, pivotX = 0f,
scaleX = 1f, pivotY = 0f,
scaleY = 1f, scaleX = 1f,
translationX = 0f, scaleY = 1f,
translationY = 960f, translationX = 0f,
clipPathData = emptyList(), translationY = 960f,
) clipPathData = emptyList(),
addPath( )
pathData = addPathNodes(PATH), addPath(
pathFillType = PathFillType.NonZero, pathData = addPathNodes(PATH),
name = "NeurologyPath", pathFillType = PathFillType.NonZero,
fill = SolidColor(IconDefaultFill), name = "NeurologyPath",
fillAlpha = 1f, fill = SolidColor(IconDefaultFill),
stroke = null, fillAlpha = 1f,
strokeAlpha = 1f, stroke = null,
strokeLineWidth = 1f, strokeAlpha = 1f,
strokeLineCap = StrokeCap.Butt, strokeLineWidth = 1f,
strokeLineJoin = StrokeJoin.Miter, strokeLineCap = StrokeCap.Butt,
strokeLineMiter = 4f, strokeLineJoin = StrokeJoin.Miter,
trimPathStart = 0f, strokeLineMiter = 4f,
trimPathEnd = 1f, trimPathStart = 0f,
trimPathOffset = 0f, trimPathEnd = 1f,
) trimPathOffset = 0f,
}.build() )
}.build()
} }
private const val PATH = private const val PATH =
@@ -82,22 +82,27 @@ fun PressureBanner(
val message = val message =
when (trigger) { when (trigger) {
PressureTrigger.DISK_CRITICAL -> PressureTrigger.DISK_CRITICAL -> {
stringResource(R.string.pressure_banner_disk_critical) + stringResource(R.string.pressure_banner_disk_critical) +
diskFreeSuffix(disk) diskFreeSuffix(disk)
}
PressureTrigger.DISK_ELEVATED -> PressureTrigger.DISK_ELEVATED -> {
stringResource(R.string.pressure_banner_disk_elevated) + stringResource(R.string.pressure_banner_disk_elevated) +
diskFreeSuffix(disk) diskFreeSuffix(disk)
}
PressureTrigger.MEMORY_CRITICAL -> PressureTrigger.MEMORY_CRITICAL -> {
stringResource(R.string.pressure_banner_memory_critical) stringResource(R.string.pressure_banner_memory_critical)
}
PressureTrigger.OOM_RESTART -> PressureTrigger.OOM_RESTART -> {
stringResource(R.string.pressure_banner_memory_oom) stringResource(R.string.pressure_banner_memory_oom)
}
PressureTrigger.MEMORY_ELEVATED -> PressureTrigger.MEMORY_ELEVATED -> {
stringResource(R.string.pressure_banner_memory_elevated) stringResource(R.string.pressure_banner_memory_elevated)
}
} }
Surface( Surface(
@@ -18,7 +18,11 @@ data class ConfigRow(
) { ) {
/** Human label: last path segment, underscores → spaces. */ /** Human label: last path segment, underscores → spaces. */
val label: String = val label: String =
key.split(".").last().replace("_", " ").replaceFirstChar { it.uppercase() } key
.split(".")
.last()
.replace("_", " ")
.replaceFirstChar { it.uppercase() }
/** Search/display text of the current value. */ /** Search/display text of the current value. */
val valueText: String = value?.let(::jsonText) ?: "" val valueText: String = value?.let(::jsonText) ?: ""
@@ -578,7 +578,10 @@ private fun ConfigFieldCard(
val field = row.field val field = row.field
val description = val description =
field?.description field?.description
?: row.key.replace(".", " → ").replace("_", " ").replaceFirstChar { it.uppercase() } ?: row.key
.replace(".", " → ")
.replace("_", " ")
.replaceFirstChar { it.uppercase() }
Card( Card(
modifier = modifier =
@@ -552,19 +552,32 @@ class CronJobsViewModel :
): CronJobEditorState = ): CronJobEditorState =
when (name) { when (name) {
"name" -> copy(name = value) "name" -> copy(name = value)
"schedule" -> copy(schedule = value) "schedule" -> copy(schedule = value)
"prompt" -> copy(prompt = value) "prompt" -> copy(prompt = value)
"deliver" -> copy(deliver = value) "deliver" -> copy(deliver = value)
"skills" -> copy(skills = value) "skills" -> copy(skills = value)
"model" -> copy(model = value) "model" -> copy(model = value)
"provider" -> copy(provider = value) "provider" -> copy(provider = value)
"base_url" -> copy(base_url = value) "base_url" -> copy(base_url = value)
"script" -> copy(script = value) "script" -> copy(script = value)
"workdir" -> copy(workdir = value) "workdir" -> copy(workdir = value)
"run_continuity" -> copy(runContinuity = value.toBooleanStrictOrNull() ?: false) "run_continuity" -> copy(runContinuity = value.toBooleanStrictOrNull() ?: false)
// Monitor fields are mutually exclusive: entering one clears the other. // Monitor fields are mutually exclusive: entering one clears the other.
"monitor_script" -> copy(monitor_script = value, monitor_url = if (value.isNotBlank()) "" else monitor_url) "monitor_script" -> copy(monitor_script = value, monitor_url = if (value.isNotBlank()) "" else monitor_url)
"monitor_url" -> copy(monitor_url = value, monitor_script = if (value.isNotBlank()) "" else monitor_script) "monitor_url" -> copy(monitor_url = value, monitor_script = if (value.isNotBlank()) "" else monitor_script)
else -> this else -> this
} }
@@ -618,8 +631,8 @@ class CronJobsViewModel :
} }
/** Monitor fields configured in the editor (at most one is non-blank). */ /** Monitor fields configured in the editor (at most one is non-blank). */
private fun editorMonitorUpdates(editor: CronJobEditorState): Map<String, String> { private fun editorMonitorUpdates(editor: CronJobEditorState): Map<String, String> =
return buildMap { buildMap {
if (editor.monitor_script.isNotBlank()) { if (editor.monitor_script.isNotBlank()) {
put("monitor_script", editor.monitor_script.trim()) put("monitor_script", editor.monitor_script.trim())
} }
@@ -627,5 +640,4 @@ class CronJobsViewModel :
put("monitor_url", editor.monitor_url.trim()) put("monitor_url", editor.monitor_url.trim())
} }
} }
}
} }
@@ -55,17 +55,28 @@ enum class KanbanTaskAction(
/** Desktop parity: which actions are valid from a given status. */ /** Desktop parity: which actions are valid from a given status. */
fun kanbanActionsForStatus(status: String): List<KanbanTaskAction> = fun kanbanActionsForStatus(status: String): List<KanbanTaskAction> =
when (status) { when (status) {
"triage" -> listOf(KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE) "triage" -> {
"todo" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE) listOf(KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
"scheduled" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE) }
"ready" ->
"todo" -> {
listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
}
"scheduled" -> {
listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
}
"ready" -> {
listOf( listOf(
KanbanTaskAction.TRIAGE, KanbanTaskAction.TRIAGE,
KanbanTaskAction.BLOCK, KanbanTaskAction.BLOCK,
KanbanTaskAction.COMPLETE, KanbanTaskAction.COMPLETE,
KanbanTaskAction.ARCHIVE, KanbanTaskAction.ARCHIVE,
) )
"running" -> }
"running" -> {
listOf( listOf(
KanbanTaskAction.TRIAGE, KanbanTaskAction.TRIAGE,
KanbanTaskAction.READY, KanbanTaskAction.READY,
@@ -73,22 +84,38 @@ fun kanbanActionsForStatus(status: String): List<KanbanTaskAction> =
KanbanTaskAction.COMPLETE, KanbanTaskAction.COMPLETE,
KanbanTaskAction.ARCHIVE, KanbanTaskAction.ARCHIVE,
) )
"blocked" -> }
"blocked" -> {
listOf( listOf(
KanbanTaskAction.TRIAGE, KanbanTaskAction.TRIAGE,
KanbanTaskAction.UNBLOCK, KanbanTaskAction.UNBLOCK,
KanbanTaskAction.COMPLETE, KanbanTaskAction.COMPLETE,
KanbanTaskAction.ARCHIVE, KanbanTaskAction.ARCHIVE,
) )
"review" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE) }
"done" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
"archived" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY) "review" -> {
else -> emptyList() listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
}
"done" -> {
listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
}
"archived" -> {
listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY)
}
else -> {
emptyList()
}
} }
class KanbanViewModel( class KanbanViewModel(
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() }, private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
) : ViewModel(), ToastHost { ) : ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(KanbanUiState()) private val _uiState = MutableStateFlow(KanbanUiState())
val uiState: StateFlow<KanbanUiState> = _uiState.asStateFlow() val uiState: StateFlow<KanbanUiState> = _uiState.asStateFlow()
@@ -456,6 +456,7 @@ class McpServersViewModel :
} }
} }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to start OAuth: ${result.error.message}") } _uiState.update { it.copy(toastMessage = "Failed to start OAuth: ${result.error.message}") }
} }
@@ -489,6 +490,7 @@ class McpServersViewModel :
} }
loadServers() loadServers()
} }
"error" -> { "error" -> {
polling = false polling = false
_uiState.update { _uiState.update {
@@ -498,15 +500,18 @@ class McpServersViewModel :
) )
} }
} }
"authorization_required" -> { "authorization_required" -> {
// Keep polling // Keep polling
} }
else -> { else -> {
// Check if worker is done or expired // Check if worker is done or expired
// Continue polling until status transitions // Continue polling until status transitions
} }
} }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
// Keep polling or stop after too many failures? Let's just log/toast n retry a few times // Keep polling or stop after too many failures? Let's just log/toast n retry a few times
} }
@@ -31,7 +31,9 @@ data class MemoryUiState(
* Retrofit suspend calls already run off the caller thread, keeping tests * Retrofit suspend calls already run off the caller thread, keeping tests
* free of the static Dispatchers mock that bleeds across test classes. * free of the static Dispatchers mock that bleeds across test classes.
*/ */
class MemoryViewModel : ViewModel(), ToastHost { class MemoryViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(MemoryUiState()) private val _uiState = MutableStateFlow(MemoryUiState())
val uiState: StateFlow<MemoryUiState> = _uiState.asStateFlow() val uiState: StateFlow<MemoryUiState> = _uiState.asStateFlow()
@@ -41,15 +43,18 @@ class MemoryViewModel : ViewModel(), ToastHost {
viewModelScope.launch { viewModelScope.launch {
val result = safeApiCall { ApiClient.hermesApi.getMemory() } val result = safeApiCall { ApiClient.hermesApi.getMemory() }
when (result) { when (result) {
is NetworkResult.Success -> is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, memory = result.data) } _uiState.update { it.copy(isLoading = false, memory = result.data) }
is NetworkResult.Failure -> }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = result.error.message, errorMessage = result.error.message,
) )
} }
}
} }
loadLearningGraph() loadLearningGraph()
} }
@@ -70,20 +75,23 @@ class MemoryViewModel : ViewModel(), ToastHost {
viewModelScope.launch { viewModelScope.launch {
val result = safeApiCall { ApiClient.hermesApi.resetMemory(MemoryResetRequest(target = target)) } val result = safeApiCall { ApiClient.hermesApi.resetMemory(MemoryResetRequest(target = target)) }
when (result) { when (result) {
is NetworkResult.Success -> is NetworkResult.Success -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
resetting = null, resetting = null,
toastMessage = "Memory ($target) reset successfully", toastMessage = "Memory ($target) reset successfully",
) )
} }
is NetworkResult.Failure -> }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
resetting = null, resetting = null,
toastMessage = "Failed to reset memory: ${result.error.message}", toastMessage = "Failed to reset memory: ${result.error.message}",
) )
} }
}
} }
load() load()
} }
@@ -328,10 +328,9 @@ private fun ApprovedCard(
} }
@Composable @Composable
private fun formatAge(ageMinutes: Int): String { private fun formatAge(ageMinutes: Int): String =
return if (ageMinutes < 60) { if (ageMinutes < 60) {
stringResource(R.string.pairing_label_request_age_m, ageMinutes) stringResource(R.string.pairing_label_request_age_m, ageMinutes)
} else { } else {
stringResource(R.string.pairing_label_request_age_hm, ageMinutes / 60, ageMinutes % 60) stringResource(R.string.pairing_label_request_age_hm, ageMinutes / 60, ageMinutes % 60)
} }
}
@@ -334,15 +334,16 @@ private fun ProviderFieldRow(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
when (field.kind) { when (field.kind) {
"select" -> "select" -> {
ExposedDropdownField( ExposedDropdownField(
label = field.label.ifEmpty { field.key }, label = field.label.ifEmpty { field.key },
options = field.options.map { it.value }.ifEmpty { listOf(value) }, options = field.options.map { it.value }.ifEmpty { listOf(value) },
selectedValue = value, selectedValue = value,
onOptionSelected = onChange, onOptionSelected = onChange,
) )
}
"bool" -> "bool" -> {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -365,8 +366,9 @@ private fun ProviderFieldRow(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
} }
}
else -> else -> {
OutlinedTextField( OutlinedTextField(
value = value, value = value,
onValueChange = onChange, onValueChange = onChange,
@@ -400,6 +402,7 @@ private fun ProviderFieldRow(
KeyboardOptions.Default KeyboardOptions.Default
}, },
) )
}
} }
} }
} }
@@ -47,7 +47,9 @@ data class MemoryProviderDetailUiState(
* [setProvider] + [load] per entry instead (same bug/fix as #782's * [setProvider] + [load] per entry instead (same bug/fix as #782's
* ToolsetDetailViewModel). * ToolsetDetailViewModel).
*/ */
class MemoryProviderDetailViewModel : ViewModel(), ToastHost { class MemoryProviderDetailViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(MemoryProviderDetailUiState()) private val _uiState = MutableStateFlow(MemoryProviderDetailUiState())
val uiState: StateFlow<MemoryProviderDetailUiState> = _uiState.asStateFlow() val uiState: StateFlow<MemoryProviderDetailUiState> = _uiState.asStateFlow()
@@ -74,13 +76,17 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
val statusResult = safeApiCall { ApiClient.hermesApi.getMemory() } val statusResult = safeApiCall { ApiClient.hermesApi.getMemory() }
val status = val status =
when (statusResult) { when (statusResult) {
is NetworkResult.Success -> is NetworkResult.Success -> {
statusResult.data.providers.firstOrNull { it.name == name } statusResult.data.providers.firstOrNull { it.name == name }
else -> null }
else -> {
null
}
} }
val configResult = safeApiCall { ApiClient.hermesApi.getMemoryProviderConfig(name) } val configResult = safeApiCall { ApiClient.hermesApi.getMemoryProviderConfig(name) }
when (configResult) { when (configResult) {
is NetworkResult.Success -> is NetworkResult.Success -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
@@ -90,7 +96,9 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
edits = seedEdits(configResult.data), edits = seedEdits(configResult.data),
) )
} }
is NetworkResult.Failure -> }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
@@ -99,6 +107,7 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
errorMessage = configResult.error.message, errorMessage = configResult.error.message,
) )
} }
}
} }
} }
} }
@@ -143,7 +152,7 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
) )
} }
when (result) { when (result) {
is NetworkResult.Success -> is NetworkResult.Success -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
saving = false, saving = false,
@@ -154,13 +163,16 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
toastMessage = "Memory provider settings saved", toastMessage = "Memory provider settings saved",
) )
} }
is NetworkResult.Failure -> }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
saving = false, saving = false,
toastMessage = "Failed to save settings: ${result.error.message}", toastMessage = "Failed to save settings: ${result.error.message}",
) )
} }
}
} }
refreshStatus(name) refreshStatus(name)
} }
@@ -180,7 +192,7 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
ApiClient.hermesApi.setupMemoryProvider(name, MemoryProviderSetupRequest()) ApiClient.hermesApi.setupMemoryProvider(name, MemoryProviderSetupRequest())
} }
when (result) { when (result) {
is NetworkResult.Success -> is NetworkResult.Success -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
runningSetup = false, runningSetup = false,
@@ -193,13 +205,16 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
}, },
) )
} }
is NetworkResult.Failure -> }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
runningSetup = false, runningSetup = false,
toastMessage = "Setup failed: ${result.error.message}", toastMessage = "Setup failed: ${result.error.message}",
) )
} }
}
} }
refreshStatus(name) refreshStatus(name)
} }
@@ -207,7 +207,8 @@ internal fun ChatSection(
) { ) {
val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f) val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f)
val currentIndex = val currentIndex =
fontScaleOptions.indexOfFirst { abs(it - chatFontScale) < 0.05f } fontScaleOptions
.indexOfFirst { abs(it - chatFontScale) < 0.05f }
.takeIf { it >= 0 } ?: 1 .takeIf { it >= 0 } ?: 1
SectionCard { SectionCard {
@@ -884,7 +884,9 @@ private fun SkillScanSection(
} }
when { when {
isScanning -> Unit isScanning -> {
Unit
}
scanError != null -> { scanError != null -> {
Surface( Surface(
@@ -86,8 +86,9 @@ data class SystemUiState(
val updateConfirmOpen: Boolean = false, val updateConfirmOpen: Boolean = false,
) )
class SystemViewModel(application: Application) : class SystemViewModel(
AndroidViewModel(application), application: Application,
) : AndroidViewModel(application),
ToastHost { ToastHost {
companion object { companion object {
private const val TAG = "SystemViewModel" private const val TAG = "SystemViewModel"
@@ -680,11 +680,23 @@ private fun PostSetupRunner(
private fun ToolsetProvider.statusTextRes(): Int = private fun ToolsetProvider.statusTextRes(): Int =
when (status) { when (status) {
"ready" -> R.string.toolset_status_ready "ready" -> {
"needs_keys" -> R.string.toolset_status_needs_keys R.string.toolset_status_ready
"needs_auth" -> R.string.toolset_status_needs_auth }
"needs_setup" -> R.string.toolset_status_needs_setup
else -> "needs_keys" -> {
R.string.toolset_status_needs_keys
}
"needs_auth" -> {
R.string.toolset_status_needs_auth
}
"needs_setup" -> {
R.string.toolset_status_needs_setup
}
else -> {
// Older backends may omit `status` — fall back to the legacy // Older backends may omit `status` — fall back to the legacy
// env-var heuristic the desktop used before server-computed // env-var heuristic the desktop used before server-computed
// readiness landed. // readiness landed.
@@ -693,16 +705,24 @@ private fun ToolsetProvider.statusTextRes(): Int =
} else { } else {
R.string.toolset_status_needs_keys R.string.toolset_status_needs_keys
} }
}
} }
private fun ToolsetProvider.statusType(): StatusBadgeType = private fun ToolsetProvider.statusType(): StatusBadgeType =
when (status) { when (status) {
"ready" -> StatusBadgeType.SUCCESS "ready" -> {
"needs_keys", "needs_auth", "needs_setup" -> StatusBadgeType.WARNING StatusBadgeType.SUCCESS
else -> }
"needs_keys", "needs_auth", "needs_setup" -> {
StatusBadgeType.WARNING
}
else -> {
if (envVars.isEmpty() || envVars.all { it.isSet }) { if (envVars.isEmpty() || envVars.all { it.isSet }) {
StatusBadgeType.SUCCESS StatusBadgeType.SUCCESS
} else { } else {
StatusBadgeType.WARNING StatusBadgeType.WARNING
} }
}
} }
@@ -59,7 +59,9 @@ data class ToolsetDetailUiState(
* first-opened toolset into every entry — bug found on-device 2026-08-06). * first-opened toolset into every entry — bug found on-device 2026-08-06).
* The screen drives [setToolset] + [loadConfig] per entry instead. * The screen drives [setToolset] + [loadConfig] per entry instead.
*/ */
class ToolsetDetailViewModel : ViewModel(), ToastHost { class ToolsetDetailViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(ToolsetDetailUiState()) private val _uiState = MutableStateFlow(ToolsetDetailUiState())
val uiState: StateFlow<ToolsetDetailUiState> = _uiState.asStateFlow() val uiState: StateFlow<ToolsetDetailUiState> = _uiState.asStateFlow()
@@ -101,11 +103,12 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
) )
} }
is NetworkResult.Failure -> is NetworkResult.Failure -> {
state.copy( state.copy(
isLoading = false, isLoading = false,
errorMessage = "Failed to load toolset config: ${result.error.message}", errorMessage = "Failed to load toolset config: ${result.error.message}",
) )
}
} }
} }
} }
@@ -150,11 +153,12 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
) )
} }
is NetworkResult.Failure -> is NetworkResult.Failure -> {
state.copy( state.copy(
selectingProvider = null, selectingProvider = null,
toastMessage = "Failed to select backend: ${result.error.message}", toastMessage = "Failed to select backend: ${result.error.message}",
) )
}
} }
} }
} }
@@ -176,18 +180,20 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
} }
_uiState.update { state -> _uiState.update { state ->
when (result) { when (result) {
is NetworkResult.Success -> is NetworkResult.Success -> {
state.copy( state.copy(
savingEnvKey = null, savingEnvKey = null,
config = state.config?.let { patchEnvIsSet(it, key, true) }, config = state.config?.let { patchEnvIsSet(it, key, true) },
toastMessage = "Saved $key", toastMessage = "Saved $key",
) )
}
is NetworkResult.Failure -> is NetworkResult.Failure -> {
state.copy( state.copy(
savingEnvKey = null, savingEnvKey = null,
toastMessage = "Failed to save $key: ${result.error.message}", toastMessage = "Failed to save $key: ${result.error.message}",
) )
}
} }
} }
} }
@@ -199,19 +205,21 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
val result = safeApiCall { ApiClient.hermesApi.deleteEnvVar(EnvVarDeleteRequest(key)) } val result = safeApiCall { ApiClient.hermesApi.deleteEnvVar(EnvVarDeleteRequest(key)) }
_uiState.update { state -> _uiState.update { state ->
when (result) { when (result) {
is NetworkResult.Success -> is NetworkResult.Success -> {
state.copy( state.copy(
deletingEnvKey = null, deletingEnvKey = null,
config = state.config?.let { patchEnvIsSet(it, key, false) }, config = state.config?.let { patchEnvIsSet(it, key, false) },
revealedValues = state.revealedValues - key, revealedValues = state.revealedValues - key,
toastMessage = "Cleared $key", toastMessage = "Cleared $key",
) )
}
is NetworkResult.Failure -> is NetworkResult.Failure -> {
state.copy( state.copy(
deletingEnvKey = null, deletingEnvKey = null,
toastMessage = "Failed to clear $key: ${result.error.message}", toastMessage = "Failed to clear $key: ${result.error.message}",
) )
}
} }
} }
} }
@@ -222,11 +230,13 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
val result = safeApiCall { ApiClient.hermesApi.revealEnvVar(EnvVarRevealRequest(key)) } val result = safeApiCall { ApiClient.hermesApi.revealEnvVar(EnvVarRevealRequest(key)) }
_uiState.update { state -> _uiState.update { state ->
when (result) { when (result) {
is NetworkResult.Success -> is NetworkResult.Success -> {
state.copy(revealedValues = state.revealedValues + (key to result.data.value)) state.copy(revealedValues = state.revealedValues + (key to result.data.value))
}
is NetworkResult.Failure -> is NetworkResult.Failure -> {
state.copy(toastMessage = "Failed to reveal $key: ${result.error.message}") state.copy(toastMessage = "Failed to reveal $key: ${result.error.message}")
}
} }
} }
} }
@@ -20,7 +20,13 @@ class KanbanEventsClientTest {
assertEquals("t-1", event.taskId) assertEquals("t-1", event.taskId)
assertEquals("r-9", event.runId) assertEquals("r-9", event.runId)
assertEquals("status", event.kind) assertEquals("status", event.kind)
assertEquals("done", event.payload?.get("status")?.jsonPrimitive?.content) assertEquals(
"done",
event.payload
?.get("status")
?.jsonPrimitive
?.content,
)
assertEquals(1710000000L, event.createdAt) assertEquals(1710000000L, event.createdAt)
} }
@@ -143,12 +143,16 @@ class BotsViewModelTest {
// Toggle show hidden (sorted: default (active), scout (worker active now), reviewer (canonical last_active)) // Toggle show hidden (sorted: default (active), scout (worker active now), reviewer (canonical last_active))
viewModel.toggleShowHidden() viewModel.toggleShowHidden()
val displayedWithHidden = viewModel.uiState.value.displayProfiles.map { it.name } val displayedWithHidden =
viewModel.uiState.value.displayProfiles
.map { it.name }
assertEquals(listOf("default", "reviewer", "scout"), displayedWithHidden) assertEquals(listOf("default", "reviewer", "scout"), displayedWithHidden)
// Search filter // Search filter
viewModel.setSearchQuery("arxiv") viewModel.setSearchQuery("arxiv")
val searchResults = viewModel.uiState.value.displayProfiles.map { it.name } val searchResults =
viewModel.uiState.value.displayProfiles
.map { it.name }
assertEquals(listOf("scout"), searchResults) assertEquals(listOf("scout"), searchResults)
} }
@@ -230,8 +234,16 @@ class BotsViewModelTest {
advanceUntilIdle() advanceUntilIdle()
assertEquals(2, viewModel.uiState.value.allGroups.size) assertEquals(2, viewModel.uiState.value.allGroups.size)
assertEquals("Dream Team", viewModel.uiState.value.allGroups[0].name) assertEquals(
assertEquals(2, viewModel.uiState.value.allGroups[0].members.size) "Dream Team",
viewModel.uiState.value.allGroups[0]
.name,
)
assertEquals(
2,
viewModel.uiState.value.allGroups[0]
.members.size,
)
var success = false var success = false
viewModel.disbandGroupChat("Dream Team") { success = true } viewModel.disbandGroupChat("Dream Team") { success = true }
@@ -205,7 +205,9 @@ class GroupChatViewModelTest {
testScheduler.runCurrent() testScheduler.runCurrent()
// State should contain user message // State should contain user message
val userMsg = viewModel.uiState.value.messages.first() val userMsg =
viewModel.uiState.value.messages
.first()
assertTrue(userMsg.isUser) assertTrue(userMsg.isUser)
assertEquals("@scout what's the status?", userMsg.text) assertEquals("@scout what's the status?", userMsg.text)
@@ -213,7 +215,9 @@ class GroupChatViewModelTest {
eventsFlow.emit(WsEvent.MessageToken(token = "Building ", sessionId = "session-scout-1")) eventsFlow.emit(WsEvent.MessageToken(token = "Building ", sessionId = "session-scout-1"))
testScheduler.runCurrent() testScheduler.runCurrent()
val streamingMsg = viewModel.uiState.value.messages.last() val streamingMsg =
viewModel.uiState.value.messages
.last()
assertFalse(streamingMsg.isUser) assertFalse(streamingMsg.isUser)
assertEquals("scout", streamingMsg.senderName) assertEquals("scout", streamingMsg.senderName)
assertEquals("Building ", streamingMsg.text) assertEquals("Building ", streamingMsg.text)
@@ -222,7 +226,9 @@ class GroupChatViewModelTest {
eventsFlow.emit(WsEvent.MessageToken(token = "the APK...", sessionId = "session-scout-1")) eventsFlow.emit(WsEvent.MessageToken(token = "the APK...", sessionId = "session-scout-1"))
testScheduler.runCurrent() testScheduler.runCurrent()
val updatedStreaming = viewModel.uiState.value.messages.last() val updatedStreaming =
viewModel.uiState.value.messages
.last()
assertEquals("Building the APK...", updatedStreaming.text) assertEquals("Building the APK...", updatedStreaming.text)
// Complete turn // Complete turn
@@ -285,7 +291,11 @@ class GroupChatViewModelTest {
testScheduler.runCurrent() testScheduler.runCurrent()
assertEquals(2, viewModel.uiState.value.messages.size) assertEquals(2, viewModel.uiState.value.messages.size)
assertEquals("scout", viewModel.uiState.value.messages[1].senderName) assertEquals(
"scout",
viewModel.uiState.value.messages[1]
.senderName,
)
// Continuation pass triggers coder // Continuation pass triggers coder
eventsFlow.emit( eventsFlow.emit(
@@ -345,7 +355,11 @@ class GroupChatViewModelTest {
// Because lease is held by desktop_client_99, mobile only posts the user message and yields // Because lease is held by desktop_client_99, mobile only posts the user message and yields
assertEquals(1, viewModel.uiState.value.messages.size) assertEquals(1, viewModel.uiState.value.messages.size)
assertTrue(viewModel.uiState.value.messages.first().isUser) assertTrue(
viewModel.uiState.value.messages
.first()
.isUser,
)
} }
@Test @Test
@@ -452,7 +466,9 @@ class GroupChatViewModelTest {
) )
testScheduler.runCurrent() testScheduler.runCurrent()
val msgWithTool = viewModel.uiState.value.messages.last() val msgWithTool =
viewModel.uiState.value.messages
.last()
assertEquals(1, msgWithTool.toolCalls.size) assertEquals(1, msgWithTool.toolCalls.size)
val toolCall = msgWithTool.toolCalls.first() val toolCall = msgWithTool.toolCalls.first()
assertEquals("terminal", toolCall.name) assertEquals("terminal", toolCall.name)
@@ -469,7 +485,9 @@ class GroupChatViewModelTest {
) )
testScheduler.runCurrent() testScheduler.runCurrent()
val msgAfterToolDone = viewModel.uiState.value.messages.last() val msgAfterToolDone =
viewModel.uiState.value.messages
.last()
assertFalse(msgAfterToolDone.toolCalls.first().isRunning) assertFalse(msgAfterToolDone.toolCalls.first().isRunning)
// Complete message // Complete message
@@ -481,7 +499,9 @@ class GroupChatViewModelTest {
) )
testScheduler.runCurrent() testScheduler.runCurrent()
val finalMsg = viewModel.uiState.value.messages.last() val finalMsg =
viewModel.uiState.value.messages
.last()
assertEquals("Current time is Sat Aug 29", finalMsg.text) assertEquals("Current time is Sat Aug 29", finalMsg.text)
assertEquals(1, finalMsg.toolCalls.size) assertEquals(1, finalMsg.toolCalls.size)
assertFalse(finalMsg.toolCalls.first().isRunning) assertFalse(finalMsg.toolCalls.first().isRunning)
@@ -158,7 +158,13 @@ class ChatInputPolicyTest {
@Test @Test
fun applyMention_insertsHandleAndAdvancesCursor() { fun applyMention_insertsHandleAndAdvancesCursor() {
val initial = TextFieldValue("ask @sc", selection = androidx.compose.ui.text.TextRange(7)) val initial =
TextFieldValue(
"ask @sc",
selection =
androidx.compose.ui.text
.TextRange(7),
)
val updated = ChatInputPolicy.applyMention(initial, "scout") val updated = ChatInputPolicy.applyMention(initial, "scout")
assertEquals("ask @scout ", updated.text) assertEquals("ask @scout ", updated.text)
assertEquals(11, updated.selection.end) assertEquals(11, updated.selection.end)
@@ -369,12 +369,13 @@ class ChatToolDedupeTest {
var stream = StreamingState() var stream = StreamingState()
stream = ChatWsEventReducer.reduce(s, stream, WsEvent.MessageStart("session-1"), "session-1").streamingState stream = ChatWsEventReducer.reduce(s, stream, WsEvent.MessageStart("session-1"), "session-1").streamingState
stream = stream =
ChatWsEventReducer.reduce( ChatWsEventReducer
s, .reduce(
stream, s,
WsEvent.ReasoningDelta("thinking about meow", "session-1"), stream,
"session-1", WsEvent.ReasoningDelta("thinking about meow", "session-1"),
).streamingState "session-1",
).streamingState
var r = var r =
ChatWsEventReducer.reduce( ChatWsEventReducer.reduce(
s, s,
@@ -159,7 +159,12 @@ class ChatUpdateCommandTest {
advanceUntilIdle() advanceUntilIdle()
assertTrue("confirm dialog should open", vm.uiState.value.updateConfirmOpen) assertTrue("confirm dialog should open", vm.uiState.value.updateConfirmOpen)
assertEquals("/update", vm.uiState.value.messages.last().content) assertEquals(
"/update",
vm.uiState.value.messages
.last()
.content,
)
// The whole point of the fix: NOTHING goes over the wire. // The whole point of the fix: NOTHING goes over the wire.
verify(exactly = 0) { verify(exactly = 0) {
@@ -513,7 +513,10 @@ class ChatViewModelTest {
// A blocklisted command can never dispatch — it must not climb // A blocklisted command can never dispatch — it must not climb
// the autocomplete ranking either. // the autocomplete ranking either.
assertTrue(viewModel.uiState.value.slashUsageCounts.isEmpty()) assertTrue(
viewModel.uiState.value.slashUsageCounts
.isEmpty(),
)
verify(exactly = 0) { verify(exactly = 0) {
HermesWsClient.request(WsMethods.COMMAND_DISPATCH, any(), any()) HermesWsClient.request(WsMethods.COMMAND_DISPATCH, any(), any())
} }
@@ -1033,7 +1036,10 @@ class ChatViewModelTest {
viewModel.uiState.value.messages[1] viewModel.uiState.value.messages[1]
.role, .role,
) )
assertFalse(viewModel.uiState.value.messages[1].isStreaming) assertFalse(
viewModel.uiState.value.messages[1]
.isStreaming,
)
assertEquals( assertEquals(
MessageRole.TOOL, MessageRole.TOOL,
viewModel.uiState.value.messages[2] viewModel.uiState.value.messages[2]
@@ -1041,7 +1047,10 @@ class ChatViewModelTest {
) )
assertNull(viewModel.streamingState.value.streamingMessage) assertNull(viewModel.streamingState.value.streamingMessage)
assertEquals( assertEquals(
listOf(viewModel.uiState.value.messages[1].id), listOf(
viewModel.uiState.value.messages[1]
.id,
),
viewModel.streamingState.value.sealedOrphanIds, viewModel.streamingState.value.sealedOrphanIds,
) )
@@ -1050,7 +1059,8 @@ class ChatViewModelTest {
mockEventsFlow.emit(WsEvent.MessageComplete("Calculating sum = 4", sessionId)) mockEventsFlow.emit(WsEvent.MessageComplete("Calculating sum = 4", sessionId))
advanceUntilIdle() advanceUntilIdle()
val assistant = val assistant =
viewModel.uiState.value.messages.filter { it.role == MessageRole.ASSISTANT } viewModel.uiState.value.messages
.filter { it.role == MessageRole.ASSISTANT }
assertEquals(2, assistant.size) assertEquals(2, assistant.size)
assertEquals("Calculating sum", assistant[0].content) assertEquals("Calculating sum", assistant[0].content)
assertEquals(" = 4", assistant[1].content) assertEquals(" = 4", assistant[1].content)
@@ -1573,7 +1583,8 @@ class ChatViewModelTest {
every { AuthManager.getBaseUrl() } returns "http://test.local/" every { AuthManager.getBaseUrl() } returns "http://test.local/"
coEvery { mockApi.getSessions(any(), any(), any()) } returns coEvery { mockApi.getSessions(any(), any(), any()) } returns
retrofit2.Response.success( retrofit2.Response.success(
com.m57.hermescontrol.data.model.SessionListResponse(sessions = emptyList(), total = 0), com.m57.hermescontrol.data.model
.SessionListResponse(sessions = emptyList(), total = 0),
) )
coEvery { coEvery {
mockApi.getSessionMessages( mockApi.getSessionMessages(
@@ -1623,7 +1634,8 @@ class ChatViewModelTest {
val empty = val empty =
retrofit2.Response.success( retrofit2.Response.success(
com.m57.hermescontrol.data.model.SessionMessagesResponse(messages = emptyList()), com.m57.hermescontrol.data.model
.SessionMessagesResponse(messages = emptyList()),
) )
messagesA.complete(empty) messagesA.complete(empty)
messagesB.complete(empty) messagesB.complete(empty)
@@ -1641,7 +1653,8 @@ class ChatViewModelTest {
every { AuthManager.getBaseUrl() } returns "http://test.local/" every { AuthManager.getBaseUrl() } returns "http://test.local/"
coEvery { mockApi.getSessions(any(), any(), any()) } returns coEvery { mockApi.getSessions(any(), any(), any()) } returns
retrofit2.Response.success( retrofit2.Response.success(
com.m57.hermescontrol.data.model.SessionListResponse(sessions = emptyList(), total = 0), com.m57.hermescontrol.data.model
.SessionListResponse(sessions = emptyList(), total = 0),
) )
coEvery { coEvery {
mockApi.getSessionMessages( mockApi.getSessionMessages(
@@ -1698,7 +1711,11 @@ class ChatViewModelTest {
advanceUntilIdle() advanceUntilIdle()
assertEquals("session-b", viewModel.uiState.value.currentSessionId) assertEquals("session-b", viewModel.uiState.value.currentSessionId)
assertEquals(listOf("message-b"), viewModel.uiState.value.messages.map { it.content }) assertEquals(
listOf("message-b"),
viewModel.uiState.value.messages
.map { it.content },
)
} }
@Test @Test
@@ -1927,7 +1944,8 @@ class ChatViewModelTest {
sessionIds.forEach { sessionId -> sessionIds.forEach { sessionId ->
coEvery { mockApi.getSessionMessages(sessionId, any(), any(), any(), any()) } returns coEvery { mockApi.getSessionMessages(sessionId, any(), any(), any(), any()) } returns
retrofit2.Response.success( retrofit2.Response.success(
com.m57.hermescontrol.data.model.SessionMessagesResponse(messages = emptyList()), com.m57.hermescontrol.data.model
.SessionMessagesResponse(messages = emptyList()),
) )
} }
} }
@@ -1956,7 +1974,11 @@ class ChatViewModelTest {
// Warm cache painted and survived the exhausted retry cycle — the // Warm cache painted and survived the exhausted retry cycle — the
// screen never went blank, and the user gets history + an error. // screen never went blank, and the user gets history + an error.
assertEquals(1, viewModel.uiState.value.messages.size) assertEquals(1, viewModel.uiState.value.messages.size)
assertEquals("Cached hello", viewModel.uiState.value.messages[0].content) assertEquals(
"Cached hello",
viewModel.uiState.value.messages[0]
.content,
)
assertFalse(viewModel.uiState.value.isLoading) assertFalse(viewModel.uiState.value.isLoading)
assertNotNull("resumeError must be set after retries exhaust", viewModel.uiState.value.resumeError) assertNotNull("resumeError must be set after retries exhaust", viewModel.uiState.value.resumeError)
assertFalse(viewModel.uiState.value.isResumeRetrying) assertFalse(viewModel.uiState.value.isResumeRetrying)
@@ -2305,7 +2327,8 @@ class ChatViewModelTest {
assertEquals("session-new", viewModel.uiState.value.currentSessionId) assertEquals("session-new", viewModel.uiState.value.currentSessionId)
assertTrue( assertTrue(
"recovery notice must be visible", "recovery notice must be visible",
viewModel.uiState.value.messages.any { it.content.contains("no longer available") }, viewModel.uiState.value.messages
.any { it.content.contains("no longer available") },
) )
} }
@@ -2328,7 +2351,8 @@ class ChatViewModelTest {
ApiClient.hermesApi.getSessionMessages(capture(fetchedSessions), any(), any(), any(), any()) ApiClient.hermesApi.getSessionMessages(capture(fetchedSessions), any(), any(), any(), any())
} returns } returns
retrofit2.Response.success( retrofit2.Response.success(
com.m57.hermescontrol.data.model.SessionMessagesResponse(messages = emptyList()), com.m57.hermescontrol.data.model
.SessionMessagesResponse(messages = emptyList()),
) )
// /fork sends session.branch keyed on the runtime id. // /fork sends session.branch keyed on the runtime id.
@@ -2379,7 +2403,8 @@ class ChatViewModelTest {
) )
} else { } else {
retrofit2.Response.success( retrofit2.Response.success(
com.m57.hermescontrol.data.model.SessionMessagesResponse(messages = emptyList()), com.m57.hermescontrol.data.model
.SessionMessagesResponse(messages = emptyList()),
) )
} }
} }
@@ -3301,7 +3326,12 @@ class ChatViewModelTest {
assertTrue(viewModel.uiState.value.hasOlderMessages) assertTrue(viewModel.uiState.value.hasOlderMessages)
assertEquals(150, viewModel.uiState.value.messages.size) assertEquals(150, viewModel.uiState.value.messages.size)
// Stable keys come from the server row id, not the from-end position. // Stable keys come from the server row id, not the from-end position.
assertEquals("rest-session-456-150", viewModel.uiState.value.messages.last().id) assertEquals(
"rest-session-456-150",
viewModel.uiState.value.messages
.last()
.id,
)
} }
@Test @Test
@@ -3444,7 +3474,10 @@ class ChatViewModelTest {
// 160 rows: the 10 new ones appended, the existing 150 kept — // 160 rows: the 10 new ones appended, the existing 150 kept —
// no duplicates, no dropped newest copy (stable row-id keys). // no duplicates, no dropped newest copy (stable row-id keys).
assertEquals(160, viewModel.uiState.value.messages.size) assertEquals(160, viewModel.uiState.value.messages.size)
assertTrue(viewModel.uiState.value.messages.any { it.id == "rest-session-456-160" }) assertTrue(
viewModel.uiState.value.messages
.any { it.id == "rest-session-456-160" },
)
} }
// ── Attachment open (issue #724) ───────────────────────────────────── // ── Attachment open (issue #724) ─────────────────────────────────────
@@ -3506,15 +3539,17 @@ class ChatViewModelTest {
runTest { runTest {
mockkObject(GatewayFileClient) mockkObject(GatewayFileClient)
val cacheDir = val cacheDir =
java.io.File( java.io
System.getProperty("java.io.tmpdir"), .File(
"gw_save_${System.nanoTime()}", System.getProperty("java.io.tmpdir"),
).apply { mkdirs() } "gw_save_${System.nanoTime()}",
).apply { mkdirs() }
every { app.cacheDir } returns cacheDir every { app.cacheDir } returns cacheDir
every { app.applicationContext } returns app every { app.applicationContext } returns app
every { app.getApplicationContext() } returns app every { app.getApplicationContext() } returns app
val file = val file =
java.io.File(System.getProperty("java.io.tmpdir"), "note_${System.nanoTime()}.txt") java.io
.File(System.getProperty("java.io.tmpdir"), "note_${System.nanoTime()}.txt")
.apply { writeText("downloaded") } .apply { writeText("downloaded") }
coEvery { GatewayFileClient.fetch("/tmp/note.txt", any()) } returns coEvery { GatewayFileClient.fetch("/tmp/note.txt", any()) } returns
GatewayFileResult.Success(GatewayFile("note.txt", "text/plain", file)) GatewayFileResult.Success(GatewayFile("note.txt", "text/plain", file))
@@ -3552,10 +3587,11 @@ class ChatViewModelTest {
runTest { runTest {
mockkObject(GatewayFileClient) mockkObject(GatewayFileClient)
val cacheDir = val cacheDir =
java.io.File( java.io
System.getProperty("java.io.tmpdir"), .File(
"gw_save_${System.nanoTime()}", System.getProperty("java.io.tmpdir"),
).apply { mkdirs() } "gw_save_${System.nanoTime()}",
).apply { mkdirs() }
every { app.cacheDir } returns cacheDir every { app.cacheDir } returns cacheDir
every { app.applicationContext } returns app every { app.applicationContext } returns app
every { app.getApplicationContext() } returns app every { app.getApplicationContext() } returns app
@@ -3590,10 +3626,11 @@ class ChatViewModelTest {
runTest { runTest {
mockkObject(GatewayFileClient) mockkObject(GatewayFileClient)
val cacheDir = val cacheDir =
java.io.File( java.io
System.getProperty("java.io.tmpdir"), .File(
"gw_save_${System.nanoTime()}", System.getProperty("java.io.tmpdir"),
).apply { mkdirs() } "gw_save_${System.nanoTime()}",
).apply { mkdirs() }
every { app.cacheDir } returns cacheDir every { app.cacheDir } returns cacheDir
every { app.applicationContext } returns app every { app.applicationContext } returns app
every { app.getApplicationContext() } returns app every { app.getApplicationContext() } returns app
@@ -3606,7 +3643,8 @@ class ChatViewModelTest {
GatewayFile( GatewayFile(
"report.pdf", "report.pdf",
"application/pdf", "application/pdf",
java.io.File(System.getProperty("java.io.tmpdir"), "report_${System.nanoTime()}.pdf") java.io
.File(System.getProperty("java.io.tmpdir"), "report_${System.nanoTime()}.pdf")
.apply { writeBytes(byteArrayOf(1)) }, .apply { writeBytes(byteArrayOf(1)) },
), ),
) )
@@ -3625,7 +3663,11 @@ class ChatViewModelTest {
verify(exactly = 0) { resolver.delete(any(), any(), any()) } verify(exactly = 0) { resolver.delete(any(), any(), any()) }
assertNull(viewModel.uiState.value.savingAttachmentPath) assertNull(viewModel.uiState.value.savingAttachmentPath)
assertTrue(viewModel.uiState.value.openError.orEmpty().startsWith("Could not save")) assertTrue(
viewModel.uiState.value.openError
.orEmpty()
.startsWith("Could not save"),
)
} }
@Test @Test
@@ -3633,10 +3675,11 @@ class ChatViewModelTest {
runTest { runTest {
mockkObject(GatewayFileClient) mockkObject(GatewayFileClient)
val cacheDir = val cacheDir =
java.io.File( java.io
System.getProperty("java.io.tmpdir"), .File(
"gw_save_${System.nanoTime()}", System.getProperty("java.io.tmpdir"),
).apply { mkdirs() } "gw_save_${System.nanoTime()}",
).apply { mkdirs() }
every { app.cacheDir } returns cacheDir every { app.cacheDir } returns cacheDir
every { app.applicationContext } returns app every { app.applicationContext } returns app
every { app.getApplicationContext() } returns app every { app.getApplicationContext() } returns app
@@ -3675,10 +3718,11 @@ class ChatViewModelTest {
runTest { runTest {
mockkObject(GatewayFileClient) mockkObject(GatewayFileClient)
val cacheDir = val cacheDir =
java.io.File( java.io
System.getProperty("java.io.tmpdir"), .File(
"gw_open_${System.nanoTime()}", System.getProperty("java.io.tmpdir"),
).apply { mkdirs() } "gw_open_${System.nanoTime()}",
).apply { mkdirs() }
every { app.cacheDir } returns cacheDir every { app.cacheDir } returns cacheDir
every { app.applicationContext } returns app every { app.applicationContext } returns app
every { app.getApplicationContext() } returns app every { app.getApplicationContext() } returns app
@@ -3711,10 +3755,11 @@ class ChatViewModelTest {
runTest { runTest {
mockkObject(GatewayFileClient) mockkObject(GatewayFileClient)
val cacheDir = val cacheDir =
java.io.File( java.io
System.getProperty("java.io.tmpdir"), .File(
"gw_open_${System.nanoTime()}", System.getProperty("java.io.tmpdir"),
).apply { mkdirs() } "gw_open_${System.nanoTime()}",
).apply { mkdirs() }
every { app.cacheDir } returns cacheDir every { app.cacheDir } returns cacheDir
every { app.applicationContext } returns app every { app.applicationContext } returns app
every { app.getApplicationContext() } returns app every { app.getApplicationContext() } returns app
@@ -3743,7 +3788,11 @@ class ChatViewModelTest {
// …and cleared in all outcomes. // …and cleared in all outcomes.
assertNull(vm.uiState.value.openingAttachmentPath) assertNull(vm.uiState.value.openingAttachmentPath)
assertTrue(vm.uiState.value.openError.orEmpty().contains("big.pdf")) assertTrue(
vm.uiState.value.openError
.orEmpty()
.contains("big.pdf"),
)
} }
@Test @Test
@@ -3763,7 +3812,10 @@ class ChatViewModelTest {
advanceUntilIdle() advanceUntilIdle()
// Optimistic UI state has the user message immediately // Optimistic UI state has the user message immediately
assertTrue(vm.uiState.value.messages.any { it.content == "My super important long prompt" }) assertTrue(
vm.uiState.value.messages
.any { it.content == "My super important long prompt" },
)
assertTrue(vm.uiState.value.isAgentTyping) assertTrue(vm.uiState.value.isAgentTyping)
// But wsClient.sendMessage not dispatched yet because session_id was null // But wsClient.sendMessage not dispatched yet because session_id was null
assertTrue(sentPrompts.isEmpty()) assertTrue(sentPrompts.isEmpty())
@@ -3781,7 +3833,10 @@ class ChatViewModelTest {
// Prompt should be dispatched automatically! // Prompt should be dispatched automatically!
assertEquals(listOf("My super important long prompt"), sentPrompts) assertEquals(listOf("My super important long prompt"), sentPrompts)
assertEquals("session-storage-969", vm.uiState.value.currentSessionId) assertEquals("session-storage-969", vm.uiState.value.currentSessionId)
assertTrue(vm.uiState.value.messages.any { it.content == "My super important long prompt" }) assertTrue(
vm.uiState.value.messages
.any { it.content == "My super important long prompt" },
)
} }
@Test @Test
@@ -3806,7 +3861,10 @@ class ChatViewModelTest {
advanceUntilIdle() advanceUntilIdle()
// Optimistic message in UI // Optimistic message in UI
assertTrue(vm.uiState.value.messages.any { it.content == "Prompt typed during reconnect" }) assertTrue(
vm.uiState.value.messages
.any { it.content == "Prompt typed during reconnect" },
)
// Complete the new SESSION_CREATE RPC // Complete the new SESSION_CREATE RPC
mockEventsFlow.emit( mockEventsFlow.emit(
@@ -3842,6 +3900,9 @@ class ChatViewModelTest {
assertFalse(vm.uiState.value.isAgentTyping) assertFalse(vm.uiState.value.isAgentTyping)
assertNotNull(vm.uiState.value.errorMessage) assertNotNull(vm.uiState.value.errorMessage)
assertTrue(vm.uiState.value.errorMessage!!.contains("Backend exploded")) assertTrue(
vm.uiState.value.errorMessage!!
.contains("Backend exploded"),
)
} }
} }
@@ -421,7 +421,12 @@ class ChatWsEventReducerTest {
) )
assertEquals(1, result.state.messages.size) assertEquals(1, result.state.messages.size)
assertEquals("complete", result.state.messages.single().content) assertEquals(
"complete",
result.state.messages
.single()
.content,
)
} }
@Test @Test
@@ -556,12 +561,13 @@ class ChatWsEventReducerTest {
var reasoningState = start.streamingState var reasoningState = start.streamingState
for (token in reasoningTokens) { for (token in reasoningTokens) {
reasoningState = reasoningState =
ChatWsEventReducer.reduce( ChatWsEventReducer
state, .reduce(
reasoningState, state,
WsEvent.ReasoningDelta(token, "session-1"), reasoningState,
"session-1", WsEvent.ReasoningDelta(token, "session-1"),
).streamingState "session-1",
).streamingState
} }
assertEquals(true, reasoningState.isReasoning) assertEquals(true, reasoningState.isReasoning)
assertEquals("The user just said \"run echo hi\". Let me run it.", reasoningState.reasoningText) assertEquals("The user just said \"run echo hi\". Let me run it.", reasoningState.reasoningText)
@@ -578,8 +584,18 @@ class ChatWsEventReducerTest {
"session-1", "session-1",
) )
assertEquals(1, result.state.messages.size) assertEquals(1, result.state.messages.size)
assertEquals(MessageRole.TOOL, result.state.messages.first().role) assertEquals(
assertEquals("terminal", result.state.messages.first().toolName) MessageRole.TOOL,
result.state.messages
.first()
.role,
)
assertEquals(
"terminal",
result.state.messages
.first()
.toolName,
)
// Issue #771: streaming message + reasoning survive the tool call // Issue #771: streaming message + reasoning survive the tool call
assertEquals(true, result.streamingState.streamingMessage?.isStreaming) assertEquals(true, result.streamingState.streamingMessage?.isStreaming)
assertEquals("The user just said \"run echo hi\". Let me run it.", result.streamingState.reasoningText) assertEquals("The user just said \"run echo hi\". Let me run it.", result.streamingState.reasoningText)
@@ -592,7 +608,12 @@ class ChatWsEventReducerTest {
WsEvent.ToolComplete("terminal", mapOf("output" to "hi"), "session-1"), WsEvent.ToolComplete("terminal", mapOf("output" to "hi"), "session-1"),
"session-1", "session-1",
) )
assertEquals(ToolStatus.COMPLETED, result.state.messages.first().toolStatus) assertEquals(
ToolStatus.COMPLETED,
result.state.messages
.first()
.toolStatus,
)
// 5. reasoning.available — authoritative full-trace fill // 5. reasoning.available — authoritative full-trace fill
val fullReasoning = "The user just said \"run echo hi\". That's a simple terminal command. Let me just run it." val fullReasoning = "The user just said \"run echo hi\". That's a simple terminal command. Let me just run it."
@@ -683,7 +704,12 @@ class ChatWsEventReducerTest {
"session-1", "session-1",
) )
assertEquals("payload reasoning", result.state.messages.last().reasoningText) assertEquals(
"payload reasoning",
result.state.messages
.last()
.reasoningText,
)
} }
@Test @Test
@@ -223,7 +223,8 @@ class SlashCommandDispatchRpcTest {
// The optimistic bubble shows the queued TEXT (prefix stripped) so // The optimistic bubble shows the queued TEXT (prefix stripped) so
// the transcript sync dedupes it against the server echo. // the transcript sync dedupes it against the server echo.
val userBubble = val userBubble =
vm.uiState.value.messages.lastOrNull { it.role == MessageRole.USER } vm.uiState.value.messages
.lastOrNull { it.role == MessageRole.USER }
assertEquals("do the thing", userBubble?.content) assertEquals("do the thing", userBubble?.content)
} }
@@ -247,7 +248,9 @@ class SlashCommandDispatchRpcTest {
vm.sendMessage("/queue") vm.sendMessage("/queue")
advanceUntilIdle() advanceUntilIdle()
val last = vm.uiState.value.messages.lastOrNull() val last =
vm.uiState.value.messages
.lastOrNull()
assertEquals("usage: /queue <prompt>", last?.content) assertEquals("usage: /queue <prompt>", last?.content)
assertEquals(0, sendCalls) assertEquals(0, sendCalls)
assertTrue( assertTrue(
@@ -628,7 +631,9 @@ class SlashCommandDispatchRpcTest {
vm.sendMessage("/focus on") vm.sendMessage("/focus on")
advanceUntilIdle() advanceUntilIdle()
val last = vm.uiState.value.messages.lastOrNull() val last =
vm.uiState.value.messages
.lastOrNull()
assertEquals("Focus view: ON (tool progress pinned off)", last?.content) assertEquals("Focus view: ON (tool progress pinned off)", last?.content)
} }
} }
@@ -202,7 +202,10 @@ class ActionProgressControllerTest {
tick() tick()
assertEquals(ActionProgressPhase.SUCCEEDED, controller.state.value.phase) assertEquals(ActionProgressPhase.SUCCEEDED, controller.state.value.phase)
assertTrue(controller.state.value.trailingLines.isEmpty()) assertTrue(
controller.state.value.trailingLines
.isEmpty(),
)
controller.pushTrailingLines(listOf("Outcome: success", "Version: 0.20.4 → 0.20.5")) controller.pushTrailingLines(listOf("Outcome: success", "Version: 0.20.4 → 0.20.5"))
@@ -216,7 +219,10 @@ class ActionProgressControllerTest {
val controller = controller() val controller = controller()
controller.open() controller.open()
controller.pushTrailingLines(emptyList()) controller.pushTrailingLines(emptyList())
assertTrue(controller.state.value.trailingLines.isEmpty()) assertTrue(
controller.state.value.trailingLines
.isEmpty(),
)
} }
@Test @Test
@@ -204,7 +204,9 @@ class CronJobsViewModelTest {
vm.saveEditor() vm.saveEditor()
settle() settle()
val toast = vm.uiState.value.editorState.toastMessage.orEmpty() val toast =
vm.uiState.value.editorState.toastMessage
.orEmpty()
assertTrue(toast, toast.contains("invalid time '25:00'")) assertTrue(toast, toast.contains("invalid time '25:00'"))
assertFalse(vm.uiState.value.editorState.isSaving) assertFalse(vm.uiState.value.editorState.isSaving)
} }
@@ -394,7 +396,11 @@ class CronJobsViewModelTest {
coVerify { mockApi.deleteCronJob("j5") } coVerify { mockApi.deleteCronJob("j5") }
assertTrue(vm.uiState.value.editorState.isOpen) assertTrue(vm.uiState.value.editorState.isOpen)
assertTrue(vm.uiState.value.editorState.toastMessage.orEmpty().contains("Failed to save")) assertTrue(
vm.uiState.value.editorState.toastMessage
.orEmpty()
.contains("Failed to save"),
)
} }
@Test @Test
@@ -549,7 +555,9 @@ class CronJobsViewModelTest {
vm.loadCronJobs() vm.loadCronJobs()
settle() settle()
val job = vm.uiState.value.jobs.first() val job =
vm.uiState.value.jobs
.first()
assertNotNull(job.last_fire_error) assertNotNull(job.last_fire_error)
assertEquals("gateway unreachable", job.last_fire_error?.detail) assertEquals("gateway unreachable", job.last_fire_error?.detail)
assertEquals("2026-08-18T09:00:00", job.last_fire_error?.at) assertEquals("2026-08-18T09:00:00", job.last_fire_error?.at)
@@ -222,7 +222,12 @@ class KanbanViewModelTest {
coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.success(Unit) coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.success(Unit)
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY) vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
settle() settle()
assertEquals("ready", vm.uiState.value.tasks.first { it.id == "t1" }.status) assertEquals(
"ready",
vm.uiState.value.tasks
.first { it.id == "t1" }
.status,
)
coVerify { mockApi.updateKanbanTask("t1", mapOf("status" to "ready")) } coVerify { mockApi.updateKanbanTask("t1", mapOf("status" to "ready")) }
} }
@@ -234,8 +239,16 @@ class KanbanViewModelTest {
coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.error(409, "".toResponseBody()) coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.error(409, "".toResponseBody())
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY) vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
settle() settle()
assertEquals("todo", vm.uiState.value.tasks.first { it.id == "t1" }.status) assertEquals(
assertTrue(vm.uiState.value.toastMessage?.contains("Move failed") == true) "todo",
vm.uiState.value.tasks
.first { it.id == "t1" }
.status,
)
assertTrue(
vm.uiState.value.toastMessage
?.contains("Move failed") == true,
)
} }
@Test @Test
@@ -81,7 +81,9 @@ class MemoryViewModelTest {
@Test @Test
fun `load also fetches learning graph for self-improvement section`() { fun `load also fetches learning graph for self-improvement section`() {
val graph = com.m57.hermescontrol.data.model.LearningGraphResponse(nodes = emptyList()) val graph =
com.m57.hermescontrol.data.model
.LearningGraphResponse(nodes = emptyList())
coEvery { mockApi.getLearningGraph() } returns Response.success(graph) coEvery { mockApi.getLearningGraph() } returns Response.success(graph)
val vm = MemoryViewModel() val vm = MemoryViewModel()
vm.load() vm.load()
@@ -100,7 +102,11 @@ class MemoryViewModelTest {
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
assertNull(vm.uiState.value.memory) assertNull(vm.uiState.value.memory)
assertTrue(vm.uiState.value.errorMessage.orEmpty().isNotBlank()) assertTrue(
vm.uiState.value.errorMessage
.orEmpty()
.isNotBlank(),
)
} }
@Test @Test
@@ -127,7 +133,11 @@ class MemoryViewModelTest {
vm.resetMemory("memory") vm.resetMemory("memory")
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
assertTrue(vm.uiState.value.toastMessage.orEmpty().contains("Failed to reset memory")) assertTrue(
vm.uiState.value.toastMessage
.orEmpty()
.contains("Failed to reset memory"),
)
assertNull(vm.uiState.value.resetting) assertNull(vm.uiState.value.resetting)
} }
} }
@@ -97,9 +97,21 @@ class PairingViewModelTest {
assertFalse(state.isLoading) assertFalse(state.isLoading)
assertNull(state.errorMessage) assertNull(state.errorMessage)
assertEquals(1, state.pairing?.pending?.size) assertEquals(1, state.pairing?.pending?.size)
assertEquals("bob", state.pairing?.pending?.first()?.userName) assertEquals(
"bob",
state.pairing
?.pending
?.first()
?.userName,
)
assertEquals(1, state.pairing?.approved?.size) assertEquals(1, state.pairing?.approved?.size)
assertEquals("alice", state.pairing?.approved?.first()?.userName) assertEquals(
"alice",
state.pairing
?.approved
?.first()
?.userName,
)
} }
@Test @Test
@@ -148,7 +160,10 @@ class PairingViewModelTest {
assertEquals("discord", requestSlot.captured.platform) assertEquals("discord", requestSlot.captured.platform)
assertEquals("u2", requestSlot.captured.userId) assertEquals("u2", requestSlot.captured.userId)
coVerify(exactly = 1) { mockApi.getPairing() } coVerify(exactly = 1) { mockApi.getPairing() }
assertTrue(vm.uiState.value.toastMessage?.contains("u2") == true) assertTrue(
vm.uiState.value.toastMessage
?.contains("u2") == true,
)
} }
@Test @Test
@@ -229,7 +229,11 @@ class MemoryProviderDetailViewModelTest {
vm.saveConfig() vm.saveConfig()
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
assertTrue(vm.uiState.value.toastMessage.orEmpty().contains("Failed to save")) assertTrue(
vm.uiState.value.toastMessage
.orEmpty()
.contains("Failed to save"),
)
} }
@Test @Test
@@ -242,7 +246,13 @@ class MemoryProviderDetailViewModelTest {
val state = vm.uiState.value val state = vm.uiState.value
assertEquals("Setup completed", state.toastMessage) assertEquals("Setup completed", state.toastMessage)
assertTrue(state.setupResult?.ok == true) assertTrue(state.setupResult?.ok == true)
assertEquals("installed", state.setupResult?.results?.first()?.status) assertEquals(
"installed",
state.setupResult
?.results
?.first()
?.status,
)
} }
@Test @Test
@@ -253,7 +263,11 @@ class MemoryProviderDetailViewModelTest {
vm.runSetup() vm.runSetup()
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
assertTrue(vm.uiState.value.toastMessage.orEmpty().contains("Setup failed")) assertTrue(
vm.uiState.value.toastMessage
.orEmpty()
.contains("Setup failed"),
)
} }
@Test @Test
@@ -136,7 +136,10 @@ class ProfilesViewModelTest {
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockApi.renameProfile("work", RenameProfileRequest("play")) } coVerify { mockApi.renameProfile("work", RenameProfileRequest("play")) }
assertTrue(vm.uiState.value.toastMessage!!.contains("renamed")) assertTrue(
vm.uiState.value.toastMessage!!
.contains("renamed"),
)
// reload happened // reload happened
coVerify { mockApi.getProfiles() } coVerify { mockApi.getProfiles() }
} }
@@ -162,7 +165,10 @@ class ProfilesViewModelTest {
vm.renameProfile("work", "play") vm.renameProfile("work", "play")
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to rename")) assertTrue(
vm.uiState.value.toastMessage!!
.contains("Failed to rename"),
)
assertFalse(vm.uiState.value.isLoading) assertFalse(vm.uiState.value.isLoading)
} }
@@ -175,7 +181,10 @@ class ProfilesViewModelTest {
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockApi.deleteProfile("work") } coVerify { mockApi.deleteProfile("work") }
assertTrue(vm.uiState.value.toastMessage!!.contains("deleted")) assertTrue(
vm.uiState.value.toastMessage!!
.contains("deleted"),
)
coVerify { mockApi.getProfiles() } coVerify { mockApi.getProfiles() }
} }
@@ -187,7 +196,10 @@ class ProfilesViewModelTest {
vm.deleteProfile("work") vm.deleteProfile("work")
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to delete")) assertTrue(
vm.uiState.value.toastMessage!!
.contains("Failed to delete"),
)
} }
@Test @Test
@@ -200,7 +212,10 @@ class ProfilesViewModelTest {
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockApi.describeProfileAuto("work", ProfileDescribeAutoRequest(overwrite = true)) } coVerify { mockApi.describeProfileAuto("work", ProfileDescribeAutoRequest(overwrite = true)) }
assertTrue(vm.uiState.value.toastMessage!!.contains("Auto-described")) assertTrue(
vm.uiState.value.toastMessage!!
.contains("Auto-described"),
)
assertFalse(vm.uiState.value.isAutoDescribing) assertFalse(vm.uiState.value.isAutoDescribing)
coVerify { mockApi.getProfiles() } coVerify { mockApi.getProfiles() }
} }
@@ -214,7 +229,10 @@ class ProfilesViewModelTest {
vm.autoDescribeProfile("work") vm.autoDescribeProfile("work")
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
assertTrue(vm.uiState.value.toastMessage!!.contains("no aux client configured")) assertTrue(
vm.uiState.value.toastMessage!!
.contains("no aux client configured"),
)
assertFalse(vm.uiState.value.isAutoDescribing) assertFalse(vm.uiState.value.isAutoDescribing)
} }
@@ -241,7 +259,10 @@ class ProfilesViewModelTest {
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
assertNull(vm.uiState.value.setupCommand) assertNull(vm.uiState.value.setupCommand)
assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to fetch setup command")) assertTrue(
vm.uiState.value.toastMessage!!
.contains("Failed to fetch setup command"),
)
} }
@Test @Test
@@ -265,7 +286,11 @@ class ProfilesViewModelTest {
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
assertEquals(1, vm.uiState.value.modelProviders.size) assertEquals(1, vm.uiState.value.modelProviders.size)
assertEquals("gpt-4o", vm.uiState.value.modelProviders[0].models!![0]) assertEquals(
"gpt-4o",
vm.uiState.value.modelProviders[0]
.models!![0],
)
assertEquals(listOf(PinnedModel("openai", "gpt-4")), vm.uiState.value.modelPickerPinned) assertEquals(listOf(PinnedModel("openai", "gpt-4")), vm.uiState.value.modelPickerPinned)
assertFalse(vm.uiState.value.isLoadingBuilderData) assertFalse(vm.uiState.value.isLoadingBuilderData)
} }
@@ -278,7 +303,10 @@ class ProfilesViewModelTest {
vm.loadModelOptions() vm.loadModelOptions()
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to load models")) assertTrue(
vm.uiState.value.toastMessage!!
.contains("Failed to load models"),
)
assertNull(vm.uiState.value.errorMessage) assertNull(vm.uiState.value.errorMessage)
assertFalse(vm.uiState.value.isLoadingBuilderData) assertFalse(vm.uiState.value.isLoadingBuilderData)
} }
@@ -292,7 +320,10 @@ class ProfilesViewModelTest {
assertEquals(listOf(PinnedModel("openai", "gpt-4")), storedPinnedModels) assertEquals(listOf(PinnedModel("openai", "gpt-4")), storedPinnedModels)
vm.togglePinModel("openai", "gpt-4") vm.togglePinModel("openai", "gpt-4")
assertTrue(vm.uiState.value.modelPickerPinned.isEmpty()) assertTrue(
vm.uiState.value.modelPickerPinned
.isEmpty(),
)
assertTrue(storedPinnedModels.isEmpty()) assertTrue(storedPinnedModels.isEmpty())
} }
@@ -305,7 +336,10 @@ class ProfilesViewModelTest {
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
coVerify { ProfileSwitchCoordinator.switchProfile("work") } coVerify { ProfileSwitchCoordinator.switchProfile("work") }
assertTrue(vm.uiState.value.toastMessage!!.contains("Switched to profile work")) assertTrue(
vm.uiState.value.toastMessage!!
.contains("Switched to profile work"),
)
coVerify { mockApi.getProfiles() } coVerify { mockApi.getProfiles() }
} }
@@ -320,7 +354,10 @@ class ProfilesViewModelTest {
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
assertNull(vm.uiState.value.activeProfileName) assertNull(vm.uiState.value.activeProfileName)
assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to switch profile")) assertTrue(
vm.uiState.value.toastMessage!!
.contains("Failed to switch profile"),
)
} }
@Test @Test
@@ -339,7 +376,10 @@ class ProfilesViewModelTest {
), ),
) )
} }
assertTrue(vm.uiState.value.toastMessage!!.contains("cloned successfully")) assertTrue(
vm.uiState.value.toastMessage!!
.contains("cloned successfully"),
)
assertFalse(vm.uiState.value.isLoading) assertFalse(vm.uiState.value.isLoading)
coVerify { mockApi.getProfiles() } coVerify { mockApi.getProfiles() }
} }
@@ -352,7 +392,10 @@ class ProfilesViewModelTest {
vm.cloneProfile("default", "dev-copy") vm.cloneProfile("default", "dev-copy")
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to clone profile")) assertTrue(
vm.uiState.value.toastMessage!!
.contains("Failed to clone profile"),
)
assertFalse(vm.uiState.value.isLoading) assertFalse(vm.uiState.value.isLoading)
} }
@@ -378,12 +421,20 @@ class ProfilesViewModelTest {
vm.hideProfile("secret-bot") vm.hideProfile("secret-bot")
assertEquals(listOf("secret-bot"), storedHiddenProfiles) assertEquals(listOf("secret-bot"), storedHiddenProfiles)
assertTrue(vm.uiState.value.hasHiddenProfiles) assertTrue(vm.uiState.value.hasHiddenProfiles)
assertEquals(listOf("default"), vm.uiState.value.displayProfiles.map { it.name }) assertEquals(
listOf("default"),
vm.uiState.value.displayProfiles
.map { it.name },
)
// Toggle show hidden // Toggle show hidden
vm.toggleShowHidden() vm.toggleShowHidden()
assertTrue(vm.uiState.value.showHidden) assertTrue(vm.uiState.value.showHidden)
assertEquals(listOf("default", "secret-bot"), vm.uiState.value.displayProfiles.map { it.name }) assertEquals(
listOf("default", "secret-bot"),
vm.uiState.value.displayProfiles
.map { it.name },
)
// Unhide // Unhide
vm.unhideProfile("secret-bot") vm.unhideProfile("secret-bot")
@@ -116,7 +116,11 @@ class SessionsViewModelTest {
vm.loadSessions() vm.loadSessions()
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
assertEquals(listOf("s-1", "s-2"), vm.uiState.value.sessions.map { it.id }) assertEquals(
listOf("s-1", "s-2"),
vm.uiState.value.sessions
.map { it.id },
)
assertEquals(2, vm.uiState.value.total) assertEquals(2, vm.uiState.value.total)
assertFalse(vm.uiState.value.hasMore) assertFalse(vm.uiState.value.hasMore)
assertFalse(vm.uiState.value.isLoadingMore) assertFalse(vm.uiState.value.isLoadingMore)
@@ -206,7 +210,11 @@ class SessionsViewModelTest {
vm.loadSessions() vm.loadSessions()
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
assertEquals(listOf("old-pinned", "recent"), vm.uiState.value.sessions.map { it.id }) assertEquals(
listOf("old-pinned", "recent"),
vm.uiState.value.sessions
.map { it.id },
)
} }
@Test @Test
@@ -226,8 +234,17 @@ class SessionsViewModelTest {
vm.togglePin("s-2") vm.togglePin("s-2")
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
assertEquals(listOf("s-2", "s-1"), vm.uiState.value.sessions.map { it.id }) assertEquals(
assertEquals(true, vm.uiState.value.sessions.first().pinned) listOf("s-2", "s-1"),
vm.uiState.value.sessions
.map { it.id },
)
assertEquals(
true,
vm.uiState.value.sessions
.first()
.pinned,
)
assertEquals("Session pinned", vm.uiState.value.toastMessage) assertEquals("Session pinned", vm.uiState.value.toastMessage)
} }
@@ -249,9 +266,19 @@ class SessionsViewModelTest {
vm.togglePin("s-2") vm.togglePin("s-2")
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
assertEquals(listOf("s-1", "s-2"), vm.uiState.value.sessions.map { it.id }) assertEquals(
assertFalse(vm.uiState.value.sessions[1].pinned ?: false) listOf("s-1", "s-2"),
vm.uiState.value.sessions
.map { it.id },
)
assertFalse(
vm.uiState.value.sessions[1]
.pinned ?: false,
)
assertNotNull(vm.uiState.value.toastMessage) assertNotNull(vm.uiState.value.toastMessage)
assertTrue(vm.uiState.value.toastMessage!!.contains("Pin failed")) assertTrue(
vm.uiState.value.toastMessage!!
.contains("Pin failed"),
)
} }
} }
@@ -126,7 +126,11 @@ class SkillsViewModelTest {
vm.loadHubSources() vm.loadHubSources()
settle() settle()
assertEquals(true, vm.uiState.value.hubSourcesError?.contains("Failed to load hub sources")) assertEquals(
true,
vm.uiState.value.hubSourcesError
?.contains("Failed to load hub sources"),
)
assertEquals(false, vm.uiState.value.isHubSourcesLoading) assertEquals(false, vm.uiState.value.isHubSourcesLoading)
} }
@@ -165,7 +165,15 @@ class ToolsetDetailViewModelTest {
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
val state = vm.uiState.value val state = vm.uiState.value
assertEquals(true, state.config?.providers?.get(1)?.envVars?.first { it.key == "KEY_B" }?.isSet) assertEquals(
true,
state.config
?.providers
?.get(1)
?.envVars
?.first { it.key == "KEY_B" }
?.isSet,
)
assertEquals("Saved KEY_B", state.toastMessage) assertEquals("Saved KEY_B", state.toastMessage)
assertNull(state.savingEnvKey) assertNull(state.savingEnvKey)
} }
@@ -196,7 +204,15 @@ class ToolsetDetailViewModelTest {
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
val state = vm.uiState.value val state = vm.uiState.value
assertEquals(false, state.config?.providers?.get(1)?.envVars?.first { it.key == "KEY_B" }?.isSet) assertEquals(
false,
state.config
?.providers
?.get(1)
?.envVars
?.first { it.key == "KEY_B" }
?.isSet,
)
assertFalse(state.revealedValues.containsKey("KEY_B")) assertFalse(state.revealedValues.containsKey("KEY_B"))
assertEquals("Cleared KEY_B", state.toastMessage) assertEquals("Cleared KEY_B", state.toastMessage)
assertNull(state.deletingEnvKey) assertNull(state.deletingEnvKey)
@@ -213,7 +229,10 @@ class ToolsetDetailViewModelTest {
assertEquals("sekret", vm.uiState.value.revealedValues["KEY_B"]) assertEquals("sekret", vm.uiState.value.revealedValues["KEY_B"])
vm.hideEnvVar("KEY_B") vm.hideEnvVar("KEY_B")
assertFalse(vm.uiState.value.revealedValues.containsKey("KEY_B")) assertFalse(
vm.uiState.value.revealedValues
.containsKey("KEY_B"),
)
} }
@Test @Test
@@ -316,7 +335,10 @@ class ToolsetDetailViewModelTest {
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
assertNull(vm.uiState.value.postSetup) assertNull(vm.uiState.value.postSetup)
assertTrue(vm.uiState.value.toastMessage?.contains("spawn rejected") == true) assertTrue(
vm.uiState.value.toastMessage
?.contains("spawn rejected") == true,
)
coVerify(exactly = 0) { mockApi.getActionStatus(any()) } coVerify(exactly = 0) { mockApi.getActionStatus(any()) }
} }
} }