fix(chat): support confirmation dialog when switching to expensive or data-policy models (#1050)
This commit is contained in:
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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user