fix(claude): verify model switches when terminal echoes omit vendor prefix (#40064)

Claude echoes "Set model to Opus 5.5" without the "Claude" vendor prefix,
and never echoes the picker-only " alias" suffix or a raw "claude-" id
prefix. The confirmation observer required every picker-label token, so a
successful switch resolved to unknown and showed "Could not verify the
model change". Normalize the expected label before matching; context
tokens such as 1M are still required.

Fixes #40058

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Calvin
2026-11-09 03:36:51 -08:00
committed by GitHub
co-authored by Neil
parent 00374cc0c2
commit 2dc0df462b
2 changed files with 36 additions and 2 deletions
@@ -4,6 +4,38 @@ import { describe, expect, it, vi } from 'vitest'
import { createClaudeModelSwitchConfirmationObserver } from './claude-model-switch-confirmation'
describe('Claude model switch confirmation detection', () => {
it.each([
[
'quoted model name',
'Claude Opus 5.5',
'Set model to `Opus 5.5` and saved as your default for new sessions'
],
[
'matching requested context variant',
'Claude Opus 5.5 (1M context)',
'Set model to `Opus 5.5 (1M context)` and saved as your default for new sessions'
],
['raw model id fallback', 'claude-opus-5-5', 'Set model to Opus 5.5'],
['alias picker row', 'Opus alias', 'Set model to Opus 5.5']
])('confirms a picker label with %s', async (_name, label, output) => {
const dataObserver = { current: (_data: string): void => {} }
const observer = createClaudeModelSwitchConfirmationObserver({
ptyId: 'pty-1',
expectedModelLabel: label,
subscribeToData: (watcher) => {
dataObserver.current = watcher
return () => {}
}
})
await observer.ready
observer.arm()
observer.startDetection()
dataObserver.current(output)
await expect(observer.result).resolves.toBe('applied')
})
it('reports matching success output split across PTY chunks', async () => {
const dataObserver = { current: (_data: string): void => {} }
const unsubscribe = vi.fn(() => {})
@@ -33,12 +33,14 @@ function compactTerminalText(buffer: string): string {
function hasClaudeModelSwitchSuccess(buffer: string, modelLabel: string): boolean {
const text = compactTerminalText(buffer)
const marker = `setmodelto${modelLabel.replace(/\s+/g, '').toLowerCase()}`
// Claude echoes neither the vendor prefix nor Orca's picker-only `alias` suffix.
const label = modelLabel.replace(/^claude[\s-]+/i, '').replace(/\s+alias$/i, '')
const marker = `setmodelto${label.replace(/\s+/g, '').toLowerCase()}`
if (text.includes(marker)) {
return true
}
// Match every picker token so a versioned echo cannot confirm the wrong context variant.
const labelTokens = modelLabel.toLowerCase().match(/[a-z]+|\d+[a-z]*/g) ?? []
const labelTokens = label.toLowerCase().match(/[a-z]+|\d+[a-z]*/g) ?? []
const successStart = text.lastIndexOf('setmodelto')
if (successStart === -1 || labelTokens.length === 0) {
return false