Merge pull request #1082 from Hy4ri/refactor/chat-viewmodel-decomposition
refactor(chat): decompose ChatViewModel into focused delegates
This commit is contained in:
8 files changed
+1298
-738
No files matched your search
@@ -0,0 +1,122 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Handles basic credential login and WS ticket minting for in-chat reconnect/re-authentication.
|
||||
*
|
||||
* Extracted from [com.m57.hermescontrol.ui.chat.ChatViewModel] to isolate raw OkHttp network calls.
|
||||
*/
|
||||
class ChatReloginAuthenticator(
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
|
||||
) {
|
||||
suspend fun relogin(
|
||||
username: String,
|
||||
password: String,
|
||||
onSuccess: () -> Unit,
|
||||
onFailure: (String) -> Unit,
|
||||
) {
|
||||
withContext(ioDispatcher) {
|
||||
val endpoint = AuthManager.endpointForBuild()
|
||||
val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val jsonBody =
|
||||
JSONObject()
|
||||
.put("provider", "basic")
|
||||
.put("username", username)
|
||||
.put("password", password)
|
||||
.put("next", "")
|
||||
.toString()
|
||||
|
||||
try {
|
||||
val loginClient =
|
||||
OkHttpProvider.probe
|
||||
.newBuilder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val loginReq =
|
||||
Request
|
||||
.Builder()
|
||||
.url(endpoint.resolve("auth/password-login").toString())
|
||||
.header("Content-Type", "application/json")
|
||||
.post(jsonBody.toRequestBody(jsonMediaType))
|
||||
.build()
|
||||
|
||||
loginClient.newCall(loginReq).execute().use { loginResp ->
|
||||
if (!loginResp.isSuccessful) {
|
||||
val msg =
|
||||
when (loginResp.code) {
|
||||
401 -> "Invalid username or password (401)"
|
||||
403 -> "Forbidden (403)"
|
||||
else -> "HTTP error code: ${loginResp.code}"
|
||||
}
|
||||
withContext(mainDispatcher) {
|
||||
onFailure(msg)
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
}
|
||||
|
||||
val ticketClient =
|
||||
OkHttpProvider.base
|
||||
.newBuilder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val ticketReq =
|
||||
Request
|
||||
.Builder()
|
||||
.url(endpoint.resolve("api/auth/ws-ticket").toString())
|
||||
.post("{}".toRequestBody(jsonMediaType))
|
||||
.build()
|
||||
|
||||
ticketClient.newCall(ticketReq).execute().use { ticketResp ->
|
||||
if (!ticketResp.isSuccessful) {
|
||||
withContext(mainDispatcher) {
|
||||
onFailure("Failed to mint WS ticket: HTTP ${ticketResp.code}")
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val body = ticketResp.body.string()
|
||||
val ticket = JSONObject(body).optString("ticket").takeIf { it.isNotBlank() }
|
||||
|
||||
if (ticket.isNullOrBlank()) {
|
||||
withContext(mainDispatcher) {
|
||||
onFailure("Invalid ticket returned from server")
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
|
||||
AuthManager.setWsAuthParam("ticket")
|
||||
AuthManager.setToken(ticket)
|
||||
|
||||
withContext(mainDispatcher) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
withContext(mainDispatcher) {
|
||||
onFailure("Connection failed: ${e.message}")
|
||||
}
|
||||
} catch (e: JSONException) {
|
||||
withContext(mainDispatcher) {
|
||||
onFailure("Connection failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Owns approval rendering, deduplication, user response submission, and
|
||||
* reconnect/resume pending approval replay.
|
||||
*
|
||||
* Extracted behavior-preservingly from [ChatViewModel].
|
||||
*/
|
||||
class ChatApprovalsDelegate(
|
||||
private val scope: CoroutineScope,
|
||||
private val ioDispatcher: CoroutineDispatcher,
|
||||
private val uiState: MutableStateFlow<ChatUiState>,
|
||||
private val runtimeSessionId: () -> String?,
|
||||
private val wsSend: (method: String, params: Map<String, Any>, onSent: ((String) -> Unit)?) -> Unit,
|
||||
private val trackRequest: (id: String, method: String) -> Unit,
|
||||
private val addSystemMessage: (text: String) -> Unit,
|
||||
) {
|
||||
fun handleApprovalRequest(event: WsEvent.ApprovalRequest) {
|
||||
val description = event.description ?: event.command ?: "Unknown command"
|
||||
val content = "**Approval Required**\n$description"
|
||||
val msg =
|
||||
ChatMessage(
|
||||
role = MessageRole.SYSTEM,
|
||||
content = content,
|
||||
approvalInfo =
|
||||
ApprovalInfo(
|
||||
command = event.command,
|
||||
description = event.description,
|
||||
patternKeys = event.patternKeys,
|
||||
requestId = event.requestId,
|
||||
choices = event.choices,
|
||||
allowPermanent = event.allowPermanent,
|
||||
smartDenied = event.smartDenied,
|
||||
),
|
||||
)
|
||||
uiState.update { state ->
|
||||
state.copy(
|
||||
messages = state.messages + msg,
|
||||
isAgentTyping = false,
|
||||
)
|
||||
}
|
||||
// Desktop parity (`prompts.ts` receiveApprovalRequest): ack the render
|
||||
// so the backend knows this client holds the prompt. Fire-and-forget.
|
||||
val requestId = event.requestId
|
||||
val sessionId = runtimeSessionId() ?: event.sessionId ?: uiState.value.currentSessionId
|
||||
if (requestId != null && sessionId != null) {
|
||||
scope.launch(ioDispatcher) {
|
||||
runCatching {
|
||||
wsSend(
|
||||
WsMethods.APPROVAL_RECEIVED,
|
||||
mapOf(
|
||||
"session_id" to sessionId,
|
||||
"request_id" to requestId,
|
||||
),
|
||||
null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun replayPendingApproval(sessionId: String) {
|
||||
val targetSessionId = runtimeSessionId() ?: sessionId
|
||||
scope.launch(ioDispatcher) {
|
||||
wsSend(
|
||||
WsMethods.APPROVAL_PENDING,
|
||||
mapOf("session_id" to targetSessionId),
|
||||
) { id -> trackRequest(id, WsMethods.APPROVAL_PENDING) }
|
||||
}
|
||||
}
|
||||
|
||||
fun handleApprovalPendingResult(result: Any?) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val approvals = (result as? Map<*, *>)?.get("approvals") as? List<*>
|
||||
val first = approvals?.filterIsInstance<Map<*, *>>()?.firstOrNull() ?: return
|
||||
val requestId = first["request_id"] as? String ?: return
|
||||
val sessionId = runtimeSessionId() ?: uiState.value.currentSessionId
|
||||
val alreadyShown =
|
||||
uiState.value.messages.any { it.approvalInfo?.requestId == requestId }
|
||||
if (alreadyShown) return
|
||||
handleApprovalRequest(parseApprovalMap(first, sessionId))
|
||||
}
|
||||
|
||||
fun handleApprovalRespondResult(result: Any?) {
|
||||
val map = result as? Map<*, *>
|
||||
val resolved = (map?.get("resolved") as? Number)?.toInt() ?: 0
|
||||
if (resolved > 0) {
|
||||
addSystemMessage("Approval submitted")
|
||||
}
|
||||
}
|
||||
|
||||
fun maybeSurfacePendingApproval(
|
||||
map: Map<*, *>,
|
||||
sessionId: String?,
|
||||
) {
|
||||
val requestId = map["request_id"] as? String
|
||||
val alreadyShown =
|
||||
requestId != null &&
|
||||
uiState.value.messages.any { it.approvalInfo?.requestId == requestId }
|
||||
if (!alreadyShown) {
|
||||
handleApprovalRequest(parseApprovalMap(map, sessionId))
|
||||
}
|
||||
}
|
||||
|
||||
fun respondToApproval(action: String) {
|
||||
val state = uiState.value
|
||||
val approvalMsg = state.messages.lastOrNull { it.approvalInfo != null } ?: return
|
||||
val sessionId = runtimeSessionId() ?: state.currentSessionId ?: return
|
||||
// Desktop sends `once` for a single run; legacy mobile sent `approve`
|
||||
// (any non-deny still unblocks, but stay on-spec going forward).
|
||||
val choice = if (action == "approve") "once" else action
|
||||
val requestId = approvalMsg.approvalInfo?.requestId
|
||||
|
||||
// Clear buttons immediately
|
||||
uiState.update { s ->
|
||||
s.copy(
|
||||
messages =
|
||||
s.messages.map {
|
||||
if (it.id == approvalMsg.id) {
|
||||
it.copy(approvalInfo = null)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
scope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"choice" to choice,
|
||||
"all" to false,
|
||||
)
|
||||
if (requestId != null) params["request_id"] = requestId
|
||||
wsSend(
|
||||
WsMethods.APPROVAL_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.APPROVAL_RESPOND) }
|
||||
// The queue can hold more pendings — surface the next one.
|
||||
replayPendingApproval(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a backend approval map (snake_case, from `approval.pending` or
|
||||
* `session.info` `pending_approval`) into a typed event. Mirrors
|
||||
* [EventParser] defaults so replay and live paths agree.
|
||||
*/
|
||||
internal fun parseApprovalMap(
|
||||
map: Map<*, *>,
|
||||
sessionId: String?,
|
||||
): WsEvent.ApprovalRequest {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val patternKeys = (map["pattern_keys"] as? List<*>)?.filterIsInstance<String>()
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val rawChoices = (map["choices"] as? List<*>)?.filterIsInstance<String>()
|
||||
val allowPermanent = map["allow_permanent"] as? Boolean
|
||||
val allowSession = map["allow_session"] as? Boolean
|
||||
val smartDenied = map["smart_denied"] as? Boolean
|
||||
val choices =
|
||||
rawChoices ?: run {
|
||||
if (smartDenied == true) {
|
||||
listOf("once", "deny")
|
||||
} else {
|
||||
buildList {
|
||||
add("once")
|
||||
if (allowSession != false) {
|
||||
add("session")
|
||||
if (allowPermanent != false) add("always")
|
||||
}
|
||||
add("deny")
|
||||
}
|
||||
}
|
||||
}
|
||||
return WsEvent.ApprovalRequest(
|
||||
command = map["command"] as? String,
|
||||
description = map["description"] as? String,
|
||||
patternKeys = patternKeys,
|
||||
sessionId = sessionId,
|
||||
requestId = map["request_id"] as? String,
|
||||
choices = choices,
|
||||
allowPermanent = allowPermanent,
|
||||
smartDenied = smartDenied,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Owns sudo and secret prompt state, timeouts, dismissal, and response RPCs.
|
||||
*
|
||||
* Extracted behavior-preservingly from [ChatViewModel] (issue #524).
|
||||
*/
|
||||
class ChatCredentialPromptsDelegate(
|
||||
private val scope: CoroutineScope,
|
||||
private val ioDispatcher: CoroutineDispatcher,
|
||||
private val uiState: MutableStateFlow<ChatUiState>,
|
||||
private val wsSend: (method: String, params: Map<String, Any>, onSent: ((String) -> Unit)?) -> Unit,
|
||||
private val trackRequest: (id: String, method: String) -> Unit,
|
||||
) {
|
||||
/**
|
||||
* The agent needs the user's sudo password. Surface a secure dialog and
|
||||
* reply via sudo.respond.
|
||||
*/
|
||||
fun handleSudoRequest(event: WsEvent.SudoRequest) {
|
||||
uiState.update {
|
||||
it.copy(
|
||||
sudoPrompt = SudoPromptUi(event.requestId, event.sessionId),
|
||||
isAgentTyping = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend sudo timeout (120s) — clear only the matching dialog so a late
|
||||
* expire for an old prompt never kills the current one.
|
||||
*/
|
||||
fun handleSudoExpire(event: WsEvent.SudoExpire) {
|
||||
uiState.update { state ->
|
||||
val current = state.sudoPrompt ?: return@update state
|
||||
if (event.requestId != null && current.requestId != null &&
|
||||
event.requestId != current.requestId
|
||||
) {
|
||||
return@update state
|
||||
}
|
||||
state.copy(sudoPrompt = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent needs a secret value (token/password). Surface a secure dialog
|
||||
* and reply via secret.respond.
|
||||
*/
|
||||
fun handleSecretRequest(event: WsEvent.SecretRequest) {
|
||||
uiState.update {
|
||||
it.copy(
|
||||
secretPrompt =
|
||||
SecretPromptUi(
|
||||
event.requestId,
|
||||
event.sessionId,
|
||||
event.envVar,
|
||||
event.prompt,
|
||||
),
|
||||
isAgentTyping = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend secret timeout — match-only clear like [handleSudoExpire].
|
||||
*/
|
||||
fun handleSecretExpire(event: WsEvent.SecretExpire) {
|
||||
uiState.update { state ->
|
||||
val current = state.secretPrompt ?: return@update state
|
||||
if (event.requestId != null && current.requestId != null &&
|
||||
event.requestId != current.requestId
|
||||
) {
|
||||
return@update state
|
||||
}
|
||||
state.copy(secretPrompt = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel → send empty password (desktop parity). Backend treats empty sudo as
|
||||
* failed sudo (no command runs), unblocking the turn instantly.
|
||||
*/
|
||||
fun dismissSudo() {
|
||||
val prompt = uiState.value.sudoPrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId
|
||||
uiState.update { it.copy(sudoPrompt = null) }
|
||||
if (sessionId == null) return
|
||||
scope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"password" to "",
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
WsMethods.SUDO_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.SUDO_RESPOND) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel → send empty value (desktop parity). Backend secret_cb returns
|
||||
* skipped=True on empty, unblocking the turn instantly.
|
||||
*/
|
||||
fun dismissSecret() {
|
||||
val prompt = uiState.value.secretPrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId
|
||||
uiState.update { it.copy(secretPrompt = null) }
|
||||
if (sessionId == null) return
|
||||
scope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"value" to "",
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
WsMethods.SECRET_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the user's sudo password back to the gateway. Clear prompt immediately,
|
||||
* then fire the RPC.
|
||||
*/
|
||||
fun respondToSudo(password: String) {
|
||||
val prompt = uiState.value.sudoPrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return
|
||||
if (password.isBlank()) return
|
||||
|
||||
uiState.update { it.copy(sudoPrompt = null) }
|
||||
|
||||
scope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"password" to password,
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
WsMethods.SUDO_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.SUDO_RESPOND) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the user's secret value back to the gateway.
|
||||
*/
|
||||
fun respondToSecret(value: String) {
|
||||
val prompt = uiState.value.secretPrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return
|
||||
if (value.isBlank()) return
|
||||
|
||||
uiState.update { it.copy(secretPrompt = null) }
|
||||
|
||||
scope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"value" to value,
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
WsMethods.SECRET_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.ModelCapabilities
|
||||
import com.m57.hermescontrol.data.model.ModelProvider
|
||||
import com.m57.hermescontrol.data.model.PinnedModel
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Tracks an in-flight `config.set key=model` RPC and the model it would replace
|
||||
* so confirmation dialog dismiss can roll back optimistically.
|
||||
*/
|
||||
private data class ActiveModelSwitch(
|
||||
val spec: String,
|
||||
val previousModel: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Owns model picker loading/caching, in-session model switches, model capabilities,
|
||||
* pinned model toggling, and expensive model switch confirmation dialogs.
|
||||
*
|
||||
* Extracted behavior-preservingly from [ChatViewModel] (issue #589).
|
||||
*/
|
||||
class ChatModelSwitchDelegate(
|
||||
private val scope: CoroutineScope,
|
||||
private val ioDispatcher: CoroutineDispatcher,
|
||||
private val uiState: MutableStateFlow<ChatUiState>,
|
||||
private val runtimeSessionId: () -> String?,
|
||||
private val wsSend: (method: String, params: Map<String, Any>, onSent: ((String) -> Unit)?) -> Unit,
|
||||
private val trackRequest: (id: String, method: String) -> Unit,
|
||||
private val addAssistantMessage: (text: String) -> Unit,
|
||||
private val handleSlashCommand: (command: String) -> Unit,
|
||||
private val fetchContextUsage: () -> Unit,
|
||||
private val getModelOptionsCall: suspend (
|
||||
refresh: Boolean,
|
||||
) -> NetworkResult<com.m57.hermescontrol.data.model.ModelOptionsResponse> =
|
||||
{ refresh -> safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh) } },
|
||||
private val getPinnedModels: () -> List<PinnedModel> = { AuthManager.getPinnedModels() },
|
||||
private val savePinnedModels: (List<PinnedModel>) -> Unit = { AuthManager.savePinnedModels(it) },
|
||||
) {
|
||||
private val pendingModelSwitchRequests = ConcurrentHashMap<String, ActiveModelSwitch>()
|
||||
private var activeModelSwitchConfirmation: ActiveModelSwitch? = null
|
||||
private var optimisticPreviousModel: String? = null
|
||||
private var cachedModelOptions: List<ModelProvider> = emptyList()
|
||||
|
||||
fun isModelPickerCommand(command: String): Boolean {
|
||||
val trimmed = command.trim()
|
||||
if (!trimmed.startsWith("/", ignoreCase = true)) return false
|
||||
val body = trimmed.removePrefix("/").trimStart()
|
||||
return body.equals("model", ignoreCase = true) ||
|
||||
(
|
||||
body.startsWith("model ", ignoreCase = true) &&
|
||||
body.substringAfter("model").trim().isEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
fun preloadModelOptions() {
|
||||
scope.launch(ioDispatcher) {
|
||||
val result = getModelOptionsCall(false)
|
||||
if (result is NetworkResult.Success) {
|
||||
cachedModelOptions = result.data.providers.orEmpty()
|
||||
uiState.update { it.copy(modelPickerPinned = getPinnedModels()) }
|
||||
syncCurrentModelCapabilities()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openModelPicker() {
|
||||
val hasCached = cachedModelOptions.isNotEmpty()
|
||||
uiState.update {
|
||||
it.copy(
|
||||
showModelPicker = true,
|
||||
modelPickerProviders = if (hasCached) cachedModelOptions else emptyList(),
|
||||
modelPickerPinned = getPinnedModels(),
|
||||
modelPickerLoading = !hasCached,
|
||||
)
|
||||
}
|
||||
if (!hasCached) {
|
||||
refreshModelOptions()
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshModelOptions() {
|
||||
uiState.update { it.copy(modelPickerLoading = true) }
|
||||
scope.launch(ioDispatcher) {
|
||||
val result = getModelOptionsCall(true)
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
cachedModelOptions = result.data.providers.orEmpty()
|
||||
uiState.update {
|
||||
it.copy(
|
||||
modelPickerProviders = cachedModelOptions,
|
||||
modelPickerLoading = false,
|
||||
)
|
||||
}
|
||||
syncCurrentModelCapabilities()
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
uiState.update {
|
||||
it.copy(
|
||||
modelPickerLoading = false,
|
||||
errorMessage = "Failed to load models: ${result.error.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun closeModelPicker() {
|
||||
uiState.update { it.copy(showModelPicker = false, modelPickerLoading = false) }
|
||||
}
|
||||
|
||||
fun togglePinModel(
|
||||
providerSlug: String,
|
||||
modelName: String,
|
||||
) {
|
||||
val currentPinned = getPinnedModels().toMutableList()
|
||||
val target = PinnedModel(providerSlug, modelName)
|
||||
if (currentPinned.contains(target)) {
|
||||
currentPinned.remove(target)
|
||||
} else {
|
||||
currentPinned.add(target)
|
||||
}
|
||||
savePinnedModels(currentPinned)
|
||||
uiState.update { it.copy(modelPickerPinned = currentPinned) }
|
||||
}
|
||||
|
||||
fun sendSlashModel(
|
||||
provider: String,
|
||||
model: String,
|
||||
) {
|
||||
optimisticPreviousModel = uiState.value.currentSessionModel
|
||||
uiState.update {
|
||||
it.copy(
|
||||
showModelPicker = false,
|
||||
modelPickerLoading = false,
|
||||
currentSessionModel = "$provider/$model",
|
||||
)
|
||||
}
|
||||
syncCurrentModelCapabilities()
|
||||
fetchContextUsage()
|
||||
handleSlashCommand("/model $model --provider $provider --session")
|
||||
}
|
||||
|
||||
fun handleModelSwitch(
|
||||
command: String,
|
||||
confirmExpensive: Boolean = false,
|
||||
) {
|
||||
val sessionId = runtimeSessionId()
|
||||
if (sessionId == null) {
|
||||
addAssistantMessage("No active session. Use `/new` to create one.")
|
||||
return
|
||||
}
|
||||
val spec =
|
||||
if (command.startsWith("/model", ignoreCase = true)) {
|
||||
command.substring(6).trim()
|
||||
} else {
|
||||
command.trim()
|
||||
}
|
||||
val previousModel = optimisticPreviousModel ?: uiState.value.currentSessionModel
|
||||
optimisticPreviousModel = null
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"key" to "model",
|
||||
"value" to spec,
|
||||
"session_id" to sessionId,
|
||||
)
|
||||
if (confirmExpensive) {
|
||||
params["confirm_expensive_model"] = true
|
||||
}
|
||||
scope.launch(ioDispatcher) {
|
||||
wsSend(
|
||||
WsMethods.CONFIG_SET,
|
||||
params,
|
||||
) { id ->
|
||||
trackRequest(id, WsMethods.CONFIG_SET)
|
||||
pendingModelSwitchRequests[id] = ActiveModelSwitch(spec, previousModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleConfigSetResult(
|
||||
id: String,
|
||||
result: Any?,
|
||||
) {
|
||||
val pending = pendingModelSwitchRequests.remove(id)
|
||||
val map = result as? Map<*, *> ?: return
|
||||
val key = map["key"] as? String
|
||||
if (key == "model") {
|
||||
val confirmRequired = map["confirm_required"] as? Boolean ?: false
|
||||
if (confirmRequired) {
|
||||
val confirmMessage =
|
||||
(map["confirm_message"] as? String)
|
||||
?: (map["warning"] as? String)
|
||||
?: "This model requires confirmation to switch. Continue?"
|
||||
activeModelSwitchConfirmation = pending
|
||||
uiState.update {
|
||||
it.copy(modelSwitchConfirmMessage = confirmMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissModelSwitchConfirm() {
|
||||
val pending = activeModelSwitchConfirmation
|
||||
activeModelSwitchConfirmation = null
|
||||
uiState.update {
|
||||
it.copy(
|
||||
modelSwitchConfirmMessage = null,
|
||||
currentSessionModel = pending?.previousModel ?: it.currentSessionModel,
|
||||
)
|
||||
}
|
||||
syncCurrentModelCapabilities()
|
||||
fetchContextUsage()
|
||||
}
|
||||
|
||||
fun confirmModelSwitchExpensive() {
|
||||
val pending = activeModelSwitchConfirmation
|
||||
activeModelSwitchConfirmation = null
|
||||
uiState.update { it.copy(modelSwitchConfirmMessage = null) }
|
||||
if (pending != null) {
|
||||
handleModelSwitch(pending.spec, confirmExpensive = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun setReasoningLevel(level: String?) {
|
||||
uiState.update { it.copy(reasoningLevel = level) }
|
||||
val sessionId = runtimeSessionId() ?: return
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
fun getModelCapabilities(
|
||||
providerSlug: String,
|
||||
modelName: String,
|
||||
): ModelCapabilities? = cachedModelOptions.find { it.slug == providerSlug }?.capabilities?.get(modelName)
|
||||
|
||||
fun getCurrentModelCapabilities(): ModelCapabilities? {
|
||||
val label = uiState.value.currentSessionModel ?: return null
|
||||
val idx = label.indexOf('/')
|
||||
if (idx <= 0) return null
|
||||
val provider = label.substring(0, idx)
|
||||
val model = label.substring(idx + 1)
|
||||
return getModelCapabilities(provider, model)
|
||||
}
|
||||
|
||||
fun syncCurrentModelCapabilities() {
|
||||
val label = uiState.value.currentSessionModel
|
||||
val caps =
|
||||
if (label == null) {
|
||||
null
|
||||
} else {
|
||||
val idx = label.indexOf('/')
|
||||
if (idx <= 0) {
|
||||
null
|
||||
} else {
|
||||
val provider = label.substring(0, idx)
|
||||
val model = label.substring(idx + 1)
|
||||
cachedModelOptions.find { it.slug == provider }?.capabilities?.get(model)
|
||||
}
|
||||
}
|
||||
uiState.update { state ->
|
||||
if (state.currentModelCapabilities != caps) state.copy(currentModelCapabilities = caps) else state
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -510,14 +510,6 @@ class ChatViewModel(
|
||||
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
|
||||
private var sessionGeneration = 0L
|
||||
|
||||
private data class ActiveModelSwitch(
|
||||
val spec: String,
|
||||
val previousModel: String?,
|
||||
)
|
||||
|
||||
private val pendingModelSwitchRequests = ConcurrentHashMap<String, ActiveModelSwitch>()
|
||||
private var activeModelSwitchConfirmation: ActiveModelSwitch? = null
|
||||
private var optimisticPreviousModel: String? = null
|
||||
private var resumeRequestSequence = 0L
|
||||
private var activeResumeRequestSequence = 0L
|
||||
private var hydrationRequestSequence = 0L
|
||||
@@ -615,12 +607,44 @@ class ChatViewModel(
|
||||
ioDispatcher = ioDispatcher,
|
||||
)
|
||||
|
||||
/**
|
||||
* Model options cached from GET /api/model/options so the in-session model
|
||||
* picker (issue #589) opens instantly when the user types /model or taps the
|
||||
* top-bar chip. Preloaded at GatewayReady; refreshed on open if empty.
|
||||
*/
|
||||
private var cachedModelOptions: List<ModelProvider> = emptyList()
|
||||
private val reloginAuthenticator =
|
||||
com.m57.hermescontrol.data.remote.ChatReloginAuthenticator(
|
||||
ioDispatcher = ioDispatcher,
|
||||
mainDispatcher = Dispatchers.Main,
|
||||
)
|
||||
|
||||
private val modelSwitchDelegate =
|
||||
ChatModelSwitchDelegate(
|
||||
scope = viewModelScope,
|
||||
ioDispatcher = ioDispatcher,
|
||||
uiState = _uiState,
|
||||
runtimeSessionId = { runtimeSessionId },
|
||||
wsSend = { method, params, onSent -> wsClient.send(method, params, onSent) },
|
||||
trackRequest = { id, method -> trackRequest(id, method) },
|
||||
addAssistantMessage = { text -> addAssistantMessage(text) },
|
||||
handleSlashCommand = { cmd -> handleSlashCommand(cmd) },
|
||||
fetchContextUsage = { fetchContextUsage() },
|
||||
)
|
||||
|
||||
private val credentialPromptsDelegate =
|
||||
ChatCredentialPromptsDelegate(
|
||||
scope = viewModelScope,
|
||||
ioDispatcher = ioDispatcher,
|
||||
uiState = _uiState,
|
||||
wsSend = { method, params, onSent -> wsClient.send(method, params, onSent) },
|
||||
trackRequest = { id, method -> trackRequest(id, method) },
|
||||
)
|
||||
|
||||
private val approvalsDelegate =
|
||||
ChatApprovalsDelegate(
|
||||
scope = viewModelScope,
|
||||
ioDispatcher = ioDispatcher,
|
||||
uiState = _uiState,
|
||||
runtimeSessionId = { runtimeSessionId },
|
||||
wsSend = { method, params, onSent -> wsClient.send(method, params, onSent) },
|
||||
trackRequest = { id, method -> trackRequest(id, method) },
|
||||
addSystemMessage = { text -> addSystemMessage(text) },
|
||||
)
|
||||
|
||||
private val streamingController =
|
||||
ChatStreamingController(
|
||||
@@ -796,7 +820,7 @@ class ChatViewModel(
|
||||
addSystemMessage("Connected to Hermes")
|
||||
loadSessions()
|
||||
fetchCommandCatalog()
|
||||
preloadModelOptions()
|
||||
modelSwitchDelegate.preloadModelOptions()
|
||||
val currentId = _uiState.value.currentSessionId
|
||||
if (currentId != null) {
|
||||
if (sessionHasServerPresence) {
|
||||
@@ -944,7 +968,7 @@ class ChatViewModel(
|
||||
fullContextTokens = if (modelSwapped) null else state.fullContextTokens,
|
||||
)
|
||||
}
|
||||
syncCurrentModelCapabilities()
|
||||
modelSwitchDelegate.syncCurrentModelCapabilities()
|
||||
if (modelSwapped) {
|
||||
// Issue #817: after a swap the REST model/info window is
|
||||
// PROFILE-scoped and may describe the old model (e.g. a
|
||||
@@ -957,18 +981,10 @@ class ChatViewModel(
|
||||
// reconciliation) — surface it unless already on screen.
|
||||
val pendingApproval = info["pending_approval"] as? Map<*, *>
|
||||
if (pendingApproval != null) {
|
||||
val rid = pendingApproval["request_id"] as? String
|
||||
val alreadyShown =
|
||||
rid != null &&
|
||||
_uiState.value.messages.any { it.approvalInfo?.requestId == rid }
|
||||
if (!alreadyShown) {
|
||||
handleApprovalRequest(
|
||||
parseApprovalMap(
|
||||
pendingApproval,
|
||||
runtimeSessionId ?: _uiState.value.currentSessionId,
|
||||
),
|
||||
)
|
||||
}
|
||||
approvalsDelegate.maybeSurfacePendingApproval(
|
||||
pendingApproval,
|
||||
runtimeSessionId ?: _uiState.value.currentSessionId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1034,23 +1050,23 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
is WsEvent.ApprovalRequest -> {
|
||||
handleApprovalRequest(event)
|
||||
approvalsDelegate.handleApprovalRequest(event)
|
||||
}
|
||||
|
||||
is WsEvent.SudoRequest -> {
|
||||
handleSudoRequest(event)
|
||||
credentialPromptsDelegate.handleSudoRequest(event)
|
||||
}
|
||||
|
||||
is WsEvent.SudoExpire -> {
|
||||
handleSudoExpire(event)
|
||||
credentialPromptsDelegate.handleSudoExpire(event)
|
||||
}
|
||||
|
||||
is WsEvent.SecretRequest -> {
|
||||
handleSecretRequest(event)
|
||||
credentialPromptsDelegate.handleSecretRequest(event)
|
||||
}
|
||||
|
||||
is WsEvent.SecretExpire -> {
|
||||
handleSecretExpire(event)
|
||||
credentialPromptsDelegate.handleSecretExpire(event)
|
||||
}
|
||||
|
||||
is WsEvent.GatewayError -> {
|
||||
@@ -1244,7 +1260,7 @@ class ChatViewModel(
|
||||
terminalBackend = terminalBackend ?: it.terminalBackend,
|
||||
)
|
||||
}
|
||||
syncCurrentModelCapabilities()
|
||||
modelSwitchDelegate.syncCurrentModelCapabilities()
|
||||
// Mirror the active runtime session id app-wide (issue #532).
|
||||
ActiveSessionHolder.set(runtimeSessionId ?: sessionId, sessionId)
|
||||
addSystemMessage("Session resumed")
|
||||
@@ -1257,21 +1273,13 @@ class ChatViewModel(
|
||||
// the full queue in case more are parked.
|
||||
val pendingApproval = resultMap?.get("pending_approval") as? Map<*, *>
|
||||
if (pendingApproval != null) {
|
||||
val rid = pendingApproval["request_id"] as? String
|
||||
val alreadyShown =
|
||||
rid != null &&
|
||||
_uiState.value.messages.any { it.approvalInfo?.requestId == rid }
|
||||
if (!alreadyShown) {
|
||||
handleApprovalRequest(
|
||||
parseApprovalMap(
|
||||
pendingApproval,
|
||||
runtimeSessionId ?: sessionId,
|
||||
),
|
||||
)
|
||||
}
|
||||
approvalsDelegate.maybeSurfacePendingApproval(
|
||||
pendingApproval,
|
||||
runtimeSessionId ?: sessionId,
|
||||
)
|
||||
}
|
||||
val activeSessionId = runtimeSessionId ?: sessionId
|
||||
if (activeSessionId != null) replayPendingApproval(activeSessionId)
|
||||
if (activeSessionId != null) approvalsDelegate.replayPendingApproval(activeSessionId)
|
||||
}
|
||||
|
||||
WsMethods.SESSION_INTERRUPT -> {
|
||||
@@ -1304,34 +1312,15 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
WsMethods.APPROVAL_RESPOND -> {
|
||||
val map = result as? Map<*, *>
|
||||
val resolved = (map?.get("resolved") as? Number)?.toInt() ?: 0
|
||||
if (resolved > 0) {
|
||||
addSystemMessage("Approval submitted")
|
||||
}
|
||||
approvalsDelegate.handleApprovalRespondResult(result)
|
||||
}
|
||||
|
||||
WsMethods.APPROVAL_PENDING -> {
|
||||
handleApprovalPendingResult(result)
|
||||
approvalsDelegate.handleApprovalPendingResult(result)
|
||||
}
|
||||
|
||||
WsMethods.CONFIG_SET -> {
|
||||
val pending = pendingModelSwitchRequests.remove(id)
|
||||
val map = result as? Map<*, *> ?: return
|
||||
val key = map["key"] as? String
|
||||
if (key == "model") {
|
||||
val confirmRequired = map["confirm_required"] as? Boolean ?: false
|
||||
if (confirmRequired) {
|
||||
val confirmMessage =
|
||||
(map["confirm_message"] as? String)
|
||||
?: (map["warning"] as? String)
|
||||
?: "This model requires confirmation to switch. Continue?"
|
||||
activeModelSwitchConfirmation = pending
|
||||
_uiState.update {
|
||||
it.copy(modelSwitchConfirmMessage = confirmMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
modelSwitchDelegate.handleConfigSetResult(id, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1455,7 +1444,7 @@ class ChatViewModel(
|
||||
if (trimmed.startsWith("/", ignoreCase = true)) {
|
||||
// Issue #589: a bare "/model" (no argument) opens the picker instead
|
||||
// of requiring the user to hand-type the provider/model.
|
||||
if (isModelPickerCommand(trimmed)) {
|
||||
if (modelSwitchDelegate.isModelPickerCommand(trimmed)) {
|
||||
openModelPicker()
|
||||
return
|
||||
}
|
||||
@@ -1880,7 +1869,7 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
is SlashResult.ModelSwitch -> {
|
||||
handleModelSwitch(command)
|
||||
modelSwitchDelegate.handleModelSwitch(command)
|
||||
}
|
||||
|
||||
is SlashResult.Update -> {
|
||||
@@ -2123,68 +2112,6 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hot-swap the current session's model via the backend's model-switch
|
||||
* mechanism (issue #589).
|
||||
*
|
||||
* The TUI gateway's `prompt.submit` does NOT parse slash commands (it would
|
||||
* make the LLM treat "/model ..." as a chat message), and `command.dispatch`
|
||||
* only knows quick/plugin/bundle/skill commands (4018s on /model). The
|
||||
* correct RPC is `config.set` with `key="model"` — the gateway (server.py
|
||||
* `config.set`, L10253) routes `key=="model"` straight to `_apply_model_switch`
|
||||
* using the same `_sessions.get(session_id)` lookup that the working
|
||||
* `command.dispatch` uses.
|
||||
*
|
||||
* IMPORTANT: `config.set` key=model passes the value DIRECTLY to
|
||||
* `parse_model_flags` (it does NOT strip a leading "/model" like slash.exec /
|
||||
* prompt.submit do). So we strip the "/model" prefix here and send the bare
|
||||
* spec `parse_model_flags` understands:
|
||||
* `<model> --provider <slug> --session`
|
||||
* (matching the TUI client's `modelValueForConfigSet`).
|
||||
*/
|
||||
private fun handleModelSwitch(
|
||||
command: String,
|
||||
confirmExpensive: Boolean = false,
|
||||
) {
|
||||
val sessionId = runtimeSessionId
|
||||
if (sessionId == null) {
|
||||
addAssistantMessage("No active session. Use `/new` to create one.")
|
||||
return
|
||||
}
|
||||
// Strip a leading "/model" (and any following whitespace) — config.set
|
||||
// key=model expects the bare spec, not a slash command. Match the
|
||||
// dispatcher's case-insensitive "/model" detection so a typed "/MODEL"
|
||||
// (or any casing) doesn't forward the literal slash prefix to the
|
||||
// backend, where parse_model_flags wouldn't recognize it.
|
||||
val spec =
|
||||
if (command.startsWith("/model", ignoreCase = true)) {
|
||||
command.substring(6).trim()
|
||||
} else {
|
||||
command.trim()
|
||||
}
|
||||
val previousModel = optimisticPreviousModel ?: _uiState.value.currentSessionModel
|
||||
optimisticPreviousModel = null
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"key" to "model",
|
||||
"value" to spec,
|
||||
"session_id" to sessionId,
|
||||
)
|
||||
if (confirmExpensive) {
|
||||
params["confirm_expensive_model"] = true
|
||||
}
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
wsClient.send(
|
||||
WsMethods.CONFIG_SET,
|
||||
params,
|
||||
onSent = { id ->
|
||||
trackRequest(id, WsMethods.CONFIG_SET)
|
||||
pendingModelSwitchRequests[id] = ActiveModelSwitch(spec, previousModel)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits [text] as a prompt to the current session via WS, without
|
||||
* adding a duplicate user message. Used by [handleDispatchResult] when
|
||||
@@ -2470,220 +2397,34 @@ class ChatViewModel(
|
||||
|
||||
// ── In-session model picker (issue #589) ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Whether [command] should open the in-session model picker instead of being
|
||||
* dispatched as a normal slash command. True for a bare `/model` (with no
|
||||
* trailing model argument) — the picker supplies the argument interactively.
|
||||
* A fully-typed `/model provider/model` is forwarded straight to the backend.
|
||||
*/
|
||||
private fun isModelPickerCommand(command: String): Boolean {
|
||||
val trimmed = command.trim()
|
||||
if (!trimmed.startsWith("/", ignoreCase = true)) return false
|
||||
val body = trimmed.removePrefix("/").trimStart()
|
||||
// Must be exactly "model" with no argument (or just whitespace).
|
||||
return body.equals("model", ignoreCase = true) ||
|
||||
(
|
||||
body.startsWith("model ", ignoreCase = true) &&
|
||||
body.substringAfter("model").trim().isEmpty()
|
||||
)
|
||||
}
|
||||
fun openModelPicker() = modelSwitchDelegate.openModelPicker()
|
||||
|
||||
/** Preload model options so the picker opens instantly (no spinner on /model). */
|
||||
private fun preloadModelOptions() {
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getModelOptions(refresh = false)
|
||||
}
|
||||
if (result is NetworkResult.Success) {
|
||||
cachedModelOptions = result.data.providers.orEmpty()
|
||||
_uiState.update { it.copy(modelPickerPinned = AuthManager.getPinnedModels()) }
|
||||
syncCurrentModelCapabilities()
|
||||
}
|
||||
}
|
||||
}
|
||||
fun refreshModelOptions() = modelSwitchDelegate.refreshModelOptions()
|
||||
|
||||
/**
|
||||
* Open the in-session model picker. Uses the preloaded options if available
|
||||
* (instant open); otherwise shows a loading state and fetches them. The
|
||||
* `/model` slash command is the supported session hot-swap mechanism per the
|
||||
* backend contract (issue #589).
|
||||
*/
|
||||
fun openModelPicker() {
|
||||
val hasCached = cachedModelOptions.isNotEmpty()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
showModelPicker = true,
|
||||
modelPickerProviders = if (hasCached) cachedModelOptions else emptyList(),
|
||||
modelPickerPinned = AuthManager.getPinnedModels(),
|
||||
modelPickerLoading = !hasCached,
|
||||
)
|
||||
}
|
||||
if (!hasCached) {
|
||||
refreshModelOptions()
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-fetch options (pull-to-refresh style) when the picker is already open. */
|
||||
fun refreshModelOptions() {
|
||||
_uiState.update { it.copy(modelPickerLoading = true) }
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getModelOptions(refresh = true)
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
cachedModelOptions = result.data.providers.orEmpty()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
modelPickerProviders = cachedModelOptions,
|
||||
modelPickerLoading = false,
|
||||
)
|
||||
}
|
||||
syncCurrentModelCapabilities()
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
modelPickerLoading = false,
|
||||
errorMessage = "Failed to load models: ${result.error.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun closeModelPicker() {
|
||||
_uiState.update { it.copy(showModelPicker = false, modelPickerLoading = false) }
|
||||
}
|
||||
fun closeModelPicker() = modelSwitchDelegate.closeModelPicker()
|
||||
|
||||
fun togglePinModel(
|
||||
providerSlug: String,
|
||||
modelName: String,
|
||||
) {
|
||||
val currentPinned = AuthManager.getPinnedModels().toMutableList()
|
||||
val target = PinnedModel(providerSlug, modelName)
|
||||
if (currentPinned.contains(target)) {
|
||||
currentPinned.remove(target)
|
||||
} else {
|
||||
currentPinned.add(target)
|
||||
}
|
||||
AuthManager.savePinnedModels(currentPinned)
|
||||
_uiState.update { it.copy(modelPickerPinned = currentPinned) }
|
||||
}
|
||||
) = modelSwitchDelegate.togglePinModel(providerSlug, modelName)
|
||||
|
||||
/**
|
||||
* Hot-swap the CURRENT session's model via the /model slash command.
|
||||
*
|
||||
* Builds the backend-valid command form `/model <model> --provider <slug>
|
||||
* --session`. The `--session` flag keeps the switch scoped to this chat
|
||||
* only (it writes a per-session override and never touches the global
|
||||
* model config), per the backend model-switch contract.
|
||||
*/
|
||||
fun sendSlashModel(
|
||||
provider: String,
|
||||
model: String,
|
||||
) {
|
||||
optimisticPreviousModel = _uiState.value.currentSessionModel
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
showModelPicker = false,
|
||||
modelPickerLoading = false,
|
||||
// Optimistic: reflect the chosen model in the top-bar chip until
|
||||
// the next session sync confirms the backend hot-swap.
|
||||
currentSessionModel = "$provider/$model",
|
||||
)
|
||||
}
|
||||
syncCurrentModelCapabilities()
|
||||
// Model switch changes the context-window denominator — refetch it.
|
||||
fetchContextUsage()
|
||||
handleSlashCommand("/model $model --provider $provider --session")
|
||||
}
|
||||
) = modelSwitchDelegate.sendSlashModel(provider, model)
|
||||
|
||||
fun dismissModelSwitchConfirm() {
|
||||
val pending = activeModelSwitchConfirmation
|
||||
activeModelSwitchConfirmation = null
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
modelSwitchConfirmMessage = null,
|
||||
currentSessionModel = pending?.previousModel ?: it.currentSessionModel,
|
||||
)
|
||||
}
|
||||
syncCurrentModelCapabilities()
|
||||
fetchContextUsage()
|
||||
}
|
||||
fun dismissModelSwitchConfirm() = modelSwitchDelegate.dismissModelSwitchConfirm()
|
||||
|
||||
fun confirmModelSwitchExpensive() {
|
||||
val pending = activeModelSwitchConfirmation
|
||||
activeModelSwitchConfirmation = null
|
||||
_uiState.update { it.copy(modelSwitchConfirmMessage = null) }
|
||||
if (pending != null) {
|
||||
handleModelSwitch(pending.spec, confirmExpensive = true)
|
||||
}
|
||||
}
|
||||
fun confirmModelSwitchExpensive() = modelSwitchDelegate.confirmModelSwitchExpensive()
|
||||
|
||||
/**
|
||||
* Set the reasoning effort level for the current session.
|
||||
*
|
||||
* Updates the UI optimistically and sends a `config.set` RPC to the
|
||||
* backend. The level applies per-session via the runtime session ID.
|
||||
* If [level] is null it resets to the model's default.
|
||||
*
|
||||
* @param level One of "low", "medium", "high", or null for default.
|
||||
*/
|
||||
fun setReasoningLevel(level: String?) {
|
||||
_uiState.update { it.copy(reasoningLevel = level) }
|
||||
val sessionId = runtimeSessionId ?: return
|
||||
if (level == null) return // null = model default, no need to send WS
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
wsClient.send(
|
||||
WsMethods.CONFIG_SET,
|
||||
mapOf(
|
||||
"key" to "reasoning",
|
||||
"value" to level,
|
||||
"session_id" to sessionId,
|
||||
),
|
||||
onSent = { id -> trackRequest(id, WsMethods.CONFIG_SET) },
|
||||
)
|
||||
}
|
||||
}
|
||||
fun setReasoningLevel(level: String?) = modelSwitchDelegate.setReasoningLevel(level)
|
||||
|
||||
fun getModelCapabilities(
|
||||
providerSlug: String,
|
||||
modelName: String,
|
||||
): ModelCapabilities? = cachedModelOptions.find { it.slug == providerSlug }?.capabilities?.get(modelName)
|
||||
): ModelCapabilities? = modelSwitchDelegate.getModelCapabilities(providerSlug, modelName)
|
||||
|
||||
fun getCurrentModelCapabilities(): ModelCapabilities? {
|
||||
val label = _uiState.value.currentSessionModel ?: return null
|
||||
val idx = label.indexOf('/')
|
||||
if (idx <= 0) return null
|
||||
val provider = label.substring(0, idx)
|
||||
val model = label.substring(idx + 1)
|
||||
return getModelCapabilities(provider, model)
|
||||
}
|
||||
|
||||
private fun syncCurrentModelCapabilities() {
|
||||
val label = _uiState.value.currentSessionModel
|
||||
val caps =
|
||||
if (label == null) {
|
||||
null
|
||||
} else {
|
||||
val idx = label.indexOf('/')
|
||||
if (idx <= 0) {
|
||||
null
|
||||
} else {
|
||||
val provider = label.substring(0, idx)
|
||||
val model = label.substring(idx + 1)
|
||||
cachedModelOptions.find { it.slug == provider }?.capabilities?.get(model)
|
||||
}
|
||||
}
|
||||
_uiState.update { state ->
|
||||
if (state.currentModelCapabilities != caps) state.copy(currentModelCapabilities = caps) else state
|
||||
}
|
||||
}
|
||||
fun getCurrentModelCapabilities(): ModelCapabilities? = modelSwitchDelegate.getCurrentModelCapabilities()
|
||||
|
||||
fun switchSession(sessionId: String) {
|
||||
if (sessionId == _uiState.value.currentSessionId) return
|
||||
@@ -3762,332 +3503,17 @@ class ChatViewModel(
|
||||
|
||||
// ── Approval flow ───────────────────────────────────────────────────
|
||||
|
||||
private fun handleApprovalRequest(event: WsEvent.ApprovalRequest) {
|
||||
val description = event.description ?: event.command ?: "Unknown command"
|
||||
val content = "**Approval Required**\n$description"
|
||||
val msg =
|
||||
ChatMessage(
|
||||
role = MessageRole.SYSTEM,
|
||||
content = content,
|
||||
approvalInfo =
|
||||
ApprovalInfo(
|
||||
command = event.command,
|
||||
description = event.description,
|
||||
patternKeys = event.patternKeys,
|
||||
requestId = event.requestId,
|
||||
choices = event.choices,
|
||||
allowPermanent = event.allowPermanent,
|
||||
smartDenied = event.smartDenied,
|
||||
),
|
||||
)
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
messages = state.messages + msg,
|
||||
isAgentTyping = false,
|
||||
)
|
||||
}
|
||||
// Desktop parity (`prompts.ts` receiveApprovalRequest): ack the render
|
||||
// so the backend knows this client holds the prompt. Fire-and-forget.
|
||||
val requestId = event.requestId
|
||||
val sessionId = runtimeSessionId ?: event.sessionId ?: _uiState.value.currentSessionId
|
||||
if (requestId != null && sessionId != null) {
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
runCatching {
|
||||
wsClient.send(
|
||||
method = WsMethods.APPROVAL_RECEIVED,
|
||||
params =
|
||||
mapOf(
|
||||
"session_id" to sessionId,
|
||||
"request_id" to requestId,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a backend approval map (snake_case, from `approval.pending` or
|
||||
* `session.info` `pending_approval`) into a typed event. Mirrors
|
||||
* [EventParser] defaults so replay and live paths agree.
|
||||
*/
|
||||
private fun parseApprovalMap(
|
||||
map: Map<*, *>,
|
||||
sessionId: String?,
|
||||
): WsEvent.ApprovalRequest {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val patternKeys = (map["pattern_keys"] as? List<*>)?.filterIsInstance<String>()
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val rawChoices = (map["choices"] as? List<*>)?.filterIsInstance<String>()
|
||||
val allowPermanent = map["allow_permanent"] as? Boolean
|
||||
val allowSession = map["allow_session"] as? Boolean
|
||||
val smartDenied = map["smart_denied"] as? Boolean
|
||||
val choices =
|
||||
rawChoices ?: run {
|
||||
if (smartDenied == true) {
|
||||
listOf("once", "deny")
|
||||
} else {
|
||||
buildList {
|
||||
add("once")
|
||||
if (allowSession != false) {
|
||||
add("session")
|
||||
if (allowPermanent != false) add("always")
|
||||
}
|
||||
add("deny")
|
||||
}
|
||||
}
|
||||
}
|
||||
return WsEvent.ApprovalRequest(
|
||||
command = map["command"] as? String,
|
||||
description = map["description"] as? String,
|
||||
patternKeys = patternKeys,
|
||||
sessionId = sessionId,
|
||||
requestId = map["request_id"] as? String,
|
||||
choices = choices,
|
||||
allowPermanent = allowPermanent,
|
||||
smartDenied = smartDenied,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect replay (desktop `replayPendingApproval` parity): ask the
|
||||
* gateway for unresolved approvals and surface the oldest one. Called
|
||||
* after resume and after each respond (the queue can hold more).
|
||||
*/
|
||||
private fun replayPendingApproval(sessionId: String) {
|
||||
val targetSessionId = runtimeSessionId ?: sessionId
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
wsClient.send(
|
||||
method = WsMethods.APPROVAL_PENDING,
|
||||
params = mapOf("session_id" to targetSessionId),
|
||||
onSent = { id -> trackRequest(id, WsMethods.APPROVAL_PENDING) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleApprovalPendingResult(result: Any?) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val approvals = (result as? Map<*, *>)?.get("approvals") as? List<*>
|
||||
val first = approvals?.filterIsInstance<Map<*, *>>()?.firstOrNull() ?: return
|
||||
val requestId = first["request_id"] as? String ?: return
|
||||
val sessionId = runtimeSessionId ?: _uiState.value.currentSessionId
|
||||
// Don't duplicate a prompt already on screen.
|
||||
val alreadyShown =
|
||||
_uiState.value.messages.any { it.approvalInfo?.requestId == requestId }
|
||||
if (alreadyShown) return
|
||||
handleApprovalRequest(parseApprovalMap(first, sessionId))
|
||||
}
|
||||
|
||||
fun respondToApproval(action: String) {
|
||||
val state = _uiState.value
|
||||
val approvalMsg = state.messages.lastOrNull { it.approvalInfo != null } ?: return
|
||||
val sessionId = runtimeSessionId ?: state.currentSessionId ?: return
|
||||
// Desktop sends `once` for a single run; legacy mobile sent `approve`
|
||||
// (any non-deny still unblocks, but stay on-spec going forward).
|
||||
val choice = if (action == "approve") "once" else action
|
||||
val requestId = approvalMsg.approvalInfo?.requestId
|
||||
|
||||
// Clear buttons immediately
|
||||
_uiState.update { s ->
|
||||
s.copy(
|
||||
messages =
|
||||
s.messages.map {
|
||||
if (it.id == approvalMsg.id) {
|
||||
it.copy(approvalInfo = null)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"choice" to choice,
|
||||
"all" to false,
|
||||
)
|
||||
if (requestId != null) params["request_id"] = requestId
|
||||
wsClient.send(
|
||||
method = WsMethods.APPROVAL_RESPOND,
|
||||
params = params,
|
||||
onSent = { id -> trackRequest(id, WsMethods.APPROVAL_RESPOND) },
|
||||
)
|
||||
// The queue can hold more pendings — surface the next one.
|
||||
replayPendingApproval(sessionId)
|
||||
}
|
||||
}
|
||||
fun respondToApproval(action: String) = approvalsDelegate.respondToApproval(action)
|
||||
|
||||
// ── Sudo / secret prompt flow (issue #524) ──────────────────────────
|
||||
|
||||
/**
|
||||
* The agent needs the user's sudo password. Previously dropped → agent
|
||||
* hung forever. Now we surface a secure dialog and reply via sudo.respond.
|
||||
*/
|
||||
private fun handleSudoRequest(event: WsEvent.SudoRequest) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
sudoPrompt = SudoPromptUi(event.requestId, event.sessionId),
|
||||
isAgentTyping = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
fun dismissSudo() = credentialPromptsDelegate.dismissSudo()
|
||||
|
||||
/**
|
||||
* Backend sudo timeout (120s) — clear only the matching dialog so a
|
||||
* late expire for an old prompt never kills the current one.
|
||||
* Desktop parity: `patchOverlayState(prev => prev.sudo?.requestId === id ? null : prev)`.
|
||||
*/
|
||||
private fun handleSudoExpire(event: WsEvent.SudoExpire) {
|
||||
_uiState.update { state ->
|
||||
val current = state.sudoPrompt ?: return@update state
|
||||
if (event.requestId != null && current.requestId != null &&
|
||||
event.requestId != current.requestId
|
||||
) {
|
||||
return@update state
|
||||
}
|
||||
state.copy(sudoPrompt = null)
|
||||
}
|
||||
}
|
||||
fun dismissSecret() = credentialPromptsDelegate.dismissSecret()
|
||||
|
||||
/**
|
||||
* The agent needs a secret value (token/password). Previously dropped →
|
||||
* agent hung forever. Now we surface a secure dialog and reply via
|
||||
* secret.respond.
|
||||
*/
|
||||
private fun handleSecretRequest(event: WsEvent.SecretRequest) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
secretPrompt =
|
||||
SecretPromptUi(
|
||||
event.requestId,
|
||||
event.sessionId,
|
||||
event.envVar,
|
||||
event.prompt,
|
||||
),
|
||||
isAgentTyping = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
fun respondToSudo(password: String) = credentialPromptsDelegate.respondToSudo(password)
|
||||
|
||||
/**
|
||||
* Backend secret timeout — match-only clear like [handleSudoExpire].
|
||||
*/
|
||||
private fun handleSecretExpire(event: WsEvent.SecretExpire) {
|
||||
_uiState.update { state ->
|
||||
val current = state.secretPrompt ?: return@update state
|
||||
if (event.requestId != null && current.requestId != null &&
|
||||
event.requestId != current.requestId
|
||||
) {
|
||||
return@update state
|
||||
}
|
||||
state.copy(secretPrompt = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel → send empty password (desktop parity:
|
||||
* `prompt-overlays.tsx` `send('')`). Backend treats empty sudo as
|
||||
* failed sudo (no command runs), so closing the dialog is a safe
|
||||
* refusal that unblocks the turn instantly instead of hanging 120s
|
||||
* until `sudo.expire`.
|
||||
*/
|
||||
fun dismissSudo() {
|
||||
val prompt = _uiState.value.sudoPrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: _uiState.value.currentSessionId
|
||||
_uiState.update { it.copy(sudoPrompt = null) }
|
||||
if (sessionId == null) return
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"password" to "",
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsClient.send(
|
||||
method = WsMethods.SUDO_RESPOND,
|
||||
params = params,
|
||||
onSent = { id -> trackRequest(id, WsMethods.SUDO_RESPOND) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel → send empty value (desktop parity). Backend `secret_cb`
|
||||
* returns `skipped=True` on empty, unblocking the turn instantly.
|
||||
*/
|
||||
fun dismissSecret() {
|
||||
val prompt = _uiState.value.secretPrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: _uiState.value.currentSessionId
|
||||
_uiState.update { it.copy(secretPrompt = null) }
|
||||
if (sessionId == null) return
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"value" to "",
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsClient.send(
|
||||
method = WsMethods.SECRET_RESPOND,
|
||||
params = params,
|
||||
onSent = { id -> trackRequest(id, WsMethods.SECRET_RESPOND) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the user's sudo password back to the gateway. Mirrors
|
||||
* respondToApproval: clear the prompt immediately, then fire the RPC.
|
||||
*/
|
||||
fun respondToSudo(password: String) {
|
||||
val prompt = _uiState.value.sudoPrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: _uiState.value.currentSessionId ?: return
|
||||
if (password.isBlank()) return
|
||||
|
||||
_uiState.update { it.copy(sudoPrompt = null) }
|
||||
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"password" to password,
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsClient.send(
|
||||
method = WsMethods.SUDO_RESPOND,
|
||||
params = params,
|
||||
onSent = { id -> trackRequest(id, WsMethods.SUDO_RESPOND) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the user's secret value back to the gateway. Mirrors respondToSudo.
|
||||
*/
|
||||
fun respondToSecret(value: String) {
|
||||
val prompt = _uiState.value.secretPrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: _uiState.value.currentSessionId ?: return
|
||||
if (value.isBlank()) return
|
||||
|
||||
_uiState.update { it.copy(secretPrompt = null) }
|
||||
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"value" to value,
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsClient.send(
|
||||
method = WsMethods.SECRET_RESPOND,
|
||||
params = params,
|
||||
onSent = { id -> trackRequest(id, WsMethods.SECRET_RESPOND) },
|
||||
)
|
||||
}
|
||||
}
|
||||
fun respondToSecret(value: String) = credentialPromptsDelegate.respondToSecret(value)
|
||||
|
||||
fun reconnect() {
|
||||
_uiState.update {
|
||||
@@ -4111,95 +3537,18 @@ class ChatViewModel(
|
||||
password: String,
|
||||
onResult: (Boolean, String?) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
val endpoint = AuthManager.endpointForBuild()
|
||||
val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val jsonBody =
|
||||
JSONObject()
|
||||
.put("provider", "basic")
|
||||
.put("username", username)
|
||||
.put("password", password)
|
||||
.put("next", "")
|
||||
.toString()
|
||||
|
||||
try {
|
||||
val loginClient =
|
||||
com.m57.hermescontrol.data.remote.OkHttpProvider.probe
|
||||
.newBuilder()
|
||||
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val loginReq =
|
||||
Request
|
||||
.Builder()
|
||||
.url(endpoint.resolve("auth/password-login").toString())
|
||||
.header("Content-Type", "application/json")
|
||||
.post(jsonBody.toRequestBody(jsonMediaType))
|
||||
.build()
|
||||
loginClient.newCall(loginReq).execute().use { loginResp ->
|
||||
if (!loginResp.isSuccessful) {
|
||||
val msg =
|
||||
when (loginResp.code) {
|
||||
401 -> "Invalid username or password (401)"
|
||||
403 -> "Forbidden (403)"
|
||||
else -> "HTTP error code: ${loginResp.code}"
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
onResult(false, msg)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
val ticketClient =
|
||||
com.m57.hermescontrol.data.remote.OkHttpProvider.base
|
||||
.newBuilder()
|
||||
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val ticketReq =
|
||||
Request
|
||||
.Builder()
|
||||
.url(endpoint.resolve("api/auth/ws-ticket").toString())
|
||||
.post("{}".toRequestBody(jsonMediaType))
|
||||
.build()
|
||||
ticketClient.newCall(ticketReq).execute().use { ticketResp ->
|
||||
if (!ticketResp.isSuccessful) {
|
||||
withContext(Dispatchers.Main) {
|
||||
onResult(false, "Failed to mint WS ticket: HTTP ${ticketResp.code}")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
val body = ticketResp.body.string()
|
||||
val ticket = JSONObject(body).optString("ticket").takeIf { it.isNotBlank() }
|
||||
|
||||
if (ticket.isNullOrBlank()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
onResult(false, "Invalid ticket returned from server")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
AuthManager.setWsAuthParam("ticket")
|
||||
AuthManager.setToken(ticket)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
onResult(true, null)
|
||||
reconnect()
|
||||
}
|
||||
}
|
||||
} catch (e: java.io.IOException) {
|
||||
withContext(Dispatchers.Main) {
|
||||
onResult(false, "Connection failed: ${e.message}")
|
||||
}
|
||||
} catch (e: org.json.JSONException) {
|
||||
withContext(Dispatchers.Main) {
|
||||
onResult(false, "Connection failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
reloginAuthenticator.relogin(
|
||||
username = username,
|
||||
password = password,
|
||||
onSuccess = {
|
||||
onResult(true, null)
|
||||
reconnect()
|
||||
},
|
||||
onFailure = { msg ->
|
||||
onResult(false, msg)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ChatApprovalsDelegateTest {
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
private val uiState = MutableStateFlow(ChatUiState(currentSessionId = "storage-session-1"))
|
||||
private var runtimeId: String? = null
|
||||
private val sentMethods = mutableListOf<String>()
|
||||
private val sentParams = mutableListOf<Map<String, Any>>()
|
||||
private val trackedRequests = mutableListOf<Pair<String, String>>()
|
||||
private val systemMessages = mutableListOf<String>()
|
||||
|
||||
private val delegate =
|
||||
ChatApprovalsDelegate(
|
||||
scope = testScope,
|
||||
ioDispatcher = testDispatcher,
|
||||
uiState = uiState,
|
||||
runtimeSessionId = { runtimeId },
|
||||
wsSend = { method, params, onSent ->
|
||||
sentMethods.add(method)
|
||||
sentParams.add(params)
|
||||
onSent?.invoke("req-id-${sentMethods.size}")
|
||||
},
|
||||
trackRequest = { id, method -> trackedRequests.add(id to method) },
|
||||
addSystemMessage = { text -> systemMessages.add(text) },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun parseApprovalMap_honorsSmartDeniedAndChoicePermutations() {
|
||||
val denied =
|
||||
parseApprovalMap(
|
||||
mapOf("smart_denied" to true),
|
||||
sessionId = "s1",
|
||||
)
|
||||
assertEquals(listOf("once", "deny"), denied.choices)
|
||||
|
||||
val full =
|
||||
parseApprovalMap(
|
||||
mapOf("allow_session" to true, "allow_permanent" to true),
|
||||
sessionId = "s1",
|
||||
)
|
||||
assertEquals(listOf("once", "session", "always", "deny"), full.choices)
|
||||
|
||||
val noAlways =
|
||||
parseApprovalMap(
|
||||
mapOf("allow_session" to true, "allow_permanent" to false),
|
||||
sessionId = "s1",
|
||||
)
|
||||
assertEquals(listOf("once", "session", "deny"), noAlways.choices)
|
||||
|
||||
val explicit =
|
||||
parseApprovalMap(
|
||||
mapOf("choices" to listOf("custom", "deny")),
|
||||
sessionId = "s1",
|
||||
)
|
||||
assertEquals(listOf("custom", "deny"), explicit.choices)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleApprovalRequest_appendsSystemMessageAndSendsAckWithPreferredRuntimeId() =
|
||||
testScope.runTest {
|
||||
runtimeId = "runtime-session-xyz"
|
||||
val event =
|
||||
WsEvent.ApprovalRequest(
|
||||
command = "git status",
|
||||
description = "inspect working tree",
|
||||
patternKeys = null,
|
||||
sessionId = "event-session",
|
||||
requestId = "req-123",
|
||||
)
|
||||
|
||||
delegate.handleApprovalRequest(event)
|
||||
advanceUntilIdle()
|
||||
|
||||
val lastMsg = uiState.value.messages.last()
|
||||
assertEquals(MessageRole.SYSTEM, lastMsg.role)
|
||||
assertEquals("**Approval Required**\ninspect working tree", lastMsg.content)
|
||||
assertEquals("req-123", lastMsg.approvalInfo?.requestId)
|
||||
assertFalse(uiState.value.isAgentTyping)
|
||||
|
||||
assertEquals(listOf(WsMethods.APPROVAL_RECEIVED), sentMethods)
|
||||
assertEquals("runtime-session-xyz", sentParams.first()["session_id"])
|
||||
assertEquals("req-123", sentParams.first()["request_id"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun respondToApproval_clearsButtonsImmediatelyAndSendsChoiceOnce() =
|
||||
testScope.runTest {
|
||||
runtimeId = "runtime-session-xyz"
|
||||
delegate.handleApprovalRequest(
|
||||
WsEvent.ApprovalRequest(
|
||||
command = "ls",
|
||||
description = null,
|
||||
patternKeys = null,
|
||||
sessionId = null,
|
||||
requestId = "req-999",
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
sentMethods.clear()
|
||||
sentParams.clear()
|
||||
|
||||
delegate.respondToApproval("approve")
|
||||
val messageBeforeIdle = uiState.value.messages.last()
|
||||
assertNull(messageBeforeIdle.approvalInfo)
|
||||
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf(WsMethods.APPROVAL_RESPOND, WsMethods.APPROVAL_PENDING), sentMethods)
|
||||
assertEquals("once", sentParams[0]["choice"])
|
||||
assertEquals("req-999", sentParams[0]["request_id"])
|
||||
assertEquals(false, sentParams[0]["all"])
|
||||
assertEquals("runtime-session-xyz", sentParams[0]["session_id"])
|
||||
assertEquals("runtime-session-xyz", sentParams[1]["session_id"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun maybeSurfacePendingApproval_dedupesExistingOnScreenRequestId() {
|
||||
delegate.handleApprovalRequest(
|
||||
WsEvent.ApprovalRequest(
|
||||
command = "ls",
|
||||
description = null,
|
||||
patternKeys = null,
|
||||
sessionId = null,
|
||||
requestId = "dup-1",
|
||||
),
|
||||
)
|
||||
val initialCount = uiState.value.messages.size
|
||||
|
||||
delegate.maybeSurfacePendingApproval(
|
||||
mapOf("command" to "ls", "request_id" to "dup-1"),
|
||||
sessionId = "s1",
|
||||
)
|
||||
assertEquals(initialCount, uiState.value.messages.size)
|
||||
|
||||
delegate.maybeSurfacePendingApproval(
|
||||
mapOf("command" to "pwd", "request_id" to "new-2"),
|
||||
sessionId = "s1",
|
||||
)
|
||||
assertEquals(initialCount + 1, uiState.value.messages.size)
|
||||
assertEquals(
|
||||
"new-2",
|
||||
uiState.value.messages
|
||||
.last()
|
||||
.approvalInfo
|
||||
?.requestId,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleApprovalPendingResult_dropsMissingRequestId_andDedupesAlreadyShown() {
|
||||
val initialCount = uiState.value.messages.size
|
||||
|
||||
// No request_id -> dropped entirely
|
||||
delegate.handleApprovalPendingResult(
|
||||
mapOf("approvals" to listOf(mapOf("command" to "rm -rf /"))),
|
||||
)
|
||||
assertEquals(initialCount, uiState.value.messages.size)
|
||||
|
||||
// Valid request_id -> surfaced
|
||||
delegate.handleApprovalPendingResult(
|
||||
mapOf("approvals" to listOf(mapOf("command" to "git pull", "request_id" to "r-valid"))),
|
||||
)
|
||||
assertEquals(initialCount + 1, uiState.value.messages.size)
|
||||
assertEquals(
|
||||
"r-valid",
|
||||
uiState.value.messages
|
||||
.last()
|
||||
.approvalInfo
|
||||
?.requestId,
|
||||
)
|
||||
|
||||
// Duplicate request_id -> ignored
|
||||
delegate.handleApprovalPendingResult(
|
||||
mapOf("approvals" to listOf(mapOf("command" to "git pull", "request_id" to "r-valid"))),
|
||||
)
|
||||
assertEquals(initialCount + 1, uiState.value.messages.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleApprovalRespondResult_appendsDesktopParitySystemMessage() {
|
||||
delegate.handleApprovalRespondResult(mapOf("resolved" to 1))
|
||||
assertEquals(listOf("Approval submitted"), systemMessages)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ChatCredentialPromptsDelegateTest {
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
private val uiState = MutableStateFlow(ChatUiState(currentSessionId = "sess-1"))
|
||||
private val sentMethods = mutableListOf<String>()
|
||||
private val sentParams = mutableListOf<Map<String, Any>>()
|
||||
private val trackedRequests = mutableListOf<Pair<String, String>>()
|
||||
|
||||
private val delegate =
|
||||
ChatCredentialPromptsDelegate(
|
||||
scope = testScope,
|
||||
ioDispatcher = testDispatcher,
|
||||
uiState = uiState,
|
||||
wsSend = { method, params, onSent ->
|
||||
sentMethods.add(method)
|
||||
sentParams.add(params)
|
||||
onSent?.invoke("req-${sentMethods.size}")
|
||||
},
|
||||
trackRequest = { id, method -> trackedRequests.add(id to method) },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun handleSudoRequest_andRespond_clearsPromptAndSendsRpc() =
|
||||
testScope.runTest {
|
||||
delegate.handleSudoRequest(WsEvent.SudoRequest(requestId = "r-sudo", sessionId = "s-1"))
|
||||
assertEquals("r-sudo", uiState.value.sudoPrompt?.requestId)
|
||||
assertFalse(uiState.value.isAgentTyping)
|
||||
|
||||
delegate.respondToSudo("secret-pass")
|
||||
assertNull(uiState.value.sudoPrompt)
|
||||
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf(WsMethods.SUDO_RESPOND), sentMethods)
|
||||
assertEquals("secret-pass", sentParams.first()["password"])
|
||||
assertEquals("r-sudo", sentParams.first()["request_id"])
|
||||
assertEquals("s-1", sentParams.first()["session_id"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleSecretRequest_andDismiss_sendsEmptyValue() =
|
||||
testScope.runTest {
|
||||
delegate.handleSecretRequest(
|
||||
WsEvent.SecretRequest(
|
||||
requestId = "r-sec",
|
||||
sessionId = "s-1",
|
||||
envVar = "OPENAI_API_KEY",
|
||||
prompt = "Enter key",
|
||||
),
|
||||
)
|
||||
assertEquals("OPENAI_API_KEY", uiState.value.secretPrompt?.envVar)
|
||||
|
||||
delegate.dismissSecret()
|
||||
assertNull(uiState.value.secretPrompt)
|
||||
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf(WsMethods.SECRET_RESPOND), sentMethods)
|
||||
assertEquals("", sentParams.first()["value"])
|
||||
assertEquals("r-sec", sentParams.first()["request_id"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expireHandlers_ignoreMismatchedRequestId() {
|
||||
delegate.handleSudoRequest(WsEvent.SudoRequest(requestId = "keep-me", sessionId = "s-1"))
|
||||
delegate.handleSudoExpire(WsEvent.SudoExpire(requestId = "other-req", sessionId = "s-1"))
|
||||
assertNotNull(uiState.value.sudoPrompt)
|
||||
|
||||
delegate.handleSudoExpire(WsEvent.SudoExpire(requestId = "keep-me", sessionId = "s-1"))
|
||||
assertNull(uiState.value.sudoPrompt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.model.ModelCapabilities
|
||||
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
||||
import com.m57.hermescontrol.data.model.ModelProvider
|
||||
import com.m57.hermescontrol.data.model.PinnedModel
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ChatModelSwitchDelegateTest {
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
private val uiState =
|
||||
MutableStateFlow(ChatUiState(currentSessionId = "sess-1", currentSessionModel = "anthropic/claude-3"))
|
||||
private var runtimeId: String? = "runtime-1"
|
||||
private val sentMethods = mutableListOf<String>()
|
||||
private val sentParams = mutableListOf<Map<String, Any>>()
|
||||
private val slashCommands = mutableListOf<String>()
|
||||
private val assistantMessages = mutableListOf<String>()
|
||||
private var contextRefetched = 0
|
||||
private var pinnedList = mutableListOf(PinnedModel("anthropic", "claude-3"))
|
||||
|
||||
private val fakeResponse =
|
||||
ModelOptionsResponse(
|
||||
providers =
|
||||
listOf(
|
||||
ModelProvider(
|
||||
slug = "openai",
|
||||
name = "OpenAI",
|
||||
models = listOf("gpt-4o"),
|
||||
capabilities = mapOf("gpt-4o" to ModelCapabilities(reasoning = true)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private val delegate =
|
||||
ChatModelSwitchDelegate(
|
||||
scope = testScope,
|
||||
ioDispatcher = testDispatcher,
|
||||
uiState = uiState,
|
||||
runtimeSessionId = { runtimeId },
|
||||
wsSend = { method, params, onSent ->
|
||||
sentMethods.add(method)
|
||||
sentParams.add(params)
|
||||
onSent?.invoke("req-${sentMethods.size}")
|
||||
},
|
||||
trackRequest = { _, _ -> },
|
||||
addAssistantMessage = { assistantMessages.add(it) },
|
||||
handleSlashCommand = { slashCommands.add(it) },
|
||||
fetchContextUsage = { contextRefetched++ },
|
||||
getModelOptionsCall = { NetworkResult.Success(fakeResponse) },
|
||||
getPinnedModels = { pinnedList },
|
||||
savePinnedModels = { pinnedList = it.toMutableList() },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun isModelPickerCommand_identifiesBareModelCommandOnly() {
|
||||
assertTrue(delegate.isModelPickerCommand("/model"))
|
||||
assertTrue(delegate.isModelPickerCommand("/MODEL "))
|
||||
assertTrue(delegate.isModelPickerCommand(" /model "))
|
||||
assertFalse(delegate.isModelPickerCommand("/model openai/gpt-4o"))
|
||||
assertFalse(delegate.isModelPickerCommand("hello /model"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openModelPicker_andPreload_populatesProvidersAndPinned() =
|
||||
testScope.runTest {
|
||||
delegate.preloadModelOptions()
|
||||
advanceUntilIdle()
|
||||
|
||||
delegate.openModelPicker()
|
||||
assertTrue(uiState.value.showModelPicker)
|
||||
assertEquals(1, uiState.value.modelPickerProviders.size)
|
||||
assertFalse(uiState.value.modelPickerLoading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendSlashModel_optimisticallyUpdatesModelAndDispatchesSlash() {
|
||||
delegate.sendSlashModel("openai", "gpt-4o")
|
||||
assertEquals("openai/gpt-4o", uiState.value.currentSessionModel)
|
||||
assertFalse(uiState.value.showModelPicker)
|
||||
assertEquals(listOf("/model gpt-4o --provider openai --session"), slashCommands)
|
||||
assertEquals(1, contextRefetched)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleModelSwitch_stripsLeadingSlashModel_andHandlesConfirmation() =
|
||||
testScope.runTest {
|
||||
delegate.handleModelSwitch("/MODEL gpt-4o --provider openai --session")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf(WsMethods.CONFIG_SET), sentMethods)
|
||||
assertEquals("gpt-4o --provider openai --session", sentParams.first()["value"])
|
||||
|
||||
delegate.handleConfigSetResult(
|
||||
id = "req-1",
|
||||
result =
|
||||
mapOf(
|
||||
"key" to "model",
|
||||
"confirm_required" to true,
|
||||
"confirm_message" to "High token cost ahead",
|
||||
),
|
||||
)
|
||||
assertEquals("High token cost ahead", uiState.value.modelSwitchConfirmMessage)
|
||||
|
||||
delegate.confirmModelSwitchExpensive()
|
||||
advanceUntilIdle()
|
||||
assertEquals(2, sentMethods.size)
|
||||
assertEquals(true, sentParams[1]["confirm_expensive_model"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dismissModelSwitchConfirm_revertsToPreviousModel() =
|
||||
testScope.runTest {
|
||||
delegate.sendSlashModel("opencode-free", "muse-spark-1.3-contributor-free")
|
||||
advanceUntilIdle()
|
||||
// In real ChatViewModel, handleSlashCommand routes to handleModelSwitch:
|
||||
delegate.handleModelSwitch("/model muse-spark-1.3-contributor-free --provider opencode-free --session")
|
||||
advanceUntilIdle()
|
||||
delegate.handleConfigSetResult(
|
||||
id = "req-1",
|
||||
result = mapOf("key" to "model", "confirm_required" to true),
|
||||
)
|
||||
|
||||
delegate.dismissModelSwitchConfirm()
|
||||
assertNull(uiState.value.modelSwitchConfirmMessage)
|
||||
assertEquals("anthropic/claude-3", uiState.value.currentSessionModel)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user