From bfde8bd828cb6df0776d250131601793d46e583d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:18:56 -0700 Subject: [PATCH 01/34] fix(native-chat): a chat's model, effort and Fast belong to the chat, not to a model Structured chats kept their option values per model on the client, so a value the host saved without a model (a resumed chat that never picked one, Codex Fast saved on the conversation) had nowhere to live. The client either dropped it or labelled the chat with the listing's default model to hold it. - One flat record per chat holds model, effort, speed and Fast independently; a model pick changes only the model and the host reconciles the rest. - Controls come from the model they apply to, separately from the values; with no model known, a saved Codex speed still shows and Standard turns it off. - The options answer marks values a catalog default supplied (current.defaulted). A resumed chat never names or sends such a model as its own; a new chat still shows the default the host says it runs. An answer without the mark reads as before. - Every send, including a new chat's first message, carries the chat's own model and effort and held picks, never a default read off the picker. - A held launch pick supersedes only an earlier pick of the same option. Tracked in STA-10947. --- .../use-mobile-host-model-catalog-upgrade.ts | 4 +- .../use-mobile-structured-agent-options.ts | 9 +- .../use-mobile-structured-agent-session.ts | 2 +- ...use-mobile-structured-send-with-outcome.ts | 16 +- ...-structured-effort-default-at-rest.test.ts | 2 +- .../claude/claude-structured-model-catalog.ts | 5 + .../claude-structured-session-options.ts | 4 +- .../codex/codex-structured-model-catalog.ts | 4 +- .../codex-structured-session-options.test.ts | 4 + ...uctured-agent-session-options-read.test.ts | 19 +- .../structured-agent-session-options-read.ts | 9 +- ...gent-session-registered-definition.test.ts | 3 +- ...ured-agent-new-chat-effort-at-rest.test.ts | 35 +++- .../use-host-model-catalog-upgrade.ts | 4 +- ...e-structured-agent-session-option-state.ts | 4 +- ...ctured-agent-session-send-options.test.tsx | 4 + ...e-structured-agent-session-send-options.ts | 8 +- ...red-agent-session-launch-create-message.ts | 7 +- ...tured-agent-session-launch-options.test.ts | 26 +++ ...structured-agent-session-launch-options.ts | 17 +- src/shared/agent-session-wire.ts | 3 + .../native-chat-session-option-snapshot.ts | 81 +++++--- ...ructured-agent-session-discovered-model.ts | 2 +- ...d-agent-session-first-frame-picker.spec.ts | 6 +- ...tructured-agent-session-option-snapshot.ts | 159 ++++++++++++++++ .../structured-agent-session-option-state.ts | 70 +++++++ ...ructured-agent-session-option-view.test.ts | 72 ++++++- .../structured-agent-session-option-view.ts | 33 ++-- .../structured-agent-session-options.test.ts | 90 ++++++++- .../structured-agent-session-options.ts | 178 ++++++------------ ...uctured-agent-session-permission-option.ts | 8 +- ...tructured-agent-session-unlisted-effort.ts | 12 +- 32 files changed, 658 insertions(+), 242 deletions(-) create mode 100644 src/shared/structured-agent-session-option-snapshot.ts create mode 100644 src/shared/structured-agent-session-option-state.ts diff --git a/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts b/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts index 77798c5955d1..782adcad2c5f 100644 --- a/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts +++ b/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts @@ -1,7 +1,7 @@ import { useEffect, type MutableRefObject } from 'react' import type { AgentSessionModelCatalogResult } from '../../../src/shared/agent-session-wire' import type { AgentSessionOptionCatalog } from '../../../src/shared/agent-session-option-catalog' -import type { NativeChatSessionOptionRecord } from '../../../src/shared/native-chat-session-option-state' +import type { StructuredAgentSessionOptionRecord } from '../../../src/shared/structured-agent-session-option-state' import { applyStructuredAgentSessionModelCatalog, settleStructuredAgentSessionBuiltinCatalog, @@ -31,7 +31,7 @@ export function useMobileHostModelCatalogUpgrade(args: { worktree?: string fence: number | null optionCatalog: AgentSessionOptionCatalog | null - activeOptionRecordRef: MutableRefObject + activeOptionRecordRef: MutableRefObject /** A listing landed after the chat's options were read, which answered without it. */ onListingLanded?: () => void updateOptionState: ( diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 3be3c400e3c9..615d1eaf84c1 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -18,6 +18,7 @@ import { } from '../../../src/shared/structured-agent-session-options' import { structuredAgentSessionOptionView, + structuredAgentSessionSendSelection, structuredAgentSessionShownSnapshot } from '../../../src/shared/structured-agent-session-option-view' import { @@ -289,6 +290,11 @@ export function useMobileStructuredAgentOptions(args: { [agent, client, mutate, optionCatalog, sessionId, updateOptionState] ) + // Read, not rendered: what the chat holds when the send goes out. + const sendSelection = useCallback( + () => structuredAgentSessionSendSelection(optionStateRef.current), + [] + ) const { optionPickerRequest, invokeStructuredOption, optionSurface } = useMobileStructuredOptionSurface({ optionSnapshot, @@ -308,6 +314,7 @@ export function useMobileStructuredAgentOptions(args: { optionSurface, pendingOptionId: optionState.pendingId, setStructuredOption, - invokeStructuredOption + invokeStructuredOption, + sendSelection } } diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 66fd76fda000..de08068c497f 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -198,7 +198,7 @@ export function useMobileStructuredAgentSession( commandPending: commandPendingRef, controller: sendController, onSendError, - carriesOptions: hostSupport?.sendOptions === true + sendOptions: hostSupport?.sendOptions === true ? options.sendSelection : undefined }) const turnIndicator = useMemo( () => ({ diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts index 724acfb5c2e6..5f1b6fcf7094 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -21,7 +21,6 @@ import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-age import type { MobileStructuredSends } from './use-mobile-structured-sends' import { structuredSessionOperationId } from './structured-session-operation-id' import { timeoutForDeadline } from './mobile-structured-agent-session-rpc' -import { structuredAgentSessionSendSelection } from '../../../src/shared/structured-agent-session-option-view' import { nativeChatTextWithFileReferences } from './mobile-native-chat-file-attachment' import { actionableStructuredApproval, @@ -64,8 +63,8 @@ export function useMobileStructuredSendWithOutcome(args: { 'snapshot' | 'setOption' | 'invokeAction' | 'conversationCommands' > onSendError: MobileNativeChatSendErrorReporter - /** Each send carries the model and effort the picker shows; an older host takes neither. */ - carriesOptions?: boolean + /** The chat's own model and effort each send carries; absent for an older host, which takes neither. */ + sendOptions?: () => Readonly> | undefined }): ( text: string, images?: string[], @@ -83,7 +82,7 @@ export function useMobileStructuredSendWithOutcome(args: { hold, onSendError, queueCapable, - carriesOptions, + sendOptions, sessionId, stateRef, stopping @@ -157,11 +156,8 @@ export function useMobileStructuredSendWithOutcome(args: { queuedAt: Date.now(), ...(stopping ? { sentWhileStopping: true as const } : {}) } - // A pick on its way holds the send, so the picker shows what the chat holds. - const options = - carriesOptions && agent - ? structuredAgentSessionSendSelection(agent, controller.snapshot) - : undefined + // A pick on its way holds the send, so the chat's values are what it holds. + const options = sendOptions?.() const outcome = await sendMobileStructuredAgentSessionMessage({ client, sessionId, @@ -194,7 +190,7 @@ export function useMobileStructuredSendWithOutcome(args: { hold, onSendError, queueCapable, - carriesOptions, + sendOptions, sessionId, stateRef, stopping diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index fa48656d6d7e..774e1cc4aa3f 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -408,7 +408,7 @@ describe('Claude effort default at rest', () => { const result = await readAtRest(store, record) - expect(result.current).toEqual({ model: 'gpt-5.5', permissionMode: 'ask' }) + expect(result.current).toEqual({ model: 'gpt-5.5', defaulted: [], permissionMode: 'ask' }) }) it("never saves the applied effort as the chat's pick, yet shows it at rest", async () => { diff --git a/src/main/claude/claude-structured-model-catalog.ts b/src/main/claude/claude-structured-model-catalog.ts index c53a310db4b8..1b7ec8f0a91e 100644 --- a/src/main/claude/claude-structured-model-catalog.ts +++ b/src/main/claude/claude-structured-model-catalog.ts @@ -97,6 +97,11 @@ export function seedModels(): ListedModel[] { })) } +/** Whether `currentModelId` falls back to the list's default, which the chat never chose. */ +export function currentModelIsDefaulted(reportedModel: string | undefined): boolean { + return !reportedModel || reportedModel === 'default' +} + export function currentModelId(models: ListedModel[], reportedModel: string | undefined): string { const matched = reportedModel ? models.find( diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index 2550b6bab18b..68c3de9906cc 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -1,6 +1,7 @@ import type { AgentSessionFastModeState } from '../../shared/agent-session-wire' import { currentModelId, + currentModelIsDefaulted, listedModels, matchListedModel, record, @@ -323,7 +324,8 @@ export function claudeStructuredSessionOptionsFrom( ...(effort ? { effort } : {}), ...(fastMode !== undefined ? { fastMode } : {}), ...(session.fastModeState ? { fastModeState: session.fastModeState } : {}), - ...(confirmed.length > 0 ? { confirmed } : {}) + ...(confirmed.length > 0 ? { confirmed } : {}), + ...(currentModelIsDefaulted(current.id) ? { defaulted: ['model'] as const } : {}) }, ...(catalogListing ? { catalogListing } : {}) } diff --git a/src/main/codex/codex-structured-model-catalog.ts b/src/main/codex/codex-structured-model-catalog.ts index 87a51a0af10a..c0c37a5516df 100644 --- a/src/main/codex/codex-structured-model-catalog.ts +++ b/src/main/codex/codex-structured-model-catalog.ts @@ -171,7 +171,9 @@ export function composeCodexSessionOptionCatalog( ...(fastMode !== undefined ? { fastMode } : {}), ...(reportedTier !== undefined && input.current.serviceTier === undefined ? { confirmed: fastMode === undefined ? ['serviceTier'] : ['serviceTier', 'fastMode'] } - : {}) + : {}), + // The listing's default stands in for a thread that reported no model of its own. + ...(input.current.model ? {} : { defaulted: ['model'] as const }) } } } diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index 9898029e14da..0b88da5f55d5 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -332,6 +332,10 @@ describe('structured Codex session options', () => { serviceTier: 'unrecognized-tier', confirmed: ['serviceTier'] }) + // A thread that reported no model runs the listing's default, which the chat never chose. + await expect( + readCodexStructuredSessionOptions({ connection, current: {} }) + ).resolves.toMatchObject({ current: { model: 'gpt-live', defaulted: ['model'] } }) }) it('hides and rejects Fast mode when the running catalog does not advertise it', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index 670d6903543d..1fbd8f2e9aef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -64,16 +64,21 @@ describe('options at rest', () => { const current = await readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) - expect(current.current).toEqual({ permissionMode: 'ask' }) + expect(current.current).toEqual({ defaulted: [], permissionMode: 'ask' }) expect(read).toHaveBeenCalledTimes(1) const older = await readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: false }) - expect(older.current).toEqual({ model: 'gpt-account', permissionMode: 'ask' }) + // The account default an older client needs is marked as one, which that client ignores. + expect(older.current).toEqual({ + model: 'gpt-account', + defaulted: ['model'], + permissionMode: 'ask' + }) expect(read).toHaveBeenLastCalledWith({ agent: 'codex', sessionId: SESSION, savedOnly: true }) }) - it("names the saved listing's ids for a live child with no listing of its own, as at rest", async () => { + it("names the saved listing's ids and model for a live child that fell back to its default", async () => { const record = { ...restingRecord(), options: { model: 'gpt-unlisted', effort: 'high' } } const listed = { id: 'gpt-5.5', label: 'GPT-5.5', isDefault: true, efforts: [] } let live = false @@ -88,7 +93,7 @@ describe('options at rest', () => { adapter: { readOptions: async () => ({ models: [listed, { ...listed, id: 'gpt-unlisted', isDefault: false }], - current: { model: 'gpt-unlisted', effort: 'high' } + current: { model: 'gpt-5.5', effort: 'high', defaulted: ['model'] } }) }, agents: NO_STRUCTURED_AGENTS, @@ -103,8 +108,11 @@ describe('options at rest', () => { readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) const resting = await read() live = true - expect((await read()).listedModelIds).toEqual(resting.listedModelIds) + const running = await read() + expect(running.listedModelIds).toEqual(resting.listedModelIds) expect(resting.listedModelIds).toEqual(['gpt-5.5']) + // The saved model shows over the child's fallback, and is the chat's own. + expect(running.current).toMatchObject({ model: 'gpt-unlisted', defaulted: [] }) }) it.each(['codex', 'omp', 'grok', 'opencode'] as const)( @@ -151,6 +159,7 @@ describe('options at rest', () => { expect((await read()).current).toEqual({ model: 'later-model', effort: 'off', + defaulted: [], ...(provider === 'codex' ? { permissionMode: 'ask' } : {}) }) for (const emptyStart of [false, true]) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 47bb3ed005ae..3a43cdb57d3d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -142,6 +142,9 @@ async function readStructuredAgentSessionOptionsAtRest( (rules?.effortDefaultsToModel || (saved.model === undefined && folderDefault) ? models.find((entry) => entry.id === model)?.defaultEffort : undefined) + const defaulted = (['model', 'effort'] as const).filter( + (key) => saved[key] === undefined && Boolean(key === 'model' ? model : effort) + ) return { models: listed ? structuredAgentSessionOptionModels(listed, model, (row) => row) : [], ...(listedModelIds ? { listedModelIds } : {}), @@ -152,7 +155,8 @@ async function readStructuredAgentSessionOptionsAtRest( ...(model ? { model } : {}), ...(effort ? { effort } : {}), ...(typeof fastMode === 'boolean' ? { fastMode } : {}), - ...(saved.serviceTier ? { serviceTier: saved.serviceTier } : {}) + ...(saved.serviceTier ? { serviceTier: saved.serviceTier } : {}), + defaulted } } } @@ -281,6 +285,8 @@ export async function readStructuredAgentSessionOptions( ...(shownSaved('model') ? { model: selected.current.model } : {}), ...(shownSaved('effort') ? { effort: selected.current.effort } : {}) } + // A saved value shown over the child's is the chat's own, never the default the child fell back to. + const defaulted = (options.current.defaulted ?? []).filter((key) => !(live && shownSaved(key))) const confirmed = current.confirmed?.filter((key) => key === 'model' ? current.model === options.current.model @@ -290,6 +296,7 @@ export async function readStructuredAgentSessionOptions( ...options, current: { ...current, + defaulted, ...(confirmed ? { confirmed } : {}), ...(permissionMode ? { permissionMode } : {}) }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts index 3998903e9b32..c8a814592d51 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts @@ -129,6 +129,7 @@ describe('a registered definition', () => { expect(options.current).toEqual({ model: 'pilot-model', effort: 'low', + defaulted: ['effort'], permissionMode: 'ask' }) expect(options.threadGoal).toEqual({ current: null }) @@ -152,7 +153,7 @@ describe('a registered definition', () => { expect(refused.persistOptions).not.toHaveBeenCalled() const options = await readAtRest(agents) expect(options.models).toEqual([]) - expect(options.current).toEqual({ model: 'pilot-model' }) + expect(options.current).toEqual({ model: 'pilot-model', defaulted: [] }) expect(options.conversationCommands).toEqual(['clear']) expect(options.acceptsImages).toBe(false) }) diff --git a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts index 5b60a65599a9..3af71403b4ea 100644 --- a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts +++ b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts @@ -263,7 +263,12 @@ describe("a new chat's effort pill before its agent starts", () => { expect(first.model?.value).toBe(model) expect(first.effort?.value).toBe(effort) expect(settled).toEqual(first) - expect(atRest.current).toEqual({ model, effort, ...modeOf(agent) }) + expect(atRest.current).toEqual({ + model, + effort, + defaulted: ['model', 'effort'], + ...modeOf(agent) + }) } ) @@ -280,7 +285,7 @@ describe("a new chat's effort pill before its agent starts", () => { expect(first.model?.value).toBeUndefined() expect(first.effort).toBeNull() expect(settled).toEqual(first) - expect(atRest.current).toEqual(modeOf(agent)) + expect(atRest.current).toEqual({ defaulted: [], ...modeOf(agent) }) } ) @@ -291,26 +296,44 @@ describe("a new chat's effort pill before its agent starts", () => { }) // The account default is not what this repo runs, so its effort is never claimed. - expect(atRest.current).toEqual({ model: 'gpt-5.5', permissionMode: 'ask' }) + expect(atRest.current).toEqual({ + model: 'gpt-5.5', + defaulted: ['model'], + permissionMode: 'ask' + }) expect(atRest.conversationCommands).toEqual(['clear', 'compact']) }) it('names the folder’s default effort for a client that needs a model too', async () => { const { atRest } = await newChatFrames('codex', { readsWithoutModel: false }) - expect(atRest.current).toEqual({ model: 'gpt-5.5', effort: 'medium', permissionMode: 'ask' }) + expect(atRest.current).toEqual({ + model: 'gpt-5.5', + effort: 'medium', + defaulted: ['model', 'effort'], + permissionMode: 'ask' + }) }) it('keeps a saved effort over the default model’s', async () => { const { atRest, settled } = await newChatFrames('grok', { saved: { effort: 'high' } }) - expect(atRest.current).toEqual({ model: 'grok-4', effort: 'high', ...modeOf('grok') }) + expect(atRest.current).toEqual({ + model: 'grok-4', + effort: 'high', + defaulted: ['model'], + ...modeOf('grok') + }) expect(settled.effort?.value).toBe('high') }) it('leaves the effort of a saved model to the agent, as its running child reports it', async () => { const { atRest } = await newChatFrames('codex', { saved: { model: 'gpt-5.5-mini' } }) - expect(atRest.current).toEqual({ model: 'gpt-5.5-mini', permissionMode: 'ask' }) + expect(atRest.current).toEqual({ + model: 'gpt-5.5-mini', + defaulted: [], + permissionMode: 'ask' + }) }) }) diff --git a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts index 1722720a7b3f..0ce998b72b3d 100644 --- a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts +++ b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts @@ -15,7 +15,7 @@ import type { RuntimeClientTarget } from '../../../../shared/runtime-client-targ import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { recordHostModelCatalogSnapshot } from '@/runtime/host-model-catalog-snapshots' import { structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability' -import type { NativeChatSessionOptionRecord } from '../../../../shared/native-chat-session-option-state' +import type { StructuredAgentSessionOptionRecord } from '../../../../shared/structured-agent-session-option-state' import { isHostModelListingWaitInFlight, joinHostModelListingWait @@ -61,7 +61,7 @@ export function useHostModelCatalogUpgrade(args: { providerRunning?: boolean /** A listing landed after the chat's options were read, which answered without it. */ onListingLanded?: () => void - activeOptionRecordRef: MutableRefObject + activeOptionRecordRef: MutableRefObject updateOptionState: ( update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState ) => void diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts index ae3de785b668..199e09c8bc4f 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts @@ -17,7 +17,7 @@ import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../../share import { useStructuredAgentSessionHostCapability } from '@/runtime/structured-agent-session-host-capability' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { readHostModelCatalogSnapshot } from '@/runtime/host-model-catalog-snapshots' -import { cloneNativeChatSessionOptionRecord } from '../../../../shared/native-chat-session-option-state' +import { cloneStructuredAgentSessionOptionRecord } from '../../../../shared/structured-agent-session-option-state' import { createCoalescedPollRunner, type CoalescedPollRunner @@ -130,7 +130,7 @@ export function useStructuredAgentSessionOptionState(args: { sameSession && previous.catalogSource !== 'seed' && previous.catalogSource !== null ? { ...previous, - record: cloneNativeChatSessionOptionRecord(previous.record), + record: cloneStructuredAgentSessionOptionRecord(previous.record), pendingId: null } : seeded diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.test.tsx index 9068f35fac70..457571430597 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.test.tsx @@ -70,6 +70,10 @@ describe('what each composer send carries', () => { }) }) + it('carries a pick on its way without the default model the picker shows', () => { + expect(sendOptionsFor(true, { effort: 'low' }, {})()).toEqual({ effort: 'low' }) + }) + it('carries nothing to an older host', () => { expect(sendOptionsFor(false, {})()).toBeUndefined() }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts index b17f735940aa..f49ec29deaee 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts @@ -1,11 +1,10 @@ import { useCallback } from 'react' -import { structuredAgentSessionOptionSnapshot } from '../../../../shared/structured-agent-session-options' import { structuredAgentSessionSendSelection } from '../../../../shared/structured-agent-session-option-view' import { getStructuredAgentSessionLaunchSelection } from '@/lib/structured-agent-session-launch-options' import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisional-launch' import type { useStructuredAgentSessionOptionState } from './use-structured-agent-session-option-state' -/** What each send carries to a host that takes it: the model and effort the composer shows now. */ +/** What each send carries to a host that takes it: the chat's own model and effort, picks over them. */ export function useStructuredAgentSessionSendOptions( sessionId: string, launch: StructuredAgentSessionLaunchView | undefined, @@ -26,9 +25,6 @@ export function useStructuredAgentSessionSendOptions( ...(selection?.held ?? launch?.heldOptions), ...pendingOptionsRef.current }) - return structuredAgentSessionSendSelection( - view.record.agent, - structuredAgentSessionOptionSnapshot(view) - ) + return structuredAgentSessionSendSelection(view) }, [launch, optionStateRef, optionView, pendingOptionsRef, sessionId, takesSendOptions]) } diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts index 53b8d2d46774..ab288ae73a81 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts @@ -10,7 +10,6 @@ import { structuredAgentSessionOptionView, structuredAgentSessionSendSelection } from '../../../shared/structured-agent-session-option-view' -import { structuredAgentSessionOptionSnapshot } from '../../../shared/structured-agent-session-options' import { firstFrameOptionState } from '@/components/native-chat/use-structured-agent-session-option-state' import { applyStructuredLaunchHeldOptions, @@ -50,8 +49,8 @@ export function prepareStructuredLaunchCreateMessage(state: StructuredLaunchStat notifyStructuredLaunchListeners() } -/** Picks held so far, and the model and effort the chat shows: a first message starts its turn - * on them, as a later send does. */ +/** Picks held so far, and the chat's own model and effort: a first message starts its turn on + * them, as a later send does. */ function structuredLaunchCreateOptions( state: StructuredLaunchState ): Readonly> | undefined { @@ -65,7 +64,7 @@ function structuredLaunchCreateOptions( const view = structuredAgentSessionOptionView(firstFrame, intent.seedOptions, selection.held) const options = { ...selection.held, - ...structuredAgentSessionSendSelection(intent.agent, structuredAgentSessionOptionSnapshot(view)) + ...structuredAgentSessionSendSelection(view) } return Object.keys(options).length ? options : undefined } diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts index 8d700ba9227f..b1fb1916bd54 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts @@ -242,6 +242,32 @@ describe('picks made while a chat launches', () => { expect(mutations().map((mutation) => mutation.value)).toEqual(['gpt-picked', 'gpt-repicked']) }) + it('keeps an effort held before a model pick; a pick supersedes only its own option', async () => { + const created = deferred<{ sessionId: string; fence: number }>() + mocks.launch.mockReturnValue(created.promise) + const launch = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-held' }) + const effortPick = holdStructuredAgentSessionLaunchOption(SESSION_ID, 'effort', 'high') + const firstModel = holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-a') + holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-b') + await expect(firstModel).resolves.toEqual({ kind: 'superseded' }) + expect(getStructuredAgentSessionLaunchSelection(SESSION_ID)?.held).toEqual({ + effort: 'high', + model: 'gpt-b' + }) + created.resolve({ sessionId: SESSION_ID, fence: 3 }) + await settle() + setOptionReplies[0]!.resolve(ACCEPTED) + await settle() + setOptionReplies[1]!.resolve(ACCEPTED) + await launch.launchResult + + expect(mutations().map(({ key, value }) => [key, value])).toEqual([ + ['model', 'gpt-b'], + ['effort', 'high'] + ]) + await expect(effortPick).resolves.toMatchObject({ kind: 'accepted' }) + }) + it('reports a refused pick to its picker and publishes all the same', async () => { mocks.launch.mockResolvedValue({ sessionId: SESSION_ID, fence: 1 }) const launch = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index 0b18e0c253ce..a96988ff3266 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -4,7 +4,6 @@ import type { AgentSessionOptionResult } from '../../../shared/agent-session-wire' import { STRUCTURED_LAUNCH_OPTION_IDS } from '../../../shared/native-chat-session-option-defaults' -import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../shared/agent-chat-permission-mode' import { createStructuredAgentSessionOperationId, structuredAgentSessionPayloadFingerprint @@ -69,18 +68,10 @@ export function holdStructuredAgentSessionLaunchOption( return null } const replies = repliesFor(state) - const held = { ...state.selection.held } - // A model pick drops picks held under the model it replaces; the permission mode is the chat's. - const dropped = - id === 'model' - ? Object.keys(held).filter((key) => key !== AGENT_CHAT_PERMISSION_MODE_OPTION_ID) - : [id] - for (const key of dropped) { - replies.get(key)?.({ kind: 'superseded' }) - replies.delete(key) - delete held[key] - } - state.selection = { ...state.selection, held: { ...held, [id]: encoded } } + // Each held value is the chat's own: a pick supersedes only an earlier pick of the same option. + replies.get(id)?.({ kind: 'superseded' }) + replies.delete(id) + state.selection = { ...state.selection, held: { ...state.selection.held, [id]: encoded } } notifyStructuredLaunchListeners() return new Promise((resolve) => replies.set(id, resolve)) } diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index d7c079a74877..7874951dea27 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -505,5 +505,8 @@ export type AgentSessionOptionsResult = { * treating the value as unconfirmed, which is what it was before. */ confirmed?: readonly string[] + /** Values a catalog default supplied, not the chat's intent or its provider's report. Absent + * from an older host, whose answer a client reads as it always has. */ + defaulted?: readonly ('model' | 'effort')[] } } diff --git a/src/shared/native-chat-session-option-snapshot.ts b/src/shared/native-chat-session-option-snapshot.ts index 76d123823cad..0127cfdfca19 100644 --- a/src/shared/native-chat-session-option-snapshot.ts +++ b/src/shared/native-chat-session-option-snapshot.ts @@ -8,7 +8,8 @@ import type { import type { NativeChatLiveOptionTransport, SessionOptionDescriptor, - SessionOptionSelectChoice + SessionOptionSelectChoice, + SessionOptionValueSource } from './native-chat-session-options' import { isFlipOnlyMidSession, @@ -174,7 +175,7 @@ export function sortNativeChatSessionOptions( export function withTrackedNativeChatModel( catalog: AgentSessionOptionCatalog, models: readonly CatalogModel[], - record: NativeChatSessionOptionRecord + record: Pick ): CatalogModel[] { const trackedId = typeof record.model?.value === 'string' ? record.model.value : null if (!trackedId || models.some((model) => model.id === trackedId)) { @@ -208,21 +209,27 @@ export function resolveEffectiveNativeChatModelId( return trackedModelId ?? cliDefaultModelId(catalog, models, trackedModelId) } -export function buildNativeChatSessionOptionSnapshot(args: { +/** The model the trigger names, and where that came from; a null id names none. */ +export type NativeChatShownModel = { + currentId: string | null + valueSource: SessionOptionValueSource + tracked: TrackedNativeChatSessionOption | undefined +} + +/** The model descriptor, then the controls that apply, each showing the chat's value for it. + * Values and controls arrive apart, so a caller decides which model's controls apply. */ +export function nativeChatSessionOptionDescriptors(args: { catalog: AgentSessionOptionCatalog models: readonly CatalogModel[] - record: NativeChatSessionOptionRecord mode: NativeChatSessionOptionMode modelLabel: string - /** Required, not defaulted: this is the only place a descriptor is built, so a - * producer that must state its lane here cannot silently inherit the other's. */ liveTransport: NativeChatLiveOptionTransport + model: NativeChatShownModel + controls: readonly CatalogOption[] + values: Readonly> + modelIsCliDefault: boolean }): SessionOptionDescriptor[] { - const { catalog, models, record, mode, modelLabel, liveTransport } = args - if (models.length === 0) { - return [] - } - const modelTracked = record.model + const { catalog, models, mode, modelLabel, liveTransport, model } = args // Why: callers reconcile the tracked model into `models` (see // withTrackedNativeChatModel), so every listed row is a real choice and the // trigger never shows a value without one. @@ -231,10 +238,7 @@ export function buildNativeChatSessionOptionSnapshot(args: { label, ...(description ? { description } : {}) })) - const trackedModelId = typeof modelTracked?.value === 'string' ? modelTracked.value : null - const defaultModelId = cliDefaultModelId(catalog, models, trackedModelId) - const effectiveModelId = trackedModelId ?? defaultModelId - const modelAction = actionForApply(catalog.modelApply, modelTracked, mode, liveTransport) + const modelAction = actionForApply(catalog.modelApply, model.tracked, mode, liveTransport) const snapshot: SessionOptionDescriptor[] = [ { id: 'model', @@ -242,27 +246,22 @@ export function buildNativeChatSessionOptionSnapshot(args: { category: 'model', kind: { type: 'select', - ...(effectiveModelId ? { currentValue: effectiveModelId } : {}), + ...(model.currentId ? { currentValue: model.currentId } : {}), choices: modelChoices }, - valueSource: modelTracked?.source ?? (defaultModelId ? 'default' : 'unknown'), + valueSource: model.valueSource, transport: liveTransport, ...settableState({ mode, liveTransport, apply: catalog.modelApply }), ...(modelAction ? { action: modelAction } : {}) } ] - if (!effectiveModelId) { - return snapshot - } - const model = models.find((candidate) => candidate.id === effectiveModelId) - const trackedValues = record.valuesByModel[effectiveModelId] ?? {} - for (const option of model?.options ?? []) { + for (const option of args.controls) { const descriptor = optionDescriptor({ option, - tracked: trackedValues[option.id], + tracked: args.values[option.id], mode, liveTransport, - modelIsCliDefault: effectiveModelId === defaultModelId, + modelIsCliDefault: args.modelIsCliDefault, composedModelApply: catalog.modelApply }) if (descriptor) { @@ -271,3 +270,35 @@ export function buildNativeChatSessionOptionSnapshot(args: { } return snapshot } + +/** A terminal chat's picker, whose values are remembered per model. */ +export function buildNativeChatSessionOptionSnapshot(args: { + catalog: AgentSessionOptionCatalog + models: readonly CatalogModel[] + record: NativeChatSessionOptionRecord + mode: NativeChatSessionOptionMode + modelLabel: string + /** Required, not defaulted: this is the only place a descriptor is built, so a + * producer that must state its lane here cannot silently inherit the other's. */ + liveTransport: NativeChatLiveOptionTransport +}): SessionOptionDescriptor[] { + const { catalog, models, record } = args + if (models.length === 0) { + return [] + } + const trackedModelId = typeof record.model?.value === 'string' ? record.model.value : null + const defaultModelId = cliDefaultModelId(catalog, models, trackedModelId) + const effectiveModelId = trackedModelId ?? defaultModelId + const model = models.find((candidate) => candidate.id === effectiveModelId) + return nativeChatSessionOptionDescriptors({ + ...args, + model: { + currentId: effectiveModelId, + valueSource: record.model?.source ?? (defaultModelId ? 'default' : 'unknown'), + tracked: record.model + }, + controls: effectiveModelId ? (model?.options ?? []) : [], + values: effectiveModelId ? (record.valuesByModel[effectiveModelId] ?? {}) : {}, + modelIsCliDefault: effectiveModelId === defaultModelId + }) +} diff --git a/src/shared/structured-agent-session-discovered-model.ts b/src/shared/structured-agent-session-discovered-model.ts index 6878f4d0ae96..6419e85e9cb5 100644 --- a/src/shared/structured-agent-session-discovered-model.ts +++ b/src/shared/structured-agent-session-discovered-model.ts @@ -32,7 +32,7 @@ function fastModeOption(): CatalogOption { } /** Standard plus the tiers the model lists, valued by tier id; one choice in place of Fast. */ -function serviceTierOption(tiers: readonly AgentSessionOptionChoice[]): CatalogOption { +export function serviceTierOption(tiers: readonly AgentSessionOptionChoice[]): CatalogOption { return { id: 'serviceTier', label: 'Speed', diff --git a/src/shared/structured-agent-session-first-frame-picker.spec.ts b/src/shared/structured-agent-session-first-frame-picker.spec.ts index feda0e82c383..dbdd90881ed4 100644 --- a/src/shared/structured-agent-session-first-frame-picker.spec.ts +++ b/src/shared/structured-agent-session-first-frame-picker.spec.ts @@ -66,7 +66,7 @@ describe('the first frame of every agent’s picker', () => { expect(canSetStructuredAgentSessionOption(state, 'model', 'anything')).toBe(false) } else { // A built-in list is presentation only: no value is tracked from it. - expect(state.record.model).toBeUndefined() + expect(state.record.values.model).toBeUndefined() } } } @@ -85,7 +85,7 @@ describe('the first frame of every agent’s picker', () => { model?.kind.type === 'select' && model.kind.choices.map((choice) => choice.value) ).toEqual(['model-listed']) // Listed, not picked: nothing reads as a value the chat confirmed or will launch with. - expect(state.record.model).toBeUndefined() + expect(state.record.values.model).toBeUndefined() } ) @@ -105,7 +105,7 @@ describe('the first frame of every agent’s picker', () => { expect(currentValue(snapshot, 'effort')).toBe('high') // Shown as the default it runs, never as a pick the next launch would replay. expect(snapshot.find((descriptor) => descriptor.id === 'model')?.valueSource).toBe('default') - expect(state.record.model).toBeUndefined() + expect(state.record.values.model).toBeUndefined() // A reopened chat may run a model picked in it, so it names none. const reopened = firstFrame( agent, diff --git a/src/shared/structured-agent-session-option-snapshot.ts b/src/shared/structured-agent-session-option-snapshot.ts new file mode 100644 index 000000000000..21e7f3c75607 --- /dev/null +++ b/src/shared/structured-agent-session-option-snapshot.ts @@ -0,0 +1,159 @@ +// A structured chat's picker: the chat's own values, drawn under the controls of the model they apply to. + +import type { + AgentSessionOptionCatalog, + CatalogModel, + CatalogOption +} from './agent-session-option-catalog' +import { + nativeChatSessionOptionDescriptors, + withTrackedNativeChatModel, + type NativeChatShownModel +} from './native-chat-session-option-snapshot' +import type { SessionOptionDescriptor, SessionOptionValue } from './native-chat-session-options' +import { serviceTierOption } from './structured-agent-session-discovered-model' +import { + structuredAgentSessionModelId, + type StructuredAgentSessionOptionRecord +} from './structured-agent-session-option-state' +import { withUnlistedEffort } from './structured-agent-session-unlisted-effort' +import { structuredAgentSessionPermissionDescriptor } from './structured-agent-session-permission-option' +import type { StructuredAgentSessionOptionState } from './structured-agent-session-options' + +/** Before the host answers, or an agent with no list reports, the quiet model pill: never + * pickable, so displaying it can never become a launch pick. */ +const PROVIDER_DEFAULT_MODEL_PLACEHOLDER: SessionOptionDescriptor = { + id: 'model', + label: 'Model', + category: 'model', + kind: { type: 'select', choices: [] }, + valueSource: 'unknown', + transport: 'agent-session', + settable: false, + disabledReason: 'available-after-session-start' +} + +/** Which model the trigger names, and which model's controls apply. A catalog default is named + * only where the host says this launch runs it; otherwise it stands in for controls alone. */ +export function structuredAgentSessionShownModel( + catalog: AgentSessionOptionCatalog, + models: readonly CatalogModel[], + record: StructuredAgentSessionOptionRecord +): { shown: NativeChatShownModel; controlsModelId: string | null; isCliDefault: boolean } { + const own = structuredAgentSessionModelId(record, 'own') + const supplied = structuredAgentSessionModelId(record, 'any') + const defaultId = + own || !catalog.defaultModelIsCliDefault + ? null + : (supplied ?? models.find((model) => model.isDefault)?.id ?? null) + const currentId = own ?? defaultId + return { + shown: { + currentId, + valueSource: own + ? (record.values.model?.source ?? 'unknown') + : defaultId + ? 'default' + : 'unknown', + tracked: own ? record.values.model : undefined + }, + controlsModelId: currentId ?? supplied, + isCliDefault: currentId !== null && currentId === defaultId + } +} + +/** With no model known, a saved speed other than Standard still shows, and Standard turns it off. */ +function modellessControls( + catalog: AgentSessionOptionCatalog, + record: StructuredAgentSessionOptionRecord +): CatalogOption[] { + const tier = record.values.serviceTier?.value + if (typeof tier !== 'string' || tier === 'default') { + return [] + } + const named = catalog.models + .flatMap((model) => model.options) + .flatMap((option) => + option.id === 'serviceTier' && option.kind.type === 'select' ? option.kind.choices : [] + ) + .find((choice) => choice.value === tier) + return [serviceTierOption([named ?? { value: tier, label: tier }])] +} + +function modelOptionSnapshot(state: StructuredAgentSessionOptionState): SessionOptionDescriptor[] { + const { catalog, record } = state + if (!catalog) { + return [] + } + // Until the host answers, the built-in list would paint a label its list may replace. + if (state.catalogSource === 'seed') { + return [PROVIDER_DEFAULT_MODEL_PLACEHOLDER] + } + // A seeded default can name a model the static seed does not list yet. + const models = withTrackedNativeChatModel(catalog, catalog.models, { model: record.values.model }) + const { shown, controlsModelId, isCliDefault } = structuredAgentSessionShownModel( + catalog, + models, + record + ) + const snapshot = + models.length === 0 + ? [] + : nativeChatSessionOptionDescriptors({ + catalog, + models, + mode: 'live', + modelLabel: 'Model', + liveTransport: 'agent-session', + model: shown, + controls: controlsModelId + ? (models.find((model) => model.id === controlsModelId)?.options ?? []) + : modellessControls(catalog, record), + values: record.values, + modelIsCliDefault: isCliDefault + }) + return snapshot.length > 0 || state.catalogSource === 'live' + ? withUnlistedEffort( + catalog, + controlsModelId, + record.values.effort, + state.verifiedModelIds, + snapshot + ) + : [PROVIDER_DEFAULT_MODEL_PLACEHOLDER] +} + +export function structuredAgentSessionOptionSnapshot( + state: StructuredAgentSessionOptionState +): SessionOptionDescriptor[] { + const permission = structuredAgentSessionPermissionDescriptor(state) + const options = modelOptionSnapshot(state) + return permission ? [...options, permission] : options +} + +/** No launch holds a pick and no fence can carry one yet, so the picker only shows. */ +export function lockedStructuredAgentSessionOptionSnapshot( + snapshot: readonly SessionOptionDescriptor[] +): SessionOptionDescriptor[] { + return snapshot.map((descriptor) => ({ + ...descriptor, + settable: false, + disabledReason: 'available-after-session-start' + })) +} + +export function canSetStructuredAgentSessionOption( + state: StructuredAgentSessionOptionState, + id: string, + value: SessionOptionValue +): boolean { + const descriptor = structuredAgentSessionOptionSnapshot(state).find((entry) => entry.id === id) + return Boolean( + state.catalog && + state.pendingId !== id && + ((typeof value === 'string' && + descriptor?.kind.type === 'select' && + descriptor.kind.choices.some((choice) => choice.value === value)) || + (typeof value === 'boolean' && descriptor?.kind.type === 'boolean')) + ) +} diff --git a/src/shared/structured-agent-session-option-state.ts b/src/shared/structured-agent-session-option-state.ts new file mode 100644 index 000000000000..c53a2231d291 --- /dev/null +++ b/src/shared/structured-agent-session-option-state.ts @@ -0,0 +1,70 @@ +import type { AgentType } from './agent-status-types' +import type { SessionOptionValue } from './native-chat-session-options' +import type { TrackedNativeChatSessionOption } from './native-chat-session-option-state' + +/** A structured chat's own option values, each independent of the others, as the host keeps them: + * an effort or speed needs no model to live under. Mutated in place, so its identity is the + * session's for as long as the picker's fences check it. */ +export type StructuredAgentSessionOptionRecord = { + agent: AgentType + values: Record +} + +/** The values a complete options answer speaks for. */ +const ANSWERED_VALUE_IDS = ['model', 'effort', 'fastMode', 'serviceTier'] as const + +export function createStructuredAgentSessionOptionRecord( + agent: AgentType +): StructuredAgentSessionOptionRecord { + return { agent, values: {} } +} + +export function cloneStructuredAgentSessionOptionRecord( + record: StructuredAgentSessionOptionRecord +): StructuredAgentSessionOptionRecord { + return { + agent: record.agent, + values: Object.fromEntries( + Object.entries(record.values).map(([id, tracked]) => [id, { ...tracked }]) + ) + } +} + +/** The chat's model id, its own or (with `any`) one a catalog supplied in its place. */ +export function structuredAgentSessionModelId( + record: StructuredAgentSessionOptionRecord, + provenance: 'own' | 'any' +): string | null { + const tracked = record.values.model + return typeof tracked?.value === 'string' && + (provenance === 'any' || tracked.source !== 'default') + ? tracked.value + : null +} + +/** A value the chat holds as its own; a catalog default only fills the picker in. */ +export function structuredAgentSessionOwnValue( + record: StructuredAgentSessionOptionRecord, + id: string +): string | undefined { + const tracked = record.values[id] + return tracked?.source !== 'default' && typeof tracked?.value === 'string' && tracked.value + ? tracked.value + : undefined +} + +/** A complete answer replaces each value it speaks for; one naming no model leaves the model. */ +export function replaceStructuredAgentSessionAnsweredValues( + record: StructuredAgentSessionOptionRecord, + answered: Readonly>>, + sourceFor: (id: string) => TrackedNativeChatSessionOption['source'] +): void { + for (const id of ANSWERED_VALUE_IDS) { + const value = answered[id] + if (value !== undefined) { + record.values[id] = { value, source: sourceFor(id) } + } else if (id !== 'model') { + delete record.values[id] + } + } +} diff --git a/src/shared/structured-agent-session-option-view.test.ts b/src/shared/structured-agent-session-option-view.test.ts index 9264c45bc4e1..46e0337c182f 100644 --- a/src/shared/structured-agent-session-option-view.test.ts +++ b/src/shared/structured-agent-session-option-view.test.ts @@ -4,6 +4,7 @@ import type { AgentSessionModelCatalogResult } from './agent-session-wire' import { applyStructuredAgentSessionModelCatalog, applyStructuredAgentSessionOptions, + commitStructuredAgentSessionOption, createStructuredAgentSessionOptionState, structuredAgentSessionOptionSnapshot, type StructuredAgentSessionOptionState @@ -55,11 +56,7 @@ const carried = ( state: StructuredAgentSessionOptionState, seed?: Readonly>, picks: Readonly> = {} -) => - structuredAgentSessionSendSelection( - state.record.agent, - structuredAgentSessionOptionSnapshot(structuredAgentSessionOptionView(state, seed, picks)) - ) +) => structuredAgentSessionSendSelection(structuredAgentSessionOptionView(state, seed, picks)) describe('a Claude model its CLI no longer lists', () => { it('stays the chat model through a refresh and every send, its effort read-only', () => { @@ -99,10 +96,11 @@ describe('a Claude model its CLI no longer lists', () => { }) describe('what a send carries', () => { - it('carries the default it shows when nothing names a model', () => { + it('carries no default it shows: the agent runs it anyway, and the chat never chose it', () => { const named = { ...SONNET, isDefault: true } const listing = { ...LISTED, models: [OPUS, named], listingNamesConfiguredModel: true } - expect(carried(withHostList(fresh(), listing))).toEqual({ model: 'sonnet' }) + expect(shown(withHostList(fresh(), listing), undefined, {}).model).toBe('sonnet') + expect(carried(withHostList(fresh(), listing))).toBeUndefined() }) it('carries a pick on its way as given', () => { @@ -123,3 +121,63 @@ describe('what a send carries', () => { expect(carried(codex)).toBeUndefined() }) }) + +describe("a chat's model and effort, apart from the defaults filling them in", () => { + const listed = CLI302.map((row) => row.id) + // A resumed chat at rest that never picked a model: its host fills in the listing's default. + const resumed = (defaulted?: readonly ('model' | 'effort')[]) => + applyStructuredAgentSessionOptions( + applyStructuredAgentSessionModelCatalog( + fresh(), + CLAUDE_SESSION_OPTION_CATALOG, + { ...LISTED, models: CLI302 }, + { newLaunch: false } + ), + CLAUDE_SESSION_OPTION_CATALOG, + { + models: CLI302, + listedModelIds: listed, + current: { model: 'opus', effort: 'low', ...(defaulted ? { defaulted } : {}) } + } + ) + + it("never names or sends a default as a resumed chat's own, and keeps its picks through a restart", () => { + const state = resumed(['model', 'effort']) + expect(shown(state, undefined, {})).toEqual({ model: undefined, effort: 'low' }) + expect( + structuredAgentSessionShownSnapshot(state).find((entry) => entry.id === 'effort') + ).toMatchObject({ settable: true, kind: { choices: levels('low', 'xhigh') } }) + expect(carried(state)).toBeUndefined() + const effort = commitStructuredAgentSessionOption(state, 'effort', 'xhigh') + expect(carried(effort)).toEqual({ effort: 'xhigh' }) + // A model pick leaves the effort for the host to reconcile. + const model = commitStructuredAgentSessionOption(effort, 'model', 'sonnet') + expect(carried(model)).toEqual({ model: 'sonnet', effort: 'xhigh' }) + const restarted = applyStructuredAgentSessionOptions(fresh(), CLAUDE_SESSION_OPTION_CATALOG, { + models: CLI302, + listedModelIds: listed, + current: { model: 'sonnet', effort: 'high', defaulted: [] } + }) + expect(shown(restarted, undefined, {})).toEqual({ model: 'sonnet', effort: 'high' }) + expect(carried(restarted)).toEqual({ model: 'sonnet', effort: 'high' }) + }) + + it("reads an older host's answer, which marks no default, as it always has", () => { + const state = resumed() + expect(shown(state, undefined, {})).toEqual({ model: 'opus', effort: 'low' }) + expect(carried(state)).toEqual({ model: 'opus', effort: 'low' }) + }) + + it('lets a new chat pick its effort before its first message, under the default it shows', () => { + const state = withHostList(fresh(), { + ...LISTED, + models: CLI302, + listingNamesConfiguredModel: true + }) + expect( + structuredAgentSessionShownSnapshot(state).find((entry) => entry.id === 'effort') + ).toMatchObject({ settable: true, kind: { choices: levels('low', 'xhigh') } }) + expect(shown(state, undefined, { effort: 'xhigh' })).toEqual({ model: 'opus', effort: 'xhigh' }) + expect(carried(state, undefined, { effort: 'xhigh' })).toEqual({ effort: 'xhigh' }) + }) +}) diff --git a/src/shared/structured-agent-session-option-view.ts b/src/shared/structured-agent-session-option-view.ts index e1c350d72bd0..ca900b02b5ca 100644 --- a/src/shared/structured-agent-session-option-view.ts +++ b/src/shared/structured-agent-session-option-view.ts @@ -4,7 +4,11 @@ import { type StructuredAgentSessionOptionState } from './structured-agent-session-options' import type { SessionOptionDescriptor } from './native-chat-session-options' -import { cloneNativeChatSessionOptionRecord } from './native-chat-session-option-state' +import { + cloneStructuredAgentSessionOptionRecord, + structuredAgentSessionModelId, + structuredAgentSessionOwnValue +} from './structured-agent-session-option-state' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from './agent-chat-permission-mode' function catalogListsSeedModel( @@ -18,7 +22,7 @@ function catalogListsSeedModel( /** * What the picker shows before the host has confirmed this session's values: * `seed` (the selection a launch seeds) stands in until the record names a - * model, and `held` picks outrank both until the host settles them. All show as `dispatched`; + * model of the chat's own, and `held` picks outrank both until the host settles them. All show as `dispatched`; * derived on every read, never written into the record. A model the list lacks is kept: a list * is no evidence the chat's own model cannot run. * @@ -50,7 +54,8 @@ function modelOptionView( seed: Readonly> | undefined, held: Readonly> ): StructuredAgentSessionOptionState { - const unconfirmedSeed = seed !== undefined && state.record.model === undefined + const unconfirmedSeed = + seed !== undefined && structuredAgentSessionModelId(state.record, 'own') === null const hasHeld = Object.keys(held).length > 0 // A saved pick the host's list does not name would show its raw id or another model's label. if ( @@ -71,7 +76,7 @@ function modelOptionView( } let view: StructuredAgentSessionOptionState = { ...state, - record: cloneNativeChatSessionOptionRecord(state.record) + record: cloneStructuredAgentSessionOptionRecord(state.record) } if (seeded) { view = commitStructuredAgentSessionOptionValues(view, seed) @@ -79,19 +84,17 @@ function modelOptionView( return { ...commitStructuredAgentSessionOptionValues(view, held), pendingId: state.pendingId } } -/** The Claude model and effort the composer shows (`snapshot`), which every send carries. */ +/** The Claude model and effort a send carries: the chat's own and the picks over them in `view`, + * never a catalog default, which the agent runs anyway and the chat would then claim as its own. */ export function structuredAgentSessionSendSelection( - agent: string, - snapshot: readonly SessionOptionDescriptor[] + view: StructuredAgentSessionOptionState ): Record | undefined { - if (agent !== 'claude') { + if (view.record.agent !== 'claude') { return undefined } - const shown = (id: string): string | undefined => { - const kind = snapshot.find((descriptor) => descriptor.id === id)?.kind - return kind?.type === 'select' ? kind.currentValue : undefined - } - const model = shown('model') - const effort = shown('effort') - return model ? { model, ...(effort ? { effort } : {}) } : undefined + const model = structuredAgentSessionOwnValue(view.record, 'model') + const effort = structuredAgentSessionOwnValue(view.record, 'effort') + return model || effort + ? { ...(model ? { model } : {}), ...(effort ? { effort } : {}) } + : undefined } diff --git a/src/shared/structured-agent-session-options.test.ts b/src/shared/structured-agent-session-options.test.ts index 661ba395be79..0442fb618aaf 100644 --- a/src/shared/structured-agent-session-options.test.ts +++ b/src/shared/structured-agent-session-options.test.ts @@ -3,9 +3,15 @@ import { OMP_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-omp' import { CODEX_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-claude-codex' import { buildNativeChatSessionOptionSnapshot } from './native-chat-session-option-snapshot' import { createNativeChatSessionOptionRecord } from './native-chat-session-option-state' +import type { + AgentSessionModelCatalogResult, + AgentSessionOptionsResult +} from './agent-session-wire' import { applyStructuredAgentSessionModelCatalog, applyStructuredAgentSessionOptions, + canSetStructuredAgentSessionOption, + commitStructuredAgentSessionOption, createStructuredAgentSessionOptionState, settleStructuredAgentSessionBuiltinCatalog, structuredAgentSessionOptionSnapshot, @@ -33,7 +39,7 @@ describe('structured agent session options', () => { ) expect(state.catalog?.models).toEqual([]) expect(structuredAgentSessionOptionSnapshot(state)).toEqual([]) - expect(state.record.model).toBeUndefined() + expect(state.record.values.model).toBeUndefined() const reported = applyStructuredAgentSessionOptions(state, OMP_SESSION_OPTION_CATALOG, { models: [{ id: 'reported-model', label: 'Reported Model', isDefault: true, efforts: [] }], @@ -190,7 +196,7 @@ describe('structured agent session options', () => { current: { model: 'account-model' } }) expect(structuredAgentSessionOptionSnapshot(absent).map(({ id }) => id)).toEqual(['model']) - expect(absent.record.valuesByModel['account-model']?.fastMode).toBeUndefined() + expect(absent.record.values.fastMode).toBeUndefined() }) it('offers one speed choice in place of the Fast toggle where the model lists speeds', () => { @@ -291,13 +297,89 @@ describe('structured agent session options', () => { expect(viewModel(cold, seed, {})).toBeUndefined() expect(viewModel(settleStructuredAgentSessionBuiltinCatalog(cold), seed, {})).toBeUndefined() // Derived only: the record itself never takes the seed. - expect(seeded.record.model).toBeUndefined() + expect(seeded.record.values.model).toBeUndefined() const live = applyStructuredAgentSessionOptions(seeded, CODEX_SESSION_OPTION_CATALOG, { models: [{ id: 'gpt-5.6-luna', label: 'GPT-5.6 Luna', isDefault: true, efforts: [] }], current: { model: 'gpt-5.6-luna', confirmed: ['model'] } }) expect(viewModel(live, seed, {})).toBe('gpt-5.6-luna') expect(viewModel(live, seed, { model: 'gpt-5.5' })).toBe('gpt-5.5') - expect(live.record.model?.value).toBe('gpt-5.6-luna') + expect(live.record.values.model?.value).toBe('gpt-5.6-luna') + }) + + it('keeps a saved Fast with no model known, Standard turning it off, through a model pick and a restart', () => { + const sol = { + id: 'gpt-6.1-sol', + label: 'GPT-6.1 Sol', + isDefault: false, + efforts: [], + serviceTiers: [{ value: 'priority', label: 'Fast' }] + } + // A resumed chat's at-rest answer, whose listing names no default. + const atRest = (current: AgentSessionOptionsResult['current']) => + applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('codex'), + CODEX_SESSION_OPTION_CATALOG, + { + models: [sol], + fastModeSupport: { supported: true }, + current: { ...current, defaulted: [] } + } + ) + const speed = (state: StructuredAgentSessionOptionState) => + structuredAgentSessionOptionSnapshot(state).find(({ id }) => id === 'serviceTier') + const fast = atRest({ serviceTier: 'priority' }) + expect(speed(fast)).toMatchObject({ + settable: true, + kind: { + currentValue: 'priority', + choices: [ + { value: 'default', label: 'Standard' }, + { value: 'priority', label: 'Fast' } + ] + } + }) + const standard = commitStructuredAgentSessionOption(fast, 'serviceTier', 'default') + // With no model, nothing says which other speed the chat can take. + expect(canSetStructuredAgentSessionOption(standard, 'serviceTier', 'priority')).toBe(false) + const picked = commitStructuredAgentSessionOption(standard, 'model', 'gpt-6.1-sol') + const restarted = atRest({ model: 'gpt-6.1-sol', serviceTier: 'default' }) + for (const state of [picked, restarted]) { + expect(speed(state)).toMatchObject({ settable: true, kind: { currentValue: 'default' } }) + expect(canSetStructuredAgentSessionOption(state, 'serviceTier', 'priority')).toBe(true) + } + }) + + it('names a default model only where the host says this launch runs it, in either order', () => { + const models = [{ id: 'gpt-5.5', label: 'GPT-5.5', isDefault: true, efforts: [] }] + const listing: AgentSessionModelCatalogResult = { origin: 'probe', fetchedAt: 1, models } + const answer: AgentSessionOptionsResult = { + models, + current: { model: 'gpt-5.5', defaulted: ['model'] } + } + const seed = CODEX_SESSION_OPTION_CATALOG + const bothOrders = (newLaunch: boolean) => { + const cold = () => createStructuredAgentSessionOptionState('codex', seed) + return [ + applyStructuredAgentSessionOptions( + applyStructuredAgentSessionModelCatalog(cold(), seed, listing, { newLaunch }), + seed, + answer + ), + applyStructuredAgentSessionModelCatalog( + applyStructuredAgentSessionOptions(cold(), seed, answer), + seed, + listing, + { newLaunch } + ) + ].map((state) => structuredAgentSessionOptionSnapshot(state)[0]) + } + const [newChat, newChatAnswerFirst] = bothOrders(true) + expect(newChatAnswerFirst).toEqual(newChat) + expect(newChat).toMatchObject({ valueSource: 'default', kind: { currentValue: 'gpt-5.5' } }) + const [resumed, resumedAnswerFirst] = bothOrders(false) + expect(resumedAnswerFirst).toEqual(resumed) + expect(resumed).toMatchObject({ valueSource: 'unknown' }) + expect(resumed?.kind).not.toHaveProperty('currentValue') }) }) diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index 1a619ff348f7..57f0dc2b9851 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -1,26 +1,17 @@ import type { AgentSessionOptionCatalog, CatalogModel } from './agent-session-option-catalog' import { discoveredModel } from './structured-agent-session-discovered-model' import { - buildNativeChatSessionOptionSnapshot, - resolveEffectiveNativeChatModelId, - withTrackedNativeChatModel -} from './native-chat-session-option-snapshot' -import { - applyNativeChatReportedSessionOptions, - clearTrackedSessionOption, - createNativeChatSessionOptionRecord, - getTrackedSessionOption, - setTrackedSessionOption, - type NativeChatSessionOptionRecord -} from './native-chat-session-option-state' + createStructuredAgentSessionOptionRecord, + replaceStructuredAgentSessionAnsweredValues, + type StructuredAgentSessionOptionRecord +} from './structured-agent-session-option-state' import { STRUCTURED_LAUNCH_OPTION_IDS, STRUCTURED_LAUNCH_SEED_OPTION_IDS } from './native-chat-session-option-defaults' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from './agent-chat-permission-mode' -import { withUnlistedEffort } from './structured-agent-session-unlisted-effort' -import { structuredAgentSessionPermissionDescriptor } from './structured-agent-session-permission-option' -import type { SessionOptionDescriptor, SessionOptionValue } from './native-chat-session-options' +import { structuredAgentSessionShownModel } from './structured-agent-session-option-snapshot' +import type { SessionOptionValue } from './native-chat-session-options' import type { AgentSessionModelCatalogResult, AgentSessionOptionsResult @@ -30,9 +21,17 @@ import { encodeStructuredAgentSessionOptionValue } from './structured-agent-session-option-codec' +export { + canSetStructuredAgentSessionOption, + lockedStructuredAgentSessionOptionSnapshot, + structuredAgentSessionOptionSnapshot +} from './structured-agent-session-option-snapshot' + export function structuredAgentSessionOptionCatalog( seed: AgentSessionOptionCatalog, - result: AgentSessionOptionsResult + result: AgentSessionOptionsResult, + /** Whether the host already said this launch runs its listed default. */ + namesDefault = false ): AgentSessionOptionCatalog { const models: CatalogModel[] = result.models.map((model) => discoveredModel(model, result.fastModeSupport?.supported === true) @@ -44,7 +43,11 @@ export function structuredAgentSessionOptionCatalog( options: seed.unknownModelOptions ?? [] }) } - return { ...seed, models, defaultModelIsCliDefault: true } + // An answer that marks its defaults names one only where the host's list says this launch runs + // it; an older answer's model was always named. + return result.current.defaulted === undefined || namesDefault + ? { ...seed, models, defaultModelIsCliDefault: true } + : { ...seed, models } } export type StructuredAgentSessionOptionState = { @@ -56,7 +59,7 @@ export type StructuredAgentSessionOptionState = { /** The rows of a live answer its agent's own listing names; a row outside them is the chat's * own model, which no list describes. Absent from an older host. */ verifiedModelIds?: readonly string[] - record: NativeChatSessionOptionRecord + record: StructuredAgentSessionOptionRecord /** The permission mode the host or launch last stated; null shows no picker. */ permissionMode: string | null pendingId: string | null @@ -71,7 +74,7 @@ export function createStructuredAgentSessionOptionState( return { catalog: seedCatalog ?? null, catalogSource: seedCatalog ? 'seed' : null, - record: createNativeChatSessionOptionRecord(agent), + record: createStructuredAgentSessionOptionRecord(agent), permissionMode: null, pendingId: null } @@ -120,23 +123,27 @@ export function applyStructuredAgentSessionModelCatalog( catalog: AgentSessionModelCatalogResult, options: { newLaunch: boolean } ): StructuredAgentSessionOptionState { - if (state.catalogSource === 'live') { - return state - } const models = catalog.origin === 'unknown' ? [] : catalog.models.map((model) => discoveredModel(model, catalog.fastModeSupport?.supported === true) ) - if (catalog.origin === 'unknown' || models.length === 0) { - return settleStructuredAgentSessionBuiltinCatalog(state) - } // The host says whether its listed default is what this launch runs; an older host never says, // so the client's own knowledge of the agent stands in. A reopened chat may keep its own model. const namesDefault = + models.length > 0 && options.newLaunch && (catalog.listingNamesConfiguredModel ?? seed.hostListingNamesConfiguredModel === true) + if (state.catalogSource === 'live') { + // The session's list stands; only the host's word on its default joins it, in either order. + return namesDefault && state.catalog && !state.catalog.defaultModelIsCliDefault + ? { ...state, catalog: { ...state.catalog, defaultModelIsCliDefault: true } } + : state + } + if (models.length === 0) { + return settleStructuredAgentSessionBuiltinCatalog(state) + } return { ...state, // `isDefault` came from a real listing, so a launch's CLI default is nameable — @@ -155,99 +162,44 @@ export function applyStructuredAgentSessionOptions( seed: AgentSessionOptionCatalog, result: AgentSessionOptionsResult ): StructuredAgentSessionOptionState { - if (result.current.fastMode === undefined) { - clearTrackedSessionOption(state.record, result.current.model ?? null, 'fastMode') - } - if (result.current.serviceTier === undefined) { - clearTrackedSessionOption(state.record, result.current.model ?? null, 'serviceTier') - } - applyNativeChatReportedSessionOptions( + const { current } = result + // A value the host supplied from a catalog default is context for the picker, not the chat's own. + replaceStructuredAgentSessionAnsweredValues( state.record, { - ...(result.current.model ? { model: result.current.model } : {}), - ...(result.current.effort ? { effort: result.current.effort } : {}), - ...(result.current.fastMode !== undefined ? { fastMode: result.current.fastMode } : {}), - ...(result.current.serviceTier ? { serviceTier: result.current.serviceTier } : {}) + ...(current.model ? { model: current.model } : {}), + ...(current.effort ? { effort: current.effort } : {}), + ...(current.fastMode !== undefined ? { fastMode: current.fastMode } : {}), + ...(current.serviceTier ? { serviceTier: current.serviceTier } : {}) }, - result.current.confirmed ?? [] + (id) => + current.defaulted?.some((key) => key === id) + ? 'default' + : current.confirmed?.includes(id) + ? 'reported' + : 'dispatched' ) return { ...state, - catalog: structuredAgentSessionOptionCatalog(seed, result), + catalog: structuredAgentSessionOptionCatalog( + seed, + result, + state.catalog?.defaultModelIsCliDefault === true + ), catalogSource: 'live', verifiedModelIds: result.listedModelIds?.length ? result.listedModelIds : undefined, permissionMode: result.current.permissionMode ?? null } } -/** Before the host answers, or an agent with no list reports, the quiet model pill: never - * pickable, so displaying it can never become a launch pick. */ -const PROVIDER_DEFAULT_MODEL_PLACEHOLDER: SessionOptionDescriptor = { - id: 'model', - label: 'Model', - category: 'model', - kind: { type: 'select', choices: [] }, - valueSource: 'unknown', - transport: 'agent-session', - settable: false, - disabledReason: 'available-after-session-start' -} - -export function structuredAgentSessionOptionSnapshot( +/** The model a pick's other values were chosen under: the chat's own, else the default shown. */ +function structuredAgentSessionControlsModelId( state: StructuredAgentSessionOptionState -): SessionOptionDescriptor[] { - const permission = structuredAgentSessionPermissionDescriptor(state) - const options = modelOptionSnapshot(state) - return permission ? [...options, permission] : options -} - -function modelOptionSnapshot(state: StructuredAgentSessionOptionState): SessionOptionDescriptor[] { - if (!state.catalog) { - return [] - } - // Until the host answers, the built-in list would paint a label its list may replace. - if (state.catalogSource === 'seed') { - return [PROVIDER_DEFAULT_MODEL_PLACEHOLDER] - } - const snapshot = buildNativeChatSessionOptionSnapshot({ - catalog: state.catalog, - // A seeded default can name a model the static seed does not list yet. - models: withTrackedNativeChatModel(state.catalog, state.catalog.models, state.record), - record: state.record, - mode: 'live', - modelLabel: 'Model', - liveTransport: 'agent-session' - }) - return snapshot.length > 0 || state.catalogSource === 'live' - ? withUnlistedEffort(state.catalog, state.record, state.verifiedModelIds, snapshot) - : [PROVIDER_DEFAULT_MODEL_PLACEHOLDER] -} - -/** No launch holds a pick and no fence can carry one yet, so the picker only shows. */ -export function lockedStructuredAgentSessionOptionSnapshot( - snapshot: readonly SessionOptionDescriptor[] -): SessionOptionDescriptor[] { - return snapshot.map((descriptor) => ({ - ...descriptor, - settable: false, - disabledReason: 'available-after-session-start' - })) -} - -export function canSetStructuredAgentSessionOption( - state: StructuredAgentSessionOptionState, - id: string, - value: SessionOptionValue -): boolean { - const descriptor = structuredAgentSessionOptionSnapshot(state).find((entry) => entry.id === id) - return Boolean( - state.catalog && - state.pendingId !== id && - ((typeof value === 'string' && - descriptor?.kind.type === 'select' && - descriptor.kind.choices.some((choice) => choice.value === value)) || - (typeof value === 'boolean' && descriptor?.kind.type === 'boolean')) - ) +): string | null { + return state.catalog + ? structuredAgentSessionShownModel(state.catalog, state.catalog.models, state.record) + .controlsModelId + : null } export function commitStructuredAgentSessionOption( @@ -261,22 +213,12 @@ export function commitStructuredAgentSessionOption( if (!state.catalog) { return state } - const effectiveModel = resolveEffectiveNativeChatModelId( - state.catalog, - state.catalog.models, - state.record - ) const decoded = decodeStructuredAgentSessionOptionValue(id, value) if (decoded === null) { return { ...state, pendingId: null } } - const effort = - id === 'model' ? getTrackedSessionOption(state.record, effectiveModel, 'effort') : undefined - setTrackedSessionOption(state.record, id, decoded, 'dispatched', effectiveModel) - // The host saves effort for the conversation, including across model picks. - if (effort) { - setTrackedSessionOption(state.record, 'effort', effort.value, 'dispatched') - } + // Each value is the chat's own: a model pick leaves the others for the host to reconcile. + state.record.values[id] = { value: decoded, source: 'dispatched' } return { ...state, pendingId: null } } @@ -325,7 +267,7 @@ export function structuredAgentSessionOptionPicks( const modelId = typeof committedModel === 'string' && committedModel.trim() ? committedModel - : resolveEffectiveNativeChatModelId(state.catalog, state.catalog.models, state.record) + : structuredAgentSessionControlsModelId(state) if (!modelId) { return [] } diff --git a/src/shared/structured-agent-session-permission-option.ts b/src/shared/structured-agent-session-permission-option.ts index cbefaa20e602..53f3e3e7cb49 100644 --- a/src/shared/structured-agent-session-permission-option.ts +++ b/src/shared/structured-agent-session-permission-option.ts @@ -8,7 +8,7 @@ import { type AgentChatPermissionMode } from './agent-chat-permission-mode' import type { AgentSessionOptionCatalog } from './agent-session-option-catalog' -import type { NativeChatSessionOptionRecord } from './native-chat-session-option-state' +import type { StructuredAgentSessionOptionRecord } from './structured-agent-session-option-state' import type { SessionOptionDescriptor } from './native-chat-session-options' /** English names and descriptions for surfaces without a translation catalog. */ @@ -57,9 +57,9 @@ function agentDescriptions(agent: string): Partial Date: Sun, 18 Oct 2026 21:22:22 -0700 Subject: [PATCH 02/34] fix(native-chat): a new chat's first send still pins the default model it shows A new chat that never picked a model carries the default its picker names, as before, so it keeps the model it actually ran after the CLI's default changes. Only a resumed chat's defaulted model is never named or sent. Tracked in STA-10947. --- ...ctured-agent-session-send-options.test.tsx | 4 --- ...ructured-agent-session-option-view.test.ts | 29 ++++++++++++++++--- .../structured-agent-session-option-view.ts | 19 ++++++++++-- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.test.tsx index 457571430597..9068f35fac70 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.test.tsx @@ -70,10 +70,6 @@ describe('what each composer send carries', () => { }) }) - it('carries a pick on its way without the default model the picker shows', () => { - expect(sendOptionsFor(true, { effort: 'low' }, {})()).toEqual({ effort: 'low' }) - }) - it('carries nothing to an older host', () => { expect(sendOptionsFor(false, {})()).toBeUndefined() }) diff --git a/src/shared/structured-agent-session-option-view.test.ts b/src/shared/structured-agent-session-option-view.test.ts index 46e0337c182f..198c9822c466 100644 --- a/src/shared/structured-agent-session-option-view.test.ts +++ b/src/shared/structured-agent-session-option-view.test.ts @@ -96,11 +96,29 @@ describe('a Claude model its CLI no longer lists', () => { }) describe('what a send carries', () => { - it('carries no default it shows: the agent runs it anyway, and the chat never chose it', () => { + it('carries the default it shows when nothing names a model', () => { const named = { ...SONNET, isDefault: true } const listing = { ...LISTED, models: [OPUS, named], listingNamesConfiguredModel: true } - expect(shown(withHostList(fresh(), listing), undefined, {}).model).toBe('sonnet') - expect(carried(withHostList(fresh(), listing))).toBeUndefined() + expect(carried(withHostList(fresh(), listing))).toEqual({ model: 'sonnet' }) + }) + + it("pins a new chat's shown default on its first send, which a restart shows as the chat's own", () => { + const listing = { ...LISTED, models: CLI302, listingNamesConfiguredModel: true } + const first = carried(withHostList(fresh(), listing)) + expect(first).toEqual({ model: 'opus' }) + // The host saved what the send carried, so the resumed chat's answer marks no default. + const restarted = applyStructuredAgentSessionOptions( + applyStructuredAgentSessionModelCatalog(fresh(), CLAUDE_SESSION_OPTION_CATALOG, listing, { + newLaunch: false + }), + CLAUDE_SESSION_OPTION_CATALOG, + { models: CLI302, current: { model: first?.model, defaulted: [] } } + ) + expect(shown(restarted, undefined, {}).model).toBe('opus') + expect(structuredAgentSessionShownSnapshot(restarted)[0]).toMatchObject({ + valueSource: 'dispatched' + }) + expect(carried(restarted)).toEqual({ model: 'opus' }) }) it('carries a pick on its way as given', () => { @@ -178,6 +196,9 @@ describe("a chat's model and effort, apart from the defaults filling them in", ( structuredAgentSessionShownSnapshot(state).find((entry) => entry.id === 'effort') ).toMatchObject({ settable: true, kind: { choices: levels('low', 'xhigh') } }) expect(shown(state, undefined, { effort: 'xhigh' })).toEqual({ model: 'opus', effort: 'xhigh' }) - expect(carried(state, undefined, { effort: 'xhigh' })).toEqual({ effort: 'xhigh' }) + expect(carried(state, undefined, { effort: 'xhigh' })).toEqual({ + model: 'opus', + effort: 'xhigh' + }) }) }) diff --git a/src/shared/structured-agent-session-option-view.ts b/src/shared/structured-agent-session-option-view.ts index ca900b02b5ca..82980d570853 100644 --- a/src/shared/structured-agent-session-option-view.ts +++ b/src/shared/structured-agent-session-option-view.ts @@ -10,6 +10,7 @@ import { structuredAgentSessionOwnValue } from './structured-agent-session-option-state' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from './agent-chat-permission-mode' +import { structuredAgentSessionShownModel } from './structured-agent-session-option-snapshot' function catalogListsSeedModel( state: StructuredAgentSessionOptionState, @@ -84,15 +85,29 @@ function modelOptionView( return { ...commitStructuredAgentSessionOptionValues(view, held), pendingId: state.pendingId } } +/** The default model a new chat's pill names: its first send pins it, so a later CLI default + * never moves the chat. A resumed chat's defaulted model is never named, so never sent. */ +function shownDefaultModel(view: StructuredAgentSessionOptionState): string | undefined { + if (!view.catalog || view.catalogSource === 'seed') { + return undefined + } + const { shown, isCliDefault } = structuredAgentSessionShownModel( + view.catalog, + view.catalog.models, + view.record + ) + return isCliDefault ? (shown.currentId ?? undefined) : undefined +} + /** The Claude model and effort a send carries: the chat's own and the picks over them in `view`, - * never a catalog default, which the agent runs anyway and the chat would then claim as its own. */ + * else the default model a new chat shows. */ export function structuredAgentSessionSendSelection( view: StructuredAgentSessionOptionState ): Record | undefined { if (view.record.agent !== 'claude') { return undefined } - const model = structuredAgentSessionOwnValue(view.record, 'model') + const model = structuredAgentSessionOwnValue(view.record, 'model') ?? shownDefaultModel(view) const effort = structuredAgentSessionOwnValue(view.record, 'effort') return model || effort ? { ...(model ? { model } : {}), ...(effort ? { effort } : {}) } From 37dcf239efe5390e9e9bc8554789b512331190b4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:38:02 -0700 Subject: [PATCH 03/34] fix(native-chat): narrow the host catalog before reading whether its listing names the default The flag lives only on a listed catalog, so the check confirms the catalog is not the unknown answer before reading it; the first-frame test reads the chat's flat model value. --- .../runtime/structured-agent-model-catalog-first-frame.test.ts | 2 +- src/shared/structured-agent-session-options.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts b/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts index d48b342d8827..4b43ab530197 100644 --- a/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts +++ b/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts @@ -136,7 +136,7 @@ describe('a new chat’s first frame from the host catalog', () => { expect(host).toMatchObject({ origin: 'probe', listingNamesConfiguredModel: false }) expect(model?.choices.map((choice) => choice.value)).toEqual(['grok-4', 'grok-3-mini']) expect(model?.currentValue).toBeUndefined() - expect(state.record.model).toBeUndefined() + expect(state.record.values.model).toBeUndefined() }) it('lists a warm Pi account’s models without naming one its listing does not name', async () => { diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index 57f0dc2b9851..c740a377352c 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -132,6 +132,7 @@ export function applyStructuredAgentSessionModelCatalog( // The host says whether its listed default is what this launch runs; an older host never says, // so the client's own knowledge of the agent stands in. A reopened chat may keep its own model. const namesDefault = + catalog.origin !== 'unknown' && models.length > 0 && options.newLaunch && (catalog.listingNamesConfiguredModel ?? seed.hostListingNamesConfiguredModel === true) From 2279958ce16c378beb9b6260e63ea7d70dcf3276 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:12:18 -0700 Subject: [PATCH 04/34] fix(native-chat): a reopened chat whose saved model was refused records the model it now runs After a retired saved model is reset, the options answer no longer marks the model the child runs as a default, so the next send records it instead of replaying the refused model on every reopen. --- .../claude/claude-structured-options.test.ts | 13 +++++++++++ .../structured-agent-session-options-read.ts | 5 ++-- ...ude-structured-retired-saved-model.test.ts | 23 +++++++++++++++---- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index 64ada44671d7..e6245b67ec58 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -435,6 +435,19 @@ describe('Claude Fast mode reported by the session frame alone', () => { }) }) +describe("Claude's model the chat never chose", () => { + it('marks the listed default it falls back to, never a model the chat runs', async () => { + const { session } = fastModeSession(true) + const current = async () => + (await readClaudeStructuredSessionOptions(session, undefined)).current + expect((await current()).defaulted).toBeUndefined() + session.options.delete('model') + expect(await current()).toMatchObject({ defaulted: ['model'] }) + session.options.set('model', 'default') + expect(await current()).toMatchObject({ defaulted: ['model'] }) + }) +}) + describe('Claude structured option write under the request deadline', () => { it("keeps a timed-out client write as the deadline's own error, not a rejection", async () => { const session = sessionFor(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 3a43cdb57d3d..2b55abbde87a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -285,8 +285,9 @@ export async function readStructuredAgentSessionOptions( ...(shownSaved('model') ? { model: selected.current.model } : {}), ...(shownSaved('effort') ? { effort: selected.current.effort } : {}) } - // A saved value shown over the child's is the chat's own, never the default the child fell back to. - const defaulted = (options.current.defaulted ?? []).filter((key) => !(live && shownSaved(key))) + // A chat that saved a value has no default for it: the saved value shows, or the child it + // refused runs what it reports, which the next send records. + const defaulted = (options.current.defaulted ?? []).filter((key) => saved?.[key] === undefined) const confirmed = current.confirmed?.filter((key) => key === 'model' ? current.model === options.current.model diff --git a/src/main/runtime/claude-structured-retired-saved-model.test.ts b/src/main/runtime/claude-structured-retired-saved-model.test.ts index 0fae6a1e9b58..afc390626707 100644 --- a/src/main/runtime/claude-structured-retired-saved-model.test.ts +++ b/src/main/runtime/claude-structured-retired-saved-model.test.ts @@ -7,6 +7,12 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' +import { structuredAgentSessionSeedCatalog } from '../../shared/structured-agent-session-seed-catalog' +import { + applyStructuredAgentSessionOptions, + createStructuredAgentSessionOptionState +} from '../../shared/structured-agent-session-options' +import { structuredAgentSessionSendSelection } from '../../shared/structured-agent-session-option-view' const SESSION = 'claude-retired-saved-model' const CALLER = { callerKey: 'client-1' } @@ -66,11 +72,20 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { effort: 'high' }) // The picker shows what the child runs now, so the next send moves the chat off the model. - await vi.waitFor(async () => - expect((await host.readOptions(SESSION, { readsWithoutModel: true })).current.model).not.toBe( - RETIRED - ) + const answer = await vi.waitFor(async () => { + const read = await host.readOptions(SESSION, { readsWithoutModel: true }) + expect(read.current.model).not.toBe(RETIRED) + return read + }) + const seed = structuredAgentSessionSeedCatalog('claude') + const reopened = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('claude', seed), + seed, + answer ) + expect(structuredAgentSessionSendSelection(reopened)).toMatchObject({ + model: answer.current.model + }) }) it("keeps a model the reply did not come from: a subagent's own error", async () => { From 9f1c2df0e2edeba2de6317ce121b243c29a1f5de Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:13:55 -0700 Subject: [PATCH 05/34] fix(native-chat): a new chat names and pins the default it shows, and a model pick keeps only what the model offers A chat this view started names the default model the host fills in, whichever of the model list and the options answer lands first, and its first send carries the shown model and effort. A resumed chat still names and sends only its own values. A model pick, held or live, keeps an effort, speed or Fast only where the new model offers it, so a value the model cannot take is never shown, sent or remembered. --- .../use-mobile-structured-agent-options.ts | 11 ++- ...ured-agent-new-chat-effort-at-rest.test.ts | 2 +- ...e-structured-agent-session-option-state.ts | 14 +++- ...-structured-agent-session-options.test.tsx | 13 +++- .../use-structured-agent-session-options.ts | 19 +++-- ...tured-agent-session-launch-options.test.ts | 40 +++++++++++ ...structured-agent-session-launch-options.ts | 25 +++++-- ...ructured-agent-session-option-view.test.ts | 33 ++++++++- .../structured-agent-session-option-view.ts | 33 ++++----- .../structured-agent-session-options.test.ts | 5 +- .../structured-agent-session-options.ts | 70 +++++++++++++------ 11 files changed, 194 insertions(+), 71 deletions(-) diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 615d1eaf84c1..18d767fdd72c 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -152,7 +152,9 @@ export function useMobileStructuredAgentOptions(args: { setConversationSupport({ sessionId, commands: result.conversationCommands ?? [] }) updateOptionState((current) => current.record === activeOptionRecordRef.current - ? applyStructuredAgentSessionOptions(current, optionCatalog, result) + ? applyStructuredAgentSessionOptions(current, optionCatalog, result, { + newLaunch: createdHere !== undefined + }) : current ) } @@ -163,6 +165,7 @@ export function useMobileStructuredAgentOptions(args: { } }, [ client, + createdHere, enabled, optionCatalog, optionStamp, @@ -270,7 +273,9 @@ export function useMobileStructuredAgentOptions(args: { if (isCurrent()) { updateOptionState((latest) => latest.record === targetRecord - ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed) + ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed, { + newLaunch: createdHere !== undefined + }) : latest ) } @@ -287,7 +292,7 @@ export function useMobileStructuredAgentOptions(args: { } } }, - [agent, client, mutate, optionCatalog, sessionId, updateOptionState] + [agent, client, createdHere, mutate, optionCatalog, sessionId, updateOptionState] ) // Read, not rendered: what the chat holds when the send goes out. diff --git a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts index 3af71403b4ea..7aa9b721531a 100644 --- a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts +++ b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts @@ -246,7 +246,7 @@ async function newChatFrames( const atRest = await readStructuredAgentSessionOptions(context, record.sessionId, { readsWithoutModel: chat.readsWithoutModel ?? true }) - const settled = applyStructuredAgentSessionOptions(first, seed, atRest) + const settled = applyStructuredAgentSessionOptions(first, seed, atRest, { newLaunch: true }) return { atRest, first: firstPills, settled: pills(settled) } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts index 199e09c8bc4f..61cd8ae87fb8 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts @@ -113,6 +113,14 @@ export function useStructuredAgentSessionOptionState(args: { }, [] ) + // A chat this view started names the default it runs until its first send pins it. + const applyOptionsAnswer = useCallback( + (current: StructuredAgentSessionOptionState, result: AgentSessionOptionsResult) => + optionCatalog + ? applyStructuredAgentSessionOptions(current, optionCatalog, result, { newLaunch }) + : current, + [newLaunch, optionCatalog] + ) const optionIdentityRef = useRef(identity) useEffect(() => { const previous = optionStateRef.current @@ -172,7 +180,7 @@ export function useStructuredAgentSessionOptionState(args: { }) updateOptionState((current) => current.record === activeOptionRecordRef.current - ? applyStructuredAgentSessionOptions(current, optionCatalog, result) + ? applyOptionsAnswer(current, result) : current ) } @@ -184,6 +192,7 @@ export function useStructuredAgentSessionOptionState(args: { runner.dispose() } }, [ + applyOptionsAnswer, fence, optionCatalog, // Another device's pick moves it. @@ -225,6 +234,7 @@ export function useStructuredAgentSessionOptionState(args: { activeOptionRecordRef, pendingOptionsRef, optionMutationGeneration, - updateOptionState + updateOptionState, + applyOptionsAnswer } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx index f86b60f488c2..5ad4d5f720b0 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx @@ -6,7 +6,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ call: vi.fn(), enqueue: vi.fn(), - hold: vi.fn<(sessionId: string, id: string, encoded: string) => Promise | null>(), + hold: vi.fn< + (sessionId: string, id: string, encoded: string, picker?: unknown) => Promise | null + >(), toastError: vi.fn<(message: string) => void>() })) @@ -307,7 +309,12 @@ describe('useStructuredAgentSessionOptions', () => { accepted = await result.current.setStructuredOption('model', 'gpt-5.6-luna') }) expect(accepted).toBe(true) - expect(mocks.hold).toHaveBeenCalledWith('session-1', 'model', 'gpt-5.6-luna') + expect(mocks.hold).toHaveBeenCalledWith( + 'session-1', + 'model', + 'gpt-5.6-luna', + expect.anything() + ) // The launch owns the pick; the view renders what it holds. rerender({ ...PROVISIONAL, launchSeedOptions: SEED, heldOptions: { model: 'gpt-5.6-luna' } }) expect(currentValue(result.current.optionSnapshot, 'model')).toBe('gpt-5.6-luna') @@ -619,7 +626,7 @@ describe('useStructuredAgentSessionOptions', () => { await act(async () => { await result.current.setStructuredOption('model', 'gpt-hosted') }) - expect(mocks.hold).toHaveBeenCalledWith('session-1', 'model', 'gpt-hosted') + expect(mocks.hold).toHaveBeenCalledWith('session-1', 'model', 'gpt-hosted', expect.anything()) unmount() }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index 143ad8184cf1..e140cf714932 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -9,7 +9,6 @@ import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode' import { - applyStructuredAgentSessionOptions, canSetStructuredAgentSessionOption, commitStructuredAgentSessionOptionValues, lockedStructuredAgentSessionOptionSnapshot, @@ -62,6 +61,7 @@ export function useStructuredAgentSessionOptions(args: { turnId } = args const providerStarting = args.providerStarting ?? false + const newLaunch = launch?.kind === 'new' const launchSeedOptions = launch?.seedOptions const held = launch?.heldOptions ?? NO_HELD_OPTIONS // Published but not attached: the launch no longer holds picks and there is no fence to send one. @@ -76,6 +76,7 @@ export function useStructuredAgentSessionOptions(args: { pendingOptionsRef, optionMutationGeneration, updateOptionState, + applyOptionsAnswer, rereadOptions, conversationSupport } = useStructuredAgentSessionOptionState({ @@ -85,7 +86,7 @@ export function useStructuredAgentSessionOptions(args: { fence, sessionId, target, - newLaunch: launch?.kind === 'new', + newLaunch, ...(launch?.worktree ? { worktree: launch.worktree } : {}), seedsModel: launchSeedOptions?.model !== undefined, providerVisible, @@ -105,14 +106,12 @@ export function useStructuredAgentSessionOptions(args: { optionCatalog, enabled: args.isVisible, // A resumed conversation may keep its own model, so only a new one runs the listed default. - newLaunch: launch?.kind === 'new', + newLaunch, ...(launch?.worktree ? { worktree: launch.worktree } : {}), fence, turnId, reportedUnpickedModel: - launch?.kind === 'new' && - launchSeedOptions?.model === undefined && - optionState.catalogSource === 'live', + newLaunch && launchSeedOptions?.model === undefined && optionState.catalogSource === 'live', ...(args.providerRunning ? { providerRunning: true } : {}), activeOptionRecordRef, updateOptionState, @@ -184,9 +183,7 @@ export function useStructuredAgentSessionOptions(args: { .then((refreshed) => { if (isCurrent()) { updateOptionState((latest) => - latest.record === targetRecord && optionCatalog - ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed) - : latest + latest.record === targetRecord ? applyOptionsAnswer(latest, refreshed) : latest ) } }) @@ -203,9 +200,9 @@ export function useStructuredAgentSessionOptions(args: { }, [ activeOptionRecordRef, + applyOptionsAnswer, launchSeedOptions, mutate, - optionCatalog, optionMutationGeneration, optionStateRef, optionView, @@ -253,7 +250,7 @@ export function useStructuredAgentSessionOptions(args: { } if (!transportEnabled || fence === null) { // No fence yet: the launch holds it and applies it before anything else is sent. - const applied = holdStructuredAgentSessionLaunchOption(sessionId, id, encoded) + const applied = holdStructuredAgentSessionLaunchOption(sessionId, id, encoded, view) void applied?.then(settleLaunchOptionPick) return applied !== null } diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts index b1fb1916bd54..2f1bcb418743 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts @@ -78,6 +78,11 @@ import { subscribeStructuredAgentLaunchStatus } from './structured-agent-session-launch-registry' import { markStructuredAgentSessionLaunchPublished } from './structured-agent-session-launch-publication' +import { CODEX_SESSION_OPTION_CATALOG } from '../../../shared/agent-session-option-catalog-claude-codex' +import { + applyStructuredAgentSessionModelCatalog, + createStructuredAgentSessionOptionState +} from '../../../shared/structured-agent-session-options' const WORKTREE_ID = 'wt-1' const SESSION_ID = 'session-1' @@ -268,6 +273,41 @@ describe('picks made while a chat launches', () => { await expect(effortPick).resolves.toMatchObject({ kind: 'accepted' }) }) + it('drops a held Fast the picked model does not offer: neither sent nor remembered', async () => { + const created = deferred<{ sessionId: string; fence: number }>() + mocks.launch.mockReturnValue(created.promise) + const launch = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-tier' }) + const picker = applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState('codex', CODEX_SESSION_OPTION_CATALOG), + CODEX_SESSION_OPTION_CATALOG, + { + origin: 'probe', + fetchedAt: 1, + models: [ + { + id: 'gpt-5.5', + label: 'GPT-5.5', + isDefault: true, + efforts: [], + serviceTiers: [{ value: 'priority', label: 'Fast' }] + }, + { id: 'gpt-nofast', label: 'No Fast', efforts: [] } + ] + }, + { newLaunch: true } + ) + const tierPick = holdStructuredAgentSessionLaunchOption(SESSION_ID, 'serviceTier', 'priority') + holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-nofast', picker) + // Superseded, so the picker never saves it as the next launch's preference. + await expect(tierPick).resolves.toEqual({ kind: 'superseded' }) + created.resolve({ sessionId: SESSION_ID, fence: 3 }) + await settle() + setOptionReplies[0]!.resolve(ACCEPTED) + await launch.launchResult + + expect(mutations().map(({ key, value }) => [key, value])).toEqual([['model', 'gpt-nofast']]) + }) + it('reports a refused pick to its picker and publishes all the same', async () => { mocks.launch.mockResolvedValue({ sessionId: SESSION_ID, fence: 1 }) const launch = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index a96988ff3266..fc7383450dd9 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -27,6 +27,10 @@ import { type AgentSessionWriteFailure } from '../../../shared/agent-session-write-failure' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import { + structuredAgentSessionModelOffers, + type StructuredAgentSessionOptionState +} from '../../../shared/structured-agent-session-options' /** The options a launch starts with, replaced whole so readers can compare by identity. */ export type StructuredLaunchSelection = { @@ -61,17 +65,28 @@ function repliesFor(state: StructuredLaunchState): Map { export function holdStructuredAgentSessionLaunchOption( sessionId: string, id: string, - encoded: string + encoded: string, + /** The picker's list, under which a model pick keeps only the held values the model offers. */ + picker?: Pick ): Promise | null { const state = getStructuredLaunchStateBySessionId(sessionId) if (!state || state.cancelled || state.callers.outcome === 'published') { return null } const replies = repliesFor(state) - // Each held value is the chat's own: a pick supersedes only an earlier pick of the same option. - replies.get(id)?.({ kind: 'superseded' }) - replies.delete(id) - state.selection = { ...state.selection, held: { ...state.selection.held, [id]: encoded } } + const held: Record = {} + // Each held value is the chat's own: a pick supersedes an earlier pick of the same option. + for (const [key, value] of Object.entries(state.selection.held)) { + const offered = + id !== 'model' || !picker || structuredAgentSessionModelOffers(picker, encoded, key, value) + if (key !== id && offered) { + held[key] = value + } else { + replies.get(key)?.({ kind: 'superseded' }) + replies.delete(key) + } + } + state.selection = { ...state.selection, held: { ...held, [id]: encoded } } notifyStructuredLaunchListeners() return new Promise((resolve) => replies.set(id, resolve)) } diff --git a/src/shared/structured-agent-session-option-view.test.ts b/src/shared/structured-agent-session-option-view.test.ts index 198c9822c466..aea3d3584c3e 100644 --- a/src/shared/structured-agent-session-option-view.test.ts +++ b/src/shared/structured-agent-session-option-view.test.ts @@ -168,9 +168,13 @@ describe("a chat's model and effort, apart from the defaults filling them in", ( expect(carried(state)).toBeUndefined() const effort = commitStructuredAgentSessionOption(state, 'effort', 'xhigh') expect(carried(effort)).toEqual({ effort: 'xhigh' }) - // A model pick leaves the effort for the host to reconcile. + // A model pick keeps an effort the new model offers, and drops one it does not. + expect(carried(commitStructuredAgentSessionOption(effort, 'model', 'opus'))).toEqual({ + model: 'opus', + effort: 'xhigh' + }) const model = commitStructuredAgentSessionOption(effort, 'model', 'sonnet') - expect(carried(model)).toEqual({ model: 'sonnet', effort: 'xhigh' }) + expect(carried(model)).toEqual({ model: 'sonnet' }) const restarted = applyStructuredAgentSessionOptions(fresh(), CLAUDE_SESSION_OPTION_CATALOG, { models: CLI302, listedModelIds: listed, @@ -180,6 +184,31 @@ describe("a chat's model and effort, apart from the defaults filling them in", ( expect(carried(restarted)).toEqual({ model: 'sonnet', effort: 'high' }) }) + it("names a new chat's default and pins it with the shown effort, whichever answer lands first", () => { + // No Claude settings learned yet: the host's list does not say this launch runs its default. + const listing = { ...LISTED, models: CLI302, listingNamesConfiguredModel: false } + const answer = { + models: CLI302, + listedModelIds: listed, + current: { model: 'opus', effort: 'xhigh', defaulted: ['model', 'effort'] as const } + } + const newChat = { newLaunch: true } + const listFirst = applyStructuredAgentSessionOptions( + withHostList(fresh(), listing), + CLAUDE_SESSION_OPTION_CATALOG, + answer, + newChat + ) + const answerFirst = withHostList( + applyStructuredAgentSessionOptions(fresh(), CLAUDE_SESSION_OPTION_CATALOG, answer, newChat), + listing + ) + for (const state of [listFirst, answerFirst]) { + expect(shown(state, undefined, {})).toEqual({ model: 'opus', effort: 'xhigh' }) + expect(carried(state)).toEqual({ model: 'opus', effort: 'xhigh' }) + } + }) + it("reads an older host's answer, which marks no default, as it always has", () => { const state = resumed() expect(shown(state, undefined, {})).toEqual({ model: 'opus', effort: 'low' }) diff --git a/src/shared/structured-agent-session-option-view.ts b/src/shared/structured-agent-session-option-view.ts index 82980d570853..4d6e0c2f955e 100644 --- a/src/shared/structured-agent-session-option-view.ts +++ b/src/shared/structured-agent-session-option-view.ts @@ -10,7 +10,6 @@ import { structuredAgentSessionOwnValue } from './structured-agent-session-option-state' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from './agent-chat-permission-mode' -import { structuredAgentSessionShownModel } from './structured-agent-session-option-snapshot' function catalogListsSeedModel( state: StructuredAgentSessionOptionState, @@ -85,30 +84,26 @@ function modelOptionView( return { ...commitStructuredAgentSessionOptionValues(view, held), pendingId: state.pendingId } } -/** The default model a new chat's pill names: its first send pins it, so a later CLI default - * never moves the chat. A resumed chat's defaulted model is never named, so never sent. */ -function shownDefaultModel(view: StructuredAgentSessionOptionState): string | undefined { - if (!view.catalog || view.catalogSource === 'seed') { - return undefined - } - const { shown, isCliDefault } = structuredAgentSessionShownModel( - view.catalog, - view.catalog.models, - view.record - ) - return isCliDefault ? (shown.currentId ?? undefined) : undefined -} - -/** The Claude model and effort a send carries: the chat's own and the picks over them in `view`, - * else the default model a new chat shows. */ +/** The Claude model and effort a send carries: the chat's own and the picks over them in `view`. + * A picker that names its defaults (a new chat, or an older host's answer) sends what it shows, + * so a new chat's first send pins them and a later CLI default never moves it; a resumed chat's + * defaults are never named, so never sent. */ export function structuredAgentSessionSendSelection( view: StructuredAgentSessionOptionState ): Record | undefined { if (view.record.agent !== 'claude') { return undefined } - const model = structuredAgentSessionOwnValue(view.record, 'model') ?? shownDefaultModel(view) - const effort = structuredAgentSessionOwnValue(view.record, 'effort') + const snapshot = + view.catalog?.defaultModelIsCliDefault === true + ? structuredAgentSessionOptionSnapshot(view) + : [] + const shown = (id: string): string | undefined => { + const kind = snapshot.find((descriptor) => descriptor.id === id)?.kind + return kind?.type === 'select' ? kind.currentValue : undefined + } + const model = structuredAgentSessionOwnValue(view.record, 'model') ?? shown('model') + const effort = structuredAgentSessionOwnValue(view.record, 'effort') ?? shown('effort') return model || effort ? { ...(model ? { model } : {}), ...(effort ? { effort } : {}) } : undefined diff --git a/src/shared/structured-agent-session-options.test.ts b/src/shared/structured-agent-session-options.test.ts index 0442fb618aaf..bf2a2bb42979 100644 --- a/src/shared/structured-agent-session-options.test.ts +++ b/src/shared/structured-agent-session-options.test.ts @@ -364,10 +364,11 @@ describe('structured agent session options', () => { applyStructuredAgentSessionOptions( applyStructuredAgentSessionModelCatalog(cold(), seed, listing, { newLaunch }), seed, - answer + answer, + { newLaunch } ), applyStructuredAgentSessionModelCatalog( - applyStructuredAgentSessionOptions(cold(), seed, answer), + applyStructuredAgentSessionOptions(cold(), seed, answer, { newLaunch }), seed, listing, { newLaunch } diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index c740a377352c..0f0cbed4e87c 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -30,8 +30,7 @@ export { export function structuredAgentSessionOptionCatalog( seed: AgentSessionOptionCatalog, result: AgentSessionOptionsResult, - /** Whether the host already said this launch runs its listed default. */ - namesDefault = false + newLaunch: boolean ): AgentSessionOptionCatalog { const models: CatalogModel[] = result.models.map((model) => discoveredModel(model, result.fastModeSupport?.supported === true) @@ -43,9 +42,9 @@ export function structuredAgentSessionOptionCatalog( options: seed.unknownModelOptions ?? [] }) } - // An answer that marks its defaults names one only where the host's list says this launch runs - // it; an older answer's model was always named. - return result.current.defaulted === undefined || namesDefault + // A new chat names the default it runs until its first send pins it; a resumed chat names only + // its own model. An older answer marks no defaults, so its model was always named. + return result.current.defaulted === undefined || newLaunch ? { ...seed, models, defaultModelIsCliDefault: true } : { ...seed, models } } @@ -123,28 +122,23 @@ export function applyStructuredAgentSessionModelCatalog( catalog: AgentSessionModelCatalogResult, options: { newLaunch: boolean } ): StructuredAgentSessionOptionState { + if (state.catalogSource === 'live') { + return state + } const models = catalog.origin === 'unknown' ? [] : catalog.models.map((model) => discoveredModel(model, catalog.fastModeSupport?.supported === true) ) + if (catalog.origin === 'unknown' || models.length === 0) { + return settleStructuredAgentSessionBuiltinCatalog(state) + } // The host says whether its listed default is what this launch runs; an older host never says, // so the client's own knowledge of the agent stands in. A reopened chat may keep its own model. const namesDefault = - catalog.origin !== 'unknown' && - models.length > 0 && options.newLaunch && (catalog.listingNamesConfiguredModel ?? seed.hostListingNamesConfiguredModel === true) - if (state.catalogSource === 'live') { - // The session's list stands; only the host's word on its default joins it, in either order. - return namesDefault && state.catalog && !state.catalog.defaultModelIsCliDefault - ? { ...state, catalog: { ...state.catalog, defaultModelIsCliDefault: true } } - : state - } - if (models.length === 0) { - return settleStructuredAgentSessionBuiltinCatalog(state) - } return { ...state, // `isDefault` came from a real listing, so a launch's CLI default is nameable — @@ -161,7 +155,10 @@ export function applyStructuredAgentSessionModelCatalog( export function applyStructuredAgentSessionOptions( state: StructuredAgentSessionOptionState, seed: AgentSessionOptionCatalog, - result: AgentSessionOptionsResult + result: AgentSessionOptionsResult, + /** Whether this view started the chat, which runs the default it shows until its first send. + * Absent reads as a chat it did not start, which never names a default as its own. */ + launch: { newLaunch: boolean } = { newLaunch: false } ): StructuredAgentSessionOptionState { const { current } = result // A value the host supplied from a catalog default is context for the picker, not the chat's own. @@ -182,11 +179,7 @@ export function applyStructuredAgentSessionOptions( ) return { ...state, - catalog: structuredAgentSessionOptionCatalog( - seed, - result, - state.catalog?.defaultModelIsCliDefault === true - ), + catalog: structuredAgentSessionOptionCatalog(seed, result, launch.newLaunch), catalogSource: 'live', verifiedModelIds: result.listedModelIds?.length ? result.listedModelIds : undefined, permissionMode: result.current.permissionMode ?? null @@ -218,11 +211,42 @@ export function commitStructuredAgentSessionOption( if (decoded === null) { return { ...state, pendingId: null } } - // Each value is the chat's own: a model pick leaves the others for the host to reconcile. state.record.values[id] = { value: decoded, source: 'dispatched' } + // Each value is the chat's own; a model pick keeps those the new model offers. + if (id === 'model' && typeof decoded === 'string') { + for (const [other, tracked] of Object.entries(state.record.values)) { + if (!structuredAgentSessionModelOffers(state, decoded, other, tracked.value)) { + delete state.record.values[other] + } + } + } return { ...state, pendingId: null } } +/** Whether a pick of `modelId` keeps the chat's `id` value (decoded or encoded): the model's + * controls offer it. A model the list does not describe keeps every value, since nothing says + * what it takes. */ +export function structuredAgentSessionModelOffers( + state: Pick, + modelId: string, + id: string, + value: SessionOptionValue +): boolean { + const model = state.catalog?.models.find((entry) => entry.id === modelId) + if ( + id === 'model' || + id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID || + !model || + (state.verifiedModelIds !== undefined && !state.verifiedModelIds.includes(modelId)) + ) { + return true + } + const option = model.options.find((entry) => entry.id === id) + return option?.kind.type === 'select' + ? option.kind.choices.some((choice) => choice.value === value) + : option !== undefined +} + export function commitStructuredAgentSessionOptionValues( state: StructuredAgentSessionOptionState, values: Readonly> From 9c1e98940b54c118ae93ef0d9c49712465b71ecd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:21:01 -0700 Subject: [PATCH 06/34] test(native-chat): give the no-Fast model fixture its required default flag --- .../src/lib/structured-agent-session-launch-options.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts index 2f1bcb418743..b00b63b46700 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts @@ -291,7 +291,7 @@ describe('picks made while a chat launches', () => { efforts: [], serviceTiers: [{ value: 'priority', label: 'Fast' }] }, - { id: 'gpt-nofast', label: 'No Fast', efforts: [] } + { id: 'gpt-nofast', label: 'No Fast', isDefault: false, efforts: [] } ] }, { newLaunch: true } From 715825ff22524f4fdd6fe41ab0f46d01e0db1e9f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:28:50 -0700 Subject: [PATCH 07/34] fix(native-chat): an open chat's model pick leaves reconciling to the host A live pick no longer drops values on the client; the host's reply, when it carries its full value set, replaces the chat's own values as a read does. Only a pick held before launch, with no host yet, keeps just what the picked model offers. --- .../use-mobile-structured-agent-options.ts | 4 +-- .../use-structured-agent-session-options.ts | 6 ++-- ...ructured-agent-session-option-view.test.ts | 13 ++++---- .../structured-agent-session-options.ts | 30 ++++++++++++------- 4 files changed, 33 insertions(+), 20 deletions(-) diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 18d767fdd72c..0bda813f1f6a 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -11,7 +11,7 @@ import { applyStructuredAgentSessionOptions, canSetStructuredAgentSessionOption, commitStructuredAgentSessionOption, - commitStructuredAgentSessionOptionValues, + commitStructuredAgentSessionOptionReply, createStructuredAgentSessionOptionState, structuredAgentSessionOptionPicks, type StructuredAgentSessionOptionState @@ -238,7 +238,7 @@ export function useMobileStructuredAgentOptions(args: { const committed = result.value.options ?? { [id]: encoded } updateOptionState((current) => current.record === targetRecord && result.sameFence - ? commitStructuredAgentSessionOptionValues(current, committed) + ? commitStructuredAgentSessionOptionReply(current, id, encoded, result.value.options) : current ) // Only an accepted pick: an `unknown` outcome commits optimistically to the diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index e140cf714932..60b1522890dd 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -10,6 +10,7 @@ import type { SessionOptionsSurface } from '../../../../shared/native-chat-sessi import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode' import { canSetStructuredAgentSessionOption, + commitStructuredAgentSessionOptionReply, commitStructuredAgentSessionOptionValues, lockedStructuredAgentSessionOptionSnapshot, structuredAgentSessionOptionPicks, @@ -156,10 +157,11 @@ export function useStructuredAgentSessionOptions(args: { ) if (result && sameRecord() && currentPick()) { const returned = result.options ?? { [id]: encoded } - const committed = isCurrent() ? returned : { [id]: returned[id] ?? encoded } + const full = isCurrent() ? result.options : undefined + const committed = full ?? { [id]: returned[id] ?? encoded } updateOptionState((current) => current.record === targetRecord - ? commitStructuredAgentSessionOptionValues(current, committed) + ? commitStructuredAgentSessionOptionReply(current, id, committed[id] ?? encoded, full) : current ) // The launch seed names the model an effort-only pick was made under. diff --git a/src/shared/structured-agent-session-option-view.test.ts b/src/shared/structured-agent-session-option-view.test.ts index aea3d3584c3e..c50abdc4f793 100644 --- a/src/shared/structured-agent-session-option-view.test.ts +++ b/src/shared/structured-agent-session-option-view.test.ts @@ -5,6 +5,7 @@ import { applyStructuredAgentSessionModelCatalog, applyStructuredAgentSessionOptions, commitStructuredAgentSessionOption, + commitStructuredAgentSessionOptionReply, createStructuredAgentSessionOptionState, structuredAgentSessionOptionSnapshot, type StructuredAgentSessionOptionState @@ -168,13 +169,13 @@ describe("a chat's model and effort, apart from the defaults filling them in", ( expect(carried(state)).toBeUndefined() const effort = commitStructuredAgentSessionOption(state, 'effort', 'xhigh') expect(carried(effort)).toEqual({ effort: 'xhigh' }) - // A model pick keeps an effort the new model offers, and drops one it does not. - expect(carried(commitStructuredAgentSessionOption(effort, 'model', 'opus'))).toEqual({ - model: 'opus', - effort: 'xhigh' - }) + // A model pick leaves the effort for the host to reconcile; its full reply speaks for it. const model = commitStructuredAgentSessionOption(effort, 'model', 'sonnet') - expect(carried(model)).toEqual({ model: 'sonnet' }) + expect(carried(model)).toEqual({ model: 'sonnet', effort: 'xhigh' }) + const reconciled = commitStructuredAgentSessionOptionReply(model, 'model', 'sonnet', { + model: 'sonnet' + }) + expect(carried(reconciled)).toEqual({ model: 'sonnet' }) const restarted = applyStructuredAgentSessionOptions(fresh(), CLAUDE_SESSION_OPTION_CATALOG, { models: CLI302, listedModelIds: listed, diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index 0f0cbed4e87c..5b75806120d2 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -211,21 +211,31 @@ export function commitStructuredAgentSessionOption( if (decoded === null) { return { ...state, pendingId: null } } + // Each value is the chat's own: a model pick leaves the others for the host to reconcile. state.record.values[id] = { value: decoded, source: 'dispatched' } - // Each value is the chat's own; a model pick keeps those the new model offers. - if (id === 'model' && typeof decoded === 'string') { - for (const [other, tracked] of Object.entries(state.record.values)) { - if (!structuredAgentSessionModelOffers(state, decoded, other, tracked.value)) { - delete state.record.values[other] - } + return { ...state, pendingId: null } +} + +/** A pick's reply. One carrying the host's full value set (`options`) speaks for each of the + * chat's own values, as a read does: a value it reconciled away is gone. */ +export function commitStructuredAgentSessionOptionReply( + state: StructuredAgentSessionOptionState, + id: string, + encoded: string, + options: Readonly> | undefined +): StructuredAgentSessionOptionState { + const next = commitStructuredAgentSessionOptionValues(state, options ?? { [id]: encoded }) + for (const key of options ? (['effort', 'fastMode', 'serviceTier'] as const) : []) { + if (options?.[key] === undefined && next.record.values[key]?.source !== 'default') { + delete next.record.values[key] } } - return { ...state, pendingId: null } + return next } -/** Whether a pick of `modelId` keeps the chat's `id` value (decoded or encoded): the model's - * controls offer it. A model the list does not describe keeps every value, since nothing says - * what it takes. */ +/** Whether a held pick of `modelId`, before any host can reconcile it, keeps the chat's `id` value + * (decoded or encoded): the model's controls offer it. A model the list does not describe keeps + * every value, since nothing says what it takes. */ export function structuredAgentSessionModelOffers( state: Pick, modelId: string, From 2ce8c3c8436ec2c18b81d2cc905cb2bfb40028ab Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:50:07 -0700 Subject: [PATCH 08/34] fix(native-chat): a pick reply only adds the values it names A pick's reply carries only the values the host set or was asked for, so it can't speak for the rest: it no longer removes values it leaves out. Joined tests pin real reply shapes and a new Claude chat in a fresh Claude home naming and pinning Opus High. --- .../use-mobile-structured-agent-options.ts | 4 +- ...-structured-agent-session-options.test.tsx | 102 ++++++++++++++++++ .../use-structured-agent-session-options.ts | 6 +- ...ructured-agent-session-option-view.test.ts | 7 +- .../structured-agent-session-options.ts | 23 +--- 5 files changed, 110 insertions(+), 32 deletions(-) diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 0bda813f1f6a..18d767fdd72c 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -11,7 +11,7 @@ import { applyStructuredAgentSessionOptions, canSetStructuredAgentSessionOption, commitStructuredAgentSessionOption, - commitStructuredAgentSessionOptionReply, + commitStructuredAgentSessionOptionValues, createStructuredAgentSessionOptionState, structuredAgentSessionOptionPicks, type StructuredAgentSessionOptionState @@ -238,7 +238,7 @@ export function useMobileStructuredAgentOptions(args: { const committed = result.value.options ?? { [id]: encoded } updateOptionState((current) => current.record === targetRecord && result.sameFence - ? commitStructuredAgentSessionOptionReply(current, id, encoded, result.value.options) + ? commitStructuredAgentSessionOptionValues(current, committed) : current ) // Only an accepted pick: an `unknown` outcome commits optimistically to the diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx index 5ad4d5f720b0..6b6935a9d596 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx @@ -30,6 +30,7 @@ vi.mock('@/lib/structured-agent-session-launch-options', () => ({ import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' +import { structuredAgentSessionSendSelection } from '../../../../shared/structured-agent-session-option-view' import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' const LOCAL_TARGET = { kind: 'local' } as const @@ -646,6 +647,107 @@ describe('useStructuredAgentSessionOptions', () => { }) }) + describe('a new Claude chat in a fresh Claude home', () => { + const efforts = ['low', 'high'].map((value) => ({ value, label: value })) + // Claude's listing never names the configured model (claudeModelCatalogDiscovery) and a fresh + // home has learned none, so the host says its default may not run (agentModelCatalogResultFromEntry). + const FRESH_LIST = { + origin: 'probe', + fetchedAt: 1, + listingNamesConfiguredModel: false, + models: [ + { id: 'opus', label: 'Opus', isDefault: true, defaultEffort: 'high', efforts }, + { id: 'sonnet', label: 'Sonnet', isDefault: false, efforts } + ] + } + // The at-rest answer for a chat with nothing saved (readStructuredAgentSessionOptionsAtRest). + const AT_REST = { + models: FRESH_LIST.models, + listedModelIds: ['opus', 'sonnet'], + current: { model: 'opus', effort: 'high', defaulted: ['model', 'effort'] } + } + + it.each(['list', 'answer'])( + 'names Opus High and pins both on the first send, the %s landing first', + async (first) => { + let landList = (): void => {} + let landAnswer = (): void => {} + const list = new Promise((resolve) => (landList = () => resolve(FRESH_LIST))) + const atRest = new Promise((resolve) => (landAnswer = () => resolve(AT_REST))) + answer({ modelCatalog: () => list, options: () => atRest }) + const { result, unmount } = renderOptions( + { ...ATTACHED, agent: 'claude' }, + mutateWith(async () => null).mutate + ) + for (const land of first === 'list' ? [landList, landAnswer] : [landAnswer, landList]) { + await act(async () => { + land() + await tick() + }) + } + await waitFor(() => + expect(currentValue(result.current.optionSnapshot, 'model')).toBe('opus') + ) + expect(currentValue(result.current.optionSnapshot, 'effort')).toBe('high') + const { optionStateRef, optionView } = result.current.sendSource + expect( + structuredAgentSessionSendSelection(optionView(optionStateRef.current, undefined, {})) + ).toEqual({ model: 'opus', effort: 'high' }) + unmount() + } + ) + }) + + it.each([ + // Claude replies with the values Orca set, so an effort it keeps is not in the reply. + { + agent: 'claude' as const, + tiers: [[], []], + current: { model: 'opus', effort: 'high' }, + reply: { model: 'second' }, + shown: ['effort', 'high'] + }, + // Codex writes Standard when the new model lacks the tier (codex-structured-service-tier.ts). + { + agent: 'codex' as const, + tiers: [[{ value: 'priority', label: 'Fast' }], [{ value: 'flex', label: 'Flex' }]], + current: { model: 'opus', serviceTier: 'priority' }, + reply: { model: 'second', serviceTier: 'default' }, + shown: ['serviceTier', 'default'] + } + ])( + 'a $agent model pick shows what its reply names and keeps what it leaves out', + async (pick) => { + const efforts = ['low', 'high'].map((value) => ({ value, label: value })) + const live = { + models: ['opus', 'second'].map((id, index) => ({ + id, + label: id, + isDefault: index === 0, + efforts, + serviceTiers: pick.tiers[index] + })), + current: { ...pick.current, confirmed: ['model'] } + } + let reads = 0 + // The read after the pick never lands, so only the reply speaks. + answer({ options: () => (++reads === 1 ? Promise.resolve(live) : new Promise(() => {})) }) + const { mutate } = mutateWith(async () => ({ + key: 'model', + value: 'second', + options: pick.reply + })) + const { result, unmount } = renderOptions({ ...REOPENED, agent: pick.agent }, mutate) + await waitFor(() => expect(currentValue(result.current.optionSnapshot, 'model')).toBe('opus')) + await act(async () => { + await result.current.setStructuredOption('model', 'second') + }) + expect(currentValue(result.current.optionSnapshot, 'model')).toBe('second') + expect(currentValue(result.current.optionSnapshot, pick.shown[0]!)).toBe(pick.shown[1]) + unmount() + } + ) + it.each([true, false, undefined])( "reports the session's own image answer, %s, once its options read lands", async (acceptsImages) => { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index 60b1522890dd..e140cf714932 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -10,7 +10,6 @@ import type { SessionOptionsSurface } from '../../../../shared/native-chat-sessi import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode' import { canSetStructuredAgentSessionOption, - commitStructuredAgentSessionOptionReply, commitStructuredAgentSessionOptionValues, lockedStructuredAgentSessionOptionSnapshot, structuredAgentSessionOptionPicks, @@ -157,11 +156,10 @@ export function useStructuredAgentSessionOptions(args: { ) if (result && sameRecord() && currentPick()) { const returned = result.options ?? { [id]: encoded } - const full = isCurrent() ? result.options : undefined - const committed = full ?? { [id]: returned[id] ?? encoded } + const committed = isCurrent() ? returned : { [id]: returned[id] ?? encoded } updateOptionState((current) => current.record === targetRecord - ? commitStructuredAgentSessionOptionReply(current, id, committed[id] ?? encoded, full) + ? commitStructuredAgentSessionOptionValues(current, committed) : current ) // The launch seed names the model an effort-only pick was made under. diff --git a/src/shared/structured-agent-session-option-view.test.ts b/src/shared/structured-agent-session-option-view.test.ts index c50abdc4f793..bda0240f64b0 100644 --- a/src/shared/structured-agent-session-option-view.test.ts +++ b/src/shared/structured-agent-session-option-view.test.ts @@ -5,7 +5,6 @@ import { applyStructuredAgentSessionModelCatalog, applyStructuredAgentSessionOptions, commitStructuredAgentSessionOption, - commitStructuredAgentSessionOptionReply, createStructuredAgentSessionOptionState, structuredAgentSessionOptionSnapshot, type StructuredAgentSessionOptionState @@ -169,13 +168,9 @@ describe("a chat's model and effort, apart from the defaults filling them in", ( expect(carried(state)).toBeUndefined() const effort = commitStructuredAgentSessionOption(state, 'effort', 'xhigh') expect(carried(effort)).toEqual({ effort: 'xhigh' }) - // A model pick leaves the effort for the host to reconcile; its full reply speaks for it. + // A model pick leaves the effort for the host to reconcile. const model = commitStructuredAgentSessionOption(effort, 'model', 'sonnet') expect(carried(model)).toEqual({ model: 'sonnet', effort: 'xhigh' }) - const reconciled = commitStructuredAgentSessionOptionReply(model, 'model', 'sonnet', { - model: 'sonnet' - }) - expect(carried(reconciled)).toEqual({ model: 'sonnet' }) const restarted = applyStructuredAgentSessionOptions(fresh(), CLAUDE_SESSION_OPTION_CATALOG, { models: CLI302, listedModelIds: listed, diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index 5b75806120d2..a31c989d718b 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -216,26 +216,9 @@ export function commitStructuredAgentSessionOption( return { ...state, pendingId: null } } -/** A pick's reply. One carrying the host's full value set (`options`) speaks for each of the - * chat's own values, as a read does: a value it reconciled away is gone. */ -export function commitStructuredAgentSessionOptionReply( - state: StructuredAgentSessionOptionState, - id: string, - encoded: string, - options: Readonly> | undefined -): StructuredAgentSessionOptionState { - const next = commitStructuredAgentSessionOptionValues(state, options ?? { [id]: encoded }) - for (const key of options ? (['effort', 'fastMode', 'serviceTier'] as const) : []) { - if (options?.[key] === undefined && next.record.values[key]?.source !== 'default') { - delete next.record.values[key] - } - } - return next -} - -/** Whether a held pick of `modelId`, before any host can reconcile it, keeps the chat's `id` value - * (decoded or encoded): the model's controls offer it. A model the list does not describe keeps - * every value, since nothing says what it takes. */ +/** Whether a pick of `modelId` held before launch keeps another held value (decoded or encoded): + * no host exists yet to reconcile it, so only what the model's controls offer stays. A model the + * list does not describe keeps every value, since nothing says what it takes. */ export function structuredAgentSessionModelOffers( state: Pick, modelId: string, From 4f3ef913306e828eff66203534f375ac4f3fde6a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:12:53 -0700 Subject: [PATCH 09/34] fix(native-chat): a tier the drawn model doesn't offer shows as Standard A Speed value another listed model offers but the named model doesn't is drawn as Standard, which is what the host reconciles it to, while the chat's record keeps the host's value. Effort, a tier no listed model offers, an unlisted model and a stand-in model's controls still show what the host holds. After a lost pick reply the picker no longer shows the old tier as a raw extra choice. --- ...se-mobile-structured-agent-options.test.ts | 71 +++++++++++++++++++ ...-structured-agent-session-options.test.tsx | 22 +++++- ...tructured-agent-session-option-snapshot.ts | 57 +++++++++++++-- .../structured-agent-session-options.ts | 14 ++-- 4 files changed, 151 insertions(+), 13 deletions(-) diff --git a/mobile/src/session/use-mobile-structured-agent-options.test.ts b/mobile/src/session/use-mobile-structured-agent-options.test.ts index 7d4c46ff9016..11e36aae21be 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.test.ts @@ -146,6 +146,7 @@ type ProbeProps = { sessionId: string | null fence: number | null mutate: StructuredAgentSessionMutate + optionStamp?: string onRender: (controller: Controller) => void } @@ -158,6 +159,7 @@ function Probe(props: ProbeProps): null { enabled: true, fence: props.fence, mutate: props.mutate, + ...(props.optionStamp ? { optionStamp: props.optionStamp } : {}), hostSupport: { permissionModes: true } }) ) @@ -671,3 +673,72 @@ describe('useMobileStructuredAgentOptions host catalog', () => { await harness.unmount() }) }) + +describe('useMobileStructuredAgentOptions after a lost pick reply', () => { + const tiers = (value: string, label: string) => [{ value, label }] + const answer = (model: string, serviceTier: string): AgentSessionOptionsResult => ({ + models: [ + { + id: 'm1', + label: 'M1', + isDefault: true, + efforts: [], + serviceTiers: tiers('priority', 'Fast') + }, + { id: 'm2', label: 'M2', isDefault: false, efforts: [], serviceTiers: tiers('flex', 'Flex') } + ], + listedModelIds: ['m1', 'm2'], + current: { model, serviceTier, confirmed: ['model'] } + }) + const speed = (controller: Controller) => { + const kind = descriptorFor(controller.optionSnapshot, 'serviceTier')?.kind + return kind?.type === 'select' + ? { value: kind.currentValue, choices: kind.choices.map((choice) => choice.value) } + : null + } + + it('draws the tier the host reconciled to, stays usable through a failed read, and takes the reconnect read', async () => { + const pendingRead = deferred() + const reads = [ + () => Promise.resolve(answer('m1', 'priority')), + () => Promise.reject(new Error('offline')), + () => pendingRead.promise, + () => Promise.resolve(answer('m2', 'flex')) + ] + const client = optionsClient(() => reads.shift()!()) + const { mutate } = recordingMutate(async () => ({ status: 'unknown' })) + const harness = await mountOptions({ ...BASE, client: client.client, mutate }) + expect(speed(harness.current())?.value).toBe('priority') + + // The host switched and set Standard, but its reply never arrived. + await act(async () => { + await harness.current().setStructuredOption('model', 'm2') + }) + expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe('m2') + expect(speed(harness.current())).toEqual({ value: 'default', choices: ['default', 'flex'] }) + expect(harness.current().pendingOptionId).toBeNull() + + // The status-triggered read fails: nothing changes. + await harness.rerender({ optionStamp: 'status-1' }) + expect(speed(harness.current())?.value).toBe('default') + + // A read in flight when the user picks Flex answers for the old state and is dropped. + await harness.rerender({ optionStamp: 'status-2' }) + let picked = false + await act(async () => { + picked = await harness.current().setStructuredOption('serviceTier', 'flex') + }) + expect(picked).toBe(true) + await act(async () => pendingRead.resolve(answer('m1', 'priority'))) + await settle() + expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe('m2') + expect(speed(harness.current())?.value).toBe('flex') + + // Reconnected, the host's answer stands. + await harness.rerender({ optionStamp: 'status-3' }) + expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe('m2') + expect(speed(harness.current())?.value).toBe('flex') + expect(client.optionReads()).toBe(4) + await harness.unmount() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx index 6b6935a9d596..dd3e39d75d73 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx @@ -714,9 +714,25 @@ describe('useStructuredAgentSessionOptions', () => { current: { model: 'opus', serviceTier: 'priority' }, reply: { model: 'second', serviceTier: 'default' }, shown: ['serviceTier', 'default'] + }, + // Codex's reply leaves out a tier both models offer, which it keeps. + { + agent: 'codex' as const, + tiers: [[{ value: 'priority', label: 'Fast' }], [{ value: 'priority', label: 'Fast' }]], + current: { model: 'opus', serviceTier: 'priority' }, + reply: { model: 'second' }, + shown: ['serviceTier', 'priority'] + }, + // A configured tier no model lists (Flex) still runs, so it shows as held. + { + agent: 'codex' as const, + tiers: [[{ value: 'priority', label: 'Fast' }], [{ value: 'priority', label: 'Fast' }]], + current: { model: 'opus', serviceTier: 'flex' }, + reply: { model: 'second' }, + shown: ['serviceTier', 'flex'] } ])( - 'a $agent model pick shows what its reply names and keeps what it leaves out', + 'a $agent model pick shows what its reply names and keeps what it leaves out, $shown', async (pick) => { const efforts = ['low', 'high'].map((value) => ({ value, label: value })) const live = { @@ -744,6 +760,10 @@ describe('useStructuredAgentSessionOptions', () => { }) expect(currentValue(result.current.optionSnapshot, 'model')).toBe('second') expect(currentValue(result.current.optionSnapshot, pick.shown[0]!)).toBe(pick.shown[1]) + // The reply's or the host's value, never one the picker filled in. + expect(descriptor(result.current.optionSnapshot, pick.shown[0]!)?.valueSource).toBe( + 'dispatched' + ) unmount() } ) diff --git a/src/shared/structured-agent-session-option-snapshot.ts b/src/shared/structured-agent-session-option-snapshot.ts index 21e7f3c75607..4c78eab4b2da 100644 --- a/src/shared/structured-agent-session-option-snapshot.ts +++ b/src/shared/structured-agent-session-option-snapshot.ts @@ -11,6 +11,7 @@ import { type NativeChatShownModel } from './native-chat-session-option-snapshot' import type { SessionOptionDescriptor, SessionOptionValue } from './native-chat-session-options' +import type { TrackedNativeChatSessionOption } from './native-chat-session-option-state' import { serviceTierOption } from './structured-agent-session-discovered-model' import { structuredAgentSessionModelId, @@ -62,6 +63,47 @@ export function structuredAgentSessionShownModel( } } +/** The row the list describes `modelId` by; none for a model it lacks, or one outside the rows the + * chat's own listing names, which says nothing about what it takes. */ +export function structuredAgentSessionDescribedModel( + state: Pick, + modelId: string +): CatalogModel | undefined { + const model = state.catalog?.models.find((entry) => entry.id === modelId) + return model && (!state.verifiedModelIds || state.verifiedModelIds.includes(modelId)) + ? model + : undefined +} + +/** What the named model's controls draw. A select value another listed model offers but this one + * doesn't is what the host replaces with the default, so the row draws that; the record keeps the + * host's value. Effort (the host keeps and runs it) and a value no listed model offers stay as + * held. A send that ever carries one of these values must read it through here. */ +function valuesUnderControls( + catalog: AgentSessionOptionCatalog, + controls: readonly CatalogOption[], + values: Readonly> +): Readonly> { + let drawn = values + for (const option of controls) { + const tracked = values[option.id] + const offers = (entry: CatalogOption): boolean => + entry.id === option.id && + entry.kind.type === 'select' && + entry.kind.choices.some((choice) => choice.value === tracked?.value) + if ( + option.kind.type === 'select' && + option.id !== 'effort' && + tracked && + !offers(option) && + catalog.models.some((model) => model.options.some(offers)) + ) { + drawn = { ...drawn, [option.id]: { value: option.kind.defaultValue, source: 'default' } } + } + } + return drawn +} + /** With no model known, a saved speed other than Standard still shows, and Standard turns it off. */ function modellessControls( catalog: AgentSessionOptionCatalog, @@ -96,6 +138,13 @@ function modelOptionSnapshot(state: StructuredAgentSessionOptionState): SessionO models, record ) + const controls = controlsModelId + ? (models.find((model) => model.id === controlsModelId)?.options ?? []) + : modellessControls(catalog, record) + // Only the named model's listed controls speak for its values; a stand-in's or an unlisted + // model's draw what the host holds. + const normalises = + shown.currentId !== null && structuredAgentSessionDescribedModel(state, shown.currentId) const snapshot = models.length === 0 ? [] @@ -106,10 +155,10 @@ function modelOptionSnapshot(state: StructuredAgentSessionOptionState): SessionO modelLabel: 'Model', liveTransport: 'agent-session', model: shown, - controls: controlsModelId - ? (models.find((model) => model.id === controlsModelId)?.options ?? []) - : modellessControls(catalog, record), - values: record.values, + controls, + values: normalises + ? valuesUnderControls(catalog, controls, record.values) + : record.values, modelIsCliDefault: isCliDefault }) return snapshot.length > 0 || state.catalogSource === 'live' diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index a31c989d718b..cab872f05c38 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -10,7 +10,10 @@ import { STRUCTURED_LAUNCH_SEED_OPTION_IDS } from './native-chat-session-option-defaults' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from './agent-chat-permission-mode' -import { structuredAgentSessionShownModel } from './structured-agent-session-option-snapshot' +import { + structuredAgentSessionDescribedModel, + structuredAgentSessionShownModel +} from './structured-agent-session-option-snapshot' import type { SessionOptionValue } from './native-chat-session-options' import type { AgentSessionModelCatalogResult, @@ -225,13 +228,8 @@ export function structuredAgentSessionModelOffers( id: string, value: SessionOptionValue ): boolean { - const model = state.catalog?.models.find((entry) => entry.id === modelId) - if ( - id === 'model' || - id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID || - !model || - (state.verifiedModelIds !== undefined && !state.verifiedModelIds.includes(modelId)) - ) { + const model = structuredAgentSessionDescribedModel(state, modelId) + if (id === 'model' || id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID || !model) { return true } const option = model.options.find((entry) => entry.id === id) From cc477af9fccb63ac4f3a9c7285c0fe4af7593116 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:22:36 -0700 Subject: [PATCH 10/34] fix(native-chat): a never-sent chat names and pins its default after a reopen The options answer no longer marks the default model and effort as stand-ins for a chat this host started that has sent nothing, so a chat reopened after a restart or on the phone names them and its first send pins them, as before. A chat taken from history, or one that has sent, keeps the mark. --- ...-structured-effort-default-at-rest.test.ts | 12 ++- ...structured-session-reading-control.test.ts | 2 + ...uctured-agent-session-options-read.test.ts | 85 +++++++++++++++++-- .../structured-agent-session-options-read.ts | 12 ++- ...gent-session-registered-definition.test.ts | 9 +- ...ured-agent-new-chat-effort-at-rest.test.ts | 17 ++-- ...claude-auto-mode-host-catalog.unit.test.ts | 9 +- 7 files changed, 128 insertions(+), 18 deletions(-) diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 774e1cc4aa3f..453cdc4886b5 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -145,6 +145,7 @@ function restingRecord(options: Record): AgentSessionRecord { return { provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }, + providerHandleChain: [], location: { executionHostId: 'local', wslDistro: null }, // Why released: a chat at rest has no live claim, so its next start reads the current account. lease: { claimStatus: 'released' }, @@ -175,7 +176,13 @@ function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) { const resting = { child: null, params: { provider: 'claude' }, - journal: { threadGoal: () => null, contextUsage: () => null, context: { floor: () => null } } + journal: { + threadGoal: () => null, + newestTurn: () => null, + submissions: () => [], + contextUsage: () => null, + context: { floor: () => null } + } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. const context = { @@ -283,6 +290,7 @@ describe('Claude effort default at rest', () => { const record = { provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, + providerHandleChain: [], location: { executionHostId: 'local', wslDistro: null }, options: { model: 'gpt-unlisted', effort: 'high' } } as unknown as AgentSessionRecord @@ -365,6 +373,7 @@ describe('Claude effort default at rest', () => { const record = { provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, + providerHandleChain: [], location: { executionHostId: 'local', wslDistro: null }, options: { model: 'gpt-5.5', effort: 'high' } } as unknown as AgentSessionRecord @@ -382,6 +391,7 @@ describe('Claude effort default at rest', () => { const record = { provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, + providerHandleChain: [], location: { executionHostId: 'local', wslDistro: null }, options: { model: 'gpt-5.5' } } as unknown as AgentSessionRecord diff --git a/src/main/claude/claude-structured-session-reading-control.test.ts b/src/main/claude/claude-structured-session-reading-control.test.ts index 9021b466bb3c..ba441268f3e0 100644 --- a/src/main/claude/claude-structured-session-reading-control.test.ts +++ b/src/main/claude/claude-structured-session-reading-control.test.ts @@ -69,6 +69,8 @@ function persistedTarget( }) satisfies StructuredAgentSessionLinkageJournal['visitItemsWithLinkage'], itemBody: (itemId: string) => persisted.get(itemId) ?? null, threadGoal: () => null, + newestTurn: () => null, + submissions: () => [], threadGoalSource: () => ({ goal: null, row: null }), epoch: 'test' }) as unknown as AgentSessionJournal diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index 1fbd8f2e9aef..e53d244ffb1e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -23,6 +23,7 @@ function restingRecord(): AgentSessionRecord { return { provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/homes/a' }, + providerHandleChain: [], location: { executionHostId: 'local', wslDistro: null }, options: {} } as unknown as AgentSessionRecord @@ -46,7 +47,12 @@ describe('options at rest', () => { const starting = { child: { phase: 'starting' }, params: { provider: 'codex' }, - journal: { threadGoal: () => null, context: { floor: () => null } } + journal: { + threadGoal: () => null, + newestTurn: () => null, + submissions: () => [], + context: { floor: () => null } + } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read touches only the declared context fields. const context = { @@ -69,10 +75,10 @@ describe('options at rest', () => { const older = await readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: false }) - // The account default an older client needs is marked as one, which that client ignores. + // A new chat that has sent nothing runs that default, which its first send pins. expect(older.current).toEqual({ model: 'gpt-account', - defaulted: ['model'], + defaulted: [], permissionMode: 'ask' }) expect(read).toHaveBeenLastCalledWith({ agent: 'codex', sessionId: SESSION, savedOnly: true }) @@ -85,7 +91,12 @@ describe('options at rest', () => { const conversation = () => ({ child: live ? { fence: 7 } : null, params: { provider: 'codex' }, - journal: { threadGoal: () => null, context: { floor: () => null } } + journal: { + threadGoal: () => null, + newestTurn: () => null, + submissions: () => [], + context: { floor: () => null } + } }) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read touches only the declared context fields. const context = { @@ -133,7 +144,12 @@ describe('options at rest', () => { const resting = { child: null, params: { provider: 'codex' }, - journal: { threadGoal: () => null, context: { floor: () => null } } + journal: { + threadGoal: () => null, + newestTurn: () => null, + submissions: () => [], + context: { floor: () => null } + } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: options reads touch only these context members. const context = { @@ -186,6 +202,43 @@ describe('options at rest', () => { ) }) +describe('a chat that has sent nothing', () => { + it.each([ + ['started here names its default as its own', [], null, []], + ['taken from history still marks it', [{ origin: 'adopted' }], null, ['model']], + ['with a turn still marks it', [], { turnId: 'turn-1' }, ['model']] + ])('%s', async (_name, chain, turn, defaulted) => { + const record = { ...restingRecord(), providerHandleChain: chain } + const resting = { + child: null, + params: { provider: 'codex' }, + journal: { + threadGoal: () => null, + newestTurn: () => turn, + submissions: () => [], + context: { floor: () => null } + } + } + const listed = { id: 'gpt-account', label: 'Model', isDefault: true, efforts: [] } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read touches only the declared context fields. + const context = { + deps: { + adapter: {}, + agents: NO_STRUCTURED_AGENTS, + store: { getRecord: () => record }, + modelCatalog: { read: async () => ({ origin: 'probe', models: [listed], fetchedAt: 1 }) } + }, + serialize: (_sessionId: string, task: () => Promise) => task(), + openConversation: async () => resting, + conversation: async () => resting + } as unknown as StructuredAgentSessionMutationContext + const read = await readStructuredAgentSessionOptions(context, SESSION, { + readsWithoutModel: false + }) + expect(read.current).toMatchObject({ model: 'gpt-account', defaulted }) + }) +}) + describe('live Codex option reads', () => { it('keeps newer saved picks after a cold live listing waits without holding the session lane', async () => { const listing = Promise.withResolvers() @@ -203,7 +256,13 @@ describe('live Codex option reads', () => { const live = { child, params: { provider: 'codex' }, - journal: { threadGoal: () => null, contextUsage: () => null, context: { floor: () => null } } + journal: { + threadGoal: () => null, + newestTurn: () => null, + submissions: () => [], + contextUsage: () => null, + context: { floor: () => null } + } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read touches only the declared context fields. const context = { @@ -239,7 +298,12 @@ describe('live Codex option reads', () => { const live = { child: { fence: 7 }, params: { provider: 'codex' }, - journal: { threadGoal: () => null, context: { floor: () => null } } + journal: { + threadGoal: () => null, + newestTurn: () => null, + submissions: () => [], + context: { floor: () => null } + } } const failure = new Error('model/list unavailable') // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the failed read ends before touching the remaining context fields. @@ -277,7 +341,12 @@ describe('live Codex option reads', () => { const live = { child: { fence: 7 }, params: { provider: 'codex' }, - journal: { threadGoal: () => null, context: { floor: () => null } } + journal: { + threadGoal: () => null, + newestTurn: () => null, + submissions: () => [], + context: { floor: () => null } + } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read touches only the declared context fields. const context = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 2b55abbde87a..f90f00b8843a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -286,8 +286,16 @@ export async function readStructuredAgentSessionOptions( ...(shownSaved('effort') ? { effort: selected.current.effort } : {}) } // A chat that saved a value has no default for it: the saved value shows, or the child it - // refused runs what it reports, which the next send records. - const defaulted = (options.current.defaulted ?? []).filter((key) => saved?.[key] === undefined) + // refused runs what it reports, which the next send records. A chat started here that has sent + // nothing runs the default it shows, which its first send pins; one taken from history may not. + const neverSent = + record !== undefined && + record.providerHandleChain[0]?.origin !== 'adopted' && + session.journal.newestTurn() === null && + session.journal.submissions().length === 0 + const defaulted = neverSent + ? [] + : (options.current.defaulted ?? []).filter((key) => saved?.[key] === undefined) const confirmed = current.confirmed?.filter((key) => key === 'model' ? current.model === options.current.model diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts index c8a814592d51..f55eeb191615 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts @@ -75,7 +75,14 @@ function readAtRest(agents: StructuredAgentRegistry, live?: StructuredAgentSessi const resting = { child: live ? { fence: 1 } : null, params: { provider: RECORD.provider }, - journal: { threadGoal: () => null, contextUsage: () => null, context: { floor: () => null } } + journal: { + threadGoal: () => null, + newestTurn: () => null, + // A chat that has sent, so a value it never saved reads as a default. + submissions: () => [{}], + contextUsage: () => null, + context: { floor: () => null } + } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the read touches only these members. const context = { diff --git a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts index 7aa9b721531a..0651048b194e 100644 --- a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts +++ b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts @@ -158,6 +158,7 @@ function newChatRecord( sessionId: `${agent}-new`, provider: agent, accountHome: HOMES[agent]!, + providerHandleChain: [], launchDirectory: workspacePath, location: { executionHostId: 'local', @@ -197,7 +198,13 @@ function host(agent: string, record: AgentSessionRecord) { const resting = { child: null, params: { provider: agent }, - journal: { threadGoal: () => null, contextUsage: () => null, context: { floor: () => null } } + journal: { + threadGoal: () => null, + newestTurn: () => null, + submissions: () => [], + contextUsage: () => null, + context: { floor: () => null } + } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the at-rest read touches only these members. const context = { @@ -266,7 +273,7 @@ describe("a new chat's effort pill before its agent starts", () => { expect(atRest.current).toEqual({ model, effort, - defaulted: ['model', 'effort'], + defaulted: [], ...modeOf(agent) }) } @@ -298,7 +305,7 @@ describe("a new chat's effort pill before its agent starts", () => { // The account default is not what this repo runs, so its effort is never claimed. expect(atRest.current).toEqual({ model: 'gpt-5.5', - defaulted: ['model'], + defaulted: [], permissionMode: 'ask' }) expect(atRest.conversationCommands).toEqual(['clear', 'compact']) @@ -310,7 +317,7 @@ describe("a new chat's effort pill before its agent starts", () => { expect(atRest.current).toEqual({ model: 'gpt-5.5', effort: 'medium', - defaulted: ['model', 'effort'], + defaulted: [], permissionMode: 'ask' }) }) @@ -321,7 +328,7 @@ describe("a new chat's effort pill before its agent starts", () => { expect(atRest.current).toEqual({ model: 'grok-4', effort: 'high', - defaulted: ['model'], + defaulted: [], ...modeOf('grok') }) expect(settled.effort?.value).toBe('high') diff --git a/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts index 3c45b200dd5d..0f778892f754 100644 --- a/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts +++ b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts @@ -105,6 +105,7 @@ async function hostCatalog(listingLands: Promise) { const record = agentSessionRecordFixture({ sessionId: SESSION, accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(dir, 'claude-home') }, + providerHandleChain: [], options: { permissionMode: 'ask' } }) const modelCatalog = createAgentModelCatalogService({ @@ -133,7 +134,13 @@ async function hostCatalog(listingLands: Promise) { const resting = { child: null, params: { provider: 'claude' }, - journal: { threadGoal: () => null, contextUsage: () => null, context: { floor: () => null } } + journal: { + threadGoal: () => null, + newestTurn: () => null, + submissions: () => [], + contextUsage: () => null, + context: { floor: () => null } + } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. const context = { From cfc2a14eff4193e2cb482370025da6f2323d636f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:28:32 -0700 Subject: [PATCH 11/34] fix(native-chat): narrow the missing record as null and type the new-chat fixture's handle chain --- .../structured-agent-session-options-read.ts | 2 +- .../runtime/structured-agent-new-chat-effort-at-rest.test.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index f90f00b8843a..9d31acd6a203 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -289,7 +289,7 @@ export async function readStructuredAgentSessionOptions( // refused runs what it reports, which the next send records. A chat started here that has sent // nothing runs the default it shows, which its first send pins; one taken from history may not. const neverSent = - record !== undefined && + record !== null && record.providerHandleChain[0]?.origin !== 'adopted' && session.journal.newestTurn() === null && session.journal.submissions().length === 0 diff --git a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts index 0651048b194e..7a1a317f2baa 100644 --- a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts +++ b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts @@ -153,12 +153,13 @@ function newChatRecord( workspacePath: string, options: Record ): AgentSessionRecord { + const providerHandleChain: AgentSessionRecord['providerHandleChain'] = [] // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the catalog service and the at-rest read touch only these fields. return { sessionId: `${agent}-new`, provider: agent, accountHome: HOMES[agent]!, - providerHandleChain: [], + providerHandleChain, launchDirectory: workspacePath, location: { executionHostId: 'local', From 936621cfb046ad7ef867bf1790709b5adeb8c8c4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:41:52 -0700 Subject: [PATCH 12/34] test(native-chat): the unloaded-turn context test's record store answers null for a missing record --- src/main/claude/claude-context-usage-unloaded-turn.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6ad422fa1606..e96102682489 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -98,7 +98,7 @@ function readOptions(journal: AgentSessionJournal, agents: StructuredAgentRegist readOptions: async () => ({ models: [], current: { model: 'opus' } }) }, agents, - store: { getRecord: () => undefined } + store: { getRecord: () => null } }, serialize: (_sessionId: string, task: () => Promise) => task(), openConversation: async () => running, From 0084b38fe831636405c5ff3d0c3ce51ba231abea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:50:42 -0700 Subject: [PATCH 13/34] fix(native-chat): a chat's speed shows what its next turn runs A Codex tier the drawn model lacks shows as Standard, which is how Codex runs it, except Flex, which Codex passes regardless: established Flex keeps showing, and after a model pick the host hasn't answered it shows no selection until the reply or a read. Each Codex turn sends the same effective tier without rewriting the chat's saved one, and a new chat's create carries held picks as the picker draws them, settling them so they aren't applied again after publish. --- .../use-mobile-structured-agent-options.ts | 4 +- ...odex-structured-service-tier-shown.test.ts | 115 ++++++++++++++++++ .../codex/codex-structured-service-tier.ts | 25 +++- src/main/codex/codex-structured-turn-start.ts | 8 +- .../use-structured-agent-session-options.ts | 3 +- ...gent-session-launch-create-message.test.ts | 51 ++++++++ ...red-agent-session-launch-create-message.ts | 16 ++- ...structured-agent-session-launch-options.ts | 15 +++ ...tructured-agent-session-option-snapshot.ts | 76 ++++++++---- .../structured-agent-session-option-state.ts | 6 +- .../structured-agent-session-options.ts | 13 ++ 11 files changed, 296 insertions(+), 36 deletions(-) create mode 100644 src/main/codex/codex-structured-service-tier-shown.test.ts diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 18d767fdd72c..56d49c4371ea 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -11,7 +11,7 @@ import { applyStructuredAgentSessionOptions, canSetStructuredAgentSessionOption, commitStructuredAgentSessionOption, - commitStructuredAgentSessionOptionValues, + commitStructuredAgentSessionOptionReply, createStructuredAgentSessionOptionState, structuredAgentSessionOptionPicks, type StructuredAgentSessionOptionState @@ -238,7 +238,7 @@ export function useMobileStructuredAgentOptions(args: { const committed = result.value.options ?? { [id]: encoded } updateOptionState((current) => current.record === targetRecord && result.sameFence - ? commitStructuredAgentSessionOptionValues(current, committed) + ? commitStructuredAgentSessionOptionReply(current, committed) : current ) // Only an accepted pick: an `unknown` outcome commits optimistically to the diff --git a/src/main/codex/codex-structured-service-tier-shown.test.ts b/src/main/codex/codex-structured-service-tier-shown.test.ts new file mode 100644 index 000000000000..407ec0248f40 --- /dev/null +++ b/src/main/codex/codex-structured-service-tier-shown.test.ts @@ -0,0 +1,115 @@ +// What the picker shows for a chat's speed is what its next turn runs, on any host's answer. + +import { describe, expect, it, vi } from 'vitest' +import { CODEX_SESSION_OPTION_CATALOG } from '../../shared/agent-session-option-catalog-claude-codex' +import { + applyStructuredAgentSessionOptions, + commitStructuredAgentSessionOption, + commitStructuredAgentSessionOptionReply, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot, + type StructuredAgentSessionOptionState +} from '../../shared/structured-agent-session-options' +import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' +import type { CodexSession } from './codex-structured-session-state' +import { startCodexTurn } from './codex-structured-turn-start' + +const tiers = (...values: string[]) => values.map((value) => ({ value, label: value })) +// M1 offers Fast and Flex; M2 offers only Ultrafast. +const MODELS = [ + { id: 'm1', label: 'M1', isDefault: true, efforts: [], serviceTiers: tiers('priority', 'flex') }, + { id: 'm2', label: 'M2', isDefault: false, efforts: [], serviceTiers: tiers('ultrafast') } +] + +/** The tier the first turn after a start carries for the chat's saved options. */ +async function firstTurnTier(saved: Record): Promise { + const store = new AgentModelCatalogStore() + store.recordSuccess('codex-account', 'codex', { models: MODELS, origin: 'probe' }, 'discovery') + const request = vi.fn(async (_method: string, _params?: Record) => ({ + turn: { id: 'turn-1' } + })) + const session: CodexSession = { + connection: { + pid: 1, + closed: false, + request, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + }, + backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), + ended: false, + fence: 1, + acquisitionGeneration: 'generation-1', + threadId: 'thread-1', + prompts: new CodexAcquisitionWindow().prompts, + options: new Map(Object.entries(saved)), + reportedOptions: {}, + catalogAccess: { store, fingerprint: 'codex-account', accountHomePath: '/account' }, + dispatchEchoes: createCodexDispatchEchoes(), + turnOpenWaits: createCodexTurnOpenWaits(), + translator: null + } + await startCodexTurn(session, { + clientMessageId: 'message-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] } + }) + return request.mock.calls.find(([method]) => method === 'turn/start')?.[1]?.serviceTier +} + +/** An older host's answer at rest: the saved values, with no defaults marked. */ +function atRest(saved: Record): StructuredAgentSessionOptionState { + return applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('codex', CODEX_SESSION_OPTION_CATALOG), + CODEX_SESSION_OPTION_CATALOG, + { models: MODELS, current: saved } + ) +} + +function shownTier(state: StructuredAgentSessionOptionState): string | undefined { + const kind = structuredAgentSessionOptionSnapshot(state).find( + (entry) => entry.id === 'serviceTier' + )?.kind + return kind?.type === 'select' ? kind.currentValue : undefined +} + +describe("a Codex chat's speed, shown and sent", () => { + it('shows what the first turn runs, and keeps the saved tier for a model that offers it', async () => { + // Codex runs a tier the model lacks as Standard, but passes Flex regardless. + for (const [saved, runs] of [ + [{ model: 'm2', serviceTier: 'priority' }, 'default'], + [{ model: 'm2', serviceTier: 'flex' }, 'flex'], + [{ model: 'm1', serviceTier: 'priority' }, 'priority'] + ] as const) { + expect(shownTier(atRest(saved))).toBe(runs) + await expect(firstTurnTier(saved)).resolves.toBe(runs) + } + // Back on a model that offers it, the saved tier is the chat's again. + const back = commitStructuredAgentSessionOption( + atRest({ model: 'm2', serviceTier: 'priority' }), + 'model', + 'm1' + ) + expect(shownTier(back)).toBe('priority') + }) + + it('asserts no Flex after a model pick the host has not answered, until its reply', () => { + const picked = commitStructuredAgentSessionOption( + atRest({ model: 'm1', serviceTier: 'flex' }), + 'model', + 'm2' + ) + expect(shownTier(picked)).toBeUndefined() + // At rest the host saves the pick beside the chat's Flex, which the next start passes. + const answered = commitStructuredAgentSessionOptionReply(picked, { + model: 'm2', + serviceTier: 'flex' + }) + expect(shownTier(answered)).toBe('flex') + }) +}) diff --git a/src/main/codex/codex-structured-service-tier.ts b/src/main/codex/codex-structured-service-tier.ts index f61728c11af6..c1fd47324bdf 100644 --- a/src/main/codex/codex-structured-service-tier.ts +++ b/src/main/codex/codex-structured-service-tier.ts @@ -6,6 +6,7 @@ import type { import { readRecord } from './codex-item-field-readers' import type { CodexOpenedThread } from './codex-structured-thread-open' import type { CodexSession } from './codex-structured-session-state' +import { codexAcquireCatalogListing } from './codex-structured-catalog-entry' /** Codex's request value for a model's standard tier. */ export const CODEX_DEFAULT_SERVICE_TIER = 'default' @@ -93,7 +94,7 @@ function codexListsServiceTier( /** Codex also takes the thread's reported tier when no model lists it, such as a configured `flex`. */ export function codexOffersServiceTier( - session: CodexSession, + session: Pick, model: AgentSessionModelOption | undefined, models: readonly AgentSessionModelOption[], tier: string @@ -106,6 +107,28 @@ export function codexOffersServiceTier( ) } +/** A turn's options: an unoffered tier other than Flex runs as standard; the saved tier is kept. */ +export function codexTurnRequestOptions( + session: Pick & { + reportedOptions?: CodexSession['reportedOptions'] + } +): Record { + const options = Object.fromEntries(session.options) + const reportedOptions = session.reportedOptions ?? {} + const models = codexAcquireCatalogListing(session.catalogAccess)?.models ?? [] + const model = models.find((entry) => entry.id === (options.model ?? reportedOptions.model)) + const tier = options.serviceTier + if ( + tier && + tier !== 'flex' && + model?.serviceTiers && + !codexOffersServiceTier({ reportedOptions }, model, models, tier) + ) { + options.serviceTier = CODEX_DEFAULT_SERVICE_TIER + } + return options +} + /** The chat's tier, picked or reported, falls back to standard where the model does not offer it. */ export function dropUnlistedCodexServiceTier( session: CodexSession, diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 8f67013a76bc..4aba4a151d0f 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -13,6 +13,8 @@ import { import { isCodexAppServerUnsupportedError } from './codex-app-server-session' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' import { readCodexTurnId } from './codex-structured-thread-facts' +import { codexTurnRequestOptions } from './codex-structured-service-tier' +import type { CodexSession } from './codex-structured-session-state' import { codexRunningOrOpeningTurn, type CodexTurnOpenWaits @@ -48,7 +50,8 @@ export type CodexTurnHost = { connection: Pick threadId: string options: Map - reportedOptions?: { model?: string } + reportedOptions?: CodexSession['reportedOptions'] + catalogAccess?: CodexSession['catalogAccess'] dispatchEchoes: CodexDispatchEchoes activeTurnIds?: ReadonlySet /** Running turns whose interrupt Codex already answered: aborted, so never steered. */ @@ -146,8 +149,7 @@ export async function startCodexTurn( threadId: host.threadId, clientUserMessageId: input.clientMessageId, input: turnInputFor(input.body), - // Codex omits a tier the model does not list. - ...Object.fromEntries(host.options) + ...codexTurnRequestOptions(host) }, { timeoutMs: input.timeoutMs, onReply: input.onReply } ) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index e140cf714932..969da725ee37 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -10,6 +10,7 @@ import type { SessionOptionsSurface } from '../../../../shared/native-chat-sessi import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode' import { canSetStructuredAgentSessionOption, + commitStructuredAgentSessionOptionReply, commitStructuredAgentSessionOptionValues, lockedStructuredAgentSessionOptionSnapshot, structuredAgentSessionOptionPicks, @@ -159,7 +160,7 @@ export function useStructuredAgentSessionOptions(args: { const committed = isCurrent() ? returned : { [id]: returned[id] ?? encoded } updateOptionState((current) => current.record === targetRecord - ? commitStructuredAgentSessionOptionValues(current, committed) + ? commitStructuredAgentSessionOptionReply(current, committed) : current ) // The launch seed names the model an effort-only pick was made under. diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts index 376266a2619d..240998751d33 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts @@ -232,6 +232,57 @@ describe('the first message owned by create', () => { } ) + it.each([ + ['priority', 'default'], + ['flex', 'flex'] + ])( + 'carries a held %s as the picker draws it after the default model changes, and never again', + async (held, carried) => { + const tiers = (...values: string[]) => values.map((value) => ({ value, label: value })) + const efforts = tiers('medium', 'high') + const list = (defaultId: string) => ({ + origin: 'probe' as const, + fetchedAt: 1, + listingNamesConfiguredModel: true, + defaultHoldsInEveryWorkspace: true as const, + models: [ + { + id: 'm1', + label: 'M1', + isDefault: defaultId === 'm1', + efforts, + serviceTiers: tiers('priority', 'flex') + }, + { + id: 'm2', + label: 'M2', + isDefault: defaultId === 'm2', + efforts, + serviceTiers: tiers('ultrafast') + } + ] + }) + recordHostModelCatalogSnapshot(target, 'codex', null, list('m1')) + const create = deferred() + mocks.call.mockReturnValue(create.promise) + const launch = start() + const tierPick = holdStructuredAgentSessionLaunchOption(launch.sessionId, 'serviceTier', held) + holdStructuredAgentSessionLaunchOption(launch.sessionId, 'effort', 'high') + // The account's default moves before create goes out. + recordHostModelCatalogSnapshot(target, 'codex', null, list('m2')) + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + // Codex runs a tier the model lacks as Standard but passes Flex; effort stays as picked. + expect(createParams().options).toEqual({ effort: 'high', serviceTier: carried }) + create.resolve(created()) + await launch.launchResult + await expect(tierPick).resolves.toEqual({ + kind: 'accepted', + options: { effort: 'high', serviceTier: carried } + }) + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create']) + } + ) + it('returns a refused create once after the new draft, with no later send', async () => { const create = deferred() mocks.call.mockReturnValue(create.promise) diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts index ab288ae73a81..668a87b09f71 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts @@ -13,8 +13,11 @@ import { import { firstFrameOptionState } from '@/components/native-chat/use-structured-agent-session-option-state' import { applyStructuredLaunchHeldOptions, + projectStructuredLaunchHeldOptions, settleStructuredLaunchCreateOptions } from './structured-agent-session-launch-options' +import { structuredAgentSessionDrawnValues } from '../../../shared/structured-agent-session-option-snapshot' +import { encodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' /** Freeze what create carries once; an ambiguous reply always replays the same payload. */ export function prepareStructuredLaunchCreateMessage(state: StructuredLaunchState): void { @@ -62,8 +65,19 @@ function structuredLaunchCreateOptions( }) // Create has always carried options, so any host takes what the chat shows. const view = structuredAgentSessionOptionView(firstFrame, intent.seedOptions, selection.held) + const drawn = structuredAgentSessionDrawnValues(view) + // Each held value as the picker draws it; one drawn as nothing is not carried. + const held = Object.fromEntries( + Object.entries(selection.held).flatMap(([id, encoded]) => { + const value = id === 'model' || !(id in view.record.values) ? encoded : drawn[id]?.value + const projected = + value === undefined ? null : encodeStructuredAgentSessionOptionValue(id, value) + return projected === null ? [] : [[id, projected]] + }) + ) + projectStructuredLaunchHeldOptions(state, held) const options = { - ...selection.held, + ...held, ...structuredAgentSessionSendSelection(view) } return Object.keys(options).length ? options : undefined diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index fc7383450dd9..7fde3ad64b60 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -154,6 +154,21 @@ function settleHeldOption( state.callers.onSettled() } +/** Held picks become what create carries, so none is applied again after publish. */ +export function projectStructuredLaunchHeldOptions( + state: StructuredLaunchState, + held: Readonly> +): void { + const replies = repliesFor(state) + for (const id of Object.keys(state.selection.held)) { + if (held[id] === undefined) { + replies.get(id)?.({ kind: 'superseded' }) + replies.delete(id) + } + } + state.selection = { ...state.selection, held } +} + /** Selections included in the durable create are already the host's option intent. */ export function settleStructuredLaunchCreateOptions( state: StructuredLaunchState, diff --git a/src/shared/structured-agent-session-option-snapshot.ts b/src/shared/structured-agent-session-option-snapshot.ts index 4c78eab4b2da..468826ffb142 100644 --- a/src/shared/structured-agent-session-option-snapshot.ts +++ b/src/shared/structured-agent-session-option-snapshot.ts @@ -75,16 +75,18 @@ export function structuredAgentSessionDescribedModel( : undefined } -/** What the named model's controls draw. A select value another listed model offers but this one - * doesn't is what the host replaces with the default, so the row draws that; the record keeps the - * host's value. Effort (the host keeps and runs it) and a value no listed model offers stay as - * held. A send that ever carries one of these values must read it through here. */ +/** Codex passes Flex whether or not the model advertises it, so only Flex can run unadvertised. */ +const PASSED_SERVICE_TIER = 'flex' + +/** What the named model's controls draw: a tier it lacks that another model lists runs as the + * default; established Flex runs as is, and an unanswered model pick leaves it undrawn. */ function valuesUnderControls( catalog: AgentSessionOptionCatalog, controls: readonly CatalogOption[], - values: Readonly> + values: Readonly>, + modelUnresolved: boolean ): Readonly> { - let drawn = values + const drawn = { ...values } for (const option of controls) { const tracked = values[option.id] const offers = (entry: CatalogOption): boolean => @@ -98,12 +100,46 @@ function valuesUnderControls( !offers(option) && catalog.models.some((model) => model.options.some(offers)) ) { - drawn = { ...drawn, [option.id]: { value: option.kind.defaultValue, source: 'default' } } + if (option.id !== 'serviceTier' || tracked.value !== PASSED_SERVICE_TIER) { + drawn[option.id] = { value: option.kind.defaultValue, source: 'default' } + } else if (modelUnresolved) { + delete drawn[option.id] + } } } return drawn } +/** The values the picker draws, which a create carries for picks it holds. */ +export function structuredAgentSessionDrawnValues( + state: StructuredAgentSessionOptionState +): Readonly> { + return drawnModelControls(state)?.values ?? state.record.values +} + +/** The named model's controls and the values they draw; null before the host answers. */ +function drawnModelControls(state: StructuredAgentSessionOptionState) { + const { catalog, record } = state + if (!catalog || state.catalogSource === 'seed') { + return null + } + // A seeded default can name a model the static seed does not list yet. + const models = withTrackedNativeChatModel(catalog, catalog.models, { model: record.values.model }) + const shown = structuredAgentSessionShownModel(catalog, models, record) + const controls = shown.controlsModelId + ? (models.find((model) => model.id === shown.controlsModelId)?.options ?? []) + : modellessControls(catalog, record) + // Only the named model's listed controls speak for its values; a stand-in's or an unlisted + // model's draw what the host holds. + const normalises = + shown.shown.currentId !== null && + structuredAgentSessionDescribedModel(state, shown.shown.currentId) !== undefined + const values = normalises + ? valuesUnderControls(catalog, controls, record.values, record.modelUnresolved === true) + : record.values + return { catalog, models, ...shown, controls, values } +} + /** With no model known, a saved speed other than Standard still shows, and Standard turns it off. */ function modellessControls( catalog: AgentSessionOptionCatalog, @@ -123,28 +159,16 @@ function modellessControls( } function modelOptionSnapshot(state: StructuredAgentSessionOptionState): SessionOptionDescriptor[] { - const { catalog, record } = state - if (!catalog) { + if (!state.catalog) { return [] } + const drawn = drawnModelControls(state) // Until the host answers, the built-in list would paint a label its list may replace. - if (state.catalogSource === 'seed') { + if (!drawn) { return [PROVIDER_DEFAULT_MODEL_PLACEHOLDER] } - // A seeded default can name a model the static seed does not list yet. - const models = withTrackedNativeChatModel(catalog, catalog.models, { model: record.values.model }) - const { shown, controlsModelId, isCliDefault } = structuredAgentSessionShownModel( - catalog, - models, - record - ) - const controls = controlsModelId - ? (models.find((model) => model.id === controlsModelId)?.options ?? []) - : modellessControls(catalog, record) - // Only the named model's listed controls speak for its values; a stand-in's or an unlisted - // model's draw what the host holds. - const normalises = - shown.currentId !== null && structuredAgentSessionDescribedModel(state, shown.currentId) + const { catalog, models, shown, controlsModelId, isCliDefault, controls, values } = drawn + const { record } = state const snapshot = models.length === 0 ? [] @@ -156,9 +180,7 @@ function modelOptionSnapshot(state: StructuredAgentSessionOptionState): SessionO liveTransport: 'agent-session', model: shown, controls, - values: normalises - ? valuesUnderControls(catalog, controls, record.values) - : record.values, + values, modelIsCliDefault: isCliDefault }) return snapshot.length > 0 || state.catalogSource === 'live' diff --git a/src/shared/structured-agent-session-option-state.ts b/src/shared/structured-agent-session-option-state.ts index c53a2231d291..48e0f5520f0b 100644 --- a/src/shared/structured-agent-session-option-state.ts +++ b/src/shared/structured-agent-session-option-state.ts @@ -8,6 +8,8 @@ import type { TrackedNativeChatSessionOption } from './native-chat-session-optio export type StructuredAgentSessionOptionRecord = { agent: AgentType values: Record + /** The model is a pick no host answer has spoken for yet, so its other values may not hold. */ + modelUnresolved?: true } /** The values a complete options answer speaks for. */ @@ -26,7 +28,8 @@ export function cloneStructuredAgentSessionOptionRecord( agent: record.agent, values: Object.fromEntries( Object.entries(record.values).map(([id, tracked]) => [id, { ...tracked }]) - ) + ), + ...(record.modelUnresolved ? { modelUnresolved: true } : {}) } } @@ -59,6 +62,7 @@ export function replaceStructuredAgentSessionAnsweredValues( answered: Readonly>>, sourceFor: (id: string) => TrackedNativeChatSessionOption['source'] ): void { + delete record.modelUnresolved for (const id of ANSWERED_VALUE_IDS) { const value = answered[id] if (value !== undefined) { diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index cab872f05c38..2eeb0fef80c4 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -216,9 +216,22 @@ export function commitStructuredAgentSessionOption( } // Each value is the chat's own: a model pick leaves the others for the host to reconcile. state.record.values[id] = { value: decoded, source: 'dispatched' } + if (id === 'model') { + state.record.modelUnresolved = true + } return { ...state, pendingId: null } } +/** A pick's reply: the host has spoken for the model, so its values stand. */ +export function commitStructuredAgentSessionOptionReply( + state: StructuredAgentSessionOptionState, + values: Readonly> +): StructuredAgentSessionOptionState { + const next = commitStructuredAgentSessionOptionValues(state, values) + delete next.record.modelUnresolved + return next +} + /** Whether a pick of `modelId` held before launch keeps another held value (decoded or encoded): * no host exists yet to reconcile it, so only what the model's controls offer stays. A model the * list does not describe keeps every value, since nothing says what it takes. */ From daa9dc9caf74f476d06b3a7fdcb7b756abfe7e67 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:56:28 -0700 Subject: [PATCH 14/34] fix(native-chat): one speed rule for picker, host and turns; never-sent needs proof; create keeps held picks A Codex tier the model doesn't list shows and runs as Standard under one rule shared by the picker, the host's live reconciliation, each turn and the at-rest answer, which keeps the saved tier for a model that offers it. An older host's Flex shows as it reports it. A chat counts as never sent only when no turn, rewind or untrustworthy journal says otherwise. A new chat's create carries held picks as the picker draws them but keeps them as the user made them until create succeeds, so a refused create and retry still has the user's own pick, and a newer pick still applies after publish. --- .../use-mobile-structured-agent-options.ts | 4 +- ...-structured-effort-default-at-rest.test.ts | 2 +- ...structured-session-reading-control.test.ts | 1 - ...odex-structured-service-tier-shown.test.ts | 57 ++++----- .../codex/codex-structured-service-tier.ts | 19 ++- .../structured-agent-session-never-sent.ts | 26 ++++ ...uctured-agent-session-options-read.test.ts | 61 +++++++--- .../structured-agent-session-options-read.ts | 17 ++- ...gent-session-registered-definition.test.ts | 2 +- ...ured-agent-new-chat-effort-at-rest.test.ts | 2 +- ...-structured-agent-session-options.test.tsx | 8 -- .../use-structured-agent-session-options.ts | 3 +- ...gent-session-launch-create-message.test.ts | 114 ++++++++++-------- ...red-agent-session-launch-create-message.ts | 15 ++- ...structured-agent-session-launch-options.ts | 22 ++-- ...structured-agent-session-effective-tier.ts | 40 ++++++ ...tructured-agent-session-option-snapshot.ts | 50 ++++---- .../structured-agent-session-option-state.ts | 6 +- ...ructured-agent-session-option-view.test.ts | 25 ---- .../structured-agent-session-options.test.ts | 43 +++---- .../structured-agent-session-options.ts | 16 +-- ...claude-auto-mode-host-catalog.unit.test.ts | 2 +- 22 files changed, 282 insertions(+), 253 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-never-sent.ts create mode 100644 src/shared/structured-agent-session-effective-tier.ts diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 56d49c4371ea..18d767fdd72c 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -11,7 +11,7 @@ import { applyStructuredAgentSessionOptions, canSetStructuredAgentSessionOption, commitStructuredAgentSessionOption, - commitStructuredAgentSessionOptionReply, + commitStructuredAgentSessionOptionValues, createStructuredAgentSessionOptionState, structuredAgentSessionOptionPicks, type StructuredAgentSessionOptionState @@ -238,7 +238,7 @@ export function useMobileStructuredAgentOptions(args: { const committed = result.value.options ?? { [id]: encoded } updateOptionState((current) => current.record === targetRecord && result.sameFence - ? commitStructuredAgentSessionOptionReply(current, committed) + ? commitStructuredAgentSessionOptionValues(current, committed) : current ) // Only an accepted pick: an `unknown` outcome commits optimistically to the diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 453cdc4886b5..565fafa34344 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -178,7 +178,7 @@ function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) { params: { provider: 'claude' }, journal: { threadGoal: () => null, - newestTurn: () => null, + visitItems: () => {}, submissions: () => [], contextUsage: () => null, context: { floor: () => null } diff --git a/src/main/claude/claude-structured-session-reading-control.test.ts b/src/main/claude/claude-structured-session-reading-control.test.ts index ba441268f3e0..786cf47120e3 100644 --- a/src/main/claude/claude-structured-session-reading-control.test.ts +++ b/src/main/claude/claude-structured-session-reading-control.test.ts @@ -69,7 +69,6 @@ function persistedTarget( }) satisfies StructuredAgentSessionLinkageJournal['visitItemsWithLinkage'], itemBody: (itemId: string) => persisted.get(itemId) ?? null, threadGoal: () => null, - newestTurn: () => null, submissions: () => [], threadGoalSource: () => ({ goal: null, row: null }), epoch: 'test' diff --git a/src/main/codex/codex-structured-service-tier-shown.test.ts b/src/main/codex/codex-structured-service-tier-shown.test.ts index 407ec0248f40..a3b604a95e08 100644 --- a/src/main/codex/codex-structured-service-tier-shown.test.ts +++ b/src/main/codex/codex-structured-service-tier-shown.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it, vi } from 'vitest' import { CODEX_SESSION_OPTION_CATALOG } from '../../shared/agent-session-option-catalog-claude-codex' +import type { AgentSessionModelOption } from '../../shared/agent-session-wire' import { applyStructuredAgentSessionOptions, commitStructuredAgentSessionOption, - commitStructuredAgentSessionOptionReply, createStructuredAgentSessionOptionState, structuredAgentSessionOptionSnapshot, type StructuredAgentSessionOptionState @@ -17,16 +17,17 @@ import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' import type { CodexSession } from './codex-structured-session-state' import { startCodexTurn } from './codex-structured-turn-start' +import { dropUnlistedCodexServiceTier } from './codex-structured-service-tier' const tiers = (...values: string[]) => values.map((value) => ({ value, label: value })) // M1 offers Fast and Flex; M2 offers only Ultrafast. -const MODELS = [ +const MODELS: AgentSessionModelOption[] = [ { id: 'm1', label: 'M1', isDefault: true, efforts: [], serviceTiers: tiers('priority', 'flex') }, { id: 'm2', label: 'M2', isDefault: false, efforts: [], serviceTiers: tiers('ultrafast') } ] -/** The tier the first turn after a start carries for the chat's saved options. */ -async function firstTurnTier(saved: Record): Promise { +/** The tier the first turn after a start carries, and the session's tier after a live read. */ +async function hostRuns(saved: Record) { const store = new AgentModelCatalogStore() store.recordSuccess('codex-account', 'codex', { models: MODELS, origin: 'probe' }, 'discovery') const request = vi.fn(async (_method: string, _params?: Record) => ({ @@ -59,15 +60,17 @@ async function firstTurnTier(saved: Record): Promise { clientMessageId: 'message-1', body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] } }) - return request.mock.calls.find(([method]) => method === 'turn/start')?.[1]?.serviceTier + const firstTurn = request.mock.calls.find(([method]) => method === 'turn/start')?.[1] + dropUnlistedCodexServiceTier(session, saved.model, MODELS) + return { firstTurn: firstTurn?.serviceTier, afterLiveRead: session.options.get('serviceTier') } } -/** An older host's answer at rest: the saved values, with no defaults marked. */ -function atRest(saved: Record): StructuredAgentSessionOptionState { +/** A host's answer naming the saved values; an older host marks no defaults. */ +function answered(saved: Record, marksDefaults: boolean) { return applyStructuredAgentSessionOptions( createStructuredAgentSessionOptionState('codex', CODEX_SESSION_OPTION_CATALOG), CODEX_SESSION_OPTION_CATALOG, - { models: MODELS, current: saved } + { models: MODELS, current: { ...saved, ...(marksDefaults ? { defaulted: [] } : {}) } } ) } @@ -79,37 +82,27 @@ function shownTier(state: StructuredAgentSessionOptionState): string | undefined } describe("a Codex chat's speed, shown and sent", () => { - it('shows what the first turn runs, and keeps the saved tier for a model that offers it', async () => { - // Codex runs a tier the model lacks as Standard, but passes Flex regardless. - for (const [saved, runs] of [ - [{ model: 'm2', serviceTier: 'priority' }, 'default'], - [{ model: 'm2', serviceTier: 'flex' }, 'flex'], - [{ model: 'm1', serviceTier: 'priority' }, 'priority'] + it('shows what the host runs, an older host as it sends it, and keeps the saved tier', async () => { + // [saved, shown from this host, shown from an older host, what this host's turns run] + for (const [saved, shown, olderShown, runs] of [ + [{ model: 'm2', serviceTier: 'priority' }, 'default', 'default', 'default'], + [{ model: 'm2', serviceTier: 'flex' }, 'default', 'flex', 'default'], + [{ model: 'm1', serviceTier: 'priority' }, 'priority', 'priority', 'priority'] ] as const) { - expect(shownTier(atRest(saved))).toBe(runs) - await expect(firstTurnTier(saved)).resolves.toBe(runs) + expect(shownTier(answered(saved, true))).toBe(shown) + // An older host sends its Flex unchanged, and Codex passes Flex. + expect(shownTier(answered(saved, false))).toBe(olderShown) + await expect(hostRuns(saved)).resolves.toEqual({ firstTurn: runs, afterLiveRead: runs }) } // Back on a model that offers it, the saved tier is the chat's again. const back = commitStructuredAgentSessionOption( - atRest({ model: 'm2', serviceTier: 'priority' }), + answered({ model: 'm2', serviceTier: 'flex' }, true), 'model', 'm1' ) - expect(shownTier(back)).toBe('priority') - }) - - it('asserts no Flex after a model pick the host has not answered, until its reply', () => { - const picked = commitStructuredAgentSessionOption( - atRest({ model: 'm1', serviceTier: 'flex' }), - 'model', - 'm2' - ) - expect(shownTier(picked)).toBeUndefined() - // At rest the host saves the pick beside the chat's Flex, which the next start passes. - const answered = commitStructuredAgentSessionOptionReply(picked, { - model: 'm2', - serviceTier: 'flex' + expect(shownTier(back)).toBe('flex') + await expect(hostRuns({ model: 'm1', serviceTier: 'flex' })).resolves.toMatchObject({ + firstTurn: 'flex' }) - expect(shownTier(answered)).toBe('flex') }) }) diff --git a/src/main/codex/codex-structured-service-tier.ts b/src/main/codex/codex-structured-service-tier.ts index c1fd47324bdf..caf0ad7bd368 100644 --- a/src/main/codex/codex-structured-service-tier.ts +++ b/src/main/codex/codex-structured-service-tier.ts @@ -7,6 +7,7 @@ import { readRecord } from './codex-item-field-readers' import type { CodexOpenedThread } from './codex-structured-thread-open' import type { CodexSession } from './codex-structured-session-state' import { codexAcquireCatalogListing } from './codex-structured-catalog-entry' +import { effectiveServiceTier } from '../../shared/structured-agent-session-effective-tier' /** Codex's request value for a model's standard tier. */ export const CODEX_DEFAULT_SERVICE_TIER = 'default' @@ -85,12 +86,8 @@ export function reportedCodexThreadOptions( } } -function codexListsServiceTier( - model: Pick | undefined, - tier: string -): boolean { - return model?.serviceTiers?.some((choice) => choice.value === tier) === true -} +const tierValues = (model: Pick | undefined) => + model?.serviceTiers?.map((choice) => choice.value) ?? [] /** Codex also takes the thread's reported tier when no model lists it, such as a configured `flex`. */ export function codexOffersServiceTier( @@ -99,15 +96,14 @@ export function codexOffersServiceTier( models: readonly AgentSessionModelOption[], tier: string ): boolean { + const listedTiers = models.flatMap(tierValues) + const reported = tier === session.reportedOptions.serviceTier return ( - tier === CODEX_DEFAULT_SERVICE_TIER || - codexListsServiceTier(model, tier) || - (tier === session.reportedOptions.serviceTier && - !models.some((entry) => codexListsServiceTier(entry, tier))) + effectiveServiceTier({ tier, modelTiers: tierValues(model), listedTiers, reported }) === tier ) } -/** A turn's options: an unoffered tier other than Flex runs as standard; the saved tier is kept. */ +/** A turn's options: an unoffered tier runs as standard; the chat's saved tier is kept. */ export function codexTurnRequestOptions( session: Pick & { reportedOptions?: CodexSession['reportedOptions'] @@ -120,7 +116,6 @@ export function codexTurnRequestOptions( const tier = options.serviceTier if ( tier && - tier !== 'flex' && model?.serviceTiers && !codexOffersServiceTier({ reportedOptions }, model, models, tier) ) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-never-sent.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-never-sent.ts new file mode 100644 index 000000000000..01f01461e68c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-never-sent.ts @@ -0,0 +1,26 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' + +/** Proof a chat started here has sent nothing, so the default it shows is the one its first send + * pins. When unsure (history it was taken from, any turn even before a context clear, a rewind, + * or a journal founded afresh for a chat whose agent already ran), it has sent. */ +export function structuredAgentSessionNeverSent( + record: AgentSessionRecord | null, + journal: Pick +): boolean { + if ( + !record || + record.providerHandleChain[0]?.origin === 'adopted' || + record.rewind !== undefined || + journal.submissions().length > 0 || + (record.providerHandleChain.length > 0 && !journal.wroteBeforeOpen(1)) + ) { + return false + } + let turned = false + journal.visitItems((_itemId, _sequence, body) => { + turned ||= readAgentJournalTurn(body) !== null + }) + return !turned +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index e53d244ffb1e..fe4c8e0cf0c3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' import { createAgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' import { AgentModelCatalogStore, @@ -49,7 +50,7 @@ describe('options at rest', () => { params: { provider: 'codex' }, journal: { threadGoal: () => null, - newestTurn: () => null, + visitItems: () => {}, submissions: () => [], context: { floor: () => null } } @@ -93,7 +94,7 @@ describe('options at rest', () => { params: { provider: 'codex' }, journal: { threadGoal: () => null, - newestTurn: () => null, + visitItems: () => {}, submissions: () => [], context: { floor: () => null } } @@ -146,7 +147,7 @@ describe('options at rest', () => { params: { provider: 'codex' }, journal: { threadGoal: () => null, - newestTurn: () => null, + visitItems: () => {}, submissions: () => [], context: { floor: () => null } } @@ -202,41 +203,63 @@ describe('options at rest', () => { ) }) -describe('a chat that has sent nothing', () => { - it.each([ - ['started here names its default as its own', [], null, []], - ['taken from history still marks it', [{ origin: 'adopted' }], null, ['model']], - ['with a turn still marks it', [], { turnId: 'turn-1' }, ['model']] - ])('%s', async (_name, chain, turn, defaulted) => { - const record = { ...restingRecord(), providerHandleChain: chain } +describe('a chat at rest, read by the host', () => { + const created = [{ origin: 'created' }] + const turn = { kind: 'turn', turnId: 'turn-1', state: 'completed' } + const flex = [{ value: 'flex', label: 'Flex' }] + const listed: AgentSessionModelOption[] = [ + { id: 'gpt-account', label: 'Model', isDefault: true, efforts: [], serviceTiers: [] }, + { id: 'gpt-flex', label: 'Flex', isDefault: false, efforts: [], serviceTiers: flex } + ] + function readAtRest(facts: object, items: object[] = [], wroteBeforeOpen = false) { + const record = { ...restingRecord(), ...facts } const resting = { child: null, params: { provider: 'codex' }, journal: { threadGoal: () => null, - newestTurn: () => turn, + visitItems: (visit: (id: string, sequence: number, body: object) => void) => + items.forEach((body, index) => visit(`item-${index}`, index + 1, body)), + wroteBeforeOpen: () => wroteBeforeOpen, submissions: () => [], context: { floor: () => null } } } - const listed = { id: 'gpt-account', label: 'Model', isDefault: true, efforts: [] } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read touches only the declared context fields. const context = { deps: { adapter: {}, agents: NO_STRUCTURED_AGENTS, store: { getRecord: () => record }, - modelCatalog: { read: async () => ({ origin: 'probe', models: [listed], fetchedAt: 1 }) } + modelCatalog: { read: async () => ({ origin: 'probe', models: listed, fetchedAt: 1 }) } }, serialize: (_sessionId: string, task: () => Promise) => task(), openConversation: async () => resting, conversation: async () => resting } as unknown as StructuredAgentSessionMutationContext - const read = await readStructuredAgentSessionOptions(context, SESSION, { - readsWithoutModel: false - }) + return readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) + } + + it.each([ + ['started here names its default as its own', {}, [], false, []], + ['taken from history', { providerHandleChain: [{ origin: 'adopted' }] }, [], true, ['model']], + // A context clear hides the turn from the newest-turn read, not from history. + ['with a turn before a context clear', {}, [turn, { kind: 'context-clear' }], true, ['model']], + ['after a rewind', { rewind: { phase: 'completed' } }, [], true, ['model']], + // A journal founded afresh proves nothing for a chat whose agent already ran. + ['whose journal went missing', { providerHandleChain: created }, [], false, ['model']] + ])('%s', async (_name, facts, items, wroteBeforeOpen, defaulted) => { + const read = await readAtRest(facts, items, wroteBeforeOpen) expect(read.current).toMatchObject({ model: 'gpt-account', defaulted }) }) + + it("names the tier the chat's model runs, and keeps the saved one", async () => { + const options = { model: 'gpt-account', serviceTier: 'flex' } + await expect(readAtRest({ options })).resolves.toMatchObject({ + current: { serviceTier: 'default' } + }) + expect(options.serviceTier).toBe('flex') + }) }) describe('live Codex option reads', () => { @@ -258,7 +281,7 @@ describe('live Codex option reads', () => { params: { provider: 'codex' }, journal: { threadGoal: () => null, - newestTurn: () => null, + visitItems: () => {}, submissions: () => [], contextUsage: () => null, context: { floor: () => null } @@ -300,7 +323,7 @@ describe('live Codex option reads', () => { params: { provider: 'codex' }, journal: { threadGoal: () => null, - newestTurn: () => null, + visitItems: () => {}, submissions: () => [], context: { floor: () => null } } @@ -343,7 +366,7 @@ describe('live Codex option reads', () => { params: { provider: 'codex' }, journal: { threadGoal: () => null, - newestTurn: () => null, + visitItems: () => {}, submissions: () => [], context: { floor: () => null } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 9d31acd6a203..41289e42cb6d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -18,6 +18,7 @@ import type { } from '../../../shared/agent-session-journal-types' import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' import { finishedListingIds } from '../../../shared/structured-agent-session-options' +import { savedEffectiveServiceTier } from '../../../shared/structured-agent-session-effective-tier' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID, agentChatPermissionModes @@ -34,6 +35,7 @@ import { structuredAgentSessionOptionModels } from './structured-agent-session-o import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { structuredAgentSessionNeverSent } from './structured-agent-session-never-sent' type RestingOptions = Pick< AgentSessionOptionsResult, @@ -145,6 +147,8 @@ async function readStructuredAgentSessionOptionsAtRest( const defaulted = (['model', 'effort'] as const).filter( (key) => saved[key] === undefined && Boolean(key === 'model' ? model : effort) ) + // The tier the chat's own model runs; the record keeps the saved one for a model that offers it. + const serviceTier = savedEffectiveServiceTier(saved, models) return { models: listed ? structuredAgentSessionOptionModels(listed, model, (row) => row) : [], ...(listedModelIds ? { listedModelIds } : {}), @@ -155,7 +159,7 @@ async function readStructuredAgentSessionOptionsAtRest( ...(model ? { model } : {}), ...(effort ? { effort } : {}), ...(typeof fastMode === 'boolean' ? { fastMode } : {}), - ...(saved.serviceTier ? { serviceTier: saved.serviceTier } : {}), + ...(serviceTier ? { serviceTier } : {}), defaulted } } @@ -286,14 +290,9 @@ export async function readStructuredAgentSessionOptions( ...(shownSaved('effort') ? { effort: selected.current.effort } : {}) } // A chat that saved a value has no default for it: the saved value shows, or the child it - // refused runs what it reports, which the next send records. A chat started here that has sent - // nothing runs the default it shows, which its first send pins; one taken from history may not. - const neverSent = - record !== null && - record.providerHandleChain[0]?.origin !== 'adopted' && - session.journal.newestTurn() === null && - session.journal.submissions().length === 0 - const defaulted = neverSent + // refused runs what it reports, which the next send records. A chat that has sent nothing runs + // the default it shows, which its first send pins. + const defaulted = structuredAgentSessionNeverSent(record, session.journal) ? [] : (options.current.defaulted ?? []).filter((key) => saved?.[key] === undefined) const confirmed = current.confirmed?.filter((key) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts index f55eeb191615..913ec7fa9aec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts @@ -77,7 +77,7 @@ function readAtRest(agents: StructuredAgentRegistry, live?: StructuredAgentSessi params: { provider: RECORD.provider }, journal: { threadGoal: () => null, - newestTurn: () => null, + visitItems: () => {}, // A chat that has sent, so a value it never saved reads as a default. submissions: () => [{}], contextUsage: () => null, diff --git a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts index 7a1a317f2baa..f5a3752217d6 100644 --- a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts +++ b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts @@ -201,7 +201,7 @@ function host(agent: string, record: AgentSessionRecord) { params: { provider: agent }, journal: { threadGoal: () => null, - newestTurn: () => null, + visitItems: () => {}, submissions: () => [], contextUsage: () => null, context: { floor: () => null } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx index dd3e39d75d73..70158e8b8ae5 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx @@ -715,14 +715,6 @@ describe('useStructuredAgentSessionOptions', () => { reply: { model: 'second', serviceTier: 'default' }, shown: ['serviceTier', 'default'] }, - // Codex's reply leaves out a tier both models offer, which it keeps. - { - agent: 'codex' as const, - tiers: [[{ value: 'priority', label: 'Fast' }], [{ value: 'priority', label: 'Fast' }]], - current: { model: 'opus', serviceTier: 'priority' }, - reply: { model: 'second' }, - shown: ['serviceTier', 'priority'] - }, // A configured tier no model lists (Flex) still runs, so it shows as held. { agent: 'codex' as const, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index 969da725ee37..e140cf714932 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -10,7 +10,6 @@ import type { SessionOptionsSurface } from '../../../../shared/native-chat-sessi import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode' import { canSetStructuredAgentSessionOption, - commitStructuredAgentSessionOptionReply, commitStructuredAgentSessionOptionValues, lockedStructuredAgentSessionOptionSnapshot, structuredAgentSessionOptionPicks, @@ -160,7 +159,7 @@ export function useStructuredAgentSessionOptions(args: { const committed = isCurrent() ? returned : { [id]: returned[id] ?? encoded } updateOptionState((current) => current.record === targetRecord - ? commitStructuredAgentSessionOptionReply(current, committed) + ? commitStructuredAgentSessionOptionValues(current, committed) : current ) // The launch seed names the model an effort-only pick was made under. diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts index 240998751d33..db3faa6e0e45 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts @@ -232,56 +232,70 @@ describe('the first message owned by create', () => { } ) - it.each([ - ['priority', 'default'], - ['flex', 'flex'] - ])( - 'carries a held %s as the picker draws it after the default model changes, and never again', - async (held, carried) => { - const tiers = (...values: string[]) => values.map((value) => ({ value, label: value })) - const efforts = tiers('medium', 'high') - const list = (defaultId: string) => ({ - origin: 'probe' as const, - fetchedAt: 1, - listingNamesConfiguredModel: true, - defaultHoldsInEveryWorkspace: true as const, - models: [ - { - id: 'm1', - label: 'M1', - isDefault: defaultId === 'm1', - efforts, - serviceTiers: tiers('priority', 'flex') - }, - { - id: 'm2', - label: 'M2', - isDefault: defaultId === 'm2', - efforts, - serviceTiers: tiers('ultrafast') - } - ] - }) - recordHostModelCatalogSnapshot(target, 'codex', null, list('m1')) - const create = deferred() - mocks.call.mockReturnValue(create.promise) - const launch = start() - const tierPick = holdStructuredAgentSessionLaunchOption(launch.sessionId, 'serviceTier', held) - holdStructuredAgentSessionLaunchOption(launch.sessionId, 'effort', 'high') - // The account's default moves before create goes out. - recordHostModelCatalogSnapshot(target, 'codex', null, list('m2')) - await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - // Codex runs a tier the model lacks as Standard but passes Flex; effort stays as picked. - expect(createParams().options).toEqual({ effort: 'high', serviceTier: carried }) - create.resolve(created()) - await launch.launchResult - await expect(tierPick).resolves.toEqual({ - kind: 'accepted', - options: { effort: 'high', serviceTier: carried } - }) - expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create']) - } - ) + it('carries a held tier as the picker draws it, keeps the pick through a refusal, and settles it once', async () => { + const tiers = (...values: string[]) => values.map((value) => ({ value, label: value })) + const efforts = tiers('medium', 'high') + const list = (defaultId: string) => ({ + origin: 'probe' as const, + fetchedAt: 1, + listingNamesConfiguredModel: true, + defaultHoldsInEveryWorkspace: true as const, + models: [ + ['m1', 'priority'], + ['m2', 'flex'] + ].map(([id, tier]) => ({ + id, + label: id, + isDefault: defaultId === id, + efforts, + serviceTiers: tiers(tier) + })) + }) + recordHostModelCatalogSnapshot(target, 'codex', null, list('m1')) + const refused = deferred() + const create = deferred() + mocks.call.mockReturnValueOnce(refused.promise).mockReturnValueOnce(create.promise) + mocks.call.mockResolvedValue({ ok: true, value: { key: 'effort', value: 'medium' } }) + const launch = start() + const tierPick = holdStructuredAgentSessionLaunchOption( + launch.sessionId, + 'serviceTier', + 'priority' + ) + holdStructuredAgentSessionLaunchOption(launch.sessionId, 'effort', 'high') + // The account's default moves before create goes out: Fast runs as Standard under it. + recordHostModelCatalogSnapshot(target, 'codex', null, list('m2')) + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + expect(createParams().options).toEqual({ effort: 'high', serviceTier: 'default' }) + refused.resolve({ + ok: false, + refusal: { code: 'structured_agent_session_unsupported', message: 'no' } + }) + await expect(launch.launchResult).rejects.toBeInstanceOf( + StructuredAgentSessionCreateRefusalError + ) + // The default moves back; the retry carries the user's own Fast. + recordHostModelCatalogSnapshot(target, 'codex', null, list('m1')) + expect(retryStructuredAgentSessionLaunch('workspace-1', launch.sessionId)).toBe(true) + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + const retried = CreateIntentParams.parse(mocks.call.mock.calls[1]?.[2]) + expect(retried.options).toEqual({ effort: 'high', serviceTier: 'priority' }) + // A pick made while create is out is newer than what it carries, so it applies after. + holdStructuredAgentSessionLaunchOption(launch.sessionId, 'effort', 'medium') + create.resolve(created()) + await expect(tierPick).resolves.toEqual({ + kind: 'accepted', + options: { effort: 'high', serviceTier: 'priority' } + }) + await vi.waitFor(() => + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual([ + 'agentSession.create', + 'agentSession.create', + 'agentSession.setOption' + ]) + ) + expect(mocks.call.mock.calls[2]?.[2]).toMatchObject({ key: 'effort', value: 'medium' }) + }) it('returns a refused create once after the new draft, with no later send', async () => { const create = deferred() diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts index 668a87b09f71..5a2fbf7864a0 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts @@ -13,7 +13,7 @@ import { import { firstFrameOptionState } from '@/components/native-chat/use-structured-agent-session-option-state' import { applyStructuredLaunchHeldOptions, - projectStructuredLaunchHeldOptions, + holdStructuredLaunchCreatePicks, settleStructuredLaunchCreateOptions } from './structured-agent-session-launch-options' import { structuredAgentSessionDrawnValues } from '../../../shared/structured-agent-session-option-snapshot' @@ -66,16 +66,15 @@ function structuredLaunchCreateOptions( // Create has always carried options, so any host takes what the chat shows. const view = structuredAgentSessionOptionView(firstFrame, intent.seedOptions, selection.held) const drawn = structuredAgentSessionDrawnValues(view) - // Each held value as the picker draws it; one drawn as nothing is not carried. + // Each held value as the picker draws it; the held picks themselves stay as the user made them. const held = Object.fromEntries( - Object.entries(selection.held).flatMap(([id, encoded]) => { - const value = id === 'model' || !(id in view.record.values) ? encoded : drawn[id]?.value - const projected = - value === undefined ? null : encodeStructuredAgentSessionOptionValue(id, value) - return projected === null ? [] : [[id, projected]] + Object.entries(selection.held).map(([id, encoded]) => { + const value = id === 'model' ? undefined : drawn[id]?.value + const shown = value === undefined ? null : encodeStructuredAgentSessionOptionValue(id, value) + return [id, shown ?? encoded] }) ) - projectStructuredLaunchHeldOptions(state, held) + holdStructuredLaunchCreatePicks(state, selection.held) const options = { ...held, ...structuredAgentSessionSendSelection(view) diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index 7fde3ad64b60..f6edaf01440f 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -154,27 +154,25 @@ function settleHeldOption( state.callers.onSettled() } -/** Held picks become what create carries, so none is applied again after publish. */ -export function projectStructuredLaunchHeldOptions( +const createHeld = new WeakMap>>() + +/** The held picks this create attempt carries, in whatever form it carries them. */ +export function holdStructuredLaunchCreatePicks( state: StructuredLaunchState, held: Readonly> ): void { - const replies = repliesFor(state) - for (const id of Object.keys(state.selection.held)) { - if (held[id] === undefined) { - replies.get(id)?.({ kind: 'superseded' }) - replies.delete(id) - } - } - state.selection = { ...state.selection, held } + createHeld.set(state, held) } -/** Selections included in the durable create are already the host's option intent. */ +/** Selections included in the durable create are already the host's option intent; a pick the + * create carried in another form settles too, and a newer pick stays held. */ export function settleStructuredLaunchCreateOptions( state: StructuredLaunchState, options: Readonly> ): void { - for (const [id, encoded] of Object.entries(options)) { + const carried = { ...options, ...createHeld.get(state) } + createHeld.delete(state) + for (const [id, encoded] of Object.entries(carried)) { settleHeldOption(state, id, encoded, { kind: 'accepted', options }) } } diff --git a/src/shared/structured-agent-session-effective-tier.ts b/src/shared/structured-agent-session-effective-tier.ts new file mode 100644 index 000000000000..e5f1d17ccf5f --- /dev/null +++ b/src/shared/structured-agent-session-effective-tier.ts @@ -0,0 +1,40 @@ +/** A model's standard tier, which every model runs. */ +export const STANDARD_SERVICE_TIER = 'default' + +/** What a tier runs as under a model: one the model doesn't list runs as standard, except a tier + * no listed model offers that the agent itself reports running (a configured one). Unknown model + * tiers leave it as is. */ +export function effectiveServiceTier(input: { + tier: string + modelTiers: readonly string[] | undefined + listedTiers: readonly string[] + reported: boolean +}): string { + const { tier, modelTiers } = input + if (tier === STANDARD_SERVICE_TIER || !modelTiers || modelTiers.includes(tier)) { + return tier + } + return input.reported && !input.listedTiers.includes(tier) ? tier : STANDARD_SERVICE_TIER +} + +type TieredModel = { id: string; serviceTiers?: readonly { value: string }[] } +const modelTierValues = (model: TieredModel | undefined) => + model?.serviceTiers?.map((choice) => choice.value) + +/** The tier a chat at rest runs under its saved model, which no agent has reported yet. */ +export function savedEffectiveServiceTier( + saved: { model?: string; serviceTier?: string }, + models: readonly TieredModel[] +): string | undefined { + return ( + saved.serviceTier && + effectiveServiceTier({ + tier: saved.serviceTier, + modelTiers: saved.model + ? modelTierValues(models.find((entry) => entry.id === saved.model)) + : undefined, + listedTiers: models.flatMap((entry) => modelTierValues(entry) ?? []), + reported: false + }) + ) +} diff --git a/src/shared/structured-agent-session-option-snapshot.ts b/src/shared/structured-agent-session-option-snapshot.ts index 468826ffb142..0dce7a95ac06 100644 --- a/src/shared/structured-agent-session-option-snapshot.ts +++ b/src/shared/structured-agent-session-option-snapshot.ts @@ -12,6 +12,7 @@ import { } from './native-chat-session-option-snapshot' import type { SessionOptionDescriptor, SessionOptionValue } from './native-chat-session-options' import type { TrackedNativeChatSessionOption } from './native-chat-session-option-state' +import { effectiveServiceTier } from './structured-agent-session-effective-tier' import { serviceTierOption } from './structured-agent-session-discovered-model' import { structuredAgentSessionModelId, @@ -75,39 +76,34 @@ export function structuredAgentSessionDescribedModel( : undefined } -/** Codex passes Flex whether or not the model advertises it, so only Flex can run unadvertised. */ -const PASSED_SERVICE_TIER = 'flex' +const tierChoices = (option: CatalogOption | undefined): string[] => + option?.id === 'serviceTier' && option.kind.type === 'select' + ? option.kind.choices.map((choice) => choice.value) + : [] -/** What the named model's controls draw: a tier it lacks that another model lists runs as the - * default; established Flex runs as is, and an unanswered model pick leaves it undrawn. */ +/** The named model's speed as it runs (the host applies the same rule). An older host's Flex + * shows as it reports it, as that host sends it. */ function valuesUnderControls( catalog: AgentSessionOptionCatalog, controls: readonly CatalogOption[], values: Readonly>, - modelUnresolved: boolean + olderHost: boolean ): Readonly> { - const drawn = { ...values } - for (const option of controls) { - const tracked = values[option.id] - const offers = (entry: CatalogOption): boolean => - entry.id === option.id && - entry.kind.type === 'select' && - entry.kind.choices.some((choice) => choice.value === tracked?.value) - if ( - option.kind.type === 'select' && - option.id !== 'effort' && - tracked && - !offers(option) && - catalog.models.some((model) => model.options.some(offers)) - ) { - if (option.id !== 'serviceTier' || tracked.value !== PASSED_SERVICE_TIER) { - drawn[option.id] = { value: option.kind.defaultValue, source: 'default' } - } else if (modelUnresolved) { - delete drawn[option.id] - } - } + const tier = values.serviceTier?.value + const control = controls.find((option) => option.id === 'serviceTier') + if (typeof tier !== 'string' || !control || (olderHost && tier === 'flex')) { + return values } - return drawn + const effective = effectiveServiceTier({ + tier, + modelTiers: tierChoices(control), + listedTiers: catalog.models.flatMap((model) => model.options.flatMap(tierChoices)), + // The answer's tier is the agent's own report when no listed model offers it. + reported: true + }) + return effective === tier + ? values + : { ...values, serviceTier: { value: effective, source: 'default' } } } /** The values the picker draws, which a create carries for picks it holds. */ @@ -135,7 +131,7 @@ function drawnModelControls(state: StructuredAgentSessionOptionState) { shown.shown.currentId !== null && structuredAgentSessionDescribedModel(state, shown.shown.currentId) !== undefined const values = normalises - ? valuesUnderControls(catalog, controls, record.values, record.modelUnresolved === true) + ? valuesUnderControls(catalog, controls, record.values, state.olderHost === true) : record.values return { catalog, models, ...shown, controls, values } } diff --git a/src/shared/structured-agent-session-option-state.ts b/src/shared/structured-agent-session-option-state.ts index 48e0f5520f0b..c53a2231d291 100644 --- a/src/shared/structured-agent-session-option-state.ts +++ b/src/shared/structured-agent-session-option-state.ts @@ -8,8 +8,6 @@ import type { TrackedNativeChatSessionOption } from './native-chat-session-optio export type StructuredAgentSessionOptionRecord = { agent: AgentType values: Record - /** The model is a pick no host answer has spoken for yet, so its other values may not hold. */ - modelUnresolved?: true } /** The values a complete options answer speaks for. */ @@ -28,8 +26,7 @@ export function cloneStructuredAgentSessionOptionRecord( agent: record.agent, values: Object.fromEntries( Object.entries(record.values).map(([id, tracked]) => [id, { ...tracked }]) - ), - ...(record.modelUnresolved ? { modelUnresolved: true } : {}) + ) } } @@ -62,7 +59,6 @@ export function replaceStructuredAgentSessionAnsweredValues( answered: Readonly>>, sourceFor: (id: string) => TrackedNativeChatSessionOption['source'] ): void { - delete record.modelUnresolved for (const id of ANSWERED_VALUE_IDS) { const value = answered[id] if (value !== undefined) { diff --git a/src/shared/structured-agent-session-option-view.test.ts b/src/shared/structured-agent-session-option-view.test.ts index bda0240f64b0..198c9822c466 100644 --- a/src/shared/structured-agent-session-option-view.test.ts +++ b/src/shared/structured-agent-session-option-view.test.ts @@ -180,31 +180,6 @@ describe("a chat's model and effort, apart from the defaults filling them in", ( expect(carried(restarted)).toEqual({ model: 'sonnet', effort: 'high' }) }) - it("names a new chat's default and pins it with the shown effort, whichever answer lands first", () => { - // No Claude settings learned yet: the host's list does not say this launch runs its default. - const listing = { ...LISTED, models: CLI302, listingNamesConfiguredModel: false } - const answer = { - models: CLI302, - listedModelIds: listed, - current: { model: 'opus', effort: 'xhigh', defaulted: ['model', 'effort'] as const } - } - const newChat = { newLaunch: true } - const listFirst = applyStructuredAgentSessionOptions( - withHostList(fresh(), listing), - CLAUDE_SESSION_OPTION_CATALOG, - answer, - newChat - ) - const answerFirst = withHostList( - applyStructuredAgentSessionOptions(fresh(), CLAUDE_SESSION_OPTION_CATALOG, answer, newChat), - listing - ) - for (const state of [listFirst, answerFirst]) { - expect(shown(state, undefined, {})).toEqual({ model: 'opus', effort: 'xhigh' }) - expect(carried(state)).toEqual({ model: 'opus', effort: 'xhigh' }) - } - }) - it("reads an older host's answer, which marks no default, as it always has", () => { const state = resumed() expect(shown(state, undefined, {})).toEqual({ model: 'opus', effort: 'low' }) diff --git a/src/shared/structured-agent-session-options.test.ts b/src/shared/structured-agent-session-options.test.ts index bf2a2bb42979..b27b73b15c81 100644 --- a/src/shared/structured-agent-session-options.test.ts +++ b/src/shared/structured-agent-session-options.test.ts @@ -350,7 +350,7 @@ describe('structured agent session options', () => { } }) - it('names a default model only where the host says this launch runs it, in either order', () => { + it("names no default as a resumed chat's own, whichever of its list and answer lands first", () => { const models = [{ id: 'gpt-5.5', label: 'GPT-5.5', isDefault: true, efforts: [] }] const listing: AgentSessionModelCatalogResult = { origin: 'probe', fetchedAt: 1, models } const answer: AgentSessionOptionsResult = { @@ -358,29 +358,24 @@ describe('structured agent session options', () => { current: { model: 'gpt-5.5', defaulted: ['model'] } } const seed = CODEX_SESSION_OPTION_CATALOG - const bothOrders = (newLaunch: boolean) => { - const cold = () => createStructuredAgentSessionOptionState('codex', seed) - return [ - applyStructuredAgentSessionOptions( - applyStructuredAgentSessionModelCatalog(cold(), seed, listing, { newLaunch }), - seed, - answer, - { newLaunch } - ), - applyStructuredAgentSessionModelCatalog( - applyStructuredAgentSessionOptions(cold(), seed, answer, { newLaunch }), - seed, - listing, - { newLaunch } - ) - ].map((state) => structuredAgentSessionOptionSnapshot(state)[0]) + const cold = () => createStructuredAgentSessionOptionState('codex', seed) + const resumed = { newLaunch: false } + for (const state of [ + applyStructuredAgentSessionOptions( + applyStructuredAgentSessionModelCatalog(cold(), seed, listing, resumed), + seed, + answer + ), + applyStructuredAgentSessionModelCatalog( + applyStructuredAgentSessionOptions(cold(), seed, answer), + seed, + listing, + resumed + ) + ]) { + const model = structuredAgentSessionOptionSnapshot(state)[0] + expect(model).toMatchObject({ valueSource: 'unknown' }) + expect(model?.kind).not.toHaveProperty('currentValue') } - const [newChat, newChatAnswerFirst] = bothOrders(true) - expect(newChatAnswerFirst).toEqual(newChat) - expect(newChat).toMatchObject({ valueSource: 'default', kind: { currentValue: 'gpt-5.5' } }) - const [resumed, resumedAnswerFirst] = bothOrders(false) - expect(resumedAnswerFirst).toEqual(resumed) - expect(resumed).toMatchObject({ valueSource: 'unknown' }) - expect(resumed?.kind).not.toHaveProperty('currentValue') }) }) diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index 2eeb0fef80c4..44ec6cd39d97 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -62,6 +62,8 @@ export type StructuredAgentSessionOptionState = { * own model, which no list describes. Absent from an older host. */ verifiedModelIds?: readonly string[] record: StructuredAgentSessionOptionRecord + /** The last answer came from a host that marks no defaults, whose own tier rule it shows. */ + olderHost?: true /** The permission mode the host or launch last stated; null shows no picker. */ permissionMode: string | null pendingId: string | null @@ -184,6 +186,7 @@ export function applyStructuredAgentSessionOptions( ...state, catalog: structuredAgentSessionOptionCatalog(seed, result, launch.newLaunch), catalogSource: 'live', + olderHost: current.defaulted === undefined ? true : undefined, verifiedModelIds: result.listedModelIds?.length ? result.listedModelIds : undefined, permissionMode: result.current.permissionMode ?? null } @@ -216,22 +219,9 @@ export function commitStructuredAgentSessionOption( } // Each value is the chat's own: a model pick leaves the others for the host to reconcile. state.record.values[id] = { value: decoded, source: 'dispatched' } - if (id === 'model') { - state.record.modelUnresolved = true - } return { ...state, pendingId: null } } -/** A pick's reply: the host has spoken for the model, so its values stand. */ -export function commitStructuredAgentSessionOptionReply( - state: StructuredAgentSessionOptionState, - values: Readonly> -): StructuredAgentSessionOptionState { - const next = commitStructuredAgentSessionOptionValues(state, values) - delete next.record.modelUnresolved - return next -} - /** Whether a pick of `modelId` held before launch keeps another held value (decoded or encoded): * no host exists yet to reconcile it, so only what the model's controls offer stays. A model the * list does not describe keeps every value, since nothing says what it takes. */ diff --git a/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts index 0f778892f754..516263c0ec65 100644 --- a/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts +++ b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts @@ -136,7 +136,7 @@ async function hostCatalog(listingLands: Promise) { params: { provider: 'claude' }, journal: { threadGoal: () => null, - newestTurn: () => null, + visitItems: () => {}, submissions: () => [], contextUsage: () => null, context: { floor: () => null } From f4fbd03dcc73b2159d9253a7d377c206c421343d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:11:21 -0700 Subject: [PATCH 15/34] test(native-chat): type the resting journal mock and cut overlapping option tests One typed resting-journal helper replaces six inline mocks and the broad object parameters the low-evidence check rejects. Cuts tests the joined flows already pin: the resumed list-or-answer ordering test, the restart-after-first-send view test, the held effort before a model pick, and the model-pick and restart loop of the saved-Fast test. --- ...uctured-agent-session-options-read.test.ts | 86 ++++++++----------- ...tured-agent-session-launch-options.test.ts | 26 ------ ...ructured-agent-session-option-view.test.ts | 19 ---- .../structured-agent-session-options.test.ts | 74 +++------------- 4 files changed, 48 insertions(+), 157 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index fe4c8e0cf0c3..28b579b7d39a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' +import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind' import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' import { createAgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' import { @@ -19,6 +22,23 @@ import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router- const SESSION = 'session-1' +type RestingFacts = Pick, 'options'> & { + providerHandleChain?: Pick[] + rewind?: Pick +} + +function restingJournal(items: AgentJournalItemBody[] = [], wroteBeforeOpen = false) { + return { + threadGoal: () => null, + visitItems: (visit: (itemId: string, sequence: number, body: AgentJournalItemBody) => void) => + items.forEach((body, index) => visit(`item-${index}`, index + 1, body)), + wroteBeforeOpen: () => wroteBeforeOpen, + submissions: () => [], + contextUsage: () => null, + context: { floor: () => null } + } +} + function restingRecord(): AgentSessionRecord { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields. return { @@ -48,12 +68,7 @@ describe('options at rest', () => { const starting = { child: { phase: 'starting' }, params: { provider: 'codex' }, - journal: { - threadGoal: () => null, - visitItems: () => {}, - submissions: () => [], - context: { floor: () => null } - } + journal: restingJournal() } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read touches only the declared context fields. const context = { @@ -92,12 +107,7 @@ describe('options at rest', () => { const conversation = () => ({ child: live ? { fence: 7 } : null, params: { provider: 'codex' }, - journal: { - threadGoal: () => null, - visitItems: () => {}, - submissions: () => [], - context: { floor: () => null } - } + journal: restingJournal() }) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read touches only the declared context fields. const context = { @@ -145,12 +155,7 @@ describe('options at rest', () => { const resting = { child: null, params: { provider: 'codex' }, - journal: { - threadGoal: () => null, - visitItems: () => {}, - submissions: () => [], - context: { floor: () => null } - } + journal: restingJournal() } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: options reads touch only these context members. const context = { @@ -204,26 +209,23 @@ describe('options at rest', () => { }) describe('a chat at rest, read by the host', () => { - const created = [{ origin: 'created' }] - const turn = { kind: 'turn', turnId: 'turn-1', state: 'completed' } + const created: RestingFacts['providerHandleChain'] = [{ origin: 'created' }] + const turn: AgentJournalItemBody = { kind: 'turn', turnId: 'turn-1', state: 'completed' } const flex = [{ value: 'flex', label: 'Flex' }] const listed: AgentSessionModelOption[] = [ { id: 'gpt-account', label: 'Model', isDefault: true, efforts: [], serviceTiers: [] }, { id: 'gpt-flex', label: 'Flex', isDefault: false, efforts: [], serviceTiers: flex } ] - function readAtRest(facts: object, items: object[] = [], wroteBeforeOpen = false) { + function readAtRest( + facts: RestingFacts, + items: AgentJournalItemBody[] = [], + wroteBeforeOpen = false + ) { const record = { ...restingRecord(), ...facts } const resting = { child: null, params: { provider: 'codex' }, - journal: { - threadGoal: () => null, - visitItems: (visit: (id: string, sequence: number, body: object) => void) => - items.forEach((body, index) => visit(`item-${index}`, index + 1, body)), - wroteBeforeOpen: () => wroteBeforeOpen, - submissions: () => [], - context: { floor: () => null } - } + journal: restingJournal(items, wroteBeforeOpen) } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read touches only the declared context fields. const context = { @@ -240,11 +242,11 @@ describe('a chat at rest, read by the host', () => { return readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) } - it.each([ + it.each<[string, RestingFacts, AgentJournalItemBody[], boolean, string[]]>([ ['started here names its default as its own', {}, [], false, []], ['taken from history', { providerHandleChain: [{ origin: 'adopted' }] }, [], true, ['model']], // A context clear hides the turn from the newest-turn read, not from history. - ['with a turn before a context clear', {}, [turn, { kind: 'context-clear' }], true, ['model']], + ['with a turn before a context clear', {}, [turn], true, ['model']], ['after a rewind', { rewind: { phase: 'completed' } }, [], true, ['model']], // A journal founded afresh proves nothing for a chat whose agent already ran. ['whose journal went missing', { providerHandleChain: created }, [], false, ['model']] @@ -279,13 +281,7 @@ describe('live Codex option reads', () => { const live = { child, params: { provider: 'codex' }, - journal: { - threadGoal: () => null, - visitItems: () => {}, - submissions: () => [], - contextUsage: () => null, - context: { floor: () => null } - } + journal: restingJournal() } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read touches only the declared context fields. const context = { @@ -321,12 +317,7 @@ describe('live Codex option reads', () => { const live = { child: { fence: 7 }, params: { provider: 'codex' }, - journal: { - threadGoal: () => null, - visitItems: () => {}, - submissions: () => [], - context: { floor: () => null } - } + journal: restingJournal() } const failure = new Error('model/list unavailable') // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the failed read ends before touching the remaining context fields. @@ -364,12 +355,7 @@ describe('live Codex option reads', () => { const live = { child: { fence: 7 }, params: { provider: 'codex' }, - journal: { - threadGoal: () => null, - visitItems: () => {}, - submissions: () => [], - context: { floor: () => null } - } + journal: restingJournal() } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read touches only the declared context fields. const context = { diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts index b00b63b46700..ad4723810913 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts @@ -247,32 +247,6 @@ describe('picks made while a chat launches', () => { expect(mutations().map((mutation) => mutation.value)).toEqual(['gpt-picked', 'gpt-repicked']) }) - it('keeps an effort held before a model pick; a pick supersedes only its own option', async () => { - const created = deferred<{ sessionId: string; fence: number }>() - mocks.launch.mockReturnValue(created.promise) - const launch = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-held' }) - const effortPick = holdStructuredAgentSessionLaunchOption(SESSION_ID, 'effort', 'high') - const firstModel = holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-a') - holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-b') - await expect(firstModel).resolves.toEqual({ kind: 'superseded' }) - expect(getStructuredAgentSessionLaunchSelection(SESSION_ID)?.held).toEqual({ - effort: 'high', - model: 'gpt-b' - }) - created.resolve({ sessionId: SESSION_ID, fence: 3 }) - await settle() - setOptionReplies[0]!.resolve(ACCEPTED) - await settle() - setOptionReplies[1]!.resolve(ACCEPTED) - await launch.launchResult - - expect(mutations().map(({ key, value }) => [key, value])).toEqual([ - ['model', 'gpt-b'], - ['effort', 'high'] - ]) - await expect(effortPick).resolves.toMatchObject({ kind: 'accepted' }) - }) - it('drops a held Fast the picked model does not offer: neither sent nor remembered', async () => { const created = deferred<{ sessionId: string; fence: number }>() mocks.launch.mockReturnValue(created.promise) diff --git a/src/shared/structured-agent-session-option-view.test.ts b/src/shared/structured-agent-session-option-view.test.ts index 198c9822c466..2b5998c0baae 100644 --- a/src/shared/structured-agent-session-option-view.test.ts +++ b/src/shared/structured-agent-session-option-view.test.ts @@ -102,25 +102,6 @@ describe('what a send carries', () => { expect(carried(withHostList(fresh(), listing))).toEqual({ model: 'sonnet' }) }) - it("pins a new chat's shown default on its first send, which a restart shows as the chat's own", () => { - const listing = { ...LISTED, models: CLI302, listingNamesConfiguredModel: true } - const first = carried(withHostList(fresh(), listing)) - expect(first).toEqual({ model: 'opus' }) - // The host saved what the send carried, so the resumed chat's answer marks no default. - const restarted = applyStructuredAgentSessionOptions( - applyStructuredAgentSessionModelCatalog(fresh(), CLAUDE_SESSION_OPTION_CATALOG, listing, { - newLaunch: false - }), - CLAUDE_SESSION_OPTION_CATALOG, - { models: CLI302, current: { model: first?.model, defaulted: [] } } - ) - expect(shown(restarted, undefined, {}).model).toBe('opus') - expect(structuredAgentSessionShownSnapshot(restarted)[0]).toMatchObject({ - valueSource: 'dispatched' - }) - expect(carried(restarted)).toEqual({ model: 'opus' }) - }) - it('carries a pick on its way as given', () => { const listed = withHostList(fresh(), { ...LISTED, models: [OPUS, SONNET] }) expect(carried(listed, remembered, { model: 'sonnet' })).toMatchObject({ model: 'sonnet' }) diff --git a/src/shared/structured-agent-session-options.test.ts b/src/shared/structured-agent-session-options.test.ts index b27b73b15c81..cbdc035fd637 100644 --- a/src/shared/structured-agent-session-options.test.ts +++ b/src/shared/structured-agent-session-options.test.ts @@ -3,10 +3,6 @@ import { OMP_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-omp' import { CODEX_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-claude-codex' import { buildNativeChatSessionOptionSnapshot } from './native-chat-session-option-snapshot' import { createNativeChatSessionOptionRecord } from './native-chat-session-option-state' -import type { - AgentSessionModelCatalogResult, - AgentSessionOptionsResult -} from './agent-session-wire' import { applyStructuredAgentSessionModelCatalog, applyStructuredAgentSessionOptions, @@ -307,7 +303,7 @@ describe('structured agent session options', () => { expect(live.record.values.model?.value).toBe('gpt-5.6-luna') }) - it('keeps a saved Fast with no model known, Standard turning it off, through a model pick and a restart', () => { + it('keeps a saved Fast with no model known, Standard turning it off', () => { const sol = { id: 'gpt-6.1-sol', label: 'GPT-6.1 Sol', @@ -316,66 +312,20 @@ describe('structured agent session options', () => { serviceTiers: [{ value: 'priority', label: 'Fast' }] } // A resumed chat's at-rest answer, whose listing names no default. - const atRest = (current: AgentSessionOptionsResult['current']) => - applyStructuredAgentSessionOptions( - createStructuredAgentSessionOptionState('codex'), - CODEX_SESSION_OPTION_CATALOG, - { - models: [sol], - fastModeSupport: { supported: true }, - current: { ...current, defaulted: [] } - } - ) - const speed = (state: StructuredAgentSessionOptionState) => - structuredAgentSessionOptionSnapshot(state).find(({ id }) => id === 'serviceTier') - const fast = atRest({ serviceTier: 'priority' }) - expect(speed(fast)).toMatchObject({ - settable: true, - kind: { - currentValue: 'priority', - choices: [ - { value: 'default', label: 'Standard' }, - { value: 'priority', label: 'Fast' } - ] + const fast = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('codex'), + CODEX_SESSION_OPTION_CATALOG, + { + models: [sol], + fastModeSupport: { supported: true }, + current: { serviceTier: 'priority', defaulted: [] } } - }) + ) + expect( + structuredAgentSessionOptionSnapshot(fast).find(({ id }) => id === 'serviceTier') + ).toMatchObject({ settable: true, kind: { currentValue: 'priority' } }) const standard = commitStructuredAgentSessionOption(fast, 'serviceTier', 'default') // With no model, nothing says which other speed the chat can take. expect(canSetStructuredAgentSessionOption(standard, 'serviceTier', 'priority')).toBe(false) - const picked = commitStructuredAgentSessionOption(standard, 'model', 'gpt-6.1-sol') - const restarted = atRest({ model: 'gpt-6.1-sol', serviceTier: 'default' }) - for (const state of [picked, restarted]) { - expect(speed(state)).toMatchObject({ settable: true, kind: { currentValue: 'default' } }) - expect(canSetStructuredAgentSessionOption(state, 'serviceTier', 'priority')).toBe(true) - } - }) - - it("names no default as a resumed chat's own, whichever of its list and answer lands first", () => { - const models = [{ id: 'gpt-5.5', label: 'GPT-5.5', isDefault: true, efforts: [] }] - const listing: AgentSessionModelCatalogResult = { origin: 'probe', fetchedAt: 1, models } - const answer: AgentSessionOptionsResult = { - models, - current: { model: 'gpt-5.5', defaulted: ['model'] } - } - const seed = CODEX_SESSION_OPTION_CATALOG - const cold = () => createStructuredAgentSessionOptionState('codex', seed) - const resumed = { newLaunch: false } - for (const state of [ - applyStructuredAgentSessionOptions( - applyStructuredAgentSessionModelCatalog(cold(), seed, listing, resumed), - seed, - answer - ), - applyStructuredAgentSessionModelCatalog( - applyStructuredAgentSessionOptions(cold(), seed, answer), - seed, - listing, - resumed - ) - ]) { - const model = structuredAgentSessionOptionSnapshot(state)[0] - expect(model).toMatchObject({ valueSource: 'unknown' }) - expect(model?.kind).not.toHaveProperty('currentValue') - } }) }) From 2485af338a6153a472ccdb63cd441c91cf3ee7e4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:14:21 -0700 Subject: [PATCH 16/34] test(native-chat): cut option assertions the joined flows already pin Drops the Codex Standard row (the joined tier test pins it), the dispatched-source and literal effort-choice checks, and the resumed view test restart and model-pick steps; folds the pick table. --- ...-structured-agent-session-options.test.tsx | 30 +++++-------------- ...ructured-agent-session-option-view.test.ts | 18 +---------- 2 files changed, 8 insertions(+), 40 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx index 70158e8b8ae5..8b162f508152 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx @@ -647,10 +647,10 @@ describe('useStructuredAgentSessionOptions', () => { }) }) + const efforts = ['low', 'high'].map((value) => ({ value, label: value })) + describe('a new Claude chat in a fresh Claude home', () => { - const efforts = ['low', 'high'].map((value) => ({ value, label: value })) - // Claude's listing never names the configured model (claudeModelCatalogDiscovery) and a fresh - // home has learned none, so the host says its default may not run (agentModelCatalogResultFromEntry). + // A fresh home has learned no configured model, so the listing doesn't name what runs. const FRESH_LIST = { origin: 'probe', fetchedAt: 1, @@ -688,7 +688,6 @@ describe('useStructuredAgentSessionOptions', () => { await waitFor(() => expect(currentValue(result.current.optionSnapshot, 'model')).toBe('opus') ) - expect(currentValue(result.current.optionSnapshot, 'effort')).toBe('high') const { optionStateRef, optionView } = result.current.sendSource expect( structuredAgentSessionSendSelection(optionView(optionStateRef.current, undefined, {})) @@ -702,38 +701,27 @@ describe('useStructuredAgentSessionOptions', () => { // Claude replies with the values Orca set, so an effort it keeps is not in the reply. { agent: 'claude' as const, - tiers: [[], []], + tiers: [], current: { model: 'opus', effort: 'high' }, - reply: { model: 'second' }, shown: ['effort', 'high'] }, - // Codex writes Standard when the new model lacks the tier (codex-structured-service-tier.ts). - { - agent: 'codex' as const, - tiers: [[{ value: 'priority', label: 'Fast' }], [{ value: 'flex', label: 'Flex' }]], - current: { model: 'opus', serviceTier: 'priority' }, - reply: { model: 'second', serviceTier: 'default' }, - shown: ['serviceTier', 'default'] - }, // A configured tier no model lists (Flex) still runs, so it shows as held. { agent: 'codex' as const, - tiers: [[{ value: 'priority', label: 'Fast' }], [{ value: 'priority', label: 'Fast' }]], + tiers: [{ value: 'priority', label: 'Fast' }], current: { model: 'opus', serviceTier: 'flex' }, - reply: { model: 'second' }, shown: ['serviceTier', 'flex'] } ])( 'a $agent model pick shows what its reply names and keeps what it leaves out, $shown', async (pick) => { - const efforts = ['low', 'high'].map((value) => ({ value, label: value })) const live = { models: ['opus', 'second'].map((id, index) => ({ id, label: id, isDefault: index === 0, efforts, - serviceTiers: pick.tiers[index] + serviceTiers: pick.tiers })), current: { ...pick.current, confirmed: ['model'] } } @@ -743,7 +731,7 @@ describe('useStructuredAgentSessionOptions', () => { const { mutate } = mutateWith(async () => ({ key: 'model', value: 'second', - options: pick.reply + options: { model: 'second' } })) const { result, unmount } = renderOptions({ ...REOPENED, agent: pick.agent }, mutate) await waitFor(() => expect(currentValue(result.current.optionSnapshot, 'model')).toBe('opus')) @@ -752,10 +740,6 @@ describe('useStructuredAgentSessionOptions', () => { }) expect(currentValue(result.current.optionSnapshot, 'model')).toBe('second') expect(currentValue(result.current.optionSnapshot, pick.shown[0]!)).toBe(pick.shown[1]) - // The reply's or the host's value, never one the picker filled in. - expect(descriptor(result.current.optionSnapshot, pick.shown[0]!)?.valueSource).toBe( - 'dispatched' - ) unmount() } ) diff --git a/src/shared/structured-agent-session-option-view.test.ts b/src/shared/structured-agent-session-option-view.test.ts index 2b5998c0baae..9a73848e9d95 100644 --- a/src/shared/structured-agent-session-option-view.test.ts +++ b/src/shared/structured-agent-session-option-view.test.ts @@ -140,25 +140,12 @@ describe("a chat's model and effort, apart from the defaults filling them in", ( } ) - it("never names or sends a default as a resumed chat's own, and keeps its picks through a restart", () => { + it("never names or sends a default as a resumed chat's own, only its picks", () => { const state = resumed(['model', 'effort']) expect(shown(state, undefined, {})).toEqual({ model: undefined, effort: 'low' }) - expect( - structuredAgentSessionShownSnapshot(state).find((entry) => entry.id === 'effort') - ).toMatchObject({ settable: true, kind: { choices: levels('low', 'xhigh') } }) expect(carried(state)).toBeUndefined() const effort = commitStructuredAgentSessionOption(state, 'effort', 'xhigh') expect(carried(effort)).toEqual({ effort: 'xhigh' }) - // A model pick leaves the effort for the host to reconcile. - const model = commitStructuredAgentSessionOption(effort, 'model', 'sonnet') - expect(carried(model)).toEqual({ model: 'sonnet', effort: 'xhigh' }) - const restarted = applyStructuredAgentSessionOptions(fresh(), CLAUDE_SESSION_OPTION_CATALOG, { - models: CLI302, - listedModelIds: listed, - current: { model: 'sonnet', effort: 'high', defaulted: [] } - }) - expect(shown(restarted, undefined, {})).toEqual({ model: 'sonnet', effort: 'high' }) - expect(carried(restarted)).toEqual({ model: 'sonnet', effort: 'high' }) }) it("reads an older host's answer, which marks no default, as it always has", () => { @@ -173,9 +160,6 @@ describe("a chat's model and effort, apart from the defaults filling them in", ( models: CLI302, listingNamesConfiguredModel: true }) - expect( - structuredAgentSessionShownSnapshot(state).find((entry) => entry.id === 'effort') - ).toMatchObject({ settable: true, kind: { choices: levels('low', 'xhigh') } }) expect(shown(state, undefined, { effort: 'xhigh' })).toEqual({ model: 'opus', effort: 'xhigh' }) expect(carried(state, undefined, { effort: 'xhigh' })).toEqual({ model: 'opus', From 8886f2fd7fa39e8c22a05fe33ba1d962ca27c640 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:27:28 -0700 Subject: [PATCH 17/34] fix(native-chat): an at-rest pick reply and answer name the speed the next start runs The reply to a pick made at rest now carries the tier the chat model runs, through the same rule as the at-rest answer, so an older app whose follow-up read fails shows what the next turn sends; the saved record keeps the pick. A saved tier no listed model offers (a Flex from the user Codex config) is the agent configured tier at rest too, as the live rule and the picker already read it. Restores the pin that an accepted Codex reply naming Standard shows with no later read. --- ...uctured-agent-session-options-read.test.ts | 54 ++++++++++++++----- .../structured-agent-session-options-read.ts | 6 ++- .../structured-agent-session-pick-reply.ts | 23 ++++++++ ...ured-agent-new-chat-effort-at-rest.test.ts | 19 ++----- ...-structured-agent-session-options.test.tsx | 18 +++++-- ...structured-agent-session-effective-tier.ts | 4 +- 6 files changed, 87 insertions(+), 37 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index 28b579b7d39a..241e0bcceed5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -12,13 +12,19 @@ import { type AgentModelCatalogSuccess } from '../agent-model-catalog/agent-model-catalog-store' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' -import { readStructuredAgentSessionOptions } from './structured-agent-session-options-read' +import { + readStructuredAgentSessionOptions, + recordStructuredAgentSessionOptionIntent +} from './structured-agent-session-options-read' import { AcpStructuredOptions } from '../../acp/acp-structured-options' import { GROK_CONFIG_OPTIONS } from '../../acp/acp-structured-adapter.test-support' import { SessionConfigOptionSchema } from '../../acp/generated/acp-protocol.generated' import { withLiveCatalogListing } from '../agent-model-catalog/agent-model-catalog-entry' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { + claudeAndCodexAgents, + NO_STRUCTURED_AGENTS +} from './structured-agent-session-adapter-router-test-support' const SESSION = 'session-1' @@ -211,15 +217,20 @@ describe('options at rest', () => { describe('a chat at rest, read by the host', () => { const created: RestingFacts['providerHandleChain'] = [{ origin: 'created' }] const turn: AgentJournalItemBody = { kind: 'turn', turnId: 'turn-1', state: 'completed' } - const flex = [{ value: 'flex', label: 'Flex' }] + const fast = [{ value: 'priority', label: 'Fast' }] + const flex = [...fast, { value: 'flex', label: 'Flex' }] const listed: AgentSessionModelOption[] = [ - { id: 'gpt-account', label: 'Model', isDefault: true, efforts: [], serviceTiers: [] }, + { id: 'gpt-account', label: 'Model', isDefault: true, efforts: [], serviceTiers: fast }, { id: 'gpt-flex', label: 'Flex', isDefault: false, efforts: [], serviceTiers: flex } ] + const catalog = (models = listed) => ({ + read: async () => ({ origin: 'probe' as const, models, fetchedAt: 1 }) + }) function readAtRest( facts: RestingFacts, items: AgentJournalItemBody[] = [], - wroteBeforeOpen = false + wroteBeforeOpen = false, + models = listed ) { const record = { ...restingRecord(), ...facts } const resting = { @@ -233,7 +244,7 @@ describe('a chat at rest, read by the host', () => { adapter: {}, agents: NO_STRUCTURED_AGENTS, store: { getRecord: () => record }, - modelCatalog: { read: async () => ({ origin: 'probe', models: listed, fetchedAt: 1 }) } + modelCatalog: catalog(models) }, serialize: (_sessionId: string, task: () => Promise) => task(), openConversation: async () => resting, @@ -255,13 +266,30 @@ describe('a chat at rest, read by the host', () => { expect(read.current).toMatchObject({ model: 'gpt-account', defaulted }) }) - it("names the tier the chat's model runs, and keeps the saved one", async () => { - const options = { model: 'gpt-account', serviceTier: 'flex' } - await expect(readAtRest({ options })).resolves.toMatchObject({ - current: { serviceTier: 'default' } - }) - expect(options.serviceTier).toBe('flex') - }) + it.each([ + ['another model offers', listed, 'default'], + // Codex lists no Flex; a Flex from the user's config runs as configured. + ['no model lists', listed.slice(0, 1), 'flex'] + ])( + 'names the tier the model runs where %s it, and keeps the saved one', + async (_n, models, runs) => { + const options = { model: 'gpt-account', serviceTier: 'flex' } + const answer = await readAtRest({ options }, [], false, models) + expect(answer.current).toMatchObject({ serviceTier: runs }) + const record = { ...restingRecord(), options } + const picked = await recordStructuredAgentSessionOptionIntent( + { + store: { getRecord: () => record, replaceSessionOptions: vi.fn() }, + agents: claudeAndCodexAgents(), + modelCatalog: catalog(models) + }, + { sessionId: SESSION, fence: 1, persistOptions: vi.fn(), publish: vi.fn(), now: () => 0 }, + { key: 'model', value: 'gpt-account' } + ) + expect(picked).toMatchObject({ value: { options: { serviceTier: runs } } }) + expect(record.options.serviceTier).toBe('flex') + } + ) }) describe('live Codex option reads', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 41289e42cb6d..92d3184beb20 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -36,6 +36,7 @@ import { structuredAgentSessionLaunchPermission } from './structured-agent-sessi import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { structuredAgentSessionNeverSent } from './structured-agent-session-never-sent' +import { restingPickReplyOptions } from './structured-agent-session-pick-reply' type RestingOptions = Pick< AgentSessionOptionsResult, @@ -170,7 +171,7 @@ export async function recordStructuredAgentSessionOptionIntent( deps: { store: Pick agents: Pick - }, + } & Parameters[0], ctx: Pick, input: { key: string; value: string } ): Promise> { @@ -196,7 +197,8 @@ export async function recordStructuredAgentSessionOptionIntent( }) : ctx.persistOptions(options)) ctx.publish() - return { ok: true, value: { ...input, options } } + const replied = await restingPickReplyOptions(deps, record, ctx.sessionId, options) + return { ok: true, value: { ...input, options: replied } } } /** A live child's own answer, or the answer at rest; the goal, usage and rewind either way. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts new file mode 100644 index 000000000000..03f77afe8398 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts @@ -0,0 +1,23 @@ +// A pick made at rest is answered with what the next start runs; the record keeps the pick itself. + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { savedEffectiveServiceTier } from '../../../shared/structured-agent-session-effective-tier' +import type { AgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' + +/** The saved options with the tier the chat's model runs, as the at-rest answer names it. */ +export async function restingPickReplyOptions( + deps: { modelCatalog?: Pick }, + record: Pick, + sessionId: string, + options: Record +): Promise> { + if (!options.serviceTier) { + return options + } + const listing = await deps.modelCatalog + ?.read({ agent: record.provider, sessionId, savedOnly: true }) + .catch(() => null) + const models = listing && listing.origin !== 'unknown' ? listing.models : [] + const serviceTier = savedEffectiveServiceTier(options, models) ?? options.serviceTier + return { ...options, serviceTier } +} diff --git a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts index f5a3752217d6..215c4c99101d 100644 --- a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts +++ b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts @@ -271,12 +271,7 @@ describe("a new chat's effort pill before its agent starts", () => { expect(first.model?.value).toBe(model) expect(first.effort?.value).toBe(effort) expect(settled).toEqual(first) - expect(atRest.current).toEqual({ - model, - effort, - defaulted: [], - ...modeOf(agent) - }) + expect(atRest.current).toEqual({ model, effort, defaulted: [], ...modeOf(agent) }) } ) @@ -304,11 +299,7 @@ describe("a new chat's effort pill before its agent starts", () => { }) // The account default is not what this repo runs, so its effort is never claimed. - expect(atRest.current).toEqual({ - model: 'gpt-5.5', - defaulted: [], - permissionMode: 'ask' - }) + expect(atRest.current).toEqual({ model: 'gpt-5.5', defaulted: [], permissionMode: 'ask' }) expect(atRest.conversationCommands).toEqual(['clear', 'compact']) }) @@ -338,10 +329,6 @@ describe("a new chat's effort pill before its agent starts", () => { it('leaves the effort of a saved model to the agent, as its running child reports it', async () => { const { atRest } = await newChatFrames('codex', { saved: { model: 'gpt-5.5-mini' } }) - expect(atRest.current).toEqual({ - model: 'gpt-5.5-mini', - defaulted: [], - permissionMode: 'ask' - }) + expect(atRest.current).toEqual({ model: 'gpt-5.5-mini', defaulted: [], permissionMode: 'ask' }) }) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx index 8b162f508152..c337c7d83f11 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx @@ -701,15 +701,25 @@ describe('useStructuredAgentSessionOptions', () => { // Claude replies with the values Orca set, so an effort it keeps is not in the reply. { agent: 'claude' as const, - tiers: [], + tiers: [[], []], current: { model: 'opus', effort: 'high' }, + reply: {}, shown: ['effort', 'high'] }, + // Codex writes Standard when the new model lacks the tier, which shows with no later read. + { + agent: 'codex' as const, + tiers: [[{ value: 'priority', label: 'Fast' }], [{ value: 'flex', label: 'Flex' }]], + current: { model: 'opus', serviceTier: 'priority' }, + reply: { serviceTier: 'default' }, + shown: ['serviceTier', 'default'] + }, // A configured tier no model lists (Flex) still runs, so it shows as held. { agent: 'codex' as const, - tiers: [{ value: 'priority', label: 'Fast' }], + tiers: [[{ value: 'priority', label: 'Fast' }], [{ value: 'priority', label: 'Fast' }]], current: { model: 'opus', serviceTier: 'flex' }, + reply: {}, shown: ['serviceTier', 'flex'] } ])( @@ -721,7 +731,7 @@ describe('useStructuredAgentSessionOptions', () => { label: id, isDefault: index === 0, efforts, - serviceTiers: pick.tiers + serviceTiers: pick.tiers[index] })), current: { ...pick.current, confirmed: ['model'] } } @@ -731,7 +741,7 @@ describe('useStructuredAgentSessionOptions', () => { const { mutate } = mutateWith(async () => ({ key: 'model', value: 'second', - options: { model: 'second' } + options: { model: 'second', ...pick.reply } })) const { result, unmount } = renderOptions({ ...REOPENED, agent: pick.agent }, mutate) await waitFor(() => expect(currentValue(result.current.optionSnapshot, 'model')).toBe('opus')) diff --git a/src/shared/structured-agent-session-effective-tier.ts b/src/shared/structured-agent-session-effective-tier.ts index e5f1d17ccf5f..1d529c31eb6a 100644 --- a/src/shared/structured-agent-session-effective-tier.ts +++ b/src/shared/structured-agent-session-effective-tier.ts @@ -21,7 +21,7 @@ type TieredModel = { id: string; serviceTiers?: readonly { value: string }[] } const modelTierValues = (model: TieredModel | undefined) => model?.serviceTiers?.map((choice) => choice.value) -/** The tier a chat at rest runs under its saved model, which no agent has reported yet. */ +/** The tier a chat at rest runs under its saved model; one no model lists is the agent's configured tier. */ export function savedEffectiveServiceTier( saved: { model?: string; serviceTier?: string }, models: readonly TieredModel[] @@ -34,7 +34,7 @@ export function savedEffectiveServiceTier( ? modelTierValues(models.find((entry) => entry.id === saved.model)) : undefined, listedTiers: models.flatMap((entry) => modelTierValues(entry) ?? []), - reported: false + reported: true }) ) } From e746e44e3411371cebbd87ff0448c8364fedb190 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:18:32 -0700 Subject: [PATCH 18/34] fix(native-chat): a reopened chat with no saved model names its default at once A reopened chat that never saved a model showed just "Model" until a send started its agent, while main names the list default straight away. The picker now names the default the host marks for every chat, as main does. Whether a send carries that default is a separate rule: a new chat sends it, so its first send pins it; a reopened chat does not, so its next start still runs the agent's own. --- .../structured-agent-session-option-view.test.ts | 4 ++-- .../structured-agent-session-option-view.ts | 7 +++---- src/shared/structured-agent-session-options.ts | 15 +++++++-------- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/shared/structured-agent-session-option-view.test.ts b/src/shared/structured-agent-session-option-view.test.ts index 9a73848e9d95..ebee6f88400f 100644 --- a/src/shared/structured-agent-session-option-view.test.ts +++ b/src/shared/structured-agent-session-option-view.test.ts @@ -140,9 +140,9 @@ describe("a chat's model and effort, apart from the defaults filling them in", ( } ) - it("never names or sends a default as a resumed chat's own, only its picks", () => { + it("names a resumed chat's defaults but never sends them, only its picks", () => { const state = resumed(['model', 'effort']) - expect(shown(state, undefined, {})).toEqual({ model: undefined, effort: 'low' }) + expect(shown(state, undefined, {})).toEqual({ model: 'opus', effort: 'low' }) expect(carried(state)).toBeUndefined() const effort = commitStructuredAgentSessionOption(state, 'effort', 'xhigh') expect(carried(effort)).toEqual({ effort: 'xhigh' }) diff --git a/src/shared/structured-agent-session-option-view.ts b/src/shared/structured-agent-session-option-view.ts index 4d6e0c2f955e..f97762156e5c 100644 --- a/src/shared/structured-agent-session-option-view.ts +++ b/src/shared/structured-agent-session-option-view.ts @@ -85,9 +85,8 @@ function modelOptionView( } /** The Claude model and effort a send carries: the chat's own and the picks over them in `view`. - * A picker that names its defaults (a new chat, or an older host's answer) sends what it shows, - * so a new chat's first send pins them and a later CLI default never moves it; a resumed chat's - * defaults are never named, so never sent. */ + * A new chat (or an older host's answer) sends the defaults it shows, so a new chat's first send + * pins them; a resumed chat shows its host's defaults but never sends them. */ export function structuredAgentSessionSendSelection( view: StructuredAgentSessionOptionState ): Record | undefined { @@ -95,7 +94,7 @@ export function structuredAgentSessionSendSelection( return undefined } const snapshot = - view.catalog?.defaultModelIsCliDefault === true + view.catalog?.defaultModelIsCliDefault === true && !view.defaultsUnsent ? structuredAgentSessionOptionSnapshot(view) : [] const shown = (id: string): string | undefined => { diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index 44ec6cd39d97..ed5ae5720d11 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -32,8 +32,7 @@ export { export function structuredAgentSessionOptionCatalog( seed: AgentSessionOptionCatalog, - result: AgentSessionOptionsResult, - newLaunch: boolean + result: AgentSessionOptionsResult ): AgentSessionOptionCatalog { const models: CatalogModel[] = result.models.map((model) => discoveredModel(model, result.fastModeSupport?.supported === true) @@ -45,11 +44,7 @@ export function structuredAgentSessionOptionCatalog( options: seed.unknownModelOptions ?? [] }) } - // A new chat names the default it runs until its first send pins it; a resumed chat names only - // its own model. An older answer marks no defaults, so its model was always named. - return result.current.defaulted === undefined || newLaunch - ? { ...seed, models, defaultModelIsCliDefault: true } - : { ...seed, models } + return { ...seed, models, defaultModelIsCliDefault: true } } export type StructuredAgentSessionOptionState = { @@ -64,6 +59,9 @@ export type StructuredAgentSessionOptionState = { record: StructuredAgentSessionOptionRecord /** The last answer came from a host that marks no defaults, whose own tier rule it shows. */ olderHost?: true + /** A resumed chat names the default its host marks but never sends it: its next start runs the + * agent's own. A new chat sends the default it shows, so its first send pins it. */ + defaultsUnsent?: true /** The permission mode the host or launch last stated; null shows no picker. */ permissionMode: string | null pendingId: string | null @@ -184,9 +182,10 @@ export function applyStructuredAgentSessionOptions( ) return { ...state, - catalog: structuredAgentSessionOptionCatalog(seed, result, launch.newLaunch), + catalog: structuredAgentSessionOptionCatalog(seed, result), catalogSource: 'live', olderHost: current.defaulted === undefined ? true : undefined, + defaultsUnsent: current.defaulted !== undefined && !launch.newLaunch ? true : undefined, verifiedModelIds: result.listedModelIds?.length ? result.listedModelIds : undefined, permissionMode: result.current.permissionMode ?? null } From b6d6610b9001a1cd3e10ddc9ed1ab2fc9569e7de Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:35:07 -0700 Subject: [PATCH 19/34] fix(native-chat): a chat with no saved model keeps the model its first turn ran A chat that never saved a model followed whatever the agent's default was at each start, so a chat resumed from history or a terminal could quietly switch models after a restart. When a message is handed to the agent on such a chat, the host now saves the model that turn ran: the message's own model when it carried one, otherwise the model the agent reports running. A default the host filled in, no model at all, or a model the agent would refuse as a choice on later sends, is never saved. The write sets the model only while none is saved, inside the record store's transaction, so a pick made meanwhile always wins and effort, speed and Fast are untouched. One host step covers Claude, Codex, ACP agents and Pi. No new saved field. --- ...-structured-effort-default-at-rest.test.ts | 44 +++++++++++- .../structured-agent-session-delivery-loop.ts | 4 +- .../structured-agent-session-handover.ts | 9 ++- .../structured-agent-session-host-delivery.ts | 10 ++- ...ured-agent-session-message-choices.test.ts | 17 ++++- ...ructured-agent-session-provider-started.ts | 13 ++++ ...ueued-message-rig-provider.test-fixture.ts | 6 +- .../structured-agent-session-ran-model.ts | 70 +++++++++++++++++++ .../runtime/agent-session-record-options.ts | 13 ++++ .../runtime/agent-session-record-store.ts | 8 ++- ...tructured-agent-session-pi-startup.test.ts | 7 +- 11 files changed, 189 insertions(+), 12 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-ran-model.ts diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index b0e8bbf74e94..98abe1b80063 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -15,6 +15,7 @@ import { agentModelCatalogFingerprintForRecord } from '../native-chat/agent-mode import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import type { StructuredAgentSessionMutationContext } from '../native-chat/agent-session-wire/structured-agent-session-host-mutations' import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read' +import { structuredAgentSessionSendSelection } from '../../shared/structured-agent-session-option-view' import { claudeAndCodexAgents } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' import { composeCodexSessionOptionCatalog } from '../codex/codex-structured-model-catalog' import { @@ -139,12 +140,15 @@ async function startChild( return adapter } -function restingRecord(options: Record): AgentSessionRecord { +function restingRecord( + options: Record, + providerHandleChain: { origin: string }[] = [] +): AgentSessionRecord { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields. return { provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }, - providerHandleChain: [], + providerHandleChain, location: { executionHostId: 'local', wslDistro: null }, // Why released: a chat at rest has no live claim, so its next start reads the current account. lease: { claimStatus: 'released' }, @@ -239,6 +243,42 @@ describe('Claude effort default at rest', () => { expect(pickerEffort(result)).toBe('medium') }) + it('names the default for a reopened chat with no saved model, then keeps the model it ran', async () => { + const store = new AgentModelCatalogStore() + const child = await startChild(store) + const seed = getAgentSessionOptionCatalog('claude')! + const picker = (answer: Awaited>) => { + const state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('claude', seed), + seed, + answer + ) + const row = structuredAgentSessionOptionSnapshot(state).find((entry) => entry.id === 'model') + const shown = row?.kind.type === 'select' ? row.kind.currentValue : undefined + return { shown, sends: structuredAgentSessionSendSelection(state)?.model } + } + const reopened = await readAtRest(store, restingRecord({}, [{ origin: 'adopted' }])) + expect(reopened.current).toMatchObject({ model: 'opus[1m]', defaulted: ['model', 'effort'] }) + expect(picker(reopened)).toEqual({ shown: 'opus[1m]', sends: undefined }) + + // Its first turn runs what the CLI reports (claude-opus-5-5[1m]); the host saves that row. + const ran = (await child.readOptions({ sessionId: SESSION, fence: 7 })).current + expect(ran).toMatchObject({ model: 'opus[1m]' }) + expect(ran.defaulted ?? []).not.toContain('model') + // The account's default moves to Sonnet; the chat keeps the model it ran, after a restart too. + const sonnet = CATALOG.map((row) => + row.value === 'default' ? { ...row, resolvedModel: 'claude-sonnet-5' } : row + ) + await startChild(store, undefined, [], { runs: 'claude-sonnet-5', lists: sonnet }) + const fresh = await readAtRest(store, restingRecord({}, [{ origin: 'adopted' }])) + expect(fresh.current.model).toBe('sonnet') + const kept = await readAtRest( + store, + restingRecord({ model: ran.model! }, [{ origin: 'adopted' }]) + ) + expect(picker(kept)).toEqual({ shown: 'opus[1m]', sends: 'opus[1m]' }) + }) + it("shows the user's pick at rest over the default", async () => { const store = new AgentModelCatalogStore() await startChild(store) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index c103bf2e4fbd..f91b3e9136df 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -86,6 +86,7 @@ export type StructuredAgentSessionDeliveryLoopDeps = { stopping: (sessionId: string) => boolean now: () => number acquireAborts: Pick + recordRanModel?: (sessionId: string, fence: number, model?: string) => Promise } type Prepared = @@ -332,7 +333,8 @@ export class StructuredAgentSessionDeliveryLoop { now: this.deps.now, acquireAborts: this.deps.acquireAborts, deliveryOrder: this.deps.deliveryOrder, - workChanged: () => this.deps.workChanged?.(sessionId) + workChanged: () => this.deps.workChanged?.(sessionId), + recordRanModel: (model) => this.deps.recordRanModel?.(sessionId, awaitedChild.fence, model) }, next, // Re-read right before the provider call, which runs outside the serialize. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts index eefe8711a197..5c2471f3471d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts @@ -35,6 +35,8 @@ export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverC acquireAborts?: Pick deliveryOrder?: StructuredAgentSessionDeliveryOrder workChanged?: () => void + /** A message the agent took: a chat with no saved model keeps the model it ran. */ + recordRanModel?: (messageModel: string | undefined) => Promise } /** A handover whose pending row and abort handle are committed: the provider call runs outside the @@ -130,7 +132,12 @@ export async function beginSubmissionHandover( } finally { attempt?.end() } - return () => resolveHandover(ctx, clientMessageId, outcome) + return async () => { + await resolveHandover(ctx, clientMessageId, outcome) + if (outcome?.state === 'accepted' || outcome?.state === 'admitted') { + await ctx.recordRanModel?.(submission.options?.model) + } + } } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index dba7c8ca96a2..5a7286e3aca0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -30,6 +30,7 @@ import { retireOutgrownStructuredAgentSessionChild } from './structured-agent-se import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' import { acceptedNoLaterThan } from './structured-agent-session-start-failure-row' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' +import { recordStructuredAgentSessionRanModel } from './structured-agent-session-ran-model' export type StructuredAgentSessionConversationDelivery = { loop: StructuredAgentSessionDeliveryLoop @@ -140,7 +141,14 @@ export function createStructuredAgentSessionConversationDelivery(input: { childWorkOpen: input.clientDelivery.childWorkOpen, stopping: input.clientDelivery.readStopping, now: () => deps.now?.() ?? Date.now(), - acquireAborts: input.acquireAborts + acquireAborts: input.acquireAborts, + recordRanModel: (sessionId, fence, model) => + recordStructuredAgentSessionRanModel( + { deps, sessions, now: () => deps.now?.() ?? Date.now() }, + sessionId, + fence, + model + ) }) const adoptOpened = async ( sessionId: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts index 03050db82ed6..b9fb9e7c3906 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts @@ -49,6 +49,20 @@ it('keeps the first accepted choice through opening and saves a later pick for t expect(rig.store.getRecord(SESSION)?.options?.model).toBe('model-b') }) +it('keeps the model a chat with none saved first ran, and later sends carry it', async () => { + await rig.dispose() + rig = await createQueuedMessageTestRig({ restartable: true, reportsModel: 'gpt-ran' }) + const first = rig.send('first') + expect(await first.result).toMatchObject({ ok: true }) + await eventually(() => expect(rig.store.getRecord(SESSION)?.options?.model).toBe('gpt-ran')) + await rig.settleAccepted(first.id, 'a') + await rig.restartHostProcess() + const second = rig.send('second') + expect(await second.result).toMatchObject({ ok: true }) + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(2)) + expect(rig.dispatch.mock.calls[1]?.[0].executionOptions).toMatchObject({ model: 'gpt-ran' }) +}) + it('rejects a refused choice before prompt input and leaves the next pick saved', async () => { await pickModel('model-b') rig.dispatch.mockRejectedValueOnce( @@ -151,5 +165,6 @@ it('steers a matching choice but holds an empty default choice for a new turn', ) await rig.host.flushStreamedEvents(SESSION) await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(3)) - expect(rig.dispatch.mock.calls[2]?.[0].executionOptions).toEqual({}) + // It names no choice of its own: it runs the model the chat first ran, which the chat kept. + expect(rig.dispatch.mock.calls[2]?.[0].executionOptions).toEqual({ model: 'model-a' }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index 00eafb4e3393..ce4452bd6abb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts @@ -17,6 +17,7 @@ import { saveStructuredAgentSessionStartReadbackListing } from './structured-agent-session-start-listing' import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' +import { noteStructuredAgentSessionReportedModel } from './structured-agent-session-ran-model' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import { recordAgentSessionProviderHandle } from '../../runtime/agent-session-provider-handle-transition' import type { AgentSessionRecord } from '../../../shared/agent-session-record' @@ -87,6 +88,12 @@ export async function settleStructuredAgentSessionProviderStarted( return null } context.runtimeState.startupAttempts.ready(event.sessionId, child) + await noteStructuredAgentSessionReportedModel( + context, + event.sessionId, + child.fence, + event.reportedOptions + ) void context.wakeDelivery(event.sessionId) noteStructuredAgentSessionProviderStarted(context.deps, event.sessionId) if (event.catalogListing) { @@ -235,6 +242,12 @@ export async function settleStructuredAgentSessionOptionsReported( return } } + await noteStructuredAgentSessionReportedModel( + context, + event.sessionId, + event.fence, + event.reportedOptions + ) if (event.catalogListing) { void saveStructuredAgentSessionStartReadbackListing(context, event, event.catalogListing) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts index 692aaefdbd66..9090bd7e7811 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts @@ -27,6 +27,8 @@ export type QueuedRigProviderOptions = { stopEndsSession?: true /** Its child never answers its start, so it runs nothing it is handed. */ startUnanswered?: true + /** The model its child reports running; otherwise the agent's own `default`. */ + reportsModel?: string } export function createQueuedRigProvider( @@ -64,11 +66,11 @@ export function createQueuedRigProvider( ? new Promise((resolve) => { proveOpen = () => resolve({ - reportedOptions: { model: 'default' } + reportedOptions: { model: options.reportsModel ?? 'default' } }) }) : Promise.resolve({ - reportedOptions: { model: 'default' } + reportedOptions: { model: options.reportsModel ?? 'default' } }), acquire: async ({ identity, fence, spawnToken, events: sink }) => { starts() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ran-model.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-model.ts new file mode 100644 index 000000000000..18c0b73414ce --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-model.ts @@ -0,0 +1,70 @@ +// A chat with no saved model keeps the first model one of its turns ran as its own: the message's +// model, else the one its agent reports running. A default the host filled in is never one. + +import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import { structuredAgentSessionOptionOverridesRefusal } from './structured-agent-session-option-intent' + +type RanModelContext = { + deps: Pick + sessions: ReadonlyMap + now: () => number +} + +/** In memory per child: the model it last reported running, and whether a turn it was handed + * still owes a record because nothing had reported one yet. */ +const childRuns = new WeakMap() + +function currentChild(context: RanModelContext, sessionId: string, fence: number) { + const child = context.sessions.get(sessionId)?.child + return child?.fence === fence ? child : null +} + +/** Notes what a child reports running; a turn handed over before any report records it now. */ +export async function noteStructuredAgentSessionReportedModel( + context: RanModelContext, + sessionId: string, + fence: number, + reported: AgentSessionOptionsResult['current'] +): Promise { + const child = currentChild(context, sessionId, fence) + const { model } = reported + if (!child || !model || model === 'default' || reported.defaulted?.includes('model')) { + return + } + const runs = childRuns.get(child) + childRuns.set(child, { reported: model }) + if (runs?.owed) { + await recordStructuredAgentSessionRanModel(context, sessionId, fence, undefined) + } +} + +/** After a turn was handed over; bookkeeping, so a failure is logged and never refuses the send. */ +export async function recordStructuredAgentSessionRanModel( + context: RanModelContext, + sessionId: string, + fence: number, + messageModel: string | undefined +): Promise { + const child = currentChild(context, sessionId, fence) + const { store, logger, agents } = context.deps + const record = store.getRecord(sessionId) + if (!child || !record || record.options?.model !== undefined) { + return + } + const reported = childRuns.get(child)?.reported + const model = messageModel ?? reported + childRuns.set(child, { ...(reported ? { reported } : {}), ...(model ? {} : { owed: true }) }) + // Never a model every later send would carry and the agent refuse as a choice. + if (!model || structuredAgentSessionOptionOverridesRefusal(agents, record.provider, { model })) { + return + } + await store + .recordRanModel({ sessionId, fence: child.fence, model, now: context.now() }) + .catch((error: unknown) => + logger.warn('recording the model a turn ran failed', { scope: 'ran-model', sessionId, error }) + ) +} diff --git a/src/main/runtime/agent-session-record-options.ts b/src/main/runtime/agent-session-record-options.ts index a99296b53638..aa9db92445d7 100644 --- a/src/main/runtime/agent-session-record-options.ts +++ b/src/main/runtime/agent-session-record-options.ts @@ -18,3 +18,16 @@ export function replaceAgentSessionRecordOptions( ? { ...record, options: { ...replacement.options }, updatedAt: replacement.now } : { ...record, permissionMode: replacement.permissionMode, updatedAt: replacement.now } } + +/** A model a turn ran, recorded as the chat's own only while it has none: never over a pick, even + * one made since, and no other option moves. */ +export type AgentSessionRanModel = { sessionId: string; fence: number; model: string; now: number } + +export function recordAgentSessionRanModel( + record: AgentSessionRecord, + { model, ...at }: AgentSessionRanModel +): AgentSessionRecord { + return record.options?.model !== undefined + ? record + : replaceAgentSessionRecordOptions(record, { ...at, options: { ...record.options, model } }) +} diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 849096dfceae..234e392aa414 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -56,7 +56,10 @@ import { collectAgentSessionRestartProbes, type AgentSessionRestartProbeArgs } from './agent-session-restart-reconciliation' -import { replaceAgentSessionRecordOptions } from './agent-session-record-options' +import { + recordAgentSessionRanModel, + replaceAgentSessionRecordOptions +} from './agent-session-record-options' import { commitAgentSessionReservation, type AgentSessionReserveRequest, @@ -320,6 +323,9 @@ export class AgentSessionRecordStore { replaceSessionOptions = (args: AgentSessionOptionsReplacement): Promise => this.mutate(args.sessionId, (record) => replaceAgentSessionRecordOptions(record, args)) + recordRanModel = (ran: Parameters[1]) => + this.mutate(ran.sessionId, (record) => recordAgentSessionRanModel(record, ran)) + async retireClaimKey(keyId: string, now: number): Promise { await this.transact((draft) => retireAgentSessionClaimKey(draft, keyId, now)) } diff --git a/src/main/runtime/structured-agent-session-pi-startup.test.ts b/src/main/runtime/structured-agent-session-pi-startup.test.ts index bf399d16ab42..857d24df27d3 100644 --- a/src/main/runtime/structured-agent-session-pi-startup.test.ts +++ b/src/main/runtime/structured-agent-session-pi-startup.test.ts @@ -100,10 +100,11 @@ it('attaches Pi at spawn and holds the opening send until the provider answers i expect(script.prompts()).toEqual([]) script.releaseHandshake() await vi.waitFor(() => expect(script.prompts()).toEqual(['hello while Pi is starting'])) + // The chat keeps the model Pi ran its first turn on. await vi.waitFor(() => - expect( - peekOpenedAgentSessionRecordStore()?.getRecord(HOST_TEST_SESSION)?.options - ).toBeUndefined() + expect(peekOpenedAgentSessionRecordStore()?.getRecord(HOST_TEST_SESSION)?.options).toEqual({ + model: 'scripted/model-1' + }) ) await stopStructuredAgentSessionRuntime() expect(script.closes()).toBe(1) From e43d91966c8847fe059f68ea99b2529dcf67a844 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:10:16 -0700 Subject: [PATCH 20/34] fix(native-chat): a chat with no saved choices keeps the model and effort its first turn ran A Codex message that left the model or effort unset was filled from the model list's defaults, not from what the running thread uses. Once the host saved the model a chat ran, the next Codex turn carried that model alone and ran the list's default effort for it (a configured xhigh became medium), and a message carrying only an effort ran the list's default model while the chat saved the thread's. A Codex message now resolves an unset model or effort to the thread's own, and only a switch to a different model takes that model's default effort, the same rule the other agents use. When a chat with no saved model records what its first turn ran, it now records the effort, service tier and Fast that turn ran too, in the same one-time store write: each only where the chat has none saved, only from the message's own value or the agent's real report, never a filled-in default, so a later change to the agent's configured effort no longer pairs the kept model with an effort it never ran. No new saved field and no wire change. A Codex follow-up sent while a chat's first turn runs is compared with what that turn actually runs, so it steers instead of waiting for a new turn just because the host saved the model meanwhile. Also fixes the delivery loop's optional recording hook type. --- ...-structured-effort-default-at-rest.test.ts | 16 +-- .../codex-structured-message-options.test.ts | 16 ++- .../codex/codex-structured-message-options.ts | 22 ++-- .../codex/codex-structured-session-start.ts | 8 -- .../codex/codex-structured-session-state.ts | 4 +- .../structured-agent-session-delivery-loop.ts | 8 +- .../structured-agent-session-handover.ts | 6 +- .../structured-agent-session-host-delivery.ts | 8 +- ...ured-agent-session-message-choices.test.ts | 45 +++++-- ...tructured-agent-session-message-options.ts | 19 ++- ...ructured-agent-session-provider-started.ts | 6 +- ...ueued-message-rig-provider.test-fixture.ts | 14 +- .../structured-agent-session-ran-model.ts | 70 ---------- .../structured-agent-session-ran-options.ts | 124 ++++++++++++++++++ .../runtime/agent-session-record-options.ts | 20 ++- .../runtime/agent-session-record-store.ts | 6 +- ...tructured-agent-session-pi-startup.test.ts | 5 +- 17 files changed, 244 insertions(+), 153 deletions(-) delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-ran-model.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 98abe1b80063..883126d45927 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -243,7 +243,7 @@ describe('Claude effort default at rest', () => { expect(pickerEffort(result)).toBe('medium') }) - it('names the default for a reopened chat with no saved model, then keeps the model it ran', async () => { + it('names the default for a reopened chat with no saved model, and its run reports that row', async () => { const store = new AgentModelCatalogStore() const child = await startChild(store) const seed = getAgentSessionOptionCatalog('claude')! @@ -261,22 +261,10 @@ describe('Claude effort default at rest', () => { expect(reopened.current).toMatchObject({ model: 'opus[1m]', defaulted: ['model', 'effort'] }) expect(picker(reopened)).toEqual({ shown: 'opus[1m]', sends: undefined }) - // Its first turn runs what the CLI reports (claude-opus-5-5[1m]); the host saves that row. + // Its first turn runs what the CLI reports (claude-opus-5-5[1m]), as that row. const ran = (await child.readOptions({ sessionId: SESSION, fence: 7 })).current expect(ran).toMatchObject({ model: 'opus[1m]' }) expect(ran.defaulted ?? []).not.toContain('model') - // The account's default moves to Sonnet; the chat keeps the model it ran, after a restart too. - const sonnet = CATALOG.map((row) => - row.value === 'default' ? { ...row, resolvedModel: 'claude-sonnet-5' } : row - ) - await startChild(store, undefined, [], { runs: 'claude-sonnet-5', lists: sonnet }) - const fresh = await readAtRest(store, restingRecord({}, [{ origin: 'adopted' }])) - expect(fresh.current.model).toBe('sonnet') - const kept = await readAtRest( - store, - restingRecord({ model: ran.model! }, [{ origin: 'adopted' }]) - ) - expect(picker(kept)).toEqual({ shown: 'opus[1m]', sends: 'opus[1m]' }) }) it("shows the user's pick at rest over the default", async () => { diff --git a/src/main/codex/codex-structured-message-options.test.ts b/src/main/codex/codex-structured-message-options.test.ts index 60203acc6642..bd2c177c5f35 100644 --- a/src/main/codex/codex-structured-message-options.test.ts +++ b/src/main/codex/codex-structured-message-options.test.ts @@ -112,7 +112,12 @@ describe('Codex captured message choices', () => { : { config: { personality: 'pragmatic', service_tier: 'flex' } } ) const session = optionSession(request) - session.reportedOptions = { model: 'model-a', effort: 'high' } + session.reportedOptions = { + model: 'model-a', + effort: 'high', + serviceTier: 'flex', + serviceTierKnown: true + } session.options = new Map([ ['model', 'model-a'], ['effort', 'high'], @@ -132,7 +137,8 @@ describe('Codex captured message choices', () => { { model: 'model-a', effort: 'high', personality: 'friendly', serviceTier: 'priority' } ], ['second', {}], - ['third', { model: 'model-a' }] + ['third', { model: 'model-a' }], + ['fourth', { effort: 'low' }] ] as const) { const turnOptions = await resolvedCodexMessageOptions(session, choices) await startCodexTurn(session, { clientMessageId: id, body, turnOptions }) @@ -142,13 +148,16 @@ describe('Codex captured message choices', () => { request.mock.calls.filter(([method]) => method === 'turn/start').map(([, params]) => params) ).toMatchObject([ { model: 'model-a', effort: 'high', personality: 'friendly', serviceTier: 'priority' }, - { model: 'model-b', effort: 'medium', personality: 'pragmatic', serviceTier: 'flex' }, + // Unchosen keys keep the thread's own model and effort, not the listing's defaults. + { model: 'model-a', effort: 'high', personality: 'pragmatic', serviceTier: 'flex' }, + { model: 'model-a', effort: 'high' }, { model: 'model-a', effort: 'low' } ]) expect(request.mock.calls.map(([method]) => method)).toEqual([ 'config/read', 'turn/start', 'turn/start', + 'turn/start', 'turn/start' ]) expect(Object.fromEntries(session.options)).toEqual({ @@ -211,6 +220,7 @@ describe('Codex captured message choices', () => { it('refuses a default reset with no trustworthy default before prompt input', async () => { const request = vi.fn(async () => ({ turn: { id: 'unexpected' } })) const session = optionSession(request) + session.reportedOptions = {} session.options.set('model', 'old-override') session.options.set('effort', 'high') await expect(resolvedCodexMessageOptions(session, {})).rejects.toThrow( diff --git a/src/main/codex/codex-structured-message-options.ts b/src/main/codex/codex-structured-message-options.ts index 2aee7279dc97..bd92483255d1 100644 --- a/src/main/codex/codex-structured-message-options.ts +++ b/src/main/codex/codex-structured-message-options.ts @@ -15,21 +15,22 @@ export async function resolvedCodexMessageOptions( return undefined } const listing = codexAcquireCatalogListing(session.catalogAccess) - const listedDefault = listing?.models.find((entry) => entry.isDefault) const priorModel = session.options.get('model') const priorEffort = session.options.get('effort') + // An unchosen key keeps what the thread runs; another model brings its own default effort. + const thread = session.reportedOptions const model = - choices.model ?? - listedDefault?.id ?? - session.nativeDefaults?.model ?? - (priorModel || priorEffort ? undefined : session.reportedOptions.model) + choices.model ?? thread.model ?? listing?.models.find((entry) => entry.isDefault)?.id if (!model && (choices.effort || priorModel || priorEffort)) { throw new AgentSessionOptionRejectedError(new Error('codex default model is unavailable')) } const listedModel = listing?.models.find((entry) => entry.id === model) + const threadEffort = model === thread.model ? thread.effort : undefined const defaultEffort = - listedModel?.defaultEffort ?? - (model === session.nativeDefaults?.model ? session.nativeDefaults?.effort : undefined) + threadEffort && + (!listedModel || listedModel.efforts.some((entry) => entry.value === threadEffort)) + ? threadEffort + : listedModel?.defaultEffort if (choices.effort === undefined && priorEffort && !defaultEffort) { throw new AgentSessionOptionRejectedError(new Error('codex default effort is unavailable')) } @@ -82,13 +83,12 @@ export async function resolvedCodexMessageOptions( (choices.serviceTier && choices.serviceTier !== CODEX_DEFAULT_SERVICE_TIER)) ) { await applyCodexStructuredSessionOption(selected, 'model', model) - if (!defaultEffort && choices.effort === undefined) { + if (choices.effort === undefined && defaultEffort) { + await applyCodexStructuredSessionOption(selected, 'effort', defaultEffort) + } else if (choices.effort === undefined) { selected.options.delete('effort') } } - if (choices.effort === undefined && defaultEffort && (priorEffort || choices.model)) { - await applyCodexStructuredSessionOption(selected, 'effort', defaultEffort) - } for (const key of ['effort', 'personality', 'fastMode', 'serviceTier'] as const) { const value = choices[key] if (value !== undefined) { diff --git a/src/main/codex/codex-structured-session-start.ts b/src/main/codex/codex-structured-session-start.ts index b9a0897fbf28..ac1fda5d6813 100644 --- a/src/main/codex/codex-structured-session-start.ts +++ b/src/main/codex/codex-structured-session-start.ts @@ -119,14 +119,6 @@ export async function openCodexStructuredSession( prompts: attempt.window.prompts, options: restoredCodexSessionOptions(input.options), reportedOptions: reportedCodexThreadOptions(opened), - ...(launch.resumeThreadId === null && !launch.model && opened.model - ? { - nativeDefaults: { - model: opened.model, - ...(opened.effort ? { effort: opened.effort } : {}) - } - } - : {}), ...(catalogAccess ? { catalogAccess } : {}), dispatchEchoes: start.dispatchEchoes, translator, diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 465dd55f6b64..c8572cffce77 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -158,8 +158,8 @@ export type CodexSession = { serviceTier?: string | null serviceTierKnown?: true } - /** A fresh thread opened without a model override proved these native defaults. */ - nativeDefaults?: { model?: string; effort?: string; personality?: string; serviceTier?: string } + /** The configured personality and tier a reset returns to, read before the first override. */ + nativeDefaults?: { personality?: string; serviceTier?: string } /** Overrides actually attempted on this child, independently of future saved picks. */ appliedTurnOptions?: Readonly> /** Absent when the adapter runs without a host catalog store (tests). */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index f91b3e9136df..36ae49b9122c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -47,6 +47,7 @@ import { holdRestartedStructuredAgentSessionSends } from './structured-agent-ses import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' import { waitsForCodexTurnOptions } from './structured-agent-session-message-options' +import type { StructuredAgentSessionRanRecorder } from './structured-agent-session-ran-options' import { sameProviderChild } from './structured-agent-session-provider-child' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' @@ -86,7 +87,7 @@ export type StructuredAgentSessionDeliveryLoopDeps = { stopping: (sessionId: string) => boolean now: () => number acquireAborts: Pick - recordRanModel?: (sessionId: string, fence: number, model?: string) => Promise + recordRanOptions?: StructuredAgentSessionRanRecorder } type Prepared = @@ -313,8 +314,7 @@ export class StructuredAgentSessionDeliveryLoop { } } if ( - this.deps.record(sessionId)?.provider === 'codex' && - waitsForCodexTurnOptions(session.journal, next) + waitsForCodexTurnOptions(this.deps.record(sessionId), session.journal, next, awaitedChild) ) { return this.stop(sessionId) } @@ -334,7 +334,7 @@ export class StructuredAgentSessionDeliveryLoop { acquireAborts: this.deps.acquireAborts, deliveryOrder: this.deps.deliveryOrder, workChanged: () => this.deps.workChanged?.(sessionId), - recordRanModel: (model) => this.deps.recordRanModel?.(sessionId, awaitedChild.fence, model) + recordRanOptions: this.deps.recordRanOptions?.(sessionId, awaitedChild.fence) }, next, // Re-read right before the provider call, which runs outside the serialize. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts index 5c2471f3471d..1f2dc8838833 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts @@ -35,8 +35,8 @@ export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverC acquireAborts?: Pick deliveryOrder?: StructuredAgentSessionDeliveryOrder workChanged?: () => void - /** A message the agent took: a chat with no saved model keeps the model it ran. */ - recordRanModel?: (messageModel: string | undefined) => Promise + /** A message the agent took: a chat with no saved model keeps the options it ran. */ + recordRanOptions?: (message: AgentSessionExecutionOptions | undefined) => Promise } /** A handover whose pending row and abort handle are committed: the provider call runs outside the @@ -135,7 +135,7 @@ export async function beginSubmissionHandover( return async () => { await resolveHandover(ctx, clientMessageId, outcome) if (outcome?.state === 'accepted' || outcome?.state === 'admitted') { - await ctx.recordRanModel?.(submission.options?.model) + await ctx.recordRanOptions?.(submission.options) } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 5a7286e3aca0..9e0f2887b85b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -30,7 +30,7 @@ import { retireOutgrownStructuredAgentSessionChild } from './structured-agent-se import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' import { acceptedNoLaterThan } from './structured-agent-session-start-failure-row' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' -import { recordStructuredAgentSessionRanModel } from './structured-agent-session-ran-model' +import { recordStructuredAgentSessionRanOptions } from './structured-agent-session-ran-options' export type StructuredAgentSessionConversationDelivery = { loop: StructuredAgentSessionDeliveryLoop @@ -142,12 +142,12 @@ export function createStructuredAgentSessionConversationDelivery(input: { stopping: input.clientDelivery.readStopping, now: () => deps.now?.() ?? Date.now(), acquireAborts: input.acquireAborts, - recordRanModel: (sessionId, fence, model) => - recordStructuredAgentSessionRanModel( + recordRanOptions: (sessionId, fence) => (message) => + recordStructuredAgentSessionRanOptions( { deps, sessions, now: () => deps.now?.() ?? Date.now() }, sessionId, fence, - model + message ) }) const adoptOpened = async ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts index b9fb9e7c3906..098ece1e75a0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts @@ -49,18 +49,43 @@ it('keeps the first accepted choice through opening and saves a later pick for t expect(rig.store.getRecord(SESSION)?.options?.model).toBe('model-b') }) -it('keeps the model a chat with none saved first ran, and later sends carry it', async () => { +it('keeps the model and effort a chat with none saved first ran, after its config moves', async () => { await rig.dispose() - rig = await createQueuedMessageTestRig({ restartable: true, reportsModel: 'gpt-ran' }) + const agent = { restartable: true as const, reportsModel: 'gpt-ran', reportsEffort: 'xhigh' } + rig = await createQueuedMessageTestRig(agent) const first = rig.send('first') expect(await first.result).toMatchObject({ ok: true }) - await eventually(() => expect(rig.store.getRecord(SESSION)?.options?.model).toBe('gpt-ran')) + await eventually(() => + expect(rig.store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-ran', effort: 'xhigh' }) + ) await rig.settleAccepted(first.id, 'a') + Object.assign(agent, { reportsModel: 'gpt-new', reportsEffort: 'low' }) await rig.restartHostProcess() const second = rig.send('second') expect(await second.result).toMatchObject({ ok: true }) await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(2)) - expect(rig.dispatch.mock.calls[1]?.[0].executionOptions).toMatchObject({ model: 'gpt-ran' }) + expect(rig.dispatch.mock.calls[1]?.[0].executionOptions).toEqual({ + model: 'gpt-ran', + effort: 'xhigh' + }) +}) + +it('records what a later readback reports when the start named only a filled-in default', async () => { + // As Claude's start: no model yet, then its settings readback names the one the turn runs. + const first = rig.send('first') + expect(await first.result).toMatchObject({ ok: true }) + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(1)) + expect(rig.store.getRecord(SESSION)?.options).toBeUndefined() + await rig.host.handleAdapterEvent({ + type: 'options-reported', + sessionId: SESSION, + fence: rig.store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: 'generation-1', + reportedOptions: { model: 'opus[1m]', effort: 'high' } + }) + await eventually(() => + expect(rig.store.getRecord(SESSION)?.options).toEqual({ model: 'opus[1m]', effort: 'high' }) + ) }) it('rejects a refused choice before prompt input and leaves the next pick saved', async () => { @@ -126,7 +151,7 @@ it('keeps a draft’s accepted choices through a host restart and Send now', asy expect((await rig.handoff(draftId))?.options).toEqual(choices) }) -it('steers a matching choice but holds an empty default choice for a new turn', async () => { +it('steers a matching choice but holds a different one for a new turn', async () => { const choices = { model: 'model-a', effort: 'high' } const first = rig.send('first', undefined, { carried: choices }) await first.result @@ -152,8 +177,8 @@ it('steers a matching choice but holds an empty default choice for a new turn', clientMessageId: matching.id, providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-a', ordinal: 1 } }) - const defaults = rig.send('default choices', undefined, { carried: {} }) - await defaults.result + const other = rig.send('other choices', undefined, { carried: { effort: 'low' } }) + await other.result expect(rig.dispatch).toHaveBeenCalledTimes(2) rig @@ -165,6 +190,8 @@ it('steers a matching choice but holds an empty default choice for a new turn', ) await rig.host.flushStreamedEvents(SESSION) await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(3)) - // It names no choice of its own: it runs the model the chat first ran, which the chat kept. - expect(rig.dispatch.mock.calls[2]?.[0].executionOptions).toEqual({ model: 'model-a' }) + expect(rig.dispatch.mock.calls[2]?.[0].executionOptions).toEqual({ + model: 'model-a', + effort: 'low' + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts index f52e3fd71162..d08bfdb932fa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts @@ -2,24 +2,33 @@ import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { structuredAgentSessionChildRan } from './structured-agent-session-ran-options' const OPTION_ORDER = ['model', 'effort'] as const +/** A choice a message leaves unset runs what the child reports running. */ function sameOptions( a: AgentSessionExecutionOptions | undefined, - b: AgentSessionExecutionOptions | undefined + b: AgentSessionExecutionOptions | undefined, + ran: Readonly> | undefined ): boolean { if ((a === undefined) !== (b === undefined)) { return false } - return OPTION_ORDER.every((key) => a?.[key] === b?.[key]) + return OPTION_ORDER.every((key) => (a?.[key] ?? ran?.[key]) === (b?.[key] ?? ran?.[key])) } /** A different Codex model or effort needs a new turn: steer has no choice parameters. */ export function waitsForCodexTurnOptions( + record: Pick | null, journal: Pick, - next: AgentJournalSubmission + next: AgentJournalSubmission, + child: object ): boolean { + if (record?.provider !== 'codex') { + return false + } const body = journal.itemBody(agentJournalSubmissionKey(next.clientMessageId)) if (body?.kind !== 'message' || body.command) { return false @@ -34,5 +43,7 @@ export function waitsForCodexTurnOptions( } return agentJournalSubmissionKey(submission.clientMessageId) === turn.userItemId }) - return active ? !sameOptions(active.options, next.options) : true + return active + ? !sameOptions(active.options, next.options, structuredAgentSessionChildRan(child)) + : true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index ce4452bd6abb..72638f5fbd15 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts @@ -17,7 +17,7 @@ import { saveStructuredAgentSessionStartReadbackListing } from './structured-agent-session-start-listing' import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' -import { noteStructuredAgentSessionReportedModel } from './structured-agent-session-ran-model' +import { noteStructuredAgentSessionReportedOptions } from './structured-agent-session-ran-options' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import { recordAgentSessionProviderHandle } from '../../runtime/agent-session-provider-handle-transition' import type { AgentSessionRecord } from '../../../shared/agent-session-record' @@ -88,7 +88,7 @@ export async function settleStructuredAgentSessionProviderStarted( return null } context.runtimeState.startupAttempts.ready(event.sessionId, child) - await noteStructuredAgentSessionReportedModel( + await noteStructuredAgentSessionReportedOptions( context, event.sessionId, child.fence, @@ -242,7 +242,7 @@ export async function settleStructuredAgentSessionOptionsReported( return } } - await noteStructuredAgentSessionReportedModel( + await noteStructuredAgentSessionReportedOptions( context, event.sessionId, event.fence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts index 9090bd7e7811..59bcec2241e5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts @@ -29,6 +29,7 @@ export type QueuedRigProviderOptions = { startUnanswered?: true /** The model its child reports running; otherwise the agent's own `default`. */ reportsModel?: string + reportsEffort?: string } export function createQueuedRigProvider( @@ -60,18 +61,17 @@ export function createQueuedRigProvider( let events: StructuredAgentSessionEventSink | undefined let proveOpen: (() => void) | null = null + const reported = () => ({ + model: options.reportsModel ?? 'default', + ...(options.reportsEffort ? { effort: options.reportsEffort } : {}) + }) const scripted: StructuredAgentSessionAdapter = { open: () => options.starting ? new Promise((resolve) => { - proveOpen = () => - resolve({ - reportedOptions: { model: options.reportsModel ?? 'default' } - }) + proveOpen = () => resolve({ reportedOptions: reported() }) }) - : Promise.resolve({ - reportedOptions: { model: options.reportsModel ?? 'default' } - }), + : Promise.resolve({ reportedOptions: reported() }), acquire: async ({ identity, fence, spawnToken, events: sink }) => { starts() const hold = startHold diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ran-model.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-model.ts deleted file mode 100644 index 18c0b73414ce..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ran-model.ts +++ /dev/null @@ -1,70 +0,0 @@ -// A chat with no saved model keeps the first model one of its turns ran as its own: the message's -// model, else the one its agent reports running. A default the host filled in is never one. - -import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' -import type { - StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession -} from './structured-agent-session-host-types' -import { structuredAgentSessionOptionOverridesRefusal } from './structured-agent-session-option-intent' - -type RanModelContext = { - deps: Pick - sessions: ReadonlyMap - now: () => number -} - -/** In memory per child: the model it last reported running, and whether a turn it was handed - * still owes a record because nothing had reported one yet. */ -const childRuns = new WeakMap() - -function currentChild(context: RanModelContext, sessionId: string, fence: number) { - const child = context.sessions.get(sessionId)?.child - return child?.fence === fence ? child : null -} - -/** Notes what a child reports running; a turn handed over before any report records it now. */ -export async function noteStructuredAgentSessionReportedModel( - context: RanModelContext, - sessionId: string, - fence: number, - reported: AgentSessionOptionsResult['current'] -): Promise { - const child = currentChild(context, sessionId, fence) - const { model } = reported - if (!child || !model || model === 'default' || reported.defaulted?.includes('model')) { - return - } - const runs = childRuns.get(child) - childRuns.set(child, { reported: model }) - if (runs?.owed) { - await recordStructuredAgentSessionRanModel(context, sessionId, fence, undefined) - } -} - -/** After a turn was handed over; bookkeeping, so a failure is logged and never refuses the send. */ -export async function recordStructuredAgentSessionRanModel( - context: RanModelContext, - sessionId: string, - fence: number, - messageModel: string | undefined -): Promise { - const child = currentChild(context, sessionId, fence) - const { store, logger, agents } = context.deps - const record = store.getRecord(sessionId) - if (!child || !record || record.options?.model !== undefined) { - return - } - const reported = childRuns.get(child)?.reported - const model = messageModel ?? reported - childRuns.set(child, { ...(reported ? { reported } : {}), ...(model ? {} : { owed: true }) }) - // Never a model every later send would carry and the agent refuse as a choice. - if (!model || structuredAgentSessionOptionOverridesRefusal(agents, record.provider, { model })) { - return - } - await store - .recordRanModel({ sessionId, fence: child.fence, model, now: context.now() }) - .catch((error: unknown) => - logger.warn('recording the model a turn ran failed', { scope: 'ran-model', sessionId, error }) - ) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts new file mode 100644 index 000000000000..23a6429e70a0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts @@ -0,0 +1,124 @@ +// A chat with no saved model keeps the options its first turn ran as its own: the message's +// choices, else what its agent reports running. A default the host filled in is never one. + +import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import { structuredAgentSessionOptionOverridesRefusal } from './structured-agent-session-option-intent' + +type RanOptionsContext = { + deps: Pick + sessions: ReadonlyMap + now: () => number +} + +type RanOptions = Partial> + +/** In memory per child: what it last reported running, and whether a turn it was handed still + * owes a record because nothing had reported a model yet. */ +const childRuns = new WeakMap() + +function currentChild(context: RanOptionsContext, sessionId: string, fence: number) { + const child = context.sessions.get(sessionId)?.child + return child?.fence === fence ? child : null +} + +/** What a child last reported running, which a turn handed over with no choices of its own runs. */ +export function structuredAgentSessionChildRan(child: object): RanOptions | undefined { + return childRuns.get(child)?.reported +} + +function reportedRan(reported: AgentSessionOptionsResult['current']): RanOptions | null { + const { model, effort, serviceTier, fastMode, fastModeState } = reported + const filled = reported.defaulted ?? [] + if (!model || model === 'default' || filled.includes('model')) { + return null + } + return { + model, + ...(effort && !filled.includes('effort') ? { effort } : {}), + ...(serviceTier ? { serviceTier } : {}), + // Fast that is on but not routed fast never ran. + ...(fastMode === false || (fastMode && fastModeState === 'on') + ? { fastMode: String(fastMode) } + : {}) + } +} + +/** Notes what a child reports running; a turn handed over before any report records it now. */ +export async function noteStructuredAgentSessionReportedOptions( + context: RanOptionsContext, + sessionId: string, + fence: number, + reported: AgentSessionOptionsResult['current'] +): Promise { + const child = currentChild(context, sessionId, fence) + const ran = reportedRan(reported) + if (!child || !ran) { + return + } + const runs = childRuns.get(child) + childRuns.set(child, { reported: ran }) + if (runs?.owed) { + await recordStructuredAgentSessionRanOptions(context, sessionId, fence, undefined) + } +} + +/** Records, for a chat's child at `fence`, what a message handed over to it ran. */ +export type StructuredAgentSessionRanRecorder = ( + sessionId: string, + fence: number +) => (message: AgentSessionExecutionOptions | undefined) => Promise + +/** After a turn was handed over; bookkeeping, so a failure is logged and never refuses the send. */ +export async function recordStructuredAgentSessionRanOptions( + context: RanOptionsContext, + sessionId: string, + fence: number, + message: AgentSessionExecutionOptions | undefined +): Promise { + const child = currentChild(context, sessionId, fence) + const { store, logger, agents } = context.deps + const record = store.getRecord(sessionId) + if (!child || !record || record.options?.model !== undefined) { + return + } + const reported = childRuns.get(child)?.reported + const model = message?.model ?? reported?.model + childRuns.set(child, { ...(reported ? { reported } : {}), ...(model ? {} : { owed: true }) }) + if (!model) { + return + } + // The report's other values ran only with its own model. + const report = reported?.model === model ? reported : {} + const ran: Record = {} + for (const [key, value] of Object.entries({ + model, + effort: message?.effort ?? report.effort, + serviceTier: message?.serviceTier ?? report.serviceTier, + fastMode: message?.fastMode ?? report.fastMode + })) { + // Never an option every later send would carry and the agent refuse as a choice. + if ( + value && + !structuredAgentSessionOptionOverridesRefusal(agents, record.provider, { [key]: value }) + ) { + ran[key] = value + } + } + if (!ran.model) { + return + } + await store + .recordRanOptions({ sessionId, fence: child.fence, options: ran, now: context.now() }) + .catch((error: unknown) => + logger.warn('recording the options a turn ran failed', { + scope: 'ran-options', + sessionId, + error + }) + ) +} diff --git a/src/main/runtime/agent-session-record-options.ts b/src/main/runtime/agent-session-record-options.ts index aa9db92445d7..ed20ea1fcde6 100644 --- a/src/main/runtime/agent-session-record-options.ts +++ b/src/main/runtime/agent-session-record-options.ts @@ -19,15 +19,23 @@ export function replaceAgentSessionRecordOptions( : { ...record, permissionMode: replacement.permissionMode, updatedAt: replacement.now } } -/** A model a turn ran, recorded as the chat's own only while it has none: never over a pick, even - * one made since, and no other option moves. */ -export type AgentSessionRanModel = { sessionId: string; fence: number; model: string; now: number } +/** The options a turn ran, recorded only while the chat has no model: each one only where the chat + * has none, so a pick, even one made since, always wins. */ +export type AgentSessionRanOptions = { + sessionId: string + fence: number + options: Readonly> + now: number +} -export function recordAgentSessionRanModel( +export function recordAgentSessionRanOptions( record: AgentSessionRecord, - { model, ...at }: AgentSessionRanModel + { options, ...at }: AgentSessionRanOptions ): AgentSessionRecord { return record.options?.model !== undefined ? record - : replaceAgentSessionRecordOptions(record, { ...at, options: { ...record.options, model } }) + : replaceAgentSessionRecordOptions(record, { + ...at, + options: { ...options, ...record.options } + }) } diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 234e392aa414..3e575f212716 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -57,7 +57,7 @@ import { type AgentSessionRestartProbeArgs } from './agent-session-restart-reconciliation' import { - recordAgentSessionRanModel, + recordAgentSessionRanOptions, replaceAgentSessionRecordOptions } from './agent-session-record-options' import { @@ -323,8 +323,8 @@ export class AgentSessionRecordStore { replaceSessionOptions = (args: AgentSessionOptionsReplacement): Promise => this.mutate(args.sessionId, (record) => replaceAgentSessionRecordOptions(record, args)) - recordRanModel = (ran: Parameters[1]) => - this.mutate(ran.sessionId, (record) => recordAgentSessionRanModel(record, ran)) + recordRanOptions = (ran: Parameters[1]) => + this.mutate(ran.sessionId, (record) => recordAgentSessionRanOptions(record, ran)) async retireClaimKey(keyId: string, now: number): Promise { await this.transact((draft) => retireAgentSessionClaimKey(draft, keyId, now)) diff --git a/src/main/runtime/structured-agent-session-pi-startup.test.ts b/src/main/runtime/structured-agent-session-pi-startup.test.ts index 857d24df27d3..1ad95ebd1219 100644 --- a/src/main/runtime/structured-agent-session-pi-startup.test.ts +++ b/src/main/runtime/structured-agent-session-pi-startup.test.ts @@ -100,10 +100,11 @@ it('attaches Pi at spawn and holds the opening send until the provider answers i expect(script.prompts()).toEqual([]) script.releaseHandshake() await vi.waitFor(() => expect(script.prompts()).toEqual(['hello while Pi is starting'])) - // The chat keeps the model Pi ran its first turn on. + // The chat keeps the model and effort Pi ran its first turn on. await vi.waitFor(() => expect(peekOpenedAgentSessionRecordStore()?.getRecord(HOST_TEST_SESSION)?.options).toEqual({ - model: 'scripted/model-1' + model: 'scripted/model-1', + effort: 'medium' }) ) await stopStructuredAgentSessionRuntime() From ca683513869647403d7cc17295a0d7da9ca9fc2a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:46:24 -0700 Subject: [PATCH 21/34] fix(native-chat): key what a child ran by its provider child type --- .../structured-agent-session-message-options.ts | 3 ++- .../structured-agent-session-ran-options.ts | 12 +++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts index d08bfdb932fa..63dc47f5bea6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts @@ -4,6 +4,7 @@ import type { AgentSessionExecutionOptions } from '../../../shared/agent-session import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { structuredAgentSessionChildRan } from './structured-agent-session-ran-options' +import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' const OPTION_ORDER = ['model', 'effort'] as const @@ -24,7 +25,7 @@ export function waitsForCodexTurnOptions( record: Pick | null, journal: Pick, next: AgentJournalSubmission, - child: object + child: StructuredAgentSessionProviderChild ): boolean { if (record?.provider !== 'codex') { return false diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts index 23a6429e70a0..c77dc62379aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts @@ -5,7 +5,8 @@ import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wi import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import type { StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import { structuredAgentSessionOptionOverridesRefusal } from './structured-agent-session-option-intent' @@ -19,7 +20,10 @@ type RanOptions = Partial() +const childRuns = new WeakMap< + StructuredAgentSessionProviderChild, + { reported?: RanOptions; owed?: true } +>() function currentChild(context: RanOptionsContext, sessionId: string, fence: number) { const child = context.sessions.get(sessionId)?.child @@ -27,7 +31,9 @@ function currentChild(context: RanOptionsContext, sessionId: string, fence: numb } /** What a child last reported running, which a turn handed over with no choices of its own runs. */ -export function structuredAgentSessionChildRan(child: object): RanOptions | undefined { +export function structuredAgentSessionChildRan( + child: StructuredAgentSessionProviderChild +): RanOptions | undefined { return childRuns.get(child)?.reported } From 73a97c6f4b9daababbd4302a5254bf3bada81a00 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:29:58 -0700 Subject: [PATCH 22/34] fix(native-chat): record each option a turn ran where none is saved, and drop what a model pick can't run A Codex message that only switched the tier set the thread's effort as a lasting override, so a later switch to a model with no listed efforts was refused; the effort is again written only where the model brought one or an earlier override needs replacing. Recording what a turn ran now works per option: a chat that already saved its model still records the effort it ran, and so on for each option it has none saved for. Tier and Fast come only from the agent's own report, since a message steered into a running turn runs that turn's, and Fast is kept only when the agent reports it actually routed on: an off state may only mean Fast is unavailable. A model pick drops a saved effort, or Fast, that the newly picked model's listed row does not offer, in the one host step both resting and live picks go through, so no later send carries a choice the agent would refuse. The saved tier is kept, as before: it runs as the model's effective tier. --- .../codex/codex-structured-message-options.ts | 12 +++++-- .../structured-agent-session-option-intent.ts | 13 ++++--- ...uctured-agent-session-options-read.test.ts | 15 ++++++++ .../structured-agent-session-pick-reply.ts | 36 +++++++++++++++---- .../structured-agent-session-ran-options.ts | 22 ++++++------ .../runtime/agent-session-record-options.ts | 6 ++-- 6 files changed, 76 insertions(+), 28 deletions(-) diff --git a/src/main/codex/codex-structured-message-options.ts b/src/main/codex/codex-structured-message-options.ts index bd92483255d1..52a93297fcbe 100644 --- a/src/main/codex/codex-structured-message-options.ts +++ b/src/main/codex/codex-structured-message-options.ts @@ -83,11 +83,17 @@ export async function resolvedCodexMessageOptions( (choices.serviceTier && choices.serviceTier !== CODEX_DEFAULT_SERVICE_TIER)) ) { await applyCodexStructuredSessionOption(selected, 'model', model) - if (choices.effort === undefined && defaultEffort) { - await applyCodexStructuredSessionOption(selected, 'effort', defaultEffort) - } else if (choices.effort === undefined) { + if (!defaultEffort && choices.effort === undefined) { selected.options.delete('effort') } + // Where the model brought an effort, it is the thread's own for the thread's model. + if ( + choices.effort === undefined && + defaultEffort && + (priorEffort || choices.model || selected.options.has('effort')) + ) { + await applyCodexStructuredSessionOption(selected, 'effort', defaultEffort) + } } for (const key of ['effort', 'personality', 'fastMode', 'serviceTier'] as const) { const value = choices[key] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts index 71074a48489d..c7f25560de4b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts @@ -8,7 +8,7 @@ import { import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentRegistry } from './structured-agent-registry' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' -import { restingPickReplyOptions } from './structured-agent-session-pick-reply' +import { structuredAgentSessionPickOptions } from './structured-agent-session-pick-reply' /** Whether the agent takes this pick: a permission mode it offers, or an option key it names. */ function acceptsOptionPick( @@ -47,7 +47,7 @@ export async function recordStructuredAgentSessionOptionIntent( deps: { store: Pick agents: Pick - } & Parameters[0], + } & Parameters[0], ctx: Pick, input: { key: string; value: string } ): Promise> { @@ -62,7 +62,13 @@ export async function recordStructuredAgentSessionOptionIntent( ) } } - const options = { ...record.options, [input.key]: input.value } + const { options, replied } = await structuredAgentSessionPickOptions( + deps, + record, + ctx.sessionId, + input, + () => deps.store.getRecord(ctx.sessionId)?.options + ) // The answer still names the mode among the options, as the wire always has. await (input.key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID ? deps.store.replaceSessionOptions({ @@ -73,6 +79,5 @@ export async function recordStructuredAgentSessionOptionIntent( }) : ctx.persistOptions(options)) ctx.publish() - const replied = await restingPickReplyOptions(deps, record, ctx.sessionId, options) return { ok: true, value: { ...input, options: replied } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index d3c7e67f9b95..6141d9682b9c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -290,6 +290,21 @@ describe('a chat at rest, read by the host', () => { expect(record.options.serviceTier).toBe('flex') } ) + + it('drops a saved effort the newly picked model does not offer', async () => { + const record = { ...restingRecord(), options: { model: 'gpt-flex', effort: 'xhigh' } } + const persistOptions = vi.fn() + await recordStructuredAgentSessionOptionIntent( + { + store: { getRecord: () => record, replaceSessionOptions: vi.fn() }, + agents: claudeAndCodexAgents(), + modelCatalog: catalog() + }, + { sessionId: SESSION, fence: 1, persistOptions, publish: vi.fn(), now: () => 0 }, + { key: 'model', value: 'gpt-account' } + ) + expect(persistOptions).toHaveBeenCalledWith({ model: 'gpt-account' }) + }) }) describe('live Codex option reads', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts index 03f77afe8398..89cb94de02bc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts @@ -1,23 +1,45 @@ // A pick made at rest is answered with what the next start runs; the record keeps the pick itself. import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' import { savedEffectiveServiceTier } from '../../../shared/structured-agent-session-effective-tier' import type { AgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' -/** The saved options with the tier the chat's model runs, as the at-rest answer names it. */ -export async function restingPickReplyOptions( +/** Whether a saved effort or Fast still runs under a newly picked model's listed row. The saved + * tier is kept: it runs as the row's effective tier. */ +function offeredUnder(row: AgentSessionModelOption, key: string, value: string): boolean { + if (key === 'effort') { + return row.efforts.some((entry) => entry.value === value) + } + return key !== 'fastMode' || value !== 'true' || row.supportsFastMode !== false +} + +/** The options a pick saves and the answer naming them, with the tier the chat's model runs. A + * model pick drops a saved effort or Fast its listed row doesn't offer, which every later send + * would carry and the agent refuse; an unlisted model keeps them. */ +export async function structuredAgentSessionPickOptions( deps: { modelCatalog?: Pick }, record: Pick, sessionId: string, - options: Record -): Promise> { - if (!options.serviceTier) { - return options + pick: { key: string; value: string }, + saved: () => Readonly> | undefined +): Promise<{ options: Record; replied: Record }> { + if (pick.key !== 'model' && !saved()?.serviceTier) { + const options = { ...saved(), [pick.key]: pick.value } + return { options, replied: options } } const listing = await deps.modelCatalog ?.read({ agent: record.provider, sessionId, savedOnly: true }) .catch(() => null) + // Read after the wait, so a value saved meanwhile is kept. + const picked = { ...saved(), [pick.key]: pick.value } const models = listing && listing.origin !== 'unknown' ? listing.models : [] + const row = pick.key === 'model' ? models.find((entry) => entry.id === pick.value) : undefined + const options = row + ? Object.fromEntries( + Object.entries(picked).filter(([key, value]) => offeredUnder(row, key, value)) + ) + : picked const serviceTier = savedEffectiveServiceTier(options, models) ?? options.serviceTier - return { ...options, serviceTier } + return { options, replied: serviceTier ? { ...options, serviceTier } : options } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts index c77dc62379aa..b490d10b8a66 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts @@ -1,5 +1,5 @@ -// A chat with no saved model keeps the options its first turn ran as its own: the message's -// choices, else what its agent reports running. A default the host filled in is never one. +// A chat keeps, for each of its options it has none saved for, what its turn ran: the message's +// choice, else what its agent reports running. A default the host filled in is never one. import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' @@ -47,10 +47,8 @@ function reportedRan(reported: AgentSessionOptionsResult['current']): RanOptions model, ...(effort && !filled.includes('effort') ? { effort } : {}), ...(serviceTier ? { serviceTier } : {}), - // Fast that is on but not routed fast never ran. - ...(fastMode === false || (fastMode && fastModeState === 'on') - ? { fastMode: String(fastMode) } - : {}) + // Only Fast routed on ran; an off state may just be Fast unavailable to this account. + ...(fastMode && fastModeState === 'on' ? { fastMode: 'true' } : {}) } } @@ -89,7 +87,7 @@ export async function recordStructuredAgentSessionRanOptions( const child = currentChild(context, sessionId, fence) const { store, logger, agents } = context.deps const record = store.getRecord(sessionId) - if (!child || !record || record.options?.model !== undefined) { + if (!child || !record) { return } const reported = childRuns.get(child)?.reported @@ -104,18 +102,20 @@ export async function recordStructuredAgentSessionRanOptions( for (const [key, value] of Object.entries({ model, effort: message?.effort ?? report.effort, - serviceTier: message?.serviceTier ?? report.serviceTier, - fastMode: message?.fastMode ?? report.fastMode + // A message's own tier or Fast may not have run: a steer runs its turn's. + serviceTier: report.serviceTier, + fastMode: report.fastMode })) { - // Never an option every later send would carry and the agent refuse as a choice. + // Never over a saved choice, nor one every later send would carry and the agent refuse. if ( value && + record.options?.[key] === undefined && !structuredAgentSessionOptionOverridesRefusal(agents, record.provider, { [key]: value }) ) { ran[key] = value } } - if (!ran.model) { + if (Object.keys(ran).length === 0) { return } await store diff --git a/src/main/runtime/agent-session-record-options.ts b/src/main/runtime/agent-session-record-options.ts index ed20ea1fcde6..483fab878794 100644 --- a/src/main/runtime/agent-session-record-options.ts +++ b/src/main/runtime/agent-session-record-options.ts @@ -19,8 +19,8 @@ export function replaceAgentSessionRecordOptions( : { ...record, permissionMode: replacement.permissionMode, updatedAt: replacement.now } } -/** The options a turn ran, recorded only while the chat has no model: each one only where the chat - * has none, so a pick, even one made since, always wins. */ +/** The options a turn ran, each recorded only where the chat has none saved, so a pick, even one + * made since, always wins. */ export type AgentSessionRanOptions = { sessionId: string fence: number @@ -32,7 +32,7 @@ export function recordAgentSessionRanOptions( record: AgentSessionRecord, { options, ...at }: AgentSessionRanOptions ): AgentSessionRecord { - return record.options?.model !== undefined + return Object.keys(options).every((key) => record.options?.[key] !== undefined) ? record : replaceAgentSessionRecordOptions(record, { ...at, From 5099c4e3b7218b3dd474eb97e4a77d44983f959d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:34:28 -0700 Subject: [PATCH 23/34] fix(native-chat): save only options a turn really ran, and only ones the chat's model runs A chat with a saved model recorded the effort its agent's settings named, not the one the turn ran: with "effortLevel": "high" in Claude's settings and Haiku saved (no effort control), the chat saved high and every later send was refused. A Codex thread effort its saved model doesn't list did the same. What a turn ran now comes from the agent's own dispatch: Claude reports the choices it applied and the effort the CLI resolved for the model it applies, never the settings level alone; Codex reports its final request over the thread's own model and effort, and a steer reports its running turn. This evidence lives only in memory on the dispatch result; agents without it keep using their own reports. One admission rule now decides every saved option, inside the ordered record write: under a model the saved catalog lists, an effort must be one it offers and Fast must be one it replays (Claude needs positive support). A late record of a turn that ran on another model than the one the chat now has saves none of that turn's options. A model pick drops inherited options its new model doesn't run, an explicit pick or a new chat's option the model doesn't offer is refused with the existing option refusal, and unknown catalogs or unlisted models pass as before. The saved tier keeps its effective-tier rule, and nothing already saved is cleaned up later. --- .../claude-adapter-capture-replay.test.ts | 2 +- .../claude/claude-command-lifecycle.test.ts | 2 +- .../claude-structured-message-options.test.ts | 40 +++++++- .../claude-structured-message-options.ts | 28 +++++- .../claude-structured-real-cli-fold.test.ts | 4 +- ...aude-structured-real-cli-stop-task.test.ts | 2 +- .../claude/claude-structured-real-cli.test.ts | 6 +- .../claude-structured-session-startup.test.ts | 2 +- .../codex-structured-message-options.test.ts | 30 +++++- .../codex-structured-session-adapter.test.ts | 6 +- src/main/codex/codex-structured-turn-start.ts | 18 +++- .../structured-agent-session-adapter.ts | 11 ++- .../structured-agent-session-attach.ts | 3 +- .../structured-agent-session-create.ts | 2 + .../structured-agent-session-handover.ts | 10 +- .../structured-agent-session-host-delivery.ts | 4 +- .../structured-agent-session-option-intent.ts | 71 +++++++++----- ...uctured-agent-session-options-read.test.ts | 78 ++++++++++----- .../structured-agent-session-pick-reply.ts | 56 ++++------- .../structured-agent-session-ran-options.ts | 60 ++++++++---- .../runtime/agent-session-record-options.ts | 95 ++++++++++++++++++- .../runtime/agent-session-record-store.ts | 13 +-- .../agent-session-reservation-admission.ts | 4 + 23 files changed, 402 insertions(+), 145 deletions(-) diff --git a/src/main/claude/claude-adapter-capture-replay.test.ts b/src/main/claude/claude-adapter-capture-replay.test.ts index 14ce07fe9682..d6c83ee4bb5a 100644 --- a/src/main/claude/claude-adapter-capture-replay.test.ts +++ b/src/main/claude/claude-adapter-capture-replay.test.ts @@ -171,7 +171,7 @@ async function replayCapture(name: string): Promise { requestedAt: nowMs, fence: 7 }) - expect(outcome).toEqual({ state: 'admitted' }) + expect(outcome).toMatchObject({ state: 'admitted' }) const sent = claude.connections[0]!.sent.at(-1)?.uuid if (typeof sent !== 'string' || before.has(sent)) { throw new Error('replay could not read the dispatched uuid') diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index 88aa6d4a12fb..a58d0c840fe5 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -205,7 +205,7 @@ async function replayCapture( body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: event.text }] }, fence: 7 }) - ).resolves.toEqual({ state: 'admitted' }) + ).resolves.toMatchObject({ state: 'admitted' }) uuidMap.set(event.sentUuid, String(connection.sent.at(-1)!.uuid)) } else if (event.kind === 'control') { const answerAt = capture.findIndex( diff --git a/src/main/claude/claude-structured-message-options.test.ts b/src/main/claude/claude-structured-message-options.test.ts index 9a043e66da75..8b790ff250d5 100644 --- a/src/main/claude/claude-structured-message-options.test.ts +++ b/src/main/claude/claude-structured-message-options.test.ts @@ -26,6 +26,40 @@ const MODELS = [ ] describe('Claude accepted message choices', () => { + it.each([ + ['the effort the CLI applied over the settings level', 'low', { effort: 'low' }], + ['no effort where the CLI applies none, whatever the settings say', null, undefined] + ])('reports %s', async (_name, applied, ranOptions) => { + let effortLevel = 'high' + const claude = fakeClaude({ + routes: { + list_models: () => MODELS, + apply_flag_settings: ({ settings } = {}) => { + effortLevel = JSON.stringify(settings).includes('low') ? 'low' : effortLevel + } + }, + settings: { + applied: { model: 'claude-sonnet-5', effort: applied }, + get effective() { + return { effortLevel } + } + } + }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(claude, {}, events) + await vi.waitFor(() => + expect(events).toContainEqual(expect.objectContaining({ type: 'options-reported' })) + ) + const outcome = await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'm', + body: USER_MESSAGE, + executionOptions: {}, + fence: 7 + }) + expect(outcome).toEqual({ state: 'admitted', ...(ranOptions ? { ranOptions } : {}) }) + }) + it('applies a held message A before its prompt, then message B on the existing child', async () => { const order: string[] = [] let currentEffort = 'high' @@ -94,7 +128,7 @@ describe('Claude accepted message choices', () => { executionOptions: { model: 'opus', effort: 'high' }, fence: 7 }) - ).resolves.toEqual({ state: 'admitted' }) + ).resolves.toMatchObject({ state: 'admitted' }) expect(order).toEqual(['model:opus', 'effort:{"effortLevel":"high"}', 'prompt']) }) @@ -168,7 +202,7 @@ describe('Claude accepted message choices', () => { executionOptions: options, fence: 7 }) - ).resolves.toEqual({ state: 'admitted' }) + ).resolves.toMatchObject({ state: 'admitted' }) } expect(captured.options.fastMode).toBe('true') expect(connection.calls.filter((call) => call.subtype === 'apply_flag_settings')).toEqual( @@ -221,7 +255,7 @@ describe('Claude accepted message choices', () => { executionOptions: {}, fence: 7 }) - ).resolves.toEqual({ state: 'admitted' }) + ).resolves.toMatchObject({ state: 'admitted' }) expect( connection.calls.filter((call) => ['set_model', 'apply_flag_settings'].includes(call.subtype)) ).toEqual([ diff --git a/src/main/claude/claude-structured-message-options.ts b/src/main/claude/claude-structured-message-options.ts index 3cef26e2dd16..38265e8b4df3 100644 --- a/src/main/claude/claude-structured-message-options.ts +++ b/src/main/claude/claude-structured-message-options.ts @@ -74,13 +74,32 @@ async function defaultModelForMessage( return fallback } -export function dispatchClaudeMessage( +/** What the turn runs on, from the CLI's own answers: the message's choices it applied, and the + * effort the CLI resolved for the model it applies (never the settings level alone). */ +function claudeRanOptions( + session: ClaudeSession, + options: AgentSessionExecutionOptions +): Record { + const resolvedHere = options.model === undefined || options.model === session.launchedModel + const ran = { + model: options.model, + effort: options.effort ?? (resolvedHere ? session.configuredDefaults?.effort : undefined), + fastMode: options.fastMode + } + return Object.fromEntries( + Object.entries(ran).flatMap(([key, value]) => + value !== undefined && session.reusableOptions.get(key) === value ? [[key, value]] : [] + ) + ) +} + +export async function dispatchClaudeMessage( session: ClaudeSession, input: Parameters[0], timeoutMs: number | undefined ): ReturnType { const messageOptions = input.executionOptions - return dispatchClaudeTurn( + const outcome = await dispatchClaudeTurn( session, input, messageOptions === undefined @@ -97,4 +116,9 @@ export function dispatchClaudeMessage( } } ) + const ranOptions = claudeRanOptions(session, messageOptions ?? {}) + return (outcome.state === 'accepted' || outcome.state === 'admitted') && + Object.keys(ranOptions).length > 0 + ? { ...outcome, ranOptions } + : outcome } diff --git a/src/main/claude/claude-structured-real-cli-fold.test.ts b/src/main/claude/claude-structured-real-cli-fold.test.ts index 8abcc0de2c9b..52c92ba3e283 100644 --- a/src/main/claude/claude-structured-real-cli-fold.test.ts +++ b/src/main/claude/claude-structured-real-cli-fold.test.ts @@ -137,7 +137,7 @@ describe.skipIf(!realClaudeAvailable)('Claude structured real CLI fold', () => { requestedAt: Date.now(), fence: 1 }) - ).resolves.toEqual({ state: 'admitted' }) + ).resolves.toMatchObject({ state: 'admitted' }) // Wait for A's replay to open the turn, then send B mid-turn: the first // `sleep 8` guarantees the CLI is still inside A's request cycle. @@ -160,7 +160,7 @@ describe.skipIf(!realClaudeAvailable)('Claude structured real CLI fold', () => { requestedAt: Date.now(), fence: 1 }) - ).resolves.toEqual({ state: 'admitted' }) + ).resolves.toMatchObject({ state: 'admitted' }) const results = () => events.flatMap((event) => diff --git a/src/main/claude/claude-structured-real-cli-stop-task.test.ts b/src/main/claude/claude-structured-real-cli-stop-task.test.ts index 719e41c50279..d56284e9d423 100644 --- a/src/main/claude/claude-structured-real-cli-stop-task.test.ts +++ b/src/main/claude/claude-structured-real-cli-stop-task.test.ts @@ -117,7 +117,7 @@ describe.skipIf(!realClaudeAvailable)('Claude structured real CLI targeted stop' requestedAt: Date.now(), fence: 1 }) - ).resolves.toEqual({ state: 'admitted' }) + ).resolves.toMatchObject({ state: 'admitted' }) const started = () => events.flatMap((event) => diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index 66b996819014..a21bab4b1673 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -458,7 +458,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { fence: 1 }) // Read as the write resolves: the CLI had not answered initialize, so nothing held it. - expect(dispatched).toEqual({ state: 'admitted' }) + expect(dispatched).toMatchObject({ state: 'admitted' }) const deadline = Date.now() + 90_000 while (!messages().some((m) => m.type === 'result') && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 250)) @@ -527,7 +527,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { const first = events.length await expect( send('real-cli-stop-1', 'Count from 1 to 300, one number per line, and nothing else.') - ).resolves.toEqual({ state: 'admitted' }) + ).resolves.toMatchObject({ state: 'admitted' }) const startedBeforeStop = replyStarted(first) await expect( @@ -672,7 +672,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { }, fence: 1 }) - ).resolves.toEqual({ state: 'admitted' }) + ).resolves.toMatchObject({ state: 'admitted' }) await vi.waitFor( () => expect(frames()).toContainEqual(expect.objectContaining({ type: 'result' })), { timeout: 120_000, interval: 200 } diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index 4dbb69d407f2..781c806a2f4b 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -94,7 +94,7 @@ describe('Claude host-awaited open', () => { executionOptions: { fastMode: 'true' }, fence: 7 }) - ).resolves.toEqual({ state: 'admitted' }) + ).resolves.toMatchObject({ state: 'admitted' }) expect(connection.calls.filter((call) => call.subtype === 'apply_flag_settings')).toEqual([ { subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'high' } } }, { subtype: 'apply_flag_settings', params: { settings: { fastMode: true } } } diff --git a/src/main/codex/codex-structured-message-options.test.ts b/src/main/codex/codex-structured-message-options.test.ts index bd2c177c5f35..0cfc3e39bca1 100644 --- a/src/main/codex/codex-structured-message-options.test.ts +++ b/src/main/codex/codex-structured-message-options.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { optionSession } from './codex-structured-option-session.test-fixture' import { resolvedCodexMessageOptions } from './codex-structured-message-options' -import { startCodexTurn } from './codex-structured-turn-start' +import { dispatchCodexTurn, startCodexTurn } from './codex-structured-turn-start' import { CodexAppServerRequestError } from './codex-app-server-connection' import type { CodexSession } from './codex-structured-session-state' import { @@ -166,6 +166,34 @@ describe('Codex captured message choices', () => { }) }) + it('reports what a turn ran: a listed effort for an unlisted thread one, a steer its turn', async () => { + const request = vi.fn(async (method: string, _params?: Record) => + method === 'turn/steer' ? { turnId: 'turn-1' } : { turn: { id: 'turn-1' } } + ) + const session = optionSession(request) + session.reportedOptions = { model: 'model-a', effort: 'xhigh' } + seedMessageDefaults(session) + const send = async (id: string, choices: Record) => + dispatchCodexTurn( + session, + { + sessionId: 'session-1', + clientMessageId: id, + body: USER_MESSAGE, + turnOptions: await resolvedCodexMessageOptions(session, choices) + }, + undefined + ) + const ran = { model: 'model-a', effort: 'low' } + expect(await send('first', { model: 'model-a' })).toMatchObject({ ranOptions: ran }) + expect(request.mock.calls[0]?.[1]).toMatchObject(ran) + session.activeTurnIds = new Set(['turn-1']) + const steered = await send('steer', { model: 'model-a', serviceTier: 'priority' }) + expect(request.mock.calls.at(-1)?.[0]).toBe('turn/steer') + expect(steered).toMatchObject({ ranOptions: ran }) + expect(steered).not.toHaveProperty('ranOptions.serviceTier') + }) + it('inherits untouched native tier and personality without a configuration probe', async () => { const request = vi.fn(async (_method: string) => ({ turn: { id: 'native' } })) const session = optionSession(request) diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 7ec13c1aa181..3672fa988c4d 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -454,7 +454,7 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { // Identity is not knowable here: a send coalesced into a running turn shares // that turn's id, so the echo settles which message landed where. - expect(outcome).toEqual({ state: 'admitted' }) + expect(outcome).toMatchObject({ state: 'admitted' }) expect(codex.connections[0].calls.find((call) => call.method === 'turn/start')?.params).toEqual( { threadId: THREAD_ID, @@ -487,7 +487,7 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { fence: 7 }) - expect(outcome).toEqual({ state: 'admitted' }) + expect(outcome).toMatchObject({ state: 'admitted' }) expect(events.at(-1)).toMatchObject({ type: 'notification', method: 'turn/started' }) }) @@ -511,7 +511,7 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { fence: 7 }) - expect(outcome).toEqual({ state: 'admitted' }) + expect(outcome).toMatchObject({ state: 'admitted' }) // Each event carries the thread it actually came from, so the journal can // keep a subagent's turn out of the root conversation. expect(events.map((event) => (event.type === 'notification' ? event.threadId : null))).toEqual([ diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index ae544870a250..6ec7ae0a55c9 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -277,5 +277,21 @@ export async function dispatchCodexTurn( ...codexAnsweredTurn(session, input.sessionId, answer.turnId, answer.via), ...rejection } - : { state: 'admitted' } + : { state: 'admitted', ...codexRanOptions(session) } +} + +/** What a turn on this child runs, a steer its running turn's: the request's model and effort over + * the thread's own, and the tier it runs as. */ +function codexRanOptions(session: CodexTurnHost): { ranOptions?: Record } { + const requested = codexTurnRequestOptions(session) + const thread = session.reportedOptions ?? {} + const model = requested.model ?? thread.model + const effort = requested.effort ?? (model === thread.model ? thread.effort : undefined) + const { serviceTier } = requested + const ranOptions = { + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + ...(serviceTier ? { serviceTier } : {}) + } + return Object.keys(ranOptions).length > 0 ? { ranOptions } : {} } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index baabf4eb1f10..f2ef4f0d9478 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -170,16 +170,23 @@ export type AgentSessionCommandAdmission = | AgentSessionDispatchOutcome | { state: 'accepted'; providerIdentity: null } +/** In memory only: the model and options the provider took the message under, from its own + * execution (a steer reports its running turn's), never a setting or a picker default. */ +export type AgentSessionRanOptionsEvidence = { ranOptions?: Readonly> } + export type AgentSessionDispatchOutcome = /** The provider owns the turn now, under this identity. */ - | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } + | ({ + state: 'accepted' + providerIdentity: AgentJournalItemIdentity + } & AgentSessionRanOptionsEvidence) /** * The provider transport took the message; identity settles later, out of band. * The submission stays `pending`: a message queued behind a running turn is * acknowledged only when that turn starts, so elapsed time is not evidence of * anything and never promotes this to `unknown`. */ - | { state: 'admitted' } + | ({ state: 'admitted' } & AgentSessionRanOptionsEvidence) /** Words from `agentSessionFailureWords`, never written by hand. `answeredInTurn`: the turn the * provider answered the send into, which ended before the answer was read. */ | ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 756d0fc95581..580542fc6f48 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -304,6 +304,7 @@ export function reserveRequestFor(input: { callerKey: string fingerprint: string now: number + models?: Parameters[0]['models'] }): Parameters[0] { const { params, authority } = input return { @@ -311,7 +312,7 @@ export function reserveRequestFor(input: { location: params.location, provider: params.provider, accountHome: params.accountHome, - ...(params.options ? { options: params.options } : {}), + ...(params.options ? { options: params.options, models: input.models } : {}), ...(params.envelope.expectedRuntimeFence === null && params.surfaceTabId ? { surfaceTabId: params.surfaceTabId } : {}), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts index 039a9ed3c58e..42b00ffec15f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts @@ -31,6 +31,7 @@ import { attachStructuredAgentSession } from './structured-agent-session-attach- import { readAgentSessionHydrationPage } from './agent-session-history-page' import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' +import { savedAgentSessionCatalogModels as savedModels } from './structured-agent-session-pick-reply' export type StructuredAgentSessionCreateOptions = { firstMessage?: { clientMessageId: string; body: AgentJournalMessageItem } @@ -107,6 +108,7 @@ async function foundConversation( const request = reserveRequestFor({ sessionId, params, + models: prior ? undefined : await savedModels(deps, params.provider, sessionId), callerKey, fingerprint: params.envelope.payloadFingerprint, now: context.now(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts index 1f2dc8838833..bcb9f82e8982 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts @@ -6,6 +6,7 @@ import { providerDiagnosticOf } from '../../../shared/agent-session-failure' import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' +import type { StructuredAgentSessionHandedOptions } from './structured-agent-session-ran-options' import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' import { abortSignalReason } from '../../../shared/abort-signal-reason' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' @@ -35,8 +36,8 @@ export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverC acquireAborts?: Pick deliveryOrder?: StructuredAgentSessionDeliveryOrder workChanged?: () => void - /** A message the agent took: a chat with no saved model keeps the options it ran. */ - recordRanOptions?: (message: AgentSessionExecutionOptions | undefined) => Promise + /** A message the agent took: a chat keeps, where it has none saved, the options it ran. */ + recordRanOptions?: (handed: StructuredAgentSessionHandedOptions) => Promise } /** A handover whose pending row and abort handle are committed: the provider call runs outside the @@ -135,7 +136,10 @@ export async function beginSubmissionHandover( return async () => { await resolveHandover(ctx, clientMessageId, outcome) if (outcome?.state === 'accepted' || outcome?.state === 'admitted') { - await ctx.recordRanOptions?.(submission.options) + await ctx.recordRanOptions?.({ + message: submission.options, + executed: outcome.ranOptions + }) } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 9e0f2887b85b..ab580d7f61bf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -142,12 +142,12 @@ export function createStructuredAgentSessionConversationDelivery(input: { stopping: input.clientDelivery.readStopping, now: () => deps.now?.() ?? Date.now(), acquireAborts: input.acquireAborts, - recordRanOptions: (sessionId, fence) => (message) => + recordRanOptions: (sessionId, fence) => (handed) => recordStructuredAgentSessionRanOptions( { deps, sessions, now: () => deps.now?.() ?? Date.now() }, sessionId, fence, - message + handed ) }) const adoptOpened = async ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts index c7f25560de4b..1915c4d06398 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts @@ -8,7 +8,14 @@ import { import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentRegistry } from './structured-agent-registry' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' -import { structuredAgentSessionPickOptions } from './structured-agent-session-pick-reply' +import { + savedAgentSessionCatalogModels, + structuredAgentSessionPickReply +} from './structured-agent-session-pick-reply' +import { + agentSessionOptionAdmitted, + pickAgentSessionOption +} from '../../runtime/agent-session-record-options' /** Whether the agent takes this pick: a permission mode it offers, or an option key it names. */ function acceptsOptionPick( @@ -42,42 +49,56 @@ export function structuredAgentSessionOptionOverridesRefusal( ) } -/** Records a pick for the next start. Only a key the provider would accept is kept. */ +/** Records a pick for the next start. Only a key the provider would accept is kept, and only a + * value the chat's model runs; a model pick drops an inherited effort or Fast it doesn't. */ export async function recordStructuredAgentSessionOptionIntent( deps: { - store: Pick + store: Pick< + AgentSessionRecordStore, + 'getRecord' | 'replaceSessionOptions' | 'reviseSessionOptions' + > agents: Pick - } & Parameters[0], - ctx: Pick, + } & Parameters[0], + ctx: Pick, input: { key: string; value: string } ): Promise> { - const record = deps.store.getRecord(ctx.sessionId) - if (!record || !acceptsOptionPick(deps.agents, record.provider, input.key, input.value)) { + const { sessionId, fence } = ctx + const record = deps.store.getRecord(sessionId) + const named = + record !== null && acceptsOptionPick(deps.agents, record.provider, input.key, input.value) + const models = record + ? await savedAgentSessionCatalogModels(deps, record.provider, sessionId) + : [] + const model = deps.store.getRecord(sessionId)?.options?.model + if ( + !record || + !named || + !agentSessionOptionAdmitted(record.provider, models, model, input.key, input.value) + ) { return { ok: false, refusal: refuse( 'agent_session_operation_invalid', { reason: 'optionRejected' }, - `${record?.provider ?? 'This session'} has no session option named ${input.key}` + named + ? `${record?.provider} model ${model} does not offer ${input.key} ${input.value}` + : `${record?.provider ?? 'This session'} has no session option named ${input.key}` ) } } - const { options, replied } = await structuredAgentSessionPickOptions( - deps, - record, - ctx.sessionId, - input, - () => deps.store.getRecord(ctx.sessionId)?.options - ) - // The answer still names the mode among the options, as the wire always has. - await (input.key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID - ? deps.store.replaceSessionOptions({ - sessionId: ctx.sessionId, - fence: ctx.fence, - permissionMode: input.value, - now: ctx.now() - }) - : ctx.persistOptions(options)) + let options: Readonly> + if (input.key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID) { + const permissionMode = input.value + await deps.store.replaceSessionOptions({ sessionId, fence, permissionMode, now: ctx.now() }) + // The answer still names the mode among the options, as the wire always has. + options = { ...record.options, [input.key]: input.value } + } else { + const picked = { sessionId, fence, ...input, models, now: ctx.now() } + options = (await deps.store.reviseSessionOptions(pickAgentSessionOption, picked)).options ?? {} + } ctx.publish() - return { ok: true, value: { ...input, options: replied } } + return { + ok: true, + value: { ...input, options: structuredAgentSessionPickReply(options, models) } + } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index 6141d9682b9c..b48d2f13cc44 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -21,6 +21,7 @@ import { GROK_CONFIG_OPTIONS } from '../../acp/acp-structured-adapter.test-suppo import { SessionConfigOptionSchema } from '../../acp/generated/acp-protocol.generated' import { withLiveCatalogListing } from '../agent-model-catalog/agent-model-catalog-entry' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' +import { recordAgentSessionRanOptions } from '../../runtime/agent-session-record-options' import { claudeAndCodexAgents, NO_STRUCTURED_AGENTS @@ -52,6 +53,7 @@ function restingRecord(): AgentSessionRecord { accountHome: { variable: 'CODEX_HOME', path: '/homes/a' }, providerHandleChain: [], location: { executionHostId: 'local', wslDistro: null }, + lease: { claimStatus: 'released', ownerProcess: null, runtimeFence: 1 }, options: {} } as unknown as AgentSessionRecord } @@ -276,34 +278,60 @@ describe('a chat at rest, read by the host', () => { const options = { model: 'gpt-account', serviceTier: 'flex' } const answer = await readAtRest({ options }, [], false, models) expect(answer.current).toMatchObject({ serviceTier: runs }) - const record = { ...restingRecord(), options } - const picked = await recordStructuredAgentSessionOptionIntent( - { - store: { getRecord: () => record, replaceSessionOptions: vi.fn() }, - agents: claudeAndCodexAgents(), - modelCatalog: catalog(models) - }, - { sessionId: SESSION, fence: 1, persistOptions: vi.fn(), publish: vi.fn(), now: () => 0 }, - { key: 'model', value: 'gpt-account' } - ) - expect(picked).toMatchObject({ value: { options: { serviceTier: runs } } }) - expect(record.options.serviceTier).toBe('flex') + const chat = picking(options, models) + expect(await chat.pick('model', 'gpt-account')).toMatchObject({ + value: { options: { serviceTier: runs } } + }) + expect(chat.record().options?.serviceTier).toBe('flex') } ) - it('drops a saved effort the newly picked model does not offer', async () => { - const record = { ...restingRecord(), options: { model: 'gpt-flex', effort: 'xhigh' } } - const persistOptions = vi.fn() - await recordStructuredAgentSessionOptionIntent( - { - store: { getRecord: () => record, replaceSessionOptions: vi.fn() }, - agents: claudeAndCodexAgents(), - modelCatalog: catalog() - }, - { sessionId: SESSION, fence: 1, persistOptions, publish: vi.fn(), now: () => 0 }, - { key: 'model', value: 'gpt-account' } - ) - expect(persistOptions).toHaveBeenCalledWith({ model: 'gpt-account' }) + const high = [{ value: 'high', label: 'High' }] + const efforts: AgentSessionModelOption[] = [ + { id: 'a', label: 'A', isDefault: true, efforts: high }, + { id: 'b', label: 'B', isDefault: false, efforts: high }, + { id: 'c', label: 'C', isDefault: false, efforts: [] } + ] + function picking(options: Record, models = efforts) { + let record: AgentSessionRecord = { ...restingRecord(), options } + const store = { + getRecord: () => record, + replaceSessionOptions: vi.fn(), + reviseSessionOptions: async ( + revise: (held: AgentSessionRecord, args: A) => AgentSessionRecord, + args: A + ) => (record = revise(record, args)) + } + const deps = { store, agents: claudeAndCodexAgents(), modelCatalog: catalog(models) } + const ctx = { sessionId: SESSION, fence: 1, publish: vi.fn(), now: () => 0 } + return { + pick: (key: string, value: string) => + recordStructuredAgentSessionOptionIntent(deps, ctx, { key, value }), + ran: (model: string, ran: Record, rows = models) => + (record = recordAgentSessionRanOptions(record, { + ...ctx, + model, + options: ran, + models: rows + })), + record: () => record + } + } + + it('admits options only under the model the chat runs, whichever write lands first', async () => { + const chat = picking({ model: 'a', effort: 'high' }) + // A model pick drops an inherited effort its row does not offer; an offered one is refused. + await chat.pick('model', 'c') + expect(chat.record().options).toEqual({ model: 'c' }) + expect(await chat.pick('effort', 'high')).toMatchObject({ ok: false }) + await chat.pick('model', 'b') + // A turn that ran on another model, recorded late, fills nothing, though b offers its effort. + expect(chat.ran('a', { effort: 'high' }).options).toEqual({ model: 'b' }) + expect(chat.ran('b', { effort: 'high' }).options).toEqual({ model: 'b', effort: 'high' }) + // A catalog that later drops it, or a pick, keeps the saved value. + chat.ran('b', { effort: 'low' }, []) + await chat.pick('serviceTier', 'priority') + expect(chat.record().options).toEqual({ model: 'b', effort: 'high', serviceTier: 'priority' }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts index 89cb94de02bc..ab2345042874 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts @@ -1,45 +1,27 @@ -// A pick made at rest is answered with what the next start runs; the record keeps the pick itself. +// The saved catalog rows a pick, a new chat's options and a turn's record are admitted against, +// and a pick's answer: the tier the chat's model runs; the record keeps the pick itself. -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' import { savedEffectiveServiceTier } from '../../../shared/structured-agent-session-effective-tier' import type { AgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' +import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' -/** Whether a saved effort or Fast still runs under a newly picked model's listed row. The saved - * tier is kept: it runs as the row's effective tier. */ -function offeredUnder(row: AgentSessionModelOption, key: string, value: string): boolean { - if (key === 'effort') { - return row.efforts.some((entry) => entry.value === value) - } - return key !== 'fastMode' || value !== 'true' || row.supportsFastMode !== false -} - -/** The options a pick saves and the answer naming them, with the tier the chat's model runs. A - * model pick drops a saved effort or Fast its listed row doesn't offer, which every later send - * would carry and the agent refuse; an unlisted model keeps them. */ -export async function structuredAgentSessionPickOptions( +/** The chat's saved catalog rows, read without starting discovery; none when nothing is saved. */ +export async function savedAgentSessionCatalogModels( deps: { modelCatalog?: Pick }, - record: Pick, - sessionId: string, - pick: { key: string; value: string }, - saved: () => Readonly> | undefined -): Promise<{ options: Record; replied: Record }> { - if (pick.key !== 'model' && !saved()?.serviceTier) { - const options = { ...saved(), [pick.key]: pick.value } - return { options, replied: options } - } + provider: string, + sessionId: string +): Promise { const listing = await deps.modelCatalog - ?.read({ agent: record.provider, sessionId, savedOnly: true }) + ?.read({ agent: provider, sessionId, savedOnly: true }) .catch(() => null) - // Read after the wait, so a value saved meanwhile is kept. - const picked = { ...saved(), [pick.key]: pick.value } - const models = listing && listing.origin !== 'unknown' ? listing.models : [] - const row = pick.key === 'model' ? models.find((entry) => entry.id === pick.value) : undefined - const options = row - ? Object.fromEntries( - Object.entries(picked).filter(([key, value]) => offeredUnder(row, key, value)) - ) - : picked - const serviceTier = savedEffectiveServiceTier(options, models) ?? options.serviceTier - return { options, replied: serviceTier ? { ...options, serviceTier } : options } + return listing && listing.origin !== 'unknown' ? listing.models : undefined +} + +/** The committed options with the tier the chat's model runs, as the at-rest answer names it. */ +export function structuredAgentSessionPickReply( + options: Readonly>, + models: readonly AgentSessionModelOption[] | undefined +): Record { + const serviceTier = savedEffectiveServiceTier(options, models ?? []) ?? options.serviceTier + return serviceTier ? { ...options, serviceTier } : { ...options } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts index b490d10b8a66..f74fb0f063ae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts @@ -1,5 +1,6 @@ -// A chat keeps, for each of its options it has none saved for, what its turn ran: the message's -// choice, else what its agent reports running. A default the host filled in is never one. +// A chat keeps, for each of its options it has none saved for, what its turn ran: the provider's +// own evidence of the dispatch, else the message's choice and what its agent reports running. A +// setting or a default the host filled in is never one. import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' @@ -9,20 +10,27 @@ import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import { structuredAgentSessionOptionOverridesRefusal } from './structured-agent-session-option-intent' +import { savedAgentSessionCatalogModels } from './structured-agent-session-pick-reply' +import { recordAgentSessionRanOptions } from '../../runtime/agent-session-record-options' type RanOptionsContext = { - deps: Pick + deps: Pick sessions: ReadonlyMap now: () => number } type RanOptions = Partial> -/** In memory per child: what it last reported running, and whether a turn it was handed still - * owes a record because nothing had reported a model yet. */ +export type StructuredAgentSessionStructuredAgentSessionHandedOptionsOptions = { + message?: AgentSessionExecutionOptions + executed?: Readonly> +} + +/** In memory per child: what it last reported running, and a handed-over turn that still owes a + * record because nothing had named its model yet. */ const childRuns = new WeakMap< StructuredAgentSessionProviderChild, - { reported?: RanOptions; owed?: true } + { reported?: RanOptions; owed?: StructuredAgentSessionHandedOptions } >() function currentChild(context: RanOptionsContext, sessionId: string, fence: number) { @@ -67,7 +75,7 @@ export async function noteStructuredAgentSessionReportedOptions( const runs = childRuns.get(child) childRuns.set(child, { reported: ran }) if (runs?.owed) { - await recordStructuredAgentSessionRanOptions(context, sessionId, fence, undefined) + await recordStructuredAgentSessionRanOptions(context, sessionId, fence, runs.owed) } } @@ -75,14 +83,14 @@ export async function noteStructuredAgentSessionReportedOptions( export type StructuredAgentSessionRanRecorder = ( sessionId: string, fence: number -) => (message: AgentSessionExecutionOptions | undefined) => Promise +) => (handed: StructuredAgentSessionHandedOptions) => Promise /** After a turn was handed over; bookkeeping, so a failure is logged and never refuses the send. */ export async function recordStructuredAgentSessionRanOptions( context: RanOptionsContext, sessionId: string, fence: number, - message: AgentSessionExecutionOptions | undefined + handed: StructuredAgentSessionHandedOptions ): Promise { const child = currentChild(context, sessionId, fence) const { store, logger, agents } = context.deps @@ -90,21 +98,24 @@ export async function recordStructuredAgentSessionRanOptions( if (!child || !record) { return } + const { message, executed } = handed const reported = childRuns.get(child)?.reported - const model = message?.model ?? reported?.model - childRuns.set(child, { ...(reported ? { reported } : {}), ...(model ? {} : { owed: true }) }) - if (!model) { + const model = executed?.model ?? message?.model ?? reported?.model + childRuns.set(child, { ...(reported ? { reported } : {}), ...(model ? {} : { owed: handed }) }) + if (!model || (record.options?.model ?? model) !== model) { return } - // The report's other values ran only with its own model. + // The report's other values ran only with its own model; a message's tier or Fast may not have + // run, as a steer runs its turn's. const report = reported?.model === model ? reported : {} const ran: Record = {} for (const [key, value] of Object.entries({ - model, - effort: message?.effort ?? report.effort, - // A message's own tier or Fast may not have run: a steer runs its turn's. - serviceTier: report.serviceTier, - fastMode: report.fastMode + ...(executed ?? { + effort: message?.effort ?? report.effort, + serviceTier: report.serviceTier, + fastMode: report.fastMode + }), + model })) { // Never over a saved choice, nor one every later send would carry and the agent refuse. if ( @@ -115,11 +126,20 @@ export async function recordStructuredAgentSessionRanOptions( ran[key] = value } } - if (Object.keys(ran).length === 0) { + // Nothing new, or a model the agent would refuse on every later send. + if (record.options?.model === undefined ? !ran.model : Object.keys(ran).length === 0) { return } + const models = await savedAgentSessionCatalogModels(context.deps, record.provider, sessionId) await store - .recordRanOptions({ sessionId, fence: child.fence, options: ran, now: context.now() }) + .reviseSessionOptions(recordAgentSessionRanOptions, { + sessionId, + fence: child.fence, + model, + options: ran, + models, + now: context.now() + }) .catch((error: unknown) => logger.warn('recording the options a turn ran failed', { scope: 'ran-options', diff --git a/src/main/runtime/agent-session-record-options.ts b/src/main/runtime/agent-session-record-options.ts index 483fab878794..d017313b8ce5 100644 --- a/src/main/runtime/agent-session-record-options.ts +++ b/src/main/runtime/agent-session-record-options.ts @@ -3,6 +3,7 @@ import type { AgentSessionOptionsReplacement, AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionModelOption } from '../../shared/agent-session-wire' export function replaceAgentSessionRecordOptions( record: AgentSessionRecord, @@ -19,23 +20,107 @@ export function replaceAgentSessionRecordOptions( : { ...record, permissionMode: replacement.permissionMode, updatedAt: replacement.now } } -/** The options a turn ran, each recorded only where the chat has none saved, so a pick, even one - * made since, always wins. */ +/** Saved catalog rows read before a write without starting discovery; none or an unlisted model + * is unknown evidence, which admits everything. */ +export type AgentSessionAdmissionModels = readonly AgentSessionModelOption[] | undefined + +/** Whether `value` runs under `model`'s listed row: an effort the row lists, Fast the agent replays + * there (Claude needs the row's positive support). The saved tier keeps its effective-tier rule. */ +export function agentSessionOptionAdmitted( + provider: string, + models: AgentSessionAdmissionModels, + model: string | undefined, + key: string, + value: string +): boolean { + const row = models?.find((entry) => entry.id === model) + if (!row) { + return true + } + if (key === 'effort') { + return row.efforts.some((entry) => entry.value === value) + } + return ( + key !== 'fastMode' || + value !== 'true' || + (provider === 'claude' ? row.supportsFastMode === true : row.supportsFastMode !== false) + ) +} + +/** A new chat's options must run under its model's listed row; refused through the actionable + * option refusal otherwise. */ +export function assertAgentSessionOptionsAdmitted( + provider: string, + models: AgentSessionAdmissionModels, + options: Readonly> | undefined +): void { + const [key, value] = + Object.entries(options ?? {}).find( + ([name, kept]) => !agentSessionOptionAdmitted(provider, models, options?.model, name, kept) + ) ?? [] + if (key) { + throw agentSessionRefusalError( + 'agent_session_operation_invalid', + { reason: 'optionRejected' }, + `${provider} model ${options?.model} does not offer ${key} ${value}` + ) + } +} + +/** A pick, reconciled against the record held at the write: a model pick keeps an inherited + * effort or Fast only where the new model runs it. */ +export type AgentSessionOptionPick = { + sessionId: string + fence: number + key: string + value: string + models: AgentSessionAdmissionModels + now: number +} + +export function pickAgentSessionOption( + record: AgentSessionRecord, + { key, value, models, ...at }: AgentSessionOptionPick +): AgentSessionRecord { + const picked = { ...record.options, [key]: value } + const options = Object.fromEntries( + Object.entries(picked).filter( + ([name, kept]) => + key !== 'model' || agentSessionOptionAdmitted(record.provider, models, value, name, kept) + ) + ) + return replaceAgentSessionRecordOptions(record, { ...at, options }) +} + +/** What a turn ran on `model`, each value recorded only where the chat has none saved and only + * while the chat's model is that one, so a pick, even one made since, always wins. */ export type AgentSessionRanOptions = { sessionId: string fence: number + model: string options: Readonly> + models: AgentSessionAdmissionModels now: number } export function recordAgentSessionRanOptions( record: AgentSessionRecord, - { options, ...at }: AgentSessionRanOptions + { model, options, models, ...at }: AgentSessionRanOptions ): AgentSessionRecord { - return Object.keys(options).every((key) => record.options?.[key] !== undefined) + const saved = record.options ?? {} + // Another model won meanwhile: none of this run's options belong to it. + if (saved.model !== undefined && saved.model !== model) { + return record + } + const added = Object.entries({ ...options, model }).filter( + ([key, value]) => + saved[key] === undefined && + agentSessionOptionAdmitted(record.provider, models, model, key, value) + ) + return added.length === 0 ? record : replaceAgentSessionRecordOptions(record, { ...at, - options: { ...options, ...record.options } + options: { ...Object.fromEntries(added), ...saved } }) } diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 3e575f212716..ebfa56c03b88 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -56,10 +56,7 @@ import { collectAgentSessionRestartProbes, type AgentSessionRestartProbeArgs } from './agent-session-restart-reconciliation' -import { - recordAgentSessionRanOptions, - replaceAgentSessionRecordOptions -} from './agent-session-record-options' +import { replaceAgentSessionRecordOptions } from './agent-session-record-options' import { commitAgentSessionReservation, type AgentSessionReserveRequest, @@ -323,8 +320,12 @@ export class AgentSessionRecordStore { replaceSessionOptions = (args: AgentSessionOptionsReplacement): Promise => this.mutate(args.sessionId, (record) => replaceAgentSessionRecordOptions(record, args)) - recordRanOptions = (ran: Parameters[1]) => - this.mutate(ran.sessionId, (record) => recordAgentSessionRanOptions(record, ran)) + /** One options revision from `agent-session-record-options`, decided against the record this + * ordered write holds. */ + reviseSessionOptions = ( + revise: (record: AgentSessionRecord, args: A) => AgentSessionRecord, + args: A + ) => this.mutate(args.sessionId, (record) => revise(record, args)) async retireClaimKey(keyId: string, now: number): Promise { await this.transact((draft) => retireAgentSessionClaimKey(draft, keyId, now)) diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index 31a1ece91ff5..2e47fd21fa58 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -10,6 +10,7 @@ */ import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { assertAgentSessionOptionsAdmitted } from './agent-session-record-options' import { chatAgentsShareConversations } from '../../shared/structured-agent-cli-conversation-adoption' import { agentSessionOperationKey, @@ -63,6 +64,8 @@ export type AgentSessionReserveRequest = { launchEnv?: AgentSessionLaunchEnv /** Initial provider options persisted before the first process is acquired. */ options?: Readonly> + /** Saved catalog rows those options are admitted against; transient, never written. */ + models?: Parameters[1] /** The tab id a create reserved for this conversation, taken when its tab is published. An id * another session's tab holds is refused here, before anything is spawned. */ surfaceTabId?: string @@ -154,6 +157,7 @@ export function validateAgentSessionReservationInput(request: AgentSessionReserv if (request.options && !isAgentSessionOptions(request.options)) { throw new Error('agent_session_options_invalid') } + assertAgentSessionOptionsAdmitted(request.provider, request.models, request.options) } export function applyAgentSessionReservation( From 98ac34708c3171e98a6d2b6fb27b935448c9a461 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:38:23 -0700 Subject: [PATCH 24/34] fix(native-chat): an attach that founds a chat admits its options against the saved catalog too --- .../structured-agent-session-attach-flow.ts | 5 ++++- .../structured-agent-session-attach-orchestration.ts | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index f45d9bdddfc7..ff255e78e941 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -76,6 +76,8 @@ export type AttachFlowInput = { acquireSignal?: AbortSignal /** How the steps below meet the session's serialize; absent, the caller holds it. */ lock?: StructuredAgentSessionAttachLock + /** Saved catalog rows a new chat's options are admitted against; transient. */ + models?: Parameters[0]['models'] /** The id the host registered this start under; see `StructuredAgentSessionStartupAttempts`. */ startupAttemptId?: string /** The attempt this attach's acquire runs under, minted before the adapter is called; what it @@ -169,7 +171,8 @@ export async function performAttach( authority: input.authority, callerKey: input.callerKey, fingerprint: admitted.fingerprint, - now: input.now() + now: input.now(), + models: input.models }) ) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 76c1655826a4..39c737d9ab14 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -22,6 +22,7 @@ import type { } from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { performAttach, type AttachFlowInput } from './structured-agent-session-attach-flow' +import { savedAgentSessionCatalogModels as savedModels } from './structured-agent-session-pick-reply' import { endStructuredAgentSessionReleasedChild } from './structured-agent-session-attach-failure' import { stampFailedCreateOwnerVerdict } from './structured-agent-session-failed-create-refusal' import { @@ -218,6 +219,9 @@ async function runAttachUnderAbort( const { startupAttempts } = context.runtimeState try { const attached = await performAttach({ + models: priorRecord + ? undefined + : await lock.wait(savedModels(context.deps, params.provider, sessionId)), store: context.deps.store, adapter: context.deps.adapter, agents: context.deps.agents, From 3631abff99e0b0deb78a1a68bebf67c5c45ff757 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:48:25 -0700 Subject: [PATCH 25/34] fix(native-chat): Claude and Codex always name what a turn ran, and picks are judged inside the write The handover type was misnamed, which broke the typecheck. Claude and Codex now attach their dispatch evidence even when it names nothing, so a Claude turn dispatched before its settings readback never records the settings effort it did not run; the agent's own report still names the model and stands in only for agents without dispatch evidence. Recording leaves the saved-model and set-if-unset checks to the store's one admission rule. An explicit effort or Fast pick is now checked inside the ordered write, with the same refusal, and only at rest: a running chat's own menu can offer more than its saved catalog row (Pi's probe menu stops at high). --- .../claude/claude-fold-steer-receipt.test.ts | 2 +- ...laude-structured-conversation-stop.test.ts | 4 +-- .../claude-structured-message-options.test.ts | 8 ++--- .../claude-structured-message-options.ts | 7 ++-- ...e-structured-session-adapter-turns.test.ts | 10 +++--- .../claude-structured-session-adapter.test.ts | 6 ++-- ...odex-structured-dispatch-admission.test.ts | 4 +-- ...codex-structured-dispatch-boundary.test.ts | 4 +-- ...dex-structured-turn-end-settlement.test.ts | 8 ++--- src/main/codex/codex-structured-turn-start.ts | 7 ++-- .../codex/codex-structured-turn-steer.test.ts | 22 ++++++------ ...nt-session-operation-start-refusal.test.ts | 9 ++--- .../structured-agent-session-option-intent.ts | 34 ++++++++----------- ...uctured-agent-session-options-read.test.ts | 4 ++- .../structured-agent-session-ran-options.ts | 31 ++++++++--------- .../runtime/agent-session-record-options.ts | 8 +++++ 16 files changed, 85 insertions(+), 83 deletions(-) diff --git a/src/main/claude/claude-fold-steer-receipt.test.ts b/src/main/claude/claude-fold-steer-receipt.test.ts index bc15a52cecd5..e11f9fadafc1 100644 --- a/src/main/claude/claude-fold-steer-receipt.test.ts +++ b/src/main/claude/claude-fold-steer-receipt.test.ts @@ -130,7 +130,7 @@ async function riggedAdapter( requestedAt: nowMs, fence: 7 }) - expect(outcome).toEqual({ state: 'admitted' }) + expect(outcome).toMatchObject({ state: 'admitted' }) const sentUuid = claude.connections[0]!.sent.at(-1)?.uuid if (typeof sentUuid !== 'string') { throw new Error('the fake connection recorded no sent uuid') diff --git a/src/main/claude/claude-structured-conversation-stop.test.ts b/src/main/claude/claude-structured-conversation-stop.test.ts index 84159669edfa..0b35caed75b3 100644 --- a/src/main/claude/claude-structured-conversation-stop.test.ts +++ b/src/main/claude/claude-structured-conversation-stop.test.ts @@ -18,7 +18,7 @@ async function written(claude: ReturnType) { body: USER_MESSAGE, fence: 7 }) - ).resolves.toEqual({ state: 'admitted' }) + ).resolves.toMatchObject({ state: 'admitted' }) return adapter } @@ -95,7 +95,7 @@ describe('Claude Stop that names no turn', () => { body: USER_MESSAGE, fence: 7 }) - ).resolves.toEqual({ state: 'admitted' }) + ).resolves.toMatchObject({ state: 'admitted' }) expect(connection.sent).toHaveLength(2) }) }) diff --git a/src/main/claude/claude-structured-message-options.test.ts b/src/main/claude/claude-structured-message-options.test.ts index 8b790ff250d5..dabdf71be3f6 100644 --- a/src/main/claude/claude-structured-message-options.test.ts +++ b/src/main/claude/claude-structured-message-options.test.ts @@ -28,7 +28,7 @@ const MODELS = [ describe('Claude accepted message choices', () => { it.each([ ['the effort the CLI applied over the settings level', 'low', { effort: 'low' }], - ['no effort where the CLI applies none, whatever the settings say', null, undefined] + ['no effort where the CLI applies none, whatever the settings say', null, {}] ])('reports %s', async (_name, applied, ranOptions) => { let effortLevel = 'high' const claude = fakeClaude({ @@ -57,7 +57,7 @@ describe('Claude accepted message choices', () => { executionOptions: {}, fence: 7 }) - expect(outcome).toEqual({ state: 'admitted', ...(ranOptions ? { ranOptions } : {}) }) + expect(outcome).toEqual({ state: 'admitted', ranOptions }) }) it('applies a held message A before its prompt, then message B on the existing child', async () => { @@ -116,7 +116,7 @@ describe('Claude accepted message choices', () => { await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 }) order.length = 0 release() - await expect(a).resolves.toEqual({ state: 'admitted' }) + await expect(a).resolves.toMatchObject({ state: 'admitted' }) expect(order).toEqual(['model:sonnet', 'effort:{"effortLevel":"low"}', 'prompt']) order.length = 0 @@ -156,7 +156,7 @@ describe('Claude accepted message choices', () => { executionOptions: { model: 'sonnet' }, fence: 7 }) - ).resolves.toEqual({ state: 'admitted' }) + ).resolves.toMatchObject({ state: 'admitted' }) expect(actual).toBe('sonnet') expect(connection.sent).toHaveLength(1) }) diff --git a/src/main/claude/claude-structured-message-options.ts b/src/main/claude/claude-structured-message-options.ts index 38265e8b4df3..0e6c35fbd72b 100644 --- a/src/main/claude/claude-structured-message-options.ts +++ b/src/main/claude/claude-structured-message-options.ts @@ -116,9 +116,8 @@ export async function dispatchClaudeMessage( } } ) - const ranOptions = claudeRanOptions(session, messageOptions ?? {}) - return (outcome.state === 'accepted' || outcome.state === 'admitted') && - Object.keys(ranOptions).length > 0 - ? { ...outcome, ranOptions } + // Always, even empty: the settings effort the CLI reports is never evidence of a run. + return outcome.state === 'accepted' || outcome.state === 'admitted' + ? { ...outcome, ranOptions: claudeRanOptions(session, messageOptions ?? {}) } : outcome } diff --git a/src/main/claude/claude-structured-session-adapter-turns.test.ts b/src/main/claude/claude-structured-session-adapter-turns.test.ts index 0fa3c8f287eb..16cf7c96b488 100644 --- a/src/main/claude/claude-structured-session-adapter-turns.test.ts +++ b/src/main/claude/claude-structured-session-adapter-turns.test.ts @@ -25,7 +25,7 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { fence: 7, executionOptions: { model: 'sonnet', effort: 'high' } }) - ).resolves.toEqual({ state: 'admitted' }) + ).resolves.toMatchObject({ state: 'admitted' }) const controls = connection.calls.slice(before).map((call) => call.subtype) expect(controls.indexOf('set_model')).toBeGreaterThan(-1) expect(controls.indexOf('apply_flag_settings')).toBeGreaterThan(controls.indexOf('set_model')) @@ -43,7 +43,7 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { fence: 7 }) - expect(result).toEqual({ state: 'admitted' }) + expect(result).toMatchObject({ state: 'admitted' }) expect(settled).toHaveBeenCalledWith({ sessionId: 'session-1', clientMessageId: 'client-1', @@ -70,7 +70,7 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { body: USER_MESSAGE, fence: 7 }) - ).resolves.toEqual({ state: 'admitted' }) + ).resolves.toMatchObject({ state: 'admitted' }) expect(settled).not.toHaveBeenCalled() }) @@ -182,7 +182,7 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { body: USER_MESSAGE, fence: 7 }) - ).resolves.toEqual({ state: 'admitted' }) + ).resolves.toMatchObject({ state: 'admitted' }) expect(settled).toHaveBeenCalledWith({ sessionId: 'session-1', clientMessageId: 'client-T', @@ -195,7 +195,7 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { body: USER_MESSAGE, fence: 7 }) - ).resolves.toEqual({ state: 'admitted' }) + ).resolves.toMatchObject({ state: 'admitted' }) expect(claude.connections[0].sent).toHaveLength(2) await expect( diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 6b93a2b99431..b470c1f5f482 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -177,7 +177,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { body: USER_MESSAGE, fence: 7 }) - ).resolves.toEqual({ state: 'admitted' }) + ).resolves.toMatchObject({ state: 'admitted' }) const sent = claude.connections[0]!.sent[0]! claude.connections[0]!.handlers.onMessage?.({ ...sent, @@ -219,7 +219,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { fence: 7, requestedAt }) - ).resolves.toEqual({ state: 'admitted' }) + ).resolves.toMatchObject({ state: 'admitted' }) } const echo = (index: number): void => { const sent = connection.sent[index]! @@ -270,7 +270,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { body: USER_MESSAGE, fence: 7 }) - ).resolves.toEqual({ state: 'admitted' }) + ).resolves.toMatchObject({ state: 'admitted' }) expect(connection.sent).toHaveLength(1) connection.handlers.onMessage?.({ ...connection.sent[0], diff --git a/src/main/codex/codex-structured-dispatch-admission.test.ts b/src/main/codex/codex-structured-dispatch-admission.test.ts index 5ab5f6bec98e..3889115134d0 100644 --- a/src/main/codex/codex-structured-dispatch-admission.test.ts +++ b/src/main/codex/codex-structured-dispatch-admission.test.ts @@ -59,7 +59,7 @@ describe('codex dispatch admission', () => { const outcome = await send(adapter, 'client-2') // No doubt: elapsed time is not evidence, so nothing invites a Retry. - expect(outcome).toEqual({ state: 'admitted' }) + expect(outcome).toMatchObject({ state: 'admitted' }) expect(settlements).toEqual([]) echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u2', clientId: 'client-2' }) @@ -358,7 +358,7 @@ describe('codex dispatch admission', () => { startTurn(connection, 'turn-1') for (let index = 0; index < MAX_CODEX_PENDING_DISPATCH_ECHOES; index += 1) { - expect(await send(adapter, `client-${index}`)).toEqual({ state: 'admitted' }) + expect(await send(adapter, `client-${index}`)).toMatchObject({ state: 'admitted' }) } expect(await send(adapter, 'client-overflow')).toEqual({ state: 'rejected', diff --git a/src/main/codex/codex-structured-dispatch-boundary.test.ts b/src/main/codex/codex-structured-dispatch-boundary.test.ts index 7e92abc06318..b7f8d4094cde 100644 --- a/src/main/codex/codex-structured-dispatch-boundary.test.ts +++ b/src/main/codex/codex-structured-dispatch-boundary.test.ts @@ -80,7 +80,7 @@ it('abandons only the stopped message while waiting for a previous turn to open' body: CODEX_TEST_USER_MESSAGE, signal }) - await expect(dispatch('first')).resolves.toEqual({ state: 'admitted' }) + await expect(dispatch('first')).resolves.toMatchObject({ state: 'admitted' }) const controller = new AbortController() const stopped = dispatch('waiting', controller.signal) await new Promise((resolve) => setTimeout(resolve, 10)) @@ -117,7 +117,7 @@ it.each(['accepted', 'uncertain'] as const)( controller.abort(new Error('Stop after write')) if (outcome === 'accepted') { answer.resolve({ turnId: 'running' }) - await expect(sent).resolves.toEqual({ state: 'admitted' }) + await expect(sent).resolves.toMatchObject({ state: 'admitted' }) } else { const lost = new Error('Reply lost after write') answer.reject(lost) diff --git a/src/main/codex/codex-structured-turn-end-settlement.test.ts b/src/main/codex/codex-structured-turn-end-settlement.test.ts index 0f08cb0f9ebc..6d5a82abe90e 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.test.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.test.ts @@ -73,7 +73,7 @@ async function turnEndRig() { describe('a Codex send its turn ended without echoing', () => { it('is withdrawn when the turn is interrupted, once', async () => { const rig = await turnEndRig() - await expect(rig.sendAndOpen('client-1')).resolves.toEqual({ state: 'admitted' }) + await expect(rig.sendAndOpen('client-1')).resolves.toMatchObject({ state: 'admitted' }) rig.turns.end('interrupted') @@ -205,7 +205,7 @@ describe('a Codex send its turn ended without echoing', () => { await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) rig.turns.start() release() - await expect(sending).resolves.toEqual({ state: 'admitted' }) + await expect(sending).resolves.toMatchObject({ state: 'admitted' }) rig.turns.end('interrupted') @@ -264,7 +264,7 @@ describe('a Codex send its turn ended without echoing', () => { rig.turns.end('completed') release() - await expect(sending).resolves.toEqual({ state: 'admitted' }) + await expect(sending).resolves.toMatchObject({ state: 'admitted' }) expect(rig.settlements).toEqual([]) rig.turns.echo('client-1') expect(rig.settledIds()).toEqual(['client-1']) @@ -369,7 +369,7 @@ describe('the turn a withdrawn Codex send names', () => { rig.turns.end('completed') release() - await expect(sending).resolves.toEqual({ state: 'admitted' }) + await expect(sending).resolves.toMatchObject({ state: 'admitted' }) await rig.sendAndOpen('client-2') rig.turns.end('completed') expect(rig.settlements).toEqual([]) diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 6ec7ae0a55c9..3875317e91d8 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -277,21 +277,20 @@ export async function dispatchCodexTurn( ...codexAnsweredTurn(session, input.sessionId, answer.turnId, answer.via), ...rejection } - : { state: 'admitted', ...codexRanOptions(session) } + : { state: 'admitted', ranOptions: codexRanOptions(session) } } /** What a turn on this child runs, a steer its running turn's: the request's model and effort over * the thread's own, and the tier it runs as. */ -function codexRanOptions(session: CodexTurnHost): { ranOptions?: Record } { +function codexRanOptions(session: CodexTurnHost): Record { const requested = codexTurnRequestOptions(session) const thread = session.reportedOptions ?? {} const model = requested.model ?? thread.model const effort = requested.effort ?? (model === thread.model ? thread.effort : undefined) const { serviceTier } = requested - const ranOptions = { + return { ...(model ? { model } : {}), ...(effort ? { effort } : {}), ...(serviceTier ? { serviceTier } : {}) } - return Object.keys(ranOptions).length > 0 ? { ranOptions } : {} } diff --git a/src/main/codex/codex-structured-turn-steer.test.ts b/src/main/codex/codex-structured-turn-steer.test.ts index 427683d774d1..45e4f4f624db 100644 --- a/src/main/codex/codex-structured-turn-steer.test.ts +++ b/src/main/codex/codex-structured-turn-steer.test.ts @@ -66,7 +66,7 @@ describe('a Codex send while a turn runs', () => { }) startTurn(connection, 'turn-1') - expect(await send('client-1')).toEqual({ state: 'admitted' }) + expect(await send('client-1')).toMatchObject({ state: 'admitted' }) expect(methods()).toEqual(['thread/start', 'turn/steer']) expect(connection.calls.at(-1)?.params).toEqual({ threadId: CODEX_TEST_THREAD_ID, @@ -93,7 +93,7 @@ describe('a Codex send while a turn runs', () => { }) startTurn(connection, 'turn-1') - expect(await send('client-1')).toEqual({ state: 'admitted' }) + expect(await send('client-1')).toMatchObject({ state: 'admitted' }) echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) endTurn('turn-1', 'completed') @@ -121,7 +121,7 @@ describe('a Codex send while a turn runs', () => { }) startTurn(connection, 'turn-1') - expect(await send('client-1')).toEqual({ state: 'admitted' }) + expect(await send('client-1')).toMatchObject({ state: 'admitted' }) expect(methods()).toEqual(['thread/start', 'turn/steer', 'turn/start']) expect(connection.calls.at(-1)?.params).toMatchObject({ clientUserMessageId: 'client-1' }) }) @@ -142,7 +142,7 @@ describe('a Codex send while a turn runs', () => { }) startTurn(connection, 'turn-1') - expect(await send('client-1')).toEqual({ state: 'admitted' }) + expect(await send('client-1')).toMatchObject({ state: 'admitted' }) expect(methods()).toEqual(['thread/start', 'turn/steer', 'turn/steer']) expect(connection.calls.at(-1)?.params).toMatchObject({ expectedTurnId: 'turn-2' }) endTurn('turn-2', 'interrupted') @@ -204,7 +204,7 @@ describe('a Codex send while a turn runs', () => { startTurn(connection, 'turn-0') endTurn('turn-0', 'completed') - expect(await send('client-1')).toEqual({ state: 'admitted' }) + expect(await send('client-1')).toMatchObject({ state: 'admitted' }) expect(methods()).toEqual(['thread/start', 'turn/start']) }) }) @@ -213,7 +213,7 @@ describe('a Codex send made after Codex answered an earlier one, before it opene /** A Codex before 0.148, which names a steered start falsely and steers only an opened turn. */ async function answeredUnopened() { const rig = await codexTurnLifecycleRig({ legacyStartAnswers: true }) - expect(await rig.send('client-1')).toEqual({ state: 'admitted' }) + expect(await rig.send('client-1')).toMatchObject({ state: 'admitted' }) const sending = rig.send('client-2') expect(await settledWithin(sending)).toBe('held') const methods = () => @@ -229,7 +229,7 @@ describe('a Codex send made after Codex answered an earlier one, before it opene rig.turns.start() - expect(await rig.sending).toEqual({ state: 'admitted' }) + expect(await rig.sending).toMatchObject({ state: 'admitted' }) expect(rig.methods()).toEqual(['thread/start', 'turn/start', 'turn/steer']) expect(await rig.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).toEqual({ cancelled: true, @@ -249,7 +249,7 @@ describe('a Codex send made after Codex answered an earlier one, before it opene rig.turns.end('failed') - expect(await rig.sending).toEqual({ state: 'admitted' }) + expect(await rig.sending).toMatchObject({ state: 'admitted' }) expect(rig.methods()).toEqual(['thread/start', 'turn/start', 'turn/start']) }) }) @@ -261,19 +261,19 @@ describe('a Codex send after a turn Codex answered and never opened', () => { it('waits for that turn once, and never again', async () => { const rig = await codexTurnLifecycleRig({ legacyStartAnswers: true }) - expect(await rig.send('client-1')).toEqual({ state: 'admitted' }) + expect(await rig.send('client-1')).toMatchObject({ state: 'admitted' }) // Before 0.148 a turn that fails before it starts reports only an `error`. rig.turns.failUnopened('invalid turn settings') vi.useFakeTimers() const waited = rig.send('client-2') await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS) - expect(await waited).toEqual({ state: 'admitted' }) + expect(await waited).toMatchObject({ state: 'admitted' }) vi.useRealTimers() rig.turns.start() rig.turns.echo('client-2') rig.turns.end('completed') - expect(await settledWithin(rig.send('client-3'))).toEqual({ state: 'admitted' }) + expect(await settledWithin(rig.send('client-3'))).toMatchObject({ state: 'admitted' }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts index 332106747791..f239bcbb0b64 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts @@ -51,18 +51,19 @@ describe('an operation whose agent start throws', () => { describe('an option picked while the chat is at rest', () => { it('refuses a key the provider would not accept as a rejected option', async () => { - const persistOptions = vi.fn(async () => {}) + const reviseSessionOptions = vi.fn() const refused = await recordStructuredAgentSessionOptionIntent( { store: { getRecord: () => // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the intent reads only the record's provider and options. ({ provider: 'codex', options: {} }) as unknown as AgentSessionRecord, - replaceSessionOptions: vi.fn() + replaceSessionOptions: vi.fn(), + reviseSessionOptions }, agents: claudeAndCodexAgents() }, - { sessionId: SESSION, fence: 1, persistOptions, publish: () => {}, now: () => 0 }, + { sessionId: SESSION, fence: 1, publish: () => {}, now: () => 0 }, { key: 'notAnOption', value: 'x' } ) @@ -73,6 +74,6 @@ describe('an option picked while the chat is at rest', () => { details: { reason: 'optionRejected' } } }) - expect(persistOptions).not.toHaveBeenCalled() + expect(reviseSessionOptions).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts index 1915c4d06398..2d48f055f296 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts @@ -12,10 +12,8 @@ import { savedAgentSessionCatalogModels, structuredAgentSessionPickReply } from './structured-agent-session-pick-reply' -import { - agentSessionOptionAdmitted, - pickAgentSessionOption -} from '../../runtime/agent-session-record-options' +import { pickAgentSessionOption } from '../../runtime/agent-session-record-options' +import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' /** Whether the agent takes this pick: a permission mode it offers, or an option key it names. */ function acceptsOptionPick( @@ -64,28 +62,17 @@ export async function recordStructuredAgentSessionOptionIntent( ): Promise> { const { sessionId, fence } = ctx const record = deps.store.getRecord(sessionId) - const named = - record !== null && acceptsOptionPick(deps.agents, record.provider, input.key, input.value) - const models = record - ? await savedAgentSessionCatalogModels(deps, record.provider, sessionId) - : [] - const model = deps.store.getRecord(sessionId)?.options?.model - if ( - !record || - !named || - !agentSessionOptionAdmitted(record.provider, models, model, input.key, input.value) - ) { + if (!record || !acceptsOptionPick(deps.agents, record.provider, input.key, input.value)) { return { ok: false, refusal: refuse( 'agent_session_operation_invalid', { reason: 'optionRejected' }, - named - ? `${record?.provider} model ${model} does not offer ${input.key} ${input.value}` - : `${record?.provider ?? 'This session'} has no session option named ${input.key}` + `${record?.provider ?? 'This session'} has no session option named ${input.key}` ) } } + const models = await savedAgentSessionCatalogModels(deps, record.provider, sessionId) let options: Readonly> if (input.key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID) { const permissionMode = input.value @@ -94,7 +81,16 @@ export async function recordStructuredAgentSessionOptionIntent( options = { ...record.options, [input.key]: input.value } } else { const picked = { sessionId, fence, ...input, models, now: ctx.now() } - options = (await deps.store.reviseSessionOptions(pickAgentSessionOption, picked)).options ?? {} + try { + options = + (await deps.store.reviseSessionOptions(pickAgentSessionOption, picked)).options ?? {} + } catch (error) { + // The ordered write refused a value the chat's model doesn't offer. + if (isAgentSessionRefusalError(error)) { + return { ok: false, refusal: error.refusal } + } + throw error + } } ctx.publish() return { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index b48d2f13cc44..44abff43adcf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -309,7 +309,9 @@ describe('a chat at rest, read by the host', () => { recordStructuredAgentSessionOptionIntent(deps, ctx, { key, value }), ran: (model: string, ran: Record, rows = models) => (record = recordAgentSessionRanOptions(record, { - ...ctx, + sessionId: SESSION, + fence: 1, + now: 0, model, options: ran, models: rows diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts index f74fb0f063ae..93db19c99609 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts @@ -21,7 +21,7 @@ type RanOptionsContext = { type RanOptions = Partial> -export type StructuredAgentSessionStructuredAgentSessionHandedOptionsOptions = { +export type StructuredAgentSessionHandedOptions = { message?: AgentSessionExecutionOptions executed?: Readonly> } @@ -102,32 +102,29 @@ export async function recordStructuredAgentSessionRanOptions( const reported = childRuns.get(child)?.reported const model = executed?.model ?? message?.model ?? reported?.model childRuns.set(child, { ...(reported ? { reported } : {}), ...(model ? {} : { owed: handed }) }) - if (!model || (record.options?.model ?? model) !== model) { + if (!model) { return } - // The report's other values ran only with its own model; a message's tier or Fast may not have - // run, as a steer runs its turn's. - const report = reported?.model === model ? reported : {} + // The dispatch's own evidence, where the agent gives it; else the message's effort and what the + // report says ran with its own model (a message's tier or Fast may not have run: a steer runs its + // turn's). + const report: RanOptions = reported?.model === model ? reported : {} + const proposed: Readonly> = executed ?? { + effort: message?.effort ?? report.effort, + serviceTier: report.serviceTier, + fastMode: report.fastMode + } const ran: Record = {} - for (const [key, value] of Object.entries({ - ...(executed ?? { - effort: message?.effort ?? report.effort, - serviceTier: report.serviceTier, - fastMode: report.fastMode - }), - model - })) { - // Never over a saved choice, nor one every later send would carry and the agent refuse. + for (const [key, value] of Object.entries({ ...proposed, model })) { + // Never one every later send would carry and the agent refuse as a choice. if ( value && - record.options?.[key] === undefined && !structuredAgentSessionOptionOverridesRefusal(agents, record.provider, { [key]: value }) ) { ran[key] = value } } - // Nothing new, or a model the agent would refuse on every later send. - if (record.options?.model === undefined ? !ran.model : Object.keys(ran).length === 0) { + if (!ran.model) { return } const models = await savedAgentSessionCatalogModels(context.deps, record.provider, sessionId) diff --git a/src/main/runtime/agent-session-record-options.ts b/src/main/runtime/agent-session-record-options.ts index d017313b8ce5..e8086b4eb083 100644 --- a/src/main/runtime/agent-session-record-options.ts +++ b/src/main/runtime/agent-session-record-options.ts @@ -82,6 +82,14 @@ export function pickAgentSessionOption( record: AgentSessionRecord, { key, value, models, ...at }: AgentSessionOptionPick ): AgentSessionRecord { + // A live child's own menu may offer more than its saved row (Pi's probe stops at high). + if (key !== 'model' && record.lease.claimStatus !== 'live') { + const { model } = record.options ?? {} + assertAgentSessionOptionsAdmitted(record.provider, models, { + ...(model ? { model } : {}), + [key]: value + }) + } const picked = { ...record.options, [key]: value } const options = Object.fromEntries( Object.entries(picked).filter( From 3199666ca2f3b7bbd1844b4f3607d4cd57fe7cfc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:03:33 -0700 Subject: [PATCH 26/34] test(native-chat): Codex admission tests accept the dispatch's run evidence --- src/main/codex/codex-structured-session-cancel.test.ts | 2 +- src/main/codex/codex-structured-turn-open-wait.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/codex/codex-structured-session-cancel.test.ts b/src/main/codex/codex-structured-session-cancel.test.ts index 7a541a2f59ec..85dc0e97c62d 100644 --- a/src/main/codex/codex-structured-session-cancel.test.ts +++ b/src/main/codex/codex-structured-session-cancel.test.ts @@ -228,7 +228,7 @@ describe('CodexStructuredSessionAdapter.cancelTurn', () => { body: USER_MESSAGE, fence: 7 }) - ).resolves.toEqual({ + ).resolves.toMatchObject({ state: 'admitted' }) }) diff --git a/src/main/codex/codex-structured-turn-open-wait.test.ts b/src/main/codex/codex-structured-turn-open-wait.test.ts index c1ca66996559..4f0c7ea97ad6 100644 --- a/src/main/codex/codex-structured-turn-open-wait.test.ts +++ b/src/main/codex/codex-structured-turn-open-wait.test.ts @@ -24,7 +24,7 @@ const stop = (rig: Rig) => rig.adapter.cancelTurn({ sessionId: 'session-1', fenc async function answeredColdSend(rig: Rig): Promise { const sending = rig.send('client-1') // The send is not held for the turn to open: its handover ends at the answer. - expect(await settledWithin(sending)).toEqual(ADMITTED) + expect(await settledWithin(sending)).toMatchObject(ADMITTED) expect(rig.turns.turnId).toBe('turn-1') } @@ -102,7 +102,7 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi const rig = await codexTurnLifecycleRig() await answeredColdSend(rig) rig.turns.start() - expect(await settledWithin(rig.send('client-2'))).toEqual(ADMITTED) + expect(await settledWithin(rig.send('client-2'))).toMatchObject(ADMITTED) expect(await settledWithin(stop(rig))).toEqual({ cancelled: true, turnId: 'turn-1' }) expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) From ef0212c1ee431e775eb10c20a9e0984d9853d83b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:08:23 -0700 Subject: [PATCH 27/34] fix(native-chat): a new chat's options are admitted against the folder list its picker showed --- ...ured-agent-session-attach-orchestration.ts | 4 +-- .../structured-agent-session-create.ts | 4 +-- .../structured-agent-session-pick-reply.ts | 34 +++++++++++++++++-- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 39c737d9ab14..6fdebb3554f3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -22,7 +22,7 @@ import type { } from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { performAttach, type AttachFlowInput } from './structured-agent-session-attach-flow' -import { savedAgentSessionCatalogModels as savedModels } from './structured-agent-session-pick-reply' +import { savedNewChatCatalogModels as savedModels } from './structured-agent-session-pick-reply' import { endStructuredAgentSessionReleasedChild } from './structured-agent-session-attach-failure' import { stampFailedCreateOwnerVerdict } from './structured-agent-session-failed-create-refusal' import { @@ -221,7 +221,7 @@ async function runAttachUnderAbort( const attached = await performAttach({ models: priorRecord ? undefined - : await lock.wait(savedModels(context.deps, params.provider, sessionId)), + : await lock.wait(savedModels(context.deps, params, launchDirectory)), store: context.deps.store, adapter: context.deps.adapter, agents: context.deps.agents, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts index 42b00ffec15f..40448f4e7617 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts @@ -31,7 +31,7 @@ import { attachStructuredAgentSession } from './structured-agent-session-attach- import { readAgentSessionHydrationPage } from './agent-session-history-page' import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' -import { savedAgentSessionCatalogModels as savedModels } from './structured-agent-session-pick-reply' +import { savedNewChatCatalogModels as savedModels } from './structured-agent-session-pick-reply' export type StructuredAgentSessionCreateOptions = { firstMessage?: { clientMessageId: string; body: AgentJournalMessageItem } @@ -108,7 +108,7 @@ async function foundConversation( const request = reserveRequestFor({ sessionId, params, - models: prior ? undefined : await savedModels(deps, params.provider, sessionId), + models: prior ? undefined : await savedModels(deps, params, launchDirectory), callerKey, fingerprint: params.envelope.payloadFingerprint, now: context.now(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts index ab2345042874..fcfae1b8e714 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts @@ -4,15 +4,20 @@ import { savedEffectiveServiceTier } from '../../../shared/structured-agent-session-effective-tier' import type { AgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' +import { isNativeLocalLocation } from '../../../shared/execution-host' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' -/** The chat's saved catalog rows, read without starting discovery; none when nothing is saved. */ +/** The chat's saved catalog rows, read without starting discovery for its own folder, or for + * `workspacePath` where it has no record yet; none when nothing is saved. */ export async function savedAgentSessionCatalogModels( deps: { modelCatalog?: Pick }, provider: string, - sessionId: string + sessionId: string, + workspacePath?: string | null ): Promise { + const scope = workspacePath === undefined ? {} : { workspacePath } const listing = await deps.modelCatalog - ?.read({ agent: provider, sessionId, savedOnly: true }) + ?.read({ agent: provider, sessionId, savedOnly: true, ...scope }) .catch(() => null) return listing && listing.origin !== 'unknown' ? listing.models : undefined } @@ -25,3 +30,26 @@ export function structuredAgentSessionPickReply( const serviceTier = savedEffectiveServiceTier(options, models ?? []) ?? options.serviceTier return serviceTier ? { ...options, serviceTier } : { ...options } } + +/** A new chat's saved rows for the folder it will run in, scoped as its new-chat picker read them: + * a local folder, else the account's. */ +export async function savedNewChatCatalogModels( + deps: Parameters[0] & { + resolveWorkspacePath?: (workspaceId: string) => Promise + }, + params: Pick, + launchDirectory: string | undefined +): Promise { + const { location } = params + const workspacePath = isNativeLocalLocation(location) + ? (launchDirectory ?? + (await deps.resolveWorkspacePath?.(location.workspaceId).catch(() => null)) ?? + null) + : null + return savedAgentSessionCatalogModels( + deps, + params.provider, + params.envelope.sessionId, + workspacePath + ) +} From 60a58ea1798cfc4ed0e2e69c463bf9ea10eaddb4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:21:23 -0700 Subject: [PATCH 28/34] fix(native-chat): a new chat's admission reads the list its picker read, resolved once by the create --- ...ured-agent-session-attach-orchestration.ts | 7 ++-- .../structured-agent-session-create.ts | 5 +-- .../structured-agent-session-pick-reply.ts | 25 -------------- ...uctured-worker-launch-seed-options.test.ts | 2 +- .../structured-agent-session-create.ts | 34 ++++++++++++++----- 5 files changed, 33 insertions(+), 40 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 6fdebb3554f3..48294c183258 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -22,7 +22,6 @@ import type { } from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { performAttach, type AttachFlowInput } from './structured-agent-session-attach-flow' -import { savedNewChatCatalogModels as savedModels } from './structured-agent-session-pick-reply' import { endStructuredAgentSessionReleasedChild } from './structured-agent-session-attach-failure' import { stampFailedCreateOwnerVerdict } from './structured-agent-session-failed-create-refusal' import { @@ -52,6 +51,8 @@ import { export type StructuredAgentSessionAttachOptions = { hostLaunchDirectory?: string + /** The saved list a new chat's options are admitted against, for the folder its picker read. */ + catalogModels?: AttachFlowInput['models'] recordPhase?: AgentSessionCreatePhaseRecorder onAcquisitionFailed?: AttachFlowInput['onAcquisitionFailed'] /** The queued message a start is for; see `StructuredAgentSessionProviderChild.startedFor`. */ @@ -219,9 +220,7 @@ async function runAttachUnderAbort( const { startupAttempts } = context.runtimeState try { const attached = await performAttach({ - models: priorRecord - ? undefined - : await lock.wait(savedModels(context.deps, params, launchDirectory)), + models: priorRecord ? undefined : options.catalogModels, store: context.deps.store, adapter: context.deps.adapter, agents: context.deps.agents, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts index 40448f4e7617..d33da7ff7cb9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts @@ -31,11 +31,12 @@ import { attachStructuredAgentSession } from './structured-agent-session-attach- import { readAgentSessionHydrationPage } from './agent-session-history-page' import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' -import { savedNewChatCatalogModels as savedModels } from './structured-agent-session-pick-reply' export type StructuredAgentSessionCreateOptions = { firstMessage?: { clientMessageId: string; body: AgentJournalMessageItem } hostLaunchDirectory?: string + /** The saved list the chat's options are admitted against, for the folder its picker read. */ + catalogModels?: Parameters[0]['models'] /** Called after the durable create, before delivery can start the agent. */ publishTab?: () => Promise } @@ -108,7 +109,7 @@ async function foundConversation( const request = reserveRequestFor({ sessionId, params, - models: prior ? undefined : await savedModels(deps, params, launchDirectory), + models: prior ? undefined : options.catalogModels, callerKey, fingerprint: params.envelope.payloadFingerprint, now: context.now(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts index fcfae1b8e714..82b9219c24ae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts @@ -4,8 +4,6 @@ import { savedEffectiveServiceTier } from '../../../shared/structured-agent-session-effective-tier' import type { AgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' -import { isNativeLocalLocation } from '../../../shared/execution-host' -import type { AgentSessionAttachParams } from './structured-agent-session-attach' /** The chat's saved catalog rows, read without starting discovery for its own folder, or for * `workspacePath` where it has no record yet; none when nothing is saved. */ @@ -30,26 +28,3 @@ export function structuredAgentSessionPickReply( const serviceTier = savedEffectiveServiceTier(options, models ?? []) ?? options.serviceTier return serviceTier ? { ...options, serviceTier } : { ...options } } - -/** A new chat's saved rows for the folder it will run in, scoped as its new-chat picker read them: - * a local folder, else the account's. */ -export async function savedNewChatCatalogModels( - deps: Parameters[0] & { - resolveWorkspacePath?: (workspaceId: string) => Promise - }, - params: Pick, - launchDirectory: string | undefined -): Promise { - const { location } = params - const workspacePath = isNativeLocalLocation(location) - ? (launchDirectory ?? - (await deps.resolveWorkspacePath?.(location.workspaceId).catch(() => null)) ?? - null) - : null - return savedAgentSessionCatalogModels( - deps, - params.provider, - params.envelope.sessionId, - workspacePath - ) -} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-launch-seed-options.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-launch-seed-options.test.ts index d17f12fa75bf..bf5035d581e5 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-launch-seed-options.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-launch-seed-options.test.ts @@ -71,7 +71,7 @@ describe('the create the seed options land in', () => { runtime: { resolveStructuredAgentSessionCreateIntent: async () => settingsResolved } as never, - ensureHost: async () => ({}) as never, + ensureHost: async () => ({ deps: {} }) as never, envelope: { sessionId: 'sess_1', clientOperationId: 'op_1', diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index 6fb7152ab65f..d5eba180f3c4 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -19,6 +19,8 @@ import type { StructuredAgentSessionFirstMessage } from '../../../../shared/structured-agent-session-create' import { structuredAgentSessionOptionOverridesRefusal } from '../../../native-chat/agent-session-wire/structured-agent-session-options-read' +import { savedAgentSessionCatalogModels } from '../../../native-chat/agent-session-wire/structured-agent-session-pick-reply' +import type { StructuredAgentSessionCreateOptions } from '../../../native-chat/agent-session-wire/structured-agent-session-create' import type { OrcaRuntimeService } from '../../orca-runtime' import { captureStructuredAgentCliSource, @@ -37,6 +39,7 @@ export type PreparedStructuredAgentSessionCreate = { host: StructuredAgentSessionHost attachParams: AgentSessionAttachParams hostLaunchDirectory?: string + catalogModels?: StructuredAgentSessionCreateOptions['catalogModels'] /** Null when the caller supplied its own location; only a resolved worktree publishes a tab. */ tab: { workspaceId: string; agent: StructuredAgentId } | null /** Absent for older clients and provider-dependent in-process callers. */ @@ -145,15 +148,30 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { ? { ...resolved.options, ...args.options } : { ...seededPermissionMode(resolved.options), ...args.options } : resolved.options + // The folder the new-chat picker read its list for, resolved once: the seed and the host's + // admission read the same list. + let folder: Promise | undefined + const catalogFolder = () => + (folder ??= args.runtime + .resolveStructuredAgentSessionLocalWorkspacePath(args.worktree) + .catch(() => null)) // After the fingerprints: a remembered model the saved listing no longer names moves, with the // effort the create runs under it. A model the create names itself is never moved. const options = args.options?.model ? picked - : await structuredLaunchSeedOnSavedListing(host, args.agent, picked, () => - args.runtime.resolveStructuredAgentSessionLocalWorkspacePath(args.worktree) - ) + : await structuredLaunchSeedOnSavedListing(host, args.agent, picked, catalogFolder) + const catalogModels = + options && host.deps.modelCatalog + ? await savedAgentSessionCatalogModels( + host.deps, + resolved.provider, + args.envelope.sessionId, + await catalogFolder() + ) + : undefined return { host, + ...(catalogModels ? { catalogModels } : {}), ...(hostLaunchDirectory ? { hostLaunchDirectory } : {}), attachParams: { ...resolvedAttach, @@ -183,6 +201,7 @@ export async function commitStructuredAgentSessionCreate(args: { if (prepared.atRest) { return prepared.host.create(args.caller, prepared.attachParams, { ...prepared.atRest, + ...(prepared.catalogModels ? { catalogModels: prepared.catalogModels } : {}), ...(prepared.hostLaunchDirectory ? { hostLaunchDirectory: prepared.hostLaunchDirectory } : {}), @@ -206,11 +225,10 @@ export async function commitStructuredAgentSessionCreate(args: { : {}) }) } - const result = prepared.hostLaunchDirectory - ? await prepared.host.attach(args.caller, prepared.attachParams, { - hostLaunchDirectory: prepared.hostLaunchDirectory - }) - : await prepared.host.attach(args.caller, prepared.attachParams) + const result = await prepared.host.attach(args.caller, prepared.attachParams, { + ...(prepared.hostLaunchDirectory ? { hostLaunchDirectory: prepared.hostLaunchDirectory } : {}), + ...(prepared.catalogModels ? { catalogModels: prepared.catalogModels } : {}) + }) if (!result.ok || !prepared.tab) { return result } From 1c2e08755f5b9a60400dd05da999490ce1bc848b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:30:25 -0700 Subject: [PATCH 29/34] chore: re-run CI against main's formatting fix (#30918) From 105b284649f53f271a82e512caff6175bf9928bf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:57:01 -0700 Subject: [PATCH 30/34] chore: re-run CI against main's formatting fix (#30948) From b93993a03c3d6d8bc217b98dfc71cac80c33a0ac Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:00:59 -0700 Subject: [PATCH 31/34] test(native-chat): explain the seed-options stub host cast --- .../worker/structured-worker-launch-seed-options.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-launch-seed-options.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-launch-seed-options.test.ts index bf5035d581e5..1b44570f6430 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-launch-seed-options.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-launch-seed-options.test.ts @@ -71,6 +71,7 @@ describe('the create the seed options land in', () => { runtime: { resolveStructuredAgentSessionCreateIntent: async () => settingsResolved } as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this prepare path touches only host.deps.modelCatalog, absent here, so options pass through unchecked. ensureHost: async () => ({ deps: {} }) as never, envelope: { sessionId: 'sess_1', From e633d2065b92f6af3840e69b61610323aa96f3de Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:17:42 -0700 Subject: [PATCH 32/34] fix(native-chat): attach keeps its two-argument call when there are no attach options --- .../rpc/methods/structured-agent-session-create.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index d5eba180f3c4..49a61e4b4308 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -225,10 +225,14 @@ export async function commitStructuredAgentSessionCreate(args: { : {}) }) } - const result = await prepared.host.attach(args.caller, prepared.attachParams, { + const attachOptions = { ...(prepared.hostLaunchDirectory ? { hostLaunchDirectory: prepared.hostLaunchDirectory } : {}), ...(prepared.catalogModels ? { catalogModels: prepared.catalogModels } : {}) - }) + } + const result = + attachOptions.hostLaunchDirectory || attachOptions.catalogModels + ? await prepared.host.attach(args.caller, prepared.attachParams, attachOptions) + : await prepared.host.attach(args.caller, prepared.attachParams) if (!result.ok || !prepared.tab) { return result } From 0f7ee70d114984d26130cde2ed15e63f95c1e14e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:18:24 -0700 Subject: [PATCH 33/34] Revert "fix(native-chat): attach keeps its two-argument call when there are no attach options" This reverts commit e633d2065b92f6af3840e69b61610323aa96f3de. --- .../rpc/methods/structured-agent-session-create.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index 49a61e4b4308..d5eba180f3c4 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -225,14 +225,10 @@ export async function commitStructuredAgentSessionCreate(args: { : {}) }) } - const attachOptions = { + const result = await prepared.host.attach(args.caller, prepared.attachParams, { ...(prepared.hostLaunchDirectory ? { hostLaunchDirectory: prepared.hostLaunchDirectory } : {}), ...(prepared.catalogModels ? { catalogModels: prepared.catalogModels } : {}) - } - const result = - attachOptions.hostLaunchDirectory || attachOptions.catalogModels - ? await prepared.host.attach(args.caller, prepared.attachParams, attachOptions) - : await prepared.host.attach(args.caller, prepared.attachParams) + }) if (!result.ok || !prepared.tab) { return result } From d0a8ab59eab20f6464431fed32c9cecb5f939385 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:19:14 -0700 Subject: [PATCH 34/34] test(native-chat): create-path attach assertions check the params argument, not the arity --- .../structured-agent-session-wire-contracts.test.ts | 3 +-- .../rpc/methods/structured-agent-session.test.ts | 12 ++++-------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-wire-contracts.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-wire-contracts.test.ts index 5a2abbecedb9..44bc193f9cd8 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-wire-contracts.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-wire-contracts.test.ts @@ -305,8 +305,7 @@ describe('legacy attach-shaped permission creation', () => { structuredAgentSessionLaunchSeedOptions: seed }) ).toMatchObject({ ok: true }) - expect(hostCalls.attach).toHaveBeenCalledWith( - expect.anything(), + expect(hostCalls.attach.mock.calls[0]?.[1]).toEqual( expect.objectContaining({ options: { permissionMode: 'full-access' }, envelope: params.envelope diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 535817b6a63f..e9b94150e97c 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -389,8 +389,7 @@ describe('method routing', () => { callerKey: 'trusted-local:runtime' }) ) - expect(hostCalls.attach).toHaveBeenCalledWith( - expect.anything(), + expect(hostCalls.attach.mock.calls[0]?.[1]).toEqual( expect.objectContaining({ accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' }, options: { model: 'gpt-5.6-sol', effort: 'medium' } @@ -422,8 +421,7 @@ describe('method routing', () => { }) // Beside `options`, after the attach fingerprint: which tab shows the chat is not which // conversation this attaches to. - expect(hostCalls.attach).toHaveBeenCalledWith( - expect.anything(), + expect(hostCalls.attach.mock.calls[0]?.[1]).toEqual( expect.objectContaining({ surfaceTabId: 'chat-tab-1' }) ) }) @@ -580,8 +578,7 @@ describe('method routing', () => { callerKey: 'trusted-local:runtime' }) ) - expect(hostCalls.attach).toHaveBeenCalledWith( - expect.anything(), + expect(hostCalls.attach.mock.calls[0]?.[1]).toEqual( expect.objectContaining({ accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/host/.claude' } }) @@ -650,8 +647,7 @@ describe('method routing', () => { }) expect(await call(method, params, STRUCTURED_CLIENT)).toMatchObject({ ok: true }) - expect(hostCalls.attach).toHaveBeenCalledWith( - expect.anything(), + expect(hostCalls.attach.mock.calls[0]?.[1]).toEqual( expect.objectContaining({ location: params.location }) ) expect(hostCalls.attach.mock.calls[0]?.[1]).not.toHaveProperty('launchDirectory')