Merge pull request #1208 from Hy4ri/dev

release: 1.29.2
This commit is contained in:
m57 authored and GitHub committed 2026-09-21 17:00:54 +03:00
commit e847e40541
33 files changed
+928 -103

No files matched your search

@@ -0,0 +1,110 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.ui.chat.ContextBreakdown
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Regression coverage for backend-reported context occupancy.
*
* Cumulative REST input-token accounting must never masquerade as the live
* context-window numerator when the backend has not reported context_used.
*/
@RunWith(AndroidJUnit4::class)
@MediumTest
class ContextUsageComponentsTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun chip_missingBackendOccupancy_doesNotShowFakeZeroPercent() {
composeTestRule.setContent {
HermesControlTheme {
ContextUsageChip(
usedTokens = null,
fullTokens = 128_000L,
)
}
}
composeTestRule
.onNodeWithText("— / 128k context")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("0%")
.assertDoesNotExist()
}
@Test
fun chip_backendOccupancyAvailable_showsDeterminateUsage() {
composeTestRule.setContent {
HermesControlTheme {
ContextUsageChip(
usedTokens = 64_000L,
fullTokens = 128_000L,
)
}
}
composeTestRule
.onNodeWithText("64k / 128k context")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("50%")
.assertIsDisplayed()
}
@OptIn(ExperimentalMaterial3Api::class)
@Test
fun detailSheet_missingBackendOccupancy_doesNotUseCumulativeInputTokensAsNumerator() {
composeTestRule.setContent {
HermesControlTheme {
ContextDetailSheet(
breakdown =
ContextBreakdown(
inputTokens = 12_300L,
outputTokens = 2_000L,
cacheReadTokens = 500L,
cacheWriteTokens = 250L,
reasoningTokens = 100L,
messageCount = 4,
),
usedTokens = null,
fullTokens = 128_000L,
onDismiss = {},
)
}
}
// The live numerator is unavailable, even though cumulative REST
// input-token accounting exists below in the detail breakdown.
composeTestRule
.onNodeWithText("—")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Context window usage unavailable")
.assertIsDisplayed()
// Cumulative accounting is still preserved as informational detail.
composeTestRule
.onNodeWithText("12.3k")
.assertIsDisplayed()
// Most important regression guard: null occupancy is not rendered as
// a determinate zero-percent context state.
composeTestRule
.onNodeWithText("0% of context window used")
.assertDoesNotExist()
}
}
@@ -80,7 +80,7 @@ object EventParser {
} }
"session.info" -> { "session.info" -> {
WsEvent.SessionInfo(payload) WsEvent.SessionInfo(payload, sessionId)
} }
"message.start" -> { "message.start" -> {
@@ -14,6 +14,7 @@ sealed class WsEvent {
data class SessionInfo( data class SessionInfo(
val data: Map<String, Any?>?, val data: Map<String, Any?>?,
val sessionId: String? = null,
) : WsEvent() ) : WsEvent()
// ── Message streaming ──────────────────────────────────────────────── // ── Message streaming ────────────────────────────────────────────────
@@ -72,6 +72,7 @@ object WsMethods {
const val COMMANDS_CATALOG = "commands.catalog" const val COMMANDS_CATALOG = "commands.catalog"
const val COMMAND_DISPATCH = "command.dispatch" const val COMMAND_DISPATCH = "command.dispatch"
const val SLASH_EXEC = "slash.exec" const val SLASH_EXEC = "slash.exec"
const val CONFIG_GET = "config.get"
const val CONFIG_SET = "config.set" const val CONFIG_SET = "config.set"
// ── Attachments ─────────────────────────────────────────────────────── // ── Attachments ───────────────────────────────────────────────────────
@@ -166,6 +167,8 @@ object WsMethods {
// Evidence: methods_session.py @_profile_scoped at each @method // Evidence: methods_session.py @_profile_scoped at each @method
// Binds HERMES_HOME so config/skills/pets resolve to the // Binds HERMES_HOME so config/skills/pets resolve to the
// focused profile. // focused profile.
CONFIG_GET, // methods_config.py: @method("config.get") + @_profile_scoped
CONFIG_SET, // methods_config_set.py: @method("config.set") + @_profile_scoped
"verification.status", // line 282 "verification.status", // line 282
PROJECTS_LIST, // methods_projects.py: @_registry.profile_scoped PROJECTS_LIST, // methods_projects.py: @_registry.profile_scoped
"pet.info", // line 1253 "pet.info", // line 1253
@@ -10,6 +10,8 @@ import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.ModelCatalogStore import com.m57.hermescontrol.data.ws.ModelCatalogStore
import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.toAny
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -18,6 +20,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
/** /**
@@ -58,6 +61,7 @@ class ChatModelSwitchDelegate(
{ refresh -> ModelCatalogStore.shared.ensureLoaded(forceRefresh = refresh) }, { refresh -> ModelCatalogStore.shared.ensureLoaded(forceRefresh = refresh) },
private val getPinnedModels: () -> List<PinnedModel> = { AuthManager.getPinnedModels() }, private val getPinnedModels: () -> List<PinnedModel> = { AuthManager.getPinnedModels() },
private val savePinnedModels: (List<PinnedModel>) -> Unit = { AuthManager.savePinnedModels(it) }, private val savePinnedModels: (List<PinnedModel>) -> Unit = { AuthManager.savePinnedModels(it) },
private val wsRequest: (suspend (method: String, params: Map<String, Any>) -> Any?)? = null,
) { ) {
private val pendingModelSwitchRequests = ConcurrentHashMap<String, ActiveModelSwitch>() private val pendingModelSwitchRequests = ConcurrentHashMap<String, ActiveModelSwitch>()
private val pendingFastSwitchRequests = ConcurrentHashMap<String, ActiveFastSwitch>() private val pendingFastSwitchRequests = ConcurrentHashMap<String, ActiveFastSwitch>()
@@ -67,6 +71,8 @@ class ChatModelSwitchDelegate(
private var lastConfirmedModel: String? = null private var lastConfirmedModel: String? = null
private var unconfirmedTargetModel: String? = null private var unconfirmedTargetModel: String? = null
private var modelSwitchSequence: Long = 0L private var modelSwitchSequence: Long = 0L
private var reasoningOpSequence: Long = 0L
private var reasoningJob: Job? = null
private var cachedModelOptions: List<ModelProvider> = emptyList() private var cachedModelOptions: List<ModelProvider> = emptyList()
private var cachedModelOptionsScope: DataScope? = dataScopeFlow.value private var cachedModelOptionsScope: DataScope? = dataScopeFlow.value
private var modelOptionsJob: Job? = null private var modelOptionsJob: Job? = null
@@ -460,21 +466,110 @@ class ChatModelSwitchDelegate(
optimisticPreviousModel = null optimisticPreviousModel = null
lastConfirmedModel = null lastConfirmedModel = null
unconfirmedTargetModel = null unconfirmedTargetModel = null
reasoningOpSequence++
reasoningJob?.cancel()
reasoningJob = null
uiState.update {
it.copy(
pendingReasoningLevel = null,
reasoningWireLevel = null,
)
}
} }
fun setReasoningLevel(level: String?) { fun setReasoningLevel(
uiState.update { it.copy(reasoningLevel = level) } level: String?,
val sessionId = runtimeSessionId() ?: return scopeName: String = "session",
enforceCapabilities: Boolean = true,
) {
if (level == null) return if (level == null) return
val currentPending = uiState.value.pendingReasoningLevel
if (currentPending != null) return // One reasoning write at a time
val sessionId = runtimeSessionId() ?: return
if (sessionId.isBlank()) return
if (scopeName != "session" && scopeName != "global") return
// Capability validation
if (enforceCapabilities) {
val caps = uiState.value.currentModelCapabilities
if (caps?.reasoning == false) return
if (level == "none" && caps?.can_disable_reasoning == false) return
}
val opSeq = ++reasoningOpSequence
val capturedScope = dataScopeFlow.value
// The old wire value belongs to the previous requested effort. Clear it
// before starting the RPC so neither possible ordering can lie:
// ACK first -> wire stays unknown until session.info arrives.
// session.info first -> its fresh wire survives the later ACK.
uiState.update {
it.copy(
pendingReasoningLevel = level,
reasoningWireLevel = null,
)
}
reasoningJob?.cancel()
reasoningJob =
scope.launch(ioDispatcher) { scope.launch(ioDispatcher) {
wsSend( try {
WsMethods.CONFIG_SET, val params =
mapOf( mapOf(
"key" to "reasoning", "key" to "reasoning",
"value" to level, "value" to level,
"session_id" to sessionId, "session_id" to sessionId,
), "scope" to scopeName,
) { id -> trackRequest(id, WsMethods.CONFIG_SET) } )
val result =
if (wsRequest != null) {
wsRequest.invoke(WsMethods.CONFIG_SET, params)
} else {
wsSend(WsMethods.CONFIG_SET, params) { reqId ->
trackRequest(reqId, WsMethods.CONFIG_SET)
}
null
}
if (opSeq == reasoningOpSequence && capturedScope == dataScopeFlow.value) {
val ackLevel =
if (wsRequest != null) {
// HermesWsClient.request() completes with the RAW JsonElement.
// Tests and a few injected callers may provide an already-decoded map.
val map =
when (result) {
is JsonElement -> result.toAny() as? Map<*, *>
is Map<*, *> -> result
else -> null
}
val resKey = map?.get("key") as? String
val resVal = map?.get("value") as? String
if (resKey != "reasoning" || resVal.isNullOrEmpty()) {
throw IllegalStateException("Invalid reasoning config.set response")
}
resVal
} else {
level
}
uiState.update { state ->
state.copy(
reasoningLevel = ackLevel,
pendingReasoningLevel = null,
)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
if (opSeq == reasoningOpSequence && capturedScope == dataScopeFlow.value) {
uiState.update { state ->
state.copy(
pendingReasoningLevel = null,
errorMessage = "Could not confirm reasoning change: ${e.message ?: e.toString()}",
)
}
}
}
} }
} }
@@ -808,6 +808,8 @@ fun ChatScreen(
currentSessionModel = state.currentSessionModel, currentSessionModel = state.currentSessionModel,
showModelProvider = state.showModelProvider, showModelProvider = state.showModelProvider,
reasoningLevel = state.reasoningLevel, reasoningLevel = state.reasoningLevel,
reasoningWireLevel = state.reasoningWireLevel,
pendingReasoningLevel = state.pendingReasoningLevel,
onModelTap = { viewModel.openModelPicker() }, onModelTap = { viewModel.openModelPicker() },
onReasoningTap = { level -> viewModel.setReasoningLevel(level) }, onReasoningTap = { level -> viewModel.setReasoningLevel(level) },
canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning, canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning,
@@ -27,6 +27,7 @@ import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.toAny
import com.m57.hermescontrol.data.ws.toJsonElement import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.notification.captureTurnBoundary import com.m57.hermescontrol.notification.captureTurnBoundary
import com.m57.hermescontrol.notification.correlationScopeId import com.m57.hermescontrol.notification.correlationScopeId
@@ -45,6 +46,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
@@ -56,6 +58,42 @@ import java.util.concurrent.ConcurrentHashMap
private const val TAG = "ChatViewModel" private const val TAG = "ChatViewModel"
private const val MESSAGE_PAGE_SIZE = 150 private const val MESSAGE_PAGE_SIZE = 150
private val REASONING_EFFORT_LEVELS =
setOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
private data class ReasoningSlashArgs(
val value: String,
val scopeName: String?,
)
/** Mirrors Desktop's reasoningSlashParams(): flags may appear in any order. */
private fun parseReasoningSlashArgs(arg: String): ReasoningSlashArgs? {
var scopeName: String? = null
val values = mutableListOf<String>()
arg
.trim()
.split(Regex("\\s+"))
.filter { it.isNotBlank() }
.forEach { part ->
when (part.lowercase()) {
"--global", "-g", "global" -> scopeName = "global"
"--session", "-s", "session" -> scopeName = "session"
else -> values += part
}
}
if (values.isEmpty()) return null
return ReasoningSlashArgs(values.joinToString(" "), scopeName)
}
private fun rpcResultMap(result: Any?): Map<*, *>? =
when (result) {
is JsonElement -> result.toAny() as? Map<*, *>
is Map<*, *> -> result
else -> null
}
private data class PreparedAttachment( private data class PreparedAttachment(
val attachment: Attachment, val attachment: Attachment,
val encodedFile: File, val encodedFile: File,
@@ -145,6 +183,10 @@ data class ChatUiState(
val currentModelCapabilities: ModelCapabilities? = null, val currentModelCapabilities: ModelCapabilities? = null,
// Reasoning effort level for the current session // Reasoning effort level for the current session
val reasoningLevel: String? = null, val reasoningLevel: String? = null,
// Gateway-reported effective effort on the active route (clamped level, e.g. "max" when requested was "ultra")
val reasoningWireLevel: String? = null,
// In-flight reasoning effort currently applying via config.set
val pendingReasoningLevel: String? = null,
// Fast mode / Priority processing state for the current session // Fast mode / Priority processing state for the current session
val fastMode: Boolean = false, val fastMode: Boolean = false,
val isFastModeChanging: Boolean = false, val isFastModeChanging: Boolean = false,
@@ -479,6 +521,7 @@ class ChatViewModel(
handleSlashCommand = { cmd -> handleSlashCommand(cmd) }, handleSlashCommand = { cmd -> handleSlashCommand(cmd) },
fetchContextUsage = { fetchContextUsage() }, fetchContextUsage = { fetchContextUsage() },
onModelSwitchInitiated = { onModelSwitchInitiated() }, onModelSwitchInitiated = { onModelSwitchInitiated() },
wsRequest = { method, params -> wsClient.request(method, params).await() },
).apply { ).apply {
attachScopeObserver(viewModelScope) attachScopeObserver(viewModelScope)
} }
@@ -788,8 +831,12 @@ class ChatViewModel(
} }
is WsEvent.SessionInfo -> { is WsEvent.SessionInfo -> {
val current = runtimeSessionId ?: _uiState.value.currentSessionId
// Ignore session.info events clearly tagged for a different session
if (event.sessionId == null || current == null || event.sessionId == current) {
handleSessionInfo(event.data) handleSessionInfo(event.data)
} }
}
is WsEvent.MessageToken -> { is WsEvent.MessageToken -> {
streamingController.handleMessageToken(event) streamingController.handleMessageToken(event)
@@ -1178,6 +1225,7 @@ class ChatViewModel(
val model = info["model"] as? String val model = info["model"] as? String
val provider = info["provider"] as? String val provider = info["provider"] as? String
val reasoningEffort = info["reasoning_effort"] as? String val reasoningEffort = info["reasoning_effort"] as? String
val reasoningEffortWire = info["reasoning_effort_wire"] as? String
val terminalBackend = info["terminal_backend"] as? String val terminalBackend = info["terminal_backend"] as? String
val serviceTier = (info["service_tier"] as? String)?.trim()?.lowercase() val serviceTier = (info["service_tier"] as? String)?.trim()?.lowercase()
val fastFlag = val fastFlag =
@@ -1209,14 +1257,24 @@ class ChatViewModel(
modelSwitchDelegate.onModelConfirmed(newModelLabel) modelSwitchDelegate.onModelConfirmed(newModelLabel)
} }
_uiState.update { state -> _uiState.update { state ->
state.copy( val newEffort =
currentSessionModel = newModelLabel ?: state.currentSessionModel, if (reasoningEffort != null) {
reasoningLevel = if (reasoningEffort.isEmpty()) null else reasoningEffort
if (reasoningEffort.isNullOrEmpty()) { } else {
state.reasoningLevel
}
val newWire =
if (reasoningEffortWire != null) {
if (reasoningEffortWire.isEmpty()) null else reasoningEffortWire
} else if (newEffort != state.reasoningLevel || modelSwapped) {
null null
} else { } else {
reasoningEffort state.reasoningWireLevel
}, }
state.copy(
currentSessionModel = newModelLabel ?: state.currentSessionModel,
reasoningLevel = newEffort,
reasoningWireLevel = newWire,
fastMode = fastFlag ?: state.fastMode, fastMode = fastFlag ?: state.fastMode,
isFastModeChanging = if (fastFlag != null) false else state.isFastModeChanging, isFastModeChanging = if (fastFlag != null) false else state.isFastModeChanging,
terminalBackend = terminalBackend ?: state.terminalBackend, terminalBackend = terminalBackend ?: state.terminalBackend,
@@ -1388,6 +1446,7 @@ class ChatViewModel(
val model = infoMap?.get("model") as? String val model = infoMap?.get("model") as? String
val provider = infoMap?.get("provider") as? String val provider = infoMap?.get("provider") as? String
val reasoningEffort = infoMap?.get("reasoning_effort") as? String val reasoningEffort = infoMap?.get("reasoning_effort") as? String
val reasoningEffortWire = infoMap?.get("reasoning_effort_wire") as? String
val terminalBackend = infoMap?.get("terminal_backend") as? String val terminalBackend = infoMap?.get("terminal_backend") as? String
val serviceTier = (infoMap?.get("service_tier") as? String)?.trim()?.lowercase() val serviceTier = (infoMap?.get("service_tier") as? String)?.trim()?.lowercase()
val fastFlag = val fastFlag =
@@ -1416,6 +1475,12 @@ class ChatViewModel(
} else { } else {
reasoningEffort reasoningEffort
}, },
reasoningWireLevel =
if (reasoningEffortWire.isNullOrEmpty()) {
null
} else {
reasoningEffortWire
},
fastMode = fastFlag ?: false, fastMode = fastFlag ?: false,
isFastModeChanging = false, isFastModeChanging = false,
terminalBackend = terminalBackend ?: it.terminalBackend, terminalBackend = terminalBackend ?: it.terminalBackend,
@@ -1705,6 +1770,7 @@ class ChatViewModel(
} }
private fun canSubmitMessage(): Boolean = private fun canSubmitMessage(): Boolean =
_uiState.value.pendingReasoningLevel == null &&
wsClient.connectionStatus.value == ConnectionStatus.CONNECTED && wsClient.connectionStatus.value == ConnectionStatus.CONNECTED &&
( (
(_uiState.value.isSessionReady && runtimeSessionId != null) || (_uiState.value.isSessionReady && runtimeSessionId != null) ||
@@ -2127,6 +2193,10 @@ class ChatViewModel(
modelSwitchDelegate.handleModelSwitch(command) modelSwitchDelegate.handleModelSwitch(command)
} }
is SlashResult.ReasoningSwitch -> {
handleReasoningSlashCommand(result.level)
}
is SlashResult.Update -> { is SlashResult.Update -> {
openUpdateConfirm() openUpdateConfirm()
} }
@@ -2174,6 +2244,97 @@ class ChatViewModel(
submitPrompt(arg, queued = true) submitPrompt(arg, queued = true)
} }
private fun handleReasoningSlashCommand(arg: String) {
val sessionId = runtimeSessionId
if (sessionId.isNullOrBlank()) {
addAssistantMessage("Reasoning controls require an active session.")
return
}
val parsed = parseReasoningSlashArgs(arg)
// Bare `/reasoning` is a status query, matching Desktop/TUI semantics.
if (parsed == null) {
viewModelScope.launch(ioDispatcher) {
try {
val result =
wsClient
.request(
WsMethods.CONFIG_GET,
mapOf(
"key" to "reasoning",
"session_id" to sessionId,
),
).await()
val map = rpcResultMap(result) ?: error("Invalid reasoning config.get response")
val value = (map["value"] as? String)?.takeIf { it.isNotBlank() } ?: "unknown"
val display = (map["display"] as? String)?.takeIf { it.isNotBlank() } ?: "unknown"
addAssistantMessage("reasoning: $value · display $display")
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
addAssistantMessage("Could not read reasoning status: ${e.message ?: e.toString()}")
}
}
return
}
val value = parsed.value.trim().lowercase()
// Effort changes use the acknowledged delegate so the composer shows
// pending state and prompt submission remains gated until config.set
// succeeds. A global preference may target a future model, so do not
// reject it based on only the current model's capabilities.
if (value in REASONING_EFFORT_LEVELS) {
if (parsed.scopeName != "global") {
val caps = _uiState.value.currentModelCapabilities
if (caps?.reasoning == false) {
addAssistantMessage("Reasoning is not supported for the current model.")
return
}
if (value == "none" && caps?.can_disable_reasoning == false) {
addAssistantMessage("Reasoning cannot be disabled for this model (always on).")
return
}
}
modelSwitchDelegate.setReasoningLevel(
value,
scopeName = parsed.scopeName ?: "session",
enforceCapabilities = parsed.scopeName != "global",
)
return
}
// Display controls (`show`/`hide`/`full`/`clamp` and backend aliases)
// and future config.set reasoning values go through the canonical
// backend parser instead of a client-maintained allowlist. Invalid
// values therefore surface the real 4002 error instead of being eaten
// locally. Scope flags are forwarded exactly like Desktop.
viewModelScope.launch(ioDispatcher) {
try {
val params =
buildMap<String, Any> {
put("key", "reasoning")
put("value", parsed.value)
put("session_id", sessionId)
parsed.scopeName?.let { put("scope", it) }
}
val result = wsClient.request(WsMethods.CONFIG_SET, params).await()
val map = rpcResultMap(result) ?: error("Invalid reasoning config.set response")
val responseKey = map["key"] as? String
val responseValue = map["value"] as? String
if (responseKey != "reasoning" || responseValue.isNullOrBlank()) {
error("Invalid reasoning config.set response")
}
addAssistantMessage("reasoning: $responseValue")
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
addAssistantMessage("Could not change reasoning: ${e.message ?: e.toString()}")
}
}
}
// ── Side Questions via /btw (issue #1015) ───────────────────────────── // ── Side Questions via /btw (issue #1015) ─────────────────────────────
fun dismissBtw() { fun dismissBtw() {
@@ -2962,6 +3123,8 @@ class ChatViewModel(
currentSessionModel = null, currentSessionModel = null,
currentModelCapabilities = null, currentModelCapabilities = null,
reasoningLevel = null, reasoningLevel = null,
reasoningWireLevel = null,
pendingReasoningLevel = null,
fastMode = false, fastMode = false,
isFastModeChanging = false, isFastModeChanging = false,
terminalBackend = null, terminalBackend = null,
@@ -42,6 +42,11 @@ class SlashCommandDispatcher {
SlashResult.ModelSwitch SlashResult.ModelSwitch
} }
"/reasoning" -> {
val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim()
SlashResult.ReasoningSwitch(level = arg)
}
"/update" -> { "/update" -> {
SlashResult.Update SlashResult.Update
} }
@@ -103,6 +108,16 @@ sealed class SlashResult {
*/ */
data object ModelSwitch : SlashResult() data object ModelSwitch : SlashResult()
/**
* Handle `/reasoning` through the gateway's first-class config RPCs rather
* than command.dispatch (which 4018s on it). [level] intentionally carries
* the raw argument tail: it may be an effort, a display word
* (`show`/`hide`/`full`/`clamp`), or include `--global` / `--session`.
*/
data class ReasoningSwitch(
val level: String,
) : SlashResult()
/** /**
* Trigger the backend update via the REST action API * Trigger the backend update via the REST action API
* (`POST /api/hermes/update`, the System screen's flow) and track it in * (`POST /api/hermes/update`, the System screen's flow) and track it in
@@ -101,12 +101,16 @@ fun ChatInputBar(
isFastModeChanging: Boolean = false, isFastModeChanging: Boolean = false,
onToggleFastMode: () -> Unit = {}, onToggleFastMode: () -> Unit = {},
showModelProvider: Boolean = false, showModelProvider: Boolean = false,
reasoningWireLevel: String? = null,
pendingReasoningLevel: String? = null,
) { ) {
// Allow sending while the agent is mid-turn or awaiting approval: the // Allow sending while the agent is mid-turn or awaiting approval: the
// gateway's prompt.submit busy-input policy queues it as the next turn // gateway's prompt.submit busy-input policy queues it as the next turn
// (tui_gateway/server.py:_handle_busy_submit), so the message is never // (tui_gateway/server.py:_handle_busy_submit), so the message is never
// dropped. Slash commands were already allowed; regular prompts now are too. // dropped. Slash commands were already allowed; regular prompts now are too.
val canSend = ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady) val canSend =
pendingReasoningLevel == null &&
ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady)
// Attachment tray state // Attachment tray state
var showAttachmentTray by remember { mutableStateOf(false) } var showAttachmentTray by remember { mutableStateOf(false) }
@@ -329,6 +333,9 @@ fun ChatInputBar(
isFastModeChanging = isFastModeChanging, isFastModeChanging = isFastModeChanging,
onToggleFastMode = onToggleFastMode, onToggleFastMode = onToggleFastMode,
showModelProvider = showModelProvider, showModelProvider = showModelProvider,
reasoningWireLevel = reasoningWireLevel,
pendingReasoningLevel = pendingReasoningLevel,
isSessionReady = isSessionReady,
) )
AttachmentTray( AttachmentTray(
@@ -104,6 +104,9 @@ fun ComposerToolbar(
isFastModeChanging: Boolean = false, isFastModeChanging: Boolean = false,
onToggleFastMode: () -> Unit = {}, onToggleFastMode: () -> Unit = {},
showModelProvider: Boolean = false, showModelProvider: Boolean = false,
reasoningWireLevel: String? = null,
pendingReasoningLevel: String? = null,
isSessionReady: Boolean = true,
) { ) {
var showReasoningMenu by remember { mutableStateOf(false) } var showReasoningMenu by remember { mutableStateOf(false) }
val palette = composerPalette() val palette = composerPalette()
@@ -179,11 +182,12 @@ fun ComposerToolbar(
} }
Box { Box {
val reasoningInteractive = isConnected && isSessionReady && pendingReasoningLevel == null
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxHeight() .fillMaxHeight()
.clickable(enabled = isConnected) { showReasoningMenu = true } .clickable(enabled = reasoningInteractive) { showReasoningMenu = true }
.padding(start = 6.dp, end = 12.dp) .padding(start = 6.dp, end = 12.dp)
.testTag("reasoning_chip"), .testTag("reasoning_chip"),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -191,7 +195,7 @@ fun ComposerToolbar(
) { ) {
val levelColor = val levelColor =
palette.onControlVariant.copy( palette.onControlVariant.copy(
alpha = if (isConnected) 1f else 0.38f, alpha = if (reasoningInteractive) 1f else 0.38f,
) )
if (fastMode) { if (fastMode) {
Icon( Icon(
@@ -201,12 +205,26 @@ fun ComposerToolbar(
modifier = Modifier.size(14.dp), modifier = Modifier.size(14.dp),
) )
} }
if (pendingReasoningLevel != null) {
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 1.5.dp,
color = MaterialTheme.colorScheme.primary,
)
}
Text( Text(
text = text =
if (reasoningDisabledForModel) { if (reasoningDisabledForModel) {
if (fastMode) stringResource(R.string.chat_fast_mode_label) else "No reasoning" if (fastMode) stringResource(R.string.chat_fast_mode_label) else "No reasoning"
} else { } else {
val rLabel = buildReasoningLabel(reasoningLevel) val targetEffort = pendingReasoningLevel ?: reasoningLevel
val wire = if (pendingReasoningLevel != null) null else reasoningWireLevel
val rLabel =
buildReasoningLabel(
level = targetEffort,
wireLevel = wire,
defaultLabel = stringResource(R.string.chat_reasoning_default),
)
if (fastMode) { if (fastMode) {
"${stringResource( "${stringResource(
R.string.chat_fast_mode_label, R.string.chat_fast_mode_label,
@@ -332,29 +350,57 @@ fun ComposerToolbar(
allLevels.forEach { (level, label) -> allLevels.forEach { (level, label) ->
val isNone = level == "none" val isNone = level == "none"
val noneDisabled = isNone && (canDisable == false || reasoningDisabledForModel) val noneDisabled = isNone && (canDisable == false || reasoningDisabledForModel)
val isSelected = (pendingReasoningLevel ?: reasoningLevel) == level
val isClampedTarget =
isSelected && reasoningWireLevel != null &&
!reasoningWireLevel.equals(level, ignoreCase = true)
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Column {
Text( Text(
text = label, text = label,
fontWeight = fontWeight =
if (reasoningLevel == level) { if (isSelected) {
MaterialTheme.typography.bodyMedium.fontWeight MaterialTheme.typography.bodyMedium.fontWeight
} else { } else {
null null
}, },
color = color =
when { when {
noneDisabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) noneDisabled -> {
reasoningLevel == level -> MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.onSurface.copy(
else -> MaterialTheme.colorScheme.onSurface alpha = 0.38f,
)
}
isSelected -> {
MaterialTheme.colorScheme.primary
}
else -> {
MaterialTheme.colorScheme.onSurface
}
}, },
) )
if (isClampedTarget) {
Text(
text =
stringResource(
R.string.chat_reasoning_clamped_desc,
label,
buildReasoningLabel(reasoningWireLevel, defaultLabel = ""),
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
)
}
}
}, },
onClick = { onClick = {
showReasoningMenu = false showReasoningMenu = false
onReasoningSelected(level) onReasoningSelected(level)
}, },
enabled = !noneDisabled && !reasoningDisabledForModel, enabled = !noneDisabled && !reasoningDisabledForModel && pendingReasoningLevel == null,
) )
} }
} }
@@ -501,9 +547,14 @@ private const val CUSTOM_PROVIDER_PREFIX = "custom:"
* "xhigh", "max", "ultra", or null for model default. * "xhigh", "max", "ultra", or null for model default.
* @return Display string such as "None", "Low", "XHigh", "Ultra", etc. * @return Display string such as "None", "Low", "XHigh", "Ultra", etc.
*/ */
private fun buildReasoningLabel(level: String?): String = fun buildReasoningLabel(
level: String?,
wireLevel: String? = null,
defaultLabel: String = "Default",
): String {
val reqLabel =
when (level) { when (level) {
null -> "Med" null -> defaultLabel
"none" -> "None" "none" -> "None"
"minimal" -> "Minimal" "minimal" -> "Minimal"
"low" -> "Low" "low" -> "Low"
@@ -514,3 +565,19 @@ private fun buildReasoningLabel(level: String?): String =
"ultra" -> "Ultra" "ultra" -> "Ultra"
else -> level else -> level
} }
if (wireLevel != null && !wireLevel.equals(level, ignoreCase = true) && level != "none") {
val wireLabel =
when (wireLevel) {
"minimal" -> "Minimal"
"low" -> "Low"
"medium" -> "Med"
"high" -> "High"
"xhigh" -> "XHigh"
"max" -> "Max"
"ultra" -> "Ultra"
else -> wireLevel
}
return "$reqLabel→$wireLabel"
}
return reqLabel
}
@@ -51,12 +51,12 @@ fun ContextDetailSheet(
sheetState = sheetState, sheetState = sheetState,
) { ) {
val full = fullTokens ?: 0L val full = fullTokens ?: 0L
val used = usedTokens ?: breakdown.inputTokens val fraction = if (full > 0L && usedTokens != null) min(1f, usedTokens.toFloat() / full.toFloat()) else 0f
val fraction = if (full > 0L) min(1f, used.toFloat() / full.toFloat()) else 0f
val pct = (fraction * 100).toInt() val pct = (fraction * 100).toInt()
val statusColors = LocalHermesStatusColors.current val statusColors = LocalHermesStatusColors.current
val barColor = val barColor =
when { when {
usedTokens == null -> MaterialTheme.colorScheme.primary
pct >= 90 -> statusColors.error pct >= 90 -> statusColors.error
pct >= 70 -> statusColors.warning pct >= 70 -> statusColors.warning
else -> MaterialTheme.colorScheme.primary else -> MaterialTheme.colorScheme.primary
@@ -83,7 +83,7 @@ fun ContextDetailSheet(
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
) { ) {
Text( Text(
text = formatTokens(used), text = if (usedTokens != null) formatTokens(usedTokens) else "—",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = barColor, color = barColor,
@@ -115,7 +115,12 @@ fun ContextDetailSheet(
) )
} }
Text( Text(
text = "$pct% of context window used", text =
if (usedTokens != null) {
"$pct% of context window used"
} else {
stringResource(R.string.context_window_usage_unavailable)
},
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -56,12 +56,12 @@ fun ContextUsageChip(
val full = fullTokens val full = fullTokens
if (full == null || full <= 0L) return if (full == null || full <= 0L) return
val used = usedTokens ?: 0L val fraction = if (usedTokens != null) min(1f, usedTokens.toFloat() / full.toFloat()) else 0f
val fraction = min(1f, used.toFloat() / full.toFloat())
val pct = (fraction * 100).toInt() val pct = (fraction * 100).toInt()
val statusColors = LocalHermesStatusColors.current val statusColors = LocalHermesStatusColors.current
val barColor = val barColor =
when { when {
usedTokens == null -> MaterialTheme.colorScheme.primary
pct >= 90 -> statusColors.error pct >= 90 -> statusColors.error
pct >= 70 -> statusColors.warning pct >= 70 -> statusColors.warning
else -> MaterialTheme.colorScheme.primary else -> MaterialTheme.colorScheme.primary
@@ -90,7 +90,7 @@ fun ContextUsageChip(
Text( Text(
text = text =
if (usedTokens != null) { if (usedTokens != null) {
"${formatTokens(used)} / ${formatTokens(full)} context" "${formatTokens(usedTokens)} / ${formatTokens(full)} context"
} else { } else {
"— / ${formatTokens(full)} context" "— / ${formatTokens(full)} context"
}, },
@@ -111,6 +111,7 @@ fun ContextUsageChip(
maxLines = 1, maxLines = 1,
) )
} }
if (usedTokens != null) {
Text( Text(
text = "$pct%", text = "$pct%",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@@ -119,6 +120,7 @@ fun ContextUsageChip(
) )
} }
} }
}
Box( Box(
modifier = modifier =
Modifier Modifier
@@ -20,6 +20,7 @@ import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.refreshOnChange import com.m57.hermescontrol.ui.common.refreshOnChange
import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -99,6 +100,7 @@ class CronJobsViewModel :
val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow() val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow()
private var loadJob: Job? = null private var loadJob: Job? = null
internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
private val jobsCache = SwrCache<String, List<CronJob>>() private val jobsCache = SwrCache<String, List<CronJob>>()
fun clearScopeOwnedState() { fun clearScopeOwnedState() {
@@ -166,7 +168,7 @@ class CronJobsViewModel :
} }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.pauseCronJob(id) } safeApiCall { ApiClient.hermesApi.pauseCronJob(id) }
} }
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
@@ -197,7 +199,7 @@ class CronJobsViewModel :
} }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.resumeCronJob(id) } safeApiCall { ApiClient.hermesApi.resumeCronJob(id) }
} }
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
@@ -218,7 +220,7 @@ class CronJobsViewModel :
fun triggerCronJob(id: String) { fun triggerCronJob(id: String) {
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.triggerCronJob(id) } safeApiCall { ApiClient.hermesApi.triggerCronJob(id) }
} }
when (result) { when (result) {
@@ -241,7 +243,7 @@ class CronJobsViewModel :
} }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.deleteCronJob(id) } safeApiCall { ApiClient.hermesApi.deleteCronJob(id) }
} }
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
@@ -292,7 +294,7 @@ class CronJobsViewModel :
fun loadEditorBlueprints() { fun loadEditorBlueprints() {
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.getCronBlueprints() } safeApiCall { ApiClient.hermesApi.getCronBlueprints() }
} }
when (result) { when (result) {
@@ -319,7 +321,7 @@ class CronJobsViewModel :
fun loadDeliveryTargets() { fun loadDeliveryTargets() {
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.getCronDeliveryTargets() } safeApiCall { ApiClient.hermesApi.getCronDeliveryTargets() }
} }
if (result is NetworkResult.Success) { if (result is NetworkResult.Success) {
@@ -345,7 +347,7 @@ class CronJobsViewModel :
} }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.getCronJob(id) } safeApiCall { ApiClient.hermesApi.getCronJob(id) }
} }
when (result) { when (result) {
@@ -462,7 +464,7 @@ class CronJobsViewModel :
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
if (editor.isNew && editor.selectedBlueprintKey != null) { if (editor.isNew && editor.selectedBlueprintKey != null) {
safeApiCall { safeApiCall {
ApiClient.hermesApi.instantiateBlueprint( ApiClient.hermesApi.instantiateBlueprint(
@@ -33,6 +33,7 @@ import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl
import com.m57.hermescontrol.data.ws.KanbanEventsClient import com.m57.hermescontrol.data.ws.KanbanEventsClient
import com.m57.hermescontrol.data.ws.KanbanLiveStatus import com.m57.hermescontrol.data.ws.KanbanLiveStatus
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -166,6 +167,7 @@ class KanbanViewModel(
private var eventsBoard: String? = null private var eventsBoard: String? = null
private var reloadJob: Job? = null private var reloadJob: Job? = null
private var currentLoadGen: Int = 0 private var currentLoadGen: Int = 0
internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
fun loadBoards() { fun loadBoards() {
val endpoint = endpointProvider() val endpoint = endpointProvider()
@@ -184,7 +186,7 @@ class KanbanViewModel(
coroutineScope { coroutineScope {
val prefetchedBoardDeferred = val prefetchedBoardDeferred =
if (previouslySelectedId != null) { if (previouslySelectedId != null) {
async(Dispatchers.IO) { async(ioDispatcher) {
repository.getBoard( repository.getBoard(
board = previouslySelectedId, board = previouslySelectedId,
includeArchived = _uiState.value.includeArchived, includeArchived = _uiState.value.includeArchived,
@@ -15,6 +15,7 @@ import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.refreshOnChange import com.m57.hermescontrol.ui.common.refreshOnChange
import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -40,6 +41,7 @@ class PairingViewModel :
val uiState: StateFlow<PairingUiState> = _uiState.asStateFlow() val uiState: StateFlow<PairingUiState> = _uiState.asStateFlow()
private var launchJob: Job? = null private var launchJob: Job? = null
internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
private val pairingCache = SwrCache<String, PairingResponse>() private val pairingCache = SwrCache<String, PairingResponse>()
fun clearScopeOwnedState() { fun clearScopeOwnedState() {
@@ -140,7 +142,7 @@ class PairingViewModel :
if (_uiState.value.actionKey != null) return if (_uiState.value.actionKey != null) return
_uiState.update { it.copy(actionKey = actionKey) } _uiState.update { it.copy(actionKey = actionKey) }
viewModelScope.launch { viewModelScope.launch {
val result = withContext(Dispatchers.IO) { apiCall() } val result = withContext(ioDispatcher) { apiCall() }
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
_uiState.update { onSuccess(it.copy(actionKey = null)) } _uiState.update { onSuccess(it.copy(actionKey = null)) }
@@ -21,6 +21,7 @@ import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -98,6 +99,7 @@ class SkillsViewModel(
private var loadJob: Job? = null private var loadJob: Job? = null
private var searchJob: Job? = null private var searchJob: Job? = null
private val skillsCache = SwrCache<String, List<Skill>>() private val skillsCache = SwrCache<String, List<Skill>>()
internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
fun clearScopeOwnedState() { fun clearScopeOwnedState() {
_uiState.update { _uiState.update {
@@ -252,7 +254,7 @@ class SkillsViewModel(
} }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.scanHubSkill(identifier = identifier) } safeApiCall { ApiClient.hermesApi.scanHubSkill(identifier = identifier) }
} }
when (result) { when (result) {
@@ -307,7 +309,7 @@ class SkillsViewModel(
} }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.previewHubSkill(identifier = identifier) } safeApiCall { ApiClient.hermesApi.previewHubSkill(identifier = identifier) }
} }
when (result) { when (result) {
@@ -343,7 +345,7 @@ class SkillsViewModel(
} }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.installHubSkill(SkillHubInstallRequest(identifier = identifier)) } safeApiCall { ApiClient.hermesApi.installHubSkill(SkillHubInstallRequest(identifier = identifier)) }
} }
when (result) { when (result) {
@@ -381,7 +383,7 @@ class SkillsViewModel(
} }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.uninstallHubSkill(SkillHubUninstallRequest(name = name)) } safeApiCall { ApiClient.hermesApi.uninstallHubSkill(SkillHubUninstallRequest(name = name)) }
} }
when (result) { when (result) {
@@ -438,7 +440,7 @@ class SkillsViewModel(
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.toggleSkill(ToggleSkillRequest(skill.name, targetEnabled)) } safeApiCall { ApiClient.hermesApi.toggleSkill(ToggleSkillRequest(skill.name, targetEnabled)) }
} }
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
@@ -489,7 +491,7 @@ class SkillsViewModel(
} }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.getSkillContent(skillName) } safeApiCall { ApiClient.hermesApi.getSkillContent(skillName) }
} }
when (result) { when (result) {
@@ -522,7 +524,7 @@ class SkillsViewModel(
_uiState.update { it.copy(isSavingContent = true, saveContentSuccess = false) } _uiState.update { it.copy(isSavingContent = true, saveContentSuccess = false) }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { safeApiCall {
ApiClient.hermesApi.saveSkillContent( ApiClient.hermesApi.saveSkillContent(
SaveSkillContentRequest( SaveSkillContentRequest(
@@ -560,7 +562,7 @@ class SkillsViewModel(
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(toastMessage = "Updating skills from hub…") } _uiState.update { it.copy(toastMessage = "Updating skills from hub…") }
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.updateSkillsFromHub() } safeApiCall { ApiClient.hermesApi.updateSkillsFromHub() }
} }
when (result) { when (result) {
+5 -1
View File
@@ -1294,6 +1294,9 @@
<string name="chat_fast_mode_label">سريع</string> <string name="chat_fast_mode_label">سريع</string>
<string name="chat_fast_mode_desc">الوضع السريع</string> <string name="chat_fast_mode_desc">الوضع السريع</string>
<string name="chat_fast_mode_unavailable">الوضع السريع غير مدعوم لهذا النموذج</string> <string name="chat_fast_mode_unavailable">الوضع السريع غير مدعوم لهذا النموذج</string>
<string name="chat_reasoning_default">الافتراضي</string>
<string name="chat_reasoning_applying">جارٍ تطبيق الاستدلال…</string>
<string name="chat_reasoning_clamped_desc">المطلوب %1$s، يرسل المسار %2$s</string>
<string name="chat_save_attachment">حفظ %1$s</string> <string name="chat_save_attachment">حفظ %1$s</string>
<string name="chat_switch_model_title">تبديل النموذج (لهذه المحادثة)</string> <string name="chat_switch_model_title">تبديل النموذج (لهذه المحادثة)</string>
<string name="config_clear_value">مسح القيمة</string> <string name="config_clear_value">مسح القيمة</string>
@@ -1308,8 +1311,9 @@
<string name="context_completion_output">الإكمال (المخرجات)</string> <string name="context_completion_output">الإكمال (المخرجات)</string>
<string name="context_compressed">مضغوط ×%1$s</string> <string name="context_compressed">مضغوط ×%1$s</string>
<string name="context_prompt_input">المطالبة (المدخلات)</string> <string name="context_prompt_input">المطالبة (المدخلات)</string>
<string name="context_tokens_note">تعكس رموز المطالبة إجمالي السياق المستخدم في هذه الجلسة حتى الآن.</string> <string name="context_tokens_note">أعداد الرموز أدناه تراكمية للجلسة ولا تمثل الاستخدام الحالي لنافذة السياق.</string>
<string name="context_window">نافذة السياق</string> <string name="context_window">نافذة السياق</string>
<string name="context_window_usage_unavailable">استخدام نافذة السياق غير متوفر</string>
<string name="gif_play">تشغيل GIF</string> <string name="gif_play">تشغيل GIF</string>
<string name="kanban_filter_placeholder">تصفية المهام حسب العنوان أو الحالة أو المسؤول...</string> <string name="kanban_filter_placeholder">تصفية المهام حسب العنوان أو الحالة أو المسؤول...</string>
<string name="logs_clipboard_label">سجلات Hermes</string> <string name="logs_clipboard_label">سجلات Hermes</string>
+3
View File
@@ -1140,6 +1140,9 @@
<string name="chat_fast_mode_label">빠름</string> <string name="chat_fast_mode_label">빠름</string>
<string name="chat_fast_mode_desc">빠른 모드</string> <string name="chat_fast_mode_desc">빠른 모드</string>
<string name="chat_fast_mode_unavailable">이 모델에서는 빠른 모드를 사용할 수 없습니다</string> <string name="chat_fast_mode_unavailable">이 모델에서는 빠른 모드를 사용할 수 없습니다</string>
<string name="chat_reasoning_default">기본값</string>
<string name="chat_reasoning_applying">추론 설정 적용 중…</string>
<string name="chat_reasoning_clamped_desc">요청 %1$s, 경로 전송 %2$s</string>
<!-- Session Integrations (issue #1091) --> <!-- Session Integrations (issue #1091) -->
<string name="session_integrations_title">연동</string> <string name="session_integrations_title">연동</string>
+5 -1
View File
@@ -1261,6 +1261,9 @@
<string name="chat_fast_mode_label">快速</string> <string name="chat_fast_mode_label">快速</string>
<string name="chat_fast_mode_desc">快速模式</string> <string name="chat_fast_mode_desc">快速模式</string>
<string name="chat_fast_mode_unavailable">此模型不支持快速模式</string> <string name="chat_fast_mode_unavailable">此模型不支持快速模式</string>
<string name="chat_reasoning_default">默认</string>
<string name="chat_reasoning_applying">正在应用推理设置…</string>
<string name="chat_reasoning_clamped_desc">请求 %1$s,路由实际发送 %2$s</string>
<string name="chat_save_attachment">保存 %1$s</string> <string name="chat_save_attachment">保存 %1$s</string>
<string name="chat_switch_model_title">切换模型(当前对话)</string> <string name="chat_switch_model_title">切换模型(当前对话)</string>
<string name="config_clear_value">清除值</string> <string name="config_clear_value">清除值</string>
@@ -1275,8 +1278,9 @@
<string name="context_completion_output">补全(输出)</string> <string name="context_completion_output">补全(输出)</string>
<string name="context_compressed">已压缩 ×%1$s</string> <string name="context_compressed">已压缩 ×%1$s</string>
<string name="context_prompt_input">提示词(输入)</string> <string name="context_prompt_input">提示词(输入)</string>
<string name="context_tokens_note">提示词 token 反映此会话迄今使用的总上下文。</string> <string name="context_tokens_note">下方 token 数为会话累计用量,不代表当前上下文窗口占用。</string>
<string name="context_window">上下文窗口</string> <string name="context_window">上下文窗口</string>
<string name="context_window_usage_unavailable">上下文窗口使用量不可用</string>
<string name="gif_play">播放 GIF</string> <string name="gif_play">播放 GIF</string>
<string name="kanban_filter_placeholder">按标题、状态或负责人筛选任务…</string> <string name="kanban_filter_placeholder">按标题、状态或负责人筛选任务…</string>
<string name="logs_clipboard_label">Hermes 日志</string> <string name="logs_clipboard_label">Hermes 日志</string>
+5 -1
View File
@@ -1420,6 +1420,9 @@
<string name="chat_fast_mode_label">Fast</string> <string name="chat_fast_mode_label">Fast</string>
<string name="chat_fast_mode_desc">Fast mode</string> <string name="chat_fast_mode_desc">Fast mode</string>
<string name="chat_fast_mode_unavailable">Fast mode unavailable for this model</string> <string name="chat_fast_mode_unavailable">Fast mode unavailable for this model</string>
<string name="chat_reasoning_default">Default</string>
<string name="chat_reasoning_applying">Applying reasoning…</string>
<string name="chat_reasoning_clamped_desc">Requested %1$s, route sends %2$s</string>
<string name="chat_reasoning_menu_desc">Reasoning options</string> <string name="chat_reasoning_menu_desc">Reasoning options</string>
<string name="chat_save_attachment">Save %1$s</string> <string name="chat_save_attachment">Save %1$s</string>
<string name="chat_switch_model_title">Switch model (this chat)</string> <string name="chat_switch_model_title">Switch model (this chat)</string>
@@ -1435,8 +1438,9 @@
<string name="context_completion_output">Completion (output)</string> <string name="context_completion_output">Completion (output)</string>
<string name="context_compressed">compressed ×%1$s</string> <string name="context_compressed">compressed ×%1$s</string>
<string name="context_prompt_input">Prompt (input)</string> <string name="context_prompt_input">Prompt (input)</string>
<string name="context_tokens_note">Prompt tokens reflect total context used by this session so far.</string> <string name="context_tokens_note">Token counts below are cumulative session accounting and do not represent current context-window occupancy.</string>
<string name="context_window">Context window</string> <string name="context_window">Context window</string>
<string name="context_window_usage_unavailable">Context window usage unavailable</string>
<string name="gif_play">Play GIF</string> <string name="gif_play">Play GIF</string>
<string name="kanban_filter_placeholder">Filter tasks by title, status, or assignee...</string> <string name="kanban_filter_placeholder">Filter tasks by title, status, or assignee...</string>
<string name="logs_clipboard_label">Hermes Logs</string> <string name="logs_clipboard_label">Hermes Logs</string>
@@ -715,11 +715,17 @@ class EventParserTest {
result = null, result = null,
error = null, error = null,
method = "event", method = "event",
params = mapOf("type" to "session.info", "payload" to mapOf("session_id" to "sess-1")), params =
mapOf(
"type" to "session.info",
"session_id" to "sess-1",
"payload" to mapOf("session_id" to "sess-1"),
),
) )
val event = EventParser.parse(response) val event = EventParser.parse(response)
assertTrue(event is WsEvent.SessionInfo) assertTrue(event is WsEvent.SessionInfo)
assertEquals(mapOf("session_id" to "sess-1"), (event as WsEvent.SessionInfo).data) assertEquals(mapOf("session_id" to "sess-1"), (event as WsEvent.SessionInfo).data)
assertEquals("sess-1", event.sessionId)
} }
@Test @Test
@@ -88,6 +88,21 @@ class WsProfileParamsTest {
assertEquals("meow", result["profile"]) assertEquals("meow", result["profile"])
} }
@Test
fun `config get and set are profile scoped`() {
AuthManager.setActiveProfileId("work")
val getResult = WsProfileParams.decorate(WsMethods.CONFIG_GET, mapOf("key" to "reasoning"))
val setResult =
WsProfileParams.decorate(
WsMethods.CONFIG_SET,
mapOf("key" to "reasoning", "value" to "high"),
)
assertEquals("work", getResult["profile"])
assertEquals("work", setResult["profile"])
}
@Test @Test
fun `profile_scoped decorator method gets profile injected`() { fun `profile_scoped decorator method gets profile injected`() {
AuthManager.setActiveProfileId("meow") AuthManager.setActiveProfileId("meow")
@@ -430,6 +430,163 @@ class ChatModelSwitchDelegateTest {
assertEquals("anthropic/claude-3-5-sonnet", uiState.value.currentSessionModel) assertEquals("anthropic/claude-3-5-sonnet", uiState.value.currentSessionModel)
} }
@Test
fun setReasoningLevel_awaitsRequest_updatesOnAck_andPreventsConcurrentPick() =
testScope.runTest {
val gate = CompletableDeferred<Any?>()
val reqMethods = mutableListOf<String>()
val reqParams = mutableListOf<Map<String, Any>>()
val customDelegate =
ChatModelSwitchDelegate(
scope = testScope,
ioDispatcher = testDispatcher,
uiState = uiState,
runtimeSessionId = { runtimeId },
wsSend = { _, _, _ -> },
trackRequest = { _, _ -> },
addAssistantMessage = {},
handleSlashCommand = {},
fetchContextUsage = {},
dataScopeFlow = dataScope,
wsRequest = { method, params ->
reqMethods.add(method)
reqParams.add(params)
gate.await()
},
)
uiState.value = uiState.value.copy(reasoningLevel = "ultra", reasoningWireLevel = "max")
customDelegate.setReasoningLevel("high")
runCurrent()
// Confirmed level remains unchanged until acknowledged. The stale
// wire is cleared immediately so "High→Max" can never be painted.
assertEquals("ultra", uiState.value.reasoningLevel)
assertEquals("high", uiState.value.pendingReasoningLevel)
assertNull(uiState.value.reasoningWireLevel)
assertEquals(listOf(WsMethods.CONFIG_SET), reqMethods)
assertEquals("session", reqParams.first()["scope"])
assertEquals("high", reqParams.first()["value"])
// Second pick while in-flight is rejected (serialized)
customDelegate.setReasoningLevel("low")
runCurrent()
assertEquals(1, reqMethods.size)
// Simulate session.info winning the race and publishing the fresh
// authoritative wire before the RPC ACK resumes the delegate.
uiState.value = uiState.value.copy(reasoningWireLevel = "high")
// Production request() returns a raw JsonObject, not a Map.
gate.complete(
kotlinx.serialization.json.buildJsonObject {
put("key", kotlinx.serialization.json.JsonPrimitive("reasoning"))
put("value", kotlinx.serialization.json.JsonPrimitive("high"))
},
)
advanceUntilIdle()
assertEquals("high", uiState.value.reasoningLevel)
assertNull(uiState.value.pendingReasoningLevel)
// ACK must not erase a fresh wire that arrived first.
assertEquals("high", uiState.value.reasoningWireLevel)
}
@Test
fun setReasoningLevel_onFailure_clearsPendingAndSurfacesError() =
testScope.runTest {
val customDelegate =
ChatModelSwitchDelegate(
scope = testScope,
ioDispatcher = testDispatcher,
uiState = uiState,
runtimeSessionId = { runtimeId },
wsSend = { _, _, _ -> },
trackRequest = { _, _ -> },
addAssistantMessage = {},
handleSlashCommand = {},
fetchContextUsage = {},
dataScopeFlow = dataScope,
wsRequest = { _, _ -> throw RuntimeException("4002 unknown reasoning value") },
)
customDelegate.setReasoningLevel("ultra")
advanceUntilIdle()
assertNull(uiState.value.reasoningLevel)
assertNull(uiState.value.pendingReasoningLevel)
assertTrue(uiState.value.errorMessage?.contains("4002") == true)
}
@Test
fun setReasoningLevel_whenRuntimeSessionNullOrBlank_doesNotSend() =
testScope.runTest {
val reqMethods = mutableListOf<String>()
runtimeId = null
val customDelegate =
ChatModelSwitchDelegate(
scope = testScope,
ioDispatcher = testDispatcher,
uiState = uiState,
runtimeSessionId = { runtimeId },
wsSend = { _, _, _ -> },
trackRequest = { _, _ -> },
addAssistantMessage = {},
handleSlashCommand = {},
fetchContextUsage = {},
dataScopeFlow = dataScope,
wsRequest = { method, _ ->
reqMethods.add(method)
null
},
)
customDelegate.setReasoningLevel("high")
advanceUntilIdle()
assertTrue(reqMethods.isEmpty())
assertNull(uiState.value.pendingReasoningLevel)
assertNull(uiState.value.reasoningLevel)
}
@Test
fun setReasoningLevel_staleScopeOrReset_dropsLateAck() =
testScope.runTest {
val gate = CompletableDeferred<Any?>()
val customDelegate =
ChatModelSwitchDelegate(
scope = testScope,
ioDispatcher = testDispatcher,
uiState = uiState,
runtimeSessionId = { runtimeId },
wsSend = { _, _, _ -> },
trackRequest = { _, _ -> },
addAssistantMessage = {},
handleSlashCommand = {},
fetchContextUsage = {},
dataScopeFlow = dataScope,
wsRequest = { _, _ -> gate.await() },
)
customDelegate.setReasoningLevel("high")
runCurrent()
assertEquals("high", uiState.value.pendingReasoningLevel)
// Reset called while in-flight
customDelegate.reset()
assertNull(uiState.value.pendingReasoningLevel)
// Late RPC response arrives
gate.complete(mapOf("key" to "reasoning", "value" to "high"))
advanceUntilIdle()
assertNull(uiState.value.reasoningLevel)
assertNull(uiState.value.pendingReasoningLevel)
}
@Test @Test
fun rapidPicks_preservesOriginalConfirmedModelForRollback() = fun rapidPicks_preservesOriginalConfirmedModelForRollback() =
testScope.runTest { testScope.runTest {
@@ -2651,9 +2651,50 @@ class ChatViewModelTest {
assertNull(state.sudoPrompt) assertNull(state.sudoPrompt)
assertNull(state.errorMessage) assertNull(state.errorMessage)
assertNull(state.resumeError) assertNull(state.resumeError)
assertNull(state.pendingReasoningLevel)
assertNull(state.reasoningWireLevel)
assertTrue(state.isLoading) assertTrue(state.isLoading)
} }
@Test
fun testSessionInfo_reconcilesRequestedAndWireReasoningEffort() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
mockEventsFlow.emit(
WsEvent.SessionInfo(
data =
mapOf(
"model" to "gpt-5",
"provider" to "openai",
"reasoning_effort" to "ultra",
"reasoning_effort_wire" to "max",
),
sessionId = sessionId,
),
)
runCurrent()
val state = viewModel.uiState.value
assertEquals("ultra", state.reasoningLevel)
assertEquals("max", state.reasoningWireLevel)
// Other session's event is ignored
mockEventsFlow.emit(
WsEvent.SessionInfo(
data =
mapOf(
"model" to "gpt-5",
"provider" to "openai",
"reasoning_effort" to "low",
),
sessionId = "other-session-id",
),
)
runCurrent()
assertEquals("ultra", viewModel.uiState.value.reasoningLevel)
}
@Test @Test
fun testSessionBranchResult_clearsSessionBoundUiState() = fun testSessionBranchResult_clearsSessionBoundUiState() =
runTest { runTest {
@@ -38,4 +38,16 @@ class ComposerToolbarTest {
assertEquals("openai/", composerModelLabel("openai/", showProvider = true)) assertEquals("openai/", composerModelLabel("openai/", showProvider = true))
assertEquals("/gpt-5", composerModelLabel("/gpt-5", showProvider = true)) assertEquals("/gpt-5", composerModelLabel("/gpt-5", showProvider = true))
} }
@Test
fun buildReasoningLabel_formatsRequestedAndClampedWire() {
assertEquals("Default", buildReasoningLabel(null, defaultLabel = "Default"))
assertEquals("الافتراضي", buildReasoningLabel(null, defaultLabel = "الافتراضي"))
assertEquals("None", buildReasoningLabel("none"))
assertEquals("High", buildReasoningLabel("high"))
assertEquals("Ultra", buildReasoningLabel("ultra"))
assertEquals("Ultra→Max", buildReasoningLabel("ultra", wireLevel = "max"))
assertEquals("High", buildReasoningLabel("high", wireLevel = "high"))
assertEquals("None", buildReasoningLabel("none", wireLevel = "low"))
}
} }
@@ -237,6 +237,121 @@ class SlashCommandDispatchRpcTest {
assertEquals("do the thing", userBubble?.content) assertEquals("do the thing", userBubble?.content)
} }
@Test
fun `reasoning effort with global flag uses acknowledged CONFIG_SET`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
val result =
if (arg<String>(0) == WsMethods.CONFIG_SET) {
kotlinx.serialization.json.buildJsonObject {
put("key", kotlinx.serialization.json.JsonPrimitive("reasoning"))
put("value", kotlinx.serialization.json.JsonPrimitive("high"))
}
} else {
kotlinx.serialization.json.JsonNull
}
CompletableDeferred<Any?>(result)
}
vm.sendMessage("/reasoning high --global")
advanceUntilIdle()
val configIndex = methodCalls.indexOf(WsMethods.CONFIG_SET)
assertTrue("expected CONFIG_SET, got $methodCalls", configIndex >= 0)
val params = paramsCalls[configIndex]
assertEquals("reasoning", params["key"])
assertEquals("high", params["value"])
assertEquals(sessionId, params["session_id"])
assertEquals("global", params["scope"])
assertEquals("high", vm.uiState.value.reasoningLevel)
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
}
@Test
fun `reasoning display word uses CONFIG_SET instead of rejecting locally`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
val result =
if (arg<String>(0) == WsMethods.CONFIG_SET) {
kotlinx.serialization.json.buildJsonObject {
put("key", kotlinx.serialization.json.JsonPrimitive("reasoning"))
put("value", kotlinx.serialization.json.JsonPrimitive("show"))
}
} else {
kotlinx.serialization.json.JsonNull
}
CompletableDeferred<Any?>(result)
}
vm.sendMessage("/reasoning show")
advanceUntilIdle()
val configIndex = methodCalls.indexOf(WsMethods.CONFIG_SET)
assertTrue("expected CONFIG_SET, got $methodCalls", configIndex >= 0)
val params = paramsCalls[configIndex]
assertEquals("reasoning", params["key"])
assertEquals("show", params["value"])
assertEquals(sessionId, params["session_id"])
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
assertEquals(
"reasoning: show",
vm.uiState.value.messages
.lastOrNull { it.role == MessageRole.ASSISTANT }
?.content,
)
}
@Test
fun `bare reasoning reads canonical CONFIG_GET status`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
val result =
if (arg<String>(0) == WsMethods.CONFIG_GET) {
kotlinx.serialization.json.buildJsonObject {
put("value", kotlinx.serialization.json.JsonPrimitive("ultra"))
put("display", kotlinx.serialization.json.JsonPrimitive("hide"))
}
} else {
kotlinx.serialization.json.JsonNull
}
CompletableDeferred<Any?>(result)
}
vm.sendMessage("/reasoning")
advanceUntilIdle()
val configIndex = methodCalls.indexOf(WsMethods.CONFIG_GET)
assertTrue("expected CONFIG_GET, got $methodCalls", configIndex >= 0)
val params = paramsCalls[configIndex]
assertEquals("reasoning", params["key"])
assertEquals(sessionId, params["session_id"])
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
assertEquals(
"reasoning: ultra · display hide",
vm.uiState.value.messages
.lastOrNull { it.role == MessageRole.ASSISTANT }
?.content,
)
}
@Test @Test
fun `slash queue with no arg shows usage and sends nothing`() = fun `slash queue with no arg shows usage and sends nothing`() =
runTest { runTest {
@@ -53,6 +53,14 @@ class SlashCommandDispatcherTest {
assertEquals(SlashResult.ModelSwitch, dispatcher.dispatch("/Model")) assertEquals(SlashResult.ModelSwitch, dispatcher.dispatch("/Model"))
} }
@Test
fun `reasoning routes to ReasoningSwitch`() {
assertEquals(SlashResult.ReasoningSwitch(""), dispatcher.dispatch("/reasoning"))
assertEquals(SlashResult.ReasoningSwitch("high"), dispatcher.dispatch("/reasoning high"))
assertEquals(SlashResult.ReasoningSwitch("HIGH"), dispatcher.dispatch("/REASONING HIGH"))
assertEquals(SlashResult.ReasoningSwitch("ultra"), dispatcher.dispatch("/Reasoning ultra"))
}
@Test @Test
fun `update routes to Update`() { fun `update routes to Update`() {
// /update's backend handler is interactive + session-exiting and can // /update's backend handler is interactive + session-exiting and can
@@ -70,19 +70,12 @@ class CronJobsViewModelTest {
private fun createViewModel(): CronJobsViewModel { private fun createViewModel(): CronJobsViewModel {
val vm = CronJobsViewModel() val vm = CronJobsViewModel()
vm.ioDispatcher = testDispatcher
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
return vm return vm
} }
/**
* Pump the test scheduler while letting the real Dispatchers.IO hops
* (safeLaunchLoad / withContext(IO)) land their resumptions.
*/
private fun settle() { private fun settle() {
repeat(20) {
testDispatcher.scheduler.advanceUntilIdle()
Thread.sleep(10)
}
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
} }
@@ -8,6 +8,8 @@ import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.model.UpdateTaskResponse import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.KanbanApiService import com.m57.hermescontrol.data.remote.KanbanApiService
import com.m57.hermescontrol.data.repository.KanbanDispatcherNudger
import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl
import com.m57.hermescontrol.data.ws.KanbanEvent import com.m57.hermescontrol.data.ws.KanbanEvent
import com.m57.hermescontrol.data.ws.KanbanEventsClient import com.m57.hermescontrol.data.ws.KanbanEventsClient
import com.m57.hermescontrol.data.ws.KanbanEventsEnvelope import com.m57.hermescontrol.data.ws.KanbanEventsEnvelope
@@ -42,20 +44,24 @@ class KanbanViewModelTest {
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true) private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
private fun createViewModel(): KanbanViewModel { private fun createViewModel(): KanbanViewModel {
val vm = KanbanViewModel(eventsClientProvider = { mockEventsClient }) val mockNudger = mockk<KanbanDispatcherNudger>(relaxed = true)
val repo =
KanbanRepositoryImpl(
apiProvider = { mockApi },
ioDispatcher = testDispatcher,
nudger = mockNudger,
)
val vm =
KanbanViewModel(
repository = repo,
eventsClientProvider = { mockEventsClient },
)
vm.ioDispatcher = testDispatcher
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
return vm return vm
} }
/**
* Pump the test scheduler while letting the real Dispatchers.IO hops
* (safeLaunchLoad / withContext(IO)) land their resumptions.
*/
private fun settle() { private fun settle() {
repeat(20) {
testDispatcher.scheduler.advanceUntilIdle()
Thread.sleep(10)
}
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
} }
@@ -26,15 +26,7 @@ class LogsViewModelTest {
private val testDispatcher = StandardTestDispatcher() private val testDispatcher = StandardTestDispatcher()
private val mockApi = mockk<HermesApiService>(relaxed = true) private val mockApi = mockk<HermesApiService>(relaxed = true)
/**
* Pump the test scheduler while letting the real Dispatchers.IO hops
* (safeLaunchLoad / withContext(IO)) land their resumptions.
*/
private fun settle() { private fun settle() {
repeat(20) {
testDispatcher.scheduler.advanceUntilIdle()
Thread.sleep(10)
}
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
} }
@@ -36,19 +36,12 @@ class PairingViewModelTest {
private fun createViewModel(): PairingViewModel { private fun createViewModel(): PairingViewModel {
val vm = PairingViewModel() val vm = PairingViewModel()
vm.ioDispatcher = testDispatcher
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
return vm return vm
} }
/**
* Pump the test scheduler while letting the real Dispatchers.IO hops
* (safeLaunchLoad / withContext(IO)) land their resumptions.
*/
private fun settle() { private fun settle() {
repeat(20) {
testDispatcher.scheduler.advanceUntilIdle()
Thread.sleep(10)
}
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
} }
@@ -35,15 +35,9 @@ class SkillsViewModelTest {
private val mockApi = mockk<HermesApiService>(relaxed = true) private val mockApi = mockk<HermesApiService>(relaxed = true)
private val app = mockk<Application>(relaxed = true) private val app = mockk<Application>(relaxed = true)
/** private fun createViewModel(): SkillsViewModel = SkillsViewModel(app).also { it.ioDispatcher = testDispatcher }
* Pump the test scheduler while letting the real Dispatchers.IO hops
* (safeLaunchLoad / withContext(IO)) land their resumptions.
*/
private fun settle() { private fun settle() {
repeat(20) {
testDispatcher.scheduler.advanceUntilIdle()
Thread.sleep(10)
}
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
} }
@@ -105,7 +99,7 @@ class SkillsViewModelTest {
@Test @Test
fun `loadHubSources stores sources and featured skills`() { fun `loadHubSources stores sources and featured skills`() {
coEvery { mockApi.getSkillHubSources() } returns Response.success(sourcesResponse) coEvery { mockApi.getSkillHubSources() } returns Response.success(sourcesResponse)
val vm = SkillsViewModel(app) val vm = createViewModel()
vm.loadHubSources() vm.loadHubSources()
settle() settle()
@@ -122,7 +116,7 @@ class SkillsViewModelTest {
404, 404,
"""{"detail":"Hub sources unavailable"}""".toResponseBody("application/json".toMediaTypeOrNull()), """{"detail":"Hub sources unavailable"}""".toResponseBody("application/json".toMediaTypeOrNull()),
) )
val vm = SkillsViewModel(app) val vm = createViewModel()
vm.loadHubSources() vm.loadHubSources()
settle() settle()
@@ -137,7 +131,7 @@ class SkillsViewModelTest {
@Test @Test
fun `setViewMode HUB triggers sources load when landing is empty`() { fun `setViewMode HUB triggers sources load when landing is empty`() {
coEvery { mockApi.getSkillHubSources() } returns Response.success(sourcesResponse) coEvery { mockApi.getSkillHubSources() } returns Response.success(sourcesResponse)
val vm = SkillsViewModel(app) val vm = createViewModel()
vm.setViewMode(SkillsViewMode.HUB) vm.setViewMode(SkillsViewMode.HUB)
settle() settle()
@@ -149,7 +143,7 @@ class SkillsViewModelTest {
fun `scanHubSkill stores the scan result for the identifier`() { fun `scanHubSkill stores the scan result for the identifier`() {
coEvery { mockApi.scanHubSkill(identifier = "hermes-index:web-research") } returns coEvery { mockApi.scanHubSkill(identifier = "hermes-index:web-research") } returns
Response.success(scanResponse) Response.success(scanResponse)
val vm = SkillsViewModel(app) val vm = createViewModel()
vm.scanHubSkill("hermes-index:web-research") vm.scanHubSkill("hermes-index:web-research")
settle() settle()
@@ -166,7 +160,7 @@ class SkillsViewModelTest {
404, 404,
"""{"detail":"Skill not found"}""".toResponseBody("application/json".toMediaTypeOrNull()), """{"detail":"Skill not found"}""".toResponseBody("application/json".toMediaTypeOrNull()),
) )
val vm = SkillsViewModel(app) val vm = createViewModel()
vm.scanHubSkill("broken:skill") vm.scanHubSkill("broken:skill")
settle() settle()
@@ -178,7 +172,7 @@ class SkillsViewModelTest {
@Test @Test
fun `scanHubSkill ignores blank identifiers`() { fun `scanHubSkill ignores blank identifiers`() {
val vm = SkillsViewModel(app) val vm = createViewModel()
vm.scanHubSkill(" ") vm.scanHubSkill(" ")
settle() settle()
@@ -190,7 +184,7 @@ class SkillsViewModelTest {
fun `clearHubScan resets scan state`() { fun `clearHubScan resets scan state`() {
coEvery { mockApi.scanHubSkill(identifier = "hermes-index:web-research") } returns coEvery { mockApi.scanHubSkill(identifier = "hermes-index:web-research") } returns
Response.success(scanResponse) Response.success(scanResponse)
val vm = SkillsViewModel(app) val vm = createViewModel()
vm.scanHubSkill("hermes-index:web-research") vm.scanHubSkill("hermes-index:web-research")
settle() settle()
vm.clearHubScan() vm.clearHubScan()
+1 -1
View File
@@ -30,7 +30,7 @@ coil = "3.6.2"
room = "3.0.2" room = "3.0.2"
sqlcipher = "4.19.0" sqlcipher = "4.19.0"
datastore = "1.1.2" datastore = "1.1.2"
latex = "1.5.4" latex = "1.5.5"
[libraries] [libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }