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.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/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 3be3c400e3c9..18d767fdd72c 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 { @@ -151,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 ) } @@ -162,6 +165,7 @@ export function useMobileStructuredAgentOptions(args: { } }, [ client, + createdHere, enabled, optionCatalog, optionStamp, @@ -269,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 ) } @@ -286,9 +292,14 @@ 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. + const sendSelection = useCallback( + () => structuredAgentSessionSendSelection(optionStateRef.current), + [] + ) const { optionPickerRequest, invokeStructuredOption, optionSurface } = useMobileStructuredOptionSurface({ optionSnapshot, @@ -308,6 +319,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 b21ce78fa0cd..84b54e2cb286 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -199,7 +199,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-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-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-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 03c57dd55639..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 @@ -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,11 +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, 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' }, @@ -174,7 +179,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, + visitItems: () => {}, + submissions: () => [], + contextUsage: () => null, + context: { floor: () => null } + } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. const context = { @@ -232,6 +243,30 @@ describe('Claude effort default at rest', () => { expect(pickerEffort(result)).toBe('medium') }) + 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')! + 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]), 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') + }) + it("shows the user's pick at rest over the default", async () => { const store = new AgentModelCatalogStore() await startChild(store) @@ -282,6 +317,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 @@ -364,6 +400,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 @@ -381,6 +418,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 @@ -407,7 +445,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-message-options.test.ts b/src/main/claude/claude-structured-message-options.test.ts index 9a043e66da75..dabdf71be3f6 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, {}] + ])('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 }) + }) + it('applies a held message A before its prompt, then message B on the existing child', async () => { const order: string[] = [] let currentEffort = 'high' @@ -82,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 @@ -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']) }) @@ -122,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) }) @@ -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..0e6c35fbd72b 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,8 @@ export function dispatchClaudeMessage( } } ) + // 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-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-options.test.ts b/src/main/claude/claude-structured-options.test.ts index bb05f48d2b7f..4fa6d0f5c272 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -437,6 +437,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/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 b1635a391cd6..f19358e1476a 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -468,7 +468,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)) @@ -543,7 +543,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( @@ -688,7 +688,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-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/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index 1ca91be20d83..f1cc170f4e69 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, @@ -342,7 +343,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/claude/claude-structured-session-reading-control.test.ts b/src/main/claude/claude-structured-session-reading-control.test.ts index 9021b466bb3c..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,6 +69,7 @@ function persistedTarget( }) satisfies StructuredAgentSessionLinkageJournal['visitItemsWithLinkage'], itemBody: (itemId: string) => persisted.get(itemId) ?? null, threadGoal: () => null, + submissions: () => [], threadGoalSource: () => ({ goal: null, row: null }), epoch: 'test' }) as unknown as AgentSessionJournal 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-dispatch-admission.test.ts b/src/main/codex/codex-structured-dispatch-admission.test.ts index 88b37da5b09b..1360045e90b8 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' }) @@ -351,7 +351,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 8f2d5d566c14..16f8f919d214 100644 --- a/src/main/codex/codex-structured-dispatch-boundary.test.ts +++ b/src/main/codex/codex-structured-dispatch-boundary.test.ts @@ -82,7 +82,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)) @@ -120,7 +120,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-message-options.test.ts b/src/main/codex/codex-structured-message-options.test.ts index 60203acc6642..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 { @@ -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({ @@ -157,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) @@ -211,6 +248,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..52a93297fcbe 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')) } @@ -85,9 +86,14 @@ export async function resolvedCodexMessageOptions( if (!defaultEffort && choices.effort === undefined) { selected.options.delete('effort') } - } - if (choices.effort === undefined && defaultEffort && (priorEffort || choices.model)) { - await applyCodexStructuredSessionOption(selected, 'effort', defaultEffort) + // 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/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-service-tier-shown.test.ts b/src/main/codex/codex-structured-service-tier-shown.test.ts new file mode 100644 index 000000000000..a3b604a95e08 --- /dev/null +++ b/src/main/codex/codex-structured-service-tier-shown.test.ts @@ -0,0 +1,108 @@ +// 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 type { AgentSessionModelOption } from '../../shared/agent-session-wire' +import { + applyStructuredAgentSessionOptions, + commitStructuredAgentSessionOption, + 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' +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: 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, 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) => ({ + 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' }] } + }) + 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') } +} + +/** 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, ...(marksDefaults ? { defaulted: [] } : {}) } } + ) +} + +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 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(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( + answered({ model: 'm2', serviceTier: 'flex' }, true), + 'model', + 'm1' + ) + expect(shownTier(back)).toBe('flex') + await expect(hostRuns({ model: 'm1', serviceTier: 'flex' })).resolves.toMatchObject({ + firstTurn: 'flex' + }) + }) +}) diff --git a/src/main/codex/codex-structured-service-tier.ts b/src/main/codex/codex-structured-service-tier.ts index f61728c11af6..caf0ad7bd368 100644 --- a/src/main/codex/codex-structured-service-tier.ts +++ b/src/main/codex/codex-structured-service-tier.ts @@ -6,6 +6,8 @@ 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' +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' @@ -84,28 +86,44 @@ 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( - session: CodexSession, + session: Pick, model: AgentSessionModelOption | undefined, 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 runs as standard; the chat's 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 && + 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-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-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-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index a9bd52325e74..3d5577694d13 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -304,6 +304,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/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/codex/codex-structured-turn-end-settlement.test.ts b/src/main/codex/codex-structured-turn-end-settlement.test.ts index afc2e6bbce5c..fd6d42589332 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') @@ -194,7 +194,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') @@ -250,7 +250,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']) @@ -355,7 +355,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-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']) diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 6b52918d8bb1..3875317e91d8 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 @@ -56,7 +58,8 @@ export type CodexTurnHost = { threadId: string options: Map appliedTurnOptions?: Readonly> - reportedOptions?: { model?: string } + reportedOptions?: CodexSession['reportedOptions'] + catalogAccess?: CodexSession['catalogAccess'] dispatchEchoes: CodexDispatchEchoes activeTurnIds?: ReadonlySet /** Running turns whose interrupt Codex already answered: aborted, so never steered. */ @@ -168,6 +171,8 @@ export async function startCodexTurn( } } host.appliedTurnOptions = input.turnOptions ?? Object.fromEntries(host.options) + // host.options now holds this message's choices; an unoffered tier runs as standard. + const requested = codexTurnRequestOptions(host) let answer: unknown try { answer = await host.connection.request( @@ -176,8 +181,7 @@ export async function startCodexTurn( threadId: host.threadId, clientUserMessageId: input.clientMessageId, input: turnInputFor(input.body), - // Codex omits a tier the model does not list. - ...(input.turnOptions ?? Object.fromEntries(host.options)) + ...requested }, { timeoutMs: input.timeoutMs, onReply: input.onReply } ) @@ -273,5 +277,20 @@ export async function dispatchCodexTurn( ...codexAnsweredTurn(session, input.sessionId, answer.turnId, answer.via), ...rejection } - : { state: 'admitted' } + : { 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): 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 + return { + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + ...(serviceTier ? { serviceTier } : {}) + } } diff --git a/src/main/codex/codex-structured-turn-steer.test.ts b/src/main/codex/codex-structured-turn-steer.test.ts index d54b81313173..a34123903c6c 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') @@ -202,7 +202,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']) }) }) @@ -211,7 +211,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 = () => @@ -227,7 +227,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, @@ -247,7 +247,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']) }) }) @@ -259,19 +259,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-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-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..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 @@ -51,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`. */ @@ -218,6 +220,7 @@ async function runAttachUnderAbort( const { startupAttempts } = context.runtimeState try { const attached = await performAttach({ + 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-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..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 @@ -35,6 +35,8 @@ import type { AgentSessionExecutionOptions } from '../../../shared/agent-session 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 } @@ -107,6 +109,7 @@ async function foundConversation( const request = reserveRequestFor({ sessionId, params, + 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-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index c103bf2e4fbd..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,6 +87,7 @@ export type StructuredAgentSessionDeliveryLoopDeps = { stopping: (sessionId: string) => boolean now: () => number acquireAborts: Pick + recordRanOptions?: StructuredAgentSessionRanRecorder } type Prepared = @@ -312,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) } @@ -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), + 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 eefe8711a197..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,6 +36,8 @@ export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverC acquireAborts?: Pick deliveryOrder?: StructuredAgentSessionDeliveryOrder workChanged?: () => void + /** 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 @@ -130,7 +133,15 @@ 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.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 dba7c8ca96a2..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 @@ -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 { recordStructuredAgentSessionRanOptions } from './structured-agent-session-ran-options' 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, + recordRanOptions: (sessionId, fence) => (handed) => + recordStructuredAgentSessionRanOptions( + { deps, sessions, now: () => deps.now?.() ?? Date.now() }, + sessionId, + fence, + handed + ) }) 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..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,6 +49,45 @@ 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 and effort a chat with none saved first ran, after its config moves', async () => { + await rig.dispose() + 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).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).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 () => { await pickModel('model-b') rig.dispatch.mockRejectedValueOnce( @@ -112,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 @@ -138,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 @@ -151,5 +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)) - expect(rig.dispatch.mock.calls[2]?.[0].executionOptions).toEqual({}) + 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..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 @@ -2,24 +2,34 @@ 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' +import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' 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: StructuredAgentSessionProviderChild ): boolean { + if (record?.provider !== 'codex') { + return false + } const body = journal.itemBody(agentJournalSubmissionKey(next.clientMessageId)) if (body?.kind !== 'message' || body.command) { return false @@ -34,5 +44,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-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-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 new file mode 100644 index 000000000000..2d48f055f296 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts @@ -0,0 +1,100 @@ +// A pick is kept only when the chat's agent takes it, and is recorded as intent for the next start. + +import { refuse, type AgentSessionOptionResult } from '../../../shared/agent-session-wire' +import { + AGENT_CHAT_PERMISSION_MODE_OPTION_ID, + agentChatPermissionModes +} from '../../../shared/agent-chat-permission-mode' +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 { + savedAgentSessionCatalogModels, + structuredAgentSessionPickReply +} from './structured-agent-session-pick-reply' +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( + agents: Pick, + provider: string, + key: string, + value: string +): boolean { + const definition = agents.definition(provider) + return key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID + ? definition !== null && + agentChatPermissionModes(provider)?.modes.some((mode) => mode === value) === true + : definition?.restingOptions.acceptsKey(key) === true +} + +/** Initial explicit picks follow the same rules as a pick made while the chat is at rest. */ +export function structuredAgentSessionOptionOverridesRefusal( + agents: Pick, + provider: string, + options: Readonly> +) { + const key = Object.keys(options).find( + (key) => !acceptsOptionPick(agents, provider, key, options[key] ?? '') + ) + return key === undefined + ? null + : refuse( + 'agent_session_operation_invalid', + { reason: 'optionRejected' }, + `${provider} has no session option named ${key}` + ) +} + +/** 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< + AgentSessionRecordStore, + 'getRecord' | 'replaceSessionOptions' | 'reviseSessionOptions' + > + agents: Pick + } & Parameters[0], + ctx: Pick, + input: { key: string; value: string } +): Promise> { + const { sessionId, fence } = ctx + const record = deps.store.getRecord(sessionId) + if (!record || !acceptsOptionPick(deps.agents, record.provider, 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}` + ) + } + } + const models = await savedAgentSessionCatalogModels(deps, record.provider, sessionId) + 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() } + 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 { + 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 ac6bde0907c5..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 @@ -2,28 +2,58 @@ 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 { AgentModelCatalogStore, 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 { recordAgentSessionRanOptions } from '../../runtime/agent-session-record-options' +import { + claudeAndCodexAgents, + NO_STRUCTURED_AGENTS +} from './structured-agent-session-adapter-router-test-support' 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 { provider: 'codex', 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 } @@ -46,7 +76,7 @@ describe('options at rest', () => { const starting = { child: { phase: 'starting' }, params: { provider: 'codex' }, - journal: { threadGoal: () => 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 = { @@ -64,23 +94,28 @@ 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' }) + // A new chat that has sent nothing runs that default, which its first send pins. + expect(older.current).toEqual({ + model: 'gpt-account', + defaulted: [], + 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 const conversation = () => ({ child: live ? { fence: 7 } : null, params: { provider: 'codex' }, - journal: { threadGoal: () => 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 = { @@ -88,7 +123,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 +138,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)( @@ -125,7 +163,7 @@ describe('options at rest', () => { const resting = { child: null, params: { provider: 'codex' }, - journal: { threadGoal: () => null, context: { floor: () => null } } + journal: restingJournal() } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: options reads touch only these context members. const context = { @@ -151,6 +189,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]) { @@ -177,6 +216,127 @@ 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 fast = [{ value: 'priority', label: 'Fast' }] + const flex = [...fast, { value: 'flex', label: 'Flex' }] + const listed: AgentSessionModelOption[] = [ + { 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, + models = listed + ) { + const record = { ...restingRecord(), ...facts } + const resting = { + child: null, + params: { provider: 'codex' }, + journal: restingJournal(items, wroteBeforeOpen) + } + // 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: catalog(models) + }, + serialize: (_sessionId: string, task: () => Promise) => task(), + openConversation: async () => resting, + conversation: async () => resting + } as unknown as StructuredAgentSessionMutationContext + return readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) + } + + 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], 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.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 chat = picking(options, models) + expect(await chat.pick('model', 'gpt-account')).toMatchObject({ + value: { options: { serviceTier: runs } } + }) + expect(chat.record().options?.serviceTier).toBe('flex') + } + ) + + 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, { + sessionId: SESSION, + fence: 1, + now: 0, + 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' }) + }) +}) + 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() @@ -194,7 +354,7 @@ describe('live Codex option reads', () => { const live = { child, params: { provider: 'codex' }, - journal: { threadGoal: () => null, 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 = { @@ -232,7 +392,7 @@ describe('live Codex option reads', () => { const live = { child: { fence: 7 }, params: { provider: 'codex' }, - journal: { threadGoal: () => null, 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. @@ -270,7 +430,7 @@ describe('live Codex option reads', () => { const live = { child: { fence: 7 }, params: { provider: 'codex' }, - journal: { threadGoal: () => 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 = { 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 add321f39ee1..6e2ce778ee61 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 @@ -3,11 +3,9 @@ // The record keeps the next message's choices; the catalog names known defaults. A provider's // reports remain execution facts and never replace the saved selection. -import { - refuse, - type AgentSessionModelCatalogResult, - type AgentSessionOptionResult, - type AgentSessionOptionsResult +import type { + AgentSessionModelCatalogResult, + AgentSessionOptionsResult } from '../../../shared/agent-session-wire' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { @@ -16,11 +14,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 { - AGENT_CHAT_PERMISSION_MODE_OPTION_ID, - agentChatPermissionModes -} from '../../../shared/agent-chat-permission-mode' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { savedEffectiveServiceTier } from '../../../shared/structured-agent-session-effective-tier' import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentDefinition } from './structured-agent-definition' import type { StructuredAgentRegistry } from './structured-agent-registry' @@ -30,8 +24,13 @@ import { saveStructuredAgentSessionListing } from './structured-agent-session-st import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { structuredAgentSessionOptionModels } from './structured-agent-session-option-models' 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' + +export { + recordStructuredAgentSessionOptionIntent, + structuredAgentSessionOptionOverridesRefusal +} from './structured-agent-session-option-intent' type RestingOptions = Pick< AgentSessionOptionsResult, @@ -46,38 +45,6 @@ export const IN_PROCESS_OPTIONS_READER: StructuredAgentSessionOptionsReader = { readsWithoutModel: true } -/** Whether the agent takes this pick: a permission mode it offers, or an option key it names. */ -function acceptsOptionPick( - agents: Pick, - provider: string, - key: string, - value: string -): boolean { - const definition = agents.definition(provider) - return key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID - ? definition !== null && - agentChatPermissionModes(provider)?.modes.some((mode) => mode === value) === true - : definition?.restingOptions.acceptsKey(key) === true -} - -/** Initial explicit picks follow the same rules as a pick made while the chat is at rest. */ -export function structuredAgentSessionOptionOverridesRefusal( - agents: Pick, - provider: string, - options: Readonly> -) { - const key = Object.keys(options).find( - (key) => !acceptsOptionPick(agents, provider, key, options[key] ?? '') - ) - return key === undefined - ? null - : refuse( - 'agent_session_operation_invalid', - { reason: 'optionRejected' }, - `${provider} has no session option named ${key}` - ) -} - /** The at-rest rules of the record's agent, as this runtime registered it; null for any other. */ function restingOptionRules( agents: Pick, @@ -140,6 +107,11 @@ 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) + ) + // 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 } : {}), @@ -150,45 +122,12 @@ async function readStructuredAgentSessionOptionsAtRest( ...(model ? { model } : {}), ...(effort ? { effort } : {}), ...(typeof fastMode === 'boolean' ? { fastMode } : {}), - ...(saved.serviceTier ? { serviceTier: saved.serviceTier } : {}) + ...(serviceTier ? { serviceTier } : {}), + defaulted } } } -/** Records a pick for the next start. Only a key the provider would accept is kept. */ -export async function recordStructuredAgentSessionOptionIntent( - deps: { - store: Pick - agents: Pick - }, - 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)) { - return { - ok: false, - refusal: refuse( - 'agent_session_operation_invalid', - { reason: 'optionRejected' }, - `${record?.provider ?? 'This session'} has no session option named ${input.key}` - ) - } - } - const options = { ...record.options, [input.key]: input.value } - // 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)) - ctx.publish() - return { ok: true, value: { ...input, options } } -} - /** A live child's own answer, or the answer at rest; the goal, usage and rewind either way. */ export async function readStructuredAgentSessionOptions( context: Pick< @@ -285,8 +224,22 @@ export async function readStructuredAgentSessionOptions( saved.fastMode !== undefined ? selected.current.fastMode : (options.current.fastMode ?? selected.current.fastMode), - serviceTier: saved.serviceTier ?? options.current.serviceTier ?? selected.current.serviceTier + // The saved tier as the shared speed rule runs it under the saved model. + serviceTier: + saved.serviceTier !== undefined + ? selected.current.serviceTier + : (options.current.serviceTier ?? selected.current.serviceTier) } + // A chat that saved a value has no default for it: the saved value shows, or the child it + // refused runs what it reports. A chat that has sent nothing runs the default it shows, which its + // first send pins. A model the child leaves blank shows the at-rest one, with its marking. + const defaultedBy = (key: 'model' | 'effort') => + (key === 'model' && !options.current.model ? selected : options).current.defaulted + const defaulted = structuredAgentSessionNeverSent(record, session.journal) + ? [] + : (['model', 'effort'] as const).filter( + (key) => saved[key] === undefined && defaultedBy(key)?.includes(key) === true + ) const confirmed = options.current.confirmed?.filter((key) => key === 'model' || key === 'effort' || key === 'fastMode' || key === 'serviceTier' ? current[key] === options.current[key] @@ -296,6 +249,7 @@ export async function readStructuredAgentSessionOptions( ...options, current: { ...current, + defaulted, confirmed, ...(options.current.fastModeState ? { fastModeState: options.current.fastModeState } : {}), ...(confirmed?.length ? { confirmed } : {}), 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..82b9219c24ae --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts @@ -0,0 +1,30 @@ +// 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 { 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' + +/** 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, + workspacePath?: string | null +): Promise { + const scope = workspacePath === undefined ? {} : { workspacePath } + const listing = await deps.modelCatalog + ?.read({ agent: provider, sessionId, savedOnly: true, ...scope }) + .catch(() => null) + 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-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index 00eafb4e3393..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,6 +17,7 @@ import { saveStructuredAgentSessionStartReadbackListing } from './structured-agent-session-start-listing' import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' +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' @@ -87,6 +88,12 @@ export async function settleStructuredAgentSessionProviderStarted( return null } context.runtimeState.startupAttempts.ready(event.sessionId, child) + await noteStructuredAgentSessionReportedOptions( + 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 noteStructuredAgentSessionReportedOptions( + 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..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 @@ -27,6 +27,9 @@ 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 + reportsEffort?: string } export function createQueuedRigProvider( @@ -58,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: 'default' } - }) + proveOpen = () => resolve({ reportedOptions: reported() }) }) - : Promise.resolve({ - reportedOptions: { model: '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-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts new file mode 100644 index 000000000000..93db19c99609 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts @@ -0,0 +1,147 @@ +// 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' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession, + 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 + sessions: ReadonlyMap + now: () => number +} + +type RanOptions = Partial> + +export type StructuredAgentSessionHandedOptions = { + 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?: StructuredAgentSessionHandedOptions } +>() + +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: StructuredAgentSessionProviderChild +): 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 } : {}), + // Only Fast routed on ran; an off state may just be Fast unavailable to this account. + ...(fastMode && fastModeState === 'on' ? { fastMode: 'true' } : {}) + } +} + +/** 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, runs.owed) + } +} + +/** Records, for a chat's child at `fence`, what a message handed over to it ran. */ +export type StructuredAgentSessionRanRecorder = ( + sessionId: string, + fence: number +) => (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, + handed: StructuredAgentSessionHandedOptions +): Promise { + const child = currentChild(context, sessionId, fence) + const { store, logger, agents } = context.deps + const record = store.getRecord(sessionId) + if (!child || !record) { + return + } + const { message, executed } = handed + const reported = childRuns.get(child)?.reported + const model = executed?.model ?? message?.model ?? reported?.model + childRuns.set(child, { ...(reported ? { reported } : {}), ...(model ? {} : { owed: handed }) }) + if (!model) { + return + } + // 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({ ...proposed, model })) { + // Never one 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 + } + const models = await savedAgentSessionCatalogModels(context.deps, record.provider, sessionId) + await store + .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', + sessionId, + error + }) + ) +} diff --git a/src/main/runtime/agent-session-record-options.ts b/src/main/runtime/agent-session-record-options.ts index a99296b53638..e8086b4eb083 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, @@ -18,3 +19,116 @@ export function replaceAgentSessionRecordOptions( ? { ...record, options: { ...replacement.options }, updatedAt: replacement.now } : { ...record, permissionMode: replacement.permissionMode, updatedAt: replacement.now } } + +/** 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 { + // 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( + ([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, + { model, options, models, ...at }: AgentSessionRanOptions +): AgentSessionRecord { + 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: { ...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 849096dfceae..ebfa56c03b88 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -320,6 +320,13 @@ export class AgentSessionRecordStore { replaceSessionOptions = (args: AgentSessionOptionsReplacement): Promise => this.mutate(args.sessionId, (record) => replaceAgentSessionRecordOptions(record, args)) + /** 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( 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 () => { 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..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,7 +71,8 @@ describe('the create the seed options land in', () => { runtime: { resolveStructuredAgentSessionCreateIntent: async () => settingsResolved } as never, - ensureHost: async () => ({}) 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', 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 } 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') 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/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..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 @@ -153,11 +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, launchDirectory: workspacePath, location: { executionHostId: 'local', @@ -197,7 +199,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, + visitItems: () => {}, + 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 = { @@ -246,7 +254,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) } } @@ -263,7 +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, ...modeOf(agent) }) + expect(atRest.current).toEqual({ model, effort, defaulted: [], ...modeOf(agent) }) } ) @@ -280,7 +288,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 +299,36 @@ 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: [], 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: [], + 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: [], + ...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/main/runtime/structured-agent-session-pi-startup.test.ts b/src/main/runtime/structured-agent-session-pi-startup.test.ts index bf399d16ab42..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,12 @@ 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 and effort 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', + effort: 'medium' + }) ) await stopStructuredAgentSessionRuntime() expect(script.closes()).toBe(1) 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..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 @@ -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 @@ -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 @@ -130,7 +138,7 @@ export function useStructuredAgentSessionOptionState(args: { sameSession && previous.catalogSource !== 'seed' && previous.catalogSource !== null ? { ...previous, - record: cloneNativeChatSessionOptionRecord(previous.record), + record: cloneStructuredAgentSessionOptionRecord(previous.record), pendingId: null } : seeded @@ -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..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 @@ -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>() })) @@ -28,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 @@ -307,7 +310,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 +627,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() }) @@ -639,6 +647,113 @@ describe('useStructuredAgentSessionOptions', () => { }) }) + const efforts = ['low', 'high'].map((value) => ({ value, label: value })) + + describe('a new Claude chat in a fresh Claude home', () => { + // A fresh home has learned no configured model, so the listing doesn't name what runs. + 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') + ) + 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: {}, + 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' }], [{ value: 'priority', label: 'Fast' }]], + current: { model: 'opus', serviceTier: 'flex' }, + reply: {}, + shown: ['serviceTier', 'flex'] + } + ])( + 'a $agent model pick shows what its reply names and keeps what it leaves out, $shown', + async (pick) => { + 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: { model: 'second', ...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 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/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.test.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts index 056d9ece9356..eed479f7922b 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 @@ -241,6 +241,71 @@ describe('the first message owned by 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() 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 53b8d2d46774..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 @@ -10,12 +10,14 @@ 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, + holdStructuredLaunchCreatePicks, 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 { @@ -50,8 +52,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 { @@ -63,9 +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; the held picks themselves stay as the user made them. + const held = Object.fromEntries( + 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] + }) + ) + holdStructuredLaunchCreatePicks(state, selection.held) const options = { - ...selection.held, - ...structuredAgentSessionSendSelection(intent.agent, structuredAgentSessionOptionSnapshot(view)) + ...held, + ...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..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 @@ -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' @@ -242,6 +247,41 @@ describe('picks made while a chat launches', () => { expect(mutations().map((mutation) => mutation.value)).toEqual(['gpt-picked', 'gpt-repicked']) }) + 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', isDefault: false, 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 0b18e0c253ce..f6edaf01440f 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 @@ -28,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 = { @@ -62,23 +65,26 @@ 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) - 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] + 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() @@ -148,12 +154,25 @@ function settleHeldOption( state.callers.onSettled() } -/** Selections included in the durable create are already the host's option intent. */ +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 { + createHeld.set(state, held) +} + +/** 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/agent-session-options-result.ts b/src/shared/agent-session-options-result.ts index 1064d95daf59..5c42a90cfed6 100644 --- a/src/shared/agent-session-options-result.ts +++ b/src/shared/agent-session-options-result.ts @@ -50,5 +50,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-effective-tier.ts b/src/shared/structured-agent-session-effective-tier.ts new file mode 100644 index 000000000000..1d529c31eb6a --- /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; one no model lists is the agent's configured tier. */ +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: true + }) + ) +} 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..0dce7a95ac06 --- /dev/null +++ b/src/shared/structured-agent-session-option-snapshot.ts @@ -0,0 +1,226 @@ +// 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 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, + 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 + } +} + +/** 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 +} + +const tierChoices = (option: CatalogOption | undefined): string[] => + option?.id === 'serviceTier' && option.kind.type === 'select' + ? option.kind.choices.map((choice) => choice.value) + : [] + +/** 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>, + olderHost: boolean +): Readonly> { + const tier = values.serviceTier?.value + const control = controls.find((option) => option.id === 'serviceTier') + if (typeof tier !== 'string' || !control || (olderHost && tier === 'flex')) { + return values + } + 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. */ +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, state.olderHost === 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, + 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[] { + 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 (!drawn) { + return [PROVIDER_DEFAULT_MODEL_PLACEHOLDER] + } + const { catalog, models, shown, controlsModelId, isCliDefault, controls, values } = drawn + const { record } = state + const snapshot = + models.length === 0 + ? [] + : nativeChatSessionOptionDescriptors({ + catalog, + models, + mode: 'live', + modelLabel: 'Model', + liveTransport: 'agent-session', + model: shown, + controls, + 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..ebee6f88400f 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', () => { @@ -123,3 +120,50 @@ 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("names a resumed chat's defaults but never sends them, only its picks", () => { + const state = resumed(['model', 'effort']) + 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' }) + }) + + 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(shown(state, undefined, { effort: 'xhigh' })).toEqual({ model: 'opus', 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 e1c350d72bd0..f97762156e5c 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,26 @@ 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`. + * 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( - agent: string, - snapshot: readonly SessionOptionDescriptor[] + view: StructuredAgentSessionOptionState ): Record | undefined { - if (agent !== 'claude') { + if (view.record.agent !== 'claude') { return undefined } + const snapshot = + view.catalog?.defaultModelIsCliDefault === true && !view.defaultsUnsent + ? 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 = shown('model') - const effort = shown('effort') - return model ? { model, ...(effort ? { effort } : {}) } : 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 661ba395be79..cbdc035fd637 100644 --- a/src/shared/structured-agent-session-options.test.ts +++ b/src/shared/structured-agent-session-options.test.ts @@ -6,6 +6,8 @@ import { createNativeChatSessionOptionRecord } from './native-chat-session-optio import { applyStructuredAgentSessionModelCatalog, applyStructuredAgentSessionOptions, + canSetStructuredAgentSessionOption, + commitStructuredAgentSessionOption, createStructuredAgentSessionOptionState, settleStructuredAgentSessionBuiltinCatalog, structuredAgentSessionOptionSnapshot, @@ -33,7 +35,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 +192,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 +293,39 @@ 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', () => { + 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 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) }) }) diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index 1a619ff348f7..ed5ae5720d11 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -1,26 +1,20 @@ 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 { + structuredAgentSessionDescribedModel, + structuredAgentSessionShownModel +} from './structured-agent-session-option-snapshot' +import type { SessionOptionValue } from './native-chat-session-options' import type { AgentSessionModelCatalogResult, AgentSessionOptionsResult @@ -30,6 +24,12 @@ 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 @@ -56,7 +56,12 @@ 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 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 @@ -71,7 +76,7 @@ export function createStructuredAgentSessionOptionState( return { catalog: seedCatalog ?? null, catalogSource: seedCatalog ? 'seed' : null, - record: createNativeChatSessionOptionRecord(agent), + record: createStructuredAgentSessionOptionRecord(agent), permissionMode: null, pendingId: null } @@ -153,101 +158,47 @@ 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 { - 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), 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 } } -/** 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,25 +212,34 @@ 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 } } +/** 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, + id: string, + value: SessionOptionValue +): boolean { + 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) + return option?.kind.type === 'select' + ? option.kind.choices.some((choice) => choice.value === value) + : option !== undefined +} + export function commitStructuredAgentSessionOptionValues( state: StructuredAgentSessionOptionState, values: Readonly> @@ -325,7 +285,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) { 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, + visitItems: () => {}, + submissions: () => [], + contextUsage: () => null, + context: { floor: () => null } + } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. const context = {