fix(desktop): confirm guarded Settings model applies

Settings → Model → Apply treated confirm_required as a red error, so
contributor-tier models like muse-spark-1.2-contributor could never be
saved. Prompt and retry with confirm_expensive_model, matching the
in-session picker handshake.

Co-authored-by: Silvio S. <silviomanuel297@gmail.com>
This commit is contained in:
Brooklyn Nicholson
2026-08-26 13:43:35 -05:00
committed by brooklyn!
parent 15b673d178
commit 8fe4816edd
9 changed files with 194 additions and 17 deletions
+5 -1
View File
@@ -1502,7 +1502,11 @@ export const ar = defineLocale({
message: count => `سيتم تخطي ${count} من المهام المجدولة حتى تراجع إعدادات النموذج الخاصة بها.`,
detailMore: (names, remaining) => `${names} و${remaining} أخرى`,
review: 'مراجعة المهام المجدولة',
saveFailed: 'لم يحفظ Hermes تغيير النموذج هذا.'
saveFailed: 'لم يحفظ Hermes تغيير النموذج هذا.',
confirmTitle: 'تحذير اختيار النموذج',
confirmDetail: 'أكّد فقط إذا كنت تقبل هذه المقايضة.',
confirmAction: 'تأكيد',
declined: 'أُلغي تغيير النموذج — رفضت تحذير طبقة تدريب البيانات.'
},
search: 'بحث',
loading: 'جار التحميل...',
+5 -1
View File
@@ -1944,7 +1944,11 @@ export const en: Translations = {
`${count} scheduled ${count === 1 ? 'job' : 'jobs'} will be skipped until you review their model settings.`,
detailMore: (names, remaining) => `${names} and ${remaining} more`,
review: 'Review scheduled jobs',
saveFailed: 'Hermes did not save that model change.'
saveFailed: 'Hermes did not save that model change.',
confirmTitle: 'Model Selection Warning',
confirmDetail: 'Confirm only if you accept this trade-off.',
confirmAction: 'Confirm',
declined: 'Model change cancelled — you declined the data-training tier warning.'
},
search: 'Search cron jobs...',
loading: 'Loading cron jobs...',
+5 -1
View File
@@ -1646,7 +1646,11 @@ export const ja = defineLocale({
message: count => `モデル設定を確認するまで、${count} 件のスケジュール済みジョブがスキップされます。`,
detailMore: (names, remaining) => `${names}、ほか ${remaining} 件`,
review: 'スケジュール済みジョブを確認',
saveFailed: 'Hermes はモデルの変更を保存しませんでした。'
saveFailed: 'Hermes はモデルの変更を保存しませんでした。',
confirmTitle: 'モデル選択の警告',
confirmDetail: 'このトレードオフを受け入れる場合のみ確認してください。',
confirmAction: '確認',
declined: 'モデル変更をキャンセルしました — データ学習ティアの警告を拒否しました。'
},
search: 'Cron ジョブを検索...',
loading: 'Cron ジョブを読み込み中...',
+4
View File
@@ -1657,6 +1657,10 @@ export interface Translations {
detailMore: (names: string, remaining: number) => string
review: string
saveFailed: string
confirmTitle: string
confirmDetail: string
confirmAction: string
declined: string
}
search: string
loading: string
+5 -1
View File
@@ -1585,7 +1585,11 @@ export const zhHant = defineLocale({
message: count => `在您檢查模型設定之前,${count} 個排程工作將被略過。`,
detailMore: (names, remaining) => `${names},以及另外 ${remaining} 個`,
review: '檢查排程工作',
saveFailed: 'Hermes 未儲存該模型變更。'
saveFailed: 'Hermes 未儲存該模型變更。',
confirmTitle: '模型選擇警告',
confirmDetail: '僅在你接受此權衡時確認。',
confirmAction: '確認',
declined: '已取消模型變更 — 你拒絕了資料訓練層級警告。'
},
search: '搜尋排程工作…',
loading: '正在載入排程工作…',
+5 -1
View File
@@ -2126,7 +2126,11 @@ export const zh: Translations = {
message: count => `在您检查模型设置之前,${count} 个定时任务将被跳过。`,
detailMore: (names, remaining) => `${names},以及另外 ${remaining} 个`,
review: '检查定时任务',
saveFailed: 'Hermes 未保存该模型更改。'
saveFailed: 'Hermes 未保存该模型更改。',
confirmTitle: '模型选择警告',
confirmDetail: '仅在你接受此权衡时确认。',
confirmAction: '确认',
declined: '已取消模型更改 — 你拒绝了数据训练层级警告。'
},
search: '搜索定时任务…',
loading: '正在加载定时任务…',
@@ -20,6 +20,16 @@ import {
import { deferred } from '../test/deferred'
async function waitForConfirmToast() {
return vi.waitFor(() => {
const toast = $notifications.get().find(item => item.id.startsWith('model-warning-confirm-'))
expect(toast).toBeDefined()
return toast!
})
}
function response(impact: ModelAssignmentResponse['cron_model_impact']): ModelAssignmentResponse {
return {
ok: true,
@@ -114,7 +124,43 @@ describe('setMainModelAssignment', () => {
expect($notifications.get()).toEqual([])
})
it('rejects non-persisted confirmation outcomes without changing impact state', async () => {
it('prompts and retries with confirm_expensive_model when the user accepts', async () => {
setModelAssignment.mockResolvedValueOnce(response(positive()))
await setMainModelAssignment({ provider: 'nous', model: 'one' })
const confirmResponse = {
ok: false,
scope: 'main',
provider: 'openrouter',
model: 'openai/gpt-5.5-pro',
confirm_required: true,
confirm_message: 'Confirm this expensive model.'
} satisfies ModelAssignmentResponse
setModelAssignment.mockResolvedValueOnce(confirmResponse)
setModelAssignment.mockResolvedValueOnce(response(positive('Confirmed job')))
const pending = setMainModelAssignment({ provider: 'openrouter', model: 'openai/gpt-5.5-pro' })
const confirm = await waitForConfirmToast()
expect(confirm.kind).toBe('warning')
expect(confirm.message).toBe('Confirm this expensive model.')
expect(confirm.action?.label).toBe('Confirm')
confirm.action?.onClick()
await pending
expect(setModelAssignment).toHaveBeenLastCalledWith(
expect.objectContaining({
provider: 'openrouter',
model: 'openai/gpt-5.5-pro',
confirm_expensive_model: true
})
)
expect($notifications.get().some(item => item.detail?.includes('Confirmed job'))).toBe(true)
})
it('declines without retrying when the user dismisses the guard prompt', async () => {
setModelAssignment.mockResolvedValueOnce(response(positive()))
await setMainModelAssignment({ provider: 'nous', model: 'one' })
@@ -127,16 +173,61 @@ describe('setMainModelAssignment', () => {
confirm_message: 'Confirm this expensive model.'
} satisfies ModelAssignmentResponse)
await expect(setMainModelAssignment({ provider: 'openrouter', model: 'openai/gpt-5.5-pro' })).rejects.toThrow(
'Confirm this expensive model.'
)
expect($notifications.get()).toHaveLength(1)
const action = $notifications.get()[0].action
const pending = setMainModelAssignment({ provider: 'openrouter', model: 'openai/gpt-5.5-pro' })
const confirm = await waitForConfirmToast()
dismissNotification(confirm.id)
await expect(pending).rejects.toThrow('Model change cancelled')
expect(setModelAssignment.mock.calls).toHaveLength(2)
const impact = $notifications.get().find(item => item.id === CRON_MODEL_IMPACT_NOTIFICATION_ID)
const reviewCount = $cronReviewRequest.get()
action?.onClick()
impact?.action?.onClick()
expect($cronReviewRequest.get()).toBe(reviewCount + 1)
})
it('fails closed without a prompt when skipConfirmPrompt is set', async () => {
setModelAssignment.mockResolvedValueOnce({
ok: false,
scope: 'main',
provider: 'openrouter',
model: 'openai/gpt-5.5-pro',
confirm_required: true,
confirm_message: 'Confirm this expensive model.'
} satisfies ModelAssignmentResponse)
await expect(
setMainModelAssignment({ provider: 'openrouter', model: 'openai/gpt-5.5-pro' }, undefined, {
skipConfirmPrompt: true
})
).rejects.toThrow('Confirm this expensive model.')
expect(setModelAssignment).toHaveBeenCalledTimes(1)
expect($notifications.get()).toEqual([])
})
it('does not recurse when the backend still demands confirm after ack', async () => {
const confirmResponse = {
ok: false,
scope: 'main',
provider: 'openrouter',
model: 'openai/gpt-5.5-pro',
confirm_required: true,
confirm_message: 'Confirm this expensive model.'
} satisfies ModelAssignmentResponse
setModelAssignment.mockResolvedValueOnce(confirmResponse)
setModelAssignment.mockResolvedValueOnce(confirmResponse)
const pending = setMainModelAssignment({ provider: 'openrouter', model: 'openai/gpt-5.5-pro' })
const confirm = await waitForConfirmToast()
confirm.action?.onClick()
await expect(pending).rejects.toThrow('Confirm this expensive model.')
expect(setModelAssignment).toHaveBeenCalledTimes(2)
})
it('publishes only the latest same-profile assignment when responses reverse', async () => {
const first = deferred<ModelAssignmentResponse>()
const second = deferred<ModelAssignmentResponse>()
+65 -5
View File
@@ -146,19 +146,44 @@ function publishImpact(impact: CronModelImpact, profile: string, connection: str
export async function setMainModelAssignment(
request: Omit<ModelAssignmentRequest, 'scope'>,
scopeProfile?: null | string
scopeProfile?: null | string,
options?: { skipConfirmPrompt?: boolean }
): Promise<ModelAssignmentResponse> {
const { connection, generation } = beginCronModelImpactAssignment()
const profile = profileIdentity()
// Only pass the extra arg when a scope override exists, so unscoped callers
// keep the exact legacy call shape.
const result =
const assign = (body: Omit<ModelAssignmentRequest, 'scope'>) =>
scopeProfile == null
? await setModelAssignment({ ...request, scope: 'main' })
: await setModelAssignment({ ...request, scope: 'main' }, scopeProfile)
? setModelAssignment({ ...body, scope: 'main' })
: setModelAssignment({ ...body, scope: 'main' }, scopeProfile)
if (result.ok !== true) {
let result = await assign(request)
// Backend demands an explicit ack before persisting a model that trips a
// selection guard (expensive / data-training tiers like *-contributor).
// Settings used to throw confirm_message as a red error, so Apply could
// never persist. Prompt, then retry with confirm_expensive_model.
if (result.confirm_required) {
if (request.confirm_expensive_model || options?.skipConfirmPrompt) {
// Already acked, or headless onboarding (nothing mounted to click).
// Fail closed instead of recursing / dangling a prompt.
throw new Error(result.confirm_message?.trim() || translateNow('cron.modelImpact.saveFailed'))
}
const accepted = await confirmModelWarning(result.confirm_message?.trim() ?? '')
if (!accepted) {
throw new Error(translateNow('cron.modelImpact.declined'))
}
result = await assign({ ...request, confirm_expensive_model: true })
if (result.confirm_required || result.ok !== true) {
throw new Error(result.confirm_message?.trim() || translateNow('cron.modelImpact.saveFailed'))
}
} else if (result.ok !== true) {
throw new Error(result.confirm_message?.trim() || translateNow('cron.modelImpact.saveFailed'))
}
@@ -199,3 +224,38 @@ export function invalidateCronModelImpactScope(options: { clearNotification?: bo
// Scope changes originating outside this module (profile/backend switches)
// clear any warning that belongs to the old runtime.
onCronModelImpactScopeInvalidated(() => dismissNotification(CRON_MODEL_IMPACT_NOTIFICATION_ID))
/**
* Selection-guard warning as a confirm toast. Resolves true on Confirm, false
* on dismiss. The desktop has no blocking confirm API; this is the same
* notify-with-action pattern the in-session model picker uses.
*/
function confirmModelWarning(message: string): Promise<boolean> {
const id = `model-warning-confirm-${Date.now()}`
return new Promise(resolve => {
let settled = false
const finish = (value: boolean) => {
if (settled) {
return
}
settled = true
dismissNotification(id)
resolve(value)
}
notify({
id,
kind: 'warning',
title: translateNow('cron.modelImpact.confirmTitle'),
message: message || translateNow('cron.modelImpact.confirmDetail'),
detail: translateNow('cron.modelImpact.confirmDetail'),
action: {
label: translateNow('cron.modelImpact.confirmAction'),
onClick: () => finish(true)
},
onDismiss: () => finish(false)
})
})
}
+2
View File
@@ -1331,6 +1331,8 @@ export interface ModelAssignmentRequest {
/** OpenAI-compatible endpoint URL. Only honored for custom/local providers
* on the main slot — wires a self-hosted endpoint into runtime resolution. */
base_url?: string
/** Ack for selection-guard warnings (expensive / data-training tiers). */
confirm_expensive_model?: boolean
model: string
provider: string
scope: 'main' | 'auxiliary'