Keep Chat UI model selections from snapping back to older replies (#40906)

* fix(native-chat): keep a model pick shown instead of an older answer's model

A saved model pick on a live structured Claude chat snapped back: the host's
options answer kept returning the model that answered before the pick, and the
picker shows a reported model over the selection (for provider fallback).

A pick now retires the child's earlier model reports until a turn carries that
pick, as the common pattern does with a fallback report once the user picks
again. The Claude adapter ignores old-execution frames until a turn applies the
picked value; the host drops a report captured by an options read when a pick or
a child change lands before the read answers. Picks still reach Claude at the
next turn.

* test(native-chat): cover the confirmed-init report under read freshness

* test(native-chat): cover picks during recovery and legacy no-op mutations
This commit is contained in:
Brennan Benson
2026-11-10 17:06:19 -08:00
committed by GitHub
parent 3d9be7c269
commit bc35323f17
14 changed files with 321 additions and 17 deletions
@@ -108,7 +108,8 @@ function readOptions(journal: AgentSessionJournal, agents: StructuredAgentRegist
},
serialize: (_sessionId: string, task: () => Promise<unknown>) => task(),
openConversation: async () => running,
conversation: async () => running
conversation: async () => running,
sessions: new Map([[SESSION, running]])
} as unknown as StructuredAgentSessionMutationContext
return readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true })
}
@@ -41,11 +41,19 @@ export function beginClaudeOptionMutation(session: ClaudeSession, key: string):
return sequence
}
/** Until a turn carries the pick, the running process's answers name the model it replaced. */
export function noteClaudeModelPick(session: ClaudeSession, model: string): void {
session.modelPickAwaitingTurn = model
delete session.reportedOptions.reportedModel
}
/** An init names execution; a control answer only acknowledges its input. */
export function observeClaudeExecutionModel(session: ClaudeSession, model: string): boolean {
const previous = claudeExecutionObservations(session).model
const previousResponse = session.reportedOptions.reportedModel
session.reportedOptions.reportedModel = model
if (!session.modelPickAwaitingTurn) {
session.reportedOptions.reportedModel = model
}
if (previous !== model) {
delete session.appliedOptions
}
@@ -93,7 +101,7 @@ function observeClaudeResponseModel(
: null,
responseModel: response
}) ?? response
if (model === session.reportedOptions.reportedModel) {
if (session.modelPickAwaitingTurn || model === session.reportedOptions.reportedModel) {
return false
}
session.reportedOptions.reportedModel = model
@@ -152,3 +152,50 @@ it('keeps a model picked during a turn over that turn’s later replies until a
await adapter.closeAll()
}
})
it('retires the answered model on a saved pick until a turn carries that pick', async () => {
const claude = fakeClaude({ initModel: 'claude-opus-5-5' })
const adapter = await acquired(claude)
const connection = claude.connections[0]!
const read = async () => (await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).current
const answer = (uuid: string, model: string) =>
claudeFrame(connection, {
type: 'assistant',
uuid,
message: { role: 'assistant', model, content: [] }
})
const turn = async (clientMessageId: string, model: string) => {
claudeFrame(connection, { type: 'system', subtype: 'session_state_changed', state: 'idle' })
await adapter.dispatch({
sessionId: 'session-1',
clientMessageId,
body: USER_MESSAGE,
executionOptions: { model },
fence: 7
})
}
try {
claudeFrame(connection, { type: 'system', subtype: 'init', model: 'claude-opus-5-5' })
answer('fallback', 'claude-opus-4-8')
expect((await read()).reportedModel).toBe('claude-opus-4-8')
adapter.noteOptionPick({ sessionId: 'session-1', fence: 7, key: 'model', value: 'sonnet' })
answer('same-turn', 'claude-opus-4-8')
expect((await read()).reportedModel).toBeUndefined()
expect((await read()).confirmed ?? []).not.toContain('model')
// A message accepted before the pick runs the model it captured.
await turn('queued-before-pick', 'opus')
claudeFrame(connection, { type: 'system', subtype: 'init', model: 'claude-opus-5-5' })
answer('queued-answer', 'claude-opus-5-5')
expect((await read()).reportedModel).toBeUndefined()
await turn('carries-pick', 'sonnet')
expect(connection.calls).toContainEqual({ subtype: 'set_model', params: { model: 'sonnet' } })
claudeFrame(connection, { type: 'system', subtype: 'init', model: 'claude-sonnet-5-5' })
answer('next-fallback', 'claude-opus-4-8')
expect((await read()).reportedModel).toBe('claude-opus-4-8')
} finally {
await adapter.closeAll()
}
})
@@ -18,6 +18,7 @@ import { supportsClaudeStructuredLocation } from './launch/claude-structured-loc
import * as claudeOptions from './launch/claude-structured-options'
import { dispatchClaudeMessage } from './launch/claude-structured-message-options'
import { readClaudeStructuredSessionOptions } from './launch/claude-structured-session-options'
import { noteClaudeModelPick } from './claude-structured-execution-observations'
import {
ClaudeAcquisitionRegistry,
type ClaudeSession,
@@ -231,6 +232,12 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
deps: this.deps,
emit: (session, event) => this.emit(session, event)
})
noteOptionPick: NonNullable<StructuredAgentSessionAdapter['noteOptionPick']> = (input) => {
const session = this.sessions.get(input.sessionId)
if (session?.fence === input.fence && input.key === 'model' && input.value) {
noteClaudeModelPick(session, input.value)
}
}
readOptions = (input: { sessionId: string; fence: number }) =>
readClaudeStructuredSessionOptions(this.session(input.sessionId))
releaseAcquisition = (input: ClaudeReleaseRequest): Promise<boolean> => {
@@ -135,6 +135,8 @@ export type ClaudeSession = {
fastModePerSessionOptIn?: boolean
/** Mutation sequence of the last model report; newer writes outrank it. */
reportedModelMutation: number
/** A saved model pick no turn of this process has carried; its reports predate the pick. */
modelPickAwaitingTurn?: string
/** `dispatchSequence` at the last model write; that turn's replies predate the pick. */
modelMutationDispatch?: number
/** Options whose recorded value the provider reported, not merely accepted. */
@@ -30,6 +30,10 @@ export async function applyClaudeMessageOptions(
): Promise<void> {
throwIfSignalAborted(signal)
const model = options.model
// Only a turn carrying the newest pick reports on it; earlier captured sends run the replaced model.
if (model === session.modelPickAwaitingTurn) {
delete session.modelPickAwaitingTurn
}
if (model && session.appliedInputOptions.get('model') !== model) {
await waitForPromiseWithSignal(
setClaudeStructuredOption(session, { key: 'model', value: model }, timeoutMs),
@@ -213,6 +213,7 @@ export function claudeStructuredSessionOptionsFrom(
const discovered = listedModels(catalog ? { models: catalog } : null)
const listed = discovered.length > 0 ? discovered : seedModels()
const current = readClaudeCurrentModel(session)
const reportsPick = !session.modelPickAwaitingTurn
const model = current.id === 'default' ? undefined : current.id
const models = structuredAgentSessionOptionModels(listed, model, (row) => ({
...row,
@@ -230,7 +231,7 @@ export function claudeStructuredSessionOptionsFrom(
session.reportedOptions.fastMode ??
(session.fastModeState === undefined ? undefined : session.fastModeState !== 'off')
const confirmed = [
...(current.confirmed ? ['model'] : []),
...(current.confirmed && reportsPick ? ['model'] : []),
...(effort && session.confirmedOptions.has('effort') ? ['effort'] : []),
...(fastMode !== undefined &&
(session.confirmedOptions.has('fastMode') || !session.options.has('fastMode'))
@@ -243,7 +244,7 @@ export function claudeStructuredSessionOptionsFrom(
...(changesLive ? {} : { effortFixed: true as const }),
current: {
model,
reportedModel: session.reportedOptions.reportedModel,
reportedModel: reportsPick ? session.reportedOptions.reportedModel : undefined,
reportedPermissionMode: session.reportedOptions.reportedPermissionMode,
...(effort ? { effort } : {}),
...(fastMode !== undefined ? { fastMode } : {}),
@@ -106,7 +106,8 @@ export function piOptionRefreshHost(sessionId = 'session-1') {
},
serialize: (_sessionId: string, task: () => Promise<unknown>) => task(),
openConversation: async () => conversation,
conversation: async () => conversation
conversation: async () => conversation,
sessions: new Map([[sessionId, conversation]])
} as unknown as StructuredAgentSessionMutationContext
return readStructuredAgentSessionOptions(context, sessionId, { readsWithoutModel: true })
},
@@ -0,0 +1,182 @@
import { expect, it } from 'vitest'
import type { AgentSessionModelCatalogResult } from '../../../../../shared/agent-session/agent-session-wire'
import type { StructuredAgentSessionMutationContext } from '../../operations/structured-agent-session-mutation-context'
import type {
StructuredAgentSessionHostSession,
StructuredAgentSessionProviderChild
} from '../structured-agent-session-host-types'
import { readStructuredAgentSessionOptions } from '../../options/structured-agent-session-options-read'
import { noteStructuredAgentSessionOptionPick } from '../../options/structured-agent-session-option-intent'
import {
hostTestCatalogReadOperation,
hostTestOptionsReadStore,
hostTestRestingOptionsJournal,
hostTestRestingOptionsRecord,
HOST_TEST_NOW as NOW,
HOST_TEST_SESSION as SESSION,
hostTestOperationId
} from './structured-agent-session-host-test-data'
import {
createQueuedMessageTestRig,
QUEUED_RIG_CALLER
} from './structured-agent-session-queued-message-rig.test-fixture'
import { appendUsageLimitedTurn } from './structured-agent-session-rewind-usage-limit.test-fixture'
const RESPONSE_REPORT = { model: 'opus[1m]', reportedModel: 'claude-opus-4-8' }
// Claude's confirmed init model stands in for a report when no response named one.
const CONFIRMED_INIT = { model: 'opus[1m]', confirmed: ['model'] }
function claudeOptionsRead(answer: Record<string, unknown> = RESPONSE_REPORT) {
const record = {
...hostTestRestingOptionsRecord('session-1'),
provider: 'claude',
options: { model: 'opus[1m]' }
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reader and pick counter use only the child's identity, fence and phase.
const child = { fence: 2, phase: 'ready' } as unknown as StructuredAgentSessionProviderChild
const conversation = {
child,
params: { provider: 'claude' },
journal: hostTestRestingOptionsJournal()
}
// Picks serialize with the live read, so they can land only after it, as the answer is assembled.
const entered = Promise.withResolvers<void>()
const assembled = Promise.withResolvers<void>()
const home: AgentSessionModelCatalogResult = { origin: 'probe', fetchedAt: 1, models: [] }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: picks and the reader read only the session's current child.
const session = { child } as unknown as StructuredAgentSessionHostSession
const sessions = new Map([['session-1', session]])
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the real reader accesses only these injected ports; no provider process is launched.
const context = {
deps: {
adapter: {
readOptions: async () => ({
models: [],
current: answer
})
},
agents: { definition: () => null, capabilities: () => null },
store: hostTestOptionsReadStore(() => record),
modelCatalog: {
captureRead: hostTestCatalogReadOperation,
read: async () => home
}
},
serialize: (_sessionId: string, task: () => Promise<unknown>) => task(),
openConversation: async () => conversation,
conversation: async () => {
entered.resolve()
await assembled.promise
return conversation
},
sessions
} as unknown as StructuredAgentSessionMutationContext
const pick = (model: string) => {
record.options.model = model
noteStructuredAgentSessionOptionPick(
{ deps: { adapter: {} }, sessions },
{ sessionId: 'session-1', fence: 2 },
{ key: 'model', value: model },
true
)
}
/** Run `during` while the read is between its live snapshot and its answer. */
const read = async (during: () => void = () => {}) => {
const answer = readStructuredAgentSessionOptions(context, 'session-1', {
readsWithoutModel: true
})
await entered.promise
during()
assembled.resolve()
return (await answer).current
}
return { read, pick, sessions, session }
}
it('keeps a live fallback report that no pick has replaced', async () => {
expect(await claudeOptionsRead(CONFIRMED_INIT).read()).toMatchObject({
reportedModel: 'opus[1m]'
})
expect(await claudeOptionsRead().read()).toMatchObject({
model: 'opus[1m]',
reportedModel: 'claude-opus-4-8'
})
})
it.each([
['picks back to the same model', ['sonnet', 'opus[1m]'], RESPONSE_REPORT],
['re-picks the shown model', ['opus[1m]'], RESPONSE_REPORT],
['picks back while only the init model confirms', ['sonnet', 'opus[1m]'], CONFIRMED_INIT]
])(
'drops a report captured before the user %s while the read is in flight',
async (_, models, answer) => {
const host = claudeOptionsRead(answer)
const current = await host.read(() => models.forEach((model) => host.pick(model)))
expect(current.model).toBe('opus[1m]')
expect(current.reportedModel).toBeUndefined()
}
)
it('drops a report from a child that ended or was replaced while the read was in flight', async () => {
for (const next of [null, { fence: 3, phase: 'ready' }]) {
const host = claudeOptionsRead()
const current = await host.read(() => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only child identity matters to the reader.
host.sessions.set('session-1', {
...host.session,
child: next
} as unknown as StructuredAgentSessionHostSession)
})
expect(current.reportedModel).toBeUndefined()
}
})
it.each([
[
'retires the report when the user picks back to it',
QUEUED_RIG_CALLER,
['sonnet', 'opus[1m]'],
undefined
],
[
'keeps the report through an old client empty no-op',
{ ...QUEUED_RIG_CALLER, readsOptionsWithoutModel: false },
[''],
'claude-opus-4-8'
]
] as const)(
'after a usage-limited turn, a pick made while recovery choices load %s',
async (_, caller, models, reportedModel) => {
const rig = await createQueuedMessageTestRig({ agent: 'claude' })
try {
const pick = async (value: string) => {
const fields = { key: 'model', value }
return rig.host.setOption(caller, {
envelope: rig.envelope(fields, 'agentSession.setOption', hostTestOperationId()),
...fields
})
}
expect(await pick('opus[1m]')).toMatchObject({ ok: true })
rig.host.deps.adapter.readOptions = async () => ({ models: [], current: RESPONSE_REPORT })
const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal
await appendUsageLimitedTurn(journal, NOW, 'quota')
const recovering = Promise.withResolvers<void>()
const released = Promise.withResolvers<undefined>()
rig.host.deps.resolveLaunchEnv = () => {
recovering.resolve()
return released.promise
}
const answer = rig.host.readOptions(SESSION)
await recovering.promise
for (const model of models) {
expect(await pick(model)).toMatchObject({ ok: true })
}
released.resolve(undefined)
const { current } = await answer
expect(current.model).toBe('opus[1m]')
expect(current.reportedModel).toBe(reportedModel)
} finally {
await rig.dispose()
}
}
)
@@ -55,7 +55,10 @@ import { openWithReadyStructuredAgentSessionChild } from './structured-agent-ses
import type { StructuredAgentSessionCaller } from '../host/structured-agent-session-host-types'
import '../options/structured-agent-session-options-read'
import { recordStructuredAgentSessionOptionIntent } from '../options/structured-agent-session-option-intent'
import {
noteStructuredAgentSessionOptionPick,
recordStructuredAgentSessionOptionIntent
} from '../options/structured-agent-session-option-intent'
import { confirmIdleStructuredAgentSessionOption } from '../options/structured-agent-session-idle-option'
export function sendStructuredAgentSessionTurn(
@@ -265,6 +268,7 @@ export async function setStructuredAgentSessionOption(
readsWithoutModel: caller.readsOptionsWithoutModel !== false
})
if (picked.ok) {
noteStructuredAgentSessionOptionPick(context, ctx, params, !noChoice)
confirmIdleStructuredAgentSessionOption(context, ctx, params)
}
return picked
@@ -19,6 +19,10 @@ import {
} from './structured-agent-session-pick-reply'
import type { StructuredAgentSessionAdapter } from '../providers/structured-agent-session-adapter'
import { pickAgentSessionOption } from '../../../runtime/agent-session/records/agent-session-record-options'
import type {
StructuredAgentSessionHostSession,
StructuredAgentSessionProviderChild
} from '../host/structured-agent-session-host-types'
function acceptsOptionPick(
agents: Pick<StructuredAgentRegistry, 'definition'>,
@@ -103,3 +107,34 @@ export async function recordStructuredAgentSessionOptionIntent(
}
}
}
const modelPicks = new WeakMap<StructuredAgentSessionProviderChild, number>()
/** Counts saved model picks per child, so a read can tell its captured report predates one. */
export function structuredAgentSessionModelPicks(
child: StructuredAgentSessionProviderChild
): number {
return modelPicks.get(child) ?? 0
}
/** A saved pick retires the current child's earlier reports, including ones a read already captured. */
export function noteStructuredAgentSessionOptionPick(
context: {
deps: { adapter: Pick<StructuredAgentSessionAdapter, 'noteOptionPick'> }
sessions: ReadonlyMap<string, StructuredAgentSessionHostSession>
},
ctx: Pick<AgentSessionTurnContext, 'sessionId' | 'fence'>,
input: { key: string; value: string },
saved: boolean
): void {
const { sessionId, fence } = ctx
// An old client's empty model clears nothing, so it retires no report.
if (!saved) {
return
}
const child = context.sessions.get(sessionId)?.child
if (input.key === 'model' && child?.fence === fence) {
modelPicks.set(child, structuredAgentSessionModelPicks(child) + 1)
}
context.deps.adapter.noteOptionPick?.({ sessionId, fence, ...input })
}
@@ -30,12 +30,13 @@ import { structuredAgentSessionImmediatePermission } from './structured-agent-se
import type { StructuredAgentSessionMutationContext } from '../operations/structured-agent-session-mutation-context'
import { readStructuredSessionRecovery } from '../recovery/structured-agent-session-recovery-read'
import { fillUnknownModelEfforts } from '../../../../shared/agent-session/options/agent-session-model-efforts'
import { structuredAgentSessionModelPicks } from './structured-agent-session-option-intent'
/** A live child's own answer, or the answer at rest; the goal, usage and rewind either way. */
export async function readStructuredAgentSessionOptions(
context: Pick<
StructuredAgentSessionMutationContext,
'deps' | 'serialize' | 'openConversation' | 'conversation'
'deps' | 'serialize' | 'openConversation' | 'conversation' | 'sessions'
> &
Partial<Pick<StructuredAgentSessionMutationContext, 'deliveryOrder'>>,
sessionId: string,
@@ -71,6 +72,7 @@ export async function readStructuredAgentSessionOptions(
child && child.phase !== 'starting'
? await adapter.readOptions?.({ sessionId, fence: child.fence })
: undefined
const modelPicks = child ? structuredAgentSessionModelPicks(child) : 0
const scope = () =>
structuredAgentSessionCatalogScopeOwned(
context.deps,
@@ -93,7 +95,8 @@ export async function readStructuredAgentSessionOptions(
records,
session,
child,
options: observed ?? null
options: observed ?? null,
modelPicks
}
})
let started = await readCurrent(true)
@@ -132,6 +135,7 @@ export async function readStructuredAgentSessionOptions(
const floor = session.journal.context.floor()
const contextFloor = floor ? { contextFloor: floor } : {}
const restingOptions = await atRest()
const recovery = await readStructuredSessionRecovery(session, context, sessionId)
if (live && usesModelSpecificAcpEfforts(agent)) {
options = {
...options,
@@ -159,12 +163,17 @@ export async function readStructuredAgentSessionOptions(
const model = saved.model ?? (options.current.model || selected.current.model || displayDefault)
const projected = live?.projectCurrent?.({ ...saved, ...(model ? { model } : {}) })
const reportedEffort = projected?.confirmed?.includes('effort') ? projected.effort : undefined
const reportedModel = live
? (options.current.reportedModel ??
(agent === 'claude' && options.current.confirmed?.includes('model')
? options.current.model
: undefined))
: undefined
// After every await: a pick or a replaced child since the live read retires its report.
const reportedModel =
live &&
started.kind === 'live' &&
context.sessions.get(sessionId)?.child === started.child &&
started.modelPicks === structuredAgentSessionModelPicks(started.child)
? (options.current.reportedModel ??
(agent === 'claude' && options.current.confirmed?.includes('model')
? options.current.model
: undefined))
: undefined
const current = {
...selected.current,
...options.current,
@@ -236,7 +245,6 @@ export async function readStructuredAgentSessionOptions(
current.serviceTier = projected.options.serviceTier
}
}
const recovery = await readStructuredSessionRecovery(session, context, sessionId)
return {
...options,
...recovery,
@@ -258,7 +266,7 @@ export async function readStructuredAgentSessionOptions(
current: {
...current,
acceptedInputSources,
...(reportedModel ? { reportedModel } : {}),
reportedModel,
...(reportedEffort !== undefined ? { reportedEffort } : {}),
defaulted,
confirmed,
@@ -204,6 +204,8 @@ export class StructuredAgentSessionAdapterRouter implements Adapter {
canSetOptionWhileIdle: Adapter['canSetOptionWhileIdle'] = (input) =>
this.liveOwnerOrNull(input.sessionId)?.canSetOptionWhileIdle?.(input) === true
noteOptionPick: Adapter['noteOptionPick'] = (input) =>
this.liveOwnerOrNull(input.sessionId)?.noteOptionPick?.(input)
readAppliedInputOptions: Adapter['readAppliedInputOptions'] = (input) =>
this.liveOwnerOrNull(input.sessionId)?.readAppliedInputOptions?.(input) ?? null
@@ -304,6 +304,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop &
): Promise<void | Readonly<Record<string, string>>>
/** Opt in only when setOption can update idle metadata without a provider turn. */
canSetOptionWhileIdle?(input: StructuredAgentSessionSetOptionInput): boolean
/** A saved pick retires this child's earlier execution reports until a turn carries it. */
noteOptionPick?(input: { sessionId: string; fence: number; key: string; value: string }): void
/** Applied inputs for this exact child; observations never decide message admission. */
readAppliedInputOptions?(input: {
sessionId: string