From bc35323f177fcd44706c8cefaadb165e27a3b302 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:06:19 -0800 Subject: [PATCH] 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 --- ...claude-context-usage-unloaded-turn.test.ts | 3 +- ...laude-structured-execution-observations.ts | 12 +- .../claude-structured-response-model.test.ts | 47 +++++ .../claude-structured-session-adapter.ts | 7 + .../claude-structured-session-state.ts | 2 + .../claude-structured-message-options.ts | 4 + .../claude-structured-session-options.ts | 5 +- ...ent-session-option-refresh.test-fixture.ts | 3 +- ...sion-options-read-report-freshness.test.ts | 182 ++++++++++++++++++ ...structured-agent-session-host-mutations.ts | 6 +- .../structured-agent-session-option-intent.ts | 35 ++++ .../structured-agent-session-options-read.ts | 28 ++- ...structured-agent-session-adapter-router.ts | 2 + .../structured-agent-session-adapter.ts | 2 + 14 files changed, 321 insertions(+), 17 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-options-read-report-freshness.test.ts diff --git a/src/main/claude/claude-context-usage-unloaded-turn.test.ts b/src/main/claude/claude-context-usage-unloaded-turn.test.ts index bd2d8c1edd4..fcfcffed4aa 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -108,7 +108,8 @@ function readOptions(journal: AgentSessionJournal, agents: StructuredAgentRegist }, serialize: (_sessionId: string, task: () => Promise) => 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 }) } diff --git a/src/main/claude/structured/claude-structured-execution-observations.ts b/src/main/claude/structured/claude-structured-execution-observations.ts index bd27887fdfd..60f7a34ecaf 100644 --- a/src/main/claude/structured/claude-structured-execution-observations.ts +++ b/src/main/claude/structured/claude-structured-execution-observations.ts @@ -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 diff --git a/src/main/claude/structured/claude-structured-response-model.test.ts b/src/main/claude/structured/claude-structured-response-model.test.ts index e37af00c246..4405ad02c96 100644 --- a/src/main/claude/structured/claude-structured-response-model.test.ts +++ b/src/main/claude/structured/claude-structured-response-model.test.ts @@ -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() + } +}) diff --git a/src/main/claude/structured/claude-structured-session-adapter.ts b/src/main/claude/structured/claude-structured-session-adapter.ts index 53d4d7a7df8..e9369cceb69 100644 --- a/src/main/claude/structured/claude-structured-session-adapter.ts +++ b/src/main/claude/structured/claude-structured-session-adapter.ts @@ -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 = (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 => { diff --git a/src/main/claude/structured/claude-structured-session-state.ts b/src/main/claude/structured/claude-structured-session-state.ts index 55959e4900f..130bc0f0441 100644 --- a/src/main/claude/structured/claude-structured-session-state.ts +++ b/src/main/claude/structured/claude-structured-session-state.ts @@ -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. */ diff --git a/src/main/claude/structured/launch/claude-structured-message-options.ts b/src/main/claude/structured/launch/claude-structured-message-options.ts index 8463df786cc..abd34fe3d5b 100644 --- a/src/main/claude/structured/launch/claude-structured-message-options.ts +++ b/src/main/claude/structured/launch/claude-structured-message-options.ts @@ -30,6 +30,10 @@ export async function applyClaudeMessageOptions( ): Promise { 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), diff --git a/src/main/claude/structured/launch/claude-structured-session-options.ts b/src/main/claude/structured/launch/claude-structured-session-options.ts index 000c85ec47e..b35843bd7c6 100644 --- a/src/main/claude/structured/launch/claude-structured-session-options.ts +++ b/src/main/claude/structured/launch/claude-structured-session-options.ts @@ -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 } : {}), diff --git a/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-option-refresh.test-fixture.ts b/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-option-refresh.test-fixture.ts index bc258adeaed..7fb76b5ae44 100644 --- a/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-option-refresh.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-option-refresh.test-fixture.ts @@ -106,7 +106,8 @@ export function piOptionRefreshHost(sessionId = 'session-1') { }, serialize: (_sessionId: string, task: () => Promise) => 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 }) }, diff --git a/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-options-read-report-freshness.test.ts b/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-options-read-report-freshness.test.ts new file mode 100644 index 00000000000..cd25747e37b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-options-read-report-freshness.test.ts @@ -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 = 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() + const assembled = Promise.withResolvers() + 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) => 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() + const released = Promise.withResolvers() + 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() + } + } +) diff --git a/src/main/native-chat/agent-session-wire/operations/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/operations/structured-agent-session-host-mutations.ts index e05d409093c..664ea0022cc 100644 --- a/src/main/native-chat/agent-session-wire/operations/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/operations/structured-agent-session-host-mutations.ts @@ -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 diff --git a/src/main/native-chat/agent-session-wire/options/structured-agent-session-option-intent.ts b/src/main/native-chat/agent-session-wire/options/structured-agent-session-option-intent.ts index 2de64669af4..f835e253b20 100644 --- a/src/main/native-chat/agent-session-wire/options/structured-agent-session-option-intent.ts +++ b/src/main/native-chat/agent-session-wire/options/structured-agent-session-option-intent.ts @@ -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, @@ -103,3 +107,34 @@ export async function recordStructuredAgentSessionOptionIntent( } } } + +const modelPicks = new WeakMap() + +/** 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 } + sessions: ReadonlyMap + }, + ctx: Pick, + 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 }) +} diff --git a/src/main/native-chat/agent-session-wire/options/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/options/structured-agent-session-options-read.ts index e2c14973552..76918d8fe34 100644 --- a/src/main/native-chat/agent-session-wire/options/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/options/structured-agent-session-options-read.ts @@ -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>, 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, diff --git a/src/main/native-chat/agent-session-wire/providers/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/providers/structured-agent-session-adapter-router.ts index 492ff9bc30c..978847e8632 100644 --- a/src/main/native-chat/agent-session-wire/providers/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/providers/structured-agent-session-adapter-router.ts @@ -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 diff --git a/src/main/native-chat/agent-session-wire/providers/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/providers/structured-agent-session-adapter.ts index 31a3adcbf6b..2a5269d08e9 100644 --- a/src/main/native-chat/agent-session-wire/providers/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/providers/structured-agent-session-adapter.ts @@ -304,6 +304,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & ): Promise>> /** 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