fix(chat): support confirmation dialog when switching to expensive or data-policy models (#1050)

This commit is contained in:
M57 authored and GitHub committed 2026-09-03 16:56:08 +03:00
1 parent fb80be04b4
commit efe6fe81cb
7 files changed
+201 -4

No files matched your search

@@ -38,6 +38,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -911,6 +912,25 @@ fun ChatScreen(
)
}
// Expensive / Data-policy model confirmation dialog (issue #589 follow-up)
if (state.modelSwitchConfirmMessage != null) {
AlertDialog(
onDismissRequest = { viewModel.dismissModelSwitchConfirm() },
title = { Text(stringResource(R.string.model_expensive_title)) },
text = { Text(state.modelSwitchConfirmMessage!!) },
confirmButton = {
Button(onClick = { viewModel.confirmModelSwitchExpensive() }) {
Text(stringResource(R.string.model_confirm_continue))
}
},
dismissButton = {
TextButton(onClick = { viewModel.dismissModelSwitchConfirm() }) {
Text(stringResource(R.string.common_cancel))
}
},
)
}
if (showContextSheet && state.contextBreakdown != null) {
ContextDetailSheet(
breakdown = state.contextBreakdown!!,
@@ -327,6 +327,7 @@ data class ChatUiState(
val modelPickerProviders: List<ModelProvider> = emptyList(),
val modelPickerPinned: List<PinnedModel> = emptyList(),
val modelPickerLoading: Boolean = false,
val modelSwitchConfirmMessage: String? = null,
// Current session's active model label (provider/model), shown in the chip
val currentSessionModel: String? = null,
// Per-model reasoning capabilities for the current session's model
@@ -463,6 +464,15 @@ 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
@@ -1208,6 +1218,25 @@ class ChatViewModel(
addSystemMessage("Approval submitted")
}
}
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)
}
}
}
}
}
}
@@ -1889,7 +1918,10 @@ class ChatViewModel(
* `<model> --provider <slug> --session`
* (matching the TUI client's `modelValueForConfigSet`).
*/
private fun handleModelSwitch(command: String) {
private fun handleModelSwitch(
command: String,
confirmExpensive: Boolean = false,
) {
val sessionId = runtimeSessionId
if (sessionId == null) {
addAssistantMessage("No active session. Use `/new` to create one.")
@@ -1906,11 +1938,25 @@ class ChatViewModel(
} 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,
mapOf("key" to "model", "value" to spec, "session_id" to sessionId),
onSent = { id -> trackRequest(id, WsMethods.CONFIG_SET) },
params,
onSent = { id ->
trackRequest(id, WsMethods.CONFIG_SET)
pendingModelSwitchRequests[id] = ActiveModelSwitch(spec, previousModel)
},
)
}
}
@@ -2265,6 +2311,7 @@ class ChatViewModel(
provider: String,
model: String,
) {
optimisticPreviousModel = _uiState.value.currentSessionModel
_uiState.update {
it.copy(
showModelPicker = false,
@@ -2280,6 +2327,28 @@ class ChatViewModel(
handleSlashCommand("/model $model --provider $provider --session")
}
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)
}
}
/**
* Set the reasoning effort level for the current session.
*
@@ -2575,6 +2644,7 @@ class ChatViewModel(
showSessionPicker = false,
showModelPicker = false,
modelPickerLoading = false,
modelSwitchConfirmMessage = null,
currentSessionModel = null,
currentModelCapabilities = null,
reasoningLevel = null,
+2 -1
View File
@@ -1078,7 +1078,8 @@
<string name="moa_title">مزيج الوكلاء (Mixture of Agents)</string>
<string name="model_aux_tasks_label">المهام المساعدة</string>
<string name="model_aux_tasks_title">المهام المساعدة</string>
<string name="model_expensive_title">نموذج عالي التكلفة</string>
<string name="model_expensive_title">نموذج مرتفع التكلفة</string>
<string name="model_confirm_continue">متابعة</string>
<string name="model_main_label">النموذج الرئيسي</string>
<string name="model_moa_label">مزيج الوكلاء (Mixture of Agents)</string>
<string name="model_new_sessions">ينطبق على الجلسات الجديدة</string>
+1
View File
@@ -387,6 +387,7 @@
<string name="model_no_models">보고된 모델이 없거나 인증이 필요합니다.</string>
<string name="content_desc_active_model">활성 모델</string>
<string name="model_section_pinned">고정된 모델</string>
<string name="model_confirm_continue">계속</string>
<string name="content_desc_unpin_model">모델 고정 해제</string>
<string name="content_desc_pinned_section_toggle">고정 섹션 표시 전환</string>
+1
View File
@@ -1069,6 +1069,7 @@
<string name="moa_title">多 Agent 混合</string>
<string name="model_aux_tasks_label">辅助任务</string>
<string name="model_aux_tasks_title">辅助任务</string>
<string name="model_confirm_continue">继续</string>
<string name="model_expensive_title">高成本模型</string>
<string name="model_main_label">主模型</string>
<string name="model_moa_label">多 Agent 混合</string>
+1
View File
@@ -1198,6 +1198,7 @@
<string name="moa_title">Mixture of Agents</string>
<string name="model_aux_tasks_label">Auxiliary tasks</string>
<string name="model_aux_tasks_title">Auxiliary Tasks</string>
<string name="model_confirm_continue">Continue</string>
<string name="model_expensive_title">Expensive Model</string>
<string name="model_main_label">Main model</string>
<string name="model_moa_label">Mixture of Agents</string>
@@ -793,6 +793,109 @@ class ChatViewModelTest {
assertEquals(sessionId, call.third)
}
@Test
fun testModelSwitch_confirmRequired_triggersConfirmationDialogAndReSends() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
var lastReqId = ""
val capturedParams = mutableListOf<Map<String, Any>>()
every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers {
val params = arg<Map<String, Any>>(1)
capturedParams.add(params)
val id = "req-confirm-${capturedParams.size}"
lastReqId = id
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
// User sends model switch command that triggers privacy/expensive guard
viewModel.sendMessage("/model muse-spark-1.3-contributor-free --provider opencode-free --session")
advanceUntilIdle()
assertEquals(1, capturedParams.size)
assertNull(capturedParams[0]["confirm_expensive_model"])
// Backend returns confirm_required = true
mockEventsFlow.emit(
WsEvent.RpcResult(
id = lastReqId,
result =
mapOf(
"key" to "model",
"value" to "muse-spark-1.3-contributor-free",
"confirm_required" to true,
"confirm_message" to "Meta training warning",
),
),
)
advanceUntilIdle()
// UI state now shows confirmation dialog message
assertEquals("Meta training warning", viewModel.uiState.value.modelSwitchConfirmMessage)
// User confirms
viewModel.confirmModelSwitchExpensive()
advanceUntilIdle()
assertNull(viewModel.uiState.value.modelSwitchConfirmMessage)
assertEquals(2, capturedParams.size)
assertEquals(true, capturedParams[1]["confirm_expensive_model"])
assertEquals(
"muse-spark-1.3-contributor-free --provider opencode-free --session",
capturedParams[1]["value"],
)
assertEquals(sessionId, capturedParams[1]["session_id"])
}
@Test
fun testModelSwitch_dismissConfirmation_revertsModel() =
runTest {
val (viewModel, _) = createViewModelWithSession()
var lastReqId = ""
every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers {
val id = "req-cfg-dismiss"
lastReqId = id
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
// Set an initial model
viewModel.sendSlashModel("openai", "gpt-4o")
advanceUntilIdle()
assertEquals("openai/gpt-4o", viewModel.uiState.value.currentSessionModel)
// Switch to expensive model
viewModel.sendSlashModel("opencode-free", "muse-spark-1.3-contributor-free")
advanceUntilIdle()
assertEquals("opencode-free/muse-spark-1.3-contributor-free", viewModel.uiState.value.currentSessionModel)
// Backend requires confirmation
mockEventsFlow.emit(
WsEvent.RpcResult(
id = lastReqId,
result =
mapOf(
"key" to "model",
"value" to "muse-spark-1.3-contributor-free",
"confirm_required" to true,
"confirm_message" to "Meta training warning",
),
),
)
advanceUntilIdle()
assertEquals("Meta training warning", viewModel.uiState.value.modelSwitchConfirmMessage)
// User dismisses/cancels
viewModel.dismissModelSwitchConfirm()
advanceUntilIdle()
assertNull(viewModel.uiState.value.modelSwitchConfirmMessage)
// Reverted back to previous model
assertEquals("openai/gpt-4o", viewModel.uiState.value.currentSessionModel)
}
// ── Connection / init tests ──────────────────────────────────────────────
@Test