mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
Show the picked session-option value and let the provider report correct it
The pill showed a "not confirmed" second tooltip line for any value we had sent but not yet seen reported back. Nothing acts on it, and for the PTY lane it was permanent — that transport has no report channel. The pill now shows the picked value immediately and the provider's per-turn report corrects it when the two disagree; a newer local write still outranks a report that precedes it. `dispatched` stays as a provenance member rather than collapsing into `applied`: it is produced independently by the PTY lane, and it is where the `confirmed` wire field lands, which would otherwise be unobservable. Effort keeps its readback and its rejection path. That matters more now, not less: with the hedge gone the rejection is the only user-visible failure signal on this surface, so a spurious one would be the loudest bug here. Skipping the readback for an effort the settings response structurally cannot echo is what prevents it — the response carries the persisted level, so reading it back for a session-scoped value would report the level underneath and fail a valid write.
This commit is contained in:
@@ -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<string, unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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(
|
||||
<NativeChatSessionOptionPickers
|
||||
surface={surface}
|
||||
@@ -350,7 +352,8 @@ describe('NativeChatSessionOptionPickers', () => {
|
||||
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(
|
||||
<NativeChatSessionOptionPickers
|
||||
surface={surface}
|
||||
snapshot={[
|
||||
model(),
|
||||
{
|
||||
...fast,
|
||||
kind: { type: 'boolean', currentValue: true },
|
||||
// Why: flip-only tracks as applied — never a healable dispatched state.
|
||||
valueSource: 'applied'
|
||||
}
|
||||
]}
|
||||
isWorking={false}
|
||||
/>
|
||||
)
|
||||
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(
|
||||
<NativeChatSessionOptionPickers
|
||||
surface={surface}
|
||||
@@ -500,6 +484,7 @@ describe('NativeChatSessionOptionPickers', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,32 +37,11 @@ export type NativeChatSessionOptionPickersProps = {
|
||||
pickerRequest?: NativeChatOptionPickerRequest | null
|
||||
}
|
||||
|
||||
function PickerTooltipContent(props: {
|
||||
label: string
|
||||
disabledReason?: string | null
|
||||
dispatched: boolean
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div>{props.disabledReason ?? props.label}</div>
|
||||
{props.dispatched ? (
|
||||
<div>
|
||||
{translate(
|
||||
'components.native-chat.composer.sentNotConfirmed',
|
||||
'Sent to the agent — not confirmed'
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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: {
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
<PickerTooltipContent
|
||||
label={props.tooltipLabel}
|
||||
disabledReason={props.disabledReason}
|
||||
dispatched={props.dispatched}
|
||||
/>
|
||||
{props.disabledReason ?? props.tooltipLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
@@ -250,7 +225,6 @@ function NativeChatSessionOptionPickersInner({
|
||||
tooltipLabel={optionsTooltip}
|
||||
disabled={isWorking || pendingId !== null}
|
||||
disabledReason={optionsReason}
|
||||
dispatched={options.some((descriptor) => descriptor.valueSource === 'dispatched')}
|
||||
/>
|
||||
<DropdownMenuContent align="start" side="top" collisionPadding={8} className="w-60">
|
||||
{options.map((descriptor, index) => {
|
||||
@@ -283,7 +257,6 @@ function NativeChatSessionOptionPickersInner({
|
||||
tooltipLabel={modelTooltip}
|
||||
disabled={isWorking || pendingId !== null}
|
||||
disabledReason={modelReason}
|
||||
dispatched={model.valueSource === 'dispatched'}
|
||||
/>
|
||||
<DropdownMenuContent align="start" side="top" collisionPadding={8} className="w-64">
|
||||
{modelReason && !model.settable ? (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -14589,7 +14589,6 @@
|
||||
"toggleOption": "{{value0}}を切り替える",
|
||||
"pillAccessibleName": "{{value0}} {{value1}}",
|
||||
"valueUnknown": "現在の値が不明です — オンまたはオフを選んでください",
|
||||
"sentNotConfirmed": "Agent に送信済み — 未確認",
|
||||
"setWhenSessionStarts": "セッション開始時に設定してください。",
|
||||
"availableAfterSessionStarts": "セッション開始後に利用できます。",
|
||||
"optionUpdateFailed": "オプションを更新できませんでした",
|
||||
|
||||
@@ -14702,7 +14702,6 @@
|
||||
"toggleOption": "{{value0}} 전환",
|
||||
"pillAccessibleName": "{{value0}} {{value1}}",
|
||||
"valueUnknown": "현재 값을 알 수 없음 — 켜짐 또는 꺼짐을 선택하세요",
|
||||
"sentNotConfirmed": "에이전트에 전송됨 — 확인되지 않음",
|
||||
"setWhenSessionStarts": "세션을 시작할 때 설정하세요.",
|
||||
"availableAfterSessionStarts": "세션이 시작된 후 사용할 수 있습니다.",
|
||||
"optionUpdateFailed": "옵션을 업데이트하지 못했습니다",
|
||||
|
||||
@@ -14645,7 +14645,6 @@
|
||||
"toggleOption": "切换{{value0}}",
|
||||
"pillAccessibleName": "{{value0}} {{value1}}",
|
||||
"valueUnknown": "当前值未知 — 请选择开或关",
|
||||
"sentNotConfirmed": "已发送给智能体 — 尚未确认",
|
||||
"setWhenSessionStarts": "请在会话开始时设置。",
|
||||
"availableAfterSessionStarts": "会话开始后可用。",
|
||||
"optionUpdateFailed": "无法更新选项",
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user