diff --git a/src/main/claude/claude-structured-option-confirmation.test.ts b/src/main/claude/claude-structured-option-confirmation.test.ts index b59dc1a26f8..60d015de5b1 100644 --- a/src/main/claude/claude-structured-option-confirmation.test.ts +++ b/src/main/claude/claude-structured-option-confirmation.test.ts @@ -6,6 +6,7 @@ import { } from '../../shared/structured-agent-session-options' import { CLAUDE_SESSION_OPTION_CATALOG } from '../../shared/agent-session-option-catalog-claude-codex' import type { AgentSessionOptionsResult } from '../../shared/agent-session-wire' +import type { SessionOptionDescriptor } from '../../shared/native-chat-session-options' import { setClaudeStructuredOption } from './claude-structured-options' import type { ClaudeSession } from './claude-structured-session-state' import { PROVIDER_SESSION_ID, acquired, fakeClaude } from './claude-structured-session-test-support' @@ -26,19 +27,28 @@ function initFrame(model: string): Record { } } -/** Source the pill reads: 'dispatched' is the one that renders the hedge. */ -function modelSource(result: AgentSessionOptionsResult): string | undefined { +function modelPill(result: AgentSessionOptionsResult): SessionOptionDescriptor | undefined { const state = applyStructuredAgentSessionOptions( createStructuredAgentSessionOptionState('claude'), CLAUDE_SESSION_OPTION_CATALOG, result ) return structuredAgentSessionOptionSnapshot(state).find((d) => d.category === 'model') - ?.valueSource +} + +/** Provenance the record keeps. Nothing renders it — the pill shows the value + * either way, and a report that disagrees is what corrects it. */ +function modelSource(result: AgentSessionOptionsResult): string | undefined { + return modelPill(result)?.valueSource +} + +function modelValue(result: AgentSessionOptionsResult): string | undefined { + const kind = modelPill(result)?.kind + return kind?.type === 'select' ? kind.currentValue : undefined } describe('structured option confirmation reaches the pill', () => { - it('leaves a just-set model hedged until a turn reports it', async () => { + it('shows a just-set model before any turn reports it', async () => { const claude = fakeClaude({ initModel: 'claude-sonnet-5', routes: { list_models: () => CATALOG } @@ -51,7 +61,7 @@ describe('structured option confirmation reaches the pill', () => { expect(modelSource(result)).toBe('dispatched') }) - it('clears the hedge once the provider reports the model back', async () => { + it('marks the model reported once the provider names it back', async () => { const claude = fakeClaude({ initModel: 'claude-sonnet-5', routes: { list_models: () => CATALOG } @@ -65,7 +75,7 @@ describe('structured option confirmation reaches the pill', () => { expect(modelSource(result)).toBe('reported') }) - it('keeps an effort the readback could not take hedged', async () => { + it('records an effort the readback could not take without confirming it', async () => { const claude = fakeClaude({ initModel: 'claude-sonnet-5', settings: { applied: {}, effective: {}, sources: {} }, @@ -95,14 +105,48 @@ describe('structured option confirmation reaches the pill', () => { }) it('treats a host that reports no confirmation as unconfirmed', () => { - // Wire compatibility: an older host omits `confirmed` entirely; the client must - // keep hedging rather than read the absence as a provider report. - expect( - modelSource({ - models: [{ id: 'haiku', label: 'Haiku', isDefault: false, efforts: [] }], - current: { model: 'haiku' } - }) - ).toBe('dispatched') + // Wire compatibility: an older host omits `confirmed` entirely. Absence must + // read as unconfirmed provenance, and the pill still shows the host's value. + const result = { + models: [{ id: 'haiku', label: 'Haiku', isDefault: false, efforts: [] }], + current: { model: 'haiku' } + } + expect(modelSource(result)).toBe('dispatched') + expect(modelValue(result)).toBe('haiku') + }) +}) + +describe('the provider report corrects the pill', () => { + it('moves the pill to the model the turn actually ran', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) + expect(modelValue(await adapter.readOptions({ sessionId: 'session-1', fence: 7 }))).toBe( + 'haiku' + ) + + claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5')) + + const corrected = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(modelValue(corrected)).toBe('sonnet') + expect(corrected.current.confirmed).toContain('model') + }) + + it('lets a newer write outrank the report it precedes', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5')) + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) + + const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(modelValue(result)).toBe('haiku') + expect(result.current.confirmed ?? []).not.toContain('model') }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx index d53e8f536d2..d5e2bf811ad 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -342,7 +342,9 @@ describe('NativeChatSessionOptionPickers', () => { expect(screen.queryByRole('button', { name: /^Effort/ })).toBeNull() }) - it('shows the unconfirmed hint for dispatched values', () => { + // The pill shows the picked value straight away; a provider report is what + // corrects it, so an unsent-yet-unreported value gets no separate hedge line. + it('tooltips a dispatched value with the category alone', () => { render( { isWorking={false} /> ) - expect(screen.getByText('Sent to the agent — not confirmed')).not.toBeNull() + expect(screen.getByText('Model')).not.toBeNull() + expect(screen.queryByText(/not confirmed/)).toBeNull() }) it('renders agent-picker routes as one action instead of radio choices', async () => { @@ -463,26 +466,7 @@ describe('NativeChatSessionOptionPickers', () => { await waitFor(() => expect(setOption).toHaveBeenCalledWith('thinking', false)) }) - it('does not show unconfirmed for applied flip-only booleans', () => { - render( - - ) - expect(screen.queryByText('Sent to the agent — not confirmed')).toBeNull() - }) - - it('shows unconfirmed for confirmable dispatched booleans', () => { + it('tooltips a dispatched option pill with the category alone', () => { render( { isWorking={false} /> ) - expect(screen.getByText('Sent to the agent — not confirmed')).not.toBeNull() + expect(screen.getAllByText('Thinking').length).toBeGreaterThan(0) + expect(screen.queryByText(/not confirmed/)).toBeNull() }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx index 87a860662d2..c73a3ac7f93 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx @@ -37,32 +37,11 @@ export type NativeChatSessionOptionPickersProps = { pickerRequest?: NativeChatOptionPickerRequest | null } -function PickerTooltipContent(props: { - label: string - disabledReason?: string | null - dispatched: boolean -}): React.JSX.Element { - return ( -
-
{props.disabledReason ?? props.label}
- {props.dispatched ? ( -
- {translate( - 'components.native-chat.composer.sentNotConfirmed', - 'Sent to the agent — not confirmed' - )} -
- ) : null} -
- ) -} - function PickerTrigger(props: { label: string tooltipLabel: string disabled: boolean disabledReason?: string | null - dispatched: boolean }): React.JSX.Element { // Why: value-only visible text must still include the category in the // accessible name (WCAG 2.5.3 Label in Name / voice control). @@ -90,11 +69,7 @@ function PickerTrigger(props: { - + {props.disabledReason ?? props.tooltipLabel} ) @@ -250,7 +225,6 @@ function NativeChatSessionOptionPickersInner({ tooltipLabel={optionsTooltip} disabled={isWorking || pendingId !== null} disabledReason={optionsReason} - dispatched={options.some((descriptor) => descriptor.valueSource === 'dispatched')} /> {options.map((descriptor, index) => { @@ -283,7 +257,6 @@ function NativeChatSessionOptionPickersInner({ tooltipLabel={modelTooltip} disabled={isWorking || pendingId !== null} disabledReason={modelReason} - dispatched={model.valueSource === 'dispatched'} /> {modelReason && !model.settable ? ( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f27ea5cfb44..470f9499255 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16905,7 +16905,6 @@ "toggleOption": "Toggle {{value0}}", "pillAccessibleName": "{{value0}} {{value1}}", "valueUnknown": "Current value unknown — pick On or Off", - "sentNotConfirmed": "Sent to the agent — not confirmed", "setWhenSessionStarts": "Set when the session starts.", "availableAfterSessionStarts": "Available after the session starts.", "optionUpdateFailed": "Could not update option", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9999634daee..eec272e08d0 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14589,7 +14589,6 @@ "toggleOption": "Alternar {{value0}}", "pillAccessibleName": "{{value0}} {{value1}}", "valueUnknown": "Valor actual desconocido — elige Activado o Desactivado", - "sentNotConfirmed": "Enviado al agente — sin confirmar", "setWhenSessionStarts": "Configúralo al iniciar la sesión.", "availableAfterSessionStarts": "Disponible después de iniciar la sesión.", "optionUpdateFailed": "No se pudo actualizar la opción", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 8eff250a820..111dc0e5977 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -16021,7 +16021,6 @@ "toggleOption": "Basculer {{value0}}", "pillAccessibleName": "{{value0}} {{value1}}", "valueUnknown": "Valeur actuelle inconnue — choisissez Activé ou Désactivé", - "sentNotConfirmed": "Envoyé à l'agent — non confirmé", "setWhenSessionStarts": "Défini au démarrage de la session.", "availableAfterSessionStarts": "Disponible après le démarrage de la session.", "optionUpdateFailed": "Impossible de mettre à jour l'option", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b3c30da9446..f6223f291d4 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -14589,7 +14589,6 @@ "toggleOption": "{{value0}}を切り替える", "pillAccessibleName": "{{value0}} {{value1}}", "valueUnknown": "現在の値が不明です — オンまたはオフを選んでください", - "sentNotConfirmed": "Agent に送信済み — 未確認", "setWhenSessionStarts": "セッション開始時に設定してください。", "availableAfterSessionStarts": "セッション開始後に利用できます。", "optionUpdateFailed": "オプションを更新できませんでした", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ac75cca2209..128da7e0acf 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -14702,7 +14702,6 @@ "toggleOption": "{{value0}} 전환", "pillAccessibleName": "{{value0}} {{value1}}", "valueUnknown": "현재 값을 알 수 없음 — 켜짐 또는 꺼짐을 선택하세요", - "sentNotConfirmed": "에이전트에 전송됨 — 확인되지 않음", "setWhenSessionStarts": "세션을 시작할 때 설정하세요.", "availableAfterSessionStarts": "세션이 시작된 후 사용할 수 있습니다.", "optionUpdateFailed": "옵션을 업데이트하지 못했습니다", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7a3b47c8f2a..3e8dea3e809 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -14645,7 +14645,6 @@ "toggleOption": "切换{{value0}}", "pillAccessibleName": "{{value0}} {{value1}}", "valueUnknown": "当前值未知 — 请选择开或关", - "sentNotConfirmed": "已发送给智能体 — 尚未确认", "setWhenSessionStarts": "请在会话开始时设置。", "availableAfterSessionStarts": "会话开始后可用。", "optionUpdateFailed": "无法更新选项", diff --git a/src/renderer/src/i18n/native-chat-locales.test.ts b/src/renderer/src/i18n/native-chat-locales.test.ts index 51deb11be28..6cc98a4bce7 100644 --- a/src/renderer/src/i18n/native-chat-locales.test.ts +++ b/src/renderer/src/i18n/native-chat-locales.test.ts @@ -53,8 +53,7 @@ describe('native chat locale copy', () => { 'sessionOptions', 'chooseInAgentPicker', 'toggleOption', - 'valueUnknown', - 'sentNotConfirmed' + 'valueUnknown' ] as const) { expect(composer[key].trim()).not.toBe('') expect(composer[key]).not.toBe(englishComposer[key]) diff --git a/src/shared/native-chat-session-options.ts b/src/shared/native-chat-session-options.ts index 84b23dcba06..35f7245d93c 100644 --- a/src/shared/native-chat-session-options.ts +++ b/src/shared/native-chat-session-options.ts @@ -7,7 +7,9 @@ export type SessionOptionSelectChoice = { } /** `default` is the catalog's own value shown before anything is observed — - * truthful to display, but never evidence about a running agent. */ + * truthful to display, but never evidence about a running agent. `dispatched` + * is provenance only: the pill shows a sent value the same as a reported one, + * and a later report that disagrees is what corrects it. */ export type SessionOptionValueSource = 'applied' | 'dispatched' | 'reported' | 'default' | 'unknown' /** Closed set of reasons an option is not settable in the current mode. A key