Merge pull request #1043 from Hy4ri/dev

dev to main
This commit is contained in:
M57 authored and GitHub committed 2026-09-02 19:33:19 +03:00
commit bb6b38c41e
106 files changed
+6272 -667

No files matched your search

+46 -6
View File
@@ -21,11 +21,43 @@ concurrency:
cancel-in-progress: true
jobs:
# ─────────────────────────────────────────────────────────────────────
# Job 0: Detect changes — skips heavy Android build/tests for doc-only PRs
# ─────────────────────────────────────────────────────────────────────
changes:
name: Detect Changes
runs-on: ubuntu-latest
timeout-minutes: 3
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Paths Filter
uses: dorny/paths-filter@v4
id: filter
with:
filters: |
code:
- 'app/**'
- 'gradle/**'
- 'build.gradle.kts'
- 'settings.gradle.kts'
- 'gradle.properties'
- 'gradlew'
- 'gradlew.bat'
- '.github/workflows/android.yml'
- '.editorconfig'
- 'flake.nix'
- 'flake.lock'
# ─────────────────────────────────────────────────────────────────────
# Job 1: ktlint (code style) — fastest feedback, runs in parallel.
# ─────────────────────────────────────────────────────────────────────
ktlint:
name: ktlint — Code Style
needs: [changes]
if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
@@ -52,13 +84,13 @@ jobs:
uses: actions/cache@v6
with:
path: ~/.local/bin/ktlint
key: ktlint-1.2.1
key: ktlint-1.8.0
- name: Install ktlint
if: steps.cache-ktlint.outputs.cache-hit != 'true'
run: |
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
mv ktlint ~/.local/bin/
@@ -88,6 +120,8 @@ jobs:
# ─────────────────────────────────────────────────────────────────────
android-lint:
name: Android Lint — Static Analysis
needs: [changes]
if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
@@ -125,6 +159,8 @@ jobs:
# ─────────────────────────────────────────────────────────────────────
unit-tests:
name: Unit & Integration Tests
needs: [changes]
if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
@@ -162,7 +198,8 @@ jobs:
# ─────────────────────────────────────────────────────────────────────
build:
name: Build Debug APK
needs: [ktlint, android-lint, unit-tests]
needs: [changes, ktlint, android-lint, unit-tests]
if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
@@ -204,7 +241,8 @@ jobs:
# ─────────────────────────────────────────────────────────────────────
release-compile:
name: Release Compile Check
needs: [ktlint, android-lint, unit-tests]
needs: [changes, ktlint, android-lint, unit-tests]
if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
@@ -236,6 +274,8 @@ jobs:
# ─────────────────────────────────────────────────────────────────────
instrumented-tests:
name: Instrumented Tests
needs: [changes]
if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
@@ -299,7 +339,7 @@ jobs:
# ─────────────────────────────────────────────────────────────────────
ci-summary:
name: CI Summary
needs: [ktlint, android-lint, unit-tests, build, release-compile]
needs: [changes, ktlint, android-lint, unit-tests, build, release-compile]
runs-on: ubuntu-latest
timeout-minutes: 2
if: always()
@@ -323,7 +363,7 @@ jobs:
name="${entry%%:*}"
result="${entry#*:}"
echo "::notice::$name = $result"
if [[ "$result" != "success" ]]; then
if [[ "$result" != "success" && "$result" != "skipped" ]]; then
FAILED=1
FAILED_JOBS+="$name "
fi
+26
View File
@@ -3,8 +3,34 @@ name: "CodeQL Analysis"
on:
push:
branches: [ "main", "dev" ]
paths-ignore:
- '**.md'
- 'docs/**'
- '.github/PULL_REQUEST_TEMPLATE.md'
- '.github/workflows/pr-labeler.yml'
- '.github/workflows/stale.yml'
- '.github/workflows/merge-conflict-detector.yml'
- 'LICENSE'
- 'fastlane/**'
- '.gitignore'
- '.editorconfig'
- '.envrc'
- '*.html'
pull_request:
branches: [ "main", "dev" ]
paths-ignore:
- '**.md'
- 'docs/**'
- '.github/PULL_REQUEST_TEMPLATE.md'
- '.github/workflows/pr-labeler.yml'
- '.github/workflows/stale.yml'
- '.github/workflows/merge-conflict-detector.yml'
- 'LICENSE'
- 'fastlane/**'
- '.gitignore'
- '.editorconfig'
- '.envrc'
- '*.html'
schedule:
- cron: '30 3 * * 1' # Runs at 03:30 UTC every Monday
+14
View File
@@ -0,0 +1,14 @@
[
{
"name": "Hermes Mobile",
"slug": "hermes-mobile",
"project": "hermes-mobile",
"vcs": "git",
"branch": "dev",
"filemask": "app/src/main/res/values-*/strings.xml",
"template": "app/src/main/res/values/strings.xml",
"file_format": "android",
"new_base": "app/src/main/res/values/strings.xml",
"language_code_style": "android"
}
]
+3 -3
View File
@@ -36,7 +36,7 @@ If you have a local Android SDK (`ANDROID_HOME` set), these work:
```bash
# 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
./ktlint <file> # check one file
./ktlint --format <file> # auto-fix
@@ -48,7 +48,7 @@ chmod +x ktlint
| 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 |
| `unit-tests` | JUnit |
| `build` | assembleDebug (gated by the 3 above) |
@@ -68,7 +68,7 @@ Requires `permissions: contents: write` on the `build-release` job.
## 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
(uppercase before lowercase: `LaunchedEffect` before `collectAsState`).
- `const val` must use SCREAMING_SNAKE_CASE.
+11 -1
View File
@@ -67,13 +67,23 @@ If you use AI coding tools (including agents) to contribute:
---
## Translations
Translations for Hermes Mobile are managed through [Hosted Weblate](https://hosted.weblate.org/projects/hermes-mobile/hermes-mobile/).
- Source strings are located in `app/src/main/res/values/strings.xml`.
- Localized strings are stored in `app/src/main/res/values-<locale>/strings.xml`.
- You can contribute translations directly on Weblate without modifying code or opening manual PRs.
---
## Code Style
We enforce Kotlin coding conventions and Jetpack Compose best practices.
### 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
./gradlew ktlintCheck # check (CI-equivalent)
./gradlew ktlintFormat # auto-fix, then re-check with ktlintCheck
+5 -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
- **Image Loading:** Coil 2.7.0
- **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)
---
@@ -159,6 +159,10 @@ app/src/main/java/com/m57/hermescontrol/
Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for our branch workflow, code style guidelines, and PR checklist.
### Translations
Help translate Hermes Mobile into your language on [Hosted Weblate](https://hosted.weblate.org/projects/hermes-mobile/hermes-mobile/)!
For developer-specific details, code conventions, and project architecture notes, refer to [AGENTS.md](AGENTS.md).
---
+6
View File
@@ -13,6 +13,12 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<queries>
<intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
</intent>
</queries>
<application
android:name=".HermesControlApp"
android:allowBackup="false"
@@ -21,7 +21,9 @@ import com.m57.hermescontrol.MainActivity
* the launch works on every supported API level (the framework's
* [AssistState] default forwards to it below API 29).
*/
class AssistantSession(context: Context) : VoiceInteractionSession(context) {
class AssistantSession(
context: Context,
) : VoiceInteractionSession(context) {
private var launchHandled = false
override fun onShow(
@@ -25,6 +25,8 @@ data class ServerStoreState(
// App display language. "system" = follow device locale; otherwise a BCP-47
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
val appLanguage: String = "system",
// Local on-device hidden profile names (filtered out from list views unless revealed).
val hiddenProfiles: List<String> = emptyList(),
// App version the silent update check (issue #867) last completed for.
// Null / mismatched with BuildConfig.VERSION_NAME → the About tab runs
// its one-time check again (re-check on app version bump).
@@ -357,6 +357,28 @@ object AuthManager {
serverStore.update { it.copy(pinnedModels = pinned) }
}
// ── Hidden Profiles (Local On-Device Filtering) ──────────────────────
fun getHiddenProfiles(): List<String> = serverStore.getLatestState().hiddenProfiles
fun isProfileHidden(name: String): Boolean = serverStore.getLatestState().hiddenProfiles.contains(name)
fun hideProfile(name: String) {
serverStore.update { s ->
s.copy(hiddenProfiles = (s.hiddenProfiles + name).distinct())
}
}
fun unhideProfile(name: String) {
serverStore.update { s ->
s.copy(hiddenProfiles = s.hiddenProfiles.filter { it != name })
}
}
fun setHiddenProfiles(hidden: List<String>) {
serverStore.update { it.copy(hiddenProfiles = hidden.distinct()) }
}
fun getProfileToken(profileId: String): String? = requirePrefs().getString("token_$profileId", null)
fun setProfileToken(
@@ -1,4 +1,5 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@@ -18,6 +19,22 @@ data class McpServer(
val status: String? = null,
val error: String? = null,
val auth: String? = null,
val tools: List<String>? = null,
)
@Serializable
data class McpServerTestResponse(
val ok: Boolean = false,
val error: String? = null,
val tools: List<McpServerToolInfo> = emptyList(),
)
@Serializable
data class McpServerToolInfo(
val name: String,
val description: String? = null,
@SerialName("schema_chars")
val schemaChars: Int? = null,
)
@Serializable
@@ -13,3 +13,14 @@ data class BuiltinFileSizes(
val memory: Long? = null,
val user: Long? = null,
)
@Serializable
data class MemoryResetRequest(
val target: String = "all",
)
@Serializable
data class MemoryResetResponse(
val ok: Boolean = false,
val deleted: List<String> = emptyList(),
)
@@ -239,13 +239,14 @@ data class GroupChatRoomMeta(
) {
val memberNames: List<String>
get() =
members?.mapNotNull { el ->
when (el) {
is JsonPrimitive -> el.content
is JsonObject -> el["name"]?.jsonPrimitive?.content ?: el["handle"]?.jsonPrimitive?.content
else -> null
}
}.orEmpty()
members
?.mapNotNull { el ->
when (el) {
is JsonPrimitive -> el.content
is JsonObject -> el["name"]?.jsonPrimitive?.content ?: el["handle"]?.jsonPrimitive?.content
else -> null
}
}.orEmpty()
}
@Serializable
@@ -83,6 +83,10 @@ data class SessionInfo(
// Uses LenientNullableBooleanSerializer to tolerate both JSON booleans and raw SQLite ints (issue #966).
@Serializable(with = LenientNullableBooleanSerializer::class)
val pinned: Boolean? = null,
// Session-level hidden flag (issue #1019): background agent sessions or internal routines.
// Uses LenientNullableBooleanSerializer to tolerate booleans, raw SQLite ints (0/1), and strings.
@Serializable(with = LenientNullableBooleanSerializer::class)
val hidden: Boolean? = null,
)
@Serializable
@@ -95,10 +99,11 @@ data class SessionStatsResponse(
@Serializable
data class SessionRenameRequest(
// Both fields are optional: the backend PATCH accepts any subset
// (SessionRename model: title/archived/pinned, omitted = unchanged).
// All fields are optional: the backend PATCH accepts any subset
// (SessionRename model: title/archived/pinned/hidden, omitted = unchanged).
val title: String? = null,
val pinned: Boolean? = null,
val hidden: Boolean? = null,
)
@Serializable
@@ -76,7 +76,9 @@ object GatewayFileClient {
token: String,
path: String,
): String? {
val kind = com.m57.hermescontrol.ui.chat.mediaKindForPath(path)
val kind =
com.m57.hermescontrol.ui.chat
.mediaKindForPath(path)
val endpoint =
if (kind == com.m57.hermescontrol.ui.chat.MediaKind.AUDIO ||
kind == com.m57.hermescontrol.ui.chat.MediaKind.VIDEO
@@ -51,6 +51,7 @@ import com.m57.hermescontrol.data.model.McpCatalogInstallRequest
import com.m57.hermescontrol.data.model.McpCatalogResponse
import com.m57.hermescontrol.data.model.McpOAuthFlowResponse
import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.data.model.McpServerTestResponse
import com.m57.hermescontrol.data.model.McpServerToggleRequest
import com.m57.hermescontrol.data.model.McpServersResponse
import com.m57.hermescontrol.data.model.MemoryProviderConfigResponse
@@ -58,6 +59,8 @@ import com.m57.hermescontrol.data.model.MemoryProviderConfigUpdateRequest
import com.m57.hermescontrol.data.model.MemoryProviderConfigUpdateResponse
import com.m57.hermescontrol.data.model.MemoryProviderSetupRequest
import com.m57.hermescontrol.data.model.MemoryProviderSetupResponse
import com.m57.hermescontrol.data.model.MemoryResetRequest
import com.m57.hermescontrol.data.model.MemoryResetResponse
import com.m57.hermescontrol.data.model.MemoryResponse
import com.m57.hermescontrol.data.model.MessagingPlatformResponse
import com.m57.hermescontrol.data.model.MessagingPlatformTestResult
@@ -214,6 +217,14 @@ interface HermesApiService {
@Body body: SessionRenameRequest,
): Response<Unit>
// Hide/unhide rides the same PATCH /api/sessions/{id} — body carries only
// {hidden} (backend SessionRename model, any subset accepted; issue #1019).
@PATCH("api/sessions/{id}")
suspend fun setSessionHidden(
@Path("id", encoded = true) sessionId: String,
@Body body: SessionRenameRequest,
): Response<Unit>
@POST("api/sessions/bulk-delete")
suspend fun bulkDeleteSessions(
@Body body: BulkDeleteRequest,
@@ -509,7 +520,7 @@ interface HermesApiService {
@POST("api/mcp/servers/{name}/test")
suspend fun testMcpServer(
@Path("name") name: String,
): Response<Unit>
): Response<McpServerTestResponse>
@DELETE("api/mcp/servers/{name}")
suspend fun deleteMcpServer(
@@ -851,8 +862,8 @@ interface HermesApiService {
@POST("api/memory/reset")
suspend fun resetMemory(
@Body body: Map<String, String>,
): Response<Map<String, Any>>
@Body body: MemoryResetRequest,
): Response<MemoryResetResponse>
// ── Admin: Credential pool ────────────────────────────────────────
@GET("api/credentials/pool")
@@ -23,11 +23,21 @@ fun isLocalNetworkHost(host: String): Boolean {
val a = raw[0].toInt() and 0xFF
val b = raw[1].toInt() and 0xFF
return when {
a == 10 -> true // 10.0.0.0/8
a == 172 && b in 16..31 -> true // 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
a == 10 -> true
// 10.0.0.0/8
a == 172 && b in 16..31 -> true
// 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
}
}
@@ -34,6 +34,7 @@ object ProfileScopeInterceptor : Interceptor {
"/api/env",
"/api/gateway",
"/api/mcp",
"/api/memory",
"/api/messaging/platforms",
"/api/messaging/telegram/onboarding",
"/api/messaging/whatsapp/onboarding",
@@ -13,7 +13,9 @@ sealed interface AppUpdateState {
data object Checking : AppUpdateState
/** 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. */
data class UpdateAvailable(
@@ -24,15 +26,21 @@ sealed interface AppUpdateState {
) : AppUpdateState
/** 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. */
data class Installing(val latestTag: String) : AppUpdateState
data class Installing(
val latestTag: String,
) : AppUpdateState
/** Install-from-unknown-sources not granted for this app yet. */
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. */
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.ui.chat.extractTodosFromMap
/**
* Converts raw [JsonRpcResponse] objects into typed [WsEvent] instances.
@@ -31,6 +32,17 @@ object EventParser {
// ── Notification / event (no id, has method) ─────────────────────
@Suppress("UNCHECKED_CAST")
val params = response.params?.toAny() as? Map<String, Any?> ?: return WsEvent.Unknown(rawJson)
return parseParams(params, rawJson)
}
/**
* Parses an event object (bare params map containing `type`, `session_id`, `seq`, `payload`).
* Used both for live notifications and for event lists returned by `session.events.since`.
*/
fun parseParams(
params: Map<String, Any?>,
rawJson: String = "",
): WsEvent {
val eventType = params["type"] as? String ?: return WsEvent.Unknown(rawJson)
@Suppress("UNCHECKED_CAST")
@@ -163,6 +175,13 @@ object EventParser {
WsEvent.ReviewSummary(text, sessionId)
}
"btw.complete" -> {
val taskId = payload?.get("task_id") as? String ?: ""
val question = payload?.get("question") as? String ?: ""
val text = (payload?.get("text") as? String)?.trim() ?: ""
WsEvent.BtwComplete(taskId, question, text, sessionId)
}
"session.updated" -> {
WsEvent.SessionUpdated(payload)
}
@@ -171,6 +190,15 @@ object EventParser {
WsEvent.SessionUsage(payload, sessionId)
}
"todo.updated" -> {
val revision =
(payload?.get("revision") as? Number)?.toInt()
?: (params["revision"] as? Number)?.toInt()
val rawTodos = payload ?: params
val todos = extractTodosFromMap(rawTodos) ?: emptyList()
WsEvent.TodoUpdated(todos, revision, sessionId)
}
"reaction" -> {
val kind = payload?.get("kind") as? String ?: ""
WsEvent.ReactionEvent(kind)
@@ -27,12 +27,14 @@ import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonElement
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.utf8Size
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.atomic.AtomicBoolean
@@ -128,6 +130,19 @@ object HermesWsClient {
@Volatile
private var outboundDrainJob: Job? = null
// ── Sequence tracking & replay on reconnect (issue #1016) ─────────────
private val lastSeenSeq = ConcurrentHashMap<String, Int>()
@Volatile
private var replayEpoch: String? = null
@Volatile
private var replayInFlight: Boolean = false
private val replayHold = ConcurrentHashMap<String, MutableList<Pair<Int?, WsEvent>>>()
@Volatile
private var replayJob: Job? = null
// ── Health and Ping/Pong tracking ────────────────────────────────────
@Volatile
@@ -137,6 +152,12 @@ object HermesWsClient {
val isHealthy: Boolean
get() = isConnected && (System.currentTimeMillis() - lastPongTimestamp < 60_000L)
// ── Observable latency tracking (issue #1017) ─────────────────────────
private val _lastLatencyMs = MutableStateFlow<Long?>(null)
/** Last measured round-trip latency in milliseconds via [ping], or null when disconnected / unmeasured. */
val lastLatencyMs: StateFlow<Long?> = _lastLatencyMs.asStateFlow()
private var healthJob: Job? = null
private fun startHealthTracking() {
@@ -146,11 +167,37 @@ object HermesWsClient {
wsScope.launch {
while (connected.get()) {
delay(30_000L)
if (connected.get()) {
try {
ping(timeoutMs = 10_000L)
} catch (e: Exception) {
Log.w(TAG, "Health check ping failed: ${e.message}")
}
}
if (!runHealthCheckPass()) break
}
}
}
/**
* Ultra-lightweight JSON-RPC liveness check and RTT measurement (issue #1017).
* Bypasses agent queues on the backend; measures round-trip time in milliseconds.
* Updates [lastLatencyMs] and [lastPongTimestamp].
*/
suspend fun ping(timeoutMs: Long = 5_000L): Long {
val start = System.currentTimeMillis()
request(WsMethods.PING, emptyMap(), timeoutMs = timeoutMs).await()
val latency = (System.currentTimeMillis() - start).coerceAtLeast(0L)
lastPongTimestamp = System.currentTimeMillis()
_lastLatencyMs.value = latency
return latency
}
@VisibleForTesting
internal fun setLastLatencyForTest(latency: Long?) {
_lastLatencyMs.value = latency
}
/**
* One liveness pass. Returns false when the tracking loop should stop:
* either the connection is already gone or the watchdog fired a cancel.
@@ -184,6 +231,7 @@ object HermesWsClient {
private fun stopHealthTracking() {
healthJob?.cancel()
healthJob = null
_lastLatencyMs.value = null
}
// ── Public observable stream ─────────────────────────────────────────
@@ -256,12 +304,15 @@ object HermesWsClient {
is WsEvent.ThinkingDelta,
is WsEvent.ReasoningDelta,
is WsEvent.ToolStart,
-> pendingReply = true
-> {
pendingReply = true
}
is WsEvent.MessageComplete -> {
pendingReply = false
disconnectIfIdleInBackground()
}
else -> {}
}
}
@@ -274,6 +325,20 @@ object HermesWsClient {
.filterIsInstance<WsEvent.ChangeEvent>()
.collect { ChangeEventHub.emit(it) }
}
// Track gateway replay_epoch across gateway.ready events (issue #1016)
wsScope.launch {
events.collect { event ->
if (event is WsEvent.GatewayReady) {
val epoch = event.data?.get("replay_epoch") as? String
if (!epoch.isNullOrEmpty()) {
if (replayEpoch != null && replayEpoch != epoch) {
lastSeenSeq.clear()
}
replayEpoch = epoch
}
}
}
}
}
// ── Connection helpers ────────────────────────────────────────────────
@@ -573,6 +638,12 @@ object HermesWsClient {
queuedMessagesById.clear()
pendingPromptSubmits.clear()
pendingReply = false
lastSeenSeq.clear()
replayHold.clear()
replayEpoch = null
replayJob?.cancel()
replayJob = null
replayInFlight = false
}
connected.set(false)
_connectionStatus.value = ConnectionStatus.DISCONNECTED
@@ -816,6 +887,135 @@ object HermesWsClient {
onSent = onSent,
)
// ── Sequence tracking & replay on reconnect (issue #1016) ─────────────
private fun triggerReplay() {
if (lastSeenSeq.isEmpty() || replayInFlight) return
replayJob?.cancel()
replayJob =
wsScope.launch {
fetchReplay()
}
}
private suspend fun fetchReplay() {
if (replayInFlight || lastSeenSeq.isEmpty()) return
replayInFlight = true
val sessionsToReplay = lastSeenSeq.keys().toList()
for (sid in sessionsToReplay) {
replayHold[sid] = Collections.synchronizedList(mutableListOf())
}
try {
for (sid in sessionsToReplay) {
val lastSeen = lastSeenSeq[sid] ?: continue
try {
val deferred =
request(
method = WsMethods.SESSION_EVENTS_SINCE,
params = mapOf("session_id" to sid, "last_seen" to lastSeen, "since_seq" to lastSeen),
timeoutMs = 10_000L,
)
val result = deferred.await()
@Suppress("UNCHECKED_CAST")
val resultMap =
when (result) {
is JsonElement -> result.toAny() as? Map<String, Any?>
is Map<*, *> -> result as? Map<String, Any?>
else -> null
}
if (resultMap != null) {
val epoch = resultMap["epoch"] as? String
if (!epoch.isNullOrEmpty()) {
if (replayEpoch != null && replayEpoch != epoch) {
replayEpoch = epoch
lastSeenSeq.clear()
continue
}
replayEpoch = epoch
}
val eventsList = (resultMap["events"] as? List<*>)?.filterIsInstance<Map<String, Any?>>()
if (eventsList != null) {
for (eventMap in eventsList) {
val eventSeq = (eventMap["seq"] as? Number)?.toInt()
val eventSid = (eventMap["session_id"] as? String) ?: sid
if (eventSeq != null && eventSid.isNotEmpty()) {
val prev = lastSeenSeq[eventSid] ?: 0
if (eventSeq <= prev) continue
lastSeenSeq[eventSid] = eventSeq
}
val parsedEvent = EventParser.parseParams(eventMap)
val finalEvent =
if (parsedEvent is WsEvent.MessageComplete) {
parsedEvent.copy(
storedSessionId =
ActiveSessionHolder.resolveStoredSessionId(
parsedEvent.sessionId,
),
)
} else {
parsedEvent
}
parsedEvents.tryEmit(finalEvent)
}
}
}
} catch (e: Exception) {
Log.w(TAG, "Replay failed for session $sid: ${e.message}")
}
}
} finally {
for (sid in sessionsToReplay) {
val held = replayHold.remove(sid)
if (held != null) {
synchronized(held) {
for ((heldSeq, heldEvent) in held) {
if (heldSeq != null) {
val prev = lastSeenSeq[sid] ?: 0
if (heldSeq <= prev) continue
lastSeenSeq[sid] = heldSeq
}
parsedEvents.tryEmit(heldEvent)
}
}
}
}
replayInFlight = false
}
}
@VisibleForTesting
internal fun getSeqWatermarks(): Map<String, Int> = HashMap(lastSeenSeq)
@VisibleForTesting
internal fun setSeqWatermark(
sessionId: String,
seq: Int,
) {
lastSeenSeq[sessionId] = seq
}
@VisibleForTesting
internal fun clearSeqWatermarks() {
lastSeenSeq.clear()
replayHold.clear()
replayEpoch = null
replayInFlight = false
}
@VisibleForTesting
internal fun isReplayInFlight(): Boolean = replayInFlight
@VisibleForTesting
internal fun setReplayEpochForTest(epoch: String?) {
replayEpoch = epoch
}
@VisibleForTesting
internal suspend fun fetchReplayForTest() {
fetchReplay()
}
// ── Internal ─────────────────────────────────────────────────────────
private fun openSocket() {
@@ -934,6 +1134,7 @@ object HermesWsClient {
messageQueue.poll()
markQueuedMessageSent(msg)
}
triggerReplay()
}
disconnectIfIdleInBackground()
}
@@ -960,6 +1161,39 @@ object HermesWsClient {
removeQueuedMessage(rpcId)
resolvePending(rpcId, rpc.result, null)
}
if (rpc.id == null) {
@Suppress("UNCHECKED_CAST")
val params = rpc.params?.toAny() as? Map<String, Any?>
val sid =
(params?.get("session_id") as? String)
?: ((params?.get("payload") as? Map<*, *>)?.get("session_id") as? String)
val seq = (params?.get("seq") as? Number)?.toInt()
if (!sid.isNullOrBlank() && seq != null) {
val prev = lastSeenSeq[sid] ?: 0
if (seq <= prev) {
return
}
val parsed = EventParser.parse(rpc, text)
val finalEvent =
if (parsed is WsEvent.MessageComplete) {
parsed.copy(
storedSessionId = ActiveSessionHolder.resolveStoredSessionId(parsed.sessionId),
)
} else {
parsed
}
if (replayInFlight && replayHold.containsKey(sid)) {
replayHold[sid]?.add(seq to finalEvent)
return
}
lastSeenSeq[sid] = seq
parsedEvents.tryEmit(finalEvent)
return
}
}
val parsed = EventParser.parse(rpc, text)
if (parsed is WsEvent.MessageComplete) {
parsed.copy(
@@ -992,7 +1226,9 @@ object HermesWsClient {
disconnectIfIdleInBackground()
}
else -> Unit
else -> {
Unit
}
}
// tryEmit on a DROP_OLDEST flow only returns false when the
// buffer is full AND no subscriber is draining; with extraBuffer=512
@@ -44,12 +44,30 @@ data class JsonRpcError(
fun Any?.toJsonElement(): JsonElement =
when (this) {
null -> JsonNull
is JsonElement -> this
is Boolean -> JsonPrimitive(this)
is Int -> JsonPrimitive(this)
is Long -> JsonPrimitive(this)
is Float -> JsonPrimitive(this)
null -> {
JsonNull
}
is JsonElement -> {
this
}
is Boolean -> {
JsonPrimitive(this)
}
is Int -> {
JsonPrimitive(this)
}
is Long -> {
JsonPrimitive(this)
}
is Float -> {
JsonPrimitive(this)
}
is Double -> {
// 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()) {
@@ -60,11 +78,26 @@ fun Any?.toJsonElement(): JsonElement =
JsonPrimitive(this)
}
}
is Number -> JsonPrimitive(this)
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())
is Number -> {
JsonPrimitive(this)
}
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? =
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.ui.chat.TodoItem
/** Parsed WebSocket events emitted by [HermesWsClient]. */
sealed class WsEvent {
// ── Gateway lifecycle ────────────────────────────────────────────────
@@ -150,6 +152,18 @@ sealed class WsEvent {
val sessionId: String? = null,
) : WsEvent()
/**
* Context-aware side-question completion event (issue #1015).
* Emitted when `prompt.btw` completes answering a side question.
* Payload: `{ task_id: "...", question: "...", text: "..." }`
*/
data class BtwComplete(
val taskId: String = "",
val question: String = "",
val text: String = "",
val sessionId: String? = null,
) : WsEvent()
// ── Status ───────────────────────────────────────────────────────────
data class StatusUpdate(
@@ -171,6 +185,17 @@ sealed class WsEvent {
val sessionId: String? = null,
) : WsEvent()
/**
* Revisioned todo snapshot event emitted by backend todo tool updates (issue #1018).
* Event: `todo.updated`
* Payload: `{ "todos": [...], "revision": Int }`
*/
data class TodoUpdated(
val todos: List<TodoItem> = emptyList(),
val revision: Int? = null,
val sessionId: String? = null,
) : WsEvent()
// ── RPC responses ────────────────────────────────────────────────────
data class RpcResult(
@@ -15,6 +15,12 @@ object WsMethods {
const val SESSION_TITLE = "session.title"
const val SESSION_BRANCH = "session.branch"
/** Replay recorded events newer than client's last-seen seq for a session. */
const val SESSION_EVENTS_SINCE = "session.events.since"
/** Replay buffer telemetry for ops/debug. */
const val SESSION_EVENTS_STATS = "session.events.stats"
/** Live context-window occupancy for the chat meter (desktop-mirror). */
const val SESSION_CONTEXT_BREAKDOWN = "session.context_breakdown"
@@ -25,11 +31,17 @@ object WsMethods {
// ── Interaction ───────────────────────────────────────────────────────
const val PROMPT_SUBMIT = "prompt.submit"
const val PROMPT_BTW = "prompt.btw"
const val CLARIFY_RESPOND = "clarify.respond"
const val APPROVAL_RESPOND = "approval.respond"
const val SUDO_RESPOND = "sudo.respond"
const val SECRET_RESPOND = "secret.respond"
// ── Heartbeat & Latency (issue #1017) ──────────────────────────────────
/** Ultra-lightweight liveness check and round-trip latency measurement. */
const val PING = "ping"
// ── Commands catalog ──────────────────────────────────────────────────
const val COMMANDS_CATALOG = "commands.catalog"
const val COMMAND_DISPATCH = "command.dispatch"
@@ -8,6 +8,7 @@ import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.RemoteInput
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods
@@ -124,11 +125,20 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
}
private suspend fun resumeSession(storedSessionId: String): String {
val profile = AuthManager.activeProfileId.value
val params =
mutableMapOf<String, Any>(
"session_id" to storedSessionId,
"omit_messages" to true,
)
if (!profile.isNullOrBlank()) {
params["profile"] = profile
}
val result =
HermesWsClient
.request(
WsMethods.SESSION_RESUME,
mapOf("session_id" to storedSessionId, "omit_messages" to true),
params,
timeoutMs = REPLY_TIMEOUT_MS,
).await() as? Map<*, *>
val runtimeSessionId =
@@ -117,7 +117,7 @@ active preset via `LocalHermesStatusColors`.
## 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).
- Import order is ASCII-lexicographic (uppercase before lowercase).
- Every change goes through a PR — never push directly to `main`.
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.bots
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.BotAvatarMeta
import com.m57.hermescontrol.data.model.BotRosterMeta
import com.m57.hermescontrol.data.model.CreateProfileRequest
@@ -47,12 +48,13 @@ data class BotsUiState(
val activeProfileName: String? = null,
val searchQuery: String = "",
val showHidden: Boolean = false,
val hiddenProfiles: Set<String> = emptySet(),
val selectedTab: BotsTab = BotsTab.BOTS,
val errorMessage: String? = null,
val toastMessage: String? = null,
) {
val hasHiddenBots: Boolean
get() = profiles.any { it.isHidden }
get() = profiles.any { it.isHidden || it.name in hiddenProfiles }
val allGroups: List<GroupInfo>
get() {
@@ -60,7 +62,12 @@ data class BotsUiState(
profiles.find { it.is_default == true || it.name == "default" }
?: profiles.firstOrNull()
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>()
@@ -99,61 +106,63 @@ data class BotsUiState(
}
}
return groupNameMap.values.map { gName ->
val lower = gName.lowercase()
val matchingRoom =
snapshotRooms[gName]
?: snapshotRooms["name:$gName"]
?: snapshotRooms["id:$gName"]
?: snapshotRooms.values.find { it.name.equals(gName, ignoreCase = true) }
return groupNameMap.values
.map { gName ->
val lower = gName.lowercase()
val matchingRoom =
snapshotRooms[gName]
?: snapshotRooms["name:$gName"]
?: 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 =
profiles.filter { profile ->
val botGroups = profile.botMeta()?.allGroups.orEmpty()
botGroups.any { it.equals(gName, ignoreCase = true) } ||
roomMemberNames.contains(profile.name.lowercase()) ||
roomMemberNames.contains(profile.effectiveTitle.lowercase())
}.toMutableList()
val matchedProfiles =
profiles
.filter { profile ->
val botGroups = profile.botMeta()?.allGroups.orEmpty()
botGroups.any { it.equals(gName, ignoreCase = true) } ||
roomMemberNames.contains(profile.name.lowercase()) ||
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 (matchingRoom != null && roomMemberNames.isNotEmpty()) {
for (mName in matchingRoom.memberNames) {
val exists =
matchedProfiles.any {
it.name.equals(mName, ignoreCase = true) ||
it.effectiveTitle.equals(mName, ignoreCase = true)
// If room listed members (e.g. remote bots or bots not in local profiles), ensure they are seated
if (matchingRoom != null && roomMemberNames.isNotEmpty()) {
for (mName in matchingRoom.memberNames) {
val exists =
matchedProfiles.any {
it.name.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")
if (groupMembers.isEmpty() && gName.contains(",")) {
val targetNames = gName.split(",").map { it.trim().lowercase() }
groupMembers = profiles.filter { targetNames.contains(it.name.lowercase()) }
}
// Fallback: If group name was composed from bot names (e.g. "default, scoutbot")
if (groupMembers.isEmpty() && gName.contains(",")) {
val targetNames = gName.split(",").map { it.trim().lowercase() }
groupMembers = profiles.filter { targetNames.contains(it.name.lowercase()) }
}
val lastAt =
matchingRoom?.log?.lastOrNull()?.at
?: matchingRoom?.updatedAt
?: 0L
val lastAt =
matchingRoom?.log?.lastOrNull()?.at
?: matchingRoom?.updatedAt
?: 0L
GroupInfo(
name = gName,
members = groupMembers,
lastActivity = lastAt,
GroupInfo(
name = gName,
members = groupMembers,
lastActivity = lastAt,
)
}.sortedWith(
compareByDescending<GroupInfo> { it.lastActivity }
.thenBy { it.name },
)
}.sortedWith(
compareByDescending<GroupInfo> { it.lastActivity }
.thenBy { it.name },
)
}
val displayGroups: List<GroupInfo>
@@ -185,20 +194,19 @@ data class BotsUiState(
val query = searchQuery.trim().lowercase()
return profiles
.filter { profile ->
if (!showHidden && profile.isHidden) return@filter false
val isHidden = profile.isHidden || profile.name in hiddenProfiles
if (!showHidden && isHidden) return@filter false
if (query.isBlank()) return@filter true
profile.name.lowercase().contains(query) ||
profile.effectiveTitle.lowercase().contains(query) ||
profile.effectiveDescription.lowercase().contains(query)
}
.sortedWith(
}.sortedWith(
compareByDescending<ProfileInfo> { it.name == activeProfileName }
.thenByDescending {
it.canonical_session?.last_active
?: it.last_session?.last_active
?: 0.0
}
.thenBy { it.name },
}.thenBy { it.name },
)
}
}
@@ -212,6 +220,7 @@ class BotsViewModel(
val uiState: StateFlow<BotsUiState> = _uiState.asStateFlow()
init {
_uiState.update { it.copy(hiddenProfiles = AuthManager.getHiddenProfiles().toSet()) }
if (autoLoad) {
loadBots()
}
@@ -262,6 +271,7 @@ class BotsViewModel(
isRefreshing = false,
profiles = profilesWithMeta,
activeProfileName = activeName ?: it.activeProfileName,
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
errorMessage = null,
)
}
@@ -273,6 +283,7 @@ class BotsViewModel(
isRefreshing = false,
profiles = profilesResult.data.profiles.orEmpty(),
activeProfileName = activeName ?: it.activeProfileName,
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
errorMessage = null,
)
}
@@ -458,13 +469,14 @@ class BotsViewModel(
rooms = updatedRooms,
deleted = updatedDeleted,
)
HermesWsClient.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()),
),
).await()
HermesWsClient
.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()),
),
).await()
}
} catch (_: Exception) {
}
@@ -522,13 +534,14 @@ class BotsViewModel(
rooms = updatedRooms,
deleted = deletedMap,
)
HermesWsClient.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()),
),
).await()
HermesWsClient
.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()),
),
).await()
}
}
} catch (_: Exception) {
@@ -100,8 +100,7 @@ fun CreateGroupChatDialog(
} else {
selectedBots + bot.name
}
}
.padding(vertical = 4.dp),
}.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
@@ -303,12 +303,13 @@ class GroupChatViewModel(
val roomMemberNames = matchingRoom?.memberNames.orEmpty().map { it.lowercase() }
val matchedProfiles =
allProfiles.filter {
val botGroups = it.botMeta()?.allGroups.orEmpty()
botGroups.any { g -> g.equals(groupName, ignoreCase = true) } ||
roomMemberNames.contains(it.name.lowercase()) ||
roomMemberNames.contains(it.effectiveTitle.lowercase())
}.toMutableList()
allProfiles
.filter {
val botGroups = it.botMeta()?.allGroups.orEmpty()
botGroups.any { g -> g.equals(groupName, ignoreCase = true) } ||
roomMemberNames.contains(it.name.lowercase()) ||
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 (matchingRoom != null && roomMemberNames.isNotEmpty()) {
@@ -350,7 +351,11 @@ class GroupChatViewModel(
initialMessages =
syncedLog.map { entry ->
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 =
allProfiles.find {
it.name.equals(senderName, ignoreCase = true) ||
@@ -465,13 +470,14 @@ class GroupChatViewModel(
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
HermesWsClient.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to uiMetaPayload,
),
).await()
HermesWsClient
.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to uiMetaPayload,
),
).await()
} catch (e: Exception) {
Log.w("GroupChatViewModel", "updateGroupLimits failed: ${e.message}")
}
@@ -559,13 +565,14 @@ class GroupChatViewModel(
val newSnapshot = existingSnapshot.copy(version = 3, updatedAt = now, rooms = updatedRooms)
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
HermesWsClient.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to uiMetaPayload,
),
).await()
HermesWsClient
.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to uiMetaPayload,
),
).await()
return true
} catch (e: Exception) {
@@ -629,10 +636,11 @@ class GroupChatViewModel(
storedId?.let { inFlightTurns[it] = turnDeferred }
try {
HermesWsClient.request(
WsMethods.PROMPT_SUBMIT,
mapOf("session_id" to runtimeId, "text" to prompt),
).await()
HermesWsClient
.request(
WsMethods.PROMPT_SUBMIT,
mapOf("session_id" to runtimeId, "text" to prompt),
).await()
} catch (submitErr: Exception) {
Log.w(
"GroupChatViewModel",
@@ -662,10 +670,11 @@ class GroupChatViewModel(
storedId?.let { inFlightTurns[it] = turnDeferred }
try {
HermesWsClient.request(
WsMethods.PROMPT_SUBMIT,
mapOf("session_id" to runtimeId, "text" to prompt),
).await()
HermesWsClient
.request(
WsMethods.PROMPT_SUBMIT,
mapOf("session_id" to runtimeId, "text" to prompt),
).await()
} catch (retryErr: Exception) {
Log.e("GroupChatViewModel", "Retry prompt.submit failed for ${bot.name}: ${retryErr.message}")
return null
@@ -880,7 +889,10 @@ class GroupChatViewModel(
val bot = remainingInitial.removeAt(0)
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
if (reply != null) {
@@ -931,7 +943,10 @@ class GroupChatViewModel(
val bot = continuationResponders.removeAt(0)
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
if (reply != null) {
@@ -1055,13 +1070,14 @@ class GroupChatViewModel(
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
HermesWsClient.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to uiMetaPayload,
),
).await()
HermesWsClient
.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to uiMetaPayload,
),
).await()
} catch (e: Exception) {
Log.w("GroupChatViewModel", "persistSyncSnapshot failed: ${e.message}")
}
@@ -38,14 +38,20 @@ fun toolCallMilestones(messages: List<ChatMessage>): Map<Int, Int> {
var toolCount = 0
messages.forEachIndexed { index, message ->
when (message.role) {
MessageRole.USER -> toolCount = 0
MessageRole.USER -> {
toolCount = 0
}
MessageRole.TOOL -> {
toolCount += 1
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
milestones[index] = toolCount
}
}
else -> Unit
else -> {
Unit
}
}
}
return milestones
@@ -180,15 +180,27 @@ class ChatMediaDelegate(
}
}
is GatewayFileResult.NotFound -> showOpenError("File not found on gateway: ${attachment.name}")
is GatewayFileResult.Forbidden -> showOpenError("Access denied: ${attachment.name}")
is GatewayFileResult.TooLarge -> showOpenError("File too large to save: ${attachment.name}")
is GatewayFileResult.Unauthorized ->
is GatewayFileResult.NotFound -> {
showOpenError("File not found on gateway: ${attachment.name}")
}
is GatewayFileResult.Forbidden -> {
showOpenError("Access denied: ${attachment.name}")
}
is GatewayFileResult.TooLarge -> {
showOpenError("File too large to save: ${attachment.name}")
}
is GatewayFileResult.Unauthorized -> {
showOpenError(
"Session expired — reconnect to save: ${attachment.name}",
)
is GatewayFileResult.Failure ->
}
is GatewayFileResult.Failure -> {
showOpenError("Could not save ${attachment.name}: ${result.throwable.message}")
}
}
} finally {
uiState.update {
@@ -85,11 +85,13 @@ data class TodoItem(
val id: String,
val content: String,
val status: String = "pending", // "pending" | "in_progress" | "completed" | "cancelled"
val parent: String? = null,
) {
val isCompleted: Boolean get() = status == "completed" || status == "done"
val isInProgress: Boolean get() = status == "in_progress" || status == "running"
val isCancelled: Boolean get() = status == "cancelled" || status == "failed"
val isPending: Boolean get() = status == "pending" || status == "queued"
val isSubtask: Boolean get() = !parent.isNullOrBlank()
}
/**
@@ -112,8 +114,10 @@ data class SubagentIndicator(
) {
val isComplete: Boolean get() = type == "subagent.complete" || status == "completed" || status == "done"
val isFailed: Boolean get() = status == "failed" || status == "interrupted"
val isCancelled: Boolean get() = status == "cancelled" || status == "stopped" || status == "canceled"
val isSteered: Boolean get() = status == "steered"
val isQueued: Boolean get() = status == "queued"
val isRunning: Boolean get() = !isComplete && !isFailed && !isQueued
val isRunning: Boolean get() = !isComplete && !isFailed && !isCancelled && !isQueued
}
enum class MessageRole {
@@ -2,12 +2,12 @@ package com.m57.hermescontrol.ui.chat
import android.Manifest
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.OpenableColumns
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -100,6 +100,7 @@ import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
@@ -230,7 +231,7 @@ fun ChatScreen(
.collectLatest { firstVisibleIndex ->
if (firstVisibleIndex > 2) {
isOlderPagingArmed = true
} else if (isOlderPagingArmed) {
} else if (isOlderPagingArmed || state.messages.size <= 3) {
val anchorId = state.messages.getOrNull(firstVisibleIndex)?.id
val offset = scrollController.captureAnchorOffset()
pagingAnchor.value = if (anchorId != null) anchorId to offset else null
@@ -342,21 +343,18 @@ fun ChatScreen(
) { granted ->
ExternalActivityLifecycleGuard.externalActivityReturned()
if (granted) {
if (SpeechRecognizer.isRecognitionAvailable(context)) {
val intent =
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
)
putExtra(
RecognizerIntent.EXTRA_PROMPT,
micListeningPrompt,
)
}
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
isListening = true
launchExternalActivity {
speechLauncher.launch(intent)
try {
speechLauncher.launch(intent)
} catch (_: ActivityNotFoundException) {
isListening = false
scrollScope.launch {
snackbarHostState.showSnackbar(sttNotAvailableMsg)
}
}
}
} else {
scrollScope.launch {
@@ -611,12 +609,13 @@ fun ChatScreen(
onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() },
onOpenSettings = {
val intent =
android.content.Intent(
android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
android.net.Uri.parse("package:${context.packageName}"),
).apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
android.content
.Intent(
android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
android.net.Uri.parse("package:${context.packageName}"),
).apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
context.startActivity(intent)
} catch (e: Exception) {
@@ -779,21 +778,18 @@ fun ChatScreen(
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
) {
if (SpeechRecognizer.isRecognitionAvailable(context)) {
val intent =
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
)
putExtra(
RecognizerIntent.EXTRA_PROMPT,
micListeningPrompt,
)
}
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
isListening = true
launchExternalActivity {
speechLauncher.launch(intent)
try {
speechLauncher.launch(intent)
} catch (_: ActivityNotFoundException) {
isListening = false
scrollScope.launch {
snackbarHostState.showSnackbar(sttNotAvailableMsg)
}
}
}
} else {
scrollScope.launch {
@@ -928,10 +924,19 @@ fun ChatScreen(
SubagentInspectionSheet(
indicators = state.subagentIndicators,
todos = state.todos,
onSteerSubagent = { indicator, msg -> viewModel.steerSubagent(indicator, msg) },
onStopSubagent = { indicator -> viewModel.stopSubagent(indicator) },
onDismiss = { showSubagentInspectionSheet = false },
)
}
state.btwState?.let { btw ->
SideQuestionSheet(
state = btw,
onDismiss = { viewModel.dismissBtw() },
)
}
viewingImage?.let { image ->
ImageViewerDialog(
image = image,
@@ -79,10 +79,14 @@ internal fun canonicalToolResultKey(content: String): String? {
fun canon(e: kotlinx.serialization.json.JsonElement): String =
when (e) {
is kotlinx.serialization.json.JsonObject ->
is kotlinx.serialization.json.JsonObject -> {
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) }
}
is kotlinx.serialization.json.JsonPrimitive -> {
// Canonicalize ALL numbers through double, collapsing int/float
// 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) {
is kotlinx.serialization.json.JsonObject ->
is kotlinx.serialization.json.JsonObject -> {
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("@file:") ||
line == "[screenshot]"
}
.joinToString("\n")
}.joinToString("\n")
.trim()
/**
@@ -346,6 +353,8 @@ data class ChatUiState(
val reactionKind: String? = null,
/** Monotonic trigger ID so consecutive same-kind reactions re-animate. */
val reactionTriggerId: Long = 0L,
/** Side-question state for /btw (issue #1015). */
val btwState: BtwUiState? = null,
/** Subagent delegation indicators (issue #538) — transient UI state. */
val subagentIndicators: List<SubagentIndicator> = emptyList(),
/** Agent todo / plan items (issue #736). */
@@ -372,6 +381,17 @@ data class ClarifyUi(
val clarifyId: String? = null,
)
/**
* State for the context-aware side-question bottom sheet (issue #1015, `/btw`).
*/
data class BtwUiState(
val taskId: String? = null,
val question: String = "",
val answer: String? = null,
val isLoading: Boolean = true,
val error: String? = null,
)
/**
* String sent to the agent when a clarify prompt is dismissed (the Dismiss
* button). This is a *reject* — "I'm not answering this question" — NOT an
@@ -627,6 +647,8 @@ class ChatViewModel(
viewModelScope.launch {
ProfileSwitchCoordinator.switched
.collect { _ ->
pendingGoneSessionNotice = false
sessionHasServerPresence = false
resetSessionState(sessionId = null, title = "Hermes", isLoading = true)
}
}
@@ -637,6 +659,8 @@ class ChatViewModel(
viewModelScope.launch {
ProfileSwitchCoordinator.connectionSwitched
.collect { _ ->
pendingGoneSessionNotice = false
sessionHasServerPresence = false
resetSessionState(sessionId = null, title = "Hermes", isLoading = true)
}
}
@@ -764,7 +788,9 @@ class ChatViewModel(
streamingController.flushPendingTokens()
}
else -> Unit
else -> {
Unit
}
}
// First, let the reducer compute the new state and any effects
@@ -1537,6 +1563,12 @@ class ChatViewModel(
// it matches the server echo and the transcript sync dedupes instead
// of rendering a duplicate below its answer.
val result = slashDispatcher.dispatch(command)
if (result is SlashResult.SideQuestion) {
handleSideQuestionCommand(result.question)
return
}
val displayContent =
if (result is SlashResult.QueuePrompt) result.displayContent else command
val userMsg = ChatMessage(role = MessageRole.USER, content = displayContent)
@@ -1618,6 +1650,10 @@ class ChatViewModel(
handleQueueCommand(command)
}
is SlashResult.SideQuestion -> {
handleSideQuestionCommand(result.question)
}
is SlashResult.RpcDispatch -> {
dispatchViaRpc(command)
}
@@ -1642,6 +1678,75 @@ class ChatViewModel(
submitPrompt(arg, queued = true)
}
// ── Side Questions via /btw (issue #1015) ─────────────────────────────
fun dismissBtw() {
_uiState.update { it.copy(btwState = null) }
}
fun submitSideQuestion(question: String) {
val trimmed = question.trim()
if (trimmed.isBlank()) {
addAssistantMessage(
"Usage: `/btw <question>` — Ask a side question about this session without mutating its history.",
)
return
}
val sessionId = runtimeSessionId ?: _uiState.value.currentSessionId
if (sessionId.isNullOrBlank()) {
addAssistantMessage("No active session for side questions. Start a chat first.")
return
}
_uiState.update {
it.copy(
btwState =
BtwUiState(
question = trimmed,
isLoading = true,
),
)
}
viewModelScope.launch(ioDispatcher) {
try {
val rpcResult =
wsClient
.request(
WsMethods.PROMPT_BTW,
mapOf("session_id" to sessionId, "text" to trimmed),
).await()
val taskId = (rpcResult as? Map<*, *>)?.get("task_id") as? String
if (!taskId.isNullOrBlank()) {
_uiState.update { state ->
state.btwState?.let { current ->
state.copy(btwState = current.copy(taskId = taskId))
} ?: state
}
}
} catch (e: Exception) {
_uiState.update { state ->
state.btwState?.let { current ->
state.copy(
btwState =
current.copy(
isLoading = false,
error = e.message ?: "Failed to dispatch side question",
),
)
} ?: state
}
}
}
}
private fun handleSideQuestionCommand(question: String) {
viewModelScope.launch {
slashUsageStore.recordUse("btw")
}
submitSideQuestion(question)
}
// ── Update from chat (issue #862) ────────────────────────────────────
// `/update` can't travel via the slash worker (the backend handler is
// interactive + session-exiting → guaranteed 45s timeout). Intercept it
@@ -1859,6 +1964,85 @@ class ChatViewModel(
}
}
/**
* Send course-correction guidance to a live subagent child session (issue #1030).
*/
fun steerSubagent(
indicator: SubagentIndicator,
message: String,
) {
val trimmed = message.trim()
if (trimmed.isBlank()) return
val sessionId = runtimeSessionId ?: return
val subagentId = indicator.subagentId
val steerCommand =
if (!subagentId.isNullOrBlank()) {
"/steer $subagentId $trimmed"
} else {
"/steer $trimmed"
}
viewModelScope.launch(ioDispatcher) {
wsClient.sendRedirect(
sessionId,
steerCommand,
onSent = { id -> trackRequest(id, WsMethods.SESSION_REDIRECT) },
)
}
_uiState.update { current ->
val updated =
current.subagentIndicators.map { ind ->
if (ind.subagentId == indicator.subagentId &&
(ind.goal == indicator.goal || indicator.subagentId != null)
) {
val newLogs =
(ind.logs + SubagentLogLine(text = "Course correction: \"$trimmed\"", isSummary = true))
.takeLast(30)
ind.copy(status = "steered", logs = newLogs)
} else {
ind
}
}
current.copy(subagentIndicators = updated)
}
}
/**
* Stop / interrupt a single live subagent early (issue #1030).
*/
fun stopSubagent(indicator: SubagentIndicator) {
val sessionId = runtimeSessionId ?: return
val subagentId = indicator.subagentId
val stopCommand =
if (!subagentId.isNullOrBlank()) {
"/stop $subagentId"
} else {
"/stop"
}
viewModelScope.launch(ioDispatcher) {
wsClient.sendRedirect(
sessionId,
stopCommand,
onSent = { id -> trackRequest(id, WsMethods.SESSION_REDIRECT) },
)
}
_uiState.update { current ->
val updated =
current.subagentIndicators.map { ind ->
if (ind.subagentId == indicator.subagentId &&
(ind.goal == indicator.goal || indicator.subagentId != null)
) {
val newLogs =
(ind.logs + SubagentLogLine(text = "Stopped subagent", isError = true))
.takeLast(30)
ind.copy(status = "cancelled", logs = newLogs)
} else {
ind
}
}
current.copy(subagentIndicators = updated)
}
}
fun createNewSession(setLoading: Boolean = true) {
// A fresh create has no persisted row until the first prompt.
sessionHasServerPresence = false
@@ -1901,7 +2085,10 @@ class ChatViewModel(
// Fetch available bot profiles for @ autocomplete
val profilesResult = safeApiCall { ApiClient.hermesApi.getProfiles() }
if (profilesResult is NetworkResult.Success) {
val profiles = profilesResult.data.profiles.orEmpty()
val profiles =
profilesResult.data.profiles.orEmpty().filter {
!AuthManager.isProfileHidden(it.name) && it.botMeta()?.hidden != true
}
_uiState.update { it.copy(availableBots = profiles) }
}
}
@@ -2165,7 +2352,10 @@ class ChatViewModel(
sessionHasServerPresence = true
sessionGoneRecoveryInFlight = 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)
viewModelScope.launch {
// Warm-cache fast-path (desktop parity): paint the cached Room
@@ -2283,6 +2473,27 @@ class ChatViewModel(
}
is NetworkResult.Failure -> {
val errorMsg = result.error.message
val is404 =
errorMsg.contains("404", ignoreCase = true) ||
errorMsg.contains("not found", ignoreCase = true)
if (is404) {
// A 404 from GET /api/sessions/{id}/messages indicates the session has no
// persisted rows in state.db (e.g. newly created/lazy unprompted session, or empty history).
// It is NOT a fatal error: keep the session open with empty transcript, clear loading,
// and mark hydrated cleanly.
_uiState.update {
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@update it
it.copy(
isLoading = false,
isLoadingOlder = false,
hasOlderMessages = false,
)
}
hydratedGeneration = generation
finishResumeWhenHydrated(generation)
return@launch
}
if (hydratedGeneration == generation) hydratedGeneration = -1L
_uiState.update {
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@update it
@@ -2390,10 +2601,19 @@ class ChatViewModel(
) {
val requestSequence = ++resumeRequestSequence
activeResumeRequestSequence = requestSequence
val profile = AuthManager.activeProfileId.value
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"omit_messages" to true,
)
if (!profile.isNullOrBlank()) {
params["profile"] = profile
}
viewModelScope.launch(ioDispatcher) {
wsClient.send(
WsMethods.SESSION_RESUME,
mapOf("session_id" to sessionId, "omit_messages" to true),
params,
onSent = { id ->
trackSessionRequest(
id = id,
@@ -2467,6 +2687,17 @@ class ChatViewModel(
private fun recoverGoneSession(sessionId: String) {
if (_uiState.value.currentSessionId != sessionId) return
if (sessionGoneRecoveryInFlight) return
if (_uiState.value.messages.isNotEmpty()) {
cancelResumeRetry()
_uiState.update {
it.copy(
isLoading = false,
isResumeRetrying = false,
resumeError = "Session not found on server (displaying cached messages)",
)
}
return
}
sessionGoneRecoveryInFlight = true
cancelResumeRetry()
_uiState.update {
@@ -9,9 +9,7 @@ import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
private fun List<ChatMessage>.upsertById(message: ChatMessage): List<ChatMessage> {
return (this + message).dedupeById()
}
private fun List<ChatMessage>.upsertById(message: ChatMessage): List<ChatMessage> = (this + message).dedupeById()
/**
* Strips this turn's sealed-orphan prefix from the final message text.
@@ -73,6 +71,7 @@ object ChatWsEventReducer {
is WsEvent.ToolGenerating -> event.sessionId
is WsEvent.SubagentEvent -> event.sessionId
is WsEvent.ReviewSummary -> event.sessionId
is WsEvent.BtwComplete -> event.sessionId
is WsEvent.SessionUsage -> event.sessionId
else -> null
}
@@ -131,8 +130,12 @@ object ChatWsEventReducer {
is WsEvent.ReviewSummary -> onReviewSummary(state, streamingState, event)
is WsEvent.BtwComplete -> onBtwComplete(state, streamingState, event)
is WsEvent.SessionUsage -> onSessionUsage(state, streamingState, event)
is WsEvent.TodoUpdated -> onTodoUpdated(state, streamingState, event)
is WsEvent.RpcError -> onRpcError(state, streamingState, event)
is WsEvent.GatewayError -> onGatewayError(state, streamingState, event)
@@ -654,6 +657,36 @@ object ChatWsEventReducer {
)
}
// ── BtwComplete (Side question completed) ─────────────────────────────
private fun onBtwComplete(
state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.BtwComplete,
): ReducerResult {
val currentBtw = state.btwState
val updatedBtw =
if (currentBtw != null) {
currentBtw.copy(
taskId = event.taskId.ifBlank { currentBtw.taskId },
question = event.question.ifBlank { currentBtw.question },
answer = event.text,
isLoading = false,
)
} else {
BtwUiState(
taskId = event.taskId,
question = event.question,
answer = event.text,
isLoading = false,
)
}
return ReducerResult(
state = state.copy(btwState = updatedBtw),
streamingState = streamingState,
)
}
// ── RpcError ──────────────────────────────────────────────────────
private fun onRpcError(
@@ -839,6 +872,20 @@ object ChatWsEventReducer {
)
}
private fun onTodoUpdated(
state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.TodoUpdated,
): ReducerResult {
if (event.sessionId != null && state.currentSessionId != null && event.sessionId != state.currentSessionId) {
return ReducerResult(state = state, streamingState = streamingState)
}
return ReducerResult(
state = state.copy(todos = event.todos),
streamingState = streamingState,
)
}
private fun onSessionUsage(
state: ChatUiState,
streamingState: StreamingState,
@@ -926,7 +973,8 @@ fun extractTodosFromMap(data: Map<String, Any?>?): List<TodoItem>? {
val id = (map["id"] as? String) ?: continue
val content = (map["content"] as? String) ?: (map["text"] as? String) ?: ""
val status = (map["status"] as? String) ?: "pending"
items.add(TodoItem(id = id, content = content, status = status))
val parent = (map["parent"] as? String)?.takeIf { it.isNotBlank() }
items.add(TodoItem(id = id, content = content, status = status, parent = parent))
}
return items.takeIf { it.isNotEmpty() }
}
@@ -943,15 +991,17 @@ fun extractTodosFromJson(content: String): List<TodoItem>? {
?: ((obj["parameters"] as? JsonObject)?.get("todos") as? JsonArray)
?: return null
todosArray.mapNotNull { item ->
val itemObj = item as? JsonObject ?: return@mapNotNull null
val id = (itemObj["id"] as? JsonPrimitive)?.content ?: return@mapNotNull null
val contentStr =
(itemObj["content"] as? JsonPrimitive)?.content
?: (itemObj["text"] as? JsonPrimitive)?.content ?: ""
val status = (itemObj["status"] as? JsonPrimitive)?.content ?: "pending"
TodoItem(id = id, content = contentStr, status = status)
}.takeIf { it.isNotEmpty() }
todosArray
.mapNotNull { item ->
val itemObj = item as? JsonObject ?: return@mapNotNull null
val id = (itemObj["id"] as? JsonPrimitive)?.content ?: return@mapNotNull null
val contentStr =
(itemObj["content"] as? JsonPrimitive)?.content
?: (itemObj["text"] as? JsonPrimitive)?.content ?: ""
val status = (itemObj["status"] as? JsonPrimitive)?.content ?: "pending"
val parent = (itemObj["parent"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
TodoItem(id = id, content = contentStr, status = status, parent = parent)
}.takeIf { it.isNotEmpty() }
} catch (_: Exception) {
null
}
@@ -580,9 +580,13 @@ private fun tableTextAlign(align: TableAlign?): TextAlign? =
}
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. */
@@ -26,11 +26,26 @@ class SlashCommandDispatcher {
val cmd = parts[0].lowercase()
return when (cmd) {
"/stop", "/interrupt" -> SlashResult.Interrupt
"/new" -> SlashResult.NewSession
"/fork", "/branch" -> SlashResult.SessionBranch
"/model" -> SlashResult.ModelSwitch
"/update" -> SlashResult.Update
"/stop", "/interrupt" -> {
SlashResult.Interrupt
}
"/new" -> {
SlashResult.NewSession
}
"/fork", "/branch" -> {
SlashResult.SessionBranch
}
"/model" -> {
SlashResult.ModelSwitch
}
"/update" -> {
SlashResult.Update
}
"/queue", "/q" -> {
val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim()
// Bubble shows the queued TEXT (prefix stripped) so the
@@ -41,8 +56,19 @@ class SlashCommandDispatcher {
// text so the usage message has something to sit under.
SlashResult.QueuePrompt(displayContent = arg.ifBlank { command })
}
"/resume", "/history" -> SlashResult.OpenHistory
else -> SlashResult.RpcDispatch
"/resume", "/history" -> {
SlashResult.OpenHistory
}
"/btw" -> {
val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim()
SlashResult.SideQuestion(question = arg)
}
else -> {
SlashResult.RpcDispatch
}
}
}
}
@@ -105,5 +131,16 @@ sealed class SlashResult {
* argument). Matching the server echo verbatim lets the transcript sync
* dedupe the two copies of the same logical message.
*/
data class QueuePrompt(val displayContent: String) : SlashResult()
data class QueuePrompt(
val displayContent: String,
) : SlashResult()
/**
* Context-aware side question about the current session (issue #1015).
* Dispatched via the `prompt.btw` RPC. Answers without mutating the
* session's history or invalidating prompt caching.
*/
data class SideQuestion(
val question: String,
) : SlashResult()
}
@@ -0,0 +1,54 @@
package com.m57.hermescontrol.ui.chat
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
object SpeechInputHelper {
/**
* Checks if speech recognition is available on this device through any registered provider,
* including standard Android [SpeechRecognizer] services or offline/third-party apps
* that handle [RecognizerIntent.ACTION_RECOGNIZE_SPEECH] (e.g. FUTO Voice Input).
*/
fun isSpeechInputAvailable(
context: Context,
isRecognizerServiceAvailable: (Context) -> Boolean = { ctx ->
runCatching { SpeechRecognizer.isRecognitionAvailable(ctx) }.getOrDefault(false)
},
): Boolean {
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
val activities =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
runCatching {
context.packageManager.queryIntentActivities(
intent,
PackageManager.ResolveInfoFlags.of(0L),
)
}.getOrDefault(emptyList())
} else {
@Suppress("DEPRECATION")
runCatching {
context.packageManager.queryIntentActivities(intent, 0)
}.getOrDefault(emptyList())
}
return activities.isNotEmpty() || isRecognizerServiceAvailable(context)
}
/**
* Builds the standard speech recognition intent with language model and prompt.
*/
fun createSpeechIntent(prompt: String): Intent =
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
)
putExtra(
RecognizerIntent.EXTRA_PROMPT,
prompt,
)
}
}
@@ -136,8 +136,8 @@ fun ChatInputBar(
// also enforced at dispatch time (issue #576, deliverable #3).
val hiddenSlashDisplay = CommandBlocklist.UNSUPPORTED
val commandNames =
commandCatalog.pairs
.map { it[0] }
(commandCatalog.pairs.map { it[0] } + listOf("/btw", "/queue", "/fork", "/model", "/new", "/stop"))
.distinct()
.filter { it.lowercase() !in hiddenSlashDisplay }
androidx.compose.animation.AnimatedVisibility(
@@ -236,8 +236,7 @@ fun ChatInputBar(
onInputChange(
ChatInputPolicy.applyMention(inputFieldValue, bot.name),
)
}
.padding(horizontal = 12.dp, vertical = 8.dp),
}.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BotAvatar(
@@ -107,7 +107,8 @@ fun parseDiffText(
line.startsWith("--- ") || line.startsWith("+++ ") -> {
if (extractedPath == null || extractedPath == defaultPath) {
val candidate =
line.drop(4)
line
.drop(4)
.trim()
.removePrefix("a/")
.removePrefix("b/")
@@ -137,8 +137,7 @@ fun ReasoningCard(
clipboard.setPrimaryClip(ClipData.newPlainText(null, reasoningText))
copied = true
},
)
.testTag("reasoning_card"),
).testTag("reasoning_card"),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
@@ -0,0 +1,239 @@
package com.m57.hermescontrol.ui.chat.components
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.HelpOutline
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.ui.chat.BtwUiState
import com.m57.hermescontrol.ui.chat.MarkdownText
/**
* Bottom sheet displaying a context-aware side question and its answer (issue #1015, `/btw`).
* Answers from a conversation snapshot without mutating the chat transcript.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SideQuestionSheet(
state: BtwUiState,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val context = LocalContext.current
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
modifier = modifier.testTag("side_question_sheet"),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 24.dp)
.verticalScroll(rememberScrollState()),
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.AutoMirrored.Filled.HelpOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Side Question",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
}
IconButton(
onClick = onDismiss,
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// Question Banner
if (state.question.isNotBlank()) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = "Question",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = state.question,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(12.dp))
}
// Loading state
if (state.isLoading) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
LinearProgressIndicator(
modifier =
Modifier
.fillMaxWidth()
.height(4.dp),
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Answering from conversation snapshot...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Error state
if (!state.error.isNullOrBlank()) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.ErrorOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = state.error,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
}
// Answer state
val answer = state.answer
if (!answer.isNullOrBlank()) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
border =
androidx.compose.foundation.BorderStroke(
1.dp,
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
),
) {
Column(modifier = Modifier.padding(14.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "Answer",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
IconButton(
onClick = {
val clipboard =
context.getSystemService(
Context.CLIPBOARD_SERVICE,
) as? ClipboardManager
clipboard?.setPrimaryClip(ClipData.newPlainText("Side Question Answer", answer))
Toast.makeText(context, "Copied answer", Toast.LENGTH_SHORT).show()
},
modifier = Modifier.size(24.dp),
) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = "Copy answer",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
}
}
Spacer(modifier = Modifier.height(8.dp))
MarkdownText(
text = answer,
textColor = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
}
}
@@ -1,6 +1,8 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -11,25 +13,42 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.AltRoute
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Autorenew
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -37,6 +56,7 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -46,17 +66,22 @@ import com.m57.hermescontrol.ui.chat.SubagentIndicator
import com.m57.hermescontrol.ui.chat.TodoItem
/**
* Inspection sheet displaying active & completed subagent tasks and agent todos.
* Inspection sheet displaying active & completed subagent tasks and agent todos (issue #1030).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SubagentInspectionSheet(
indicators: List<SubagentIndicator> = emptyList(),
todos: List<TodoItem> = emptyList(),
onSteerSubagent: ((SubagentIndicator, String) -> Unit)? = null,
onStopSubagent: ((SubagentIndicator) -> Unit)? = null,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val todoTree = remember(todos) { buildTodoTree(todos) }
var collapsedParentIds by remember { mutableStateOf(setOf<String>()) }
val displayRows = remember(todoTree, collapsedParentIds) { flattenTodoTree(todoTree, collapsedParentIds) }
ModalBottomSheet(
onDismissRequest = onDismiss,
@@ -111,9 +136,9 @@ fun SubagentInspectionSheet(
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (todos.isNotEmpty()) {
if (displayRows.isNotEmpty()) {
item(key = "todos_header") {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -134,11 +159,26 @@ fun SubagentInspectionSheet(
)
}
}
itemsIndexed(
items = todos,
key = { index, todo -> "todo-${todo.id}_$index" },
) { _, todo ->
TodoInspectionCard(todo = todo)
items(
items = displayRows,
key = { row -> "todo-${row.todo.id}_${row.depth}" },
) { row ->
TodoInspectionCard(
row = row,
onToggleExpand =
if (row.hasChildren) {
{
collapsedParentIds =
if (row.todo.id in collapsedParentIds) {
collapsedParentIds - row.todo.id
} else {
collapsedParentIds + row.todo.id
}
}
} else {
null
},
)
}
}
@@ -171,7 +211,21 @@ fun SubagentInspectionSheet(
"subagent-${indicator.subagentId ?: indicator.goal ?: "${indicator.type}_$index"}"
},
) { _, indicator ->
InspectionItemCard(indicator = indicator)
InspectionItemCard(
indicator = indicator,
onSteer =
if (onSteerSubagent != null) {
{ message -> onSteerSubagent(indicator, message) }
} else {
null
},
onStop =
if (onStopSubagent != null) {
{ onStopSubagent(indicator) }
} else {
null
},
)
}
}
}
@@ -180,12 +234,128 @@ fun SubagentInspectionSheet(
}
}
/**
* Node in the hierarchical todo tree.
*/
data class TodoNode(
val item: TodoItem,
val children: List<TodoNode> = emptyList(),
val depth: Int = 0,
)
/**
* Flattened row representation ready for lazy column rendering with indentation and collapse state.
*/
data class TodoDisplayRow(
val todo: TodoItem,
val depth: Int,
val hasChildren: Boolean,
val isExpanded: Boolean,
val completedSubtasksCount: Int,
val totalSubtasksCount: Int,
)
/**
* Constructs a hierarchical tree from a flat list of [TodoItem]s using the [TodoItem.parent] property.
*/
fun buildTodoTree(todos: List<TodoItem>): List<TodoNode> {
val itemsById = todos.associateBy { it.id }
val childrenByParent = mutableMapOf<String, MutableList<TodoItem>>()
val rootItems = mutableListOf<TodoItem>()
for (item in todos) {
val p = item.parent
if (p != null && itemsById.containsKey(p) && p != item.id) {
childrenByParent.getOrPut(p) { mutableListOf() }.add(item)
} else {
rootItems.add(item)
}
}
fun buildNode(
item: TodoItem,
depth: Int,
): TodoNode {
val children = childrenByParent[item.id]?.map { buildNode(it, depth + 1) } ?: emptyList()
return TodoNode(item = item, children = children, depth = depth)
}
return rootItems.map { buildNode(it, 0) }
}
fun getAllDescendantTodos(node: TodoNode): List<TodoItem> {
val descendants = mutableListOf<TodoItem>()
for (child in node.children) {
descendants.add(child.item)
descendants.addAll(getAllDescendantTodos(child))
}
return descendants
}
fun flattenTodoTree(
nodes: List<TodoNode>,
collapsedParentIds: Set<String>,
): List<TodoDisplayRow> {
val result = mutableListOf<TodoDisplayRow>()
fun traverse(node: TodoNode) {
val isExpanded = node.item.id !in collapsedParentIds
val descendants = getAllDescendantTodos(node)
val completedCount = descendants.count { it.isCompleted }
val totalCount = descendants.size
result.add(
TodoDisplayRow(
todo = node.item,
depth = node.depth,
hasChildren = node.children.isNotEmpty(),
isExpanded = isExpanded,
completedSubtasksCount = completedCount,
totalSubtasksCount = totalCount,
),
)
if (node.children.isNotEmpty() && isExpanded) {
for (child in node.children) {
traverse(child)
}
}
}
for (root in nodes) {
traverse(root)
}
return result
}
@Composable
private fun TodoInspectionCard(todo: TodoItem) {
private fun TodoInspectionCard(
row: TodoDisplayRow,
onToggleExpand: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val todo = row.todo
val depth = row.depth
val startPadding = (depth * 20).coerceAtMost(60).dp
Surface(
modifier = Modifier.fillMaxWidth(),
modifier =
modifier
.fillMaxWidth()
.padding(start = startPadding)
.then(
if (onToggleExpand != null) {
Modifier.clickable(onClick = onToggleExpand)
} else {
Modifier
},
),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
color =
if (depth > 0) {
MaterialTheme.colorScheme.surfaceContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
},
) {
Row(
modifier = Modifier.padding(12.dp),
@@ -194,29 +364,52 @@ private fun TodoInspectionCard(todo: TodoItem) {
val statusColors = LocalHermesStatusColors.current
val (statusIcon, statusTint) =
when {
todo.isCompleted ->
todo.isCompleted -> {
Icons.Filled.CheckCircle to statusColors.success
todo.isInProgress ->
}
todo.isInProgress -> {
Icons.Filled.Autorenew to MaterialTheme.colorScheme.primary
todo.isCancelled ->
}
todo.isCancelled -> {
Icons.Filled.Cancel to statusColors.warning
else ->
}
else -> {
Icons.Filled.RadioButtonUnchecked to
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
}
}
if (depth > 0) {
Text(
text = "↳",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
modifier = Modifier.padding(end = 6.dp),
)
}
Icon(
imageVector = statusIcon,
contentDescription = null,
tint = statusTint,
modifier = Modifier.size(18.dp),
modifier = Modifier.size(if (depth > 0) 16.dp else 18.dp),
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = todo.content,
style =
MaterialTheme.typography.bodyMedium.copy(
textDecoration = if (todo.isCompleted) TextDecoration.LineThrough else TextDecoration.None,
),
if (depth > 0) {
MaterialTheme.typography.bodySmall.copy(
textDecoration = if (todo.isCompleted) TextDecoration.LineThrough else TextDecoration.None,
)
} else {
MaterialTheme.typography.bodyMedium.copy(
textDecoration = if (todo.isCompleted) TextDecoration.LineThrough else TextDecoration.None,
)
},
fontWeight = if (todo.isInProgress) FontWeight.Bold else FontWeight.Normal,
color =
if (todo.isCompleted) {
@@ -226,29 +419,104 @@ private fun TodoInspectionCard(todo: TodoItem) {
},
modifier = Modifier.weight(1f),
)
if (row.hasChildren) {
Spacer(modifier = Modifier.width(8.dp))
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceContainerHighest,
) {
Text(
text = "${row.completedSubtasksCount}/${row.totalSubtasksCount}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = if (row.isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (row.isExpanded) "Collapse" else "Expand",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
}
}
@Composable
private fun InspectionItemCard(indicator: SubagentIndicator) {
private fun InspectionItemCard(
indicator: SubagentIndicator,
onSteer: ((String) -> Unit)? = null,
onStop: (() -> Unit)? = null,
) {
var showSteerInput by remember { mutableStateOf(false) }
var steerText by remember { mutableStateOf("") }
val statusColors = LocalHermesStatusColors.current
val (statusIcon, statusTint, statusLabel) =
when {
indicator.isComplete -> {
Triple(
Icons.Filled.CheckCircle,
statusColors.success,
stringResource(R.string.subagent_status_completed),
)
}
indicator.isFailed -> {
Triple(
Icons.Filled.Cancel,
statusColors.error,
stringResource(R.string.subagent_status_failed),
)
}
indicator.isCancelled -> {
Triple(
Icons.Filled.Cancel,
statusColors.warning,
stringResource(R.string.subagent_status_cancelled),
)
}
indicator.isSteered -> {
Triple(
Icons.AutoMirrored.Filled.AltRoute,
MaterialTheme.colorScheme.primary,
stringResource(R.string.subagent_status_steered),
)
}
indicator.isQueued -> {
Triple(
Icons.Filled.HourglassEmpty,
MaterialTheme.colorScheme.onSurfaceVariant,
stringResource(R.string.subagent_status_running),
)
}
else -> {
Triple(
Icons.Filled.Autorenew,
MaterialTheme.colorScheme.primary,
stringResource(R.string.subagent_status_running),
)
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
val statusColors = LocalHermesStatusColors.current
val (statusIcon, statusTint) =
when {
indicator.isComplete ->
Icons.Filled.CheckCircle to statusColors.success
indicator.isFailed ->
Icons.Filled.Cancel to statusColors.error
else ->
Icons.Filled.Autorenew to MaterialTheme.colorScheme.primary
}
// Header Row: Status Icon, Goal, and Status Badge
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = statusIcon,
contentDescription = null,
@@ -264,6 +532,44 @@ private fun InspectionItemCard(indicator: SubagentIndicator) {
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.width(8.dp))
Surface(
shape = RoundedCornerShape(8.dp),
color = statusTint.copy(alpha = 0.15f),
) {
Text(
text = statusLabel,
style = MaterialTheme.typography.labelSmall,
color = statusTint,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
// Duration & Model info
if (indicator.durationSeconds != null || !indicator.model.isNullOrBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (indicator.durationSeconds != null) {
Text(
text = stringResource(R.string.subagent_duration, indicator.durationSeconds),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (!indicator.model.isNullOrBlank()) {
Text(
text = indicator.model,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
)
}
}
}
if (!indicator.summary.isNullOrBlank()) {
@@ -275,6 +581,7 @@ private fun InspectionItemCard(indicator: SubagentIndicator) {
)
}
// Live Transcript Logs
if (indicator.logs.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Column(
@@ -311,6 +618,106 @@ private fun InspectionItemCard(indicator: SubagentIndicator) {
}
}
}
// Interactive Controls for active subagent (issue #1030)
if (indicator.isRunning || indicator.isSteered) {
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (onSteer != null) {
FilledTonalButton(
onClick = { showSteerInput = !showSteerInput },
modifier = Modifier.weight(1f),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.AltRoute,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.subagent_action_steer))
}
}
if (onStop != null) {
OutlinedButton(
onClick = onStop,
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = statusColors.error,
),
) {
Icon(
imageVector = Icons.Filled.Stop,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.subagent_action_stop))
}
}
}
// Inline Steering Input Field
AnimatedVisibility(visible = showSteerInput) {
Column(modifier = Modifier.padding(top = 8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = steerText,
onValueChange = { steerText = it },
placeholder = {
Text(
text = stringResource(R.string.subagent_steer_placeholder),
style = MaterialTheme.typography.bodySmall,
)
},
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions =
KeyboardActions(
onSend = {
if (steerText.isNotBlank()) {
onSteer?.invoke(steerText)
steerText = ""
showSteerInput = false
}
},
),
modifier = Modifier.weight(1f),
textStyle = MaterialTheme.typography.bodySmall,
)
Spacer(modifier = Modifier.width(6.dp))
IconButton(
onClick = {
if (steerText.isNotBlank()) {
onSteer?.invoke(steerText)
steerText = ""
showSteerInput = false
}
},
enabled = steerText.isNotBlank(),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.subagent_action_steer),
tint =
if (steerText.isNotBlank()) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
},
)
}
}
}
}
}
}
}
}
@@ -13,18 +13,28 @@ import com.m57.hermescontrol.ui.chat.MessageRole
*/
sealed interface ChatTurn {
/** 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. */
data class Agent(val entries: List<AgentEntry>) : ChatTurn
data class Agent(
val entries: List<AgentEntry>,
) : ChatTurn
}
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
// stable content prefix and route it as a system event too, tagging
// it so the timeline chip can name it.
message.displayKind != null ->
message.displayKind != null -> {
agentEntries += AgentEntry.SystemEvent(message)
}
message.isSyntheticSystemRow() ->
message.isSyntheticSystemRow() -> {
agentEntries +=
AgentEntry.SystemEvent(message.copy(displayKind = "max_iterations_reached"))
}
message.role == MessageRole.USER -> {
flushAgent()
turns += ChatTurn.User(message)
}
MessageRole.ASSISTANT == message.role -> agentEntries += AgentEntry.Prose(message)
MessageRole.TOOL == message.role -> agentEntries += AgentEntry.ToolRow(message)
MessageRole.SYSTEM == message.role -> agentEntries += AgentEntry.SystemEvent(message)
MessageRole.ASSISTANT == message.role -> {
agentEntries += AgentEntry.Prose(message)
}
MessageRole.TOOL == message.role -> {
agentEntries += AgentEntry.ToolRow(message)
}
MessageRole.SYSTEM == message.role -> {
agentEntries += AgentEntry.SystemEvent(message)
}
}
}
flushAgent()
@@ -167,8 +187,13 @@ fun messageIdToLazyIndex(
itemIndex++
}
is AgentEntry.ToolRow -> itemIndex++
is AgentEntry.SystemEvent -> itemIndex++
is AgentEntry.ToolRow -> {
itemIndex++
}
is AgentEntry.SystemEvent -> {
itemIndex++
}
}
}
}
@@ -218,8 +218,7 @@ object ToolResultSummary {
.mapNotNull { k ->
val s = summarizeScalar(record[k])
if (s.isEmpty()) null else "${titleCase(k)}: $s"
}
.take(2)
}.take(2)
return if (pairs.isNotEmpty()) pairs.joinToString(" · ") else pluralize(record.keys.size, "field")
}
@@ -474,12 +473,13 @@ object ToolResultSummary {
if (v is JsonArray) {
return clipBlock(
v.mapNotNull {
valueErrorText(it).takeIf {
s ->
s.isNotEmpty()
}
}.take(3).joinToString("; "),
v
.mapNotNull {
valueErrorText(it).takeIf { s ->
s.isNotEmpty()
}
}.take(3)
.joinToString("; "),
700,
12,
)
@@ -110,7 +110,10 @@ object ToolViewBuilder {
private fun parseMaybeObject(value: JsonElement?): JsonObject? =
when (value) {
is JsonObject -> value
is JsonObject -> {
value
}
is JsonPrimitive -> {
if (!value.isString || value.content.trim().isEmpty()) {
null
@@ -122,7 +125,10 @@ object ToolViewBuilder {
}
}
}
else -> null
else -> {
null
}
}
private fun unwrapToolPayload(value: JsonElement?): JsonElement? {
@@ -459,7 +465,13 @@ object ToolViewBuilder {
val lines =
content
.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()) {
lineLabel =
@@ -659,13 +671,11 @@ object ToolViewBuilder {
Regex(
"""\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b""",
RegexOption.IGNORE_CASE,
)
.find(t)
).find(t)
?: Regex(
"""\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b""",
RegexOption.IGNORE_CASE,
)
.find(t)
).find(t)
val n = unitMatch?.groupValues?.get(1)?.toIntOrNull() ?: return null
val noun = unitMatch.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: fallbackNoun
@@ -715,12 +725,11 @@ object ToolViewBuilder {
return if (payload === record) emptyList() else collectResultItems(payload)
}
private fun cleanVisibleText(text: String): String {
return text
private fun cleanVisibleText(text: String): String =
text
.replace(Regex("`{3,}"), "")
.replace(Regex("(?<=[\\p{L}\\p{N})\\].,!?:;\"'”’])\\[(?:\\d+(?:\\s*,\\s*\\d+)*)\\](?!\\()"), "")
.replace(Regex("\\[([^\\]]+)\\]\\(([^)\\s]+)\\)")) { m -> "${m.groupValues[1]} ${m.groupValues[2]}" }
}
private fun extractSearchResults(
result: JsonElement?,
@@ -735,8 +744,7 @@ object ToolViewBuilder {
url = firstString(r, listOf("url", "href", "link")),
snippet = cleanVisibleText(firstString(r, listOf("snippet", "description", "body"))),
)
}
.filter { it.title.isNotEmpty() || it.url.isNotEmpty() }
}.filter { it.title.isNotEmpty() || it.url.isNotEmpty() }
.take(limit)
}
@@ -913,12 +921,24 @@ object ToolViewBuilder {
private fun pendingTitle(
toolName: String,
args: JsonObject?,
): String {
return when (toolName) {
"web_extract" -> "Reading ${targetHostname(toolName, args, null)}"
"browser_navigate" -> "Opening ${targetHostname(toolName, args, null)}"
"web_search" -> "Searching \"${compactPreview(searchQueryFor(args), 48)}\""
"read_file" -> "Reading ${readFileDisplayTarget(args, null)}"
): String =
when (toolName) {
"web_extract" -> {
"Reading ${targetHostname(toolName, 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" -> {
val command = shellCommand(args)
val verb = if (toolName == "execute_code") "Running code" else "Running"
@@ -931,21 +951,38 @@ object ToolViewBuilder {
titleForTool(toolName)
}
}
"memory" -> "Saving"
else -> titleForTool(toolName)
"memory" -> {
"Saving"
}
else -> {
titleForTool(toolName)
}
}
}
private fun doneTitle(
toolName: String,
args: JsonObject?,
result: JsonObject?,
): String {
return when (toolName) {
"web_extract" -> "Read ${targetHostname(toolName, args, result)}"
"browser_navigate" -> "Opened ${targetHostname(toolName, args, result)}"
"web_search" -> "Searched \"${compactPreview(searchQueryFor(args), 48)}\""
"read_file" -> "Read ${readFileDisplayTarget(args, result)}"
): String =
when (toolName) {
"web_extract" -> {
"Read ${targetHostname(toolName, 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" -> {
val command = shellCommand(args)
val verb = if (toolName == "execute_code") "Ran code" else "Ran"
@@ -958,6 +995,7 @@ object ToolViewBuilder {
titleForTool(toolName)
}
}
"memory" -> {
val action = firstString(args, listOf("action")).lowercase()
val target = firstString(args, listOf("target"))
@@ -973,27 +1011,35 @@ object ToolViewBuilder {
// pre-engine mobile summary did.
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" -> {
val path = fileEditPath(args, result)
if (path.isNotEmpty()) fileEditBasename(path) else titleForTool(toolName)
}
else -> titleForTool(toolName)
else -> {
titleForTool(toolName)
}
}
}
private fun errorTitle(
toolName: String,
result: JsonObject?,
): String {
return when (toolName) {
): String =
when (toolName) {
"browser_navigate" -> {
val url = findFirstUrl(result).ifEmpty { result?.get("url")?.toString() ?: "" }
if (url.isNotEmpty()) "Failed to open ${hostnameOf(url)}" else titleForTool(toolName)
}
else -> titleForTool(toolName)
else -> {
titleForTool(toolName)
}
}
}
private fun targetHostname(
toolName: String,
@@ -1033,6 +1079,7 @@ object ToolViewBuilder {
if (url.isNotEmpty()) hostnameOf(url) else "Navigated in browser"
}
"browser_snapshot" -> {
val snapshot = firstString(result, listOf("snapshot"))
if (snapshot.isNotEmpty()) {
@@ -1043,6 +1090,7 @@ object ToolViewBuilder {
"Captured a browser accessibility snapshot"
}
}
"browser_click" -> {
val clicked =
firstString(
@@ -1058,21 +1106,24 @@ object ToolViewBuilder {
"Clicked $clicked"
}
}
"browser_fill", "browser_type" -> {
val field = firstString(args, listOf("label", "field", "ref", "target"))
val value = firstString(args, listOf("value", "text"))
listOf(
field.takeIf { it.isNotEmpty() }?.let { "Field: $it" },
value.takeIf { it.isNotEmpty() }?.let { "Value: ${compactPreview(it, 42)}" },
)
.filterNotNull()
).filterNotNull()
.joinToString(" · ")
.ifEmpty { "Filled page input" }
}
"web_search" ->
"web_search" -> {
searchQueryFor(
args,
).takeIf { it.isNotEmpty() }?.let { "Query: $it" } ?: "Queried web sources"
}
"terminal", "execute_code" -> {
val output = shellOutput(result)
val firstMeaningfulLine =
@@ -1089,6 +1140,7 @@ object ToolViewBuilder {
""
}
}
"read_file", "edit_file", "patch", "write_file" -> {
val path =
if (isFileEditTool(toolName)) {
@@ -1103,6 +1155,7 @@ object ToolViewBuilder {
fallbackDetailText(args, rawResult)
}
}
"web_extract" -> {
val url =
firstString(args, listOf("url"))
@@ -1111,11 +1164,27 @@ object ToolViewBuilder {
if (url.isNotEmpty()) hostnameOf(url) else "Fetched webpage"
}
"memory" -> firstString(result, listOf("message", "error"))
"cronjob" -> cronjobSubtitle(args, result)
"todo" -> todoSubtitle(result)
"fact_store" -> factStoreSubtitle(args, result)
"session_search" -> sessionSearchSubtitle(result)
"memory" -> {
firstString(result, listOf("message", "error"))
}
"cronjob" -> {
cronjobSubtitle(args, result)
}
"todo" -> {
todoSubtitle(result)
}
"fact_store" -> {
factStoreSubtitle(args, result)
}
"session_search" -> {
sessionSearchSubtitle(result)
}
"skills_list" -> {
val category = firstString(args, listOf("category"))
val count =
@@ -1128,17 +1197,23 @@ object ToolViewBuilder {
"No skills found"
}
}
"skill_view" -> firstString(args, listOf("name"))
"skill_view" -> {
firstString(args, listOf("name"))
}
"skill_manage" -> {
val action = firstString(args, listOf("action"))
val name = firstString(args, listOf("name"))
if (name.isNotEmpty()) "Skill $action: $name" else "Skill $action"
}
"process" -> {
val action = firstString(args, listOf("action"))
val procId = firstString(args, listOf("session_id"))
if (procId.isNotEmpty()) "$action: $procId" else action
}
"x_search" -> {
val query = firstString(args, listOf("query"))
val degraded =
@@ -1149,12 +1224,17 @@ object ToolViewBuilder {
)?.let { !it.isString && it.content == "true" } == true
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" -> {
val query = firstString(args, listOf("query"))
val matches = (result?.get("matches") as? JsonArray)?.size
if (matches != null) "$query ($matches matches)" else query
}
"image_generate" -> {
val imageUrl = firstString(result, listOf("image")).ifEmpty { firstString(args, listOf("image_url")) }
val modality = firstString(result, listOf("modality"))
@@ -1162,6 +1242,7 @@ object ToolViewBuilder {
.filterNotNull()
.joinToString(" ")
}
"project_list" -> {
val projects = (result?.get("projects") as? JsonArray)?.size
val active = firstString(result, listOf("active_id"))
@@ -1171,11 +1252,13 @@ object ToolViewBuilder {
"Projects"
}
}
"project_create", "project_switch" -> {
val actionLabel = if (toolName == "project_create") "Created" else "Switched to"
val name = firstString(result, listOf("name"))
if (name.isNotEmpty()) "$actionLabel $name" else actionLabel
}
"read_terminal" -> {
val error = firstString(result, listOf("error"))
if (error.isNotEmpty()) {
@@ -1193,7 +1276,11 @@ object ToolViewBuilder {
}
}
}
"computer_use" -> computerUseSubtitle(args, result)
"computer_use" -> {
computerUseSubtitle(args, result)
}
else -> {
val summary = ToolResultSummary.formatToolResultSummary(rawResult)
val firstLine = summary.split("\n").firstOrNull()?.takeIf { it.isNotEmpty() } ?: ""
@@ -1304,9 +1391,13 @@ object ToolViewBuilder {
60,
)}"
} ?: ""}"
"scroll" -> "${messages?.size ?: 0} messages (scroll)"
"read" -> "${msgCount ?: messages?.size ?: 0} messages${if (truncated) " (truncated)" else ""}"
"browse" -> "${count ?: 0} recent sessions"
else -> "session_search"
}
}
@@ -1369,6 +1460,7 @@ object ToolViewBuilder {
fallbackDetailText(rawArgs, rawResult)
}
}
"terminal", "execute_code" -> {
val output = shellOutput(result)
if (output.isNotEmpty()) {
@@ -1381,6 +1473,7 @@ object ToolViewBuilder {
""
}
}
"web_extract" -> {
val direct = firstString(result, listOf("content", "text", "markdown", "body", "summary", "message"))
if (direct.isNotEmpty()) {
@@ -1393,20 +1486,24 @@ object ToolViewBuilder {
.mapNotNull { item ->
val row = parseMaybeObject(item) ?: return@mapNotNull null
firstString(row, listOf("content", "text", "markdown", "body"))
}
.filter { it.isNotEmpty() }
}.filter { it.isNotEmpty() }
.joinToString("\n\n---\n\n")
}
fallbackDetailText(rawArgs, rawResult)
}
"read_file" -> {
if (rawResult == null) {
return ""
}
firstString(result, listOf("content", "text", "data", "body"))
}
"memory" -> firstString(result, listOf("message", "error"))
"memory" -> {
firstString(result, listOf("message", "error"))
}
"edit_file", "patch", "write_file" -> {
if (inlineDiffFromResult(rawResult).isNotEmpty()) {
""
@@ -1416,11 +1513,27 @@ object ToolViewBuilder {
}
}
}
"web_search" -> fallbackDetailText(rawArgs, rawResult)
"cronjob" -> cronjobDetail(args, result)
"todo" -> todoDetail(result) ?: ""
"fact_store" -> factStoreDetail(result) ?: ""
"session_search" -> sessionSearchDetail(result) ?: ""
"web_search" -> {
fallbackDetailText(rawArgs, rawResult)
}
"cronjob" -> {
cronjobDetail(args, result)
}
"todo" -> {
todoDetail(result) ?: ""
}
"fact_store" -> {
factStoreDetail(result) ?: ""
}
"session_search" -> {
sessionSearchDetail(result) ?: ""
}
"skills_list" -> {
val skills =
(result?.get("skills") as? JsonArray)
@@ -1435,10 +1548,10 @@ object ToolViewBuilder {
if (desc.isNotEmpty()) lines += " $desc"
if (cat.isNotEmpty()) lines += " [$cat]"
lines.joinToString("\n")
}
?.joinToString("\n\n")
}?.joinToString("\n\n")
?: "No skills found"
}
"skill_view" -> {
val content = firstString(result, listOf("content"))
val linkedFiles = result?.get("linked_files") as? JsonObject
@@ -1456,12 +1569,19 @@ object ToolViewBuilder {
?.joinToString("\n") { (k, v) ->
val paths =
when (v) {
is JsonArray ->
is JsonArray -> {
v.joinToString(
", ",
) { (it as? JsonPrimitive)?.content ?: it.toString() }
is JsonPrimitive -> v.content
else -> v.toString()
}
is JsonPrimitive -> {
v.content
}
else -> {
v.toString()
}
}
" 📎 $k: $paths"
}
@@ -1475,6 +1595,7 @@ object ToolViewBuilder {
if (content.isEmpty() && linkedFiles == null) append("No content available")
}
}
"skill_manage" -> {
val error = firstString(result, listOf("error"))
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" -> {
val error = firstString(result, listOf("error"))
val description = firstString(result, listOf("description"))
@@ -1507,6 +1635,7 @@ object ToolViewBuilder {
else -> "No description available"
}
}
"tool_search" -> {
val matches = result?.get("matches") as? JsonArray
matches
@@ -1517,10 +1646,10 @@ object ToolViewBuilder {
val lines = mutableListOf("🔧 $name")
if (desc.isNotEmpty()) lines += " $desc"
lines.joinToString("\n")
}
?.joinToString("\n\n")
}?.joinToString("\n\n")
?: "No matching tools"
}
"image_generate" -> {
val error = firstString(result, listOf("error"))
val imageUrl = firstString(result, listOf("image"))
@@ -1539,6 +1668,7 @@ object ToolViewBuilder {
}
}
}
"project_list" -> {
val projects = result?.get("projects") as? JsonArray
projects
@@ -1555,10 +1685,10 @@ object ToolViewBuilder {
if (path.isNotEmpty()) lines += " 📍 $path"
if (isActive) lines += " ✅ ACTIVE PROJECT"
lines.joinToString("\n")
}
?.joinToString("\n\n")
}?.joinToString("\n\n")
?: ""
}
"project_create", "project_switch" -> {
val error = firstString(result, listOf("error"))
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)
else -> fallbackDetailText(rawArgs, rawResult)
"read_terminal" -> {
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 sched = firstString(row, listOf("schedule_display", "schedule"))
if (sched.isNotEmpty()) "- $name · $sched" else "- $name"
}
.joinToString("\n")
}.joinToString("\n")
}
val rows =
@@ -1631,6 +1769,7 @@ object ToolViewBuilder {
val id = firstString(item, listOf("id"))
val content = firstString(item, listOf("content"))
val status = firstString(item, listOf("status")).ifEmpty { "pending" }
val parent = firstString(item, listOf("parent"))
val marker =
when (status) {
"completed" -> "[x]"
@@ -1638,9 +1777,9 @@ object ToolViewBuilder {
"cancelled" -> "[~]"
else -> "[ ]"
}
"$marker $id. $content"
}
.joinToString("\n")
val indent = if (parent.isNotEmpty()) " ↳ " else ""
"$indent$marker $id. $content"
}.joinToString("\n")
.takeIf { it.isNotEmpty() }
}
@@ -1669,8 +1808,7 @@ object ToolViewBuilder {
} else {
"#$fid $content"
}
}
.joinToString("\n")
}.joinToString("\n")
.takeIf { it.isNotEmpty() }
}
@@ -1700,8 +1838,7 @@ object ToolViewBuilder {
snippet.takeIf { it.isNotEmpty() }?.let { "\n ┃ ${it.take(200).replace("\n", " ")}" } ?: ""
"━━━ #${idx + 1} $header$sourceTag$titleLine$modelLine$matchedLine$countLine$snippetLine"
}
?.joinToString("\n")
}?.joinToString("\n")
?.takeIf { it.isNotEmpty() }
val anchorId = intValue(result?.get("around_message_id"))
@@ -1727,8 +1864,7 @@ object ToolViewBuilder {
val cleanContent = content.take(300).replace("\n", " ")
"[$msgId] $roleEmoji $role$namePart$anchor\n $cleanContent"
}
?.joinToString("\n")
}?.joinToString("\n")
?.takeIf { it.isNotEmpty() }
return formattedResults ?: formattedMessages
@@ -1756,8 +1892,7 @@ object ToolViewBuilder {
if (pStatus.isNotEmpty()) parts += " Status: $pStatus"
if (running != null) parts += " Running: $running"
parts.joinToString("\n")
}
.joinToString("\n\n")
}.joinToString("\n\n")
}
val output = firstString(result, listOf("output"))
@@ -1782,7 +1917,10 @@ object ToolViewBuilder {
val degraded = (result?.get("degraded") as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true
return when {
error.isNotEmpty() -> "❌ $error"
error.isNotEmpty() -> {
"❌ $error"
}
answer.isNotEmpty() -> {
val lines = mutableListOf(answer)
if (citations != null && citations.isNotEmpty()) {
@@ -1798,7 +1936,10 @@ object ToolViewBuilder {
if (degraded) lines += "\n⚠️ No citations — answer based on model's knowledge"
lines.joinToString("\n")
}
else -> "No results"
else -> {
"No results"
}
}
}
@@ -1845,8 +1986,7 @@ object ToolViewBuilder {
.mapNotNull { el ->
val e = parseMaybeObject(el) ?: return@mapNotNull null
if (firstString(e, listOf("type")) == "text") firstString(e, listOf("text")) else null
}
.joinToString("\n")
}.joinToString("\n")
} else {
""
}
@@ -1866,12 +2006,13 @@ object ToolViewBuilder {
val body =
buildString {
val textPart =
multimodalText.ifEmpty {
firstString(
result,
listOf("text_summary"),
)
}.ifEmpty { textSummary }
multimodalText
.ifEmpty {
firstString(
result,
listOf("text_summary"),
)
}.ifEmpty { textSummary }
if (textPart.isNotEmpty()) {
append(textPart)
if (textPart == multimodalText && textSummary.isNotEmpty() && multimodalText != textSummary) {
@@ -107,21 +107,25 @@ fun ActionProgressDialog(
Text(
text =
when (state.phase) {
ActionProgressPhase.STARTING ->
ActionProgressPhase.STARTING -> {
stringResource(R.string.action_progress_starting)
}
ActionProgressPhase.RUNNING ->
ActionProgressPhase.RUNNING -> {
stringResource(R.string.action_progress_running)
}
ActionProgressPhase.SUCCEEDED ->
ActionProgressPhase.SUCCEEDED -> {
stringResource(R.string.action_progress_succeeded)
}
ActionProgressPhase.FAILED ->
ActionProgressPhase.FAILED -> {
if (exitCode != null) {
stringResource(R.string.action_progress_failed_exit, exitCode)
} else {
stringResource(R.string.action_progress_failed)
}
}
},
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
@@ -87,7 +87,8 @@ fun BotAvatar(
if (imageUrl != null) {
AsyncImage(
model =
ImageRequest.Builder(LocalContext.current)
ImageRequest
.Builder(LocalContext.current)
.data(imageUrl)
.crossfade(true)
.build(),
@@ -137,21 +138,20 @@ fun BotAvatar(
fun resolveAvatarShape(
shapeKey: String?,
size: Dp,
): Shape {
return when (shapeKey?.lowercase()?.trim()) {
): Shape =
when (shapeKey?.lowercase()?.trim()) {
"square", "box", "boxy" -> RoundedCornerShape(size * 0.15f)
"rounded", "nub", "organic" -> RoundedCornerShape(size * 0.32f)
"hexagon", "cut", "diamond", "cloud", "sun" -> CutCornerShape(size * 0.25f)
"circle", "round" -> CircleShape
else -> CircleShape
}
}
/**
* Maps known icon keys to Material [ImageVector] icons.
*/
fun resolveAvatarIcon(iconKey: String?): ImageVector? {
return when (iconKey?.lowercase()?.trim()) {
fun resolveAvatarIcon(iconKey: String?): ImageVector? =
when (iconKey?.lowercase()?.trim()) {
"code", "dev", "terminal" -> Icons.Filled.Code
"build", "tool", "tools" -> Icons.Filled.Build
"psychology", "brain", "ai", "research", "intel" -> Icons.Filled.Psychology
@@ -162,7 +162,6 @@ fun resolveAvatarIcon(iconKey: String?): ImageVector? {
"robot", "bot", "smart_toy" -> Icons.Filled.SmartToy
else -> null
}
}
/**
* 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).
*/
val NeurologyIcon: ImageVector by lazy {
ImageVector.Builder(
name = "Neurology",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f,
).apply {
addGroup(
name = "translate",
rotate = 0f,
pivotX = 0f,
pivotY = 0f,
scaleX = 1f,
scaleY = 1f,
translationX = 0f,
translationY = 960f,
clipPathData = emptyList(),
)
addPath(
pathData = addPathNodes(PATH),
pathFillType = PathFillType.NonZero,
name = "NeurologyPath",
fill = SolidColor(IconDefaultFill),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Miter,
strokeLineMiter = 4f,
trimPathStart = 0f,
trimPathEnd = 1f,
trimPathOffset = 0f,
)
}.build()
ImageVector
.Builder(
name = "Neurology",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f,
).apply {
addGroup(
name = "translate",
rotate = 0f,
pivotX = 0f,
pivotY = 0f,
scaleX = 1f,
scaleY = 1f,
translationX = 0f,
translationY = 960f,
clipPathData = emptyList(),
)
addPath(
pathData = addPathNodes(PATH),
pathFillType = PathFillType.NonZero,
name = "NeurologyPath",
fill = SolidColor(IconDefaultFill),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Miter,
strokeLineMiter = 4f,
trimPathStart = 0f,
trimPathEnd = 1f,
trimPathOffset = 0f,
)
}.build()
}
private const val PATH =
@@ -82,22 +82,27 @@ fun PressureBanner(
val message =
when (trigger) {
PressureTrigger.DISK_CRITICAL ->
PressureTrigger.DISK_CRITICAL -> {
stringResource(R.string.pressure_banner_disk_critical) +
diskFreeSuffix(disk)
}
PressureTrigger.DISK_ELEVATED ->
PressureTrigger.DISK_ELEVATED -> {
stringResource(R.string.pressure_banner_disk_elevated) +
diskFreeSuffix(disk)
}
PressureTrigger.MEMORY_CRITICAL ->
PressureTrigger.MEMORY_CRITICAL -> {
stringResource(R.string.pressure_banner_memory_critical)
}
PressureTrigger.OOM_RESTART ->
PressureTrigger.OOM_RESTART -> {
stringResource(R.string.pressure_banner_memory_oom)
}
PressureTrigger.MEMORY_ELEVATED ->
PressureTrigger.MEMORY_ELEVATED -> {
stringResource(R.string.pressure_banner_memory_elevated)
}
}
Surface(
@@ -18,7 +18,11 @@ data class ConfigRow(
) {
/** Human label: last path segment, underscores → spaces. */
val label: String =
key.split(".").last().replace("_", " ").replaceFirstChar { it.uppercase() }
key
.split(".")
.last()
.replace("_", " ")
.replaceFirstChar { it.uppercase() }
/** Search/display text of the current value. */
val valueText: String = value?.let(::jsonText) ?: ""
@@ -578,7 +578,10 @@ private fun ConfigFieldCard(
val field = row.field
val description =
field?.description
?: row.key.replace(".", " → ").replace("_", " ").replaceFirstChar { it.uppercase() }
?: row.key
.replace(".", " → ")
.replace("_", " ")
.replaceFirstChar { it.uppercase() }
Card(
modifier =
@@ -552,19 +552,32 @@ class CronJobsViewModel :
): CronJobEditorState =
when (name) {
"name" -> copy(name = value)
"schedule" -> copy(schedule = value)
"prompt" -> copy(prompt = value)
"deliver" -> copy(deliver = value)
"skills" -> copy(skills = value)
"model" -> copy(model = value)
"provider" -> copy(provider = value)
"base_url" -> copy(base_url = value)
"script" -> copy(script = value)
"workdir" -> copy(workdir = value)
"run_continuity" -> copy(runContinuity = value.toBooleanStrictOrNull() ?: false)
// 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_url" -> copy(monitor_url = value, monitor_script = if (value.isNotBlank()) "" else monitor_script)
else -> this
}
@@ -618,8 +631,8 @@ class CronJobsViewModel :
}
/** Monitor fields configured in the editor (at most one is non-blank). */
private fun editorMonitorUpdates(editor: CronJobEditorState): Map<String, String> {
return buildMap {
private fun editorMonitorUpdates(editor: CronJobEditorState): Map<String, String> =
buildMap {
if (editor.monitor_script.isNotBlank()) {
put("monitor_script", editor.monitor_script.trim())
}
@@ -627,5 +640,4 @@ class CronJobsViewModel :
put("monitor_url", editor.monitor_url.trim())
}
}
}
}
@@ -55,17 +55,28 @@ enum class KanbanTaskAction(
/** Desktop parity: which actions are valid from a given status. */
fun kanbanActionsForStatus(status: String): List<KanbanTaskAction> =
when (status) {
"triage" -> listOf(KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
"todo" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
"scheduled" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
"ready" ->
"triage" -> {
listOf(KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
}
"todo" -> {
listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
}
"scheduled" -> {
listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
}
"ready" -> {
listOf(
KanbanTaskAction.TRIAGE,
KanbanTaskAction.BLOCK,
KanbanTaskAction.COMPLETE,
KanbanTaskAction.ARCHIVE,
)
"running" ->
}
"running" -> {
listOf(
KanbanTaskAction.TRIAGE,
KanbanTaskAction.READY,
@@ -73,22 +84,38 @@ fun kanbanActionsForStatus(status: String): List<KanbanTaskAction> =
KanbanTaskAction.COMPLETE,
KanbanTaskAction.ARCHIVE,
)
"blocked" ->
}
"blocked" -> {
listOf(
KanbanTaskAction.TRIAGE,
KanbanTaskAction.UNBLOCK,
KanbanTaskAction.COMPLETE,
KanbanTaskAction.ARCHIVE,
)
"review" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
"done" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
"archived" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY)
else -> emptyList()
}
"review" -> {
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(
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
) : ViewModel(), ToastHost {
) : ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(KanbanUiState())
val uiState: StateFlow<KanbanUiState> = _uiState.asStateFlow()
@@ -0,0 +1,287 @@
package com.m57.hermescontrol.ui.mcp
import com.m57.hermescontrol.data.model.AddMcpServerRequest
import com.m57.hermescontrol.data.model.McpServerTestResponse
import com.m57.hermescontrol.data.model.McpServerToolInfo
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.util.Locale
import kotlin.math.ceil
/**
* Client-side parser for MCP server JSON configs (Claude Desktop / Cursor formats, single server JSON).
* Issue #1029.
*/
object McpJsonParser {
private val json =
Json {
ignoreUnknownKeys = true
isLenient = true
}
/**
* Parses pasted JSON text into a list of [AddMcpServerRequest] models.
* Returns an empty list if no valid server entries are found or if parsing fails.
*/
fun parse(text: String): List<AddMcpServerRequest> {
val trimmed = text.trim()
if (trimmed.isEmpty()) return emptyList()
val rootElement: JsonElement =
try {
json.parseToJsonElement(trimmed)
} catch (_: Exception) {
return emptyList()
}
if (rootElement !is JsonObject) return emptyList()
// 1. Check for wrapped "mcpServers" or "mcp_servers" container
val wrapper = rootElement["mcpServers"] ?: rootElement["mcp_servers"]
if (wrapper is JsonObject) {
return parseServerMap(wrapper)
}
// 2. Check if the root object itself is a single server definition (has "command" or "url" directly)
if (isServerObject(rootElement)) {
val name =
rootElement["name"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
?: inferName(rootElement)
val req = parseSingleServer(name, rootElement)
return if (req != null) listOf(req) else emptyList()
}
// 3. Check if the root object is a dictionary of name -> serverObject
val mapEntries = parseServerMap(rootElement)
if (mapEntries.isNotEmpty()) {
return mapEntries
}
return emptyList()
}
private fun parseServerMap(obj: JsonObject): List<AddMcpServerRequest> {
val results = mutableListOf<AddMcpServerRequest>()
for ((name, element) in obj) {
if (element is JsonObject && isServerObject(element)) {
val req = parseSingleServer(name, element)
if (req != null) {
results.add(req)
}
}
}
return results
}
private fun isServerObject(obj: JsonObject): Boolean =
obj.containsKey("command") || obj.containsKey("url") || obj.containsKey("transport")
private fun parseSingleServer(
name: String,
obj: JsonObject,
): AddMcpServerRequest? {
val cleanName = name.trim()
if (cleanName.isEmpty()) return null
val url =
obj["url"]
?.jsonPrimitive
?.contentOrNull
?.trim()
?.takeIf { it.isNotEmpty() }
val command =
obj["command"]
?.jsonPrimitive
?.contentOrNull
?.trim()
?.takeIf { it.isNotEmpty() }
if (url == null && command == null) return null
val args: List<String>? =
when (val argsElement = obj["args"]) {
is JsonArray -> {
argsElement.mapNotNull {
(it as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull
}
}
else -> {
null
}
}
val env: Map<String, String>? =
when (val envElement = obj["env"]) {
is JsonObject -> {
envElement.entries
.mapNotNull { (k, v) ->
(v as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull?.let { k to it }
}.toMap()
.takeIf { it.isNotEmpty() }
}
else -> {
null
}
}
// Auth detection from "auth" or "headers"
var auth = (obj["auth"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull?.lowercase()
var bearerToken = (obj["bearerToken"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull
val headers = obj["headers"]
if (headers is JsonObject) {
val authHeader =
(headers["Authorization"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull
?: (headers["authorization"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull
if (authHeader != null) {
auth = "header"
if (authHeader.startsWith("Bearer ", ignoreCase = true)) {
bearerToken = authHeader.substring(7).trim()
} else {
bearerToken = authHeader.trim()
}
}
}
val finalAuth =
when (auth) {
"oauth" -> "oauth"
"header" -> "header"
else -> "none"
}
return AddMcpServerRequest(
name = cleanName,
url = if (command == null) url else null,
command = command,
args = args,
env = env,
auth = finalAuth,
bearerToken = if (finalAuth == "header") bearerToken else null,
)
}
private fun inferName(obj: JsonObject): String {
val command = (obj["command"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull?.trim()
val args = obj["args"]
if (!command.isNullOrEmpty()) {
if (args is JsonArray && args.isNotEmpty()) {
val firstArg = (args[0] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull
if (firstArg != null && !firstArg.startsWith("-")) {
return sanitizeName(firstArg.split("/").last())
}
}
return sanitizeName(command.split("/").last())
}
val url = (obj["url"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull?.trim()
if (!url.isNullOrEmpty()) {
return try {
val host = java.net.URI(url).host ?: "server"
host.split(".").firstOrNull { it != "api" && it != "www" && it != "mcp" } ?: "server"
} catch (_: Exception) {
"server"
}
}
return "server"
}
private fun sanitizeName(raw: String): String =
raw
.replace(Regex("""\.(cjs|js|mjs|py|ts)$""", RegexOption.IGNORE_CASE), "")
.replace(Regex("""^(mcp-server-|server-|mcp-)"""), "")
.replace(Regex("""(-mcp-server|-mcp|-server)$"""), "")
.ifEmpty { "server" }
}
/**
* Token overhead estimator for MCP server tool schemas (issue #1029).
*/
object McpTokenEstimator {
/**
* Approximate token overhead for a server's tool schemas.
* Sum of ceil(schema_chars / 4.0) over tools carrying valid schema_chars.
*/
fun estimateTokens(tools: List<McpServerToolInfo>): Int? {
var total = 0
var sawValid = false
for (tool in tools) {
val chars = tool.schemaChars
if (chars != null && chars > 0) {
total += ceil(chars / 4.0).toInt()
sawValid = true
}
}
return if (sawValid) total else null
}
fun formatTokenOverhead(
toolCount: Int,
tokenEstimate: Int?,
): String {
if (tokenEstimate == null) {
return if (toolCount == 1) "1 tool" else "$toolCount tools"
}
val tokenFormatted =
if (tokenEstimate >= 1000) {
String.format(Locale.US, "%.1fk", tokenEstimate / 1000.0)
} else {
tokenEstimate.toString()
}
val toolStr = if (toolCount == 1) "1 tool" else "$toolCount tools"
return "$toolStr • ~$tokenFormatted tokens"
}
}
/**
* Health status for visual color-coded badges (issue #1029).
*/
enum class McpHealthStatus {
HEALTHY,
AUTH_REQUIRED,
ERROR,
TESTING,
UNKNOWN,
;
companion object {
fun resolve(
isTesting: Boolean,
testResult: McpServerTestResponse?,
serverStatus: String?,
serverError: String?,
): McpHealthStatus {
if (isTesting) return TESTING
if (testResult != null) {
if (testResult.ok) return HEALTHY
val err = (testResult.error ?: "").lowercase()
if (err.contains("oauth") || err.contains("auth") || err.contains("token")) {
return AUTH_REQUIRED
}
return ERROR
}
if (!serverError.isNullOrBlank()) {
val err = serverError.lowercase()
if (err.contains("oauth") || err.contains("auth") || err.contains("token")) {
return AUTH_REQUIRED
}
return ERROR
}
if (serverStatus != null) {
return when (serverStatus.lowercase()) {
"running", "ok", "connected", "healthy" -> HEALTHY
"error", "failed" -> ERROR
else -> UNKNOWN
}
}
return UNKNOWN
}
}
}
@@ -22,11 +22,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Science
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@@ -41,6 +45,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
@@ -54,8 +59,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
@@ -90,6 +97,7 @@ fun McpServersScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
val spacing = LocalSpacing.current
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
var query by remember { mutableStateOf("") }
var showDetail by remember { mutableStateOf<McpServer?>(null) }
@@ -123,6 +131,32 @@ fun McpServersScreen(
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadServers() },
actions = {
IconButton(
onClick = { viewModel.testAllServers() },
enabled = !state.isTestingAll && state.servers.any { it.enabled },
) {
if (state.isTestingAll) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
} else {
Icon(
imageVector = Icons.Filled.Science,
contentDescription = stringResource(R.string.mcp_servers_action_test_all),
)
}
}
IconButton(
onClick = { viewModel.toggleImportDialog() },
) {
Icon(
imageVector = Icons.Filled.UploadFile,
contentDescription = stringResource(R.string.mcp_servers_action_import_json),
)
}
},
) { paddingValues ->
when {
state.isLoading && state.servers.isEmpty() -> {
@@ -272,6 +306,95 @@ fun McpServersScreen(
},
)
}
if (state.showImportDialog) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { viewModel.toggleImportDialog() },
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.UploadFile,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(spacing.sm))
Text(stringResource(R.string.mcp_servers_import_dialog_title))
}
},
text = {
Column {
Text(
text = stringResource(R.string.mcp_servers_import_dialog_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(spacing.sm))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
OutlinedButton(
onClick = {
val clip = clipboardManager.getText()?.text
if (!clip.isNullOrBlank()) {
viewModel.updateImportJsonInput(clip)
}
},
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp),
) {
Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(modifier = Modifier.width(spacing.xs))
Text(
text = stringResource(R.string.mcp_servers_import_dialog_paste),
style = MaterialTheme.typography.labelSmall,
)
}
}
Spacer(modifier = Modifier.height(spacing.xs))
OutlinedTextField(
value = state.importJsonInput,
onValueChange = viewModel::updateImportJsonInput,
placeholder = {
Text(
"{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}",
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
)
},
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
modifier =
Modifier
.fillMaxWidth()
.height(180.dp),
maxLines = 10,
)
}
},
confirmButton = {
Button(
onClick = { viewModel.submitImportJson() },
enabled = !state.isImportingJson && state.importJsonInput.isNotBlank(),
) {
if (state.isImportingJson) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(modifier = Modifier.width(spacing.xs))
Text(stringResource(R.string.mcp_servers_import_dialog_importing))
} else {
Text(stringResource(R.string.mcp_servers_action_submit))
}
}
},
dismissButton = {
OutlinedButton(onClick = { viewModel.toggleImportDialog() }) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
}
// ── Section header (MCP-specific: distinct icon + neutral title so it reads
@@ -325,15 +448,29 @@ private fun AddServerSection(
icon = Icons.Filled.Add,
title = stringResource(R.string.mcp_servers_add_server),
trailing = {
TextButton(
onClick = { viewModel.toggleAddForm() },
colors =
ButtonDefaults.textButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
) {
Text(if (state.showAddForm) "Hide" else "New")
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
TextButton(
onClick = { viewModel.toggleImportDialog() },
colors =
ButtonDefaults.textButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(modifier = Modifier.width(spacing.xs))
Text(stringResource(R.string.mcp_servers_action_import_json))
}
TextButton(
onClick = { viewModel.toggleAddForm() },
colors =
ButtonDefaults.textButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
) {
Text(if (state.showAddForm) "Hide" else "New")
}
}
},
)
@@ -463,6 +600,9 @@ private fun ServerCard(
onOpenBrowser: (String) -> Unit,
) {
var showEnv by remember { mutableStateOf(false) }
val isTesting = server.name in state.testingServers
val testResult = state.serverTestResults[server.name]
val healthStatus = McpHealthStatus.resolve(isTesting, testResult, server.status, server.error)
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
@@ -491,13 +631,77 @@ private fun ServerCard(
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.width(spacing.sm))
ServerStatusBadge(server.status)
when (healthStatus) {
McpHealthStatus.HEALTHY -> {
StatusBadge(
text = stringResource(R.string.mcp_servers_status_healthy),
status = StatusBadgeType.SUCCESS,
)
}
McpHealthStatus.AUTH_REQUIRED -> {
StatusBadge(
text = stringResource(R.string.mcp_servers_status_needs_auth),
status = StatusBadgeType.WARNING,
)
}
McpHealthStatus.ERROR -> {
StatusBadge(
text = stringResource(R.string.mcp_servers_status_error),
status = StatusBadgeType.ERROR,
)
}
McpHealthStatus.TESTING -> {
StatusBadge(
text = stringResource(R.string.mcp_servers_status_testing),
status = StatusBadgeType.INFO,
)
}
McpHealthStatus.UNKNOWN -> {
ServerStatusBadge(server.status)
}
}
}
Text(
text = stringResource(R.string.mcp_servers_label_transport, server.transport ?: "stdio"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Tool count & schema token overhead badge (issue #1029)
val toolInfos = testResult?.tools
val toolCount = toolInfos?.size ?: server.tools?.size
if (toolCount != null && toolCount > 0) {
val tokenEst = toolInfos?.let { McpTokenEstimator.estimateTokens(it) }
val label = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst)
Spacer(modifier = Modifier.height(spacing.xs))
Surface(
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
shape = RoundedCornerShape(6.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = spacing.xs + 2.dp, vertical = 2.dp),
) {
Icon(
imageVector = Icons.Filled.Build,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
Spacer(modifier = Modifier.width(spacing.xs))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
fontWeight = FontWeight.Medium,
)
}
}
}
}
Switch(
checked = server.enabled,
@@ -556,12 +760,22 @@ private fun ServerCard(
}
FilledTonalButton(
onClick = { viewModel.testServer(server.name) },
enabled = !isTesting,
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
) {
Icon(Icons.Filled.Science, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(spacing.xs))
Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1)
if (isTesting) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(spacing.xs))
Text(stringResource(R.string.mcp_servers_status_testing), maxLines = 1)
} else {
Icon(Icons.Filled.Science, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(spacing.xs))
Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1)
}
}
IconButton(onClick = { viewModel.deleteServer(server.name) }) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
@@ -7,6 +7,7 @@ import com.m57.hermescontrol.data.model.McpCatalogEntry
import com.m57.hermescontrol.data.model.McpCatalogInstallRequest
import com.m57.hermescontrol.data.model.McpOAuthFlowResponse
import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.data.model.McpServerTestResponse
import com.m57.hermescontrol.data.model.McpServerToggleRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
@@ -14,6 +15,8 @@ import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -38,6 +41,14 @@ data class McpServersUiState(
val addServerAuth: String = "none", // "none" | "header" | "oauth"
val addServerBearerToken: String = "",
val addingServer: Boolean = false,
// JSON Import (issue #1029)
val showImportDialog: Boolean = false,
val importJsonInput: String = "",
val isImportingJson: Boolean = false,
// Batch health & tool test results (issue #1029)
val serverTestResults: Map<String, McpServerTestResponse> = emptyMap(),
val testingServers: Set<String> = emptySet(),
val isTestingAll: Boolean = false,
// Env vars for editing
val editingEnvFor: String? = null,
val envKeyInput: String = "",
@@ -69,6 +80,8 @@ class McpServersViewModel :
it.copy(
isLoading = false,
servers = data.servers.orEmpty(),
serverTestResults = emptyMap(),
testingServers = emptySet(),
)
}
},
@@ -126,23 +139,105 @@ class McpServersViewModel :
fun testServer(name: String) {
viewModelScope.launch {
_uiState.update { it.copy(toastMessage = "Testing server '$name'…") }
_uiState.update {
it.copy(
testingServers = it.testingServers + name,
toastMessage = "Testing server '$name'…",
)
}
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.testMcpServer(name) }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Server '$name' tested — OK") }
val response = result.data
val toolCount = response.tools.size
val tokenEst = McpTokenEstimator.estimateTokens(response.tools)
val tokenStr = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst)
_uiState.update { state ->
state.copy(
serverTestResults = state.serverTestResults + (name to response),
testingServers = state.testingServers - name,
toastMessage =
if (response.ok) {
"Server '$name' tested — OK ($tokenStr)"
} else {
"Server '$name' test failed: ${response.error ?: "unknown error"}"
},
)
}
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Server '$name' test failed: ${result.error.message}") }
val errResp = McpServerTestResponse(ok = false, error = result.error.message)
_uiState.update { state ->
state.copy(
serverTestResults = state.serverTestResults + (name to errResp),
testingServers = state.testingServers - name,
toastMessage = "Server '$name' test failed: ${result.error.message}",
)
}
}
}
}
}
fun testAllServers() {
val enabledServers = _uiState.value.servers.filter { it.enabled }
if (enabledServers.isEmpty()) {
_uiState.update { it.copy(toastMessage = "No enabled servers to test") }
return
}
viewModelScope.launch {
val names = enabledServers.map { it.name }.toSet()
_uiState.update {
it.copy(
isTestingAll = true,
testingServers = it.testingServers + names,
toastMessage = "Testing ${enabledServers.size} server(s)…",
)
}
val testJobs =
enabledServers.map { server ->
async(Dispatchers.IO) {
val res = safeApiCall { ApiClient.hermesApi.testMcpServer(server.name) }
server.name to res
}
}
val results = testJobs.awaitAll()
val newResults = mutableMapOf<String, McpServerTestResponse>()
var passCount = 0
for ((name, result) in results) {
when (result) {
is NetworkResult.Success -> {
newResults[name] = result.data
if (result.data.ok) passCount++
}
is NetworkResult.Failure -> {
newResults[name] = McpServerTestResponse(ok = false, error = result.error.message)
}
}
}
val failCount = enabledServers.size - passCount
_uiState.update { state ->
state.copy(
isTestingAll = false,
serverTestResults = state.serverTestResults + newResults,
testingServers = state.testingServers - names,
toastMessage =
"Tested ${enabledServers.size} servers: $passCount passed, $failCount failed",
)
}
}
}
fun deleteServer(name: String) {
viewModelScope.launch {
val result =
@@ -162,6 +257,64 @@ class McpServersViewModel :
}
}
// ── JSON Import (issue #1029) ────────────────────────────
fun toggleImportDialog() {
_uiState.update { it.copy(showImportDialog = !it.showImportDialog, importJsonInput = "") }
}
fun updateImportJsonInput(v: String) {
_uiState.update { it.copy(importJsonInput = v) }
}
fun submitImportJson() {
val input = _uiState.value.importJsonInput
val requests = McpJsonParser.parse(input)
if (requests.isEmpty()) {
_uiState.update { it.copy(toastMessage = "Invalid JSON or no MCP servers found in input") }
return
}
_uiState.update { it.copy(isImportingJson = true) }
viewModelScope.launch {
val importJobs =
requests.map { req ->
async(Dispatchers.IO) {
val result = safeApiCall { ApiClient.hermesApi.addMcpServer(req) }
req to result
}
}
val results = importJobs.awaitAll()
var successCount = 0
val errors = mutableListOf<String>()
for ((req, result) in results) {
when (result) {
is NetworkResult.Success -> successCount++
is NetworkResult.Failure -> errors.add("${req.name}: ${result.error.message}")
}
}
_uiState.update { state ->
val errSummary = errors.joinToString("; ")
val msg =
when {
errors.isEmpty() -> "Successfully imported $successCount server(s)"
successCount > 0 -> "Imported $successCount server(s), ${errors.size} failed: $errSummary"
else -> "Failed to import: $errSummary"
}
state.copy(
isImportingJson = false,
showImportDialog = false,
importJsonInput = "",
toastMessage = msg,
)
}
loadServers()
}
}
// ── Add server form ──────────────────────────────────────
fun toggleAddForm() {
@@ -456,6 +609,7 @@ class McpServersViewModel :
}
}
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to start OAuth: ${result.error.message}") }
}
@@ -489,6 +643,7 @@ class McpServersViewModel :
}
loadServers()
}
"error" -> {
polling = false
_uiState.update {
@@ -498,15 +653,18 @@ class McpServersViewModel :
)
}
}
"authorization_required" -> {
// Keep polling
}
else -> {
// Check if worker is done or expired
// Continue polling until status transitions
}
}
}
is NetworkResult.Failure -> {
// Keep polling or stop after too many failures? Let's just log/toast n retry a few times
}
@@ -3,10 +3,12 @@ package com.m57.hermescontrol.ui.memory
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.LearningGraphResponse
import com.m57.hermescontrol.data.model.MemoryResetRequest
import com.m57.hermescontrol.data.model.MemoryResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -30,25 +32,45 @@ data class MemoryUiState(
* Retrofit suspend calls already run off the caller thread, keeping tests
* free of the static Dispatchers mock that bleeds across test classes.
*/
class MemoryViewModel : ViewModel(), ToastHost {
class MemoryViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(MemoryUiState())
val uiState: StateFlow<MemoryUiState> = _uiState.asStateFlow()
fun load() {
init {
viewModelScope.launch {
ProfileSwitchCoordinator.switched.collect {
load(silent = true)
}
}
viewModelScope.launch {
ProfileSwitchCoordinator.connectionSwitched.collect {
load(silent = true)
}
}
}
fun load(silent: Boolean = false) {
if (_uiState.value.isLoading) return
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
if (!silent) {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
}
viewModelScope.launch {
val result = safeApiCall { ApiClient.hermesApi.getMemory() }
when (result) {
is NetworkResult.Success ->
is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, memory = result.data) }
is NetworkResult.Failure ->
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = result.error.message,
)
}
}
}
loadLearningGraph()
}
@@ -67,22 +89,25 @@ class MemoryViewModel : ViewModel(), ToastHost {
if (_uiState.value.resetting != null) return
_uiState.update { it.copy(resetting = target) }
viewModelScope.launch {
val result = safeApiCall { ApiClient.hermesApi.resetMemory(mapOf("target" to target)) }
val result = safeApiCall { ApiClient.hermesApi.resetMemory(MemoryResetRequest(target = target)) }
when (result) {
is NetworkResult.Success ->
is NetworkResult.Success -> {
_uiState.update {
it.copy(
resetting = null,
toastMessage = "Memory ($target) reset successfully",
)
}
is NetworkResult.Failure ->
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
resetting = null,
toastMessage = "Failed to reset memory: ${result.error.message}",
)
}
}
}
load()
}
@@ -328,10 +328,9 @@ private fun ApprovedCard(
}
@Composable
private fun formatAge(ageMinutes: Int): String {
return if (ageMinutes < 60) {
private fun formatAge(ageMinutes: Int): String =
if (ageMinutes < 60) {
stringResource(R.string.pairing_label_request_age_m, ageMinutes)
} else {
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))
when (field.kind) {
"select" ->
"select" -> {
ExposedDropdownField(
label = field.label.ifEmpty { field.key },
options = field.options.map { it.value }.ifEmpty { listOf(value) },
selectedValue = value,
onOptionSelected = onChange,
)
}
"bool" ->
"bool" -> {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -365,8 +366,9 @@ private fun ProviderFieldRow(
style = MaterialTheme.typography.bodyMedium,
)
}
}
else ->
else -> {
OutlinedTextField(
value = value,
onValueChange = onChange,
@@ -400,6 +402,7 @@ private fun ProviderFieldRow(
KeyboardOptions.Default
},
)
}
}
}
}
@@ -47,7 +47,9 @@ data class MemoryProviderDetailUiState(
* [setProvider] + [load] per entry instead (same bug/fix as #782's
* ToolsetDetailViewModel).
*/
class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
class MemoryProviderDetailViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(MemoryProviderDetailUiState())
val uiState: StateFlow<MemoryProviderDetailUiState> = _uiState.asStateFlow()
@@ -74,13 +76,17 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
val statusResult = safeApiCall { ApiClient.hermesApi.getMemory() }
val status =
when (statusResult) {
is NetworkResult.Success ->
is NetworkResult.Success -> {
statusResult.data.providers.firstOrNull { it.name == name }
else -> null
}
else -> {
null
}
}
val configResult = safeApiCall { ApiClient.hermesApi.getMemoryProviderConfig(name) }
when (configResult) {
is NetworkResult.Success ->
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isLoading = false,
@@ -90,7 +96,9 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
edits = seedEdits(configResult.data),
)
}
is NetworkResult.Failure ->
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
@@ -99,6 +107,7 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
errorMessage = configResult.error.message,
)
}
}
}
}
}
@@ -143,7 +152,7 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
)
}
when (result) {
is NetworkResult.Success ->
is NetworkResult.Success -> {
_uiState.update {
it.copy(
saving = false,
@@ -154,13 +163,16 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
toastMessage = "Memory provider settings saved",
)
}
is NetworkResult.Failure ->
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
saving = false,
toastMessage = "Failed to save settings: ${result.error.message}",
)
}
}
}
refreshStatus(name)
}
@@ -180,7 +192,7 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
ApiClient.hermesApi.setupMemoryProvider(name, MemoryProviderSetupRequest())
}
when (result) {
is NetworkResult.Success ->
is NetworkResult.Success -> {
_uiState.update {
it.copy(
runningSetup = false,
@@ -193,13 +205,16 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
},
)
}
is NetworkResult.Failure ->
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
runningSetup = false,
toastMessage = "Setup failed: ${result.error.message}",
)
}
}
}
refreshStatus(name)
}
@@ -21,6 +21,8 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@@ -49,6 +51,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
@@ -131,6 +134,29 @@ fun ProfilesScreen(
},
actions = {
if (!isBuildingProfile) {
if (state.hasHiddenProfiles) {
IconButton(
onClick = { viewModel.toggleShowHidden() },
modifier = Modifier.testTag("profiles_action_toggle_hidden"),
) {
Icon(
imageVector =
if (state.showHidden) {
Icons.Default.VisibilityOff
} else {
Icons.Default.Visibility
},
contentDescription =
stringResource(
if (state.showHidden) {
R.string.profiles_hide_hidden
} else {
R.string.profiles_show_hidden
},
),
)
}
}
IconButton(onClick = {
isBuildingProfile = true
viewModel.loadBuilderData()
@@ -173,6 +199,16 @@ fun ProfilesScreen(
)
}
state.displayProfiles.isEmpty() -> {
EmptyState(
title = stringResource(R.string.profiles_empty_title),
subtitle = stringResource(R.string.profiles_all_hidden_desc),
onAction = { viewModel.toggleShowHidden() },
actionLabel = stringResource(R.string.profiles_show_hidden),
modifier = Modifier.padding(paddingValues),
)
}
else -> {
Box(Modifier.fillMaxSize()) {
if (state.isLoading && state.profiles.isEmpty()) {
@@ -194,7 +230,7 @@ fun ProfilesScreen(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.profiles, key = { it.name }) { profile ->
items(state.displayProfiles, key = { it.name }) { profile ->
val isActive = profile.name == state.activeProfileName
Card(
modifier = Modifier.fillMaxWidth(),
@@ -225,11 +261,37 @@ fun ProfilesScreen(
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = profile.name.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = profile.name.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
val isProfileHidden =
profile.name in state.hiddenProfiles ||
profile.botMeta()?.hidden == true
if (isProfileHidden) {
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier =
Modifier
.clip(RoundedCornerShape(4.dp))
.background(
MaterialTheme.colorScheme.surfaceVariant,
).padding(horizontal = 6.dp, vertical = 2.dp),
) {
Text(
text =
stringResource(
R.string.profiles_hidden_badge,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
)
}
}
}
val descriptionText =
if (!profile.description.isNullOrBlank()) {
profile.description
@@ -376,6 +438,30 @@ fun ProfilesScreen(
setupCmdProfileName = profile.name
},
)
val isProfileHidden =
profile.name in state.hiddenProfiles ||
profile.botMeta()?.hidden == true
DropdownMenuItem(
text = {
Text(
stringResource(
if (isProfileHidden) {
R.string.profiles_action_unhide
} else {
R.string.profiles_action_hide
},
),
)
},
onClick = {
showMenu = false
if (isProfileHidden) {
viewModel.unhideProfile(profile.name)
} else {
viewModel.hideProfile(profile.name)
}
},
)
if (profile.is_default != true) {
HorizontalDivider()
DropdownMenuItem(
@@ -38,6 +38,8 @@ data class ProfilesUiState(
val isLoadingSoul: Boolean = false,
val errorMessage: String? = null,
val toastMessage: String? = null,
val showHidden: Boolean = false,
val hiddenProfiles: Set<String> = emptySet(),
// Rename / delete / auto-describe / setup-command states
val isAutoDescribing: Boolean = false,
val setupCommand: String? = null,
@@ -50,7 +52,20 @@ data class ProfilesUiState(
val availableSkills: List<Skill> = emptyList(),
val hubSearchResults: List<HubSkill> = emptyList(),
val isSearchingHub: Boolean = false,
)
) {
val hasHiddenProfiles: Boolean
get() = profiles.any { it.name in hiddenProfiles || it.botMeta()?.hidden == true }
val displayProfiles: List<ProfileInfo>
get() =
profiles.filter { profile ->
if (showHidden) {
true
} else {
!(profile.name in hiddenProfiles || profile.botMeta()?.hidden == true)
}
}
}
class ProfilesViewModel(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
@@ -59,6 +74,34 @@ class ProfilesViewModel(
private val _uiState = MutableStateFlow(ProfilesUiState())
val uiState: StateFlow<ProfilesUiState> = _uiState.asStateFlow()
init {
_uiState.update { it.copy(hiddenProfiles = AuthManager.getHiddenProfiles().toSet()) }
}
fun toggleShowHidden() {
_uiState.update { it.copy(showHidden = !it.showHidden) }
}
fun hideProfile(name: String) {
AuthManager.hideProfile(name)
_uiState.update {
it.copy(
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
toastMessage = "Profile $name hidden",
)
}
}
fun unhideProfile(name: String) {
AuthManager.unhideProfile(name)
_uiState.update {
it.copy(
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
toastMessage = "Profile $name unhidden",
)
}
}
fun loadProfiles() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
@@ -77,6 +120,7 @@ class ProfilesViewModel(
isLoading = false,
profiles = profilesResult.data.profiles.orEmpty(),
activeProfileName = activeResult.data.active,
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
)
}
} else {
@@ -46,6 +46,8 @@ import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
@@ -59,6 +61,7 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@@ -290,7 +293,7 @@ private fun displayedSessions(state: SessionsUiState): List<SessionTreeItem> =
)
}
} else {
flattenSessionTree(state.sessions)
flattenSessionTree(state.displaySessions)
}
@OptIn(ExperimentalFoundationApi::class)
@@ -313,6 +316,7 @@ fun SessionsScreen(
state.isSearchMode,
state.searchQuery,
state.sessions,
state.showHidden,
state.searchResults,
) {
displayedSessions(state)
@@ -521,6 +525,29 @@ fun SessionsScreen(
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadSessions() },
actions = {
if (state.hasHiddenSessions) {
IconButton(
onClick = { viewModel.toggleShowHidden() },
modifier = Modifier.testTag("sessions_action_toggle_hidden"),
) {
Icon(
imageVector =
if (state.showHidden) {
Icons.Filled.VisibilityOff
} else {
Icons.Filled.Visibility
},
contentDescription =
if (state.showHidden) {
stringResource(R.string.sessions_hide_hidden)
} else {
stringResource(R.string.sessions_show_hidden)
},
)
}
}
},
modifier = modifier,
) {
// Single scrolling column: search bar pinned on top, list fills below.
@@ -685,6 +712,16 @@ fun SessionsScreen(
)
}
state.displaySessions.isEmpty() -> {
EmptyState(
title = stringResource(R.string.history_empty_title),
subtitle = stringResource(R.string.sessions_all_hidden_desc),
icon = Icons.Filled.VisibilityOff,
actionLabel = stringResource(R.string.sessions_show_hidden),
onAction = { viewModel.toggleShowHidden() },
)
}
else -> {
Column(modifier = Modifier.fillMaxSize()) {
// ── Stats row ───────────────────────────────────────
@@ -767,9 +804,13 @@ fun SessionsScreen(
derivedStateOf {
val info = listState.layoutInfo
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
lastVisible >= 0 &&
info.totalItemsCount >= AUTO_LOAD_THRESHOLD &&
if (lastVisible < 0 || info.totalItemsCount == 0) {
false
} else if (info.totalItemsCount <= AUTO_LOAD_THRESHOLD) {
lastVisible >= info.totalItemsCount - 1
} else {
lastVisible >= info.totalItemsCount - AUTO_LOAD_THRESHOLD
}
}
}
LaunchedEffect(
@@ -801,6 +842,7 @@ fun SessionsScreen(
isSelected = session.id in state.selectedIds,
isDeleting = session.id in state.deletingSessionIds,
isPinned = session.pinned == true,
isHidden = session.hidden == true,
highlightBackground = primaryContainer,
highlightForeground = onPrimaryContainer,
onCardClick = {
@@ -822,6 +864,7 @@ fun SessionsScreen(
)
},
onTogglePin = { viewModel.togglePin(session.id) },
onToggleHide = { viewModel.toggleHide(session.id) },
onDelete = { viewModel.requestDeleteSession(session.id) },
)
}
@@ -969,6 +1012,7 @@ private fun SessionCard(
isSelected: Boolean,
isDeleting: Boolean,
isPinned: Boolean,
isHidden: Boolean = false,
highlightBackground: Color,
highlightForeground: Color,
onCardClick: () -> Unit,
@@ -976,6 +1020,7 @@ private fun SessionCard(
onSelect: () -> Unit,
onRename: () -> Unit,
onTogglePin: () -> Unit,
onToggleHide: () -> Unit,
onDelete: () -> Unit,
) {
val spacing = LocalSpacing.current
@@ -1095,6 +1140,13 @@ private fun SessionCard(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (session.hidden == true) {
Spacer(modifier = Modifier.width(spacing.sm))
StatusBadge(
text = stringResource(R.string.sessions_hidden_badge),
status = StatusBadgeType.NEUTRAL,
)
}
if (!session.status.isNullOrBlank()) {
Spacer(modifier = Modifier.width(spacing.sm))
StatusBadge(
@@ -1116,6 +1168,11 @@ private fun SessionCard(
menuExpanded = false
onTogglePin()
},
isHidden = isHidden,
onToggleHide = {
menuExpanded = false
onToggleHide()
},
onSelect = {
menuExpanded = false
onSelect()
@@ -1301,6 +1358,8 @@ private fun SessionActionMenu(
isDeleting: Boolean,
isPinned: Boolean = false,
onTogglePin: (() -> Unit)? = null,
isHidden: Boolean = false,
onToggleHide: (() -> Unit)? = null,
onSelect: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
@@ -1324,6 +1383,33 @@ private fun SessionActionMenu(
onClick = onTogglePin,
)
}
if (onToggleHide != null) {
DropdownMenuItem(
text = {
Text(
stringResource(
if (isHidden) {
R.string.sessions_action_unhide
} else {
R.string.sessions_action_hide
},
),
)
},
leadingIcon = {
Icon(
imageVector =
if (isHidden) {
Icons.Filled.Visibility
} else {
Icons.Filled.VisibilityOff
},
contentDescription = null,
)
},
onClick = onToggleHide,
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.sessions_action_select)) },
leadingIcon = { Icon(Icons.Filled.CheckCircle, contentDescription = null) },
@@ -89,8 +89,15 @@ data class SessionsUiState(
val isSearching: Boolean = false,
val searchResults: List<SessionSearchResult> = emptyList(),
val searchError: String? = null,
val showHidden: Boolean = false,
) {
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
val hasHiddenSessions: Boolean
get() = sessions.any { it.hidden == true }
val displaySessions: List<SessionInfo>
get() = if (showHidden) sessions else sessions.filter { it.hidden != true }
}
class SessionsViewModel :
@@ -209,11 +216,6 @@ class SessionsViewModel :
val hasMore = receivedFullPage && addedNewItems && data.total > newSessions.size
it.copy(
isLoadingMore = false,
// distinctBy guards offset-pagination churn: if a new
// session lands on top between page loads, offsets
// shift and the next page can repeat an id we already
// have — LazyColumn would crash on the duplicate key.
// pinnedFirst keeps pinned sessions above the rest.
sessions = newSessions,
total = if (hasMore) data.total else newSessions.size,
hasMore = hasMore,
@@ -315,6 +317,10 @@ class SessionsViewModel :
// ── Bulk selection ───────────────────────────────────────────────────
fun toggleShowHidden() {
_uiState.update { it.copy(showHidden = !it.showHidden) }
}
fun toggleSelecting() {
_uiState.update {
it.copy(
@@ -455,6 +461,49 @@ class SessionsViewModel :
}
}
// ── Hide / unhide (issue #1019) ──────────────────────────────────────
/**
* Toggle the hidden flag on a session via PATCH /api/sessions/{id} ({hidden}).
*/
fun toggleHide(sessionId: String) {
val session = _uiState.value.sessions.find { it.id == sessionId } ?: return
val targetHidden = session.hidden != true
viewModelScope.launch {
val result =
safeApiCall {
ApiClient.hermesApi.setSessionHidden(
sessionId = sessionId,
body = SessionRenameRequest(hidden = targetHidden),
)
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
sessions =
it.sessions
.map { s -> if (s.id == sessionId) s.copy(hidden = targetHidden) else s }
.pinnedFirst(),
toastMessage =
if (targetHidden) {
"Session hidden"
} else {
"Session unhidden"
},
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(toastMessage = "Hide failed: ${result.error.message}")
}
}
}
}
}
// ── Delete (single) ──────────────────────────────────────────────────
fun requestDeleteSession(sessionId: String) {
@@ -207,7 +207,8 @@ internal fun ChatSection(
) {
val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f)
val currentIndex =
fontScaleOptions.indexOfFirst { abs(it - chatFontScale) < 0.05f }
fontScaleOptions
.indexOfFirst { abs(it - chatFontScale) < 0.05f }
.takeIf { it >= 0 } ?: 1
SectionCard {
@@ -32,6 +32,7 @@ internal fun supportedLanguages(): List<Pair<String, String>> =
listOf(
LocaleContextWrapper.SYSTEM_LANGUAGE to stringResource(R.string.language_system),
"en" to stringResource(R.string.language_english),
"ar" to stringResource(R.string.language_arabic),
"zh" to stringResource(R.string.language_chinese),
"ko" to stringResource(R.string.language_korean),
)
@@ -884,7 +884,9 @@ private fun SkillScanSection(
}
when {
isScanning -> Unit
isScanning -> {
Unit
}
scanError != null -> {
Surface(
@@ -86,8 +86,9 @@ data class SystemUiState(
val updateConfirmOpen: Boolean = false,
)
class SystemViewModel(application: Application) :
AndroidViewModel(application),
class SystemViewModel(
application: Application,
) : AndroidViewModel(application),
ToastHost {
companion object {
private const val TAG = "SystemViewModel"
@@ -680,11 +680,23 @@ private fun PostSetupRunner(
private fun ToolsetProvider.statusTextRes(): Int =
when (status) {
"ready" -> R.string.toolset_status_ready
"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 ->
"ready" -> {
R.string.toolset_status_ready
}
"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
// env-var heuristic the desktop used before server-computed
// readiness landed.
@@ -693,16 +705,24 @@ private fun ToolsetProvider.statusTextRes(): Int =
} else {
R.string.toolset_status_needs_keys
}
}
}
private fun ToolsetProvider.statusType(): StatusBadgeType =
when (status) {
"ready" -> StatusBadgeType.SUCCESS
"needs_keys", "needs_auth", "needs_setup" -> StatusBadgeType.WARNING
else ->
"ready" -> {
StatusBadgeType.SUCCESS
}
"needs_keys", "needs_auth", "needs_setup" -> {
StatusBadgeType.WARNING
}
else -> {
if (envVars.isEmpty() || envVars.all { it.isSet }) {
StatusBadgeType.SUCCESS
} else {
StatusBadgeType.WARNING
}
}
}
@@ -59,7 +59,9 @@ data class ToolsetDetailUiState(
* first-opened toolset into every entry — bug found on-device 2026-08-06).
* The screen drives [setToolset] + [loadConfig] per entry instead.
*/
class ToolsetDetailViewModel : ViewModel(), ToastHost {
class ToolsetDetailViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(ToolsetDetailUiState())
val uiState: StateFlow<ToolsetDetailUiState> = _uiState.asStateFlow()
@@ -101,11 +103,12 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
)
}
is NetworkResult.Failure ->
is NetworkResult.Failure -> {
state.copy(
isLoading = false,
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(
selectingProvider = null,
toastMessage = "Failed to select backend: ${result.error.message}",
)
}
}
}
}
@@ -176,18 +180,20 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
}
_uiState.update { state ->
when (result) {
is NetworkResult.Success ->
is NetworkResult.Success -> {
state.copy(
savingEnvKey = null,
config = state.config?.let { patchEnvIsSet(it, key, true) },
toastMessage = "Saved $key",
)
}
is NetworkResult.Failure ->
is NetworkResult.Failure -> {
state.copy(
savingEnvKey = null,
toastMessage = "Failed to save $key: ${result.error.message}",
)
}
}
}
}
@@ -199,19 +205,21 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
val result = safeApiCall { ApiClient.hermesApi.deleteEnvVar(EnvVarDeleteRequest(key)) }
_uiState.update { state ->
when (result) {
is NetworkResult.Success ->
is NetworkResult.Success -> {
state.copy(
deletingEnvKey = null,
config = state.config?.let { patchEnvIsSet(it, key, false) },
revealedValues = state.revealedValues - key,
toastMessage = "Cleared $key",
)
}
is NetworkResult.Failure ->
is NetworkResult.Failure -> {
state.copy(
deletingEnvKey = null,
toastMessage = "Failed to clear $key: ${result.error.message}",
)
}
}
}
}
@@ -222,11 +230,13 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
val result = safeApiCall { ApiClient.hermesApi.revealEnvVar(EnvVarRevealRequest(key)) }
_uiState.update { state ->
when (result) {
is NetworkResult.Success ->
is NetworkResult.Success -> {
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}")
}
}
}
}
@@ -79,6 +79,7 @@ object LocaleContextWrapper {
@Suppress("DEPRECATION")
setLocale(locale)
}
setLayoutDirection(locale)
}
return base.createConfigurationContext(newConfig)
}
File diff suppressed because it is too large. Load diff
+44
View File
@@ -240,6 +240,14 @@
<string name="theme_preset_catppuccin">Catppuccin</string>
<string name="theme_preset_amoled">AMOLED 블랙 (다크 전용)</string>
<!-- Language -->
<string name="settings_item_language">언어</string>
<string name="language_system">시스템 기본값</string>
<string name="language_english">English</string>
<string name="language_arabic">العربية</string>
<string name="language_chinese">简体中文</string>
<string name="language_korean">한국어</string>
<!-- Connect Screen -->
<string name="connect_selected_profile">프로필: %1$s</string>
<string name="connect_action_select_profile">저장된 프로필 선택</string>
@@ -406,6 +414,12 @@
<string name="sessions_action_select_all">전체 선택</string>
<string name="sessions_action_deselect_all">선택 해제</string>
<string name="sessions_action_delete_n">%1$d개 삭제</string>
<string name="sessions_action_hide">숨기기</string>
<string name="sessions_action_unhide">숨김 해제</string>
<string name="sessions_show_hidden">숨겨진 세션 표시</string>
<string name="sessions_hide_hidden">숨겨진 세션 숨기기</string>
<string name="sessions_hidden_badge">숨김</string>
<string name="sessions_all_hidden_desc">모든 세션이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 세션을 표시하세요.</string>
<!-- Toolsets Screen -->
<string name="toolsets_empty_title">툴셋이 없습니다</string>
@@ -453,6 +467,12 @@
<string name="profiles_action_set_model">모델 설정</string>
<string name="profiles_action_clone">복제</string>
<string name="profiles_action_edit_description">설명 편집</string>
<string name="profiles_action_hide">프로필 숨기기</string>
<string name="profiles_action_unhide">프로필 숨김 해제</string>
<string name="profiles_show_hidden">숨겨진 프로필 표시</string>
<string name="profiles_hide_hidden">숨겨진 프로필 숨기기</string>
<string name="profiles_hidden_badge">숨김</string>
<string name="profiles_all_hidden_desc">모든 프로필이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 프로필을 표시하세요.</string>
<string name="profiles_content_desc_active">활성 프로필</string>
<string name="profiles_title_edit_soul">소울 프로필 편집: %1$s</string>
<string name="profiles_title_set_model">모델 설정: %1$s</string>
@@ -571,6 +591,16 @@
<string name="mcp_servers_label_transport">전송: %1$s</string>
<string name="mcp_servers_label_command">명령어:</string>
<string name="mcp_servers_action_test">테스트</string>
<string name="mcp_servers_action_test_all">모두 테스트</string>
<string name="mcp_servers_action_import_json">JSON 가져오기</string>
<string name="mcp_servers_import_dialog_title">MCP 서버 가져오기</string>
<string name="mcp_servers_import_dialog_desc">Claude Desktop / Cursor mcpServers JSON 스니펫 또는 서버 정의를 붙여넣으세요.</string>
<string name="mcp_servers_import_dialog_paste">클립보드 붙여넣기</string>
<string name="mcp_servers_import_dialog_importing">가져오는 중…</string>
<string name="mcp_servers_status_healthy">정상</string>
<string name="mcp_servers_status_needs_auth">인증 필요</string>
<string name="mcp_servers_status_error">오류</string>
<string name="mcp_servers_status_testing">테스트 중…</string>
<string name="mcp_servers_add_server">서버 추가</string>
<string name="mcp_servers_add_http">HTTP URL</string>
<string name="mcp_servers_add_stdio">Stdio 명령어</string>
@@ -834,4 +864,18 @@
<string name="group_chat_settings_system_prompt_hint">이 그룹을 위한 사용자 지정 규칙, 어조 또는 컨텍스트 (선택사항)…</string>
<string name="group_chat_settings_system_prompt_desc">이 방의 모든 봇에 적용되는 사용자 지정 가이드라인</string>
<string name="group_chat_action_settings">설정</string>
<string name="subagent_agent_plan">에이전트 계획</string>
<string name="subagent_live_transcript">실시간 기록</string>
<string name="subagent_no_active">활성 작업 또는 계획 항목이 없습니다.</string>
<string name="subagent_summary">요약: %1$s</string>
<string name="subagent_task_plan_inspection">작업 및 계획 검사</string>
<string name="subagent_action_steer">안내</string>
<string name="subagent_action_stop">중지</string>
<string name="subagent_steer_placeholder">하위 에이전트 안내 입력…</string>
<string name="subagent_status_running">실행 중</string>
<string name="subagent_status_completed">완료됨</string>
<string name="subagent_status_steered">안내됨</string>
<string name="subagent_status_cancelled">취소됨</string>
<string name="subagent_status_failed">실패</string>
<string name="subagent_duration">%1$.1f초</string>
</resources>
+32
View File
@@ -249,6 +249,7 @@
<string name="settings_item_language">语言</string>
<string name="language_system">跟随系统</string>
<string name="language_english">English</string>
<string name="language_arabic">العربية</string>
<string name="language_chinese">简体中文</string>
<string name="language_korean">한국어</string>
<string name="connect_selected_profile">配置:%1$s</string>
@@ -523,6 +524,12 @@
<string name="sessions_action_rename">重命名</string>
<string name="sessions_action_pin">置顶</string>
<string name="sessions_action_unpin">取消置顶</string>
<string name="sessions_action_hide">隐藏</string>
<string name="sessions_action_unhide">取消隐藏</string>
<string name="sessions_show_hidden">显示已隐藏的会话</string>
<string name="sessions_hide_hidden">隐藏已隐藏的会话</string>
<string name="sessions_hidden_badge">已隐藏</string>
<string name="sessions_all_hidden_desc">所有会话当前均已隐藏。点击下方以显示已隐藏的会话。</string>
<string name="sessions_pinned_indicator">已置顶</string>
<string name="sessions_fork_indicator">分支会话</string>
<string name="sessions_rename_title">重命名会话</string>
@@ -596,6 +603,12 @@
<string name="profiles_action_set_model">设置模型</string>
<string name="profiles_action_clone">克隆</string>
<string name="profiles_action_edit_description">编辑描述</string>
<string name="profiles_action_hide">隐藏配置文件</string>
<string name="profiles_action_unhide">取消隐藏配置文件</string>
<string name="profiles_show_hidden">显示隐藏的配置文件</string>
<string name="profiles_hide_hidden">隐藏已隐藏的配置文件</string>
<string name="profiles_hidden_badge">已隐藏</string>
<string name="profiles_all_hidden_desc">所有配置文件当前均已隐藏。点击下方以显示已隐藏的配置文件。</string>
<string name="profiles_content_desc_active">当前配置</string>
<string name="profiles_title_edit_soul">编辑 Soul 配置:%1$s</string>
<string name="profiles_title_set_model">设置模型:%1$s</string>
@@ -708,6 +721,16 @@
<string name="mcp_servers_label_transport">传输:%1$s</string>
<string name="mcp_servers_label_command">命令:</string>
<string name="mcp_servers_action_test">测试</string>
<string name="mcp_servers_action_test_all">测试全部</string>
<string name="mcp_servers_action_import_json">导入 JSON</string>
<string name="mcp_servers_import_dialog_title">导入 MCP 服务器</string>
<string name="mcp_servers_import_dialog_desc">粘贴 Claude Desktop / Cursor mcpServers JSON 片段或服务器定义。</string>
<string name="mcp_servers_import_dialog_paste">从剪贴板粘贴</string>
<string name="mcp_servers_import_dialog_importing">导入中…</string>
<string name="mcp_servers_status_healthy">正常</string>
<string name="mcp_servers_status_needs_auth">需要认证</string>
<string name="mcp_servers_status_error">错误</string>
<string name="mcp_servers_status_testing">测试中…</string>
<string name="mcp_servers_add_server">添加服务器</string>
<string name="mcp_servers_add_http">HTTP 地址</string>
<string name="mcp_servers_empty_title">没有 MCP 服务器</string>
@@ -1077,6 +1100,15 @@
<string name="subagent_no_active">没有活动任务或计划项。</string>
<string name="subagent_summary">摘要:%1$s</string>
<string name="subagent_task_plan_inspection">任务与计划检查</string>
<string name="subagent_action_steer">引导</string>
<string name="subagent_action_stop">停止</string>
<string name="subagent_steer_placeholder">向子代理发送调整引导…</string>
<string name="subagent_status_running">运行中</string>
<string name="subagent_status_completed">已完成</string>
<string name="subagent_status_steered">已引导</string>
<string name="subagent_status_cancelled">已取消</string>
<string name="subagent_status_failed">失败</string>
<string name="subagent_duration">%1$.1f秒</string>
<string name="system_auth_required">需要认证</string>
<string name="system_checkpoint_prune">清理检查点</string>
<string name="system_config_migrate">迁移配置</string>
+32
View File
@@ -276,6 +276,7 @@
<string name="settings_item_language">Language</string>
<string name="language_system">System</string>
<string name="language_english">English</string>
<string name="language_arabic">العربية</string>
<string name="language_chinese">简体中文</string>
<string name="language_korean">한국어</string>
@@ -599,6 +600,12 @@
<string name="sessions_action_rename">Rename</string>
<string name="sessions_action_pin">Pin</string>
<string name="sessions_action_unpin">Unpin</string>
<string name="sessions_action_hide">Hide</string>
<string name="sessions_action_unhide">Unhide</string>
<string name="sessions_show_hidden">Show hidden sessions</string>
<string name="sessions_hide_hidden">Hide hidden sessions</string>
<string name="sessions_hidden_badge">Hidden</string>
<string name="sessions_all_hidden_desc">All sessions are currently hidden. Tap below to show hidden sessions.</string>
<string name="sessions_pinned_indicator">Pinned</string>
<string name="sessions_fork_indicator">Forked session</string>
<string name="sessions_rename_title">Rename Session</string>
@@ -682,6 +689,12 @@
<string name="profiles_action_set_model">Set Model</string>
<string name="profiles_action_clone">Clone</string>
<string name="profiles_action_edit_description">Edit Description</string>
<string name="profiles_action_hide">Hide Profile</string>
<string name="profiles_action_unhide">Unhide Profile</string>
<string name="profiles_show_hidden">Show hidden profiles</string>
<string name="profiles_hide_hidden">Hide hidden profiles</string>
<string name="profiles_hidden_badge">Hidden</string>
<string name="profiles_all_hidden_desc">All profiles are currently hidden. Tap below to show hidden profiles.</string>
<string name="profiles_content_desc_active">Active Profile</string>
<string name="profiles_title_edit_soul">Edit Soul Profile: %1$s</string>
<string name="profiles_title_set_model">Set Model: %1$s</string>
@@ -800,6 +813,16 @@
<string name="mcp_servers_label_transport">Transport: %1$s</string>
<string name="mcp_servers_label_command">Command:</string>
<string name="mcp_servers_action_test">Test</string>
<string name="mcp_servers_action_test_all">Test All</string>
<string name="mcp_servers_action_import_json">Import JSON</string>
<string name="mcp_servers_import_dialog_title">Import MCP Servers</string>
<string name="mcp_servers_import_dialog_desc">Paste Claude Desktop / Cursor mcpServers JSON snippet or server definition.</string>
<string name="mcp_servers_import_dialog_paste">Paste Clipboard</string>
<string name="mcp_servers_import_dialog_importing">Importing…</string>
<string name="mcp_servers_status_healthy">Healthy</string>
<string name="mcp_servers_status_needs_auth">Needs Auth</string>
<string name="mcp_servers_status_error">Error</string>
<string name="mcp_servers_status_testing">Testing…</string>
<string name="mcp_servers_add_server">Add server</string>
<string name="mcp_servers_add_http">HTTP URL</string>
<string name="mcp_servers_empty_title">No MCP servers</string>
@@ -1206,6 +1229,15 @@
<string name="subagent_no_active">No active tasks or plan items.</string>
<string name="subagent_summary">Summary: %1$s</string>
<string name="subagent_task_plan_inspection">Task &amp; Plan Inspection</string>
<string name="subagent_action_steer">Steer</string>
<string name="subagent_action_stop">Stop</string>
<string name="subagent_steer_placeholder">Course-correction guidance for subagent…</string>
<string name="subagent_status_running">Running</string>
<string name="subagent_status_completed">Completed</string>
<string name="subagent_status_steered">Steered</string>
<string name="subagent_status_cancelled">Cancelled</string>
<string name="subagent_status_failed">Failed</string>
<string name="subagent_duration">%1$.1fs</string>
<string name="system_auth_required">Auth required</string>
<string name="system_checkpoint_prune">Checkpoint prune</string>
<string name="system_config_migrate">Config migrate</string>
@@ -312,6 +312,41 @@ class ModelSerializationTest {
assertEquals(false, s8.pinned)
}
@Test
fun testSessionInfoDeserialization_hiddenToleratesIntAndBooleanAndString() {
val s1 = json.decodeFromString<SessionInfo>("""{"id": "s1", "hidden": 0}""")
assertEquals(false, s1.hidden)
val s2 = json.decodeFromString<SessionInfo>("""{"id": "s2", "hidden": 1}""")
assertEquals(true, s2.hidden)
val s3 = json.decodeFromString<SessionInfo>("""{"id": "s3", "hidden": false}""")
assertEquals(false, s3.hidden)
val s4 = json.decodeFromString<SessionInfo>("""{"id": "s4", "hidden": true}""")
assertEquals(true, s4.hidden)
val s5 = json.decodeFromString<SessionInfo>("""{"id": "s5", "hidden": null}""")
assertNull(s5.hidden)
val s6 = json.decodeFromString<SessionInfo>("""{"id": "s6"}""")
assertNull(s6.hidden)
val s7 = json.decodeFromString<SessionInfo>("""{"id": "s7", "hidden": "1"}""")
assertEquals(true, s7.hidden)
val s8 = json.decodeFromString<SessionInfo>("""{"id": "s8", "hidden": "false"}""")
assertEquals(false, s8.hidden)
}
@Test
fun testSessionRenameRequestSerialization_includesHidden() {
val req = SessionRenameRequest(title = "new name", hidden = true)
val encoded = json.encodeToString(SessionRenameRequest.serializer(), req)
assertTrue(encoded.contains("\"hidden\":true"))
assertTrue(encoded.contains("\"title\":\"new name\""))
}
@Test
fun testSessionMessagesResponseDeserialization_missingMessages_causesNullOnNonNullableField() {
val jsonStr = "{}"
@@ -118,6 +118,19 @@ class ProfileScopeInterceptorTest {
assertEquals("alpha", url.queryParameter("profile"))
}
@Test
fun memoryEndpoints_areScoped() {
val client = clientFor("yasmin")
for (path in listOf("api/memory", "api/memory/learning-graph", "api/memory/providers/mem0/config")) {
server.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
val req = Request.Builder().url(server.url(path)).build()
client.newCall(req).execute().close()
val url = server.takeRequest().requestUrl!!
assertEquals("yasmin", url.queryParameter("profile"))
assertEquals("/$path", url.encodedPath)
}
}
@Test
fun lookalikePath_notScoped() {
// Sourcery review (PR #540): `startsWith` must not match non-segment
@@ -6,6 +6,7 @@ import io.mockk.unmockkAll
import kotlinx.serialization.json.JsonObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -668,4 +669,81 @@ class EventParserTest {
assertEquals(3, (usage?.get("compressions") as? Number)?.toInt())
assertEquals(12500, (usage?.get("context_used") as? Number)?.toInt())
}
@Test
fun testParseParams_parsesBareEventMap() {
val params =
mapOf(
"type" to "message.token",
"session_id" to "sess-replay-1",
"seq" to 42,
"payload" to mapOf("text" to "hello replay"),
)
val event = EventParser.parseParams(params)
assertTrue(event is WsEvent.MessageToken)
val tokenEvent = event as WsEvent.MessageToken
assertEquals("hello replay", tokenEvent.token)
assertEquals("sess-replay-1", tokenEvent.sessionId)
}
@Test
fun testParseParams_parsesToolEvent() {
val params =
mapOf(
"type" to "tool.start",
"session_id" to "sess-replay-2",
"seq" to 43,
"payload" to mapOf("name" to "terminal", "tool_id" to "t1"),
)
val event = EventParser.parseParams(params)
assertTrue(event is WsEvent.ToolStart)
val toolEvent = event as WsEvent.ToolStart
assertEquals("terminal", toolEvent.name)
assertEquals("sess-replay-2", toolEvent.sessionId)
}
@Test
fun testParseTodoUpdated_returnsTodoUpdatedEvent() {
val response =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "todo.updated",
"session_id" to "sess-abc",
"payload" to
mapOf(
"revision" to 5,
"todos" to
listOf(
mapOf("id" to "1", "content" to "Parent task", "status" to "in_progress"),
mapOf(
"id" to "2",
"content" to "Sub task",
"status" to "pending",
"parent" to "1",
),
),
),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.TodoUpdated)
val todoUpdated = event as WsEvent.TodoUpdated
assertEquals("sess-abc", todoUpdated.sessionId)
assertEquals(5, todoUpdated.revision)
assertEquals(2, todoUpdated.todos.size)
assertEquals("1", todoUpdated.todos[0].id)
assertEquals("Parent task", todoUpdated.todos[0].content)
assertEquals(null, todoUpdated.todos[0].parent)
assertFalse(todoUpdated.todos[0].isSubtask)
assertEquals("2", todoUpdated.todos[1].id)
assertEquals("Sub task", todoUpdated.todos[1].content)
assertEquals("1", todoUpdated.todos[1].parent)
assertTrue(todoUpdated.todos[1].isSubtask)
}
}
@@ -1527,4 +1527,226 @@ class HermesWsClientTest {
HermesWsClient.connectionStatus.value != ConnectionStatus.CONNECTED,
)
}
@Test
fun testSequenceNumberDeduplication() {
var serverWebSocket: WebSocket? = null
val serverLatch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: okhttp3.Response,
) {
serverWebSocket = webSocket
serverLatch.countDown()
}
},
),
)
HermesWsClient.connect()
runBlocking {
withTimeout(5000) {
HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED }
}
}
assertTrue(serverLatch.await(5, TimeUnit.SECONDS))
val ws = serverWebSocket
assertNotNull(ws)
val receivedTokens = mutableListOf<String>()
val collectorJob =
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
HermesWsClient.events.collect { event ->
if (event is WsEvent.MessageToken) {
receivedTokens.add(event.token)
}
}
}
// Send seq 1
ws!!.send(
"""{"method":"event","params":{"type":"message.token","session_id":"s1","seq":1,"payload":{"text":"A"}}}""",
)
Thread.sleep(100)
assertEquals(1, HermesWsClient.getSeqWatermarks()["s1"])
assertEquals(listOf("A"), receivedTokens)
// Send duplicate seq 1 -> should be dropped
ws.send(
"""{"method":"event","params":{"type":"message.token","session_id":"s1","seq":1,"payload":{"text":"A-dup"}}}""",
)
Thread.sleep(100)
assertEquals(1, HermesWsClient.getSeqWatermarks()["s1"])
assertEquals(listOf("A"), receivedTokens)
// Send seq 2 -> should be accepted
ws.send(
"""{"method":"event","params":{"type":"message.token","session_id":"s1","seq":2,"payload":{"text":"B"}}}""",
)
Thread.sleep(100)
assertEquals(2, HermesWsClient.getSeqWatermarks()["s1"])
assertEquals(listOf("A", "B"), receivedTokens)
collectorJob.cancel()
}
@Test
fun testEpochChangeClearsWatermarks() {
var serverWebSocket: WebSocket? = null
val serverLatch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: okhttp3.Response,
) {
serverWebSocket = webSocket
serverLatch.countDown()
}
},
),
)
HermesWsClient.connect()
runBlocking {
withTimeout(5000) {
HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED }
}
}
assertTrue(serverLatch.await(5, TimeUnit.SECONDS))
val ws = serverWebSocket
assertNotNull(ws)
// Initial gateway.ready with epoch-1
ws!!.send(
"""{"method":"event","params":{"type":"gateway.ready","payload":{"replay_epoch":"epoch-1"}}}""",
)
Thread.sleep(100)
HermesWsClient.setSeqWatermark("s1", 10)
assertEquals(10, HermesWsClient.getSeqWatermarks()["s1"])
// Second gateway.ready with new epoch-2 -> backend restarted, watermarks cleared
ws.send(
"""{"method":"event","params":{"type":"gateway.ready","payload":{"replay_epoch":"epoch-2"}}}""",
)
Thread.sleep(100)
assertTrue(HermesWsClient.getSeqWatermarks().isEmpty())
}
@Test
fun testReplayOnReconnectFlow() {
var serverWebSocket: WebSocket? = null
val serverLatch = CountDownLatch(1)
val requestLatch = CountDownLatch(1)
var receivedMethod: String? = null
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: okhttp3.Response,
) {
serverWebSocket = webSocket
serverLatch.countDown()
}
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
if (text.contains(WsMethods.SESSION_EVENTS_SINCE)) {
receivedMethod = WsMethods.SESSION_EVENTS_SINCE
// Extract ID from JSON-RPC request
val id = Regex(""""id":"([^"]+)"""").find(text)?.groupValues?.get(1) ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":{"epoch":"ep1","events":[{"type":"message.token","session_id":"s1","seq":6,"payload":{"text":"replayed"}}]}}""",
)
requestLatch.countDown()
}
}
},
),
)
HermesWsClient.setSeqWatermark("s1", 5)
HermesWsClient.connect()
runBlocking {
withTimeout(5000) {
HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED }
}
}
assertTrue(serverLatch.await(5, TimeUnit.SECONDS))
assertTrue(requestLatch.await(5, TimeUnit.SECONDS))
assertEquals(WsMethods.SESSION_EVENTS_SINCE, receivedMethod)
// Wait for replay processing
Thread.sleep(200)
assertEquals(6, HermesWsClient.getSeqWatermarks()["s1"])
}
@Test
fun testPingMethodConstant() {
assertEquals("ping", WsMethods.PING)
}
@Test
fun testPingMeasuresLatencyAndUpdatesTimestamp() {
var serverWebSocket: WebSocket? = null
val serverLatch = CountDownLatch(1)
val pingLatch = CountDownLatch(1)
var pingReceived = false
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: okhttp3.Response,
) {
serverWebSocket = webSocket
serverLatch.countDown()
}
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
if (text.contains(""""method":"ping"""")) {
pingReceived = true
val id = Regex(""""id":"([^"]+)"""").find(text)?.groupValues?.get(1) ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":{"pong":true,"timestamp":1700000000.0}}""",
)
pingLatch.countDown()
}
}
},
),
)
HermesWsClient.connect()
runBlocking {
withTimeout(5000) {
HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED }
}
}
assertTrue(serverLatch.await(5, TimeUnit.SECONDS))
val latency =
runBlocking {
HermesWsClient.ping(timeoutMs = 5000)
}
assertTrue(pingLatch.await(5, TimeUnit.SECONDS))
assertTrue(pingReceived)
assertTrue(latency >= 0)
assertNotNull(HermesWsClient.lastLatencyMs.value)
assertEquals(latency, HermesWsClient.lastLatencyMs.value)
assertTrue(HermesWsClient.lastPongTimestamp > 0)
}
}
@@ -20,7 +20,13 @@ class KanbanEventsClientTest {
assertEquals("t-1", event.taskId)
assertEquals("r-9", event.runId)
assertEquals("status", event.kind)
assertEquals("done", event.payload?.get("status")?.jsonPrimitive?.content)
assertEquals(
"done",
event.payload
?.get("status")
?.jsonPrimitive
?.content,
)
assertEquals(1710000000L, event.createdAt)
}
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.bots
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.ActiveProfileResponse
import com.m57.hermescontrol.data.model.BotAvatarMeta
import com.m57.hermescontrol.data.model.BotRosterMeta
@@ -45,6 +46,8 @@ class BotsViewModelTest {
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockkObject(AuthManager)
every { AuthManager.getHiddenProfiles() } returns emptyList()
mockkObject(ApiClient)
mockApi = mockk(relaxed = true)
every { ApiClient.hermesApi } returns mockApi
@@ -57,6 +60,7 @@ class BotsViewModelTest {
@After
fun tearDown() {
unmockkObject(AuthManager)
unmockkObject(ApiClient)
unmockkObject(HermesWsClient)
Dispatchers.resetMain()
@@ -139,12 +143,16 @@ class BotsViewModelTest {
// Toggle show hidden (sorted: default (active), scout (worker active now), reviewer (canonical last_active))
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)
// Search filter
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)
}
@@ -226,8 +234,16 @@ class BotsViewModelTest {
advanceUntilIdle()
assertEquals(2, viewModel.uiState.value.allGroups.size)
assertEquals("Dream Team", viewModel.uiState.value.allGroups[0].name)
assertEquals(2, viewModel.uiState.value.allGroups[0].members.size)
assertEquals(
"Dream Team",
viewModel.uiState.value.allGroups[0]
.name,
)
assertEquals(
2,
viewModel.uiState.value.allGroups[0]
.members.size,
)
var success = false
viewModel.disbandGroupChat("Dream Team") { success = true }
@@ -205,7 +205,9 @@ class GroupChatViewModelTest {
testScheduler.runCurrent()
// State should contain user message
val userMsg = viewModel.uiState.value.messages.first()
val userMsg =
viewModel.uiState.value.messages
.first()
assertTrue(userMsg.isUser)
assertEquals("@scout what's the status?", userMsg.text)
@@ -213,7 +215,9 @@ class GroupChatViewModelTest {
eventsFlow.emit(WsEvent.MessageToken(token = "Building ", sessionId = "session-scout-1"))
testScheduler.runCurrent()
val streamingMsg = viewModel.uiState.value.messages.last()
val streamingMsg =
viewModel.uiState.value.messages
.last()
assertFalse(streamingMsg.isUser)
assertEquals("scout", streamingMsg.senderName)
assertEquals("Building ", streamingMsg.text)
@@ -222,7 +226,9 @@ class GroupChatViewModelTest {
eventsFlow.emit(WsEvent.MessageToken(token = "the APK...", sessionId = "session-scout-1"))
testScheduler.runCurrent()
val updatedStreaming = viewModel.uiState.value.messages.last()
val updatedStreaming =
viewModel.uiState.value.messages
.last()
assertEquals("Building the APK...", updatedStreaming.text)
// Complete turn
@@ -285,7 +291,11 @@ class GroupChatViewModelTest {
testScheduler.runCurrent()
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
eventsFlow.emit(
@@ -345,7 +355,11 @@ class GroupChatViewModelTest {
// Because lease is held by desktop_client_99, mobile only posts the user message and yields
assertEquals(1, viewModel.uiState.value.messages.size)
assertTrue(viewModel.uiState.value.messages.first().isUser)
assertTrue(
viewModel.uiState.value.messages
.first()
.isUser,
)
}
@Test
@@ -452,7 +466,9 @@ class GroupChatViewModelTest {
)
testScheduler.runCurrent()
val msgWithTool = viewModel.uiState.value.messages.last()
val msgWithTool =
viewModel.uiState.value.messages
.last()
assertEquals(1, msgWithTool.toolCalls.size)
val toolCall = msgWithTool.toolCalls.first()
assertEquals("terminal", toolCall.name)
@@ -469,7 +485,9 @@ class GroupChatViewModelTest {
)
testScheduler.runCurrent()
val msgAfterToolDone = viewModel.uiState.value.messages.last()
val msgAfterToolDone =
viewModel.uiState.value.messages
.last()
assertFalse(msgAfterToolDone.toolCalls.first().isRunning)
// Complete message
@@ -481,7 +499,9 @@ class GroupChatViewModelTest {
)
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(1, finalMsg.toolCalls.size)
assertFalse(finalMsg.toolCalls.first().isRunning)
@@ -158,7 +158,13 @@ class ChatInputPolicyTest {
@Test
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")
assertEquals("ask @scout ", updated.text)
assertEquals(11, updated.selection.end)
@@ -369,12 +369,13 @@ class ChatToolDedupeTest {
var stream = StreamingState()
stream = ChatWsEventReducer.reduce(s, stream, WsEvent.MessageStart("session-1"), "session-1").streamingState
stream =
ChatWsEventReducer.reduce(
s,
stream,
WsEvent.ReasoningDelta("thinking about meow", "session-1"),
"session-1",
).streamingState
ChatWsEventReducer
.reduce(
s,
stream,
WsEvent.ReasoningDelta("thinking about meow", "session-1"),
"session-1",
).streamingState
var r =
ChatWsEventReducer.reduce(
s,
@@ -159,7 +159,12 @@ class ChatUpdateCommandTest {
advanceUntilIdle()
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.
verify(exactly = 0) {
@@ -478,6 +478,40 @@ class ChatViewModelTest {
verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
}
@Test
fun testSlashCommand_btw_dispatchesPromptBtw_andSetsBtwState() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
every {
HermesWsClient.request(WsMethods.PROMPT_BTW, any(), any())
} returns
CompletableDeferred(
mapOf("task_id" to "btw_123456"),
)
viewModel.sendMessage("/btw which file was that in?")
advanceUntilIdle()
val btw = viewModel.uiState.value.btwState
assertNotNull(btw)
assertEquals("which file was that in?", btw?.question)
assertEquals("btw_123456", btw?.taskId)
assertTrue(btw?.isLoading == true)
verify {
HermesWsClient.request(
WsMethods.PROMPT_BTW,
mapOf("session_id" to sessionId, "text" to "which file was that in?"),
any(),
)
}
// Ensure transcript messages were NOT polluted with the side question
assertTrue(
viewModel.uiState.value.messages
.none { it.content.contains("which file was that in?") },
)
}
// ── Slash usage ranking (issue #865) ────────────────────────────────────
@Test
@@ -513,7 +547,10 @@ class ChatViewModelTest {
// A blocklisted command can never dispatch — it must not climb
// the autocomplete ranking either.
assertTrue(viewModel.uiState.value.slashUsageCounts.isEmpty())
assertTrue(
viewModel.uiState.value.slashUsageCounts
.isEmpty(),
)
verify(exactly = 0) {
HermesWsClient.request(WsMethods.COMMAND_DISPATCH, any(), any())
}
@@ -1033,7 +1070,10 @@ class ChatViewModelTest {
viewModel.uiState.value.messages[1]
.role,
)
assertFalse(viewModel.uiState.value.messages[1].isStreaming)
assertFalse(
viewModel.uiState.value.messages[1]
.isStreaming,
)
assertEquals(
MessageRole.TOOL,
viewModel.uiState.value.messages[2]
@@ -1041,7 +1081,10 @@ class ChatViewModelTest {
)
assertNull(viewModel.streamingState.value.streamingMessage)
assertEquals(
listOf(viewModel.uiState.value.messages[1].id),
listOf(
viewModel.uiState.value.messages[1]
.id,
),
viewModel.streamingState.value.sealedOrphanIds,
)
@@ -1050,7 +1093,8 @@ class ChatViewModelTest {
mockEventsFlow.emit(WsEvent.MessageComplete("Calculating sum = 4", sessionId))
advanceUntilIdle()
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("Calculating sum", assistant[0].content)
assertEquals(" = 4", assistant[1].content)
@@ -1573,7 +1617,8 @@ class ChatViewModelTest {
every { AuthManager.getBaseUrl() } returns "http://test.local/"
coEvery { mockApi.getSessions(any(), any(), any()) } returns
retrofit2.Response.success(
com.m57.hermescontrol.data.model.SessionListResponse(sessions = emptyList(), total = 0),
com.m57.hermescontrol.data.model
.SessionListResponse(sessions = emptyList(), total = 0),
)
coEvery {
mockApi.getSessionMessages(
@@ -1623,7 +1668,8 @@ class ChatViewModelTest {
val empty =
retrofit2.Response.success(
com.m57.hermescontrol.data.model.SessionMessagesResponse(messages = emptyList()),
com.m57.hermescontrol.data.model
.SessionMessagesResponse(messages = emptyList()),
)
messagesA.complete(empty)
messagesB.complete(empty)
@@ -1641,7 +1687,8 @@ class ChatViewModelTest {
every { AuthManager.getBaseUrl() } returns "http://test.local/"
coEvery { mockApi.getSessions(any(), any(), any()) } returns
retrofit2.Response.success(
com.m57.hermescontrol.data.model.SessionListResponse(sessions = emptyList(), total = 0),
com.m57.hermescontrol.data.model
.SessionListResponse(sessions = emptyList(), total = 0),
)
coEvery {
mockApi.getSessionMessages(
@@ -1698,7 +1745,11 @@ class ChatViewModelTest {
advanceUntilIdle()
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
@@ -1927,7 +1978,8 @@ class ChatViewModelTest {
sessionIds.forEach { sessionId ->
coEvery { mockApi.getSessionMessages(sessionId, any(), any(), any(), any()) } returns
retrofit2.Response.success(
com.m57.hermescontrol.data.model.SessionMessagesResponse(messages = emptyList()),
com.m57.hermescontrol.data.model
.SessionMessagesResponse(messages = emptyList()),
)
}
}
@@ -1956,7 +2008,11 @@ class ChatViewModelTest {
// Warm cache painted and survived the exhausted retry cycle — the
// screen never went blank, and the user gets history + an error.
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)
assertNotNull("resumeError must be set after retries exhaust", viewModel.uiState.value.resumeError)
assertFalse(viewModel.uiState.value.isResumeRetrying)
@@ -2305,7 +2361,8 @@ class ChatViewModelTest {
assertEquals("session-new", viewModel.uiState.value.currentSessionId)
assertTrue(
"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 +2385,8 @@ class ChatViewModelTest {
ApiClient.hermesApi.getSessionMessages(capture(fetchedSessions), any(), any(), any(), any())
} returns
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.
@@ -2379,7 +2437,8 @@ class ChatViewModelTest {
)
} else {
retrofit2.Response.success(
com.m57.hermescontrol.data.model.SessionMessagesResponse(messages = emptyList()),
com.m57.hermescontrol.data.model
.SessionMessagesResponse(messages = emptyList()),
)
}
}
@@ -3301,7 +3360,12 @@ class ChatViewModelTest {
assertTrue(viewModel.uiState.value.hasOlderMessages)
assertEquals(150, viewModel.uiState.value.messages.size)
// 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
@@ -3444,7 +3508,10 @@ class ChatViewModelTest {
// 160 rows: the 10 new ones appended, the existing 150 kept —
// no duplicates, no dropped newest copy (stable row-id keys).
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) ─────────────────────────────────────
@@ -3506,15 +3573,17 @@ class ChatViewModelTest {
runTest {
mockkObject(GatewayFileClient)
val cacheDir =
java.io.File(
System.getProperty("java.io.tmpdir"),
"gw_save_${System.nanoTime()}",
).apply { mkdirs() }
java.io
.File(
System.getProperty("java.io.tmpdir"),
"gw_save_${System.nanoTime()}",
).apply { mkdirs() }
every { app.cacheDir } returns cacheDir
every { app.applicationContext } returns app
every { app.getApplicationContext() } returns app
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") }
coEvery { GatewayFileClient.fetch("/tmp/note.txt", any()) } returns
GatewayFileResult.Success(GatewayFile("note.txt", "text/plain", file))
@@ -3552,10 +3621,11 @@ class ChatViewModelTest {
runTest {
mockkObject(GatewayFileClient)
val cacheDir =
java.io.File(
System.getProperty("java.io.tmpdir"),
"gw_save_${System.nanoTime()}",
).apply { mkdirs() }
java.io
.File(
System.getProperty("java.io.tmpdir"),
"gw_save_${System.nanoTime()}",
).apply { mkdirs() }
every { app.cacheDir } returns cacheDir
every { app.applicationContext } returns app
every { app.getApplicationContext() } returns app
@@ -3590,10 +3660,11 @@ class ChatViewModelTest {
runTest {
mockkObject(GatewayFileClient)
val cacheDir =
java.io.File(
System.getProperty("java.io.tmpdir"),
"gw_save_${System.nanoTime()}",
).apply { mkdirs() }
java.io
.File(
System.getProperty("java.io.tmpdir"),
"gw_save_${System.nanoTime()}",
).apply { mkdirs() }
every { app.cacheDir } returns cacheDir
every { app.applicationContext } returns app
every { app.getApplicationContext() } returns app
@@ -3606,7 +3677,8 @@ class ChatViewModelTest {
GatewayFile(
"report.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)) },
),
)
@@ -3625,7 +3697,11 @@ class ChatViewModelTest {
verify(exactly = 0) { resolver.delete(any(), any(), any()) }
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
@@ -3633,10 +3709,11 @@ class ChatViewModelTest {
runTest {
mockkObject(GatewayFileClient)
val cacheDir =
java.io.File(
System.getProperty("java.io.tmpdir"),
"gw_save_${System.nanoTime()}",
).apply { mkdirs() }
java.io
.File(
System.getProperty("java.io.tmpdir"),
"gw_save_${System.nanoTime()}",
).apply { mkdirs() }
every { app.cacheDir } returns cacheDir
every { app.applicationContext } returns app
every { app.getApplicationContext() } returns app
@@ -3675,10 +3752,11 @@ class ChatViewModelTest {
runTest {
mockkObject(GatewayFileClient)
val cacheDir =
java.io.File(
System.getProperty("java.io.tmpdir"),
"gw_open_${System.nanoTime()}",
).apply { mkdirs() }
java.io
.File(
System.getProperty("java.io.tmpdir"),
"gw_open_${System.nanoTime()}",
).apply { mkdirs() }
every { app.cacheDir } returns cacheDir
every { app.applicationContext } returns app
every { app.getApplicationContext() } returns app
@@ -3711,10 +3789,11 @@ class ChatViewModelTest {
runTest {
mockkObject(GatewayFileClient)
val cacheDir =
java.io.File(
System.getProperty("java.io.tmpdir"),
"gw_open_${System.nanoTime()}",
).apply { mkdirs() }
java.io
.File(
System.getProperty("java.io.tmpdir"),
"gw_open_${System.nanoTime()}",
).apply { mkdirs() }
every { app.cacheDir } returns cacheDir
every { app.applicationContext } returns app
every { app.getApplicationContext() } returns app
@@ -3743,7 +3822,11 @@ class ChatViewModelTest {
// …and cleared in all outcomes.
assertNull(vm.uiState.value.openingAttachmentPath)
assertTrue(vm.uiState.value.openError.orEmpty().contains("big.pdf"))
assertTrue(
vm.uiState.value.openError
.orEmpty()
.contains("big.pdf"),
)
}
@Test
@@ -3763,7 +3846,10 @@ class ChatViewModelTest {
advanceUntilIdle()
// 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)
// But wsClient.sendMessage not dispatched yet because session_id was null
assertTrue(sentPrompts.isEmpty())
@@ -3781,7 +3867,10 @@ class ChatViewModelTest {
// Prompt should be dispatched automatically!
assertEquals(listOf("My super important long prompt"), sentPrompts)
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
@@ -3806,7 +3895,10 @@ class ChatViewModelTest {
advanceUntilIdle()
// 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
mockEventsFlow.emit(
@@ -3842,6 +3934,73 @@ class ChatViewModelTest {
assertFalse(vm.uiState.value.isAgentTyping)
assertNotNull(vm.uiState.value.errorMessage)
assertTrue(vm.uiState.value.errorMessage!!.contains("Backend exploded"))
assertTrue(
vm.uiState.value.errorMessage!!
.contains("Backend exploded"),
)
}
// ── Subagent steering & cancellation (issue #1030) ───────────────────
@Test
fun testSteerSubagent_sendsRedirectAndUpdatesStatus() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
val indicator =
SubagentIndicator(
type = "subagent.start",
subagentId = "sub-123",
goal = "Search database",
status = "running",
)
mockEventsFlow.emit(
WsEvent.SubagentEvent(
type = "subagent.start",
payload = mapOf("subagent_id" to "sub-123", "goal" to "Search database"),
sessionId = sessionId,
),
)
advanceUntilIdle()
viewModel.steerSubagent(indicator, "Use index scan instead")
advanceUntilIdle()
val updated =
viewModel.uiState.value.subagentIndicators
.first { it.subagentId == "sub-123" }
assertEquals("steered", updated.status)
assertTrue(updated.logs.any { it.text.contains("Use index scan instead") })
io.mockk.verify { HermesWsClient.sendRedirect(sessionId, "/steer sub-123 Use index scan instead", any()) }
}
@Test
fun testStopSubagent_sendsRedirectAndMarksCancelled() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
val indicator =
SubagentIndicator(
type = "subagent.start",
subagentId = "sub-123",
goal = "Run deep scan",
status = "running",
)
mockEventsFlow.emit(
WsEvent.SubagentEvent(
type = "subagent.start",
payload = mapOf("subagent_id" to "sub-123", "goal" to "Run deep scan"),
sessionId = sessionId,
),
)
advanceUntilIdle()
viewModel.stopSubagent(indicator)
advanceUntilIdle()
val updated =
viewModel.uiState.value.subagentIndicators
.first { it.subagentId == "sub-123" }
assertEquals("cancelled", updated.status)
assertTrue(updated.logs.any { it.text.contains("Stopped subagent") })
io.mockk.verify { HermesWsClient.sendRedirect(sessionId, "/stop sub-123", any()) }
}
}
@@ -421,7 +421,12 @@ class ChatWsEventReducerTest {
)
assertEquals(1, result.state.messages.size)
assertEquals("complete", result.state.messages.single().content)
assertEquals(
"complete",
result.state.messages
.single()
.content,
)
}
@Test
@@ -530,6 +535,43 @@ class ChatWsEventReducerTest {
assertEquals("💾 Self-improvement review: Skill 'android-ci' patched", msg.content)
}
@Test
fun testBtwComplete_updatesBtwState() {
val state =
ChatUiState(
currentSessionId = "session-1",
btwState =
BtwUiState(
question = "what model?",
isLoading = true,
),
)
val event =
WsEvent.BtwComplete(
taskId = "btw_123456",
question = "what model?",
text = "This conversation uses Gemini 3.7.",
sessionId = "session-1",
)
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
event = event,
currentSessionId = "session-1",
)
val btw = result.state.btwState
org.junit.Assert.assertNotNull(btw)
assertEquals("btw_123456", btw?.taskId)
assertEquals("what model?", btw?.question)
assertEquals("This conversation uses Gemini 3.7.", btw?.answer)
assertEquals(false, btw?.isLoading)
// Zero messages appended to transcript!
assertEquals(0, result.state.messages.size)
}
// ── Issue #771: reasoning survives a tool call (regression) ────────────
//
// Real gateway capture for `run echo hi` (reasoning model):
@@ -556,12 +598,13 @@ class ChatWsEventReducerTest {
var reasoningState = start.streamingState
for (token in reasoningTokens) {
reasoningState =
ChatWsEventReducer.reduce(
state,
reasoningState,
WsEvent.ReasoningDelta(token, "session-1"),
"session-1",
).streamingState
ChatWsEventReducer
.reduce(
state,
reasoningState,
WsEvent.ReasoningDelta(token, "session-1"),
"session-1",
).streamingState
}
assertEquals(true, reasoningState.isReasoning)
assertEquals("The user just said \"run echo hi\". Let me run it.", reasoningState.reasoningText)
@@ -578,8 +621,18 @@ class ChatWsEventReducerTest {
"session-1",
)
assertEquals(1, result.state.messages.size)
assertEquals(MessageRole.TOOL, result.state.messages.first().role)
assertEquals("terminal", result.state.messages.first().toolName)
assertEquals(
MessageRole.TOOL,
result.state.messages
.first()
.role,
)
assertEquals(
"terminal",
result.state.messages
.first()
.toolName,
)
// Issue #771: streaming message + reasoning survive the tool call
assertEquals(true, result.streamingState.streamingMessage?.isStreaming)
assertEquals("The user just said \"run echo hi\". Let me run it.", result.streamingState.reasoningText)
@@ -592,7 +645,12 @@ class ChatWsEventReducerTest {
WsEvent.ToolComplete("terminal", mapOf("output" to "hi"), "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
val fullReasoning = "The user just said \"run echo hi\". That's a simple terminal command. Let me just run it."
@@ -683,7 +741,12 @@ class ChatWsEventReducerTest {
"session-1",
)
assertEquals("payload reasoning", result.state.messages.last().reasoningText)
assertEquals(
"payload reasoning",
result.state.messages
.last()
.reasoningText,
)
}
@Test
@@ -916,4 +979,88 @@ class ChatWsEventReducerTest {
assertEquals(1, result.state.compressionCount)
assertEquals(null, result.state.usedContextTokens)
}
@Test
fun testTodoUpdated_updatesStateTodos() {
val state = ChatUiState(currentSessionId = "session-1")
val todos =
listOf(
TodoItem(id = "1", content = "Main task", status = "in_progress"),
TodoItem(id = "2", content = "Subtask 1", status = "completed", parent = "1"),
TodoItem(id = "3", content = "Subtask 2", status = "pending", parent = "1"),
)
val event = WsEvent.TodoUpdated(todos = todos, revision = 1, sessionId = "session-1")
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
event = event,
currentSessionId = "session-1",
)
assertEquals(3, result.state.todos.size)
assertEquals("Main task", result.state.todos[0].content)
assertEquals(null, result.state.todos[0].parent)
assertEquals("1", result.state.todos[1].parent)
assertTrue(result.state.todos[1].isCompleted)
assertTrue(result.state.todos[1].isSubtask)
}
@Test
fun testTodoUpdated_differentSession_isIgnored() {
val state =
ChatUiState(
currentSessionId = "session-1",
todos = listOf(TodoItem(id = "orig", content = "Original", status = "pending")),
)
val event =
WsEvent.TodoUpdated(
todos = listOf(TodoItem(id = "new", content = "New", status = "in_progress")),
revision = 1,
sessionId = "session-other",
)
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
event = event,
currentSessionId = "session-1",
)
assertEquals(1, result.state.todos.size)
assertEquals("Original", result.state.todos[0].content)
}
@Test
fun testExtractTodosFromMap_preservesParentHierarchy() {
val payload =
mapOf(
"todos" to
listOf(
mapOf("id" to "t1", "content" to "Root", "status" to "in_progress"),
mapOf("id" to "t2", "content" to "Child", "status" to "pending", "parent" to "t1"),
),
)
val extracted = extractTodosFromMap(payload)
assertEquals(2, extracted?.size)
assertEquals("t1", extracted?.get(0)?.id)
assertEquals(null, extracted?.get(0)?.parent)
assertEquals("t2", extracted?.get(1)?.id)
assertEquals("t1", extracted?.get(1)?.parent)
}
@Test
fun testExtractTodosFromJson_preservesParentHierarchy() {
val json =
"""{"todos":[{"id":"a","content":"Task A","status":"done"},""" +
"""{"id":"b","content":"Task B","status":"pending","parent":"a"}]}"""
val extracted = extractTodosFromJson(json)
assertEquals(2, extracted?.size)
assertEquals("a", extracted?.get(0)?.id)
assertEquals(null, extracted?.get(0)?.parent)
assertEquals("b", extracted?.get(1)?.id)
assertEquals("a", extracted?.get(1)?.parent)
}
}
@@ -223,7 +223,8 @@ class SlashCommandDispatchRpcTest {
// The optimistic bubble shows the queued TEXT (prefix stripped) so
// the transcript sync dedupes it against the server echo.
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)
}
@@ -247,7 +248,9 @@ class SlashCommandDispatchRpcTest {
vm.sendMessage("/queue")
advanceUntilIdle()
val last = vm.uiState.value.messages.lastOrNull()
val last =
vm.uiState.value.messages
.lastOrNull()
assertEquals("usage: /queue <prompt>", last?.content)
assertEquals(0, sendCalls)
assertTrue(
@@ -628,7 +631,9 @@ class SlashCommandDispatchRpcTest {
vm.sendMessage("/focus on")
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)
}
}
@@ -74,6 +74,14 @@ class SlashCommandDispatcherTest {
assertEquals(SlashResult.OpenHistory, dispatcher.dispatch("/History"))
}
@Test
fun `btw routes to SideQuestion`() {
assertEquals(SlashResult.SideQuestion("what model is this?"), dispatcher.dispatch("/btw what model is this?"))
assertEquals(SlashResult.SideQuestion("what model is this?"), dispatcher.dispatch("/BTW what model is this?"))
assertEquals(SlashResult.SideQuestion(""), dispatcher.dispatch("/btw"))
assertEquals(SlashResult.SideQuestion(""), dispatcher.dispatch("/btw "))
}
@Test
fun `NEW uppercase still routes to NewSession`() {
// Dispatcher lower-cases before matching, so case must not matter.
@@ -0,0 +1,92 @@
package com.m57.hermescontrol.ui.chat
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.unmockkConstructor
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class SpeechInputHelperTest {
@Before
fun setUp() {
mockkConstructor(Intent::class)
every { anyConstructed<Intent>().setAction(any()) } answers { self as Intent }
every { anyConstructed<Intent>().putExtra(any<String>(), any<String>()) } answers { self as Intent }
}
@After
fun tearDown() {
unmockkConstructor(Intent::class)
}
@Test
fun isSpeechInputAvailable_returnsTrue_whenPackageManagerFindsActivities() {
val context = mockk<Context>()
val packageManager = mockk<PackageManager>()
val resolveInfo = mockk<ResolveInfo>()
every { context.packageManager } returns packageManager
every {
packageManager.queryIntentActivities(any<Intent>(), any<Int>())
} returns listOf(resolveInfo)
val result =
SpeechInputHelper.isSpeechInputAvailable(
context = context,
isRecognizerServiceAvailable = { false },
)
assertTrue(result)
}
@Test
fun isSpeechInputAvailable_returnsTrue_whenRecognitionServiceAvailableEvenIfNoActivities() {
val context = mockk<Context>()
val packageManager = mockk<PackageManager>()
every { context.packageManager } returns packageManager
every {
packageManager.queryIntentActivities(any<Intent>(), any<Int>())
} returns emptyList()
val result =
SpeechInputHelper.isSpeechInputAvailable(
context = context,
isRecognizerServiceAvailable = { true },
)
assertTrue(result)
}
@Test
fun isSpeechInputAvailable_returnsFalse_whenNoActivitiesAndNoRecognitionService() {
val context = mockk<Context>()
val packageManager = mockk<PackageManager>()
every { context.packageManager } returns packageManager
every {
packageManager.queryIntentActivities(any<Intent>(), any<Int>())
} returns emptyList()
val result =
SpeechInputHelper.isSpeechInputAvailable(
context = context,
isRecognizerServiceAvailable = { false },
)
assertFalse(result)
}
@Test
fun createSpeechIntent_constructsIntentWithActionAndPrompt() {
val prompt = "Listening..."
val intent = SpeechInputHelper.createSpeechIntent(prompt)
assertNotNull(intent)
}
}
@@ -1,6 +1,9 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.components.buildTodoTree
import com.m57.hermescontrol.ui.chat.components.computeChipDisplay
import com.m57.hermescontrol.ui.chat.components.flattenTodoTree
import com.m57.hermescontrol.ui.chat.components.getAllDescendantTodos
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -142,4 +145,72 @@ class TaskProgressChipTest {
)
assertTrue(hydrateTodosFromMessages(messages).isEmpty())
}
@Test
fun `buildTodoTree constructs correct multi-level hierarchy`() {
val todos =
listOf(
TodoItem(id = "1", content = "Root 1", status = "in_progress"),
TodoItem(id = "1.1", content = "Child 1.1", status = "completed", parent = "1"),
TodoItem(id = "1.2", content = "Child 1.2", status = "pending", parent = "1"),
TodoItem(id = "1.2.1", content = "Grandchild 1.2.1", status = "pending", parent = "1.2"),
TodoItem(id = "2", content = "Root 2", status = "pending"),
)
val tree = buildTodoTree(todos)
assertEquals(2, tree.size)
assertEquals("1", tree[0].item.id)
assertEquals(0, tree[0].depth)
assertEquals(2, tree[0].children.size)
assertEquals("1.1", tree[0].children[0].item.id)
assertEquals(1, tree[0].children[0].depth)
assertEquals("1.2", tree[0].children[1].item.id)
assertEquals(1, tree[0].children[1].depth)
assertEquals(1, tree[0].children[1].children.size)
assertEquals(
"1.2.1",
tree[0]
.children[1]
.children[0]
.item.id,
)
assertEquals(2, tree[0].children[1].children[0].depth)
assertEquals("2", tree[1].item.id)
assertEquals(0, tree[1].depth)
}
@Test
fun `flattenTodoTree collapses and expands subtasks correctly`() {
val todos =
listOf(
TodoItem(id = "1", content = "Root 1", status = "in_progress"),
TodoItem(id = "1.1", content = "Child 1.1", status = "completed", parent = "1"),
TodoItem(id = "1.2", content = "Child 1.2", status = "pending", parent = "1"),
TodoItem(id = "2", content = "Root 2", status = "pending"),
)
val tree = buildTodoTree(todos)
// Expanded state
val expandedRows = flattenTodoTree(tree, collapsedParentIds = emptySet())
assertEquals(4, expandedRows.size)
assertEquals("1", expandedRows[0].todo.id)
assertTrue(expandedRows[0].hasChildren)
assertTrue(expandedRows[0].isExpanded)
assertEquals(1, expandedRows[0].completedSubtasksCount)
assertEquals(2, expandedRows[0].totalSubtasksCount)
assertEquals("1.1", expandedRows[1].todo.id)
assertEquals(1, expandedRows[1].depth)
assertEquals("1.2", expandedRows[2].todo.id)
assertEquals(1, expandedRows[2].depth)
assertEquals("2", expandedRows[3].todo.id)
assertEquals(0, expandedRows[3].depth)
// Collapsed state
val collapsedRows = flattenTodoTree(tree, collapsedParentIds = setOf("1"))
assertEquals(2, collapsedRows.size)
assertEquals("1", collapsedRows[0].todo.id)
assertTrue(collapsedRows[0].hasChildren)
assertFalse(collapsedRows[0].isExpanded)
assertEquals("2", collapsedRows[1].todo.id)
}
}
@@ -202,7 +202,10 @@ class ActionProgressControllerTest {
tick()
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"))
@@ -216,7 +219,10 @@ class ActionProgressControllerTest {
val controller = controller()
controller.open()
controller.pushTrailingLines(emptyList())
assertTrue(controller.state.value.trailingLines.isEmpty())
assertTrue(
controller.state.value.trailingLines
.isEmpty(),
)
}
@Test
@@ -31,13 +31,19 @@ class RefreshOnChangeTest {
private class TestViewModel : ViewModel() {
val state = MutableStateFlow("initial")
var job: kotlinx.coroutines.Job? = null
init {
refreshOnChange(
eventType = ChangeEvents.CRON,
apiCall = { NetworkResult.Success("refreshed") },
onSuccess = { state.value = it },
)
job =
refreshOnChange(
eventType = ChangeEvents.CRON,
apiCall = { NetworkResult.Success("refreshed") },
onSuccess = { state.value = it },
)
}
fun cleanup() {
job?.cancel()
}
}
@@ -55,23 +61,31 @@ class RefreshOnChangeTest {
fun testRefreshOnChange_updatesOnMatchingEvent() =
runTest(testDispatcher) {
val viewModel = TestViewModel()
// Let the init coroutine run so the collector actually subscribes
// before the event fires (replay=0 — events before subscription
// are dropped, matching production where VMs subscribe at
// construction and events arrive afterwards).
advanceUntilIdle()
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.CRON))
advanceUntilIdle()
assertEquals("refreshed", viewModel.state.value)
try {
// Let the init coroutine run so the collector actually subscribes
// before the event fires (replay=0 — events before subscription
// are dropped, matching production where VMs subscribe at
// construction and events arrive afterwards).
advanceUntilIdle()
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.CRON))
advanceUntilIdle()
assertEquals("refreshed", viewModel.state.value)
} finally {
viewModel.cleanup()
}
}
@Test
fun testRefreshOnChange_ignoresOtherEventTypes() =
runTest(testDispatcher) {
val viewModel = TestViewModel()
advanceUntilIdle()
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.SESSIONS))
advanceUntilIdle()
assertEquals("initial", viewModel.state.value)
try {
advanceUntilIdle()
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.SESSIONS))
advanceUntilIdle()
assertEquals("initial", viewModel.state.value)
} finally {
viewModel.cleanup()
}
}
}
@@ -204,7 +204,9 @@ class CronJobsViewModelTest {
vm.saveEditor()
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'"))
assertFalse(vm.uiState.value.editorState.isSaving)
}
@@ -394,7 +396,11 @@ class CronJobsViewModelTest {
coVerify { mockApi.deleteCronJob("j5") }
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
@@ -549,7 +555,9 @@ class CronJobsViewModelTest {
vm.loadCronJobs()
settle()
val job = vm.uiState.value.jobs.first()
val job =
vm.uiState.value.jobs
.first()
assertNotNull(job.last_fire_error)
assertEquals("gateway unreachable", job.last_fire_error?.detail)
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)
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
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")) }
}
@@ -234,8 +239,16 @@ class KanbanViewModelTest {
coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.error(409, "".toResponseBody())
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
settle()
assertEquals("todo", vm.uiState.value.tasks.first { it.id == "t1" }.status)
assertTrue(vm.uiState.value.toastMessage?.contains("Move failed") == true)
assertEquals(
"todo",
vm.uiState.value.tasks
.first { it.id == "t1" }
.status,
)
assertTrue(
vm.uiState.value.toastMessage
?.contains("Move failed") == true,
)
}
@Test
@@ -0,0 +1,244 @@
package com.m57.hermescontrol.ui.mcp
import com.m57.hermescontrol.data.model.McpServerTestResponse
import com.m57.hermescontrol.data.model.McpServerToolInfo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class McpJsonParserTest {
@Test
fun testParseClaudeDesktopMcpServersWrapper() {
val json =
"""
{
"mcpServers": {
"sqlite": {
"command": "uvx",
"args": ["mcp-server-sqlite", "--db-path", "/path/to/db.sqlite"],
"env": {
"DEBUG": "1"
}
},
"github": {
"url": "https://api.github.com/mcp",
"headers": {
"Authorization": "Bearer gh_secret_123"
}
}
}
}
""".trimIndent()
val parsed = McpJsonParser.parse(json)
assertEquals(2, parsed.size)
val sqlite = parsed.find { it.name == "sqlite" }
assertNotNull(sqlite)
assertEquals("uvx", sqlite?.command)
assertEquals(listOf("mcp-server-sqlite", "--db-path", "/path/to/db.sqlite"), sqlite?.args)
assertEquals(mapOf("DEBUG" to "1"), sqlite?.env)
assertEquals("none", sqlite?.auth)
val github = parsed.find { it.name == "github" }
assertNotNull(github)
assertEquals("https://api.github.com/mcp", github?.url)
assertEquals("header", github?.auth)
assertEquals("gh_secret_123", github?.bearerToken)
}
@Test
fun testParseSnakeCaseMcpServersWrapper() {
val json =
"""
{
"mcp_servers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
}
}
}
""".trimIndent()
val parsed = McpJsonParser.parse(json)
assertEquals(1, parsed.size)
assertEquals("filesystem", parsed[0].name)
assertEquals("npx", parsed[0].command)
assertEquals(listOf("-y", "@modelcontextprotocol/server-filesystem", "/tmp"), parsed[0].args)
}
@Test
fun testParseDirectServerMap() {
val json =
"""
{
"postgres": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/db"]
}
}
""".trimIndent()
val parsed = McpJsonParser.parse(json)
assertEquals(1, parsed.size)
assertEquals("postgres", parsed[0].name)
assertEquals("npx", parsed[0].command)
}
@Test
fun testParseSingleServerObject() {
val json =
"""
{
"name": "custom-tools",
"command": "python3",
"args": ["-m", "mcp_server"],
"auth": "oauth"
}
""".trimIndent()
val parsed = McpJsonParser.parse(json)
assertEquals(1, parsed.size)
assertEquals("custom-tools", parsed[0].name)
assertEquals("python3", parsed[0].command)
assertEquals("oauth", parsed[0].auth)
}
@Test
fun testParseInvalidJsonReturnsEmpty() {
val parsed = McpJsonParser.parse("not json at all")
assertTrue(parsed.isEmpty())
}
@Test
fun testParseMalformedArgsAndEnvDoesNotCrash() {
val json =
"""
{
"mcpServers": {
"faulty": {
"command": "npx",
"args": ["valid", {"nested": "bad"}, ["array"], 123],
"env": {
"VALID_KEY": "valid_val",
"BAD_KEY": {"nested": "bad"},
"BAD_LIST": [1, 2]
}
}
}
}
""".trimIndent()
val parsed = McpJsonParser.parse(json)
assertEquals(1, parsed.size)
val server = parsed[0]
assertEquals("faulty", server.name)
assertEquals(listOf("valid", "123"), server.args)
assertEquals(mapOf("VALID_KEY" to "valid_val"), server.env)
}
@Test
fun testTokenOverheadEstimator() {
val tools =
listOf(
McpServerToolInfo(name = "read_file", schemaChars = 400),
McpServerToolInfo(name = "write_file", schemaChars = 401),
McpServerToolInfo(name = "no_chars", schemaChars = null),
)
// ceil(400/4) = 100, ceil(401/4) = 101 -> sum = 201
val est = McpTokenEstimator.estimateTokens(tools)
assertEquals(201, est)
val formatted = McpTokenEstimator.formatTokenOverhead(tools.size, est)
assertEquals("3 tools • ~201 tokens", formatted)
}
@Test
fun testTokenOverheadLargeFormatting() {
val tools =
listOf(
McpServerToolInfo(name = "tool_huge", schemaChars = 12800),
)
// ceil(12800/4) = 3200 tokens -> ~3.2k tokens
val est = McpTokenEstimator.estimateTokens(tools)
assertEquals(3200, est)
val formatted = McpTokenEstimator.formatTokenOverhead(49, est)
assertEquals("49 tools • ~3.2k tokens", formatted)
}
@Test
fun testTokenOverheadNoSchemaChars() {
val tools =
listOf(
McpServerToolInfo(name = "tool1", schemaChars = null),
McpServerToolInfo(name = "tool2", schemaChars = 0),
)
val est = McpTokenEstimator.estimateTokens(tools)
assertNull(est)
assertEquals("2 tools", McpTokenEstimator.formatTokenOverhead(2, est))
}
@Test
fun testHealthStatusResolution() {
// In flight
assertEquals(
McpHealthStatus.TESTING,
McpHealthStatus.resolve(
isTesting = true,
testResult = null,
serverStatus = "ok",
serverError = null,
),
)
// Healthy result
assertEquals(
McpHealthStatus.HEALTHY,
McpHealthStatus.resolve(
isTesting = false,
testResult = McpServerTestResponse(ok = true),
serverStatus = null,
serverError = null,
),
)
// Auth needed
assertEquals(
McpHealthStatus.AUTH_REQUIRED,
McpHealthStatus.resolve(
isTesting = false,
testResult = McpServerTestResponse(ok = false, error = "OAuth authentication required"),
serverStatus = null,
serverError = null,
),
)
// Error
assertEquals(
McpHealthStatus.ERROR,
McpHealthStatus.resolve(
isTesting = false,
testResult = McpServerTestResponse(ok = false, error = "Process exited with 1"),
serverStatus = null,
serverError = null,
),
)
// Fallback from status
assertEquals(
McpHealthStatus.HEALTHY,
McpHealthStatus.resolve(
isTesting = false,
testResult = null,
serverStatus = "running",
serverError = null,
),
)
}
}
@@ -1,9 +1,12 @@
package com.m57.hermescontrol.ui.memory
import com.m57.hermescontrol.data.model.MemoryProviderStatusRow
import com.m57.hermescontrol.data.model.MemoryResetRequest
import com.m57.hermescontrol.data.model.MemoryResetResponse
import com.m57.hermescontrol.data.model.MemoryResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@@ -12,6 +15,7 @@ import io.mockk.mockkObject
import io.mockk.unmockkAll
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
@@ -34,6 +38,8 @@ import retrofit2.Response
class MemoryViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var mockApi: HermesApiService
private val mockSwitchFlow = MutableSharedFlow<String>(extraBufferCapacity = 1)
private val mockConnSwitchFlow = MutableSharedFlow<String>(extraBufferCapacity = 1)
private val memoryResponse =
MemoryResponse(
@@ -54,6 +60,9 @@ class MemoryViewModelTest {
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockkObject(ApiClient)
mockkObject(ProfileSwitchCoordinator)
every { ProfileSwitchCoordinator.switched } returns mockSwitchFlow
every { ProfileSwitchCoordinator.connectionSwitched } returns mockConnSwitchFlow
mockApi = mockk(relaxed = true)
every { ApiClient.hermesApi } returns mockApi
coEvery { mockApi.getMemory() } returns Response.success(memoryResponse)
@@ -77,9 +86,35 @@ class MemoryViewModelTest {
assertEquals(1024L, state.memory?.builtin_files?.memory)
}
@Test
fun `profile switch triggers silent reload`() {
val vm = MemoryViewModel()
testDispatcher.scheduler.advanceUntilIdle()
coVerify(exactly = 0) { mockApi.getMemory() }
mockSwitchFlow.tryEmit("yasmin")
testDispatcher.scheduler.advanceUntilIdle()
coVerify(exactly = 1) { mockApi.getMemory() }
}
@Test
fun `connection profile switch triggers silent reload`() {
val vm = MemoryViewModel()
testDispatcher.scheduler.advanceUntilIdle()
coVerify(exactly = 0) { mockApi.getMemory() }
mockConnSwitchFlow.tryEmit("server-remote")
testDispatcher.scheduler.advanceUntilIdle()
coVerify(exactly = 1) { mockApi.getMemory() }
}
@Test
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)
val vm = MemoryViewModel()
vm.load()
@@ -98,20 +133,24 @@ class MemoryViewModelTest {
testDispatcher.scheduler.advanceUntilIdle()
assertNull(vm.uiState.value.memory)
assertTrue(vm.uiState.value.errorMessage.orEmpty().isNotBlank())
assertTrue(
vm.uiState.value.errorMessage
.orEmpty()
.isNotBlank(),
)
}
@Test
fun `resetMemory posts target and reloads`() {
coEvery { mockApi.resetMemory(mapOf("target" to "all")) } returns
Response.success(emptyMap<String, String>())
coEvery { mockApi.resetMemory(MemoryResetRequest(target = "all")) } returns
Response.success(MemoryResetResponse(ok = true))
val vm = MemoryViewModel()
vm.load()
testDispatcher.scheduler.advanceUntilIdle()
vm.resetMemory("all")
testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockApi.resetMemory(mapOf("target" to "all")) }
coVerify { mockApi.resetMemory(MemoryResetRequest(target = "all")) }
assertEquals("Memory (all) reset successfully", vm.uiState.value.toastMessage)
// One load + one reload after reset.
coVerify(exactly = 2) { mockApi.getMemory() }
@@ -119,13 +158,17 @@ class MemoryViewModelTest {
@Test
fun `resetMemory failure shows error toast`() {
coEvery { mockApi.resetMemory(mapOf("target" to "memory")) } returns
coEvery { mockApi.resetMemory(MemoryResetRequest(target = "memory")) } returns
Response.error(500, "boom".toResponseBody())
val vm = MemoryViewModel()
vm.resetMemory("memory")
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)
}
}
@@ -97,9 +97,21 @@ class PairingViewModelTest {
assertFalse(state.isLoading)
assertNull(state.errorMessage)
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("alice", state.pairing?.approved?.first()?.userName)
assertEquals(
"alice",
state.pairing
?.approved
?.first()
?.userName,
)
}
@Test
@@ -148,7 +160,10 @@ class PairingViewModelTest {
assertEquals("discord", requestSlot.captured.platform)
assertEquals("u2", requestSlot.captured.userId)
coVerify(exactly = 1) { mockApi.getPairing() }
assertTrue(vm.uiState.value.toastMessage?.contains("u2") == true)
assertTrue(
vm.uiState.value.toastMessage
?.contains("u2") == true,
)
}
@Test
@@ -229,7 +229,11 @@ class MemoryProviderDetailViewModelTest {
vm.saveConfig()
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(vm.uiState.value.toastMessage.orEmpty().contains("Failed to save"))
assertTrue(
vm.uiState.value.toastMessage
.orEmpty()
.contains("Failed to save"),
)
}
@Test
@@ -242,7 +246,13 @@ class MemoryProviderDetailViewModelTest {
val state = vm.uiState.value
assertEquals("Setup completed", state.toastMessage)
assertTrue(state.setupResult?.ok == true)
assertEquals("installed", state.setupResult?.results?.first()?.status)
assertEquals(
"installed",
state.setupResult
?.results
?.first()
?.status,
)
}
@Test
@@ -253,7 +263,11 @@ class MemoryProviderDetailViewModelTest {
vm.runSetup()
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(vm.uiState.value.toastMessage.orEmpty().contains("Setup failed"))
assertTrue(
vm.uiState.value.toastMessage
.orEmpty()
.contains("Setup failed"),
)
}
@Test
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more