Merge pull request #1206 from Hy4ri/feat/reasoning-composer-correctness
feat(chat): make reasoning control acknowledged and route-honest
This commit is contained in:
20 files changed
+773
-54
No files matched your search
@@ -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,22 +466,111 @@ 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
|
||||||
scope.launch(ioDispatcher) {
|
val currentPending = uiState.value.pendingReasoningLevel
|
||||||
wsSend(
|
if (currentPending != null) return // One reasoning write at a time
|
||||||
WsMethods.CONFIG_SET,
|
val sessionId = runtimeSessionId() ?: return
|
||||||
mapOf(
|
if (sessionId.isBlank()) return
|
||||||
"key" to "reasoning",
|
if (scopeName != "session" && scopeName != "global") return
|
||||||
"value" to level,
|
|
||||||
"session_id" to sessionId,
|
// Capability validation
|
||||||
),
|
if (enforceCapabilities) {
|
||||||
) { id -> trackRequest(id, WsMethods.CONFIG_SET) }
|
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(
|
fun getModelCapabilities(
|
||||||
|
|||||||
@@ -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,7 +831,11 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is WsEvent.SessionInfo -> {
|
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 -> {
|
is WsEvent.MessageToken -> {
|
||||||
@@ -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 ->
|
||||||
|
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(
|
state.copy(
|
||||||
currentSessionModel = newModelLabel ?: state.currentSessionModel,
|
currentSessionModel = newModelLabel ?: state.currentSessionModel,
|
||||||
reasoningLevel =
|
reasoningLevel = newEffort,
|
||||||
if (reasoningEffort.isNullOrEmpty()) {
|
reasoningWireLevel = newWire,
|
||||||
null
|
|
||||||
} else {
|
|
||||||
reasoningEffort
|
|
||||||
},
|
|
||||||
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,7 +1770,8 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun canSubmitMessage(): Boolean =
|
private fun canSubmitMessage(): Boolean =
|
||||||
wsClient.connectionStatus.value == ConnectionStatus.CONNECTED &&
|
_uiState.value.pendingReasoningLevel == null &&
|
||||||
|
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 = {
|
||||||
Text(
|
Column {
|
||||||
text = label,
|
Text(
|
||||||
fontWeight =
|
text = label,
|
||||||
if (reasoningLevel == level) {
|
fontWeight =
|
||||||
MaterialTheme.typography.bodyMedium.fontWeight
|
if (isSelected) {
|
||||||
} else {
|
MaterialTheme.typography.bodyMedium.fontWeight
|
||||||
null
|
} else {
|
||||||
},
|
null
|
||||||
color =
|
},
|
||||||
when {
|
color =
|
||||||
noneDisabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
when {
|
||||||
reasoningLevel == level -> MaterialTheme.colorScheme.primary
|
noneDisabled -> {
|
||||||
else -> MaterialTheme.colorScheme.onSurface
|
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 = {
|
onClick = {
|
||||||
showReasoningMenu = false
|
showReasoningMenu = false
|
||||||
onReasoningSelected(level)
|
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.
|
* "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(
|
||||||
when (level) {
|
level: String?,
|
||||||
null -> "Med"
|
wireLevel: String? = null,
|
||||||
"none" -> "None"
|
defaultLabel: String = "Default",
|
||||||
"minimal" -> "Minimal"
|
): String {
|
||||||
"low" -> "Low"
|
val reqLabel =
|
||||||
"medium" -> "Med"
|
when (level) {
|
||||||
"high" -> "High"
|
null -> defaultLabel
|
||||||
"xhigh" -> "XHigh"
|
"none" -> "None"
|
||||||
"max" -> "Max"
|
"minimal" -> "Minimal"
|
||||||
"ultra" -> "Ultra"
|
"low" -> "Low"
|
||||||
else -> level
|
"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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user