diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index bbad8a29..b78d4824 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -80,7 +80,7 @@ object EventParser { } "session.info" -> { - WsEvent.SessionInfo(payload) + WsEvent.SessionInfo(payload, sessionId) } "message.start" -> { diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index 712d4bd5..131693c5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -14,6 +14,7 @@ sealed class WsEvent { data class SessionInfo( val data: Map?, + val sessionId: String? = null, ) : WsEvent() // ── Message streaming ──────────────────────────────────────────────── diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index bb81bd5c..2d585be5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -72,6 +72,7 @@ object WsMethods { const val COMMANDS_CATALOG = "commands.catalog" const val COMMAND_DISPATCH = "command.dispatch" const val SLASH_EXEC = "slash.exec" + const val CONFIG_GET = "config.get" const val CONFIG_SET = "config.set" // ── Attachments ─────────────────────────────────────────────────────── @@ -166,6 +167,8 @@ object WsMethods { // Evidence: methods_session.py @_profile_scoped at each @method // Binds HERMES_HOME so config/skills/pets resolve to the // 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 PROJECTS_LIST, // methods_projects.py: @_registry.profile_scoped "pet.info", // line 1253 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt index 9ccfcda5..1667f98b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt @@ -10,6 +10,8 @@ import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.ws.ModelCatalogStore import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.toAny +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -18,6 +20,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonElement import java.util.concurrent.ConcurrentHashMap /** @@ -58,6 +61,7 @@ class ChatModelSwitchDelegate( { refresh -> ModelCatalogStore.shared.ensureLoaded(forceRefresh = refresh) }, private val getPinnedModels: () -> List = { AuthManager.getPinnedModels() }, private val savePinnedModels: (List) -> Unit = { AuthManager.savePinnedModels(it) }, + private val wsRequest: (suspend (method: String, params: Map) -> Any?)? = null, ) { private val pendingModelSwitchRequests = ConcurrentHashMap() private val pendingFastSwitchRequests = ConcurrentHashMap() @@ -67,6 +71,8 @@ class ChatModelSwitchDelegate( private var lastConfirmedModel: String? = null private var unconfirmedTargetModel: String? = null private var modelSwitchSequence: Long = 0L + private var reasoningOpSequence: Long = 0L + private var reasoningJob: Job? = null private var cachedModelOptions: List = emptyList() private var cachedModelOptionsScope: DataScope? = dataScopeFlow.value private var modelOptionsJob: Job? = null @@ -460,22 +466,111 @@ class ChatModelSwitchDelegate( optimisticPreviousModel = null lastConfirmedModel = null unconfirmedTargetModel = null + reasoningOpSequence++ + reasoningJob?.cancel() + reasoningJob = null + uiState.update { + it.copy( + pendingReasoningLevel = null, + reasoningWireLevel = null, + ) + } } - fun setReasoningLevel(level: String?) { - uiState.update { it.copy(reasoningLevel = level) } - val sessionId = runtimeSessionId() ?: return + fun setReasoningLevel( + level: String?, + scopeName: String = "session", + enforceCapabilities: Boolean = true, + ) { if (level == null) return - scope.launch(ioDispatcher) { - wsSend( - WsMethods.CONFIG_SET, - mapOf( - "key" to "reasoning", - "value" to level, - "session_id" to sessionId, - ), - ) { id -> trackRequest(id, WsMethods.CONFIG_SET) } + 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) { + try { + val params = + mapOf( + "key" to "reasoning", + "value" to level, + "session_id" to sessionId, + "scope" to scopeName, + ) + 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()}", + ) + } + } + } + } } fun getModelCapabilities( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 287ce2fb..8c935e40 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -808,6 +808,8 @@ fun ChatScreen( currentSessionModel = state.currentSessionModel, showModelProvider = state.showModelProvider, reasoningLevel = state.reasoningLevel, + reasoningWireLevel = state.reasoningWireLevel, + pendingReasoningLevel = state.pendingReasoningLevel, onModelTap = { viewModel.openModelPicker() }, onReasoningTap = { level -> viewModel.setReasoningLevel(level) }, canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index cf5ec8c8..1154a550 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -27,6 +27,7 @@ import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.WsEvent 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.notification.captureTurnBoundary import com.m57.hermescontrol.notification.correlationScopeId @@ -45,6 +46,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.decodeFromJsonElement @@ -56,6 +58,42 @@ import java.util.concurrent.ConcurrentHashMap private const val TAG = "ChatViewModel" 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() + + 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( val attachment: Attachment, val encodedFile: File, @@ -145,6 +183,10 @@ data class ChatUiState( val currentModelCapabilities: ModelCapabilities? = null, // Reasoning effort level for the current session 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 val fastMode: Boolean = false, val isFastModeChanging: Boolean = false, @@ -479,6 +521,7 @@ class ChatViewModel( handleSlashCommand = { cmd -> handleSlashCommand(cmd) }, fetchContextUsage = { fetchContextUsage() }, onModelSwitchInitiated = { onModelSwitchInitiated() }, + wsRequest = { method, params -> wsClient.request(method, params).await() }, ).apply { attachScopeObserver(viewModelScope) } @@ -788,7 +831,11 @@ class ChatViewModel( } is WsEvent.SessionInfo -> { - handleSessionInfo(event.data) + 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) + } } is WsEvent.MessageToken -> { @@ -1178,6 +1225,7 @@ class ChatViewModel( val model = info["model"] as? String val provider = info["provider"] 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 serviceTier = (info["service_tier"] as? String)?.trim()?.lowercase() val fastFlag = @@ -1209,14 +1257,24 @@ class ChatViewModel( modelSwitchDelegate.onModelConfirmed(newModelLabel) } _uiState.update { state -> + val newEffort = + if (reasoningEffort != null) { + if (reasoningEffort.isEmpty()) null else reasoningEffort + } else { + state.reasoningLevel + } + val newWire = + if (reasoningEffortWire != null) { + if (reasoningEffortWire.isEmpty()) null else reasoningEffortWire + } else if (newEffort != state.reasoningLevel || modelSwapped) { + null + } else { + state.reasoningWireLevel + } state.copy( currentSessionModel = newModelLabel ?: state.currentSessionModel, - reasoningLevel = - if (reasoningEffort.isNullOrEmpty()) { - null - } else { - reasoningEffort - }, + reasoningLevel = newEffort, + reasoningWireLevel = newWire, fastMode = fastFlag ?: state.fastMode, isFastModeChanging = if (fastFlag != null) false else state.isFastModeChanging, terminalBackend = terminalBackend ?: state.terminalBackend, @@ -1388,6 +1446,7 @@ class ChatViewModel( val model = infoMap?.get("model") as? String val provider = infoMap?.get("provider") 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 serviceTier = (infoMap?.get("service_tier") as? String)?.trim()?.lowercase() val fastFlag = @@ -1416,6 +1475,12 @@ class ChatViewModel( } else { reasoningEffort }, + reasoningWireLevel = + if (reasoningEffortWire.isNullOrEmpty()) { + null + } else { + reasoningEffortWire + }, fastMode = fastFlag ?: false, isFastModeChanging = false, terminalBackend = terminalBackend ?: it.terminalBackend, @@ -1705,7 +1770,8 @@ class ChatViewModel( } private fun canSubmitMessage(): Boolean = - wsClient.connectionStatus.value == ConnectionStatus.CONNECTED && + _uiState.value.pendingReasoningLevel == null && + wsClient.connectionStatus.value == ConnectionStatus.CONNECTED && ( (_uiState.value.isSessionReady && runtimeSessionId != null) || ( @@ -2127,6 +2193,10 @@ class ChatViewModel( modelSwitchDelegate.handleModelSwitch(command) } + is SlashResult.ReasoningSwitch -> { + handleReasoningSlashCommand(result.level) + } + is SlashResult.Update -> { openUpdateConfirm() } @@ -2174,6 +2244,97 @@ class ChatViewModel( 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 { + 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) ───────────────────────────── fun dismissBtw() { @@ -2962,6 +3123,8 @@ class ChatViewModel( currentSessionModel = null, currentModelCapabilities = null, reasoningLevel = null, + reasoningWireLevel = null, + pendingReasoningLevel = null, fastMode = false, isFastModeChanging = false, terminalBackend = null, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt index f96d78a7..31712655 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt @@ -42,6 +42,11 @@ class SlashCommandDispatcher { SlashResult.ModelSwitch } + "/reasoning" -> { + val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim() + SlashResult.ReasoningSwitch(level = arg) + } + "/update" -> { SlashResult.Update } @@ -103,6 +108,16 @@ sealed class 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 * (`POST /api/hermes/update`, the System screen's flow) and track it in diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index 67efc277..cf67124f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -101,12 +101,16 @@ fun ChatInputBar( isFastModeChanging: Boolean = false, onToggleFastMode: () -> Unit = {}, showModelProvider: Boolean = false, + reasoningWireLevel: String? = null, + pendingReasoningLevel: String? = null, ) { // 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 // (tui_gateway/server.py:_handle_busy_submit), so the message is never // 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 var showAttachmentTray by remember { mutableStateOf(false) } @@ -329,6 +333,9 @@ fun ChatInputBar( isFastModeChanging = isFastModeChanging, onToggleFastMode = onToggleFastMode, showModelProvider = showModelProvider, + reasoningWireLevel = reasoningWireLevel, + pendingReasoningLevel = pendingReasoningLevel, + isSessionReady = isSessionReady, ) AttachmentTray( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt index ac1de39d..bfee7e24 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt @@ -104,6 +104,9 @@ fun ComposerToolbar( isFastModeChanging: Boolean = false, onToggleFastMode: () -> Unit = {}, showModelProvider: Boolean = false, + reasoningWireLevel: String? = null, + pendingReasoningLevel: String? = null, + isSessionReady: Boolean = true, ) { var showReasoningMenu by remember { mutableStateOf(false) } val palette = composerPalette() @@ -179,11 +182,12 @@ fun ComposerToolbar( } Box { + val reasoningInteractive = isConnected && isSessionReady && pendingReasoningLevel == null Row( modifier = Modifier .fillMaxHeight() - .clickable(enabled = isConnected) { showReasoningMenu = true } + .clickable(enabled = reasoningInteractive) { showReasoningMenu = true } .padding(start = 6.dp, end = 12.dp) .testTag("reasoning_chip"), verticalAlignment = Alignment.CenterVertically, @@ -191,7 +195,7 @@ fun ComposerToolbar( ) { val levelColor = palette.onControlVariant.copy( - alpha = if (isConnected) 1f else 0.38f, + alpha = if (reasoningInteractive) 1f else 0.38f, ) if (fastMode) { Icon( @@ -201,12 +205,26 @@ fun ComposerToolbar( modifier = Modifier.size(14.dp), ) } + if (pendingReasoningLevel != null) { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 1.5.dp, + color = MaterialTheme.colorScheme.primary, + ) + } Text( text = if (reasoningDisabledForModel) { if (fastMode) stringResource(R.string.chat_fast_mode_label) else "No reasoning" } 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) { "${stringResource( R.string.chat_fast_mode_label, @@ -332,29 +350,57 @@ fun ComposerToolbar( allLevels.forEach { (level, label) -> val isNone = level == "none" val noneDisabled = isNone && (canDisable == false || reasoningDisabledForModel) + val isSelected = (pendingReasoningLevel ?: reasoningLevel) == level + val isClampedTarget = + isSelected && reasoningWireLevel != null && + !reasoningWireLevel.equals(level, ignoreCase = true) DropdownMenuItem( text = { - Text( - text = label, - fontWeight = - if (reasoningLevel == level) { - MaterialTheme.typography.bodyMedium.fontWeight - } else { - null - }, - color = - when { - noneDisabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - reasoningLevel == level -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.onSurface - }, - ) + Column { + Text( + text = label, + fontWeight = + if (isSelected) { + MaterialTheme.typography.bodyMedium.fontWeight + } else { + null + }, + color = + when { + noneDisabled -> { + MaterialTheme.colorScheme.onSurface.copy( + 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 = { showReasoningMenu = false onReasoningSelected(level) }, - enabled = !noneDisabled && !reasoningDisabledForModel, + enabled = !noneDisabled && !reasoningDisabledForModel && pendingReasoningLevel == null, ) } } @@ -501,16 +547,37 @@ private const val CUSTOM_PROVIDER_PREFIX = "custom:" * "xhigh", "max", "ultra", or null for model default. * @return Display string such as "None", "Low", "XHigh", "Ultra", etc. */ -private fun buildReasoningLabel(level: String?): String = - when (level) { - null -> "Med" - "none" -> "None" - "minimal" -> "Minimal" - "low" -> "Low" - "medium" -> "Med" - "high" -> "High" - "xhigh" -> "XHigh" - "max" -> "Max" - "ultra" -> "Ultra" - else -> level +fun buildReasoningLabel( + level: String?, + wireLevel: String? = null, + defaultLabel: String = "Default", +): String { + val reqLabel = + when (level) { + null -> defaultLabel + "none" -> "None" + "minimal" -> "Minimal" + "low" -> "Low" + "medium" -> "Med" + "high" -> "High" + "xhigh" -> "XHigh" + "max" -> "Max" + "ultra" -> "Ultra" + 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 +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 83265e23..80f5cb59 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1294,6 +1294,9 @@ سريع الوضع السريع الوضع السريع غير مدعوم لهذا النموذج +الافتراضي +جارٍ تطبيق الاستدلال… +المطلوب %1$s، يرسل المسار %2$s حفظ %1$s تبديل النموذج (لهذه المحادثة) مسح القيمة diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 8d9afa91..ee1048c0 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1140,6 +1140,9 @@ 빠름 빠른 모드 이 모델에서는 빠른 모드를 사용할 수 없습니다 + 기본값 + 추론 설정 적용 중… + 요청 %1$s, 경로 전송 %2$s 연동 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 0c36aea0..5cbae308 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1261,6 +1261,9 @@ 快速 快速模式 此模型不支持快速模式 + 默认 + 正在应用推理设置… + 请求 %1$s,路由实际发送 %2$s 保存 %1$s 切换模型(当前对话) 清除值 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c5f01c34..9d6e2222 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1420,6 +1420,9 @@ Fast Fast mode Fast mode unavailable for this model + Default + Applying reasoning… + Requested %1$s, route sends %2$s Reasoning options Save %1$s Switch model (this chat) diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt index 70a8f0cf..892b9d69 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt @@ -715,11 +715,17 @@ class EventParserTest { result = null, error = null, 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) assertTrue(event is WsEvent.SessionInfo) assertEquals(mapOf("session_id" to "sess-1"), (event as WsEvent.SessionInfo).data) + assertEquals("sess-1", event.sessionId) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt index a0825212..a1f7cd81 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt @@ -88,6 +88,21 @@ class WsProfileParamsTest { 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 fun `profile_scoped decorator method gets profile injected`() { AuthManager.setActiveProfileId("meow") diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt index efe0c636..f8ee5ecb 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt @@ -430,6 +430,163 @@ class ChatModelSwitchDelegateTest { assertEquals("anthropic/claude-3-5-sonnet", uiState.value.currentSessionModel) } + @Test + fun setReasoningLevel_awaitsRequest_updatesOnAck_andPreventsConcurrentPick() = + testScope.runTest { + val gate = CompletableDeferred() + val reqMethods = mutableListOf() + val reqParams = mutableListOf>() + + 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() + 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() + 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 fun rapidPicks_preservesOriginalConfirmedModelForRollback() = testScope.runTest { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 7c072688..68a69b9c 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -2651,9 +2651,50 @@ class ChatViewModelTest { assertNull(state.sudoPrompt) assertNull(state.errorMessage) assertNull(state.resumeError) + assertNull(state.pendingReasoningLevel) + assertNull(state.reasoningWireLevel) 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 fun testSessionBranchResult_clearsSessionBoundUiState() = runTest { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerToolbarTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerToolbarTest.kt index cd3df878..c5db82da 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerToolbarTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerToolbarTest.kt @@ -38,4 +38,16 @@ class ComposerToolbarTest { assertEquals("openai/", composerModelLabel("openai/", 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")) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt index 95c33f4a..f64050d3 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt @@ -237,6 +237,121 @@ class SlashCommandDispatchRpcTest { 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() + val paramsCalls = mutableListOf>() + every { + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) + } answers { + val result = + if (arg(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(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() + val paramsCalls = mutableListOf>() + every { + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) + } answers { + val result = + if (arg(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(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() + val paramsCalls = mutableListOf>() + every { + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) + } answers { + val result = + if (arg(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(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 fun `slash queue with no arg shows usage and sends nothing`() = runTest { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt index a196f878..b85b4f23 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt @@ -53,6 +53,14 @@ class SlashCommandDispatcherTest { 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 fun `update routes to Update`() { // /update's backend handler is interactive + session-exiting and can