diff --git a/config/scripts/pr-code-change-scope-released-phone.test.mjs b/config/scripts/pr-code-change-scope-released-phone.test.mjs new file mode 100644 index 00000000000..87f8579e4e1 --- /dev/null +++ b/config/scripts/pr-code-change-scope-released-phone.test.mjs @@ -0,0 +1,19 @@ +import { expect, it } from 'vitest' +import { classifyPrJobs } from './pr-code-change-scope.mjs' + +it('runs the released phone contract for its current discovery dependencies', () => { + for (const file of [ + 'src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts', + 'src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts', + 'src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts', + 'src/main/native-chat/agent-model-catalog/agent-model-catalog-lister.ts', + 'src/main/native-chat/agent-model-catalog/agent-model-catalog-listing-waiters.ts', + 'src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.ts', + 'src/main/claude/claude-model-catalog-probe.ts' + ]) { + expect(classifyPrJobs([file])['cross-version-wire'], file).toBe(true) + } + expect( + classifyPrJobs(['mobile/src/transport/rpc-client-request-tracker.ts'])['cross-version-wire'] + ).toBe(false) +}) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 0e6076bb167..d3e49daad6d 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -230,6 +230,14 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/main/ai-vault/structured-session-ownership.ts', 'src/main/native-chat/agent-session-journal/', 'src/main/native-chat/agent-session-wire/', + // The released phone contract runs the current discovery owner and Claude producer. + 'src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts', + 'src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts', + 'src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts', + 'src/main/native-chat/agent-model-catalog/agent-model-catalog-lister.ts', + 'src/main/native-chat/agent-model-catalog/agent-model-catalog-listing-waiters.ts', + 'src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.ts', + 'src/main/claude/claude-model-catalog-probe.ts', 'src/main/runtime/agent-session-record-store', 'src/main/runtime/agent-session-recovery-capsule', 'src/shared/agent-session-resume-marker', diff --git a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx index 3af115dd13a..3a89171b95b 100644 --- a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx +++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from 'react' import { ActivityIndicator, Keyboard, Pressable, StyleSheet, Text, View } from 'react-native' import { ChevronLeft, @@ -102,6 +103,14 @@ export function MobileNativeChatSessionOptionPickers({ onDrawerOpen, onDrawerAfterClose }: MobileNativeChatSessionOptionPickersProps & PresentationProps): React.JSX.Element | null { + const open = presentation?.page != null + const wasOpen = useRef(false) + useEffect(() => { + if (open && !wasOpen.current) { + controller.optionSurface?.demandModelCatalog?.() + } + wasOpen.current = open + }, [open, controller.optionSurface]) const { snapshot, pendingId } = controller const model = snapshot.find((descriptor) => descriptor.category === 'model') const permission = snapshot.find( diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts index a7bddb950ca..6960c995128 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from '../../../src/shared/structured-agent-session-surface-capabilities' import { AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY, AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, @@ -19,6 +20,7 @@ import { structuredAgentSessionHostSupport } from './mobile-structured-agent-ses describe('structuredAgentSessionHostSupport', () => { it('reads each structured-session feature from the host capability list', () => { const none = { + coherentModelCatalog: false, promptCancel: false, questionAnswers: false, queuedMessages: false, @@ -34,6 +36,9 @@ describe('structuredAgentSessionHostSupport', () => { phoneAttachments: false } expect(structuredAgentSessionHostSupport([])).toEqual(none) + expect( + structuredAgentSessionHostSupport([AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY]) + ).toEqual({ ...none, coherentModelCatalog: true }) expect( structuredAgentSessionHostSupport([AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY]) ).toEqual({ ...none, questionAnswers: true }) diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.ts b/mobile/src/session/mobile-structured-agent-session-host-support.ts index 5efe36061b4..bfb88077188 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts @@ -1,3 +1,4 @@ +import { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from '../../../src/shared/structured-agent-session-surface-capabilities' import { AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY, @@ -16,6 +17,7 @@ import { AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY } from '../../../src /** Structured-session features the connected host advertised; null until the status probe answers. */ export type StructuredAgentSessionHostSupport = { + coherentModelCatalog?: boolean promptCancel: boolean questionAnswers: boolean /** Mid-turn sends queue as host-held drafts; an older host keeps today's immediate path. */ @@ -47,6 +49,7 @@ export function structuredAgentSessionHostSupport( capabilities: readonly string[] ): StructuredAgentSessionHostSupport { return { + coherentModelCatalog: capabilities.includes(AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY), promptCancel: capabilities.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY), questionAnswers: capabilities.includes(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY), queuedMessages: capabilities.includes(AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY), 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 782adcad2c5..f4d0655a5a6 100644 --- a/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts +++ b/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts @@ -1,13 +1,13 @@ -import { useEffect, type MutableRefObject } from 'react' +import { useCallback, useEffect, useRef, useState, 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 { StructuredAgentSessionOptionRecord } from '../../../src/shared/structured-agent-session-option-state' -import { - applyStructuredAgentSessionModelCatalog, - settleStructuredAgentSessionBuiltinCatalog, - type StructuredAgentSessionOptionState -} from '../../../src/shared/structured-agent-session-options' +import type { StructuredAgentSessionOptionState } from '../../../src/shared/structured-agent-session-options' import type { RpcClient } from '../transport/rpc-client' +import { + isHostModelListingWaitInFlight, + joinHostModelListingWait +} from '../../../src/shared/host-model-listing-waits' import { callAgentSession } from './mobile-structured-agent-session-rpc' // The host answers a waiting read when its first listing lands, within its own 30s picker wait. @@ -31,13 +31,17 @@ export function useMobileHostModelCatalogUpgrade(args: { worktree?: string fence: number | null optionCatalog: AgentSessionOptionCatalog | null + receiveCatalog: ( + catalog: AgentSessionModelCatalogResult | null, + event: 'demand' | 'progress' | 'settled' + ) => void activeOptionRecordRef: MutableRefObject /** A listing landed after the chat's options were read, which answered without it. */ onListingLanded?: () => void updateOptionState: ( update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState ) => void -}): void { +}): () => void { const { activeOptionRecordRef, agent, @@ -51,6 +55,12 @@ export function useMobileHostModelCatalogUpgrade(args: { updateOptionState, worktree } = args + const receiver = useRef(args.receiveCatalog) + useEffect(() => { + receiver.current = args.receiveCatalog + }, [args.receiveCatalog]) + const [rereads, setRereads] = useState(0) + const recheck = useCallback(() => setRereads((count) => count + 1), []) useEffect(() => { if (!client || !sessionId || !enabled || !agent || !optionCatalog) { return @@ -67,44 +77,62 @@ export function useMobileHostModelCatalogUpgrade(args: { ) : callAgentSession(client, 'agentSession.modelCatalog', params) // A pick made meanwhile stays on the record; a listing only replaces the list it is picked from. - const apply = (catalog: AgentSessionModelCatalogResult): void => { + const apply = ( + catalog: AgentSessionModelCatalogResult, + settled = false, + event: 'demand' | 'progress' = 'demand' + ): void => { if (stale) { return } - updateOptionState((current) => - current.record === activeOptionRecordRef.current - ? applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, { newLaunch }) - : current - ) + receiver.current(catalog, settled ? 'settled' : event) } // A refused or failed read is no list: the built-in one becomes usable, naming nothing. const settleBuiltin = (): void => { if (!stale) { - updateOptionState((current) => - current.record === activeOptionRecordRef.current - ? settleStructuredAgentSessionBuiltinCatalog(current) - : current - ) + receiver.current(null, 'demand') } } - void read(false) - .then((catalog) => { - if (catalog.origin === 'unknown' && catalog.listingInProgress === true && !stale) { - // Usable on the built-in list while the listing runs; it lands in place. - apply(catalog) - return read(true).then((listed) => { - apply(listed) - if (!stale && listed.origin !== 'unknown') { + const key = `${agent}\u0000${sessionId}` + let leave: (() => void) | undefined + const wait = () => { + leave = joinHostModelListingWait( + key, + () => read(true), + (listed) => { + if (stale) { + return + } + if (listed) { + apply(listed, !listed.listingInProgress && listed.origin !== 'unknown', 'progress') + if (listed.origin !== 'unknown' && !listed.listingInProgress) { onListingLanded?.() } - }) - } - apply(catalog) - return undefined - }) - .catch(settleBuiltin) + } else { + settleBuiltin() + } + }, + client + ) + } + if (isHostModelListingWaitInFlight(key, client)) { + wait() + } else { + void read(false) + .then((catalog) => { + if (stale) { + return + } + apply(catalog) + if (catalog.listingInProgress) { + wait() + } + }) + .catch(settleBuiltin) + } return () => { stale = true + leave?.() } }, [ activeOptionRecordRef, @@ -117,6 +145,8 @@ export function useMobileHostModelCatalogUpgrade(args: { optionCatalog, sessionId, updateOptionState, - worktree + worktree, + rereads ]) + return recheck } diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index afe1434c279..28bdae2ef68 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -22,8 +22,6 @@ import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' export type { MobileNativeChatController } from './mobile-native-chat-controller-contract' -const NATIVE_CHAT_STREAM_THROTTLE_MS = 50 - /** Owns mobile native-chat state and teardown outside the already dense session * route. The route remains responsible only for choosing and rendering the view. */ export function useMobileNativeChatController(args: { @@ -142,7 +140,7 @@ export function useMobileNativeChatController(args: { activeChatStructured ? undefined : mobileNativeChatStreamPreview(nativeChatStatus, nativeChatAgentWorking), - NATIVE_CHAT_STREAM_THROTTLE_MS + 50 ) const { permission: legacyNativeChatPermission, @@ -241,6 +239,7 @@ export function useMobileNativeChatController(args: { modelSwitchCommand: activeSessionTab?.agentStatus?.modelSwitchCommand, structured: { optionPickerRequest: structuredNativeChat.optionPickerRequest, + optionSurface: structuredNativeChat.optionSurface, conversationCommands: structuredNativeChat.conversationCommands, snapshot: structuredNativeChat.optionSnapshot, pendingId: structuredNativeChat.pendingOptionId, diff --git a/mobile/src/session/use-mobile-native-chat-session-option-controller.ts b/mobile/src/session/use-mobile-native-chat-session-option-controller.ts index 026f0fa08d6..c7ff56a5774 100644 --- a/mobile/src/session/use-mobile-native-chat-session-option-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-session-option-controller.ts @@ -4,7 +4,8 @@ import type { AgentSessionConversationCommand } from '../../../src/shared/agent- import { useCallback, useMemo } from 'react' import type { SessionOptionDescriptor, - SessionOptionValue + SessionOptionValue, + SessionOptionsSurface } from '../../../src/shared/native-chat-session-options' import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' @@ -26,6 +27,7 @@ export function useMobileNativeChatSessionOptionController(args: { reportedModel: string | null modelSwitchCommand?: string structured: { + optionSurface?: SessionOptionsSurface conversationCommands?: readonly AgentSessionConversationCommand[] optionPickerRequest?: { id: string; sequence: number } | null snapshot: SessionOptionDescriptor[] @@ -87,6 +89,7 @@ export function useMobileNativeChatSessionOptionController(args: { ? { snapshot: structuredSnapshot, optionPickerRequest: structured.optionPickerRequest, + optionSurface: structured.optionSurface, conversationCommands: structured.conversationCommands, pendingId: structuredPendingId, setOption: setStructuredOption, @@ -101,7 +104,8 @@ export function useMobileNativeChatSessionOptionController(args: { structuredPendingId, structuredSnapshot, structured.conversationCommands, - structured.optionPickerRequest + structured.optionPickerRequest, + structured.optionSurface ] ) const nativeChatSessionOptions = useMemo( diff --git a/mobile/src/session/use-mobile-native-chat-session-options.ts b/mobile/src/session/use-mobile-native-chat-session-options.ts index 8054e16dab3..d57c4c06976 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.ts @@ -9,6 +9,7 @@ import { type CatalogModel } from '../../../src/shared/agent-session-option-catalog' import type { + SessionOptionsSurface, SessionOptionDescriptor, SessionOptionValue } from '../../../src/shared/native-chat-session-options' @@ -31,6 +32,7 @@ import { } from '../../../src/shared/native-chat-session-option-state' export type MobileNativeChatSessionOptionsController = { + optionSurface?: SessionOptionsSurface conversationCommands?: readonly AgentSessionConversationCommand[] optionPickerRequest?: { id: string; sequence: number } | null /** Model descriptor first, then the current model's options; empty when the diff --git a/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts b/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts index 16a194cecd3..190f1bedffb 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts @@ -1,10 +1,12 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionModelCatalogResult } from '../../../src/shared/agent-session-wire' +import type { + AgentSessionModelCatalogResult, + AgentSessionOptionsResult +} from '../../../src/shared/agent-session-wire' import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options' import type { RpcClient } from '../transport/rpc-client' -import type { StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc' import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' const HOST_LIST: AgentSessionModelCatalogResult = { @@ -14,12 +16,17 @@ const HOST_LIST: AgentSessionModelCatalogResult = { } /** Catalog reads take each scripted answer in turn; option reads never answer. */ -function client(catalog: () => Promise): RpcClient { +function client( + catalog: () => Promise, + options?: () => Promise +): RpcClient { return { sendRequest: async (method: string) => method === 'agentSession.modelCatalog' ? { id: 'rpc-1', ok: true as const, result: await catalog(), _meta: { runtimeId: 'r' } } - : new Promise(() => {}), + : method === 'agentSession.options' && options + ? { id: 'rpc-2', ok: true as const, result: await options(), _meta: { runtimeId: 'r' } } + : new Promise(() => {}), subscribe: () => () => {}, updateTerminalSubscriptionViewport: () => {}, getState: () => 'connected', @@ -40,7 +47,10 @@ type Props = { rpc: RpcClient; fence: number } async function mount(initial: Props) { const frames: string[][] = [] - const mutate: StructuredAgentSessionMutate = vi.fn() + const effortFrames: (boolean | undefined)[] = [] + let snapshot: readonly SessionOptionDescriptor[] = [] + const mutate = vi.fn().mockResolvedValue({ status: 'rejected' }) + let latest!: ReturnType function Probe(props: Props): null { const options = useMobileStructuredAgentOptions({ agent: 'codex', @@ -49,9 +59,12 @@ async function mount(initial: Props) { enabled: true, fence: props.fence, mutate, - hostSupport: { permissionModes: true } + hostSupport: { permissionModes: true, coherentModelCatalog: true } }) + latest = options frames.push(modelChoices(options.optionSnapshot)) + effortFrames.push(options.optionSnapshot.find((row) => row.id === 'effort')?.settable) + snapshot = options.optionSnapshot return null } let renderer: ReactTestRenderer | null = null @@ -60,6 +73,10 @@ async function mount(initial: Props) { }) return { frames, + effortFrames, + snapshot: () => snapshot, + options: () => latest, + mutate, update: (next: Props) => act(async () => { renderer?.update(createElement(Probe, next)) @@ -100,4 +117,24 @@ describe('the phone picker’s first frame', () => { expect(harness.frames.at(-1)?.length).toBeGreaterThan(0) await harness.unmount() }) + + it('replaces installed controls while preserving the saved effort', async () => { + const child: AgentSessionOptionsResult = { + models: HOST_LIST.models, + current: { model: 'gpt-host', effort: 'high', permissionMode: 'ask', defaulted: [] } + } + let raw = HOST_LIST + const rpc = client( + () => Promise.resolve(raw), + () => Promise.resolve(child) + ) + const harness = await mount({ rpc, fence: 1 }) + raw = { ...HOST_LIST, models: [] } + await act(async () => harness.options().optionSurface.demandModelCatalog?.()) + expect(harness.snapshot().find((row) => row.id === 'effort')).toMatchObject({ + settable: false, + kind: { currentValue: 'high' } + }) + 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 18d767fdd72..d5251c4c78d 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -1,20 +1,16 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { AgentSessionConversationCommand } from '../../../src/shared/agent-session-conversation-command' +import { useMobileStructuredOptionState } from './use-mobile-structured-option-state' +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { isAgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle' import { structuredAgentSessionSeedCatalog } from '../../../src/shared/structured-agent-session-seed-catalog' -import type { - AgentSessionOptionResult, - AgentSessionOptionsResult -} from '../../../src/shared/agent-session-wire' +import type { AgentSessionOptionResult } from '../../../src/shared/agent-session-wire' import type { SessionOptionValue } from '../../../src/shared/native-chat-session-options' import { - applyStructuredAgentSessionOptions, canSetStructuredAgentSessionOption, commitStructuredAgentSessionOption, commitStructuredAgentSessionOptionValues, - createStructuredAgentSessionOptionState, - structuredAgentSessionOptionPicks, - type StructuredAgentSessionOptionState + lockedStructuredAgentSessionOptionSnapshot, + structuredAgentSessionOptionPicks } from '../../../src/shared/structured-agent-session-options' import { structuredAgentSessionOptionView, @@ -28,16 +24,10 @@ import { import { legacyHostPermissionDescriptor } from '../../../src/shared/structured-agent-session-permission-option' import type { RpcClient } from '../transport/rpc-client' import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' -import { - callAgentSession, - type StructuredAgentSessionMutate -} from './mobile-structured-agent-session-rpc' +import type { StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc' import { persistMobileStructuredOptionPicks } from './mobile-native-chat-session-option-persistence' import { useMobileHostModelCatalogUpgrade } from './use-mobile-host-model-catalog-upgrade' -import { - forgetMobileCreatedStructuredSession, - mobileCreatedStructuredSession -} from './mobile-created-structured-sessions' +import { mobileCreatedStructuredSession } from './mobile-created-structured-sessions' import { encodeStructuredAgentSessionOptionValue } from '../../../src/shared/structured-agent-session-option-codec' import { useMobileStructuredOptionSurface } from './use-mobile-structured-option-surface' @@ -53,7 +43,10 @@ export function useMobileStructuredAgentOptions(args: { /** Moves when the host's status says a session option changed; each move re-reads them. */ optionStamp?: string /** What the host advertised; null until its status probe answers. */ - hostSupport: Pick | null + hostSupport: Pick< + StructuredAgentSessionHostSupport, + 'permissionModes' | 'coherentModelCatalog' + > | null }) { const { agent, client, enabled, fence, mutate, optionStamp, sessionId, hostSupport } = args // Every agent's seed, as on the desktop: a built-in list or the provider-default pill. @@ -61,62 +54,38 @@ export function useMobileStructuredAgentOptions(args: { () => (agent ? structuredAgentSessionSeedCatalog(agent) : null), [agent] ) - const [optionState, setOptionState] = useState(() => - createStructuredAgentSessionOptionState(agent ?? 'codex', optionCatalog) - ) - const optionStateRef = useRef(optionState) - const activeOptionRecordRef = useRef(optionState.record) - const pendingOptionRef = useRef(null) - const optionMutationGeneration = useRef(0) - const updateOptionState = useCallback( - (update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState) => { - const next = update(optionStateRef.current) - optionStateRef.current = next - setOptionState(next) - }, - [] - ) - const [conversationSupport, setConversationSupport] = useState<{ - sessionId: string - commands: readonly AgentSessionConversationCommand[] - } | null>(null) - const [imageSupport, setImageSupport] = useState<{ - sessionId: string - acceptsImages: boolean | undefined - /** The answer named the chat's permission mode, as only a host applying modes does. */ - statesPermissionMode: boolean - } | null>(null) - - const optionIdentityRef = useRef(`${agent}:${sessionId}`) - useEffect(() => { - const identity = `${agent}:${sessionId}` - const sameSession = optionIdentityRef.current === identity - optionIdentityRef.current = identity - const previous = optionStateRef.current - const seeded = createStructuredAgentSessionOptionState(agent ?? 'codex', optionCatalog) - // A host answer is the account's, not the fence's: keep it rather than fall back to the placeholder. - const kept = - sameSession && (previous.catalogSource === 'host' || previous.catalogSource === 'builtin') - ? { ...seeded, catalog: previous.catalog, catalogSource: previous.catalogSource } - : seeded - // The chat's own, which a restart for a new mode keeps: the pill never blanks between them. - const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept - optionMutationGeneration.current += 1 - pendingOptionRef.current = null - optionStateRef.current = next - activeOptionRecordRef.current = next.record - setOptionState(next) - }, [agent, enabled, fence, optionCatalog, sessionId]) - // A chat this phone created runs the listed default, as a desktop chat its own view launched does. const createdHere = useMemo( () => (sessionId ? mobileCreatedStructuredSession(sessionId) : undefined), [sessionId] ) - // An options answer made before the account's listing landed listed none of it. - const [listingsLanded, setListingsLanded] = useState(0) - const onListingLanded = useCallback(() => setListingsLanded((count) => count + 1), []) - useMobileHostModelCatalogUpgrade({ + const { + optionState, + optionStateRef, + activeOptionRecordRef, + pendingOptionRef, + optionMutationGeneration, + updateOptionState, + conversationSupport, + imageSupport, + awaitingOptions, + writes, + readRef, + receiveCatalog, + isCurrentContext + } = useMobileStructuredOptionState({ + agent, + enabled, + fence, + optionCatalog, + sessionId, + client, + optionStamp, + newLaunch: createdHere !== undefined, + worktree: createdHere?.worktree, + coherentCatalog: hostSupport?.coherentModelCatalog === true + }) + const demandModelCatalog = useMobileHostModelCatalogUpgrade({ agent, client, sessionId, @@ -127,54 +96,9 @@ export function useMobileStructuredAgentOptions(args: { optionCatalog, activeOptionRecordRef, updateOptionState, - onListingLanded + receiveCatalog }) - useEffect(() => { - if (!client || !sessionId || !enabled || !optionCatalog) { - return - } - let stale = false - const readGeneration = optionMutationGeneration.current - void callAgentSession(client, 'agentSession.options', { sessionId }) - .then((result) => { - // This view keeps its latch; a later one may run a model picked here. - forgetMobileCreatedStructuredSession(sessionId) - // Not an option value: a pick made during the read leaves it standing. - if (!stale) { - setImageSupport({ - sessionId, - acceptsImages: result.acceptsImages, - statesPermissionMode: result.current?.permissionMode !== undefined - }) - } - if (!stale && optionMutationGeneration.current === readGeneration) { - setConversationSupport({ sessionId, commands: result.conversationCommands ?? [] }) - updateOptionState((current) => - current.record === activeOptionRecordRef.current - ? applyStructuredAgentSessionOptions(current, optionCatalog, result, { - newLaunch: createdHere !== undefined - }) - : current - ) - } - }) - .catch(() => undefined) - return () => { - stale = true - } - }, [ - client, - createdHere, - enabled, - optionCatalog, - optionStamp, - listingsLanded, - sessionId, - fence, - updateOptionState - ]) - // A host predating per-chat modes (1.4.224 and older) states none: say what its chats run instead. const legacyHostPermissions = agent !== null && @@ -183,16 +107,25 @@ export function useMobileStructuredAgentOptions(args: { imageSupport?.sessionId === sessionId && !imageSupport.statesPermissionMode && optionState.permissionMode === null - const optionSnapshot = useMemo(() => { - const shown = structuredAgentSessionShownSnapshot(optionState) - return legacyHostPermissions ? [...shown, legacyHostPermissionDescriptor()] : shown - }, [legacyHostPermissions, optionState]) + const lockSnapshotRef = useRef( + (snapshot: ReturnType) => snapshot + ) + const lockSnapshot = (snapshot: ReturnType) => + awaitingOptions ? lockedStructuredAgentSessionOptionSnapshot(snapshot) : snapshot + useLayoutEffect(() => { + lockSnapshotRef.current = lockSnapshot + }) + const shown = lockSnapshot(structuredAgentSessionShownSnapshot(optionState)) + const optionSnapshot = legacyHostPermissions + ? [...shown, legacyHostPermissionDescriptor()] + : shown const setStructuredOption = useCallback( async (id: string, value: SessionOptionValue): Promise => { const currentState = optionStateRef.current const encoded = encodeStructuredAgentSessionOptionValue(id, value) if ( + !isCurrentContext() || pendingOptionRef.current !== null || !client || !sessionId || @@ -201,14 +134,17 @@ export function useMobileStructuredAgentOptions(args: { !canSetStructuredAgentSessionOption( structuredAgentSessionOptionView(currentState, undefined, {}), id, - value + value, + lockSnapshotRef.current(structuredAgentSessionShownSnapshot(currentState)) ) ) { return false } const targetRecord = currentState.record + writes.current.count += 1 const mutationGeneration = ++optionMutationGeneration.current const isCurrent = (): boolean => + isCurrentContext() && activeOptionRecordRef.current === targetRecord && optionMutationGeneration.current === mutationGeneration // Record-only: shown at once, so the picker stays usable while the write waits behind a start. @@ -265,23 +201,6 @@ export function useMobileStructuredAgentOptions(args: { } return false } finally { - if (readsBack && isCurrent()) { - void callAgentSession(client, 'agentSession.options', { - sessionId - }) - .then((refreshed) => { - if (isCurrent()) { - updateOptionState((latest) => - latest.record === targetRecord - ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed, { - newLaunch: createdHere !== undefined - }) - : latest - ) - } - }) - .catch(() => undefined) - } if (isCurrent()) { pendingOptionRef.current = null updateOptionState((current) => @@ -290,9 +209,27 @@ export function useMobileStructuredAgentOptions(args: { : current ) } + if (isCurrentContext() && activeOptionRecordRef.current === targetRecord) { + writes.current.count -= 1 + writes.current.readback ||= readsBack && isCurrent() + if (!writes.current.count && writes.current.readback) { + readRef.current?.() + } + } } }, - [agent, client, createdHere, mutate, optionCatalog, sessionId, updateOptionState] + [ + agent, + awaitingOptions, + client, + createdHere, + mutate, + optionCatalog, + writes, + isCurrentContext, + sessionId, + updateOptionState + ] ) // Read, not rendered: what the chat holds when the send goes out. @@ -303,8 +240,10 @@ export function useMobileStructuredAgentOptions(args: { const { optionPickerRequest, invokeStructuredOption, optionSurface } = useMobileStructuredOptionSurface({ optionSnapshot, + demandModelCatalog, setStructuredOption, - optionStateRef + optionStateRef, + lockSnapshot: (snapshot) => lockSnapshotRef.current(snapshot) }) return { diff --git a/mobile/src/session/use-mobile-structured-agent-session.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.test.tsx index 098c73e2c17..9b0a6616157 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session.test.tsx @@ -419,7 +419,12 @@ describe('useMobileStructuredAgentSession', () => { await vi.waitFor(() => expect(hook.permission).not.toBeNull()) await vi.waitFor(() => expect(hook.question).not.toBeNull()) // The seed paints first; the session's own list is what a pick is checked against. - await vi.waitFor(() => expect(listedModelIds(hook.optionSnapshot)).toContain('gpt-slow')) + await vi.waitFor(() => { + expect(listedModelIds(hook.optionSnapshot)).toContain('gpt-slow') + expect(hook.optionSnapshot.find((descriptor) => descriptor.id === 'model')).toMatchObject({ + settable: true + }) + }) expect(hook.permission).toMatchObject({ title: 'Allow Bash?', @@ -713,7 +718,12 @@ describe('useMobileStructuredAgentSession', () => { }) await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) act(() => listener?.(snapshotEvent(3))) - await vi.waitFor(() => expect(listedModelIds(hook!.optionSnapshot)).toContain('gpt-slow')) + await vi.waitFor(() => { + expect(listedModelIds(hook!.optionSnapshot)).toContain('gpt-slow') + expect(hook!.optionSnapshot.find((descriptor) => descriptor.id === 'model')).toMatchObject({ + settable: true + }) + }) sendRequest.mockImplementation(async (method, params) => { if (method === 'agentSession.setOption') { throw markRpcDeliveryUnknown(new Error('Connection closed')) diff --git a/mobile/src/session/use-mobile-structured-option-state.ts b/mobile/src/session/use-mobile-structured-option-state.ts new file mode 100644 index 00000000000..14ca06c2b01 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-option-state.ts @@ -0,0 +1,210 @@ +import type { AgentSessionConversationCommand } from '../../../src/shared/agent-session-conversation-command' +import type { + AgentSessionOptionsResult, + AgentSessionModelCatalogResult +} from '../../../src/shared/agent-session-wire' +import { callAgentSession } from './mobile-structured-agent-session-rpc' +import { forgetMobileCreatedStructuredSession } from './mobile-created-structured-sessions' +import { createCoalescingKeyedRunner } from '../../../src/shared/coalescing-keyed-runner' +import type { RpcClient } from '../transport/rpc-client' +import { useCallback, useEffect, useRef, useState } from 'react' +import type { AgentSessionOptionCatalog } from '../../../src/shared/agent-session-option-catalog' +import { usesInstalledAgentModelCatalog } from '../../../src/shared/agent-model-catalog-ownership' +import { cloneStructuredAgentSessionOptionRecord } from '../../../src/shared/structured-agent-session-option-state' +import { + createStructuredAgentSessionOptionState, + applyStructuredAgentSessionOptions, + applyStructuredAgentSessionModelCatalog, + settleStructuredAgentSessionBuiltinCatalog, + type StructuredAgentSessionOptionState +} from '../../../src/shared/structured-agent-session-options' + +export function useMobileStructuredOptionState(args: { + client: RpcClient | null + optionStamp?: string + newLaunch: boolean + worktree?: string + coherentCatalog?: boolean + agent: string | null + enabled: boolean + fence: number | null + optionCatalog: AgentSessionOptionCatalog | null + sessionId: string | null +}) { + const { + agent, + enabled, + fence, + optionCatalog, + sessionId, + client, + optionStamp, + newLaunch, + worktree, + coherentCatalog: hostCoherentCatalog = false + } = args + const coherentCatalog = usesInstalledAgentModelCatalog(agent ?? '', hostCoherentCatalog) + const [optionState, setOptionState] = useState(() => + createStructuredAgentSessionOptionState(agent ?? 'codex', optionCatalog) + ) + const optionStateRef = useRef(optionState) + const activeOptionRecordRef = useRef(optionState.record) + const pendingOptionRef = useRef(null) + const optionMutationGeneration = useRef(0) + const updateOptionState = useCallback( + (update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState) => { + const next = update(optionStateRef.current) + optionStateRef.current = next + setOptionState(next) + }, + [] + ) + const contextKey = JSON.stringify([agent, sessionId, newLaunch, worktree, fence]) + const contextRef = useRef({ key: contextKey, client }) + useEffect(() => { + contextRef.current = { key: contextKey, client } + }, [contextKey, client]) + const isCurrentContext = useCallback( + () => contextRef.current.key === contextKey && contextRef.current.client === client, + [contextKey, client] + ) + const writes = useRef({ count: 0, readback: false }) + const optionIdentityRef = useRef(`${agent}:${sessionId}`) + useEffect(() => { + const identity = `${agent}:${sessionId}` + const sameSession = optionIdentityRef.current === identity + optionIdentityRef.current = identity + const previous = optionStateRef.current + const seeded = createStructuredAgentSessionOptionState(agent ?? 'codex', optionCatalog) + // A host answer is the account's, not the fence's: keep it rather than fall back to the placeholder. + const kept = + sameSession && previous.catalogSource !== 'seed' && previous.catalogSource !== null + ? { + ...previous, + catalogSource: 'host' as const, + record: cloneStructuredAgentSessionOptionRecord(previous.record), + pendingId: null + } + : seeded + // The chat's own, which a restart for a new mode keeps: the pill never blanks between them. + const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept + writes.current = { count: 0, readback: false } + optionMutationGeneration.current += 1 + pendingOptionRef.current = null + optionStateRef.current = next + activeOptionRecordRef.current = next.record + setOptionState(next) + }, [agent, enabled, fence, optionCatalog, sessionId, contextKey, client]) + + const [conversationSupport, setConversationSupport] = useState<{ + sessionId: string + commands: readonly AgentSessionConversationCommand[] + fence: number | null + } | null>(null) + const [imageSupport, setImageSupport] = useState<{ + sessionId: string + acceptsImages: boolean | undefined + /** The answer named the chat's permission mode, as only a host applying modes does. */ + statesPermissionMode: boolean + } | null>(null) + + const awaitingOptions = + conversationSupport?.sessionId === sessionId && conversationSupport.fence !== fence + + const readRef = useRef<(() => void) | null>(null) + const receiveCatalog = ( + catalog: AgentSessionModelCatalogResult | null, + _event: 'demand' | 'progress' | 'settled' + ) => { + updateOptionState((current) => + catalog && optionCatalog + ? applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, { + newLaunch, + coherentCatalog + }) + : settleStructuredAgentSessionBuiltinCatalog(current, false) + ) + if (!coherentCatalog && catalog && catalog.origin !== 'unknown' && !catalog.listingInProgress) { + readRef.current?.() + } + } + useEffect(() => { + if (!client || !sessionId || !enabled || !optionCatalog) { + return + } + let stale = false + const run = createCoalescingKeyedRunner() + const request = () => { + void run('options', 'latest', async () => { + if (stale || !isCurrentContext()) { + return + } + if (writes.current.count) { + writes.current.readback = true + return + } + writes.current.readback = false + const record = activeOptionRecordRef.current + const readGeneration = optionMutationGeneration.current + const result = await callAgentSession( + client, + 'agentSession.options', + { sessionId } + ) + if (stale || !isCurrentContext() || activeOptionRecordRef.current !== record) { + return + } + // This view keeps its latch; a later one may run a model picked here. + forgetMobileCreatedStructuredSession(sessionId) + setImageSupport({ + sessionId, + acceptsImages: result.acceptsImages, + statesPermissionMode: result.current?.permissionMode !== undefined + }) + if (optionMutationGeneration.current !== readGeneration) { + return + } + setConversationSupport({ sessionId, fence, commands: result.conversationCommands ?? [] }) + updateOptionState((current) => + applyStructuredAgentSessionOptions(current, optionCatalog, result, { + newLaunch, + coherentCatalog + }) + ) + }).catch(() => undefined) + } + readRef.current = request + request() + return () => { + stale = true + readRef.current = null + } + }, [ + client, + newLaunch, + enabled, + optionCatalog, + optionStamp, + isCurrentContext, + coherentCatalog, + sessionId, + fence, + updateOptionState + ]) + + return { + conversationSupport, + imageSupport, + awaitingOptions, + writes, + readRef, + receiveCatalog, + isCurrentContext, + optionState, + optionStateRef, + activeOptionRecordRef, + pendingOptionRef, + optionMutationGeneration, + updateOptionState + } +} diff --git a/mobile/src/session/use-mobile-structured-option-surface.ts b/mobile/src/session/use-mobile-structured-option-surface.ts index 5c60340bc9b..44b4209102a 100644 --- a/mobile/src/session/use-mobile-structured-option-surface.ts +++ b/mobile/src/session/use-mobile-structured-option-surface.ts @@ -9,12 +9,14 @@ import type { /** The picker surface over a structured chat's options: picks, `/`-opened pickers, snapshots. */ export function useMobileStructuredOptionSurface(args: { + demandModelCatalog: () => void + lockSnapshot: (snapshot: SessionOptionDescriptor[]) => SessionOptionDescriptor[] optionSnapshot: SessionOptionDescriptor[] setStructuredOption: (id: string, value: SessionOptionValue) => Promise /** The latest option state, which a settled pick's snapshot is read from. */ optionStateRef: RefObject }) { - const { optionSnapshot, optionStateRef, setStructuredOption } = args + const { optionSnapshot, optionStateRef, setStructuredOption, demandModelCatalog } = args const [optionPickerRequest, setOptionPickerRequest] = useState<{ id: string sequence: number @@ -34,19 +36,22 @@ export function useMobileStructuredOptionSurface(args: { const setOption = useCallback( async (id: string, value: SessionOptionValue) => { await setStructuredOption(id, value) - return { snapshot: structuredAgentSessionShownSnapshot(optionStateRef.current) } + return { + snapshot: args.lockSnapshot(structuredAgentSessionShownSnapshot(optionStateRef.current)) + } }, - [optionStateRef, setStructuredOption] + [args.lockSnapshot, optionStateRef, setStructuredOption] ) const optionSurface = useMemo( () => ({ getSnapshot: () => optionSnapshot, + demandModelCatalog, setOption, invokeAction: async () => ({ snapshot: optionSnapshot }), subscribe: () => () => {} }), - [optionSnapshot, setOption] + [optionSnapshot, setOption, demandModelCatalog] ) return { optionPickerRequest, invokeStructuredOption, optionSurface } diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.ts b/mobile/src/transport/mobile-runtime-client-capabilities.ts index a5b8977a85f..56bc9dd6a28 100644 --- a/mobile/src/transport/mobile-runtime-client-capabilities.ts +++ b/mobile/src/transport/mobile-runtime-client-capabilities.ts @@ -1,3 +1,4 @@ +import { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from '../../../src/shared/structured-agent-session-surface-capabilities' import { AGENT_SESSION_CURRENT_REQUESTS_CLIENT_CAPABILITY } from '../../../src/shared/agent-session-current-requests-capability' import { AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, @@ -20,6 +21,7 @@ import { remoteRuntimeClientCapabilities } from '../../../src/shared/remote-runt import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilities([ + AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, AGENT_SESSION_CURRENT_REQUESTS_CLIENT_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, diff --git a/src/main/acp/acp-structured-host-live-listing.test.ts b/src/main/acp/acp-structured-host-live-listing.test.ts index 6808f129e92..907210341c5 100644 --- a/src/main/acp/acp-structured-host-live-listing.test.ts +++ b/src/main/acp/acp-structured-host-live-listing.test.ts @@ -14,6 +14,7 @@ afterEach(async () => { describe('a live Grok listing', () => { it('is saved through the host catalog when the chat reads its options', async () => { const modelCatalog: AgentModelCatalogService = { + peek: () => null, read: vi.fn(async () => ({ origin: 'unknown' as const })), retainedLiveModelIds: vi.fn(async () => []), recordLiveListing: vi.fn(), @@ -38,6 +39,7 @@ describe('a live Grok listing', () => { it('starts the chat even when saving its start listing throws', async () => { const modelCatalog: AgentModelCatalogService = { + peek: () => null, read: vi.fn(async () => ({ origin: 'unknown' as const })), retainedLiveModelIds: vi.fn(async () => []), recordLiveListing: vi.fn().mockImplementationOnce(() => { diff --git a/src/main/claude/claude-model-catalog-probe.test.ts b/src/main/claude/claude-model-catalog-probe.test.ts index 7fd12f41a77..389425b87ec 100644 --- a/src/main/claude/claude-model-catalog-probe.test.ts +++ b/src/main/claude/claude-model-catalog-probe.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { PROVIDER_STDIN_END_GRACE_MS } from '../provider-process/provider-process-supervisor' import { createClaudeModelCatalogProbe } from './claude-model-catalog-probe' +import { discoveredModel } from '../../shared/structured-agent-session-discovered-model' import { resolveClaudeStructuredInvocation } from './claude-structured-launch-resolution' import { resolveStructuredAgentCommand } from '../native-chat/structured-agent-command-resolution' import { @@ -94,15 +95,16 @@ describe('claude model catalog probe', () => { const [launch, options] = calls[0]! expect(launch).toEqual({ command: invocation.command, - args: [...CLAUDE_MODEL_LIST_ARGS], - stdin: CLAUDE_MODEL_LIST_STDIN + args: [...CLAUDE_MODEL_LIST_ARGS, '--setting-sources', 'user,project,local'], + cwd: process.cwd(), + stdin: expect.stringContaining(CLAUDE_MODEL_LIST_STDIN) }) expect(options.inheritedEnv).toEqual(invocation.env) } ) // The rows `claude` 2.1.280 lists: what a chat naming no model runs, and its auto support. - it('marks the row the CLI default resolves to and keeps each row auto support', async () => { + it('keeps the CLI default and account Fast and per-row auto support', async () => { const listing = `${JSON.stringify({ type: 'control_response', response: { @@ -111,14 +113,24 @@ describe('claude model catalog probe', () => { response: { models: [ { value: 'default', resolvedModel: 'claude-opus-5-5[1m]', supportsAutoMode: true }, - { value: 'opus[1m]', resolvedModel: 'claude-opus-5-5[1m]', supportsAutoMode: true }, + { + value: 'opus[1m]', + resolvedModel: 'claude-opus-5-5[1m]', + supportsAutoMode: true, + supportsFastMode: true + }, { value: 'haiku', resolvedModel: 'claude-haiku-4-5-20251001', supportsAutoMode: null } ] } } })}\n` const probe = createClaudeModelCatalogProbe({ ...probeDeps(), runListing: async () => listing }) - const { models } = await probe(HOME('/homes/account-a')) + const { models, fastModeSupport } = await probe(HOME('/homes/account-a')) + expect(fastModeSupport).toEqual({ supported: true }) + expect(models[0]?.supportsFastMode).toBe(true) + expect( + models[0] && discoveredModel(models[0], fastModeSupport?.supported === true).options + ).toContainEqual(expect.objectContaining({ id: 'fastMode' })) expect( models.map(({ id, isDefault, supportsAutoMode }) => ({ id, isDefault, supportsAutoMode })) ).toEqual([ @@ -179,7 +191,7 @@ let input = '' process.stdin.on('data', (chunk) => (input += chunk)) process.stdin.on('end', () => setTimeout(() => { require('node:fs').writeFileSync(${JSON.stringify(parentFile)}, String(process.ppid)) - const request = JSON.parse(input.trim().split('\\n').at(-1)) + const request = JSON.parse(input.trim().split('\\n').find(line => line.includes('list_models'))) if (request.request.subtype !== 'list_models') process.exit(2) console.log(JSON.stringify({ type: 'control_response', response: { subtype: 'success', request_id: request.request_id, response: { models: [{ value: 'sonnet', displayName: 'Sonnet' }] } } })) @@ -264,3 +276,72 @@ it.each([null, 0, 1])('enterprise retry uses the actual exit code %s', async (ex : expect(result).rejects.toMatchObject({ failedExit: { exitCode } })) expect(runListing).toHaveBeenCalledTimes(exitCode === 1 ? 2 : 1) }) + +it('discovers a configured launch override without chat picks and preserves literal settings', async () => { + const calls: ListingCall[] = [] + const probe = createClaudeModelCatalogProbe({ + ...probeDeps(), + resolveLaunchArgs: () => [ + '--model', + 'sonnet', + '--effort', + 'high', + '--settings', + '/literal/settings.json' + ], + runListing: async (...call) => { + calls.push(call) + return `${LISTED}${JSON.stringify({ + type: 'control_response', + response: { + subtype: 'success', + request_id: 'orca-model-settings', + response: { applied: { model: 'sonnet', effort: 'high' } } + } + })}\n` + } + }) + const resolved = await probe.resolve?.(HOME('/account'), '/project') + expect(await resolved?.run()).toMatchObject({ configured: { model: 'sonnet', effort: 'high' } }) + expect(calls[0]?.[0].args).toEqual( + expect.arrayContaining([ + '--model', + 'sonnet', + '--effort', + 'high', + '--settings', + '/literal/settings.json' + ]) + ) + expect(calls[0]?.[0].args.filter((arg) => arg === '--settings')).toHaveLength(1) + expect( + calls[0]?.[0].stdin + ?.trim() + .split('\n') + .map((line) => JSON.parse(line)) + ).toHaveLength(2) +}) + +it('qualifies only a correlated valid settings observation', async () => { + for (const [applied, requestId, complete] of [ + [{}, 'orca-model-settings', true], + [[], 'orca-model-settings', false], + [{ model: 42 }, 'orca-model-settings', false], + [{}, 'other-request', false], + [undefined, 'orca-model-settings', false] + ] as const) { + const probe = createClaudeModelCatalogProbe({ + ...probeDeps(), + runListing: async () => + LISTED + + JSON.stringify({ + response: { + subtype: 'success', + request_id: requestId, + response: applied === undefined ? {} : { applied } + } + }) + }) + expect((await probe(HOME('/account'))).complete).toBe(complete) + } +}) diff --git a/src/main/claude/claude-model-catalog-probe.ts b/src/main/claude/claude-model-catalog-probe.ts index ff42abdf824..8270ed6559e 100644 --- a/src/main/claude/claude-model-catalog-probe.ts +++ b/src/main/claude/claude-model-catalog-probe.ts @@ -1,18 +1,26 @@ import { buildClaudeModelListArgs, - CLAUDE_MODEL_LIST_STDIN, - parseClaudeModelList + CLAUDE_MODEL_LIST_STDIN } from '../../shared/claude-model-list-probe' import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' import { runClaudeBackgroundAttempts } from '../../shared/claude-background-run-isolation' -import { parseClaudeModels } from '../../shared/commit-message-model-parsers' +import { + isCompleteClaudeModelListing, + listedModels, + record, + type ListedModel +} from './claude-structured-model-catalog' +import { claudeFastModeSupport, wireClaudeModels } from './claude-structured-catalog-listing' +import { readClaudeSettingsApplied } from './claude-structured-session-options' +import { claudeStructuredLaunchArgs } from './claude-structured-launch-args' import { AgentModelCatalogUnavailableError } from '../native-chat/agent-model-catalog/agent-model-catalog-unavailable' import { claudeConfigDirEnvPatch } from './claude-config-dir-pin' import { + CLAUDE_DEFAULT_SETTING_SOURCES, resolveClaudeStructuredInvocation, type ClaudeStructuredLaunchPartDeps } from './claude-structured-launch-resolution' -import type { ClaudeEnvDeps } from './claude-structured-child-env' +import { resolveClaudeChildEnvSources, type ClaudeEnvDeps } from './claude-structured-child-env' import type { AgentModelCatalogProbe, AgentModelCatalogSuccess @@ -24,6 +32,9 @@ import { export type ClaudeModelCatalogProbeDeps = ClaudeEnvDeps & Pick & { + /** Existing captured environment only: saved preference reads never recover a missing CLI. */ + resolveSavedEnvironment?: () => Promise> + resolveLaunchArgs?: () => Promise | string[] /** Test seam; production runs the shared supervised listing. */ runListing?: typeof runAgentModelCatalogListing } @@ -89,48 +100,132 @@ export async function runClaudeListing( export function createClaudeModelCatalogProbe( deps: ClaudeModelCatalogProbeDeps ): AgentModelCatalogProbe { - return async (accountHome, options): Promise => { - const stdout = await runClaudeListing(deps, { - accountHomePath: requireLegacyAgentSessionAccountHome(accountHome).path, - stdin: CLAUDE_MODEL_LIST_STDIN, - site: 'claude-model-catalog-probe', - ...(options?.signal ? { signal: options.signal } : {}) - }).catch((error: unknown) => { - // Claude has no pre-send sign-in verdict; a missing CLI is the one this probe can find. - if (error instanceof AgentModelCatalogProbeError && error.executableMissing) { - throw new AgentModelCatalogUnavailableError({ reason: 'cliMissing' }) - } - throw error - }) - // A CLI that predates the request answers a control error: no models, so no catalog. - const listed = parseClaudeModels(stdout) - if (listed.length === 0) { - throw new Error('claude listed no models') + const resolve = async ( + accountHome: Parameters[0], + cwd?: string, + savedOnly = false + ) => { + const configuredArgs = claudeStructuredLaunchArgs( + cwd ? ((await deps.resolveLaunchArgs?.()) ?? []) : [] + ) + const userArgs = [ + ...Object.entries(configuredArgs.extraArgs).flatMap(([key, value]) => + value === null ? [`--${key}`] : [`--${key}`, value] + ), + ...configuredArgs.additionalDirectories.flatMap((directory) => ['--add-dir', directory]) + ] + const argsFor = (strictMcp: boolean) => [ + ...buildClaudeModelListArgs(strictMcp, userArgs), + ...(cwd ? ['--setting-sources', CLAUDE_DEFAULT_SETTING_SOURCES.join(',')] : []), + ...userArgs + ] + const args = argsFor(true) + const launchDeps = { + ...deps, + ...(savedOnly + ? { + resolveInheritedEnv: deps.resolveSavedEnvironment, + refreshEnvironmentAfterMiss: undefined + } + : {}), + ...(cwd ? { resolveWorkingDirectory: () => cwd } : {}) } - // Only the catalog marks the CLI default; the commit-message parse keeps its own default. - const runsDefault = new Set( - parseClaudeModelList(stdout).flatMap((model) => (model.isDefault ? [model.id] : [])) + const sources = await resolveClaudeChildEnvSources(launchDeps) + const { command, env } = await resolveClaudeStructuredInvocation( + launchDeps, + (base) => ({ + ...base, + ...claudeConfigDirEnvPatch(requireLegacyAgentSessionAccountHome(accountHome).path, { + env: base + }) + }), + sources ) return { - models: listed.map((model) => ({ - id: model.id, - label: model.label, - ...(model.description ? { description: model.description } : {}), - isDefault: runsDefault.has(model.id), - // No defaultEffort: the parser's thinking default is the commit-message generator's - // choice, not the effort Claude runs; the store keeps the one a live child reported. - efforts: (model.thinkingLevels ?? []).map((level) => ({ - value: level.id, - label: level.label - })), - ...(model.supportsFastMode !== undefined - ? { supportsFastMode: model.supportsFastMode } - : {}), - ...(model.supportsAutoMode !== undefined - ? { supportsAutoMode: model.supportsAutoMode } - : {}) - })), - origin: 'probe' + identity: { command, args, cwd, overlay: Object.entries(sources.overlay ?? {}).sort() }, + run: async (signal?: AbortSignal): Promise => { + const outcome = await runClaudeBackgroundAttempts({ + agentId: 'claude', + run: (strictMcp) => + (deps.runListing ?? runAgentModelCatalogListing)( + { + command, + args: argsFor(strictMcp), + ...(cwd ? { cwd } : {}), + stdin: `${CLAUDE_MODEL_LIST_STDIN}${JSON.stringify({ type: 'control_request', request_id: 'orca-model-settings', request: { subtype: 'get_settings' } })}\n` + }, + { site: 'claude-model-catalog-probe', inheritedEnv: env, signal } + ).catch((error: unknown) => { + if (error instanceof AgentModelCatalogProbeError) { + return error + } + throw error + }), + failedExit: (outcome) => + outcome instanceof AgentModelCatalogProbeError && outcome.reason === 'exit' + ? outcome.failedExit + : undefined + }) + if (outcome instanceof AgentModelCatalogProbeError) { + if (outcome.executableMissing) { + throw new AgentModelCatalogUnavailableError({ reason: 'cliMissing' }) + } + throw outcome + } + const stdout = outcome + let models: ListedModel[] = [] + let complete = false + let configured: AgentModelCatalogSuccess['configured'] + for (const line of stdout.split(/\r?\n/)) { + try { + const response = record(record(JSON.parse(line))?.response) + if (response?.subtype !== 'success') { + continue + } + const body = response.response + const listed = response.request_id === 'orca-model-discovery' ? listedModels(body) : [] + if (listed.length > 0) { + models = listed + complete = isCompleteClaudeModelListing(body) + } + if (response.request_id === 'orca-model-settings') { + const applied = record(record(body)?.applied) + if ( + applied && + !Array.isArray(applied) && + ['model', 'effort'].every( + (key) => applied[key] == null || typeof applied[key] === 'string' + ) + ) { + configured = readClaudeSettingsApplied(body) ?? undefined + } + } + } catch { + /* Non-JSON diagnostic output carries no catalog facts. */ + } + } + if (models.length === 0) { + throw new Error('claude listed no models') + } + const fastModeSupport = claudeFastModeSupport(models, undefined) + return { + models: wireClaudeModels(models), + origin: 'probe', + complete: complete && configured !== undefined, + ...(fastModeSupport ? { fastModeSupport } : {}), + ...(configured ? { configured } : {}) + } + } } } + return Object.assign< + AgentModelCatalogProbe, + Pick + >( + async (home, options) => + (await resolve(home, options?.cwd ?? options?.workspacePath ?? process.cwd())).run( + options?.signal + ), + { resolve, resolveLegacy: (home) => resolve(home) } + ) } diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 746ded29127..10669854294 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -17,8 +17,6 @@ import { openAgentSessionJournal } from '../native-chat/agent-session-journal/jo import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { claudeAuthDiagnostic } from './claude-structured-init-proof' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' -import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' -import type { ClaudeSession } from './claude-structured-session-state' import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' @@ -537,49 +535,6 @@ describe('Claude stream-json connection', () => { ]) }) - it('serves the picker the live catalog rather than falling back to the static seed', async () => { - const scenario = scriptScenario([HOLD_OPEN], { - initialize: { - models: [ - { value: 'default', resolvedModel: 'claude-opus-5' }, - { - value: 'opus', - displayName: 'Opus 5', - description: 'The live row, not the seed', - resolvedModel: 'claude-opus-5', - supportsEffort: true, - supportedEffortLevels: ['low', 'high'] - } - ] - } - }) - const connection = await open(launchFor(scenario)) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture supplies every session member the option paths under test read. - const session = { - connection, - options: new Map(), - appliedInputOptions: new Map(), - reportedOptions: {}, - startup: { state: 'proven' } - } as unknown as ClaudeSession - - const options = await readClaudeStructuredSessionOptions(session, 5_000) - - // The seed carries neither this description nor a two-level effort list, so - // both can only have come from the child. - expect(options.models).toContainEqual({ - id: 'opus', - label: 'Opus 5', - description: 'The live row, not the seed', - isDefault: true, - efforts: [ - { value: 'low', label: 'Low' }, - { value: 'high', label: 'High' } - ] - }) - expect(options.current.model).toBe('opus') - }) - it('feeds the auth diagnostic from the settings the running child reports', async () => { for (const key of ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY']) { vi.stubEnv(key, undefined) diff --git a/src/main/claude/claude-structured-applied-options.test.ts b/src/main/claude/claude-structured-applied-options.test.ts index bb762ec8d29..fec18dc2ec7 100644 --- a/src/main/claude/claude-structured-applied-options.test.ts +++ b/src/main/claude/claude-structured-applied-options.test.ts @@ -80,10 +80,10 @@ async function readCurrent(settings: unknown) { describe('Claude model before the first turn', () => { it.each([ - ['nothing overrides the default', SETTINGS.noOverride, 'opus[1m]', 'medium'], - ['settings name a model', SETTINGS.settingsHaiku, 'haiku', undefined], - ['the env names a model', SETTINGS.envSonnet, 'sonnet', 'high'], - ['the env overrides the settings', SETTINGS.settingsHaikuEnvSonnet, 'sonnet', 'high'] + ['nothing overrides the default', SETTINGS.noOverride, 'claude-opus-5-5[1m]', 'medium'], + ['settings name a model', SETTINGS.settingsHaiku, 'claude-haiku-4-5-20251001', undefined], + ['the env names a model', SETTINGS.envSonnet, 'claude-sonnet-5', 'high'], + ['the env overrides the settings', SETTINGS.settingsHaikuEnvSonnet, 'claude-sonnet-5', 'high'] ])( 'names what Claude will apply when %s, unconfirmed', async (_case, settings, model, effort) => { @@ -97,10 +97,10 @@ describe('Claude model before the first turn', () => { } ) - it('falls back to the listing default only when the CLI reports no applied model', async () => { + it('marks an unknown applied model as defaulted for the host to resolve', async () => { const current = await readCurrent({ effective: {}, sources: [] }) - expect(current.model).toBe('opus[1m]') + expect(current.defaulted).toEqual(['model']) expect(current.effort).toBeUndefined() }) @@ -118,7 +118,7 @@ describe('Claude model before the first turn', () => { }) const { current } = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) - expect(current.model).toBe('sonnet') + expect(current.model).toBe('claude-sonnet-5') expect(current.confirmed).toContain('model') }) diff --git a/src/main/claude/claude-structured-catalog-listing.ts b/src/main/claude/claude-structured-catalog-listing.ts index c529ff92059..8ba32c65250 100644 --- a/src/main/claude/claude-structured-catalog-listing.ts +++ b/src/main/claude/claude-structured-catalog-listing.ts @@ -4,12 +4,7 @@ import type { AgentSessionFastModeSupport, AgentSessionOptionsResult } from '../../shared/agent-session-wire' -import type { - AgentModelCatalogConfiguredChoice, - AgentModelCatalogLiveListing -} from '../native-chat/agent-model-catalog/agent-model-catalog-entry' import type { ListedModel } from './claude-structured-model-catalog' -import type { ClaudeSession } from './claude-structured-session-state' const TRANSIENT_FAST_MODE_REASONS = new Set(['network_error', 'unknown', 'pending']) const NON_BLOCKING_FAST_MODE_REASONS = new Set(['preference', 'sdk_opt_in_required']) @@ -37,6 +32,8 @@ export type WireClaudeModel = AgentSessionOptionsResult['models'][number] function wireClaudeModel(entry: ListedModel): WireClaudeModel { return { id: entry.id, + ...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}), + ...(entry.effortsKnown === false ? { effortsKnown: false as const } : {}), label: entry.label, ...(entry.description ? { description: entry.description } : {}), isDefault: entry.isDefault, @@ -49,66 +46,3 @@ function wireClaudeModel(entry: ListedModel): WireClaudeModel { export function wireClaudeModels(models: readonly ListedModel[]): WireClaudeModel[] { return models.map(wireClaudeModel) } - -/** The account-level facts of a provider listing, for the host to save: this session's disabled - * reason and its unlisted current model stay out, so another surface never inherits session state - * as a catalog. A child launched with `--model X` lists X itself (Claude Code 2.1.280 adds a row - * named by the raw id, "Custom model"), whether or not X exists; native rows carry a display name - * of their own, so a row named by its id is the launch talking, not the account. */ -export function claudeCatalogListing( - session: ClaudeSession, - discovered: ListedModel[], - /** `withConfiguredDefault`: only the start's settings readback says what the config resolved; - * every later read omits it. */ - options: { withConfiguredDefault?: boolean } = {} -): AgentModelCatalogLiveListing | undefined { - if (discovered.length === 0) { - return undefined - } - const launched = session.launchedModel - const launchOnly = - launched !== null && discovered.some((row) => row.id === launched && row.label === row.id) - const support = claudeFastModeSupport(discovered, undefined) - const configured = options.withConfiguredDefault - ? configuredClaudeDefault(session, discovered) - : undefined - return { - // No default effort here: the CLI's effort is a config fact, saved with the configured model. - models: wireClaudeModels(discovered), - ...(support ? { fastModeSupport: support } : {}), - ...(launchOnly ? { launchOnlyModelId: launched } : {}), - ...(configured !== undefined ? { configuredDefault: configured } : {}) - } -} - -/** With no model sent at launch or since, the model and effort the CLI says it applies are its own - * resolution of env over settings over its default: the configured default for this session's - * config scope. Null when that resolution names no listed model; undefined when the session - * picked its own model or never read its settings back. */ -function configuredClaudeDefault( - session: ClaudeSession, - discovered: ListedModel[] -): AgentModelCatalogConfiguredChoice | null | undefined { - const applied = session.appliedOptions - if (session.launchedModel !== null || session.options.has('model') || !applied) { - return undefined - } - const row = applied.model - ? discovered.find( - (entry) => entry.id === applied.model || entry.resolvedModel === applied.model - ) - : undefined - if (!row) { - return null - } - const runs = row.resolvedModel ?? row.id - const sameModelIds = discovered - .filter((entry) => entry !== row && (entry.resolvedModel ?? entry.id) === runs) - .map((entry) => entry.id) - return { - modelId: row.id, - ...(sameModelIds.length > 0 ? { sameModelIds } : {}), - // An effort this session picked is its own and says nothing of the config's. - ...(session.options.has('effort') ? {} : { effort: applied.effort ?? null }) - } -} diff --git a/src/main/claude/claude-structured-configured-default.test.ts b/src/main/claude/claude-structured-configured-default.test.ts deleted file mode 100644 index a003253e408..00000000000 --- a/src/main/claude/claude-structured-configured-default.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -// A new Claude chat names its model and effort on the first frame: a chat launched with nothing -// picked reports the model the CLI's own config resolution applies, and the host keeps that as the -// account's configured default when the chat's workspace has no Claude settings of its own. - -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import { getAgentSessionOptionCatalog } from '../../shared/agent-session-option-catalog' -import { - applyStructuredAgentSessionModelCatalog, - createStructuredAgentSessionOptionState, - structuredAgentSessionOptionSnapshot -} from '../../shared/structured-agent-session-options' -import { createAgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' -import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import { agentModelCatalogFingerprintForRecord } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' -import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' -import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import { - PROVIDER_SESSION_ID, - fakeClaude, - identityFor, - recordingJournalSink, - claudeStartupSettled -} from './claude-structured-session-test-support' - -const SESSION = 'session-1' -const ACCOUNT_HOME = '/accounts/claude' -const EFFORTS = ['low', 'medium', 'high'] - -const CATALOG = [ - { - value: 'default', - resolvedModel: 'claude-opus-5-5', - displayName: 'Default (recommended)', - supportsEffort: true, - supportedEffortLevels: EFFORTS - }, - { - value: 'opus', - resolvedModel: 'claude-opus-5-5', - displayName: 'Opus', - supportsEffort: true, - supportedEffortLevels: EFFORTS - }, - { - value: 'sonnet', - resolvedModel: 'claude-sonnet-5', - displayName: 'Sonnet', - supportsEffort: true, - supportedEffortLevels: EFFORTS - } -] - -/** The user's settings pick Sonnet over the listing's recommended Opus; the CLI applies Sonnet. - * `listing` is what the start's settings readback hands the host; `read` is a later options read. */ -async function startedChat( - options?: Record, - applied: string | null = 'claude-sonnet-5' -) { - const events: ClaudeStructuredSessionEvent[] = [] - const claude = fakeClaude({ - initProof: 'session-start', - initModel: applied ?? 'claude-opus-5-5', - initModels: CATALOG, - settings: { - effective: {}, - sources: [], - applied: { model: applied, effort: applied ? 'high' : null, advisor: null, ultracode: false } - }, - routes: { list_models: () => CATALOG } - }) - const adapter = new ClaudeStructuredSessionAdapter({ - resolveLaunch: async () => ({ - pathToClaudeCodeExecutable: 'claude', - options: options?.model ? { model: options.model } : {}, - cwd: '/work/repo', - claudeConfigDir: ACCOUNT_HOME, - providerSessionId: PROVIDER_SESSION_ID, - resumeLeafUuid: null, - resumesTranscript: false, - continuesChain: false - }), - openConnection: claude.openConnection, - readProcessStartTime: async () => 1_700_000_000_000, - now: () => 1_700_000_000_500, - persistHandle: async () => {}, - onEvent: (event) => events.push(event) - }) - await adapter.acquire({ - identity: identityFor(SESSION), - fence: 7, - spawnToken: 'spawn-9', - events: recordingJournalSink(), - ...(options ? { options } : {}) - }) - await claudeStartupSettled(adapter, SESSION) - const reported = events.find((event) => event.type === 'options-reported') - return { - listing: reported && 'catalogListing' in reported ? reported.catalogListing : undefined, - read: async () => (await adapter.readOptions({ sessionId: SESSION, fence: 7 })).catalogListing - } -} - -async function liveListing(options?: Record, applied?: string | null) { - return (await startedChat(options, applied)).listing -} - -function record(): AgentSessionRecord { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the catalog reads only these fields. - return { - sessionId: SESSION, - provider: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }, - location: { executionHostId: 'local', wslDistro: null, workspaceId: 'ws-1' }, - launchDirectory: '/work/repo' - } as unknown as AgentSessionRecord -} - -function service(store: AgentModelCatalogStore, projectSettings: boolean) { - const launched = record() - return createAgentModelCatalogService({ - store, - getRecord: (sessionId) => (sessionId === SESSION ? launched : undefined), - drivesRecord: () => true, - resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }), - recordWorkspacePath: async (row) => row.launchDirectory ?? null, - workspaceMayOverrideDefaultModel: async ({ workspacePath }) => - projectSettings && workspacePath === '/work/repo' - }) -} - -/** What a new chat's first frame shows from the host's answer alone. */ -async function firstFrame(store: AgentModelCatalogStore, workspacePath: string) { - const answer = await service(store, false).read({ agent: 'claude', workspacePath }) - const seed = getAgentSessionOptionCatalog('claude')! - const state = applyStructuredAgentSessionModelCatalog( - createStructuredAgentSessionOptionState('claude', seed), - seed, - answer, - { newLaunch: true } - ) - const snapshot = structuredAgentSessionOptionSnapshot(state) - const current = (id: string) => { - const kind = snapshot.find((row) => row.id === id)?.kind - return kind && 'currentValue' in kind ? kind.currentValue : undefined - } - return { answer, model: current('model'), effort: current('effort') } -} - -describe('Claude configured default', () => { - it('names the model and effort the CLI resolves for a chat with nothing picked', async () => { - const store = new AgentModelCatalogStore() - const listing = await liveListing() - // Sonnet, as the user's settings pick it — not the listing's recommended Opus. - expect(listing?.configuredDefault).toEqual({ modelId: 'sonnet', effort: 'high' }) - // The effort travels with the configured model, never as a live row's own default. - expect(listing?.models.some((model) => model.defaultEffort !== undefined)).toBe(false) - service(store, false).recordLiveListing(SESSION, listing!) - - await vi.waitFor(async () => - expect((await firstFrame(store, '/work/other')).answer).toMatchObject({ - listingNamesConfiguredModel: true - }) - ) - const frame = await firstFrame(store, '/work/other') - expect(frame.model).toBe('sonnet') - expect(frame.effort).toBe('high') - }) - - it('keeps nothing from a chat whose workspace has Claude settings of its own', async () => { - const store = new AgentModelCatalogStore() - service(store, true).recordLiveListing(SESSION, (await liveListing())!) - await new Promise((resolve) => setTimeout(resolve, 0)) - - const frame = await firstFrame(store, '/work/other') - expect(frame.answer).toMatchObject({ listingNamesConfiguredModel: false }) - expect(frame.model).toBeUndefined() - // The project's effort stays out of the account's catalog too. - const entry = store.get(agentModelCatalogFingerprintForRecord(record())) - expect(entry?.configured).toBeNull() - expect(entry?.models.some((model) => model.defaultEffort !== undefined)).toBe(false) - }) - - it('forgets a saved default once the config resolves to no listed model, for good', async () => { - const store = new AgentModelCatalogStore() - const fingerprint = agentModelCatalogFingerprintForRecord(record()) - service(store, false).recordLiveListing(SESSION, (await liveListing())!) - await vi.waitFor(() => expect(store.get(fingerprint)?.configured?.modelId).toBe('sonnet')) - - // The user's settings now pick a model the account no longer lists. - const unlisted = await liveListing(undefined, 'claude-retired-1') - expect(unlisted?.configuredDefault).toBeNull() - service(store, false).recordLiveListing(SESSION, unlisted!) - await vi.waitFor(() => expect(store.get(fingerprint)?.configured).toBeNull()) - - // A later background listing keeps it forgotten. - store.recordSuccess( - fingerprint, - 'claude', - { - models: CATALOG.map((row) => ({ - id: row.value, - label: row.displayName, - isDefault: row.value === 'default', - efforts: EFFORTS.map((value) => ({ value, label: value })) - })), - origin: 'probe' - }, - 'discovery' - ) - const frame = await firstFrame(store, '/work/other') - expect(store.get(fingerprint)?.configured).toBeNull() - expect(frame.answer).toMatchObject({ listingNamesConfiguredModel: false }) - expect(frame.model).toBeUndefined() - }) - - it('forgets a saved default when the config resolves to no model at all', async () => { - const listing = await liveListing(undefined, null) - expect(listing?.configuredDefault).toBeNull() - }) - - it('learns nothing from a chat launched with a model pick', async () => { - const listing = await liveListing({ model: 'sonnet' }) - expect(listing).toBeDefined() - expect(listing?.configuredDefault).toBeUndefined() - }) - - it('names nothing in a workspace whose own Claude settings could pick another model', async () => { - const store = new AgentModelCatalogStore() - service(store, false).recordLiveListing(SESSION, (await liveListing())!) - await vi.waitFor(() => - expect(store.get(agentModelCatalogFingerprintForRecord(record()))?.configured).not.toBeNull() - ) - - const answer = await createAgentModelCatalogService({ - store, - getRecord: () => undefined, - drivesRecord: () => true, - resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }), - workspaceMayOverrideDefaultModel: async () => true - }).read({ agent: 'claude', workspacePath: '/work/repo' }) - expect(answer).toMatchObject({ listingNamesConfiguredModel: false }) - }) - - it('a chat’s later reads teach nothing: a newer chat’s default stands', async () => { - const store = new AgentModelCatalogStore() - const fingerprint = agentModelCatalogFingerprintForRecord(record()) - const first = await startedChat() - service(store, false).recordLiveListing(SESSION, first.listing!) - await vi.waitFor(() => expect(store.get(fingerprint)?.configured?.modelId).toBe('sonnet')) - - // The user's settings now pick Opus: a newer chat with nothing picked starts on it. - const newer = (await liveListing(undefined, 'claude-opus-5-5'))! - const opus = newer.configuredDefault?.modelId - expect(opus).not.toBe('sonnet') - service(store, false).recordLiveListing(SESSION, newer) - await vi.waitFor(() => expect(store.get(fingerprint)?.configured?.modelId).toBe(opus)) - - // The first chat keeps running and reads its options again, as every turn does. - const later = await first.read() - expect(later).not.toHaveProperty('configuredDefault') - service(store, false).recordLiveListing(SESSION, later!) - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(store.get(fingerprint)?.configured?.modelId).toBe(opus) - }) -}) 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 883126d4592..c260872374c 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 @@ -1,6 +1,4 @@ -// A Claude chat at rest shows the effort its next start will run: a live child with nothing picked -// teaches the host catalog the configured model and effort at its start, and the resting read -// answers it. +// Independent discovery supplies defaults; saved choices and actual-run reports remain chat-owned. import { describe, expect, it } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' @@ -11,13 +9,17 @@ import { structuredAgentSessionOptionSnapshot } from '../../shared/structured-agent-session-options' import { createAgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' -import { agentModelCatalogFingerprintForRecord } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' +import { + agentModelCatalogScopeFingerprint, + agentModelCatalogFingerprintForRecord +} from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' 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 { listedModels } from './claude-structured-model-catalog' import { ClaudeStructuredSessionAdapter, type ClaudeStructuredSessionEvent @@ -123,20 +125,21 @@ async function startChild( ...(options ? { options } : {}) }) await claudeStartupSettled(adapter, SESSION) - // As the host does: the start's settings readback says once what the config resolved, and every - // live options read's listing becomes the account's too. - const reported = events.find((event) => event.type === 'options-reported') - const { catalogListing } = await adapter.readOptions({ sessionId: SESSION, fence: 7 }) - for (const listing of [ - reported && 'catalogListing' in reported ? reported.catalogListing : undefined, - catalogListing - ]) { - if (listing) { - catalogService(store, restingRecord({})).recordLiveListing(SESSION, listing) - } + // Independent discovery resolves config without any chat's saved choices. + if (cli.lists?.length !== 0) { + const record = restingRecord({}) + store.recordSuccess( + exactKey(record), + 'claude', + { + models: listedModels({ models: CATALOG }), + origin: 'probe', + complete: true, + configured: { model: 'opus[1m]', effort: 'medium' } + }, + 'discovery' + ) } - // The configured default lands after the workspace check. - await new Promise((resolve) => setTimeout(resolve, 0)) return adapter } @@ -146,6 +149,7 @@ function restingRecord( ): AgentSessionRecord { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields. return { + sessionId: SESSION, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }, providerHandleChain, @@ -156,8 +160,15 @@ function restingRecord( } as unknown as AgentSessionRecord } +function exactKey(record: AgentSessionRecord): string { + return agentModelCatalogScopeFingerprint( + agentModelCatalogFingerprintForRecord(record), + '/work/repo' + ) +} + function catalogDefault(store: AgentModelCatalogStore, model: string): string | undefined { - const entry = store.get(agentModelCatalogFingerprintForRecord(restingRecord({}))) + const entry = store.get(exactKey(restingRecord({}))) return entry?.models.find((row) => row.id === model)?.defaultEffort } @@ -224,7 +235,7 @@ const READ_ONLY_EFFORT = (value: string, label = value) => ({ }) describe('Claude effort default at rest', () => { - it("learns the model's default from a live child's applied effort", async () => { + it('uses the independently discovered configured effort at rest', async () => { const store = new AgentModelCatalogStore() await startChild(store) @@ -263,7 +274,7 @@ describe('Claude effort default at rest', () => { // 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).toMatchObject({ model: 'claude-opus-5-5[1m]' }) expect(ran.defaulted ?? []).not.toContain('model') }) @@ -281,7 +292,7 @@ describe('Claude effort default at rest', () => { const store = new AgentModelCatalogStore() const restored = await startChild(store, { model: 'opus[1m]', effort: 'high' }) await restored.readOptions({ sessionId: SESSION, fence: 7 }) - expect(catalogDefault(store, 'opus[1m]')).toBeUndefined() + expect(catalogDefault(store, 'opus[1m]')).toBe('medium') const other = new AgentModelCatalogStore() const live = await startChild(other) @@ -335,11 +346,12 @@ describe('Claude effort default at rest', () => { ] } store.recordSuccess( - agentModelCatalogFingerprintForRecord(record), + exactKey(record), 'codex', { ...listing, - origin: 'live-session' + origin: 'probe', + complete: true }, 'discovery' ) @@ -408,7 +420,9 @@ describe('Claude effort default at rest', () => { const resting = await readAtRest(new AgentModelCatalogStore(), record) // A Codex child lists nothing without its catalog, so the answer leaves the list to the client. - expect(resting.models).toEqual([]) + expect(resting.models).toEqual([ + { id: 'gpt-5.5', label: 'gpt-5.5', isDefault: false, efforts: [], effortsKnown: false } + ]) expect(pickerEffort(resting, 'codex')).toBe('high') }) @@ -423,7 +437,7 @@ describe('Claude effort default at rest', () => { options: { model: 'gpt-5.5' } } as unknown as AgentSessionRecord store.recordSuccess( - agentModelCatalogFingerprintForRecord(record), + exactKey(record), 'codex', { models: [ @@ -438,7 +452,8 @@ describe('Claude effort default at rest', () => { ] } ], - origin: 'live-session' + origin: 'probe', + complete: true }, 'discovery' ) diff --git a/src/main/claude/claude-structured-effort-reporting.test.ts b/src/main/claude/claude-structured-effort-reporting.test.ts index 68040c3ebcf..675a3d8ace0 100644 --- a/src/main/claude/claude-structured-effort-reporting.test.ts +++ b/src/main/claude/claude-structured-effort-reporting.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from 'vitest' -import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { setClaudeStructuredOption } from './claude-structured-options' -import { - readClaudeStructuredSessionOptions, - readClaudeSettingsEffort -} from './claude-structured-session-options' +import { readClaudeSettingsEffort } from './claude-structured-session-options' import type { ClaudeSession } from './claude-structured-session-state' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' import { USER_MESSAGE, acquired, fakeClaude } from './claude-structured-session-test-support' @@ -83,12 +79,15 @@ describe('Claude effort reporting', () => { }) it('leaves the effort unreported when the session never learns one', async () => { - const claude = fakeClaude({ settings: { applied: {}, effective: {}, sources: {} } }) + const model = 'claude-opus-5[1m]' + const claude = fakeClaude({ + settings: { applied: { model }, effective: { model }, sources: {} } + }) const adapter = await acquired(claude) const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) expect(options.current.effort).toBeUndefined() - expect(options.current.model).toBeTruthy() + expect(options.current.model).toBe(model) }) it('keeps the init fixture free of an effort the real frame never sends', async () => { @@ -146,98 +145,3 @@ describe('Claude effort readback', () => { ).resolves.toEqual({ effort: 'low' }) }) }) - -describe('Claude effort against the model that must run it', () => { - const HAIKU = { value: 'haiku', resolvedModel: 'claude-haiku-4-5-20251001', displayName: 'Haiku' } - const SONNET = { - value: 'sonnet', - resolvedModel: 'claude-sonnet-5', - displayName: 'Sonnet', - supportsEffort: true, - supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'] - } - - it('refuses an effort the current model advertises no control for', async () => { - const { session, calls } = sessionWith('high', [], { model: 'haiku', catalog: [HAIKU, SONNET] }) - - await expect( - setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined) - ).rejects.toBeInstanceOf(AgentSessionOptionRejectedError) - // Measured on Claude Code 2.1.260: apply_flag_settings stores `high` on a - // haiku session and get_settings reads it straight back, so a send here is - // never undone. The refusal has to land before the write. - expect(calls).toEqual(['list_models']) - expect(session.options.has('effort')).toBe(false) - }) - - it('refuses a level outside the ones the current model advertises', async () => { - const { session } = sessionWith('high', [], { - model: 'sonnet', - catalog: [{ ...SONNET, supportedEffortLevels: ['low', 'medium'] }] - }) - - await expect( - setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined) - ).rejects.toBeInstanceOf(AgentSessionOptionRejectedError) - }) - - it('sends an effort the current model advertises', async () => { - const { session, calls } = sessionWith('high', [], { - model: 'sonnet', - catalog: [HAIKU, SONNET] - }) - - await expect( - setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined) - ).resolves.toEqual({ model: 'sonnet', effort: 'high' }) - expect(calls).toEqual(['list_models', 'apply:high', 'get_settings']) - expect(session.confirmedOptions.has('effort')).toBe(true) - }) - - it('sends `max`, which the readback cannot report, when the model advertises it', async () => { - // The underlying setting may differ from a transient execution effort. - const { session, calls } = sessionWith('high', [], { model: 'sonnet', catalog: [SONNET] }) - - await expect( - setClaudeStructuredOption(session, { key: 'effort', value: 'max' }, undefined) - ).resolves.toEqual({ model: 'sonnet', effort: 'max' }) - expect(calls).toEqual(['list_models', 'apply:max']) - await readClaudeStructuredSessionOptions(session, undefined) - expect(session.appliedInputOptions.get('effort')).toBe('max') - expect(session.confirmedOptions.has('effort')).toBe(false) - }) - - it('sends the effort when the model is not in the catalog the CLI listed', async () => { - // An unlisted model is an unknown one, not one that refuses effort. - const { session, calls } = sessionWith('high', [], { model: 'sonnet', catalog: [HAIKU] }) - - await expect( - setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined) - ).resolves.toEqual({ model: 'sonnet', effort: 'high' }) - expect(calls).toEqual(['list_models', 'apply:high', 'get_settings']) - }) - - it('sends the effort when list_models is unavailable', async () => { - const calls: string[] = [] - const { session } = sessionWith('high', calls, { model: 'sonnet', catalog: [] }) - session.connection.supportedModels = async () => { - calls.push('list_models') - throw new Error('this CLI predates list_models') - } - - await expect( - setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined) - ).resolves.toEqual({ model: 'sonnet', effort: 'high' }) - expect(calls).toEqual(['list_models', 'apply:high', 'get_settings']) - }) - - it('matches the model the init frame reported, not just the id the user picked', async () => { - const { session } = sessionWith('high', [], { model: 'sonnet', catalog: [HAIKU, SONNET] }) - session.options.delete('model') - session.reportedOptions.model = 'claude-haiku-4-5-20251001' - - await expect( - setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined) - ).rejects.toBeInstanceOf(AgentSessionOptionRejectedError) - }) -}) diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index bb5c83b64c6..d95cdd4b795 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -142,7 +142,7 @@ export type ClaudeStructuredInvocation = { * The one place a structured Claude child's binary and environment are * resolved. The session launch and the session-less catalog probe both build * on it, so a probe can never list under a different binary or env than the - * session it stands in for. Env VALUES stay out of the catalog fingerprint: + * session it stands in for. Inherited env values stay out of the catalog fingerprint: * drift there heals on the next refresh. */ export async function resolveClaudeStructuredInvocation( diff --git a/src/main/claude/claude-structured-model-catalog.ts b/src/main/claude/claude-structured-model-catalog.ts index 1b7ec8f0a91..47970a1dc5e 100644 --- a/src/main/claude/claude-structured-model-catalog.ts +++ b/src/main/claude/claude-structured-model-catalog.ts @@ -30,6 +30,15 @@ function listedEfforts(row: Record): AgentSessionOptionChoice[] : [] } +export function isCompleteClaudeModelListing(value: unknown): boolean { + const rows = record(value)?.models + return ( + Array.isArray(rows) && + rows.length > 0 && + rows.every((value) => text(record(value)?.value) !== null) + ) +} + export function listedModels(value: unknown): ListedModel[] { const response = record(value) const rows = Array.isArray(response?.models) @@ -40,7 +49,7 @@ export function listedModels(value: unknown): ListedModel[] { const seen = new Set() return rows.flatMap((row) => { const id = text(row.value) - if (!id || id === 'default' || seen.has(id)) { + if (!id || id === 'default' || row.disabled === true || seen.has(id)) { return [] } seen.add(id) @@ -57,6 +66,12 @@ export function listedModels(value: unknown): ListedModel[] { ...(description ? { description } : {}), isDefault: resolvedModel !== null && resolvedModel === defaultResolvedModel, efforts: listedEfforts(row), + ...(row.supportsEffort !== false && + (row.supportsEffort !== true || + !Array.isArray(row.supportedEffortLevels) || + row.supportedEffortLevels.some((value) => text(value) === null)) + ? { effortsKnown: false as const } + : {}), ...(supportsFastMode !== undefined ? { supportsFastMode } : {}), ...(supportsAutoMode !== undefined ? { supportsAutoMode } : {}), resolvedModel @@ -97,21 +112,7 @@ export function seedModels(): ListedModel[] { })) } -/** Whether `currentModelId` falls back to the list's default, which the chat never chose. */ +/** The default placeholder names no model the chat chose or the agent reported. */ 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( - (model) => - model.id === reportedModel || - model.resolvedModel === reportedModel || - (reportedModel === 'default' && model.isDefault) - ) - : undefined - return ( - matched?.id ?? reportedModel ?? models.find((model) => model.isDefault)?.id ?? models[0]!.id - ) -} diff --git a/src/main/claude/claude-structured-model-confirmation.test.ts b/src/main/claude/claude-structured-model-confirmation.test.ts index a0087ec2063..2e26067d896 100644 --- a/src/main/claude/claude-structured-model-confirmation.test.ts +++ b/src/main/claude/claude-structured-model-confirmation.test.ts @@ -24,7 +24,12 @@ const CATALOG = [ supportsEffort: true, supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'] }, - { value: 'haiku', resolvedModel: 'claude-haiku-4-5-20251001', displayName: 'Haiku' } + { + value: 'haiku', + resolvedModel: 'claude-haiku-4-5-20251001', + displayName: 'Haiku', + supportsEffort: false + } ] function initFrame(model: string): Record { @@ -49,7 +54,7 @@ describe('Claude model confirmation', () => { const adapter = await acquired(claude) await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ - current: { model: 'sonnet' } + current: { model: 'claude-sonnet-5' } }) // The CLI's own report of what it is running — the only channel that carries @@ -57,7 +62,7 @@ describe('Claude model confirmation', () => { claude.connections[0]!.handlers.onMessage?.(initFrame('claude-haiku-4-5-20251001')) await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ - current: { model: 'haiku' } + current: { model: 'claude-haiku-4-5-20251001' } }) }) @@ -88,64 +93,10 @@ describe('Claude model confirmation', () => { claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5')) await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ - current: { model: 'sonnet' } + current: { model: 'claude-sonnet-5' } }) }) - it('guards an effort against the model the turn reported, not the one that was set', async () => { - const claude = fakeClaude({ - initModel: 'claude-sonnet-5', - routes: { list_models: () => CATALOG } - }) - const adapter = await acquired(claude) - - await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) - // set_model answered success for a model it never resolved; the turn runs sonnet. - claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5')) - - // The picker offers sonnet's levels, so refusing one under haiku — a model the - // pill does not show and the child is not running — is the false positive. - await expect( - adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 }) - ).resolves.toMatchObject({ effort: 'high' }) - }) - - it('keeps guarding against the reported model across a second effort write', async () => { - const claude = fakeClaude({ - initModel: 'claude-sonnet-5', - routes: { list_models: () => CATALOG } - }) - const adapter = await acquired(claude) - - await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) - claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5')) - await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 }) - - // The effort write bumps the option fence but does not change what the child - // runs, so the sonnet report is still current and still governs the guard. - // `max` skips the settings readback by contract, so only the catalog gates it: - // sonnet advertises it, haiku advertises no effort control at all. - await expect( - adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'max', fence: 7 }) - ).resolves.toMatchObject({ effort: 'max' }) - }) - - it('guards an effort against a just-set model no turn has reported yet', async () => { - const claude = fakeClaude({ - initModel: 'claude-sonnet-5', - routes: { list_models: () => CATALOG } - }) - const adapter = await acquired(claude) - - await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) - - // The acquisition-time report predates the write, so haiku — which advertises - // no effort control — is still the model the guard must answer for. - await expect( - adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 }) - ).rejects.toThrow('claude model haiku does not accept effort high') - }) - it('stops vouching for a confirmed effort once the model changes', async () => { const claude = fakeClaude({ initModel: 'claude-sonnet-5', @@ -162,7 +113,7 @@ describe('Claude model confirmation', () => { await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 }) await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ - current: { model: 'sonnet', effort: 'high', confirmed: ['model', 'effort'] } + current: { model: 'claude-sonnet-5', effort: 'high', confirmed: ['model', 'effort'] } }) await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) @@ -219,6 +170,6 @@ describe('Claude effort the settings readback cannot report', () => { await expect( setClaudeStructuredOption(session, { key: 'effort', value: 'max' }, undefined) ).resolves.toEqual({ model: 'sonnet', effort: 'max' }) - expect(calls).toEqual(['list_models', 'apply:max']) + expect(calls).toEqual(['apply:max']) }) }) diff --git a/src/main/claude/claude-structured-option-confirmation.test.ts b/src/main/claude/claude-structured-option-confirmation.test.ts index 5cd42112f85..bec8aa94257 100644 --- a/src/main/claude/claude-structured-option-confirmation.test.ts +++ b/src/main/claude/claude-structured-option-confirmation.test.ts @@ -139,7 +139,7 @@ describe('the provider report corrects the pill', () => { claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5')) const corrected = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) - expect(modelValue(corrected)).toBe('sonnet') + expect(modelValue(corrected)).toBe('claude-sonnet-5') expect(corrected.current.confirmed).toContain('model') }) diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index d8afe582879..232a4023bfe 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -14,8 +14,6 @@ import { function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSession { return { - // An empty catalog identifies no model, so the pre-flight refuses nothing and - // this stays a test about fencing. connection: { setModel, supportedModels: async (): Promise => [] @@ -133,14 +131,6 @@ describe('Claude structured Fast mode', () => { ) }) - it('rejects definitively unsupported Fast before applying', async () => { - const { session, applyFlagSettings } = fastModeSession(false) - await expect( - setClaudeStructuredOption(session, { key: 'fastMode', value: 'true' }, undefined) - ).rejects.toThrow('does not support Fast mode') - expect(applyFlagSettings).not.toHaveBeenCalled() - }) - it.each([undefined, false])( 'allows explicit Fast off when model support is %s', async (supportsFastMode) => { @@ -153,8 +143,8 @@ describe('Claude structured Fast mode', () => { } ) - // Turning Fast off needs no support evidence, so it must not pay a catalog round trip. - it('reads no catalog to turn Fast off, but does to turn it on', async () => { + // Host admission already checked the installed catalog. + it('applies Fast picks without a child catalog round trip', async () => { const { session } = fastModeSession(true) const listed = session.connection.supportedModels let reads = 0 @@ -171,7 +161,7 @@ describe('Claude structured Fast mode', () => { await expect( setClaudeStructuredOption(session, { key: 'fastMode', value: 'true' }, undefined) ).resolves.toMatchObject({ fastMode: 'true' }) - expect(reads).toBe(1) + expect(reads).toBe(0) }) it('resolves the running CLI default model before applying Fast', async () => { @@ -220,9 +210,9 @@ describe('Claude structured Fast mode', () => { // Switched back to a model that allows Fast: state reported, reason omitted. observeClaudeFastModeFacts(session, { fast_mode_state: 'on' }) expect(session.fastModeDisabledReason).toBeUndefined() - await expect(readClaudeStructuredSessionOptions(session, undefined)).resolves.toMatchObject({ - fastModeSupport: { supported: true } - }) + expect( + (await readClaudeStructuredSessionOptions(session, undefined)).fastModeSupport + ).toBeUndefined() await expect( setClaudeStructuredOption(session, { key: 'fastMode', value: 'true' }, undefined) ).resolves.toMatchObject({ fastMode: 'true' }) @@ -253,15 +243,13 @@ describe('Claude structured Fast mode', () => { expect(session.confirmedOptions.has('fastMode')).toBe(true) }) - it('publishes support and explicit false from running CLI reports', async () => { + it('reports actual Fast values without child capability rows', async () => { const { session } = fastModeSession(true) observeClaudeFastModeFacts(session, { fast_mode_state: 'cooldown', fast_mode_disabled_reason: null }) await expect(readClaudeStructuredSessionOptions(session, undefined)).resolves.toMatchObject({ - models: [expect.objectContaining({ id: 'opus', supportsFastMode: true })], - fastModeSupport: { supported: true }, current: { model: 'opus', fastMode: false, @@ -284,21 +272,6 @@ describe('Claude structured Fast mode', () => { }) }) - it('reconciles Fast off when switching to a model without support', async () => { - const { session, applyFlagSettings } = fastModeSession(true) - session.options.set('fastMode', 'true') - session.connection.supportedModels = async () => [ - { value: 'opus', displayName: 'Opus', supportsFastMode: true }, - { value: 'haiku', displayName: 'Haiku', supportsFastMode: false } - ] - - await expect( - setClaudeStructuredOption(session, { key: 'model', value: 'haiku' }, undefined) - ).resolves.toMatchObject({ model: 'haiku', fastMode: 'false' }) - expect(applyFlagSettings).toHaveBeenCalledWith({ fastMode: false }, { timeoutMs: undefined }) - expect(session.appliedInputOptions.get('fastMode')).toBe('false') - }) - it('keeps Fast on across a model switch while support discovery is transient', async () => { const { session, applyFlagSettings } = fastModeSession(true) session.options.set('fastMode', 'true') @@ -331,26 +304,6 @@ describe('Claude structured Fast mode', () => { expect(session.confirmedOptions.has('fastMode')).toBe(true) }) - it('keeps the accepted model when the unsupported-model Fast-off write fails', async () => { - const { session, applyFlagSettings } = fastModeSession(true) - session.options.set('fastMode', 'true') - session.reportedOptions.fastMode = true - session.confirmedOptions.add('fastMode') - session.connection.supportedModels = async () => [ - { value: 'opus', displayName: 'Opus', supportsFastMode: true }, - { value: 'haiku', displayName: 'Haiku', supportsFastMode: false } - ] - applyFlagSettings.mockRejectedValueOnce(new Error('flag write failed')) - - await expect( - setClaudeStructuredOption(session, { key: 'model', value: 'haiku' }, undefined) - ).resolves.toMatchObject({ model: 'haiku', fastMode: 'false' }) - expect(session.reportedOptions.fastMode).toBe(true) - expect(session.confirmedOptions.has('fastMode')).toBe(false) - expect(session.appliedInputOptions.get('model')).toBe('haiku') - expect(session.appliedInputOptions.has('fastMode')).toBe(false) - }) - it('treats an unrecognized provider disabled reason as unavailable', async () => { const { session, applyFlagSettings } = fastModeSession(true) observeClaudeFastModeFacts(session, { @@ -385,15 +338,6 @@ describe('Claude Fast mode against a catalog that identifies nothing', () => { ).resolves.toMatchObject({ fastMode: 'true' }) expect(applyFlagSettings).toHaveBeenCalledWith({ fastMode: true }, { timeoutMs: undefined }) }) - - it('still refuses Fast on when the catalog lists the model and omits Fast support', async () => { - const { session, applyFlagSettings } = fastModeSession(undefined) - - await expect( - setClaudeStructuredOption(session, { key: 'fastMode', value: 'true' }, undefined) - ).rejects.toThrow('does not support Fast mode') - expect(applyFlagSettings).not.toHaveBeenCalled() - }) }) describe('Claude Fast mode reported by the session frame alone', () => { diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index d6612a75917..963adebadd0 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -2,11 +2,7 @@ import type { EffortLevel } from '@anthropic-ai/claude-agent-sdk' import { ClaudeControlRequestError } from './claude-stream-json-connection' import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { - claudeModelEffortLevels, - claudeModelFastModeSupport, - readClaudeCurrentModel, observeClaudeSettingsApplied, - readClaudeListedModels, readClaudeSettingsEffort, readClaudeSettingsFastMode } from './claude-structured-session-options' @@ -70,40 +66,10 @@ export async function setClaudeStructuredOption( `claude stream-json has no session option named ${input.key}` ) } - // One read answers every catalog question this write asks, so the guards below - // cannot each pay a round trip for the same list nor disagree about the model. - // Two writes ask nothing of it and so read nothing: an effort write with no current - // model has nothing to look up, and turning Fast off needs no support evidence. - const needsCatalog = - (input.key === 'model' && session.options.get('fastMode') === 'true') || - (input.key === 'fastMode' && fastMode === true) || - (input.key === 'effort' && readClaudeCurrentModel(session).id !== undefined) - const listed = needsCatalog ? await readClaudeListedModels(session, timeoutMs) : [] - // The child stores an effort its model has no control for and keeps it across - // every later model switch, so refuse before the write rather than read the - // acceptance back as adoption. - if (input.key === 'effort') { - const { modelId, levels } = claudeModelEffortLevels(session, listed) - if (levels && !levels.has(input.value)) { - throw new AgentSessionOptionRejectedError( - `claude model ${modelId} does not accept effort ${input.value}` - ) - } - } if (input.key === 'fastMode') { if (typeof fastMode !== 'boolean') { throw new AgentSessionOptionRejectedError('claude fast mode must be encoded as true or false') } - const support = claudeModelFastModeSupport(session, listed) - // A catalog that identified nothing is not evidence against this model, the same - // rule used for unknown capability evidence — otherwise a CLI that cannot answer has Fast - // refused on every model. A catalog that did list the model and stayed silent - // about Fast is still not positive evidence, so that case keeps refusing. - if (fastMode && listed.length > 0 && support.supported !== true) { - throw new AgentSessionOptionRejectedError( - `claude model ${support.modelId ?? 'current'} does not support Fast mode` - ) - } if ( fastMode && session.fastModeDisabledReason && @@ -114,10 +80,6 @@ export async function setClaudeStructuredOption( ) } } - const modelFastModeSupport = - input.key === 'model' && session.options.get('fastMode') === 'true' - ? claudeModelFastModeSupport(session, listed, input.value) - : null session.appliedInputOptions.delete(input.key) const mutationSequence = beginClaudeOptionMutation(session, input.key) // Read with the fence bump, so a write that lands after an earlier one's bookkeeping compares against it. @@ -134,30 +96,6 @@ export async function setClaudeStructuredOption( if (input.key === 'model') { session.translator?.modelWritten(input.value) } - if ( - input.key === 'model' && - session.options.get('fastMode') === 'true' && - modelFastModeSupport?.supported === false - ) { - if (mutationSequence !== session.optionMutationSequence) { - return Object.fromEntries(session.options) - } - session.options.set('model', input.value) - session.options.set('fastMode', 'false') - session.confirmedOptions.delete('effort') - session.confirmedOptions.delete('fastMode') - // The requested model is already accepted; a cleanup failure cannot reject that write. - session.appliedInputOptions.delete('fastMode') - await session.connection.applyFlagSettings({ fastMode: false }, { timeoutMs }).then( - () => { - if (mutationSequence === session.optionMutationSequence) { - session.appliedInputOptions.set('fastMode', 'false') - } - }, - () => {} - ) - return Object.fromEntries(session.options) - } } catch (error) { if (error instanceof ClaudeControlRequestError) { throw new AgentSessionOptionRejectedError(error) diff --git a/src/main/claude/claude-structured-retired-model.test.ts b/src/main/claude/claude-structured-retired-model.test.ts index d7a5d71d4c1..64fb6595516 100644 --- a/src/main/claude/claude-structured-retired-model.test.ts +++ b/src/main/claude/claude-structured-retired-model.test.ts @@ -1,8 +1,4 @@ import { describe, expect, it } from 'vitest' -import { - AgentModelCatalogStore, - type AgentModelCatalogLiveListing -} from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { retireClaudeLaunchedModel } from './claude-structured-retired-model' import { adapterFor, @@ -65,7 +61,7 @@ describe("the CLI's word that a launched model does not exist", () => { }) }) -describe("the account catalog facts a child's listing hands the host", () => { +describe("a child's listing ownership", () => { async function listingOfChildLaunchedWith(model: string) { const claude = fakeClaude({ initModels: [NATIVE_OPUS, LAUNCHED_ROW], @@ -85,38 +81,8 @@ describe("the account catalog facts a child's listing hands the host", () => { return catalogListing } - function savedIds(cached: string[] | null, listing: AgentModelCatalogLiveListing) { - const store = new AgentModelCatalogStore() - if (cached) { - store.recordSuccess( - 'fp', - 'claude', - { - models: cached.map((id) => ({ id, label: id, isDefault: false, efforts: [] })), - origin: 'probe' - }, - 'discovery' - ) - } - store.recordSuccess('fp', 'claude', { ...listing, origin: 'live-session' }, 'live') - return store.get('fp')?.models.map((model) => model.id) - } - - it('marks the row the launch added for its own model, which the account catalog leaves out', async () => { - const listing = await listingOfChildLaunchedWith(RETIRED) - expect(listing?.launchOnlyModelId).toBe(RETIRED) - expect(savedIds(null, listing!)).toEqual(['opus']) - }) - - it('keeps that row when the account already listed the model', async () => { - const listing = await listingOfChildLaunchedWith(RETIRED) - expect(savedIds([RETIRED], listing!)).toEqual(['opus', RETIRED]) - }) - - it('marks nothing for a launched native model, which carries its own display name', async () => { - const listing = await listingOfChildLaunchedWith('opus') - expect(listing?.launchOnlyModelId).toBeUndefined() - expect(savedIds(null, listing!)).toEqual(['opus', RETIRED]) + it('keeps a child listing local to its session', async () => { + expect(await listingOfChildLaunchedWith(RETIRED)).toBeUndefined() }) }) 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 75d86ba6ab6..afc5228d25e 100644 --- a/src/main/claude/claude-structured-session-adapter-turns.test.ts +++ b/src/main/claude/claude-structured-session-adapter-turns.test.ts @@ -226,7 +226,7 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { ).rejects.toThrow('timed out') }) - it('hydrates live model choices and maps the resolved current model to its CLI id', async () => { + it('reports the running literal model without reading child capability choices', async () => { const claude = fakeClaude({ initModel: 'claude-sonnet-5', routes: { @@ -260,23 +260,13 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { sessionId: 'session-1', fence: 7 }) - // The listing the host saves for the account: the CLI's rows, none of this session's state. - expect(catalogListing?.models.map((model) => model.id)).toEqual(['opus', 'sonnet']) - expect(options).toEqual({ - models: [ - { - id: 'opus', - label: 'Opus', - isDefault: true, - efforts: [ - { value: 'low', label: 'Low' }, - { value: 'high', label: 'High' } - ] - }, - { id: 'sonnet', label: 'Sonnet', isDefault: false, efforts: [] } - ], - current: { model: 'sonnet', effort: 'high', confirmed: ['model', 'effort'] } + expect(catalogListing).toBeUndefined() + expect(options.current).toEqual({ + model: 'claude-sonnet-5', + effort: 'high', + confirmed: ['model', 'effort'] }) + expect(options.models.some((model) => model.label === 'Default')).toBe(false) }) it('keeps the shared Claude seed when live model discovery is unavailable', async () => { diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 21f4c8b6fbb..584c10ef99d 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -159,13 +159,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda this.sessions.get(sessionId)?.prompts.bindJournalItemId(journalItemId, promptKey) } - dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => - dispatchClaudeMessage( - this.session(input.sessionId), - input, - this.deps.requestTimeoutMs, - this.deps.logger - ) + dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => { + const { requestTimeoutMs, logger } = this.deps + return dispatchClaudeMessage(this.session(input.sessionId), input, requestTimeoutMs, logger) + } // A rewind forks the kept conversation into a new session, which the next launch makes. forkForRewind = claudeForkForRewind diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index 0226d21819d..9cacb592acc 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -1,24 +1,19 @@ import type { AgentSessionFastModeState } from '../../shared/agent-session-wire' import { - currentModelId, currentModelIsDefaulted, listedModels, - matchListedModel, record, seedModels, - text, - type ListedModel + text } from './claude-structured-model-catalog' import type { ClaudeSession } from './claude-structured-session-state' import { structuredAgentSessionOptionModels } from '../native-chat/agent-session-wire/structured-agent-session-option-models' import { - claudeCatalogListing, claudeFastModeSupport, wireClaudeModels, type WireClaudeModel } from './claude-structured-catalog-listing' import type { StructuredAgentSessionLiveOptions } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { AgentModelCatalogLiveListing } from '../native-chat/agent-model-catalog/agent-model-catalog-entry' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' /** @@ -128,62 +123,6 @@ export function readClaudeCurrentModel(session: ClaudeSession): { } } -/** One catalog read serves a whole option write. The admit check, the effort guard - * and the Fast guard all ask about the same list; each taking its own read made a - * single model write pay for two `list_models` round trips and let two guards answer - * from two different catalogs. An unreadable catalog is an empty list, which - * identifies no model and so refuses nothing. */ -export async function readClaudeListedModels( - session: ClaudeSession, - timeoutMs: number | undefined -): Promise { - const catalog = await session.connection.supportedModels({ timeoutMs }).catch(() => null) - return catalog ? listedModels({ models: catalog }) : [] -} - -/** - * The effort levels the session's current model advertises, with the catalog id - * that matched so a refusal names the model the pill shows. Levels are null when - * nothing identified the model: `apply_flag_settings` accepts and stores any - * level for a model with no effort control, so the catalog is the only evidence - * of a refusal — and an absent or unlisted one is not evidence, or a live CLI - * that predates `list_models` would have every effort refused under it. - */ -export function claudeModelEffortLevels( - session: ClaudeSession, - models: readonly ListedModel[] -): { modelId: string | undefined; levels: ReadonlySet | null } { - const modelId = readClaudeCurrentModel(session).id - const matched = modelId - ? models.find((model) => model.id === modelId || model.resolvedModel === modelId) - : undefined - return { - modelId: matched?.id ?? modelId, - levels: matched ? new Set(matched.efforts.map((choice) => choice.value)) : null - } -} - -export function claudeModelFastModeSupport( - session: ClaudeSession, - models: readonly ListedModel[], - requestedModel?: string -): { modelId: string | undefined; supported: boolean | null } { - const reportedModelId = requestedModel ?? readClaudeCurrentModel(session).id - const modelId = reportedModelId ?? models.find((model) => model.isDefault)?.id - const matched = modelId ? matchListedModel(models, modelId) : undefined - return { - modelId: matched?.id ?? modelId, - supported: matched?.supportsFastMode ?? null - } -} - -function listedModelFastModeSupport( - models: readonly ListedModel[], - modelId: string -): boolean | undefined { - return matchListedModel(models, modelId)?.supportsFastMode -} - function decodedFastMode(session: ClaudeSession): boolean | undefined { const encoded = session.options.get('fastMode') if (encoded === undefined) { @@ -204,12 +143,9 @@ export async function readClaudeStructuredSessionOptions( timeoutMs: number | undefined ): Promise { const readMutationSequence = session.optionMutationSequence - const [catalog, settings] = await Promise.all([ - session.connection.supportedModels({ timeoutMs }).catch(() => null), - session.connection.getSettings({ timeoutMs }).catch(() => null) - ]) + const settings = await session.connection.getSettings({ timeoutMs }).catch(() => null) observeClaudeSettingsReadback(session, settings, readMutationSequence) - return claudeStructuredSessionOptionsFrom(session, catalog, readMutationSequence) + return claudeStructuredSessionOptionsFrom(session, null) } function observeClaudeSettingsReadback( @@ -241,32 +177,16 @@ function observeClaudeSettingsReadback( } } -/** The account listing with what the start's settings readback resolved, for the host to save - * once; empty when the listing names no model. */ -export function claudeResolvedCatalogListing( - session: ClaudeSession, - catalog: unknown[] | null -): { catalogListing?: AgentModelCatalogLiveListing } { - const listing = claudeCatalogListing( - session, - listedModels(catalog ? { models: catalog } : null), - { withConfiguredDefault: true } - ) - return listing ? { catalogListing: listing } : {} -} - /** The options already held in memory, over `catalog`; asks the CLI nothing. The SDK answers * `list_models` from initialize, so open passes that result without another round trip. */ export function claudeStructuredSessionOptionsFrom( session: ClaudeSession, - catalog: unknown[] | null, - readMutationSequence = session.optionMutationSequence + catalog: unknown[] | null ): StructuredAgentSessionLiveOptions { const discovered = listedModels(catalog ? { models: catalog } : null) - const catalogListing = claudeCatalogListing(session, discovered) const listed = discovered.length > 0 ? discovered : seedModels() const current = readClaudeCurrentModel(session) - const model = currentModelId(listed, current.id) + const model = current.id === 'default' ? undefined : current.id const models = structuredAgentSessionOptionModels(listed, model, (row) => ({ ...row, resolvedModel: null @@ -275,16 +195,7 @@ export function claudeStructuredSessionOptionsFrom( session.options.get('effort') ?? session.reportedOptions.effort ?? session.appliedOptions?.effort - let desiredFastMode = decodedFastMode(session) - if ( - desiredFastMode === true && - listedModelFastModeSupport(discovered, model) === false && - readMutationSequence === session.optionMutationSequence - ) { - session.options.set('fastMode', 'false') - session.confirmedOptions.delete('fastMode') - desiredFastMode = false - } + const desiredFastMode = decodedFastMode(session) // The child answers Fast two ways and need not answer both: the settings readback // carries the boolean, and the session frames carry a routing state. A fresh // session reports the state while the boolean is still absent, so without this @@ -295,7 +206,7 @@ export function claudeStructuredSessionOptionsFrom( desiredFastMode ?? session.reportedOptions.fastMode ?? (session.fastModeState === undefined ? undefined : session.fastModeState !== 'off') - const support = claudeFastModeSupport(discovered, session.fastModeDisabledReason) + const support = claudeFastModeSupport([], session.fastModeDisabledReason) const confirmed = [ ...(current.confirmed ? ['model'] : []), ...(effort && session.confirmedOptions.has('effort') ? ['effort'] : []), @@ -314,7 +225,6 @@ export function claudeStructuredSessionOptionsFrom( ...(session.fastModeState ? { fastModeState: session.fastModeState } : {}), ...(confirmed.length > 0 ? { confirmed } : {}), ...(currentModelIsDefaulted(current.id) ? { defaulted: ['model'] as const } : {}) - }, - ...(catalogListing ? { catalogListing } : {}) + } } } diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index 96d6877dabe..ae1c313ac64 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -19,7 +19,6 @@ import { prepareClaudeStructuredSessionAcquisitionOptions } from './claude-structured-session-acquisition-options' import { - claudeResolvedCatalogListing, claudeStructuredSessionOptionsFrom, observeClaudeSettingsApplied, readClaudeSettingsApplied, @@ -33,10 +32,7 @@ import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' -export type ClaudeOpenFacts = Pick< - StructuredAgentSessionProviderOpenResult, - 'reportedOptions' | 'catalogListing' -> +export type ClaudeOpenFacts = Pick /** A lifecycle event the start reports, before the session's identity is stamped on it. */ export type ClaudeStartupReport = { @@ -198,23 +194,17 @@ function applyClaudeSettingsFacts( * on every reopen, past a later settings change. */ function claudeStartedReportedOptions( session: ClaudeSession, - catalog: unknown[], - readMutationSequence = session.optionMutationSequence -): Pick { - const { current, catalogListing } = claudeStructuredSessionOptionsFrom( - session, - catalog, - readMutationSequence - ) - const listing = catalogListing ? { catalogListing } : {} + catalog: unknown[] +): Pick { + const { current } = claudeStructuredSessionOptionsFrom(session, catalog) const { model: _projected, ...rest } = current const model = claudeExecutionObservations(session).model const observed = { ...rest, ...(model ? { model } : {}) } if (session.options.has('effort') || session.reportedOptions.effort !== undefined) { - return { reportedOptions: observed, ...listing } + return { reportedOptions: observed } } const { effort: _displayOnly, ...persisted } = observed - return { reportedOptions: persisted, ...listing } + return { reportedOptions: persisted } } /** The host awaits initialize and proof while owning the published child. */ @@ -239,11 +229,7 @@ export async function openClaudeSession(input: { } applyClaudeInitializeFacts(session, initialized) const result: ClaudeOpenFacts = { - ...claudeStartedReportedOptions( - session, - readClaudeModels(initialized.initialization), - session.fastModeAtStart ? session.optionMutationSequence - 1 : undefined - ) + ...claudeStartedReportedOptions(session, readClaudeModels(initialized.initialization)) } void settleClaudeStartupSettings(input, initialized) return result @@ -280,10 +266,7 @@ async function settleClaudeStartupSettings( applyClaudeSettingsFacts(session, facts, writtenSinceRead) input.report({ type: 'options-reported', - // Open already returned the initialize listing to the host. reportedOptions: claudeStartedReportedOptions(session, readClaudeModels(facts.initialization)) - .reportedOptions, - // The one report of what the config resolved: the readback just applied it. - ...claudeResolvedCatalogListing(session, readClaudeModels(facts.initialization)) + .reportedOptions }) } diff --git a/src/main/codex/codex-configured-launch-defaults.ts b/src/main/codex/codex-configured-launch-defaults.ts index b8add319b8a..f5fc80429c3 100644 --- a/src/main/codex/codex-configured-launch-defaults.ts +++ b/src/main/codex/codex-configured-launch-defaults.ts @@ -18,19 +18,28 @@ function configuredText(value: unknown): string | null { /** The model and effort the user's Codex config selects, or null when Codex can't say. */ export async function readCodexConfiguredLaunchDefaults( connection: Pick, - timeoutMs = CONFIG_READ_TIMEOUT_MS + timeoutMs = CONFIG_READ_TIMEOUT_MS, + cwd?: string ): Promise { try { - const response = await connection.request( - 'config/read', - {}, - { timeoutMs: Math.min(timeoutMs, CONFIG_READ_TIMEOUT_MS) } - ) + const response = await connection.request('config/read', cwd ? { cwd } : {}, { + timeoutMs: Math.min(timeoutMs, CONFIG_READ_TIMEOUT_MS) + }) const config = typeof response === 'object' && response !== null && 'config' in response ? response.config : null - if (typeof config !== 'object' || config === null) { + if (typeof config !== 'object' || config === null || Array.isArray(config)) { + return null + } + if ( + Object.entries(config).some( + ([key, value]) => + ['model', 'model_reasoning_effort', 'personality', 'service_tier'].includes(key) && + value != null && + typeof value !== 'string' + ) + ) { return null } const personality = 'personality' in config ? configuredText(config.personality) : null @@ -43,7 +52,7 @@ export async function readCodexConfiguredLaunchDefaults( 'model_reasoning_effort' in config ? configuredText(config.model_reasoning_effort) : null } } catch { - // Older Codex has no config/read; its own listing default then stands. + // Refusal and transport failure leave configuration unknown. return null } } diff --git a/src/main/codex/codex-model-catalog-probe.test.ts b/src/main/codex/codex-model-catalog-probe.test.ts index d2643ba8427..685be583d92 100644 --- a/src/main/codex/codex-model-catalog-probe.test.ts +++ b/src/main/codex/codex-model-catalog-probe.test.ts @@ -114,6 +114,7 @@ describe('codex model catalog probe', () => { ) }) const success = await probe({ variable: 'CODEX_HOME', path: '/homes/a' }) + expect(success.complete).toBe(false) expect(configReadTimeouts).toEqual([3_000]) expect(configReadFailures).toEqual([ expect.objectContaining({ @@ -314,3 +315,31 @@ describe('Codex catalog probe reads the home a launch would', () => { expect(order).toEqual(['prepare /homes/mirror', 'spawn']) }) }) + +it('keeps unknown or unsupported configuration incomplete and observes genuine empty config', async () => { + for (const config of [undefined, [], { model: 42 }, {}]) { + const probe = createCodexModelCatalogProbe({ + resolveEnvironment: async () => ({ PATH: '/bin' }), + resolveCommand: () => '/bin/codex', + runSession: async (_invocation, body) => + body({ + notify: () => {}, + request: async (method) => { + if (method === 'model/list') { + return { data: [MODEL_ROW], nextCursor: null } + } + if (method === 'config/read') { + if (config === undefined) { + throw new Error('method not found') + } + return { config } + } + return {} + } + }) + }) + expect((await probe({ variable: 'CODEX_HOME', path: '/account' })).complete).toBe( + config !== undefined && !Array.isArray(config) && !('model' in config) + ) + } +}) diff --git a/src/main/codex/codex-model-catalog-probe.ts b/src/main/codex/codex-model-catalog-probe.ts index 77da423188a..6ecafa33e46 100644 --- a/src/main/codex/codex-model-catalog-probe.ts +++ b/src/main/codex/codex-model-catalog-probe.ts @@ -1,9 +1,11 @@ +import { codexStructuredLaunchArgs } from './codex-structured-launch-args' import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' import { AgentModelCatalogUnavailableError } from '../native-chat/agent-model-catalog/agent-model-catalog-unavailable' import { isMissingProviderExecutable } from '../provider-process/provider-executable-missing' import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' import { runCodexAppServerSession } from './codex-app-server-session' import { fetchCodexModelCatalogListing } from './codex-structured-model-catalog' +import { applyCodexConfiguredLaunchDefaults } from './codex-configured-launch-defaults' import { resolveCodexStructuredInvocation, type CodexStructuredInvocationDeps @@ -16,6 +18,10 @@ import type { } from '../native-chat/agent-model-catalog/agent-model-catalog-store' export type CodexModelCatalogProbeDeps = CodexStructuredInvocationDeps & { + /** Existing captured environment only: saved preference reads never recover a missing CLI. */ + resolveSavedEnvironment?: () => Promise + resolveEnv?: () => Record + resolveLaunchArgs?: () => Promise | string[] resolveAccountKind?: (home: string) => AgentSessionAccountKind | undefined /** The sync a launch runs on this home first, so the probe reads the login a launch would. */ prepareHome?: (home: string) => void @@ -43,61 +49,100 @@ function definedEnv(env: NodeJS.ProcessEnv | undefined): Record export function createCodexModelCatalogProbe( deps: CodexModelCatalogProbeDeps ): AgentModelCatalogProbe { - return async (accountHome, options): Promise => { + const resolve = async ( + accountHome: Parameters[0], + cwd?: string, + savedOnly = false + ) => { const accountHomePath = requireLegacyAgentSessionAccountHome(accountHome).path - const { command, environment } = await resolveCodexStructuredInvocation(deps) - deps.prepareHome?.(accountHomePath) - const run = deps.runSession ?? runCodexAppServerSession - const { listing, unavailable } = await run( - { - command, - args: [...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS], - cliPath: command, - env: { ...definedEnv(environment), CODEX_HOME: accountHomePath }, - timeoutMs: AGENT_MODEL_CATALOG_PROBE_TIMEOUT_MS, - ...(options?.signal ? { signal: options.signal } : {}) - }, - async (rpc) => { - // Both at once, so the account check adds no latency. - const listed = fetchCodexModelCatalogListing({ connection: rpc }) - void listed.catch(() => {}) - const response: unknown = await rpc - .request('account/read', { refreshToken: false }, { timeoutMs: 2_000 }) - // Unknown account status says nothing. - .catch(() => undefined) - if ( - typeof response !== 'object' || - response === null || - Array.isArray(response) || - !('requiresOpenaiAuth' in response) || - response.requiresOpenaiAuth !== true || - !('account' in response) || - response.account !== null - ) { - return { listing: await listed, unavailable: undefined } - } - const account = deps.resolveAccountKind?.(accountHomePath) - const signedOut = { reason: 'notSignedIn' as const, ...(account ? { account } : {}) } - // The verdict can be wrong (a gateway in Arguments), so a list in hand still fills the picker. - const inHand = await listed.catch(() => null) - if (!inHand?.models.length) { - throw new AgentModelCatalogUnavailableError(signedOut) - } - return { listing: inHand, unavailable: signedOut } - } - ).catch((error: unknown) => { - if (isMissingProviderExecutable(error, command)) { - throw new AgentModelCatalogUnavailableError({ reason: 'cliMissing' }) - } - throw error + const overlay = { ...deps.resolveEnv?.() } + const { command, environment } = await resolveCodexStructuredInvocation({ + ...deps, + resolveEnvironment: async () => ({ + ...(await (savedOnly ? deps.resolveSavedEnvironment?.() : deps.resolveEnvironment?.())), + ...overlay + }), + ...(savedOnly ? { refreshEnvironmentAfterMiss: undefined } : {}), + ...(cwd ? { resolveWorkingDirectory: () => cwd } : {}) }) - if (listing.models.length === 0) { - throw new Error('codex app-server listed no models') - } + const args = [ + ...(cwd ? codexStructuredLaunchArgs((await deps.resolveLaunchArgs?.()) ?? []) : []), + ...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS + ] + const env = { ...definedEnv(environment), CODEX_HOME: accountHomePath } return { - models: listing.models, - origin: 'probe', - ...(unavailable ? { unavailable } : {}) + identity: { command, args, cwd, overlay: Object.entries(overlay).sort() }, + run: async (signal?: AbortSignal): Promise => { + deps.prepareHome?.(accountHomePath) + const run = deps.runSession ?? runCodexAppServerSession + const { listing, unavailable } = await run( + { + command, + args, + ...(cwd ? { cwd } : {}), + cliPath: command, + env, + timeoutMs: AGENT_MODEL_CATALOG_PROBE_TIMEOUT_MS, + ...(signal ? { signal } : {}) + }, + async (rpc) => { + // Both at once, so the account check adds no latency. + const listed = fetchCodexModelCatalogListing({ connection: rpc, cwd }) + void listed.catch(() => {}) + const response: unknown = await rpc + .request('account/read', { refreshToken: false }, { timeoutMs: 2_000 }) + // Unknown account status says nothing. + .catch(() => undefined) + if ( + typeof response !== 'object' || + response === null || + Array.isArray(response) || + !('requiresOpenaiAuth' in response) || + response.requiresOpenaiAuth !== true || + !('account' in response) || + response.account !== null + ) { + return { listing: await listed, unavailable: undefined } + } + const account = deps.resolveAccountKind?.(accountHomePath) + const signedOut = { reason: 'notSignedIn' as const, ...(account ? { account } : {}) } + // The verdict can be wrong (a gateway in Arguments), so a list in hand still fills the picker. + const inHand = await listed.catch(() => null) + if (!inHand?.models.length) { + throw new AgentModelCatalogUnavailableError(signedOut) + } + return { listing: inHand, unavailable: signedOut } + } + ).catch((error: unknown) => { + if (isMissingProviderExecutable(error, command)) { + throw new AgentModelCatalogUnavailableError({ reason: 'cliMissing' }) + } + throw error + }) + if (listing.models.length === 0) { + throw new Error('codex app-server listed no models') + } + return { + models: cwd + ? listing.models + : applyCodexConfiguredLaunchDefaults(listing.models, { + model: listing.configured?.model ?? null, + effort: listing.configured?.effort ?? null + }), + complete: listing.complete !== false, + ...(cwd && listing.configured ? { configured: listing.configured } : {}), + origin: 'probe', + ...(unavailable ? { unavailable } : {}) + } + } } } + return Object.assign< + AgentModelCatalogProbe, + Pick + >( + async (home, options) => + (await resolve(home, options?.cwd ?? process.cwd())).run(options?.signal), + { resolve, resolveLegacy: (home) => resolve(home) } + ) } diff --git a/src/main/codex/codex-structured-acquire-catalog.ts b/src/main/codex/codex-structured-acquire-catalog.ts index d093032bbc8..f64207fb553 100644 --- a/src/main/codex/codex-structured-acquire-catalog.ts +++ b/src/main/codex/codex-structured-acquire-catalog.ts @@ -7,8 +7,29 @@ import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catal import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition' export function codexAcquireCatalogAccess( - deps: Pick, - launch: Pick + deps: Pick< + CodexStructuredSessionAdapterDeps, + 'modelCatalog' | 'readModelCatalog' | 'demandModelCatalog' + >, + launch: Pick, + sessionId?: string ): CodexSessionCatalogAccess | undefined { - return agentModelCatalogSessionAccess(deps.modelCatalog, CODEX_STRUCTURED_AGENT, launch.codexHome) + const access = agentModelCatalogSessionAccess( + deps.modelCatalog, + CODEX_STRUCTURED_AGENT, + launch.codexHome + ) + const demand = deps.demandModelCatalog + return access && sessionId + ? { + ...access, + ...(deps.readModelCatalog + ? { + read: (verifiedOnly?: boolean) => + deps.readModelCatalog?.(sessionId, verifiedOnly) ?? null + } + : {}), + ...(demand ? { demand: (legacy?: boolean) => demand(sessionId, legacy) } : {}) + } + : access } diff --git a/src/main/codex/codex-structured-background-catalog.ts b/src/main/codex/codex-structured-background-catalog.ts deleted file mode 100644 index 06bd08353b6..00000000000 --- a/src/main/codex/codex-structured-background-catalog.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' -import { fetchCodexModelCatalogListing } from './codex-structured-model-catalog' -import type { CodexSession } from './codex-structured-session-state' - -type BackgroundCatalogInput = { - session: CodexSession - sessionId: string - sessions: ReadonlyMap - timeoutMs: number | undefined - logger: StructuredAgentSessionLogger | undefined -} - -async function refresh(input: BackgroundCatalogInput): Promise { - const { session, sessionId, sessions } = input - const access = session.catalogAccess - const store = access?.store - const fingerprint = access?.fingerprint - const prior = fingerprint ? store?.get(fingerprint) : undefined - if ( - fingerprint && - store && - (store.hasActiveFailure(fingerprint) || (prior && !store.isStale(prior))) - ) { - return - } - const isCurrent = (): boolean => sessions.get(sessionId) === session && !session.ended - try { - const listing = await fetchCodexModelCatalogListing({ - connection: session.connection, - timeoutMs: input.timeoutMs, - deadlineMs: Math.min(input.timeoutMs ?? 30_000, 30_000) - }) - if (listing.models.length === 0) { - throw new Error('codex app-server returned no available models') - } - if (!isCurrent()) { - return - } - const latest = fingerprint ? store?.get(fingerprint) : undefined - if (latest && latest !== prior) { - return - } - if (fingerprint && store) { - // `model/list` is Codex's account-level listing, the one its probe also runs. - store.recordSuccess( - fingerprint, - 'codex', - { - models: listing.models, - origin: 'live-session' - }, - 'discovery' - ) - } - } catch (error) { - if (!isCurrent()) { - return - } - if (fingerprint && store && store.get(fingerprint) === prior) { - store.recordFailure(fingerprint, error instanceof Error ? error.message : String(error)) - } - input.logger?.warn('Codex model catalog refresh failed', { - scope: 'codex-background-catalog', - sessionId, - error - }) - } -} - -/** Catalog work begins after ownership and never joins a chat action. */ -export function startBackgroundCodexCatalogRefresh(input: BackgroundCatalogInput): void { - void refresh(input).catch((error: unknown) => { - try { - input.logger?.warn('Codex model catalog refresh failed', { - scope: 'codex-background-catalog', - sessionId: input.sessionId, - error - }) - } catch { - // Reporting must never reject an unawaited task. - } - }) -} diff --git a/src/main/codex/codex-structured-catalog-entry.ts b/src/main/codex/codex-structured-catalog-entry.ts index 5cc8723e33c..529a561cbc3 100644 --- a/src/main/codex/codex-structured-catalog-entry.ts +++ b/src/main/codex/codex-structured-catalog-entry.ts @@ -1,17 +1,45 @@ +import { structuredAgentSessionCapabilityView } from '../native-chat/agent-session-wire/structured-agent-session-option-models' import type { AgentModelCatalogEntry } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import type { CodexSessionCatalogAccess } from './codex-structured-session-state' +import type { CodexSession, CodexSessionCatalogAccess } from './codex-structured-session-state' import type { CodexModelCatalogListing } from './codex-structured-model-catalog' export function listingFromEntry(entry: AgentModelCatalogEntry): CodexModelCatalogListing { return { - models: entry.models.map((model) => ({ ...model })) + models: entry.models.map((model) => ({ ...model })), + complete: entry.discovered?.complete === true, + ...(entry.configured + ? { + configured: { + model: entry.configured.modelId, + ...(entry.configured.effort ? { effort: entry.configured.effort } : {}) + } + } + : {}) } } /** Acquisition uses known choices only; picker reads discover new choices later. */ export function codexAcquireCatalogListing( - catalogAccess: CodexSessionCatalogAccess | undefined + catalogAccess: CodexSessionCatalogAccess | undefined, + verifiedOnly = false ): CodexModelCatalogListing | null { - const entry = catalogAccess?.store.get(catalogAccess.fingerprint) + const entry = catalogAccess?.read + ? catalogAccess.read(verifiedOnly) + : catalogAccess?.store.get(catalogAccess.fingerprint) + if (verifiedOnly && entry && !catalogAccess?.read && !catalogAccess?.store.verified(entry)) { + return null + } return entry ? listingFromEntry(entry) : null } + +/** Installed inventory supplies every session's capability choices. */ +export function codexSessionCapabilities( + session: Pick & + Partial>, + model = session.options.get('model') ?? session.reportedOptions?.model +): ReturnType { + return structuredAgentSessionCapabilityView( + codexAcquireCatalogListing(session.catalogAccess) ?? { origin: 'unknown' }, + model + ) +} diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 949a69be20e..151f3bc7be9 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -47,7 +47,7 @@ export type CodexStructuredInvocation = { * The one place a structured Codex child's binary and environment are * resolved. The session launch and the session-less catalog probe both build * on it, so a probe can never list under a different binary or env than the - * session it stands in for. Env VALUES stay out of the catalog fingerprint: + * session it stands in for. Inherited env values stay out of the catalog fingerprint: * drift there heals on the next refresh. */ export function resolveCodexStructuredInvocation( diff --git a/src/main/codex/codex-structured-message-options.test.ts b/src/main/codex/codex-structured-message-options.test.ts index 0cfc3e39bca..00b4381df3c 100644 --- a/src/main/codex/codex-structured-message-options.test.ts +++ b/src/main/codex/codex-structured-message-options.test.ts @@ -105,6 +105,21 @@ describe('Codex captured message choices', () => { }) }) + it('sends saved literal model and effort after the installed choices change', async () => { + const request = vi.fn(async (_method: string, _params?: Record) => ({ + turn: { id: 'turn-1' } + })) + const session = optionSession(request) + seedMessageDefaults(session) + for (const model of ['model-b', 'retired-model']) { + session.activeTurnIds = new Set() + const turnOptions = await resolvedCodexMessageOptions(session, { model, effort: 'low' }) + await startCodexTurn(session, { clientMessageId: model, body: USER_MESSAGE, turnOptions }) + expect(request.mock.calls.at(-1)?.[0]).toBe('turn/start') + expect(request.mock.calls.at(-1)?.[1]).toMatchObject({ model, effort: 'low' }) + } + }) + it('resets an explicit empty choice after a thread launch and earlier turn override', async () => { const request = vi.fn(async (method: string, _params?: Record) => method === 'turn/start' @@ -166,7 +181,7 @@ describe('Codex captured message choices', () => { }) }) - it('reports what a turn ran: a listed effort for an unlisted thread one, a steer its turn', async () => { + it('reports what a turn ran: its thread effort, and a steer its turn', async () => { const request = vi.fn(async (method: string, _params?: Record) => method === 'turn/steer' ? { turnId: 'turn-1' } : { turn: { id: 'turn-1' } } ) @@ -184,7 +199,7 @@ describe('Codex captured message choices', () => { }, undefined ) - const ran = { model: 'model-a', effort: 'low' } + const ran = { model: 'model-a', effort: 'xhigh' } expect(await send('first', { model: 'model-a' })).toMatchObject({ ranOptions: ran }) expect(request.mock.calls[0]?.[1]).toMatchObject(ran) session.activeTurnIds = new Set(['turn-1']) diff --git a/src/main/codex/codex-structured-message-options.ts b/src/main/codex/codex-structured-message-options.ts index 52a93297fcb..de616d13641 100644 --- a/src/main/codex/codex-structured-message-options.ts +++ b/src/main/codex/codex-structured-message-options.ts @@ -26,11 +26,7 @@ export async function resolvedCodexMessageOptions( } const listedModel = listing?.models.find((entry) => entry.id === model) const threadEffort = model === thread.model ? thread.effort : undefined - const defaultEffort = - threadEffort && - (!listedModel || listedModel.efforts.some((entry) => entry.value === threadEffort)) - ? threadEffort - : listedModel?.defaultEffort + const defaultEffort = threadEffort ?? listedModel?.defaultEffort if (choices.effort === undefined && priorEffort && !defaultEffort) { throw new AgentSessionOptionRejectedError(new Error('codex default effort is unavailable')) } @@ -82,7 +78,8 @@ export async function resolvedCodexMessageOptions( choices.fastMode === 'true' || (choices.serviceTier && choices.serviceTier !== CODEX_DEFAULT_SERVICE_TIER)) ) { - await applyCodexStructuredSessionOption(selected, 'model', model) + // Captured intent was admitted when picked; an installation change cannot rewrite it. + selected.options.set('model', model) if (!defaultEffort && choices.effort === undefined) { selected.options.delete('effort') } @@ -92,13 +89,17 @@ export async function resolvedCodexMessageOptions( defaultEffort && (priorEffort || choices.model || selected.options.has('effort')) ) { - await applyCodexStructuredSessionOption(selected, 'effort', defaultEffort) + selected.options.set('effort', defaultEffort) } } for (const key of ['effort', 'personality', 'fastMode', 'serviceTier'] as const) { const value = choices[key] if (value !== undefined) { - await applyCodexStructuredSessionOption(selected, key, value) + if (key === 'fastMode') { + await applyCodexStructuredSessionOption(selected, key, value) + } else { + selected.options.set(key, value) + } } } return Object.fromEntries(selected.options) diff --git a/src/main/codex/codex-structured-model-catalog.ts b/src/main/codex/codex-structured-model-catalog.ts index c0c37a5516d..7bdf850218c 100644 --- a/src/main/codex/codex-structured-model-catalog.ts +++ b/src/main/codex/codex-structured-model-catalog.ts @@ -3,6 +3,7 @@ import type { AgentSessionOptionChoice, AgentSessionOptionsResult } from '../../shared/agent-session-wire' +import { effectiveServiceTier } from '../../shared/structured-agent-session-effective-tier' import type { CodexAppServerConnection } from './codex-app-server-connection' import { structuredAgentSessionOptionModels } from '../native-chat/agent-session-wire/structured-agent-session-option-models' import { @@ -75,6 +76,10 @@ function modelOption(value: unknown): AgentSessionModelOption | null { isDefault: row.isDefault === true, ...(defaultEffort ? { defaultEffort } : {}), efforts, + ...(!Array.isArray(row.supportedReasoningEfforts) || + efforts.length !== row.supportedReasoningEfforts.length + ? { effortsKnown: false as const } + : {}), ...(serviceTiers ? { serviceTiers, supportsFastMode: codexFastServiceTier({ serviceTiers }) !== undefined } : {}) @@ -85,6 +90,10 @@ export type CodexSessionOptionCatalog = AgentSessionOptionsResult & { current: { export type CodexModelCatalogListing = { models: AgentSessionModelOption[] + complete?: boolean + configured?: { model?: string; effort?: string } + /** Installed tier universe retained while the current child's row overrides its controls. */ + listedServiceTiers?: readonly string[] } /** One paginated `model/list` pass. The provider fetch and the shaping of a @@ -93,6 +102,7 @@ export async function fetchCodexModelCatalogListing(input: { connection: Pick timeoutMs?: number deadlineMs?: number + cwd?: string }): Promise { const deadline = input.deadlineMs === undefined ? null : Date.now() + input.deadlineMs const remainingTimeout = (): number | undefined => { @@ -107,6 +117,7 @@ export async function fetchCodexModelCatalogListing(input: { } const parsedModels: AgentSessionModelOption[] = [] let cursor: string | null = null + let complete = true for (let page = 0; page < MAX_MODEL_PAGES; page += 1) { const response = record( await input.connection.request( @@ -115,21 +126,47 @@ export async function fetchCodexModelCatalogListing(input: { { timeoutMs: remainingTimeout() } ) ) + if (!Array.isArray(response?.data)) { + complete = false + } const rows = Array.isArray(response?.data) ? response.data : [] for (const row of rows) { const parsed = modelOption(row) + if (!parsed && record(row)?.hidden !== true) { + complete = false + } if (parsed && !parsedModels.some((model) => model.id === parsed.id)) { parsedModels.push(parsed) } } + if ( + response?.nextCursor !== null && + response?.nextCursor !== undefined && + !text(response.nextCursor) + ) { + complete = false + } cursor = text(response?.nextCursor) if (!cursor) { break } } - const configured = await readCodexConfiguredLaunchDefaults(input.connection, remainingTimeout()) + const configured = await readCodexConfiguredLaunchDefaults( + input.connection, + remainingTimeout(), + input.cwd + ) return { - models: applyCodexConfiguredLaunchDefaults(parsedModels, configured) + models: applyCodexConfiguredLaunchDefaults(parsedModels, configured), + complete: complete && cursor === null && configured !== null, + ...(configured + ? { + configured: { + ...(configured.model ? { model: configured.model } : {}), + ...(configured.effort ? { effort: configured.effort } : {}) + } + } + : {}) } } @@ -143,7 +180,12 @@ export function composeCodexSessionOptionCatalog( } ): CodexSessionOptionCatalog { const models = structuredAgentSessionOptionModels( - listing.models.map((entry) => ({ ...entry })), + applyCodexConfiguredLaunchDefaults( + listing.models, + listing.configured + ? { model: listing.configured.model ?? null, effort: listing.configured.effort ?? null } + : null + ).map((entry) => ({ ...entry })), input.current.model, (row) => row ) @@ -154,7 +196,19 @@ export function composeCodexSessionOptionCatalog( const reportedTier = input.reportedServiceTierKnown ? (input.reportedServiceTier ?? CODEX_DEFAULT_SERVICE_TIER) : undefined - const serviceTier = input.current.serviceTier ?? reportedTier + const selectedTier = input.current.serviceTier ?? reportedTier + const modelTiers = models.find((row) => row.id === model)?.serviceTiers + const serviceTier = + selectedTier && modelTiers + ? effectiveServiceTier({ + tier: selectedTier, + modelTiers: modelTiers.map((row) => row.value), + listedTiers: + listing.listedServiceTiers ?? + models.flatMap((row) => row.serviceTiers?.map((tier) => tier.value) ?? []), + reported: selectedTier === reportedTier + }) + : selectedTier // Older clients only read Fast; any other tier leaves their toggle unset. const fastMode = codexFastModeOfServiceTier( serviceTier, diff --git a/src/main/codex/codex-structured-service-tier-shown.test.ts b/src/main/codex/codex-structured-service-tier-shown.test.ts index a3b604a95e0..50025ef024f 100644 --- a/src/main/codex/codex-structured-service-tier-shown.test.ts +++ b/src/main/codex/codex-structured-service-tier-shown.test.ts @@ -17,7 +17,7 @@ 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' +import { readLiveCodexSessionOptions } from './codex-structured-session-options' const tiers = (...values: string[]) => values.map((value) => ({ value, label: value })) // M1 offers Fast and Flex; M2 offers only Ultrafast. @@ -27,12 +27,39 @@ const MODELS: AgentSessionModelOption[] = [ ] /** 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' } - })) +async function hostRuns( + saved: Record, + ageMs = 0, + child?: { tiers?: readonly string[]; reported?: string } +) { + let now = 1 + const store = new AgentModelCatalogStore({ now: () => now }) + store.recordSuccess( + 'codex-account', + 'codex', + { models: MODELS, complete: true, origin: 'probe' }, + 'discovery' + ) + now += ageMs + let offline = false + const request = vi.fn(async (method: string, _params?: Record) => { + if (method !== 'model/list') { + return { turn: { id: 'turn-1' } } + } + if (offline) { + throw new Error('offline') + } + return { + data: [ + { + model: saved.model, + supportedReasoningEfforts: [], + ...(child?.tiers ? { additionalSpeedTiers: child.tiers } : {}) + } + ], + nextCursor: null + } + }) const session: CodexSession = { connection: { pid: 1, @@ -50,19 +77,30 @@ async function hostRuns(saved: Record) { threadId: 'thread-1', prompts: new CodexAcquisitionWindow().prompts, options: new Map(Object.entries(saved)), - reportedOptions: {}, + reportedOptions: child?.reported ? { serviceTier: child.reported, serviceTierKnown: true } : {}, catalogAccess: { store, fingerprint: 'codex-account', accountHomePath: '/account' }, dispatchEchoes: createCodexDispatchEchoes(), turnOpenWaits: createCodexTurnOpenWaits(), translator: null } + const shown = child + ? (await readLiveCodexSessionOptions(session, undefined)).current.serviceTier + : undefined + if (child) { + offline = true + store.recordFailure('codex-account', 'offline', 'codex') + } 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') } + await readLiveCodexSessionOptions(session, undefined) + return { + firstTurn: firstTurn?.serviceTier, + afterLiveRead: session.options.get('serviceTier'), + ...(child ? { shown } : {}) + } } /** A host's answer naming the saved values; an older host marks no defaults. */ @@ -82,6 +120,22 @@ function shownTier(state: StructuredAgentSessionOptionState): string | undefined } describe("a Codex chat's speed, shown and sent", () => { + it('uses installed tiers through discovery failure, retaining only reported opaque exceptions', async () => { + for (const [child, tier, runs] of [ + [{ tiers: ['priority'] }, 'priority', 'default'], + [{ tiers: ['ultrafast'] }, 'priority', 'default'], + [{}, 'priority', 'default'], + [{ tiers: [] }, 'priority', 'default'], + [{ tiers: [], reported: 'opaque' }, 'opaque', 'opaque'], + [{ tiers: [], reported: 'ultrafast' }, 'ultrafast', 'ultrafast'] + ] as const) { + await expect(hostRuns({ model: 'm2', serviceTier: tier }, 120_000, child)).resolves.toEqual({ + firstTurn: runs, + shown: runs, + afterLiveRead: tier + }) + } + }) 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 [ @@ -92,7 +146,12 @@ describe("a Codex chat's speed, shown and sent", () => { 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 }) + for (const ageMs of [0, 2 * 60_000]) { + await expect(hostRuns(saved, ageMs)).resolves.toEqual({ + firstTurn: runs, + afterLiveRead: saved.serviceTier + }) + } } // Back on a model that offers it, the saved tier is the chat's again. const back = commitStructuredAgentSessionOption( diff --git a/src/main/codex/codex-structured-service-tier.test.ts b/src/main/codex/codex-structured-service-tier.test.ts index b73d8c8df38..a01d845b6b1 100644 --- a/src/main/codex/codex-structured-service-tier.test.ts +++ b/src/main/codex/codex-structured-service-tier.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, type Mock } from 'vitest' +import { describe, expect, it } from 'vitest' import { acquireReadyCodexForTest, codexStartReport, @@ -7,13 +7,9 @@ import { adapterFor, answerWithOpenedTurn, fakeCodex, - identityFor, - type Route + identityFor } from './codex-structured-session-adapter-fixture' -import { - AGENT_MODEL_CATALOG_FAILURE_TTL_MS, - AgentModelCatalogStore -} from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' import type { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition' @@ -52,7 +48,7 @@ function finishTurn(codex: ReturnType, turnId: string) { } describe('Codex structured service tier without send-path catalog waits', () => { - it('starts the chat and sends the saved tier when its own listing rejects', async () => { + it('starts the chat and sends the saved tier without asking its child for an inventory', async () => { const codex = fakeCodex({ 'model/list': () => { throw new Error('model listing unavailable') @@ -71,27 +67,8 @@ describe('Codex structured service tier without send-path catalog waits', () => ).toMatchObject({ serviceTier: 'priority' }) }) - it('sends the saved tier while its own bounded listing is still pending', async () => { - const pending = Promise.withResolvers() - const codex = fakeCodex({ 'model/list': () => pending.promise }) - codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'first-turn') - const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { - modelCatalog: new AgentModelCatalogStore() - }) - await acquire(adapter) - await vi.waitFor(() => - expect(codex.connections[0].calls.some((call) => call.method === 'model/list')).toBe(true) - ) - await send(adapter, 'first') - expect( - codex.connections[0].calls.find((call) => call.method === 'turn/start')?.params - ).toMatchObject({ serviceTier: 'priority' }) - pending.resolve(listing()) - }) - - it('steers an active turn while catalog discovery remains pending', async () => { - const pending = Promise.withResolvers() - const codex = fakeCodex({ 'model/list': () => pending.promise }) + it('steers an active turn without querying the child inventory', async () => { + const codex = fakeCodex() codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'running-turn') codex.routes['turn/steer'] = () => ({ turn: { id: 'running-turn' } }) const adapter = adapterFor(codex) @@ -100,14 +77,12 @@ describe('Codex structured service tier without send-path catalog waits', () => await send(adapter, 'follow-up') expect(codex.connections[0].calls.some((call) => call.method === 'turn/steer')).toBe(true) expect(codex.connections[0].calls.filter((call) => call.method === 'model/list')).toHaveLength( - 1 + 0 ) - pending.resolve(listing()) }) - it('stops a running turn and starts its replacement on the saved tier while listing stalls', async () => { - const pending = Promise.withResolvers() - const codex = fakeCodex({ 'model/list': () => pending.promise }) + it('stops a running turn and starts its replacement on the saved tier', async () => { + const codex = fakeCodex() codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'running-turn') const adapter = adapterFor(codex) await acquire(adapter) @@ -122,69 +97,6 @@ describe('Codex structured service tier without send-path catalog waits', () => const starts = codex.connections[0].calls.filter((call) => call.method === 'turn/start') expect(starts).toHaveLength(2) expect(starts[1]?.params).toMatchObject({ serviceTier: 'priority' }) - pending.resolve(listing()) - }) - - it('records a background failure without delaying a send and retries after the failure TTL', async () => { - let now = 1_000 - const clock = vi.spyOn(Date, 'now').mockImplementation(() => now) - const modelCatalog = new AgentModelCatalogStore({ now: () => now }) - const listModels: Mock = vi - .fn() - .mockImplementationOnce(() => { - throw new Error('catalog unavailable') - }) - .mockImplementation(() => listing()) - const codex = fakeCodex({ 'model/list': listModels }) - codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-tier') - const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog }) - const access = agentModelCatalogSessionAccess( - modelCatalog, - CODEX_STRUCTURED_AGENT, - '/codex/home' - )! - try { - await acquire(adapter) - await vi.waitFor(() => expect(modelCatalog.hasActiveFailure(access.fingerprint)).toBe(true)) - await send(adapter, 'first') - expect( - codex.connections[0].calls.find((call) => call.method === 'turn/start')?.params - ).toMatchObject({ serviceTier: 'priority' }) - await acquire(adapter, 'session-2') - expect(listModels).toHaveBeenCalledOnce() - now += AGENT_MODEL_CATALOG_FAILURE_TTL_MS - await acquire(adapter, 'session-3') - await vi.waitFor(() => expect(listModels).toHaveBeenCalledTimes(2)) - } finally { - clock.mockRestore() - } - }) - - it('does not record an obsolete child’s catalog over its replacement’s', async () => { - const oldListing = Promise.withResolvers() - const newListing = Promise.withResolvers() - const codex = fakeCodex({ - 'model/list': vi - .fn() - .mockImplementationOnce(() => oldListing.promise) - .mockImplementationOnce(() => newListing.promise) - }) - const modelCatalog = new AgentModelCatalogStore() - const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog }) - const access = agentModelCatalogSessionAccess( - modelCatalog, - CODEX_STRUCTURED_AGENT, - '/codex/home' - )! - await acquire(adapter) - await acquire(adapter) - oldListing.resolve(listing('priority-old')) - newListing.resolve(listing('priority-new')) - await vi.waitFor(() => - expect(modelCatalog.get(access.fingerprint)?.models[0]?.serviceTiers?.[0]?.value).toBe( - 'priority-new' - ) - ) }) it('restores the saved model and tier over the resumed thread’s', async () => { @@ -233,7 +145,8 @@ describe('Codex service tier across clients and rest', () => { serviceTiers: [{ value: 'priority', label: 'Fast' }] } ], - origin: 'probe' + origin: 'probe', + complete: true }, 'discovery' ) diff --git a/src/main/codex/codex-structured-service-tier.ts b/src/main/codex/codex-structured-service-tier.ts index caf0ad7bd36..cb884f70131 100644 --- a/src/main/codex/codex-structured-service-tier.ts +++ b/src/main/codex/codex-structured-service-tier.ts @@ -6,7 +6,7 @@ import type { import { readRecord } from './codex-item-field-readers' import type { CodexOpenedThread } from './codex-structured-thread-open' import type { CodexSession } from './codex-structured-session-state' -import { codexAcquireCatalogListing } from './codex-structured-catalog-entry' +import { codexSessionCapabilities } from './codex-structured-catalog-entry' import { effectiveServiceTier } from '../../shared/structured-agent-session-effective-tier' /** Codex's request value for a model's standard tier. */ @@ -94,45 +94,56 @@ export function codexOffersServiceTier( session: Pick, model: AgentSessionModelOption | undefined, models: readonly AgentSessionModelOption[], - tier: string + tier: string, + listedTiers: readonly string[] = models.flatMap(tierValues) ): boolean { - const listedTiers = models.flatMap(tierValues) const reported = tier === session.reportedOptions.serviceTier return ( - effectiveServiceTier({ tier, modelTiers: tierValues(model), listedTiers, reported }) === tier + effectiveServiceTier({ + tier, + modelTiers: model?.serviceTiers?.map((row) => row.value), + 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'] - } + session: Parameters[0] ): Record { const options = Object.fromEntries(session.options) const reportedOptions = session.reportedOptions ?? {} - const models = codexAcquireCatalogListing(session.catalogAccess)?.models ?? [] + const capabilities = codexSessionCapabilities(session) + const models = capabilities.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) + !codexOffersServiceTier( + { reportedOptions }, + model, + models, + tier, + capabilities.listedServiceTiers + ) ) { 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, - modelId: string | undefined, - models: readonly AgentSessionModelOption[] -): void { - const tier = session.options.get('serviceTier') ?? session.reportedOptions.serviceTier - const model = models.find((entry) => entry.id === modelId) - if (tier && model?.serviceTiers && !codexOffersServiceTier(session, model, models, tier)) { - session.options.set('serviceTier', CODEX_DEFAULT_SERVICE_TIER) +export function projectCodexSessionOptions( + session: CodexSession | undefined, + options: Readonly> +) { + if (!session || session.ended || session.connection.closed) { + return undefined + } + const selected = { ...session, options: new Map(Object.entries(options)) } + return { + models: codexSessionCapabilities(selected).models, + options: codexTurnRequestOptions(selected) } } diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index c43919a6a45..f51b2f4f613 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -46,8 +46,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { expect(codex.connections[0].calls.map((call) => call.method)).toEqual([ 'initialize', 'thread/start', - 'skills/list', - 'model/list' + 'skills/list' ]) }) @@ -92,7 +91,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { }) }) - it('leaves a rejected listing as a catalog error after the thread opens', async () => { + it('keeps current intent usable without inventing controls when a listing fails', async () => { const codex = fakeCodex({ 'model/list': () => { throw new Error('stub model/list rejected') @@ -103,16 +102,38 @@ describe('CodexStructuredSessionAdapter.acquire', () => { await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, - spawnToken: 'spawn-9' + spawnToken: 'spawn-9', + options: { model: 'gpt-live', effort: 'medium' } }) expect(codexStartReport(adapter).reportedOptions).toEqual({ model: 'gpt-live', effort: 'medium' }) - await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).rejects.toThrow( - 'stub model/list rejected' - ) + await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + current: { model: 'gpt-live', effort: 'medium' }, + models: [ + { + id: 'gpt-live', + label: 'gpt-live', + isDefault: false, + efforts: [], + effortsKnown: false + } + ] + }) + codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-1') + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'after-listing-failure', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toMatchObject({ state: 'admitted' }) + expect( + codex.connections[0].calls.findLast((call) => call.method === 'turn/start')?.params + ).toMatchObject({ model: 'gpt-live', effort: 'medium' }) expect(codex.connections[0].closeCount).toBe(0) }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 92e112d8f6f..d895e1b0c24 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -1,3 +1,4 @@ +import { projectCodexSessionOptions } from './codex-structured-service-tier' import * as codexRewind from './codex-structured-rewind' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' @@ -321,6 +322,9 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap readOptions = (input: { sessionId: string; fence: number }) => readLiveCodexSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) + projectOptions = (sessionId: string, options: Readonly>) => + projectCodexSessionOptions(this.sessions.get(sessionId), options) + prepareReadOptions = (input: { sessionId: string; fence: number }) => prepareLiveCodexSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) diff --git a/src/main/codex/codex-structured-session-options-catalog.test.ts b/src/main/codex/codex-structured-session-options-catalog.test.ts index 4cfc1dca710..e04d83e308f 100644 --- a/src/main/codex/codex-structured-session-options-catalog.test.ts +++ b/src/main/codex/codex-structured-session-options-catalog.test.ts @@ -1,331 +1,22 @@ -import { describe, expect, it, vi } from 'vitest' -import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' -import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' -import type { CodexAppServerConnection } from './codex-app-server-connection' -import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' +import type { AgentSessionOptionsResult } from '../../shared/agent-session-wire' +import { projectCodexSessionOptions } from './codex-structured-service-tier' import { applyCodexStructuredSessionOption, readLiveCodexSessionOptions } from './codex-structured-session-options' +import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { optionSession } from './codex-structured-option-session.test-fixture' +import { describe, expect, it, vi } from 'vitest' import { fetchCodexModelCatalogListing } from './codex-structured-model-catalog' -import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' -import type { CodexSession } from './codex-structured-session-state' -import { - AGENT_MODEL_CATALOG_FAILURE_TTL_MS, - AGENT_MODEL_CATALOG_FRESH_MS, - AgentModelCatalogStore, - type AgentModelCatalogProbe -} from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import { AgentModelCatalogUnavailableError } from '../native-chat/agent-model-catalog/agent-model-catalog-unavailable' -import { createAgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' -import { agentModelCatalogFingerprint } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' -const FINGERPRINT = 'fp-session-account' - -function modelRow(id: string, isDefault = false): Record { +function modelRow(id: string): Record { return { model: id, displayName: id.toUpperCase(), - hidden: false, - supportedReasoningEfforts: [{ reasoningEffort: 'high' }], - defaultReasoningEffort: 'high', - isDefault + supportedReasoningEfforts: [{ reasoningEffort: 'high' }] } } -function listAnswer(...ids: string[]): { data: Record[]; nextCursor: null } { - return { data: ids.map((id, index) => modelRow(id, index === 0)), nextCursor: null } -} - -// Each listing also reads the configured defaults; count the provider fetches only. -function modelListCalls(request: { mock: { calls: unknown[][] } }): number { - return request.mock.calls.filter(([method]) => method === 'model/list').length -} - -function storeSession( - request: CodexAppServerConnection['request'], - store: AgentModelCatalogStore -): CodexSession { - return { - 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(), - reportedOptions: { model: 'gpt-live', effort: 'high' }, - dispatchEchoes: createCodexDispatchEchoes(), - turnOpenWaits: createCodexTurnOpenWaits(), - translator: null, - catalogAccess: { store, fingerprint: FINGERPRINT, accountHomePath: '/homes/a' } - } -} - -function seedEntry(store: AgentModelCatalogStore, ...ids: string[]): void { - store.recordSuccess( - FINGERPRINT, - 'codex', - { - models: ids.map((id, index) => ({ - id, - label: id.toUpperCase(), - isDefault: index === 0, - efforts: [{ value: 'high', label: 'High' }], - defaultEffort: 'high' - })), - origin: 'live-session' - }, - 'discovery' - ) -} - -describe('Codex session options through the host catalog store', () => { - it("keeps the probe's signed-out verdict when the chat's own picker lists", async () => { - const store = new AgentModelCatalogStore({ now: () => 1000 }) - const signedOut: AgentModelCatalogProbe = async () => { - throw new AgentModelCatalogUnavailableError({ reason: 'notSignedIn', account: 'system' }) - } - await store.refresh(FINGERPRINT, 'codex', signedOut, () => - signedOut({ variable: 'CODEX_HOME', path: '/homes/a' }) - ) - const request = vi.fn(async () => listAnswer('gpt-live')) - const result = await readLiveCodexSessionOptions(storeSession(request, store), undefined) - expect(result.models.map((model) => model.id)).toEqual(['gpt-live']) - expect(store.failure(FINGERPRINT)?.unavailable).toEqual({ - reason: 'notSignedIn', - account: 'system' - }) - }) - - it('never re-lists for an aged signed-out verdict beside a fresh entry', async () => { - let at = 1_000 - const store = new AgentModelCatalogStore({ now: () => at }) - seedEntry(store, 'gpt-live') - const signedOut: AgentModelCatalogProbe = async () => { - throw new AgentModelCatalogUnavailableError({ reason: 'notSignedIn', account: 'system' }) - } - await store.refresh(FINGERPRINT, 'codex', signedOut, () => - signedOut({ variable: 'CODEX_HOME', path: '/homes/a' }) - ) - at += AGENT_MODEL_CATALOG_FAILURE_TTL_MS - const request = vi.fn(async () => listAnswer('gpt-live')) - const session = storeSession(request, store) - // Only a probe re-derives the verdict, so the chat's own listing would repeat on every read. - await readLiveCodexSessionOptions(session, undefined) - await readLiveCodexSessionOptions(session, undefined) - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(modelListCalls(request)).toBe(0) - expect(store.probeDue(FINGERPRINT)).toBe(true) - }) - - it('lists once at the first read and serves every later read from the store', async () => { - const store = new AgentModelCatalogStore() - const request = vi.fn(async () => listAnswer('gpt-live', 'gpt-next')) - const session = storeSession(request, store) - // The acquire-time restore read. - const first = await readLiveCodexSessionOptions(session, undefined) - expect(first.models.map((model) => model.id)).toEqual(['gpt-live', 'gpt-next']) - expect(modelListCalls(request)).toBe(1) - // The picker's first read after attach must not pay a second listing. - const second = await readLiveCodexSessionOptions(session, undefined) - expect(second.models.map((model) => model.id)).toEqual(['gpt-live', 'gpt-next']) - expect(modelListCalls(request)).toBe(1) - // The write-through landed under the session's spawn-pinned key only. - expect(store.get(FINGERPRINT)!.models.map((model) => model.id)).toEqual([ - 'gpt-live', - 'gpt-next' - ]) - expect(store.get('some-other-account')).toBeNull() - }) - - it('restores a new chat from its own connection while a session-less probe hangs', async () => { - const store = new AgentModelCatalogStore() - // Opening the chat's picker kicked the host probe for this account; its Codex never answers. - const hungProbe: AgentModelCatalogProbe = () => new Promise(() => {}) - void store.refresh(FINGERPRINT, 'codex', hungProbe, () => - hungProbe({ variable: 'CODEX_HOME', path: '/homes/a' }) - ) - const request = vi.fn(async () => listAnswer('gpt-live')) - const session = storeSession(request, store) - // The acquire-time restore read: joining the probe would fail the chat at the probe's deadline. - const result = await readLiveCodexSessionOptions(session, undefined) - expect(result.models.map((model) => model.id)).toEqual(['gpt-live']) - expect(modelListCalls(request)).toBe(1) - }) - - it('restores a new chat while the probe its opening picker read kicked hangs', async () => { - const store = new AgentModelCatalogStore() - const fingerprint = agentModelCatalogFingerprint({ - agent: 'codex', - accountHomeVariable: 'CODEX_HOME', - accountHomePath: '/homes/a', - wslDistro: null - }) - const hungProbe = vi.fn(() => new Promise(() => {})) - const service = createAgentModelCatalogService({ - store, - getRecord: () => undefined, - drivesRecord: () => true, - resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/homes/a' }), - probes: { codex: hungProbe } - }) - expect(await service.read({ agent: 'codex' })).toEqual({ - origin: 'unknown', - listingInProgress: true - }) - expect(hungProbe).toHaveBeenCalledTimes(1) - const request = vi.fn(async () => listAnswer('gpt-live')) - const session = storeSession(request, store) - session.catalogAccess = { store, fingerprint, accountHomePath: '/homes/a' } - const result = await readLiveCodexSessionOptions(session, undefined) - expect(result.models.map((model) => model.id)).toEqual(['gpt-live']) - // The picker's waiting read now answers from the chat's listing. - const picker = await service.read({ agent: 'codex', waitForListing: true }) - expect(picker.origin === 'unknown' ? null : picker.models[0]!.id).toBe('gpt-live') - }) - - it("restores a new chat from its own connection while another chat's listing hangs", async () => { - const store = new AgentModelCatalogStore() - // Another chat on the same account is mid-listing and its Codex never answers. - const wedged = storeSession( - vi.fn(() => new Promise(() => {})), - store - ) - void readLiveCodexSessionOptions(wedged, undefined) - const request = vi.fn(async () => listAnswer('gpt-live')) - const session = storeSession(request, store) - const result = await readLiveCodexSessionOptions(session, undefined) - expect(result.models.map((model) => model.id)).toEqual(['gpt-live']) - expect(modelListCalls(request)).toBe(1) - }) - - it("shares one listing between a chat's own concurrent reads", async () => { - const store = new AgentModelCatalogStore() - let answer!: () => void - const answered = new Promise((resolve) => (answer = resolve)) - const request = vi.fn(async () => { - await answered - return listAnswer('gpt-live') - }) - const session = storeSession(request, store) - const reads = [ - readLiveCodexSessionOptions(session, undefined), - readLiveCodexSessionOptions(session, undefined) - ] - answer() - for (const result of await Promise.all(reads)) { - expect(result.models.map((model) => model.id)).toEqual(['gpt-live']) - } - expect(modelListCalls(request)).toBe(1) - }) - - it('answers the picker with zero provider fetches when the store is already warm', async () => { - const store = new AgentModelCatalogStore() - seedEntry(store, 'gpt-live', 'gpt-next') - const request = vi.fn(async () => listAnswer('gpt-live')) - const session = storeSession(request, store) - const result = await readLiveCodexSessionOptions(session, undefined) - expect(result.models.map((model) => model.id)).toEqual(['gpt-live', 'gpt-next']) - expect(result.current.model).toBe('gpt-live') - expect(request).not.toHaveBeenCalled() - }) - - it('serves a stale entry immediately and refreshes it behind the answer', async () => { - let at = 1_000 - const store = new AgentModelCatalogStore({ now: () => at }) - seedEntry(store, 'gpt-live') - at += AGENT_MODEL_CATALOG_FRESH_MS - const request = vi.fn(async () => listAnswer('gpt-live', 'gpt-new')) - const session = storeSession(request, store) - const result = await readLiveCodexSessionOptions(session, undefined) - // The stale models answer the read; the refetch happens off this path. - expect(result.models.map((model) => model.id)).toEqual(['gpt-live']) - await vi.waitFor(() => { - expect(store.get(FINGERPRINT)!.models.map((model) => model.id)).toEqual([ - 'gpt-live', - 'gpt-new' - ]) - }) - expect(modelListCalls(request)).toBe(1) - }) - - it('never blocks a read behind an in-flight background refresh', async () => { - let at = 1_000 - const store = new AgentModelCatalogStore({ now: () => at }) - seedEntry(store, 'gpt-live') - at += AGENT_MODEL_CATALOG_FRESH_MS - // The provider never answers; the stale entry must still answer instantly. - const request = vi.fn(() => new Promise(() => {})) - const session = storeSession(request, store) - const result = await readLiveCodexSessionOptions(session, undefined) - expect(result.models.map((model) => model.id)).toEqual(['gpt-live']) - expect(modelListCalls(request)).toBe(1) - }) - - it('accepts a new model after the picker refreshes a stale entry', async () => { - let at = 1_000 - const store = new AgentModelCatalogStore({ now: () => at }) - seedEntry(store, 'gpt-live') - at += AGENT_MODEL_CATALOG_FRESH_MS - const request = vi.fn(async () => listAnswer('gpt-live', 'gpt-next')) - const session = storeSession(request, store) - expect((await readLiveCodexSessionOptions(session, undefined)).models.map((m) => m.id)).toEqual( - ['gpt-live'] - ) - await vi.waitFor(() => - expect(store.get(FINGERPRINT)!.models.map((model) => model.id)).toEqual([ - 'gpt-live', - 'gpt-next' - ]) - ) - expect((await readLiveCodexSessionOptions(session, undefined)).models.map((m) => m.id)).toEqual( - ['gpt-live', 'gpt-next'] - ) - const committed = await applyCodexStructuredSessionOption(session, 'model', 'gpt-next') - expect(committed.model).toBe('gpt-next') - expect(modelListCalls(request)).toBe(1) - }) - - it('refuses a model missing from a young entry without refetching', async () => { - const store = new AgentModelCatalogStore() - seedEntry(store, 'gpt-live') - const request = vi.fn(async () => listAnswer('gpt-live', 'gpt-next')) - const session = storeSession(request, store) - await expect(applyCodexStructuredSessionOption(session, 'model', 'gpt-next')).rejects.toThrow( - /does not offer model gpt-next/ - ) - expect(request).not.toHaveBeenCalled() - }) - - it('keeps the stored entry when a picker refresh fails', async () => { - let at = 1_000 - const store = new AgentModelCatalogStore({ now: () => at }) - seedEntry(store, 'gpt-live') - at += AGENT_MODEL_CATALOG_FRESH_MS - const request = vi.fn(async () => { - throw new Error('provider gone') - }) - const session = storeSession(request, store) - await readLiveCodexSessionOptions(session, undefined) - await expect(applyCodexStructuredSessionOption(session, 'model', 'gpt-next')).rejects.toThrow( - /does not offer model gpt-next/ - ) - expect(modelListCalls(request)).toBe(1) - // The failed refresh never displaced the last good listing. - expect(store.get(FINGERPRINT)!.models.map((model) => model.id)).toEqual(['gpt-live']) - }) -}) - describe('first-turn catalog deadline', () => { it('budgets all pages and config/read against one 30-second deadline', async () => { let now = 1_000 @@ -356,3 +47,39 @@ describe('first-turn catalog deadline', () => { } }) }) + +it('uses installed choices after an update and keeps saved values for the next turn', async () => { + const request = vi.fn(async () => { + throw new Error('child catalog must not be read') + }) + const session = optionSession(request) + session.options = new Map([ + ['model', 'A'], + ['effort', 'low'] + ]) + const store = new AgentModelCatalogStore() + const install = (models: AgentSessionOptionsResult['models']) => + store.recordSuccess('scope', 'codex', { origin: 'probe', complete: true, models }, 'discovery') + install([{ id: 'A', label: 'A', isDefault: true, efforts: [{ value: 'high', label: 'High' }] }]) + session.catalogAccess = { + store, + fingerprint: 'scope', + accountHomePath: '/account', + demand: async () => {} + } + const before = await readLiveCodexSessionOptions(session, 100) + expect(before.models[0]?.efforts).toEqual([{ value: 'high', label: 'High' }]) + expect(before.current).toMatchObject({ model: 'A', effort: 'low' }) + await expect(applyCodexStructuredSessionOption(session, 'effort', 'low')).rejects.toThrow( + 'does not support low' + ) + install([{ id: 'B', label: 'B', isDefault: true, efforts: [] }]) + const after = await readLiveCodexSessionOptions(session, 100) + expect(after.models.map((row) => row.id)).toEqual(['B', 'A']) + expect(after.models[1]).toMatchObject({ efforts: [], effortsKnown: false }) + expect(after.current).toMatchObject({ model: 'A', effort: 'low' }) + expect(projectCodexSessionOptions(session, Object.fromEntries(session.options))?.options).toEqual( + { model: 'A', effort: 'low' } + ) + expect(request).not.toHaveBeenCalled() +}) diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index 3d5577694d1..b670f988472 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it, vi } from 'vitest' import { applyCodexStructuredSessionOption, readCodexStructuredSessionOptions, + readCodexStructuredSessionOptionCatalog, readLiveCodexSessionOptions, restoredCodexSessionOptions } from './codex-structured-session-options' @@ -25,7 +26,21 @@ async function primePicker(session: CodexSession): Promise { fingerprint: 'codex-test-account', accountHomePath: '/test-account' } - await readLiveCodexSessionOptions(session, undefined) + const listing = await readCodexStructuredSessionOptionCatalog({ + connection: session.connection, + current: {} + }) + session.catalogAccess.store.recordSuccess( + session.catalogAccess.fingerprint, + 'codex', + { + models: listing.models, + origin: 'probe', + complete: true, + ...(listing.fastModeSupport ? { fastModeSupport: listing.fastModeSupport } : {}) + }, + 'discovery' + ) } describe('structured Codex session options', () => { @@ -334,7 +349,7 @@ describe('structured Codex session options', () => { ) }) - it('drops a restored tier to Standard when the selected model no longer lists it', async () => { + it('runs a restored tier as Standard without erasing the pick when the selected model no longer lists it', async () => { const requests: { method: string; params?: Record }[] = [] const session = optionSession( vi.fn(async (method: string, params?: Record) => { @@ -354,11 +369,12 @@ describe('structured Codex session options', () => { }) ) session.options.set('serviceTier', 'priority') + await primePicker(session) await expect(readLiveCodexSessionOptions(session, undefined)).resolves.toMatchObject({ current: { serviceTier: 'default', fastMode: false } }) - expect(Object.fromEntries(session.options)).toEqual({ serviceTier: 'default' }) + expect(Object.fromEntries(session.options)).toEqual({ serviceTier: 'priority' }) await startCodexTurn(session, { clientMessageId: 'message-standard', @@ -466,7 +482,7 @@ describe('structured Codex session options', () => { expect(result.fastModeSupport).toEqual({ supported: true }) }) - it('drops to Standard when switching to a model without the picked tier', async () => { + it('retains the tier pick when switching to a model without the picked tier', async () => { const session = optionSession( vi.fn(async () => ({ data: [ @@ -485,7 +501,7 @@ describe('structured Codex session options', () => { await expect( applyCodexStructuredSessionOption(session, 'model', 'gpt-standard') - ).resolves.toMatchObject({ model: 'gpt-standard', serviceTier: 'default' }) + ).resolves.toMatchObject({ model: 'gpt-standard', serviceTier: 'priority-x' }) }) }) @@ -562,7 +578,7 @@ describe('Codex service tiers', () => { await applyCodexStructuredSessionOption(session, 'serviceTier', 'ultrafast') await expect( applyCodexStructuredSessionOption(session, 'model', 'gpt-6-luna') - ).resolves.toMatchObject({ model: 'gpt-6-luna', serviceTier: 'default' }) + ).resolves.toMatchObject({ model: 'gpt-6-luna', serviceTier: 'ultrafast' }) }) }) @@ -632,28 +648,28 @@ describe('Codex reported tier', () => { it.each([ ['untouched', []], ['re-picked', ['priority', 'ultrafast']] - ])( - 'resets a %s reported tier another model lists on a switch to a model without it', - async (_case, picks) => { - const session = optionSession(vi.fn(async () => listing)) - session.reportedOptions = { - model: 'gpt-6.1-sol', - serviceTier: 'ultrafast', - serviceTierKnown: true - } - await primePicker(session) - for (const pick of picks) { - await applyCodexStructuredSessionOption(session, 'serviceTier', pick) - } - - await expect( - applyCodexStructuredSessionOption(session, 'model', 'gpt-6-luna') - ).resolves.toEqual({ model: 'gpt-6-luna', serviceTier: 'default' }) - await expect( - applyCodexStructuredSessionOption(session, 'serviceTier', 'ultrafast') - ).rejects.toThrow('does not offer the ultrafast tier') + ])('preserves a %s tier choice when switching models', async (_case, picks) => { + const session = optionSession(vi.fn(async () => listing)) + session.reportedOptions = { + model: 'gpt-6.1-sol', + serviceTier: 'ultrafast', + serviceTierKnown: true } - ) + await primePicker(session) + for (const pick of picks) { + await applyCodexStructuredSessionOption(session, 'serviceTier', pick) + } + + await expect( + applyCodexStructuredSessionOption(session, 'model', 'gpt-6-luna') + ).resolves.toEqual({ + model: 'gpt-6-luna', + ...(picks.length ? { serviceTier: 'ultrafast' } : {}) + }) + await expect( + applyCodexStructuredSessionOption(session, 'serviceTier', 'ultrafast') + ).rejects.toThrow('does not offer the ultrafast tier') + }) }) describe('Codex option picks before the model list arrives', () => { @@ -669,9 +685,7 @@ describe('Codex option picks before the model list arrives', () => { fence: 7, spawnToken: 'spawn-1' }) - await vi.waitFor(() => - expect(codex.connections[0].calls.some((call) => call.method === 'model/list')).toBe(true) - ) + expect(codex.connections[0].calls.some((call) => call.method === 'model/list')).toBe(false) const pick = (key: string, value: string) => adapter.setOption({ sessionId: 'session-1', key, value, fence: 7 }) @@ -692,7 +706,7 @@ describe('Codex option picks before the model list arrives', () => { codex.connections[0].calls.find((call) => call.method === 'turn/start')?.params ).toMatchObject({ model: 'gpt-next', effort: 'low', serviceTier: 'priority' }) expect(codex.connections[0].calls.filter((call) => call.method === 'model/list')).toHaveLength( - 1 + 0 ) pending.resolve({ data: [], nextCursor: null }) }) diff --git a/src/main/codex/codex-structured-session-options.ts b/src/main/codex/codex-structured-session-options.ts index bbac1d4c300..f9824f921c9 100644 --- a/src/main/codex/codex-structured-session-options.ts +++ b/src/main/codex/codex-structured-session-options.ts @@ -1,3 +1,5 @@ +import type { StructuredAgentSessionLiveOptions } from '../native-chat/agent-session-wire/structured-agent-session-option-models' +import { agentSessionEffortLevels } from '../../shared/agent-session-effort-levels' import type { AgentSessionOptionsResult } from '../../shared/agent-session-wire' import type { CodexAppServerConnection } from './codex-app-server-connection' import type { CodexSession } from './codex-structured-session-state' @@ -7,17 +9,17 @@ import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured import { CODEX_DEFAULT_SERVICE_TIER, codexFastServiceTier, - codexOffersServiceTier, - dropUnlistedCodexServiceTier + codexOffersServiceTier } from './codex-structured-service-tier' import { composeCodexSessionOptionCatalog, - fetchCodexModelCatalogListing, readCodexStructuredSessionOptionCatalog, - type CodexModelCatalogListing, type CodexSessionOptionCatalog } from './codex-structured-model-catalog' -import { codexAcquireCatalogListing, listingFromEntry } from './codex-structured-catalog-entry' +import { + codexAcquireCatalogListing, + codexSessionCapabilities +} from './codex-structured-catalog-entry' export { codexAcquireCatalogListing } from './codex-structured-catalog-entry' export function restoredCodexSessionOptions( @@ -47,48 +49,6 @@ export type { CodexSessionOptionCatalog } from './codex-structured-model-catalog export { readCodexStructuredSessionOptionCatalog } from './codex-structured-model-catalog' -async function fetchCodexListingThroughStore( - session: CodexSession, - timeoutMs: number | undefined -): Promise { - const access = session.catalogAccess - if (!access) { - return fetchCodexModelCatalogListing({ connection: session.connection, timeoutMs }) - } - const entry = await access.store.refresh(access.fingerprint, 'codex', access, async () => { - const listing = await fetchCodexModelCatalogListing({ - connection: session.connection, - timeoutMs - }) - return { models: listing.models, origin: 'live-session' } - }) - if (!entry) { - throw new Error(access.store.failureDetail(access.fingerprint) ?? 'codex model listing failed') - } - return listingFromEntry(entry) -} - -/** - * The listing a picker read answers with: any stored entry immediately, with a - * background refresh once it ages out; a provider fetch only when this key has - * never listed. The refresh rides the session's own connection and its bounded - * request timeout, off the caller's path. - */ -export async function codexSessionCatalogListingForPicker( - session: CodexSession, - timeoutMs: number | undefined -): Promise { - const access = session.catalogAccess - const entry = access?.store.get(access.fingerprint) - if (access && entry) { - if (access.store.shouldRefresh(access.fingerprint)) { - void fetchCodexListingThroughStore(session, timeoutMs).catch(() => {}) - } - return listingFromEntry(entry) - } - return fetchCodexListingThroughStore(session, timeoutMs) -} - export async function readCodexStructuredSessionOptions(input: { connection: Pick current: { model?: string; effort?: string; serviceTier?: string } @@ -101,12 +61,12 @@ export async function readCodexStructuredSessionOptions(input: { function composeLiveCodexCatalog( session: CodexSession, - listing: CodexModelCatalogListing + listing: ReturnType ): CodexSessionOptionCatalog { const model = session.options.get('model') ?? session.reportedOptions.model const effort = session.options.get('effort') ?? session.reportedOptions.effort const serviceTier = session.options.get('serviceTier') - return composeCodexSessionOptionCatalog(listing, { + const catalog = composeCodexSessionOptionCatalog(listing, { current: { ...(model ? { model } : {}), ...(effort ? { effort } : {}), @@ -119,35 +79,23 @@ function composeLiveCodexCatalog( } : {}) }) -} - -function applyLiveCodexCatalog( - session: CodexSession, - listing: CodexModelCatalogListing -): AgentSessionOptionsResult { - dropUnlistedCodexServiceTier( - session, - session.options.get('model') ?? session.reportedOptions.model, - listing.models - ) - return composeLiveCodexCatalog(session, listing) + return { ...catalog, fastModeSupport: listing.fastModeSupport } } export async function readLiveCodexSessionOptions( session: CodexSession, timeoutMs: number | undefined -): Promise { - const listing = await codexSessionCatalogListingForPicker(session, timeoutMs) - return applyLiveCodexCatalog(session, listing) +): Promise { + return (await prepareLiveCodexSessionOptions(session, timeoutMs))() } -/** Fetch outside the session lane; apply against current options inside it. */ +/** Demand discovery outside the session lane; compose from its installed snapshot inside it. */ export async function prepareLiveCodexSessionOptions( session: CodexSession, - timeoutMs: number | undefined -): Promise<() => AgentSessionOptionsResult> { - const listing = await codexSessionCatalogListingForPicker(session, timeoutMs) - return () => applyLiveCodexCatalog(session, listing) + _timeoutMs: number | undefined +): Promise<() => StructuredAgentSessionLiveOptions> { + await session.catalogAccess?.demand?.() + return () => composeLiveCodexCatalog(session, codexSessionCapabilities(session)) } export async function applyCodexStructuredSessionOption( @@ -187,7 +135,10 @@ function applyValidatedCodexStructuredSessionOption( requestedKey: string, requestedValue: string ): Readonly> { - const listing = codexAcquireCatalogListing(session.catalogAccess) + const verified = codexAcquireCatalogListing(session.catalogAccess, true) + const capabilities = codexSessionCapabilities(session) + const listing = + requestedKey === 'model' ? verified : capabilities.models.length ? capabilities : null const priorModel = session.options.get('model') ?? session.reportedOptions.model const catalog = listing ? composeCodexSessionOptionCatalog(listing, { @@ -220,7 +171,15 @@ function applyValidatedCodexStructuredSessionOption( const modelId = key === 'model' ? value : catalog.current.model const model = catalog.models.find((entry) => entry.id === modelId) if (key === 'serviceTier') { - if (!codexOffersServiceTier(session, model, catalog.models, value)) { + if ( + !codexOffersServiceTier( + session, + model, + catalog.models, + value, + capabilities.listedServiceTiers + ) + ) { throw new Error(`codex app-server model ${modelId} does not offer the ${value} tier`) } session.options.set(key, value) @@ -228,25 +187,24 @@ function applyValidatedCodexStructuredSessionOption( } const priorEffort = session.options.get('effort') ?? session.reportedOptions.effort const requestedEffort = key === 'effort' ? value : priorEffort - if ( - key === 'effort' && - (!model?.efforts.length || !model.efforts.some((effort) => effort.value === requestedEffort)) - ) { + const levels = agentSessionEffortLevels(model) + if (key === 'effort' && levels && !levels.has(value)) { throw new Error(`codex app-server model ${modelId} does not support ${value}`) } const effort = - model?.efforts.length === 0 - ? undefined - : (model?.efforts.find((entry) => entry.value === requestedEffort)?.value ?? - model?.defaultEffort ?? - model?.efforts[0]?.value) + levels === null + ? requestedEffort + : levels.size === 0 + ? undefined + : (model?.efforts.find((entry) => entry.value === requestedEffort)?.value ?? + model?.defaultEffort ?? + model?.efforts[0]?.value) session.options.set('model', modelId) if (effort) { session.options.set('effort', effort) } else { session.options.delete('effort') } - dropUnlistedCodexServiceTier(session, modelId, catalog.models) return Object.fromEntries(session.options) } diff --git a/src/main/codex/codex-structured-session-start.ts b/src/main/codex/codex-structured-session-start.ts index e2115d24ed6..9578f1625ef 100644 --- a/src/main/codex/codex-structured-session-start.ts +++ b/src/main/codex/codex-structured-session-start.ts @@ -21,7 +21,6 @@ import { restoredCodexSessionOptions, reportedCodexSessionOptions } from './codex-structured-session-options' -import { startBackgroundCodexCatalogRefresh } from './codex-structured-background-catalog' import { CodexSkillReport } from './codex-structured-skill-report' import { codexAcquireCatalogAccess } from './codex-structured-acquire-catalog' import { reportedCodexThreadOptions } from './codex-structured-service-tier' @@ -136,7 +135,7 @@ export async function openCodexStructuredSession( ) current() } - const catalogAccess = codexAcquireCatalogAccess(deps, launch) + const catalogAccess = codexAcquireCatalogAccess(deps, launch, sessionId) const session: CodexSession = { events: input.events, account: start.account, @@ -181,13 +180,6 @@ export async function openCodexStructuredSession( ...(deps.logger ? { logger: deps.logger } : {}) }) session.skillReport.refresh() - startBackgroundCodexCatalogRefresh({ - session, - sessionId, - sessions, - timeoutMs: deps.requestTimeoutMs, - logger: deps.logger - }) return { link: codexProviderHandleLink({ threadId: opened.threadId, diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 348f4e0b4dc..84455bc1702 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -34,7 +34,10 @@ import type { StructuredAgentSessionProviderOpen } from '../native-chat/agent-se import type { NativeChatVisualsLaunch } from '../native-chat/native-chat-visuals-delivery' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -export type CodexSessionCatalogAccess = AgentModelCatalogSessionAccess +export type CodexSessionCatalogAccess = AgentModelCatalogSessionAccess & { + read?: (verifiedOnly?: boolean) => ReturnType + demand?: (legacy?: boolean) => Promise +} export type CodexStructuredLaunch = { command: string @@ -130,6 +133,11 @@ export type CodexStructuredSessionAdapterDeps = { requestTimeoutMs?: number /** Host model catalog; sessions write their listings through and read back. */ modelCatalog?: AgentModelCatalogStore + readModelCatalog?: ( + sessionId: string, + verifiedOnly?: boolean + ) => ReturnType + demandModelCatalog?: (sessionId: string, legacy?: boolean) => Promise } export type CodexSession = { diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index 72af9f0e33f..62cc265d6b9 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -17,6 +17,7 @@ import { } from '../../shared/protocol-version' import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../shared/agent-session-create-capabilities' import { AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY } from '../../shared/agent-session-optional-model-capability' +import { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from '../../shared/structured-agent-session-surface-capabilities' import { AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY, AGENT_LAUNCH_RUNTIME_CAPABILITY, @@ -54,6 +55,7 @@ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapab STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY, + AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY, // The renderer reads `agentSession.agents` and renders a chat tab of any agent its host lists. STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts index 6cb8778dd31..e75e9db4dcf 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts @@ -1,4 +1,24 @@ -import type { AgentModelCatalogProbe } from './agent-model-catalog-store' +import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' +import type { AgentModelCatalogSuccess } from './agent-model-catalog-entry' + +export type AgentModelCatalogResolvedProbe = { + identity: unknown + run: (signal?: AbortSignal) => Promise +} + +export type AgentModelCatalogProbe = (( + accountHome: AgentSessionAccountHome, + options?: { signal?: AbortSignal; workspacePath?: string; cwd?: string } +) => Promise) & { + resolveLegacy?: (home: AgentSessionAccountHome) => Promise + resolve?: ( + home: AgentSessionAccountHome, + cwd: string, + savedOnly?: boolean + ) => Promise +} + +export { independentAgentModelCatalog } from '../../../shared/agent-model-catalog-ownership' /** * What every agent registration must say about its models: a probe that lists them without diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts index eb4be98e707..48fa7558577 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts @@ -1,3 +1,4 @@ +import { independentAgentModelCatalog } from './agent-model-catalog-discovery' import type { AgentSessionUnavailable } from '../../../shared/agent-session-availability' import type { AgentSessionFastModeSupport, @@ -14,6 +15,8 @@ export type AgentModelCatalogSuccess = { models: AgentSessionModelOption[] fastModeSupport?: AgentSessionFastModeSupport origin: 'live-session' | 'probe' + complete?: boolean + configured?: { model?: string; effort?: string } /** A row only this session's launch added (its own `--model`): kept only once the account's * catalog already lists that model. */ launchOnlyModelId?: string @@ -27,6 +30,7 @@ export type AgentModelCatalogListing = { fastModeSupport?: AgentSessionFastModeSupport origin: 'live-session' | 'probe' at: number + complete?: boolean } /** What a running session listed, as its adapter hands it to the host. Kept here, apart from the @@ -124,16 +128,17 @@ export type AgentModelCatalogEntry = { export function agentModelCatalogResultFromEntry( entry: AgentModelCatalogEntry, namesDefault: boolean, - listingNamesConfiguredModel: boolean + listingNamesConfiguredModel: boolean, + useConfigured = true ): Exclude { // A CLI-resolved default names the configured model even where the listing names none. - const namesConfigured = listingNamesConfiguredModel || entry.configured !== null + const namesConfigured = + listingNamesConfiguredModel || (useConfigured && entry.configured !== null) + const models = useConfigured ? entry.models : (entry.discovered?.models ?? entry.models) return { origin: entry.origin, // Without a default the picker names nothing until the chat reports its model. - models: entry.models.map((model) => - namesDefault ? { ...model } : { ...model, isDefault: false } - ), + models: models.map((model) => (namesDefault ? { ...model } : { ...model, isDefault: false })), ...(entry.fastModeSupport ? { fastModeSupport: entry.fastModeSupport } : {}), fetchedAt: entry.fetchedAt, listingNamesConfiguredModel: namesDefault && namesConfigured @@ -202,7 +207,11 @@ export function agentModelCatalogEntry( discovered, live, configured, - models: mergedModels(discovered, live, configured), + models: mergedModels(discovered, live, configured).map((model) => + independentAgentModelCatalog(agent) && discovered?.complete !== true + ? { ...model, isDefault: false } + : model + ), ...(fastModeSupport ? { fastModeSupport } : {}), origin: newer.origin, fetchedAt: newer.at @@ -228,6 +237,39 @@ export function agentModelCatalogEntryWithSuccess( source: AgentModelCatalogSource, at: number ): AgentModelCatalogEntry | null { + if (independentAgentModelCatalog(identity.agent)) { + if (source !== 'discovery' || success.models.length === 0) { + return null + } + if (success.complete !== true && previous?.discovered?.complete === true) { + return previous + } + const configuredModel = success.complete === true ? success.configured?.model : undefined + const configuredRow = configuredModel + ? success.models.find( + (row) => row.id === configuredModel || row.resolvedModel === configuredModel + ) + : undefined + return agentModelCatalogEntry( + identity.agent, + identity.fingerprint, + { + models: success.models.map((row) => ({ ...row })), + origin: success.origin, + at, + complete: success.complete === true, + ...(success.fastModeSupport ? { fastModeSupport: success.fastModeSupport } : {}) + }, + null, + configuredModel + ? { + modelId: configuredRow?.id ?? configuredModel, + at, + ...(success.configured?.effort ? { effort: success.configured.effort } : {}) + } + : null + ) + } const models = retainedAgentModelCatalogModels(previous, success) if (models.length === 0) { // An empty list identifies no model; it is doubt, not a catalog. @@ -274,6 +316,9 @@ export function entryWithConfiguredDefault( choice: AgentModelCatalogConfiguredChoice | null, at: number ): AgentModelCatalogEntry | null { + if (independentAgentModelCatalog(entry.agent)) { + return null + } const next = configuredDefaultFacts(choice, entry.configured) if (JSON.stringify(next) === JSON.stringify(configuredDefaultFacts(entry.configured))) { return null @@ -290,7 +335,12 @@ export function entryWithConfiguredDefault( /** What a saved entry says, without its clocks: an unchanged key needs no write to disk. */ export function agentModelCatalogListingKey(entry: AgentModelCatalogEntry): string { const facts = (listing: AgentModelCatalogListing | null): unknown => - listing && [listing.origin, listing.models, listing.fastModeSupport ?? null] + listing && [ + listing.origin, + listing.models, + listing.fastModeSupport ?? null, + listing.complete ?? null + ] return JSON.stringify([ facts(entry.discovered), facts(entry.live), diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts index 27e8c072fa4..5519a257fce 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts @@ -87,3 +87,13 @@ export function agentModelCatalogSessionAccess( accountHomePath } } + +export function agentModelCatalogScopeFingerprint( + account: string, + cwd: string, + invocation: unknown = null +): string { + return createHash('sha256') + .update(JSON.stringify([account, cwd, invocation])) + .digest('hex') +} diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-independent-discovery.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-independent-discovery.test.ts new file mode 100644 index 00000000000..e19e757ecda --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-independent-discovery.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { agentSessionRecordFixture } from '../agent-session-record-test-fixture' +import { createAgentModelCatalogService } from './agent-model-catalog-service' +import { + AgentModelCatalogStore, + type AgentModelCatalogProbe, + type AgentModelCatalogSuccess +} from './agent-model-catalog-store' + +const HOME = { variable: 'CLAUDE_CONFIG_DIR', path: '/account' } +const listing = (id: string, model?: string): AgentModelCatalogSuccess => ({ + origin: 'probe', + complete: true, + models: [{ id, label: id, isDefault: false, efforts: [{ value: 'high', label: 'High' }] }], + configured: model ? { model, effort: 'high' } : {} +}) + +function record(sessionId: string, model: string): AgentSessionRecord { + return agentSessionRecordFixture({ + sessionId, + provider: 'claude', + accountHome: HOME, + options: { model }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'folder', + workspaceKind: 'folder' + } + }) +} + +function harness() { + let now = 1000 + let installed = listing('old', 'old') + let failure = false + const store = new AgentModelCatalogStore({ now: () => now }) + const run = vi.fn(async () => { + if (failure) { + throw new Error('offline') + } + return installed + }) + const resolve = vi.fn(async (_home: unknown, cwd: string) => ({ + identity: { command: 'same-cli-path', cwd }, + run + })) + const probe: AgentModelCatalogProbe = Object.assign(async () => run(), { resolve }) + const service = createAgentModelCatalogService({ + store, + getRecord: (id) => record(id, id === 'a' ? 'old' : 'another'), + drivesRecord: () => true, + resolveAccountHome: async () => HOME, + recordWorkspacePath: async () => '/project', + workspaceMayOverrideDefaultModel: async () => true, + probes: { claude: probe } + }) + return { + store, + service, + run, + resolve, + update: (next: AgentModelCatalogSuccess) => { + now += 120001 + installed = next + }, + fail: () => { + now += 120001 + failure = true + }, + read: (sessionId = 'a', extra = {}) => service.read({ agent: 'claude', sessionId, ...extra }) + } +} + +describe('independent installed CLI discovery', () => { + it('refreshes a same-path CLI on picker demand and an old chat cannot put the old list back', async () => { + const h = harness() + await h.read('a', { waitForListing: true }) + h.update(listing('new', 'new')) + const warm = await h.read() + expect(warm).toMatchObject({ models: [{ id: 'old' }], listingInProgress: true }) + await h.read('a', { waitForListing: true }) + h.service.recordLiveListing('a', { + models: listing('old').models, + configuredDefault: { modelId: 'old' } + }) + expect(await h.read()).toMatchObject({ models: [{ id: 'new', isDefault: true }] }) + expect(h.run).toHaveBeenCalledTimes(2) + h.fail() + await h.read('a', { waitForListing: true }) + expect(await h.read()).toMatchObject({ models: [{ id: 'new' }] }) + expect(await h.read('a', { savedOnly: true, verifiedOnly: true })).toEqual({ + origin: 'unknown' + }) + h.service.stop() + }) + + it('shares one scope and configured default between chats with different saved picks', async () => { + const h = harness() + await Promise.all([ + h.read('a', { waitForListing: true }), + h.read('b', { waitForListing: true }) + ]) + expect(h.run).toHaveBeenCalledTimes(1) + expect(h.resolve.mock.calls.every((call) => call.length === 2)).toBe(true) + expect(h.service.peek('a', true)).toBe(h.service.peek('b', true)) + expect(await h.read('b')).toMatchObject({ + models: [{ id: 'old', isDefault: true, defaultEffort: 'high' }] + }) + h.service.stop() + }) + + it('discovers the project default before any chat has run and clears it when config names none', async () => { + const h = harness() + expect( + await h.service.read({ agent: 'claude', workspacePath: '/project', waitForListing: true }) + ).toMatchObject({ + models: [{ id: 'old', isDefault: true }], + listingNamesConfiguredModel: true + }) + h.update(listing('new')) + expect(await h.read('a', { waitForListing: true })).toMatchObject({ + models: [{ id: 'new', isDefault: false }], + listingNamesConfiguredModel: false + }) + expect(h.service.peek('a', true)?.configured).toBeNull() + h.service.stop() + }) + + it('loads disk rows for display but requires completed discovery in this process as proof', async () => { + const h = harness() + await h.read('a', { waitForListing: true }) + const entry = h.service.peek('a', true) + expect(entry).not.toBeNull() + const restarted = new AgentModelCatalogStore() + await restarted.attachPersistence({ + load: async () => (entry ? [entry] : []), + save: () => {}, + flush: async () => {} + }) + expect(entry && restarted.get(entry.fingerprint)).not.toBeNull() + expect(entry && restarted.verified(entry)).toBe(false) + if (entry) { + restarted.recordSuccess( + entry.fingerprint, + 'claude', + { ...listing('partial'), complete: false }, + 'discovery' + ) + expect(restarted.get(entry.fingerprint)).toBe(entry) + expect(restarted.verified(entry)).toBe(false) + } + h.service.stop() + }) + it('retains complete B/high whole on partial acquisition, then clears it on observed empty config', async () => { + const h = harness() + await h.read('a', { waitForListing: true }) + const good = h.service.peek('a') + h.update({ ...listing('partial', 'partial'), complete: false }) + await h.read('a', { waitForListing: true }) + expect(h.service.peek('a')).toBe(good) + expect(await h.read()).not.toHaveProperty('listingInProgress') + expect(h.run).toHaveBeenCalledTimes(2) + h.update(listing('new')) + await h.read('a', { waitForListing: true }) + expect(h.service.peek('a', true)?.configured).toBeNull() + h.service.stop() + }) + + it('paces cold unsupported display independently of proof and recovers on the same path', async () => { + const h = harness() + h.update({ ...listing('partial', 'partial'), complete: false }) + expect(await h.read('a', { waitForListing: true })).toMatchObject({ + models: [{ id: 'partial', isDefault: false }], + listingNamesConfiguredModel: false + }) + expect(h.service.peek('a', true)).toBeNull() + await h.read() + expect(h.run).toHaveBeenCalledTimes(1) + h.update(listing('upgraded', 'upgraded')) + await h.read('a', { waitForListing: true }) + expect(h.service.peek('a', true)?.models[0]?.id).toBe('upgraded') + h.service.stop() + }) +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-lister.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-lister.ts new file mode 100644 index 00000000000..52504bc25f9 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-lister.ts @@ -0,0 +1,162 @@ +import type { AgentSessionAccountHome } from '../../../shared/agent-session-record' +import type { PrioritySemaphore } from '../../../shared/priority-semaphore' +import { startSpan } from '../../observability/tracer' +import { independentAgentModelCatalog } from './agent-model-catalog-discovery' +import { + AgentModelCatalogListingStoppedError, + type AgentModelCatalogFailures +} from './agent-model-catalog-failures' +import type { AgentModelCatalogListingWaiters } from './agent-model-catalog-listing-waiters' +import type { + AgentModelCatalogEntry, + AgentModelCatalogSource, + AgentModelCatalogSuccess +} from './agent-model-catalog-entry' +import type { + AgentModelCatalogProbe, + AgentModelCatalogResolvedProbe +} from './agent-model-catalog-discovery' +import type { AgentModelCatalogStore } from './agent-model-catalog-store' + +/** Who lists, by identity: a live session's per-spawn handle, or the session-less probe. */ +export type AgentModelCatalogLister = AgentModelCatalogSessionAccess | AgentModelCatalogProbe + +/** A live session's handle into the store, pinned at spawn to the account home + * THAT child launched under — an account switched afterwards must never + * receive or poison this session's listing. */ +export type AgentModelCatalogSessionAccess = { + store: AgentModelCatalogStore + fingerprint: string + accountHomePath: string +} + +export type InFlightListings = Map> + +type CatalogSuccessWriter = ( + fingerprint: string, + agent: string, + success: AgentModelCatalogSuccess, + source: AgentModelCatalogSource, + order: number +) => AgentModelCatalogEntry | null + +export function refreshAgentModelCatalogListing(input: { + fingerprint: string + agent: string + lister: AgentModelCatalogLister + listModels: () => Promise + refreshes: Map + entries: ReadonlyMap + latestWrittenOrder: Map + failures: AgentModelCatalogFailures + listingWaiters: AgentModelCatalogListingWaiters + nextListingOrder: () => number + entryFromSuccess: ( + fingerprint: string, + agent: string, + success: AgentModelCatalogSuccess, + source: AgentModelCatalogSource + ) => AgentModelCatalogEntry | null + writeSuccess: CatalogSuccessWriter + recordFailure: (fingerprint: string, detail: string, agent?: string) => void +}): Promise { + const { fingerprint, agent, lister, listModels } = input + const listers: InFlightListings = input.refreshes.get(fingerprint) ?? new Map() + const inFlight = independentAgentModelCatalog(agent) + ? listers.values().next().value + : listers.get(lister) + if (inFlight) { + return inFlight + } + const settle = (): void => { + listers.delete(lister) + if (listers.size === 0 && input.refreshes.get(fingerprint) === listers) { + input.refreshes.delete(fingerprint) + input.latestWrittenOrder.delete(fingerprint) + } + input.listingWaiters.notify(fingerprint) + } + const order = input.nextListingOrder() + // Agent, outcome and duration only: a slow listing shows in the trace log without its content. + const span = startSpan('agentModelCatalog.discovery', { + attributes: { agent, lister: typeof lister === 'function' ? 'probe' : 'session' } + }) + const run = listModels().then( + (success) => { + span.setAttribute('models', success.models.length) + span.end() + // An older lister still receives its own result, but cannot replace a newer discovery. + const superseded = + (input.latestWrittenOrder.get(fingerprint) ?? 0) > order && input.entries.has(fingerprint) + if (superseded && success.origin === 'probe') { + input.failures.listed(fingerprint, agent, success.origin, success.unavailable) + } + const entry = superseded + ? input.entryFromSuccess(fingerprint, agent, success, 'discovery') + : input.writeSuccess(fingerprint, agent, success, 'discovery', order) + settle() + return entry + }, + (error: unknown) => { + span.fail(error instanceof Error ? error : String(error)) + // Probes are functions; a live session lists through its access object. + if (typeof lister === 'function') { + if (!(error instanceof AgentModelCatalogListingStoppedError)) { + input.failures.probeFailed(fingerprint, agent, error) + } + } else { + input.recordFailure( + fingerprint, + error instanceof Error ? error.message : String(error), + agent + ) + } + settle() + return null + } + ) + listers.set(lister, run) + input.refreshes.set(fingerprint, listers) + return run +} + +export function scopedAgentModelCatalogLister(input: { + legacy?: boolean + admission: PrioritySemaphore + signal: AbortSignal + resolved: AgentModelCatalogResolvedProbe | undefined + probe: AgentModelCatalogProbe | undefined + probeHome: AgentSessionAccountHome | null + workspacePath: string | undefined +}): () => Promise { + const { legacy, admission, signal, resolved, probe, probeHome, workspacePath } = input + return async () => { + const release = legacy ? () => {} : await admission.acquire(0, signal) + const deadline = new AbortController() + // Retries share this body budget; bounded provider teardown is awaited before settlement. + const timer = legacy ? setTimeout(() => deadline.abort(), 30_000) : undefined + const attemptSignal = AbortSignal.any([signal, deadline.signal]) + try { + if (resolved) { + return await resolved.run(attemptSignal) + } + if (!probe || !probeHome) { + throw new AgentModelCatalogListingStoppedError('catalog scope unavailable') + } + return await probe(probeHome, { signal: attemptSignal, workspacePath }) + } catch (error) { + throw signal.aborted ? new AgentModelCatalogListingStoppedError(String(error)) : error + } finally { + clearTimeout(timer) + release() + } + } +} + +export function agentModelCatalogProbeLister(signal: AbortSignal) { + return (probe: AgentModelCatalogProbe, home: AgentSessionAccountHome, workspacePath?: string) => + (): Promise => + probe(home, { signal, workspacePath }).catch((error: unknown) => { + throw signal.aborted ? new AgentModelCatalogListingStoppedError(String(error)) : error + }) +} diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-listing-waiters.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-listing-waiters.ts index cbbca251a5f..79dcfa94847 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-listing-waiters.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-listing-waiters.ts @@ -12,10 +12,15 @@ export class AgentModelCatalogListingWaiters { private readonly listing: (fingerprint: string) => boolean ) {} - wait(fingerprint: string): Promise | null { + wait( + fingerprint: string, + waitForChange = false, + terminal = false + ): Promise | null { if (!this.listing(fingerprint)) { return null } + const previous = this.read(fingerprint) return new Promise((resolve) => { const waiters = this.waiters.get(fingerprint) ?? new Set<() => void>() let settled = false @@ -33,14 +38,16 @@ export class AgentModelCatalogListingWaiters { } const check = (): void => { const entry = this.read(fingerprint) - if (entry || !this.listing(fingerprint)) { + if ( + (!terminal && entry && (!waitForChange || entry !== previous)) || + !this.listing(fingerprint) + ) { finish(entry) } } - const deadline = setTimeout( - () => finish(this.read(fingerprint)), - AGENT_MODEL_CATALOG_PICKER_WAIT_MS - ) + const deadline = terminal + ? undefined + : setTimeout(() => finish(this.read(fingerprint)), AGENT_MODEL_CATALOG_PICKER_WAIT_MS) waiters.add(check) this.waiters.set(fingerprint, waiters) check() diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts index 262ab8e4e2f..e30eb3ebcbc 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts @@ -61,9 +61,12 @@ function parseModel(value: unknown): AgentSessionModelOption | null { : undefined const description = text(row.description) const defaultEffort = text(row.defaultEffort) + const resolvedModel = text(row.resolvedModel) return { id, label, + ...(resolvedModel ? { resolvedModel } : {}), + ...(row.effortsKnown === false ? { effortsKnown: false as const } : {}), ...(description ? { description } : {}), isDefault: row.isDefault === true, ...(defaultEffort ? { defaultEffort } : {}), @@ -107,7 +110,8 @@ function parseListing(value: unknown): AgentModelCatalogListing | null { } : {}), origin: row.origin, - at: row.at + at: row.at, + ...(row.complete === true ? { complete: true } : {}) } } @@ -118,6 +122,10 @@ function parseEntry(value: unknown): AgentModelCatalogEntry | null { if (!row || !isStructuredAgentId(row.agent) || typeof row.fingerprint !== 'string') { return null } + if (row.agent === 'claude' || row.agent === 'codex') { + const display = parseListing(row.discovered) ?? parseListing(row.live) + return agentModelCatalogEntry(row.agent, row.fingerprint, display, null, null) + } const configured = asRecord(row.configured) const configuredModelId = text(configured?.modelId) const configuredEffort = text(configured?.effort) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts index b6387f20561..2597c9e8061 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts @@ -39,7 +39,8 @@ vi.mock('../../observability/tracer', () => ({ function listing(id: string): AgentModelCatalogSuccess { return { models: [{ id, label: id, isDefault: true, efforts: [] }], - origin: 'probe' + origin: 'probe', + complete: true } } @@ -82,6 +83,7 @@ describe('model catalog prewarm', () => { expect(codex).toHaveBeenCalledTimes(1) expect(codex).toHaveBeenCalledWith(HOME('CODEX_HOME', '/homes/a'), { + workspacePath: expect.any(String), signal: expect.any(AbortSignal) }) expect(grok).toHaveBeenCalledTimes(1) @@ -391,8 +393,8 @@ describe('one account, one catalog key', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the key reads only provider, accountHome and location. return { sessionId: 'session-1', - provider: 'codex', - accountHome: HOME('CODEX_HOME', path), + provider: 'grok', + accountHome: HOME('GROK_HOME', path), location: { executionHostId: 'local', wslDistro: null, @@ -405,15 +407,15 @@ describe('one account, one catalog key', () => { // The read and launch resolvers agreeing on the home is runtime-home-catalog-account-key.test.ts. it('a session’s saves through the host and through its adapter’s handle land under its record’s key', async () => { const store = new AgentModelCatalogStore() - const managedHome = '/orca/codex-accounts/acct-1/home' + const managedHome = '/orca/grok-accounts/acct-1/home' const launched = record(managedHome) const service = createAgentModelCatalogService({ store, getRecord: () => launched, drivesRecord: () => true, - resolveAccountHome: async () => HOME('CODEX_HOME', managedHome) + resolveAccountHome: async () => HOME('GROK_HOME', managedHome) }) - // What the chat saved, through the host and through the Codex adapter's own handle… + // What the chat saved, through the host and through the Grok adapter's own handle… service.recordLiveListing('session-1', { models: listing('gpt-live').models }) const incoming = { models: [...listing('gpt-live').models, ...listing('gpt-new').models] } const retained = (launchOnlyModelId: string) => @@ -422,13 +424,13 @@ describe('one account, one catalog key', () => { expect(await retained('gpt-new')).toEqual(['gpt-live']) const adapterAccess = agentModelCatalogSessionAccess( store, - { agent: 'codex', accountHomeVariable: 'CODEX_HOME' }, + { agent: 'grok', accountHomeVariable: 'GROK_HOME' }, managedHome ) expect(adapterAccess?.fingerprint).toBe(agentModelCatalogFingerprintForRecord(launched)) // …is what the next new chat reads for the same home: no cold start for this account. - const read = await service.read({ agent: 'codex' }) + const read = await service.read({ agent: 'grok' }) expect(read.origin !== 'unknown' && read.models.map((model) => model.id)).toEqual(['gpt-live']) }) }) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.ts new file mode 100644 index 00000000000..62e5fc64418 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.ts @@ -0,0 +1,75 @@ +import type { AgentSessionAccountHome } from '../../../shared/agent-session-record' +import type { + AgentModelCatalogService, + AgentModelCatalogServiceDeps +} from './agent-model-catalog-service-contract' +import type { AgentModelCatalogProbe, AgentModelCatalogSuccess } from './agent-model-catalog-store' +import { agentModelCatalogFingerprint } from './agent-model-catalog-fingerprint' +import { independentAgentModelCatalog } from './agent-model-catalog-discovery' + +/** The catalog a new chat of `agent` would read: the account a launch would pin right now. */ +export async function newChatCatalogKey( + deps: AgentModelCatalogServiceDeps, + agent: string, + folder?: string +): Promise<{ fingerprint: string; accountHome: AgentSessionAccountHome } | null> { + let accountHome: AgentSessionAccountHome + try { + accountHome = await deps.resolveAccountHome(agent) + } catch { + return null + } + return { + fingerprint: agentModelCatalogFingerprint({ agent, accountHome, wslDistro: null, folder }), + accountHome + } +} + +async function runBounded( + items: readonly T[], + limit: number, + stopped: AbortSignal, + run: (item: T) => Promise +): Promise { + const queue = [...items] + const worker = async (): Promise => { + for (let item = queue.shift(); item !== undefined && !stopped.aborted; item = queue.shift()) { + await run(item).catch(() => {}) + } + } + await Promise.all(Array.from({ length: Math.min(limit, queue.length) }, worker)) +} + +export async function prewarmAgentModelCatalog( + deps: AgentModelCatalogServiceDeps, + signal: AbortSignal, + read: AgentModelCatalogService['read'], + listWith: ( + probe: AgentModelCatalogProbe, + home: AgentSessionAccountHome + ) => () => Promise +): Promise { + if (signal.aborted) { + return + } + const probes = deps.probes ?? {} + // An agent never used here waits for its first chat, which shows the quiet placeholder. + const used = Object.keys(probes).filter( + (agent) => + !deps.listsInChatFolder?.has(agent) && + (deps.store.hasEntryForAgent(agent) || deps.hasChatRecords?.(agent) === true) + ) + await runBounded(used, 2, signal, async (agent) => { + if (independentAgentModelCatalog(agent)) { + await read({ agent, waitForListing: true }) + return + } + const probe = probes[agent] + const key = probe ? await newChatCatalogKey(deps, agent) : null + // A fresh entry, a listing already running, or a recent failure each mean nothing to do. + if (!probe || !key || signal.aborted || !deps.store.probeDue(key.fingerprint)) { + return + } + await deps.store.refresh(key.fingerprint, agent, probe, listWith(probe, key.accountHome)) + }) +} diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.test.ts new file mode 100644 index 00000000000..e23312cd68f --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { createClaudeModelCatalogProbe } from '../../claude/claude-model-catalog-probe' +import { createCodexModelCatalogProbe } from '../../codex/codex-model-catalog-probe' +import { + agentModelCatalogFingerprint, + agentModelCatalogScopeFingerprint +} from './agent-model-catalog-fingerprint' + +describe('installed catalog invocation identity', () => { + it.each(['claude', 'codex'] as const)( + 'keeps %s scope stable across shell captures while distinguishing launch inputs', + async (agent) => { + let capture = 'first' + let catalogFile = '/catalog/A.json' + const overlay = () => ({ CATALOG_FILE: catalogFile }) + let command = '/installed/agent' + const launchArgs = (name: string) => + agent === 'claude' + ? ['--settings', JSON.stringify({ model: name })] + : ['--config', `model_provider="${name}"`] + let args = launchArgs('work') + const environment = () => ({ SSH_AUTH_SOCK: `/ssh/${capture}`, SSH_AGENT_PID: capture }) + const probe = + agent === 'claude' + ? createClaudeModelCatalogProbe({ + resolveCommand: () => command, + resolveInheritedEnv: async () => environment(), + resolveEnv: overlay, + resolveAuthPolicy: () => ({ account: 'system' }), + resolveLaunchArgs: () => args + }) + : createCodexModelCatalogProbe({ + resolveCommand: () => command, + resolveEnvironment: async () => environment(), + resolveEnv: overlay, + resolveLaunchArgs: () => args + }) + const key = async (account = '/account', cwd = '/project') => { + const home = { + variable: agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', + path: account + } + const resolved = await probe.resolve?.(home, cwd) + expect(resolved).toBeDefined() + const base = agentModelCatalogFingerprint({ agent, accountHome: home, wslDistro: null }) + return agentModelCatalogScopeFingerprint(base, cwd, resolved?.identity) + } + const first = await key() + capture = 'second' + expect(await key()).toBe(first) + catalogFile = '/catalog/B.json' + expect(await key()).not.toBe(first) + catalogFile = '/catalog/A.json' + expect(await key()).toBe(first) + expect(await key('/other-account')).not.toBe(first) + expect(await key('/account', '/other-project')).not.toBe(first) + command = '/other/agent' + expect(await key()).not.toBe(first) + command = '/installed/agent' + args = launchArgs('personal') + expect(await key()).not.toBe(first) + } + ) +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.ts new file mode 100644 index 00000000000..c0f7bfc3003 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.ts @@ -0,0 +1,69 @@ +import { homedir } from 'node:os' +import type { + AgentSessionAccountHome, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import { isNativeLocalLocation } from '../../../shared/execution-host' +import type { + AgentModelCatalogService, + AgentModelCatalogServiceDeps +} from './agent-model-catalog-service-contract' +import { agentModelCatalogScopeFingerprint } from './agent-model-catalog-fingerprint' +import { + independentAgentModelCatalog, + type AgentModelCatalogResolvedProbe +} from './agent-model-catalog-discovery' +import { sessionWorkspacePath } from './agent-model-catalog-workspace' + +/** Launch configuration determines discovery scope; a chat's choices never participate. */ +export async function resolveAgentModelCatalogScope( + deps: AgentModelCatalogServiceDeps, + params: Parameters[0], + record: AgentSessionRecord | undefined, + base: string, + home: AgentSessionAccountHome | null +): Promise<{ + base: string + fingerprint: string + exact: boolean + cwd?: string + resolved?: AgentModelCatalogResolvedProbe +} | null> { + if (!independentAgentModelCatalog(params.agent) || !home) { + return { base, fingerprint: base, exact: false } + } + if (record && !isNativeLocalLocation(record.location)) { + return { base, fingerprint: base, exact: false } + } + const probe = deps.probes?.[params.agent] + if (params.legacy) { + const resolved = params.savedOnly ? null : await probe?.resolveLegacy?.(home).catch(() => null) + return { + base, + fingerprint: agentModelCatalogScopeFingerprint(base, 'legacy-account'), + exact: false, + ...(resolved ? { resolved } : {}) + } + } + const cwd = + params.workspacePath === null + ? null + : (params.workspacePath ?? (record ? await sessionWorkspacePath(deps, record) : homedir())) + if (!cwd) { + return { base, fingerprint: base, exact: false } + } + const resolution = params.savedOnly + ? probe?.resolve?.(home, cwd, true) + : probe?.resolve?.(home, cwd) + const resolved = await resolution?.catch(() => null) + if (probe?.resolve && !resolved) { + return null + } + return { + base, + fingerprint: agentModelCatalogScopeFingerprint(base, cwd, resolved?.identity), + exact: true, + cwd, + ...(resolved ? { resolved } : {}) + } +} diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service-contract.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service-contract.ts new file mode 100644 index 00000000000..7000eb4dc08 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service-contract.ts @@ -0,0 +1,65 @@ +import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session-wire' +import type { + AgentSessionAccountHome, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import type { + AgentModelCatalogEntry, + AgentModelCatalogLiveListing, + AgentModelCatalogProbe +} from './agent-model-catalog-store' +import type { AgentModelCatalogWorkspaceDeps } from './agent-model-catalog-workspace' +export type AgentModelCatalogServiceDeps = AgentModelCatalogWorkspaceDeps & { + getRecord: (sessionId: string) => AgentSessionRecord | undefined + /** Whether this build can start the record's agent as the record pins it; a record it cannot + * names no account a probe may start that agent's CLI under. */ + drivesRecord: (record: AgentSessionRecord) => boolean + /** The account home a structured launch for this agent would pin right now — + * the SAME resolver the create path fills `record.accountHome` with, so a + * record-less read can never answer from another account's listing. */ + resolveAccountHome: (agent: string) => Promise + /** Session-less listers, one per agent that has one on this host. */ + probes?: Readonly>> + /** Agents whose listing marks the model the account is configured to run as its default. */ + listingNamesConfiguredModel?: ReadonlySet + /** False for an agent whose model no project config can pick; its default holds everywhere. */ + agentReadsProjectModelConfig?: (agent: string) => boolean + /** Whether this host keeps any chat of the agent; with a saved catalog, what marks it in use. */ + hasChatRecords?: (agent: string) => boolean +} + +export type AgentModelCatalogService = { + peek: (sessionId: string, verifiedOnly?: boolean) => AgentModelCatalogEntry | null + read: (params: { + agent: string + sessionId?: string + /** Where a new chat would run; null when one was named but is not a local directory. */ + workspacePath?: string | null + /** Answer for the folder the named session runs in, as its new-chat read did. */ + inSessionWorkspace?: true + /** With no entry yet, answer from the listing this read starts or joins instead of `unknown`; + * with a held reason past its TTL, from the probe re-checking it. */ + waitForListing?: boolean + /** Answer only from the saved entry and held reason; start, join or re-check no listing. */ + savedOnly?: boolean + verifiedOnly?: boolean + legacy?: boolean + /** Old options readers need account-default presentation, never launch or admission proof. */ + legacyOptionsDisplay?: true + }) => Promise + /** Incoming listing ids, checking only a launch-added row against the pinned account. */ + retainedLiveModelIds: ( + sessionId: string, + listing: AgentModelCatalogLiveListing + ) => Promise + /** Saves a session's listing and newly learned menus so the next chat starts warm. */ + recordLiveListing: (sessionId: string, listing: AgentModelCatalogLiveListing) => void + /** Lists, in the background, every agent the user has used here (a saved catalog or a chat) + * whose catalog for the account a new chat would pin is missing or old, so a picker never meets + * a cold catalog. Resolves once those listings settle. */ + prewarm: () => Promise + /** The host is going away: start no listing, and stop the ones running. */ + stop: () => void + /** A chat under this record's account proved its start: a held reason is re-checked sooner. */ + providerStarted: (record: AgentSessionRecord) => void +} diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts index 164126b6b55..69ec1708337 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts @@ -1,3 +1,7 @@ +import { + joinHostModelListingWait, + isHostModelListingWaitInFlight +} from '../../../shared/host-model-listing-waits' import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' @@ -16,8 +20,8 @@ function record(accountHomePath: string): AgentSessionRecord { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only provider, accountHome and location; the rest of the record is irrelevant here. return { sessionId: 'session-1', - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: accountHomePath }, + provider: 'grok', + accountHome: { variable: 'GROK_HOME', path: accountHomePath }, location: { executionHostId: 'local', wslDistro: null, @@ -36,15 +40,15 @@ function listing(id: string): AgentModelCatalogSuccess { function selectedHomeFingerprint(path: string): string { return agentModelCatalogFingerprint({ - agent: 'codex', - accountHomeVariable: 'CODEX_HOME', + agent: 'grok', + accountHomeVariable: 'GROK_HOME', accountHomePath: path, wslDistro: null }) } -const CODEX_HOME = (path: string): { variable: 'CODEX_HOME'; path: string } => ({ - variable: 'CODEX_HOME', +const GROK_HOME = (path: string): { variable: 'GROK_HOME'; path: string } => ({ + variable: 'GROK_HOME', path }) @@ -56,20 +60,20 @@ describe('agent model catalog service', () => { store, getRecord: () => record('/homes/a'), drivesRecord: () => true, - resolveAccountHome: async () => CODEX_HOME('/homes/selected'), - probes: { codex: probe } + resolveAccountHome: async () => GROK_HOME('/homes/selected'), + probes: { grok: probe } }) - expect(await service.read({ agent: 'codex', sessionId: 'session-1' })).toEqual({ + expect(await service.read({ agent: 'grok', sessionId: 'session-1' })).toEqual({ origin: 'unknown', listingInProgress: true }) // A second read while the probe is in flight must not start another, and a // record-scoped read probes the RECORD's pinned home, not the selection. - await service.read({ agent: 'codex', sessionId: 'session-1' }) + await service.read({ agent: 'grok', sessionId: 'session-1' }) expect(probe).toHaveBeenCalledTimes(1) - expect(probe).toHaveBeenCalledWith(CODEX_HOME('/homes/a'), { signal: expect.any(AbortSignal) }) + expect(probe).toHaveBeenCalledWith(GROK_HOME('/homes/a'), { signal: expect.any(AbortSignal) }) await vi.waitFor(async () => { - const result = await service.read({ agent: 'codex', sessionId: 'session-1' }) + const result = await service.read({ agent: 'grok', sessionId: 'session-1' }) expect(result.origin).toBe('probe') }) }) @@ -84,14 +88,14 @@ describe('agent model catalog service', () => { store, getRecord: () => pinned, drivesRecord, - resolveAccountHome: async () => CODEX_HOME('/homes/selected'), - probes: { codex: probe } + resolveAccountHome: async () => GROK_HOME('/homes/selected'), + probes: { grok: probe } }) - await service.read({ agent: 'codex', sessionId: 'session-1' }) + await service.read({ agent: 'grok', sessionId: 'session-1' }) expect(drivesRecord).toHaveBeenCalledWith(pinned) - expect(probe).toHaveBeenCalledExactlyOnceWith(CODEX_HOME('/homes/selected'), { + expect(probe).toHaveBeenCalledExactlyOnceWith(GROK_HOME('/homes/selected'), { signal: expect.any(AbortSignal) }) }) @@ -100,25 +104,25 @@ describe('agent model catalog service', () => { const store = new AgentModelCatalogStore() // The old account listed under its own fingerprint before the switch. const oldFingerprint = selectedHomeFingerprint('/homes/old') - store.recordSuccess(oldFingerprint, 'codex', listing('gpt-old'), 'discovery') + store.recordSuccess(oldFingerprint, 'grok', listing('gpt-old'), 'discovery') const probe = vi.fn(async () => listing('gpt-new')) const service = createAgentModelCatalogService({ store, getRecord: () => undefined, drivesRecord: () => true, - resolveAccountHome: async () => CODEX_HOME('/homes/new'), - probes: { codex: probe } + resolveAccountHome: async () => GROK_HOME('/homes/new'), + probes: { grok: probe } }) // The record-less read follows the CURRENT selection: unknown, never gpt-old. - expect(await service.read({ agent: 'codex' })).toEqual({ + expect(await service.read({ agent: 'grok' })).toEqual({ origin: 'unknown', listingInProgress: true }) - expect(probe).toHaveBeenCalledWith(CODEX_HOME('/homes/new'), { + expect(probe).toHaveBeenCalledWith(GROK_HOME('/homes/new'), { signal: expect.any(AbortSignal) }) await vi.waitFor(async () => { - const result = await service.read({ agent: 'codex' }) + const result = await service.read({ agent: 'grok' }) expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-new') }) // The new listing landed under the new selection's key; the old entry is untouched. @@ -130,13 +134,13 @@ describe('agent model catalog service', () => { const store = new AgentModelCatalogStore() store.recordSuccess( selectedHomeFingerprint('/homes/selected'), - 'codex', + 'grok', listing('gpt-mine'), 'discovery' ) store.recordSuccess( selectedHomeFingerprint('/homes/other'), - 'codex', + 'grok', listing('gpt-other'), 'discovery' ) @@ -144,9 +148,9 @@ describe('agent model catalog service', () => { store, getRecord: () => undefined, drivesRecord: () => true, - resolveAccountHome: async () => CODEX_HOME('/homes/selected') + resolveAccountHome: async () => GROK_HOME('/homes/selected') }) - const result = await service.read({ agent: 'codex' }) + const result = await service.read({ agent: 'grok' }) expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-mine') }) @@ -155,13 +159,13 @@ describe('agent model catalog service', () => { const sessionRecord = record('/homes/session') store.recordSuccess( agentModelCatalogFingerprintForRecord(sessionRecord), - 'codex', + 'grok', listing('gpt-session'), 'discovery' ) store.recordSuccess( selectedHomeFingerprint('/homes/selected'), - 'codex', + 'grok', listing('gpt-selected'), 'discovery' ) @@ -169,9 +173,9 @@ describe('agent model catalog service', () => { store, getRecord: () => sessionRecord, drivesRecord: () => true, - resolveAccountHome: async () => CODEX_HOME('/homes/selected') + resolveAccountHome: async () => GROK_HOME('/homes/selected') }) - const result = await service.read({ agent: 'codex', sessionId: 'session-1' }) + const result = await service.read({ agent: 'grok', sessionId: 'session-1' }) expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-session') }) @@ -184,16 +188,16 @@ describe('agent model catalog service', () => { store, getRecord: () => record('/homes/a'), drivesRecord: () => true, - resolveAccountHome: async () => CODEX_HOME('/homes/a'), - probes: { codex: probe } + resolveAccountHome: async () => GROK_HOME('/homes/a'), + probes: { grok: probe } }) - expect(await service.read({ agent: 'codex', sessionId: 'session-1' })).toEqual({ + expect(await service.read({ agent: 'grok', sessionId: 'session-1' })).toEqual({ origin: 'unknown', listingInProgress: true }) await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(1)) // Still a clean unknown — and the failure TTL suppresses a probe storm. - expect(await service.read({ agent: 'codex', sessionId: 'session-1' })).toEqual({ + expect(await service.read({ agent: 'grok', sessionId: 'session-1' })).toEqual({ origin: 'unknown' }) expect(probe).toHaveBeenCalledTimes(1) @@ -209,9 +213,9 @@ describe('agent model catalog service', () => { resolveAccountHome: async () => { throw new Error('no store yet') }, - probes: { codex: probe } + probes: { grok: probe } }) - expect(await service.read({ agent: 'codex' })).toEqual({ origin: 'unknown' }) + expect(await service.read({ agent: 'grok' })).toEqual({ origin: 'unknown' }) expect(probe).not.toHaveBeenCalled() }) @@ -234,8 +238,8 @@ describe('agent model catalog service', () => { store, getRecord: () => undefined, drivesRecord: () => true, - resolveAccountHome: async () => CODEX_HOME('/homes/selected'), - probes: { codex: probe } + resolveAccountHome: async () => GROK_HOME('/homes/selected'), + probes: { grok: probe } }) return { store, service } } @@ -244,11 +248,11 @@ describe('agent model catalog service', () => { const pending = deferredListing() const probe = vi.fn(() => pending.promise) const { service } = coldService(probe) - expect(await service.read({ agent: 'codex' })).toEqual({ + expect(await service.read({ agent: 'grok' })).toEqual({ origin: 'unknown', listingInProgress: true }) - const waited = service.read({ agent: 'codex', waitForListing: true }) + const waited = service.read({ agent: 'grok', waitForListing: true }) pending.resolve(listing('gpt-listed')) const result = await waited expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-listed') @@ -264,8 +268,8 @@ describe('agent model catalog service', () => { fingerprint: selectedHomeFingerprint('/homes/selected'), accountHomePath: '/homes/selected' } - void store.refresh(chat.fingerprint, 'codex', chat, () => pending.promise) - const waited = service.read({ agent: 'codex', waitForListing: true }) + void store.refresh(chat.fingerprint, 'grok', chat, () => pending.promise) + const waited = service.read({ agent: 'grok', waitForListing: true }) pending.resolve(listing('gpt-chat')) const result = await waited expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-chat') @@ -276,15 +280,15 @@ describe('agent model catalog service', () => { const pendingProbe = deferredListing() const pendingChat = deferredListing() const { store, service } = coldService(() => pendingProbe.promise) - expect(await service.read({ agent: 'codex' })).toEqual({ + expect(await service.read({ agent: 'grok' })).toEqual({ origin: 'unknown', listingInProgress: true }) - const waited = service.read({ agent: 'codex', waitForListing: true }) + const waited = service.read({ agent: 'grok', waitForListing: true }) await Promise.resolve() const fingerprint = selectedHomeFingerprint('/homes/selected') const chat = { store, fingerprint, accountHomePath: '/homes/selected' } - const chatListing = store.refresh(fingerprint, 'codex', chat, () => pendingChat.promise) + const chatListing = store.refresh(fingerprint, 'grok', chat, () => pendingChat.promise) pendingChat.resolve(listing('gpt-chat')) await chatListing const result = await waited @@ -296,12 +300,12 @@ describe('agent model catalog service', () => { const pendingProbe = deferredListing() const pendingChat = deferredListing() const { store, service } = coldService(() => pendingProbe.promise) - await service.read({ agent: 'codex' }) - const waited = service.read({ agent: 'codex', waitForListing: true }) + await service.read({ agent: 'grok' }) + const waited = service.read({ agent: 'grok', waitForListing: true }) await Promise.resolve() const fingerprint = selectedHomeFingerprint('/homes/selected') const chat = { store, fingerprint, accountHomePath: '/homes/selected' } - const chatListing = store.refresh(fingerprint, 'codex', chat, () => pendingChat.promise) + const chatListing = store.refresh(fingerprint, 'grok', chat, () => pendingChat.promise) pendingProbe.reject(new Error('probe timed out')) await vi.waitFor(() => expect(store.hasActiveFailure(fingerprint)).toBe(true)) let completed = false @@ -320,13 +324,13 @@ describe('agent model catalog service', () => { const firstChat = deferredListing() const secondChat = deferredListing() const { store, service } = coldService(() => pendingProbe.promise) - await service.read({ agent: 'codex' }) - const waited = service.read({ agent: 'codex', waitForListing: true }) + await service.read({ agent: 'grok' }) + const waited = service.read({ agent: 'grok', waitForListing: true }) await Promise.resolve() const fingerprint = selectedHomeFingerprint('/homes/selected') const first = store.refresh( fingerprint, - 'codex', + 'grok', { store, fingerprint, accountHomePath: '/homes/selected' }, () => firstChat.promise ) @@ -334,7 +338,7 @@ describe('agent model catalog service', () => { await vi.waitFor(() => expect(store.hasActiveFailure(fingerprint)).toBe(true)) const second = store.refresh( fingerprint, - 'codex', + 'grok', { store, fingerprint, accountHomePath: '/homes/selected' }, () => secondChat.promise ) @@ -351,13 +355,13 @@ describe('agent model catalog service', () => { const firstChat = deferredListing() const secondChat = deferredListing() const { store, service } = coldService(() => pendingProbe.promise) - await service.read({ agent: 'codex' }) - const waited = service.read({ agent: 'codex', waitForListing: true }) + await service.read({ agent: 'grok' }) + const waited = service.read({ agent: 'grok', waitForListing: true }) await Promise.resolve() const fingerprint = selectedHomeFingerprint('/homes/selected') const first = store.refresh( fingerprint, - 'codex', + 'grok', { store, fingerprint, accountHomePath: '/homes/selected' }, () => firstChat.promise ) @@ -365,7 +369,7 @@ describe('agent model catalog service', () => { await vi.waitFor(() => expect(store.hasActiveFailure(fingerprint)).toBe(true)) const second = store.refresh( fingerprint, - 'codex', + 'grok', { store, fingerprint, accountHomePath: '/homes/selected' }, () => secondChat.promise ) @@ -386,14 +390,14 @@ describe('agent model catalog service', () => { const pendingProbe = deferredListing() const pendingChat = deferredListing() const { store, service } = coldService(() => pendingProbe.promise) - await service.read({ agent: 'codex' }) + await service.read({ agent: 'grok' }) const fingerprint = selectedHomeFingerprint('/homes/selected') const chat = { store, fingerprint, accountHomePath: '/homes/selected' } - const chatListing = store.refresh(fingerprint, 'codex', chat, () => pendingChat.promise) + const chatListing = store.refresh(fingerprint, 'grok', chat, () => pendingChat.promise) pendingProbe.reject(new Error('probe timed out')) await vi.waitFor(() => expect(store.hasActiveFailure(fingerprint)).toBe(true)) - const waited = service.read({ agent: 'codex', waitForListing: true }) + const waited = service.read({ agent: 'grok', waitForListing: true }) let completed = false void waited.then(() => (completed = true)) await Promise.resolve() @@ -407,7 +411,7 @@ describe('agent model catalog service', () => { it('answers a plain unknown when the listing fails', async () => { const pending = deferredListing() const { service } = coldService(() => pending.promise) - const waited = service.read({ agent: 'codex', waitForListing: true }) + const waited = service.read({ agent: 'grok', waitForListing: true }) pending.reject(new Error('spawn failed')) expect(await waited).toEqual({ origin: 'unknown' }) }) @@ -418,10 +422,10 @@ describe('agent model catalog service', () => { }) const { store, service } = coldService(probe) store.recordFailure(selectedHomeFingerprint('/homes/selected'), 'spawn failed') - expect(await service.read({ agent: 'codex', waitForListing: true })).toEqual({ + expect(await service.read({ agent: 'grok', waitForListing: true })).toEqual({ origin: 'unknown' }) - expect(await service.read({ agent: 'codex' })).toEqual({ origin: 'unknown' }) + expect(await service.read({ agent: 'grok' })).toEqual({ origin: 'unknown' }) expect(probe).not.toHaveBeenCalled() }) @@ -431,9 +435,9 @@ describe('agent model catalog service', () => { store, getRecord: () => undefined, drivesRecord: () => true, - resolveAccountHome: async () => CODEX_HOME('/homes/selected') + resolveAccountHome: async () => GROK_HOME('/homes/selected') }) - expect(await service.read({ agent: 'codex', waitForListing: true })).toEqual({ + expect(await service.read({ agent: 'grok', waitForListing: true })).toEqual({ origin: 'unknown' }) }) @@ -443,7 +447,7 @@ describe('agent model catalog service', () => { const store = new AgentModelCatalogStore({ now: () => now }) store.recordSuccess( selectedHomeFingerprint('/homes/selected'), - 'codex', + 'grok', listing('gpt-old'), 'discovery' ) @@ -453,10 +457,10 @@ describe('agent model catalog service', () => { store, getRecord: () => undefined, drivesRecord: () => true, - resolveAccountHome: async () => CODEX_HOME('/homes/selected'), - probes: { codex: probe } + resolveAccountHome: async () => GROK_HOME('/homes/selected'), + probes: { grok: probe } }) - const result = await service.read({ agent: 'codex', waitForListing: true }) + const result = await service.read({ agent: 'grok', waitForListing: true }) expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-old') expect(probe).toHaveBeenCalledTimes(1) }) @@ -467,7 +471,7 @@ describe('agent model catalog service', () => { const store = new AgentModelCatalogStore() store.recordSuccess( selectedHomeFingerprint('/homes/selected'), - 'codex', + 'grok', listing('gpt-user'), 'discovery' ) @@ -476,7 +480,7 @@ describe('agent model catalog service', () => { store, getRecord: () => undefined, drivesRecord: () => true, - resolveAccountHome: async () => CODEX_HOME('/homes/selected'), + resolveAccountHome: async () => GROK_HOME('/homes/selected'), workspaceMayOverrideDefaultModel }) return { service, workspaceMayOverrideDefaultModel } @@ -490,10 +494,10 @@ describe('agent model catalog service', () => { it('names no default when the workspace config could pick another model', async () => { const { service, workspaceMayOverrideDefaultModel } = serviceWith(true) - const result = await service.read({ agent: 'codex', workspacePath: '/repo/wt' }) + const result = await service.read({ agent: 'grok', workspacePath: '/repo/wt' }) expect(defaults(result)).toEqual([false]) expect(workspaceMayOverrideDefaultModel).toHaveBeenCalledWith({ - agent: 'codex', + agent: 'grok', workspacePath: '/repo/wt', accountHomePath: '/homes/selected' }) @@ -501,20 +505,20 @@ describe('agent model catalog service', () => { it('keeps the listed default when nothing in the workspace can replace it', async () => { const { service } = serviceWith(false) - expect(defaults(await service.read({ agent: 'codex', workspacePath: '/repo/wt' }))).toEqual([ + expect(defaults(await service.read({ agent: 'grok', workspacePath: '/repo/wt' }))).toEqual([ true ]) }) it('names no default for a workspace it could not place on this machine', async () => { const { service, workspaceMayOverrideDefaultModel } = serviceWith(false) - expect(defaults(await service.read({ agent: 'codex', workspacePath: null }))).toEqual([false]) + expect(defaults(await service.read({ agent: 'grok', workspacePath: null }))).toEqual([false]) expect(workspaceMayOverrideDefaultModel).not.toHaveBeenCalled() }) it('leaves a read that names no workspace as it was', async () => { const { service, workspaceMayOverrideDefaultModel } = serviceWith(true) - expect(defaults(await service.read({ agent: 'codex' }))).toEqual([true]) + expect(defaults(await service.read({ agent: 'grok' }))).toEqual([true]) expect(workspaceMayOverrideDefaultModel).not.toHaveBeenCalled() }) }) @@ -574,3 +578,82 @@ describe('agent model catalog service', () => { }) }) }) + +it.each(['cold', 'warm', 'failure', 'stop'] as const)( + 'delivers queued %s settlement after the 30s snapshot to joined readers', + async (mode) => { + vi.useFakeTimers() + const store = new AgentModelCatalogStore() + let failing = false + const run = vi.fn( + ( + _home: AgentSessionAccountHome, + options?: { signal?: AbortSignal; workspacePath?: string } + ) => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => + failing + ? reject(new Error('offline')) + : resolve({ + ...listing(options?.workspacePath ?? 'listed'), + complete: true, + configured: {} + }), + 12_000 + ) + options?.signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer) + reject(new Error('stopped')) + }, + { once: true } + ) + }) + ) + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + drivesRecord: () => true, + resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/account' }), + probes: { codex: run } + }) + const read = (path: string, waitForListing = false) => + service.read({ agent: 'codex', workspacePath: path, waitForListing }) + const replies: unknown[] = [] + let leave = () => {} + try { + if (mode === 'warm') { + const initial = read('/5', true) + await vi.advanceTimersByTimeAsync(12_000) + await initial + await vi.advanceTimersByTimeAsync(120_001) + } + failing = mode === 'failure' + await Promise.all(['/1', '/2', '/3', '/4', '/5'].map((path) => read(path))) + const follow = vi.fn(() => read('/5', true)) + const owner = { close() {} } + leave = joinHostModelListingWait('queued', follow, (reply) => replies.push(reply), owner) + const second = joinHostModelListingWait('queued', follow, () => {}, owner) + await vi.advanceTimersByTimeAsync(30_000) + expect(replies[0]).toMatchObject({ listingInProgress: true }) + expect(follow).toHaveBeenCalledTimes(1) + if (mode === 'stop') { + service.stop() + } + await vi.advanceTimersByTimeAsync(7_000) + expect(replies.at(-1)).not.toHaveProperty('listingInProgress') + expect(isHostModelListingWaitInFlight('queued', owner)).toBe(false) + if (mode === 'cold' || mode === 'warm') { + expect(replies.at(-1)).toMatchObject({ models: [{ id: '/5' }] }) + } + expect(run).toHaveBeenCalledTimes(mode === 'warm' ? 6 : 5) + second() + } finally { + leave() + service.stop() + vi.useRealTimers() + } + } +) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts index 19f9765ac3b..8bbf4553211 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts @@ -1,117 +1,38 @@ +import { + agentModelCatalogProbeLister, + scopedAgentModelCatalogLister +} from './agent-model-catalog-lister' +import { prewarmAgentModelCatalog, newChatCatalogKey } from './agent-model-catalog-prewarm' import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session-wire' import type { AgentSessionAccountHome, AgentSessionRecord } from '../../../shared/agent-session-record' import { isLegacyAgentSessionAccountHome } from '../../../shared/agent-session-account-home' -import { - agentModelCatalogFingerprint, - agentModelCatalogFingerprintForRecord -} from './agent-model-catalog-fingerprint' +import { agentModelCatalogFingerprintForRecord } from './agent-model-catalog-fingerprint' import { agentModelCatalogResultFromEntry, retainedAgentModelCatalogModels } from './agent-model-catalog-entry' import type { AgentModelCatalogEntry, - AgentModelCatalogLiveListing, - AgentModelCatalogProbe, - AgentModelCatalogSuccess + AgentModelCatalogLiveListing } from './agent-model-catalog-store' -import { AgentModelCatalogListingStoppedError } from './agent-model-catalog-failures' import { catalogFolder, recordConfiguredDefault, sessionWorkspacePath, - workspaceKeepsListedDefault, - type AgentModelCatalogWorkspaceDeps + workspaceKeepsListedDefault } from './agent-model-catalog-workspace' -export type AgentModelCatalogServiceDeps = AgentModelCatalogWorkspaceDeps & { - getRecord: (sessionId: string) => AgentSessionRecord | undefined - /** Whether this build can start the record's agent as the record pins it; a record it cannot - * names no account a probe may start that agent's CLI under. */ - drivesRecord: (record: AgentSessionRecord) => boolean - /** The account home a structured launch for this agent would pin right now — - * the SAME resolver the create path fills `record.accountHome` with, so a - * record-less read can never answer from another account's listing. */ - resolveAccountHome: (agent: string) => Promise - /** Session-less listers, one per agent that has one on this host. */ - probes?: Readonly>> - /** Agents whose listing marks the model the account is configured to run as its default. */ - listingNamesConfiguredModel?: ReadonlySet - /** False for an agent whose model no project config can pick; its default holds everywhere. */ - agentReadsProjectModelConfig?: (agent: string) => boolean - /** Whether this host keeps any chat of the agent; with a saved catalog, what marks it in use. */ - hasChatRecords?: (agent: string) => boolean -} - -export type AgentModelCatalogService = { - read: (params: { - agent: string - sessionId?: string - /** Where a new chat would run; null when one was named but is not a local directory. */ - workspacePath?: string | null - /** Answer for the folder the named session runs in, as its new-chat read did. */ - inSessionWorkspace?: true - /** With no entry yet, answer from the listing this read starts or joins instead of `unknown`; - * with a held reason past its TTL, from the probe re-checking it. */ - waitForListing?: boolean - /** Answer only from the saved entry and held reason; start, join or re-check no listing. */ - savedOnly?: boolean - }) => Promise - /** Incoming listing ids, checking only a launch-added row against the pinned account. */ - retainedLiveModelIds: ( - sessionId: string, - listing: AgentModelCatalogLiveListing - ) => Promise - /** Saves a session's listing and newly learned menus so the next chat starts warm. */ - recordLiveListing: (sessionId: string, listing: AgentModelCatalogLiveListing) => void - /** Lists, in the background, every agent the user has used here (a saved catalog or a chat) - * whose catalog for the account a new chat would pin is missing or old, so a picker never meets - * a cold catalog. Resolves once those listings settle. */ - prewarm: () => Promise - /** The host is going away: start no listing, and stop the ones running. */ - stop: () => void - /** A chat under this record's account proved its start: a held reason is re-checked sooner. */ - providerStarted: (record: AgentSessionRecord) => void -} - -// At most this many agents list at once: each listing spawns that agent's CLI. -const PREWARM_CONCURRENCY = 2 - -/** The catalog a new chat of `agent` would read: the account a launch would pin right now. */ -async function newChatCatalogKey( - deps: AgentModelCatalogServiceDeps, - agent: string, - folder?: string -): Promise<{ fingerprint: string; accountHome: AgentSessionAccountHome } | null> { - let accountHome: AgentSessionAccountHome - try { - accountHome = await deps.resolveAccountHome(agent) - } catch { - return null - } - return { - fingerprint: agentModelCatalogFingerprint({ agent, accountHome, wslDistro: null, folder }), - accountHome - } -} - -async function runBounded( - items: readonly T[], - limit: number, - stopped: AbortSignal, - run: (item: T) => Promise -): Promise { - const queue = [...items] - const worker = async (): Promise => { - for (let item = queue.shift(); item !== undefined && !stopped.aborted; item = queue.shift()) { - await run(item).catch(() => {}) - } - } - await Promise.all(Array.from({ length: Math.min(limit, queue.length) }, worker)) -} +import type { + AgentModelCatalogServiceDeps, + AgentModelCatalogService +} from './agent-model-catalog-service-contract' +export type { AgentModelCatalogServiceDeps, AgentModelCatalogService } +import { resolveAgentModelCatalogScope } from './agent-model-catalog-scope' +import { independentAgentModelCatalog } from './agent-model-catalog-discovery' +import { PrioritySemaphore } from '../../../shared/priority-semaphore' /** * Serves the host catalog to pickers, never through a session's serialize @@ -130,17 +51,12 @@ export function createAgentModelCatalogService( ): AgentModelCatalogService { // One per host: stopping it ends every listing this service started. const lifetime = new AbortController() + const admission = new PrioritySemaphore(2) + const sessionKeys = new Map() // A chat's configured default still being checked against its workspace, per catalog: a read // answers after it, so the chat that reported it is followed by an answer naming it. const recordingDefaults = new Map>() - const listWith = - (probe: AgentModelCatalogProbe, home: AgentSessionAccountHome, workspacePath?: string) => - (): Promise => - probe(home, { signal: lifetime.signal, workspacePath }).catch((error: unknown) => { - throw lifetime.signal.aborted - ? new AgentModelCatalogListingStoppedError(String(error)) - : error - }) + const listWith = agentModelCatalogProbeLister(lifetime.signal) // A running chat's saves for agents kept per folder, which wait on a folder lookup. const liveWrites = new Map>() /** The catalog a session's own listings belong to: its pinned account and host, and its folder @@ -176,7 +92,16 @@ export function createAgentModelCatalogService( } } return { + peek(sessionId, verifiedOnly) { + const key = sessionKeys.get(sessionId) + const entry = key ? deps.store.get(key) : null + return entry && (!verifiedOnly || deps.store.verified(entry)) ? entry : null + }, providerStarted(record) { + const key = sessionKeys.get(record.sessionId) + if (key) { + deps.store.expireFailure(key) + } if (!deps.listsInChatFolder?.has(record.provider)) { deps.store.expireFailure(agentModelCatalogFingerprintForRecord(record)) return @@ -201,7 +126,10 @@ export function createAgentModelCatalogService( if (scoped) { fingerprint = agentModelCatalogFingerprintForRecord(scoped, folder) // Probes spawn natively; a WSL-pinned record has no host-side lister. - probeHome = scoped.location.wslDistro === null ? scoped.accountHome : null + probeHome = + scoped.location.executionHostId === 'local' && scoped.location.wslDistro === null + ? scoped.accountHome + : null } else { const key = await newChatCatalogKey(deps, params.agent, folder) if (!key) { @@ -210,25 +138,80 @@ export function createAgentModelCatalogService( fingerprint = key.fingerprint probeHome = key.accountHome } + const scope = await resolveAgentModelCatalogScope( + deps, + params, + scoped, + fingerprint, + probeHome + ) + if (!scope) { + return { origin: 'unknown' } + } + const { base, exact, resolved, cwd } = scope + fingerprint = scope.fingerprint + if (scoped && exact) { + if (sessionKeys.size >= 256 && !sessionKeys.has(scoped.sessionId)) { + const oldest = sessionKeys.keys().next().value + if (oldest !== undefined) { + sessionKeys.delete(oldest) + } + } + sessionKeys.set(scoped.sessionId, fingerprint) + } const accountHomePath = probeHome && isLegacyAgentSessionAccountHome(probeHome) ? probeHome.path : null - // A chat's report still finding its folder lands first, as it would have before the read. await Promise.all(liveWrites.values()) await recordingDefaults.get(fingerprint) let entry = deps.store.get(fingerprint) const probe = deps.probes?.[params.agent] - const home = probeHome + const list = scopedAgentModelCatalogLister({ + legacy: params.legacy, + admission, + signal: lifetime.signal, + resolved, + probe, + probeHome, + workspacePath: cwd ?? folder + }) // Every answer carries the reason the probe last found, read when the answer is made. const answer = async ( listed: AgentModelCatalogEntry | null, extra: { listingInProgress?: true } = {} ): Promise => { - const unavailable = deps.store.failure(fingerprint)?.unavailable - const result = listed + if ( + params.verifiedOnly && + (!listed || + (independentAgentModelCatalog(params.agent) && + (!exact || !deps.store.verified(listed)))) + ) { + return { origin: 'unknown' } + } + const display = listed ?? (exact && !params.verifiedOnly ? deps.store.get(base) : null) + const independent = independentAgentModelCatalog(params.agent) + const accountFallback = + independent && + scoped && + params.savedOnly && + params.workspacePath === null && + !params.inSessionWorkspace + const result = display ? agentModelCatalogResultFromEntry( - listed, - await workspaceKeepsListedDefault(deps, params.agent, workspacePath, accountHomePath), - deps.listingNamesConfiguredModel?.has(params.agent) === true + display, + independent && + !params.legacyOptionsDisplay && + (!deps.store.verified(display) || (!exact && !params.legacy)) + ? false + : exact && listed !== null && deps.store.verified(listed) + ? true + : await workspaceKeepsListedDefault( + deps, + params.agent, + accountFallback ? undefined : workspacePath, + accountHomePath + ), + deps.listingNamesConfiguredModel?.has(params.agent) === true, + !independent || (exact && listed !== null && deps.store.verified(listed)) ) : null const holdsEverywhere = @@ -236,51 +219,66 @@ export function createAgentModelCatalogService( deps.agentReadsProjectModelConfig?.(params.agent) === false return { ...(result ?? { origin: 'unknown' }), - ...(holdsEverywhere ? { defaultHoldsInEveryWorkspace: true as const } : {}), - ...(result && folder !== undefined ? { modelsDifferByWorkspace: true as const } : {}), ...extra, - ...(unavailable ? { unavailable } : {}) + ...(holdsEverywhere ? { defaultHoldsInEveryWorkspace: true as const } : {}), + ...(result && (folder !== undefined || exact) + ? { modelsDifferByWorkspace: true as const } + : {}), + ...(!params.savedOnly && + !params.verifiedOnly && + deps.store.isListing(fingerprint) && + (independent || !listed) + ? { listingInProgress: true as const } + : {}), + ...(deps.store.failure(fingerprint)?.unavailable + ? { unavailable: deps.store.failure(fingerprint)?.unavailable } + : {}) } } - // Death evidence keeps a read after an observed exit from spawning another agent; a new chat - // also starts released, but still needs its first listing. + // An observed exit prevents background relaunch; a new chat can still discover. if ( params.savedOnly || - (scoped?.lease?.claimStatus === 'released' && scoped.lease.deathEvidence) + (!exact && scoped?.lease?.claimStatus === 'released' && scoped.lease.deathEvidence) ) { return answer(entry) } - // Past its TTL, only the probe re-derives a held reason. The reason is served meanwhile; - // only a read that asks waits for the probe's answer. - if (probe && home && !lifetime.signal.aborted && deps.store.heldReasonDue(fingerprint)) { - const probing = deps.store.refresh( - fingerprint, - params.agent, - probe, - listWith(probe, home, folder) - ) + // Only a requested wait blocks on rechecking an expired held reason. + if (probe && probeHome && !lifetime.signal.aborted && deps.store.heldReasonDue(fingerprint)) { + void deps.store.refresh(fingerprint, params.agent, probe, list) if (!params.waitForListing) { return answer(entry, { listingInProgress: true }) } - await probing + await deps.store.pendingListing(fingerprint, true, params.legacy) return answer(deps.store.get(fingerprint)) } // Without an entry, answer from any running listing instead of starting a second one. - let listing = !entry && home ? deps.store.pendingListing(fingerprint) : null - if (probe && home && !lifetime.signal.aborted) { + let listing = + !entry && probeHome ? deps.store.pendingListing(fingerprint, false, params.legacy) : null + if (probe && probeHome && !lifetime.signal.aborted) { if (entry && deps.store.shouldRefresh(fingerprint)) { - void deps.store.refresh(fingerprint, params.agent, probe, listWith(probe, home, folder)) - } else if (!entry && !listing && !deps.store.hasActiveFailure(fingerprint)) { - void deps.store.refresh(fingerprint, params.agent, probe, listWith(probe, home, folder)) - listing = deps.store.pendingListing(fingerprint) + void deps.store.refresh(fingerprint, params.agent, probe, list) + } else if (!entry && !listing && deps.store.shouldRefresh(fingerprint)) { + void deps.store.refresh(fingerprint, params.agent, probe, list) + listing = deps.store.pendingListing(fingerprint, false, params.legacy) } } + if ( + entry && + independentAgentModelCatalog(params.agent) && + deps.store.isListing(fingerprint) + ) { + if (!params.waitForListing) { + return answer(entry) + } + await deps.store.pendingListing(fingerprint, true, params.legacy) + return answer(deps.store.get(fingerprint)) + } if (!entry) { if (!listing) { return answer(null) } if (!params.waitForListing) { - return answer(null, { listingInProgress: true }) + return answer(null) } const listed = await listing entry = deps.store.get(fingerprint) ?? listed @@ -297,7 +295,7 @@ export function createAgentModelCatalogService( }, recordLiveListing(sessionId, listing) { const record = deps.getRecord(sessionId) - if (!record || !deps.drivesRecord(record)) { + if (!record || !deps.drivesRecord(record) || independentAgentModelCatalog(record.provider)) { return } if (!deps.listsInChatFolder?.has(record.provider)) { @@ -318,28 +316,12 @@ export function createAgentModelCatalogService( liveWrites.set(sessionId, writing) }, async prewarm() { - if (lifetime.signal.aborted) { - return - } - const probes = deps.probes ?? {} - // An agent never used here waits for its first chat, which shows the quiet placeholder. - const used = Object.keys(probes).filter( - (agent) => - !deps.listsInChatFolder?.has(agent) && - (deps.store.hasEntryForAgent(agent) || deps.hasChatRecords?.(agent) === true) - ) - await runBounded(used, PREWARM_CONCURRENCY, lifetime.signal, async (agent) => { - const probe = probes[agent] - const key = probe ? await newChatCatalogKey(deps, agent) : null - // A fresh entry, a listing already running, or a recent failure each mean nothing to do. - if (!probe || !key || lifetime.signal.aborted || !deps.store.probeDue(key.fingerprint)) { - return - } - await deps.store.refresh(key.fingerprint, agent, probe, listWith(probe, key.accountHome)) - }) + await prewarmAgentModelCatalog(deps, lifetime.signal, (params) => this.read(params), listWith) }, stop() { lifetime.abort() + sessionKeys.clear() + deps.store.clearPartialAttempts() } } } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts index a2d81ea0a2c..14c3cb2af1d 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts @@ -46,7 +46,7 @@ describe('agent model catalog store', () => { it('serves an entry at any age and flags staleness at the refresh threshold', () => { let at = 1_000 const store = new AgentModelCatalogStore({ now: () => at }) - store.recordSuccess('fp-1', 'codex', success('gpt-a'), 'discovery') + store.recordSuccess('fp-1', 'grok', success('gpt-a'), 'discovery') const entry = store.get('fp-1')! expect(entry.models.map((model) => model.id)).toEqual(['gpt-a']) expect(store.isStale(entry)).toBe(false) @@ -61,7 +61,7 @@ describe('agent model catalog store', () => { expect( store.recordSuccess( 'fp-1', - 'codex', + 'grok', { models: [], origin: 'live-session' @@ -75,7 +75,7 @@ describe('agent model catalog store', () => { it('holds a failure under its TTL without touching the last good entry', () => { let at = 1_000 const store = new AgentModelCatalogStore({ now: () => at }) - store.recordSuccess('fp-1', 'codex', success('gpt-a'), 'discovery') + store.recordSuccess('fp-1', 'grok', success('gpt-a'), 'discovery') store.recordFailure('fp-1', 'timed out') expect(store.get('fp-1')!.models.map((model) => model.id)).toEqual(['gpt-a']) expect(store.hasActiveFailure('fp-1')).toBe(true) @@ -92,8 +92,8 @@ describe('agent model catalog store', () => { () => new Promise((resolve) => (settle = resolve)) ) const session = liveLister(store) - const first = store.refresh('fp-1', 'codex', session, fetch) - const second = store.refresh('fp-1', 'codex', session, fetch) + const first = store.refresh('fp-1', 'grok', session, fetch) + const second = store.refresh('fp-1', 'grok', session, fetch) expect(fetch).toHaveBeenCalledTimes(1) settle(success('gpt-a')) const [entryA, entryB] = await Promise.all([first, second]) @@ -106,12 +106,12 @@ describe('agent model catalog store', () => { let failProbe!: (error: Error) => void const hungProbe: AgentModelCatalogProbe = () => new Promise((_resolve, reject) => (failProbe = reject)) - const probe = store.refresh('fp-1', 'codex', hungProbe, () => - hungProbe({ variable: 'CODEX_HOME', path: '/homes/a' }) + const probe = store.refresh('fp-1', 'grok', hungProbe, () => + hungProbe({ variable: 'GROK_HOME', path: '/homes/a' }) ) expect(store.shouldRefresh('fp-1')).toBe(false) - const live = await store.refresh('fp-1', 'codex', liveLister(store), async () => + const live = await store.refresh('fp-1', 'grok', liveLister(store), async () => success('gpt-live') ) expect(live!.models[0]!.id).toBe('gpt-live') @@ -130,13 +130,13 @@ describe('agent model catalog store', () => { let settleSecond!: (success: AgentModelCatalogSuccess) => void void store.refresh( 'fp-1', - 'codex', + 'grok', liveLister(store), () => new Promise((_resolve, reject) => (failFirst = reject)) ) void store.refresh( 'fp-1', - 'codex', + 'grok', liveLister(store), () => new Promise((resolve) => (settleSecond = resolve)) ) @@ -145,7 +145,7 @@ describe('agent model catalog store', () => { settleSecond(success('gpt-second')) expect((await pending)!.models[0]!.id).toBe('gpt-second') - void store.refresh('fp-2', 'codex', liveLister(store), async () => { + void store.refresh('fp-2', 'grok', liveLister(store), async () => { throw new Error('no provider') }) expect(await store.pendingListing('fp-2')).toBeNull() @@ -158,7 +158,7 @@ describe('agent model catalog store', () => { let settle!: (success: AgentModelCatalogSuccess) => void const listing = store.refresh( 'fp-1', - 'codex', + 'grok', liveLister(store), () => new Promise((resolve) => (settle = resolve)) ) @@ -178,11 +178,11 @@ describe('agent model catalog store', () => { let settleSlow!: (success: AgentModelCatalogSuccess) => void const slow = store.refresh( 'fp-1', - 'codex', + 'grok', liveLister(store), () => new Promise((resolve) => (settleSlow = resolve)) ) - await store.refresh('fp-1', 'codex', liveLister(store), async () => success('gpt-fast')) + await store.refresh('fp-1', 'grok', liveLister(store), async () => success('gpt-fast')) at += AGENT_MODEL_CATALOG_FRESH_MS expect(store.shouldRefresh('fp-1')).toBe(false) @@ -200,11 +200,11 @@ describe('agent model catalog store', () => { let settleOlder!: (success: AgentModelCatalogSuccess) => void const older = store.refresh( 'fp-1', - 'codex', + 'grok', liveLister(store), () => new Promise((resolve) => (settleOlder = resolve)) ) - const newer = await store.refresh('fp-1', 'codex', liveLister(store), async () => + const newer = await store.refresh('fp-1', 'grok', liveLister(store), async () => success('gpt-new') ) settleOlder(success('gpt-old')) @@ -221,10 +221,10 @@ describe('agent model catalog store', () => { let settleProbe!: (success: AgentModelCatalogSuccess) => void const probe: AgentModelCatalogProbe = () => new Promise((resolve) => (settleProbe = resolve)) - const pending = store.refresh('fp-1', 'codex', probe, () => - probe({ variable: 'CODEX_HOME', path: '/homes/a' }) + const pending = store.refresh('fp-1', 'grok', probe, () => + probe({ variable: 'GROK_HOME', path: '/homes/a' }) ) - store.recordSuccess('fp-1', 'codex', success('gpt-live'), 'discovery') + store.recordSuccess('fp-1', 'grok', success('gpt-live'), 'discovery') settleProbe({ ...success('gpt-probe'), origin: 'probe' }) expect((await pending)!.models[0]!.id).toBe('gpt-probe') expect(store.get('fp-1')!.models[0]!.id).toBe('gpt-live') @@ -235,10 +235,10 @@ describe('agent model catalog store', () => { let settleProbe!: (success: AgentModelCatalogSuccess) => void const probe: AgentModelCatalogProbe = () => new Promise((resolve) => (settleProbe = resolve)) - const pending = store.refresh('fp-1', 'codex', probe, () => - probe({ variable: 'CODEX_HOME', path: '/homes/a' }) + const pending = store.refresh('fp-1', 'grok', probe, () => + probe({ variable: 'GROK_HOME', path: '/homes/a' }) ) - store.recordSuccess('fp-1', 'codex', success('gpt-live'), 'live') + store.recordSuccess('fp-1', 'grok', success('gpt-live'), 'live') settleProbe({ ...success('gpt-probe'), origin: 'probe' }) await pending const entry = store.get('fp-1')! @@ -246,7 +246,7 @@ describe('agent model catalog store', () => { expect(entry.live!.models[0]!.id).toBe('gpt-live') // A live save neither postpones the next discovery nor clears its failure back-off. store.recordFailure('fp-1', 'timed out') - store.recordSuccess('fp-1', 'codex', success('gpt-live'), 'live') + store.recordSuccess('fp-1', 'grok', success('gpt-live'), 'live') expect(store.hasActiveFailure('fp-1')).toBe(true) }) @@ -255,13 +255,13 @@ describe('agent model catalog store', () => { let settleOlder!: (success: AgentModelCatalogSuccess) => void const older = store.refresh( 'fp-1', - 'codex', + 'grok', liveLister(store), () => new Promise((resolve) => (settleOlder = resolve)) ) - await store.refresh('fp-1', 'codex', liveLister(store), async () => success('gpt-new')) + await store.refresh('fp-1', 'grok', liveLister(store), async () => success('gpt-new')) for (let index = 0; index < AGENT_MODEL_CATALOG_MAX_ENTRIES; index++) { - store.recordSuccess(`other-${index}`, 'codex', success('other'), 'discovery') + store.recordSuccess(`other-${index}`, 'grok', success('other'), 'discovery') } expect(store.get('fp-1')).toBeNull() const save = vi.fn() @@ -277,7 +277,7 @@ describe('agent model catalog store', () => { it('records a failed refresh as a failure and resolves null without rejecting', async () => { const store = new AgentModelCatalogStore() - const entry = await store.refresh('fp-1', 'codex', liveLister(store), async () => { + const entry = await store.refresh('fp-1', 'grok', liveLister(store), async () => { throw new Error('no provider') }) expect(entry).toBeNull() @@ -288,27 +288,27 @@ describe('agent model catalog store', () => { it('keys entries by fingerprint so one account never answers for another', () => { const fingerprintA = agentModelCatalogFingerprint({ - agent: 'codex', - accountHomeVariable: 'CODEX_HOME', + agent: 'grok', + accountHomeVariable: 'GROK_HOME', accountHomePath: '/homes/a', wslDistro: null }) const fingerprintB = agentModelCatalogFingerprint({ - agent: 'codex', - accountHomeVariable: 'CODEX_HOME', + agent: 'grok', + accountHomeVariable: 'GROK_HOME', accountHomePath: '/homes/b', wslDistro: null }) expect(fingerprintA).not.toBe(fingerprintB) const store = new AgentModelCatalogStore() - store.recordSuccess(fingerprintA, 'codex', success('gpt-a'), 'discovery') + store.recordSuccess(fingerprintA, 'grok', success('gpt-a'), 'discovery') expect(store.get(fingerprintB)).toBeNull() }) it('derives the record fingerprint from the pinned account home', () => { const record: Pick = { - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/homes/a' }, + provider: 'grok', + accountHome: { variable: 'GROK_HOME', path: '/homes/a' }, location: { executionHostId: LOCAL_EXECUTION_HOST_ID, wslDistro: null, @@ -318,8 +318,8 @@ describe('agent model catalog store', () => { } expect(agentModelCatalogFingerprintForRecord(record)).toBe( agentModelCatalogFingerprint({ - agent: 'codex', - accountHomeVariable: 'CODEX_HOME', + agent: 'grok', + accountHomeVariable: 'GROK_HOME', accountHomePath: '/homes/a', wslDistro: null }) @@ -331,12 +331,12 @@ describe('agent model catalog store', () => { const store = new AgentModelCatalogStore({ now: () => at }) const save = vi.fn() void store.attachPersistence({ load: async () => [], save, flush: async () => {} }) - store.recordSuccess('fp', 'claude', success('opus'), 'discovery') + store.recordSuccess('fp', 'grok', success('opus'), 'discovery') at += AGENT_MODEL_CATALOG_FRESH_MS - store.recordSuccess('fp', 'claude', success('opus'), 'discovery') + store.recordSuccess('fp', 'grok', success('opus'), 'discovery') expect(save).toHaveBeenCalledTimes(1) expect(store.shouldRefresh('fp')).toBe(false) - store.recordSuccess('fp', 'claude', success('opus', 'sonnet'), 'discovery') + store.recordSuccess('fp', 'grok', success('opus', 'sonnet'), 'discovery') expect(save).toHaveBeenCalledTimes(2) }) @@ -360,9 +360,9 @@ describe('agent model catalog store', () => { ], origin: 'live-session' }) - store.recordSuccess('fp', 'claude', listing('medium'), 'live') - // A session-less probe never names Claude's default. - store.recordSuccess('fp', 'claude', { ...listing(), origin: 'probe' }, 'discovery') + store.recordSuccess('fp', 'grok', listing('medium'), 'live') + // A session-less probe never names the provider's default. + store.recordSuccess('fp', 'grok', { ...listing(), origin: 'probe' }, 'discovery') expect(store.get('fp')!.models[0]!.defaultEffort).toBe('medium') await store.flushPersistence() const restarted = new AgentModelCatalogStore() @@ -370,11 +370,11 @@ describe('agent model catalog store', () => { expect(restarted.get('fp')!.models[0]!.defaultEffort).toBe('medium') // A newer report replaces it; a model that stops offering it drops it. - store.recordSuccess('fp', 'claude', listing('high'), 'live') + store.recordSuccess('fp', 'grok', listing('high'), 'live') expect(store.get('fp')!.models[0]!.defaultEffort).toBe('high') store.recordSuccess( 'fp', - 'claude', + 'grok', { ...listing(), models: [{ id: 'opus', label: 'Opus', isDefault: true, efforts: [efforts[0]!] }] @@ -392,11 +392,11 @@ describe('agent model catalog store', () => { store.recordConfiguredDefault('fp', { modelId: 'sonnet' }) expect(store.get('fp')).toBeNull() - store.recordSuccess('fp', 'claude', success('opus', 'sonnet'), 'live') + store.recordSuccess('fp', 'grok', success('opus', 'sonnet'), 'live') store.recordConfiguredDefault('fp', { modelId: 'sonnet', effort: 'high' }) store.recordSuccess( 'fp', - 'claude', + 'grok', { ...success('opus', 'sonnet'), origin: 'probe' }, 'discovery' ) @@ -426,7 +426,7 @@ describe('agent model catalog store', () => { const directory = mkdtempSync(join(tmpdir(), 'agent-model-catalog-')) const store = new AgentModelCatalogStore() await store.attachPersistence(createAgentModelCatalogFilePersistence(directory)) - store.recordSuccess('fp-1', 'codex', success('gpt-a'), 'discovery') + store.recordSuccess('fp-1', 'grok', success('gpt-a'), 'discovery') store.recordFailure('fp-2', 'timed out') await vi.waitFor( async () => { @@ -454,7 +454,7 @@ describe('agent model catalog store', () => { ] store.recordSuccess( 'fp-tiers', - 'codex', + 'grok', { models: [{ id: 'gpt-a', label: 'A', isDefault: true, efforts: [], serviceTiers }], origin: 'probe' @@ -470,7 +470,7 @@ describe('agent model catalog store', () => { const directory = mkdtempSync(join(tmpdir(), 'agent-model-catalog-')) const store = new AgentModelCatalogStore() await store.attachPersistence(createAgentModelCatalogFilePersistence(directory)) - store.recordSuccess('fp-1', 'codex', success('gpt-a'), 'discovery') + store.recordSuccess('fp-1', 'grok', success('gpt-a'), 'discovery') await store.flushPersistence() const persisted = await createAgentModelCatalogFilePersistence(directory).load() expect(persisted.map((entry) => entry.fingerprint)).toEqual(['fp-1']) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts index 53ec19ef3cf..41bf86beffb 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts @@ -1,6 +1,6 @@ -import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' +import { independentAgentModelCatalog } from './agent-model-catalog-discovery' +export type { AgentModelCatalogProbe } from './agent-model-catalog-discovery' import type { AgentModelCatalogPersistence } from './agent-model-catalog-persistence' -import { startSpan } from '../../observability/tracer' import { agentModelCatalogEntryWithSuccess, agentModelCatalogListingKey, @@ -22,7 +22,6 @@ export { withLiveCatalogListing } from './agent-model-catalog-entry' import { AGENT_MODEL_CATALOG_FAILURE_TTL_MS, AgentModelCatalogFailures, - AgentModelCatalogListingStoppedError, type AgentModelCatalogFailure } from './agent-model-catalog-failures' import { @@ -33,13 +32,9 @@ import { export { AGENT_MODEL_CATALOG_FAILURE_TTL_MS, AGENT_MODEL_CATALOG_PICKER_WAIT_MS } export type { AgentModelCatalogFailure } -// The execution host's one model catalog per (agent, launch fingerprint): -// served immediately at any age, refreshed in the background when old, and -// written through by each session's startup and later capability listings. -// Each entry keeps two listings: the last account-level discovery (the only -// source of the configured default and default efforts, and the only clock a -// refresh follows) and the last received session listing (which models exist -// and their efforts). Readers see the two merged; neither write erases the other. +// Claude/Codex inventory has one discovery owner per launch scope; other agents +// retain separate discovery and live listings. Stale entries remain displayable, +// while proof requires fresh discovery completed in this process. // Success-only: a failure, timeout or empty list is never stored as a catalog // and never persisted — it is held separately under a short TTL so a burst of // picker opens does not hammer a dead binary, then dies on its own (see @@ -48,27 +43,12 @@ export type { AgentModelCatalogFailure } export const AGENT_MODEL_CATALOG_FRESH_MS = 10 * 60_000 export const AGENT_MODEL_CATALOG_MAX_ENTRIES = 256 -/** Lists an agent's models before a chat, under the account a launch would pin. `signal` stops - * the listing and its child once the host that asked is going away; `workspacePath` is the folder - * of the chat that asked, where a listing that opens a session runs. */ -export type AgentModelCatalogProbe = ( - accountHome: AgentSessionAccountHome, - options?: { signal?: AbortSignal; workspacePath?: string } -) => Promise - -/** Who lists, by identity: a live session's per-spawn handle, or the session-less probe. */ -export type AgentModelCatalogLister = AgentModelCatalogSessionAccess | AgentModelCatalogProbe - -type InFlightListings = Map> - -/** A live session's handle into the store, pinned at spawn to the account home - * THAT child launched under — an account switched afterwards must never - * receive or poison this session's listing. */ -export type AgentModelCatalogSessionAccess = { - store: AgentModelCatalogStore - fingerprint: string - accountHomePath: string -} +import { + refreshAgentModelCatalogListing, + type AgentModelCatalogLister, + type InFlightListings +} from './agent-model-catalog-lister' +export type { AgentModelCatalogSessionAccess } from './agent-model-catalog-lister' export class AgentModelCatalogStore { private readonly entries = new Map() @@ -81,6 +61,7 @@ export class AgentModelCatalogStore { private readonly latestWrittenOrder = new Map() // Catalogs listed under a command or env the agent no longer launches with; cleared by a discovery. private readonly dueEntries = new Set() + private readonly partialAttempts = new Map() private nextListingOrder = 0 private persistence: AgentModelCatalogPersistence | null = null private readonly now: () => number @@ -96,6 +77,9 @@ export class AgentModelCatalogStore { for (const entry of await persistence.load()) { if (!this.entries.has(entry.fingerprint)) { this.entries.set(entry.fingerprint, entry) + if (independentAgentModelCatalog(entry.agent)) { + this.dueEntries.add(entry.fingerprint) + } } } this.evictOverCap() @@ -131,7 +115,9 @@ export class AgentModelCatalogStore { return ( this.dueEntries.has(entry.fingerprint) || !entry.discovered || - this.now() - entry.discovered.at >= AGENT_MODEL_CATALOG_FRESH_MS + (independentAgentModelCatalog(entry.agent) && entry.discovered.complete !== true) || + this.now() - entry.discovered.at >= + (independentAgentModelCatalog(entry.agent) ? 2 * 60_000 : AGENT_MODEL_CATALOG_FRESH_MS) ) } @@ -165,6 +151,11 @@ export class AgentModelCatalogStore { } } this.failures.expireAgent(agent) + for (const [key, attempt] of this.partialAttempts) { + if (attempt.agent === agent) { + this.partialAttempts.delete(key) + } + } } /** The one ingestion step every listing goes through, whoever listed it. */ @@ -220,6 +211,9 @@ export class AgentModelCatalogStore { ): AgentModelCatalogEntry | null { const entry = this.entryFromSuccess(fingerprint, agent, success, source) if (!entry) { + if (source === 'discovery' && independentAgentModelCatalog(agent)) { + this.failures.probeFailed(fingerprint, agent, new Error('agent listed no models')) + } return null } const previous = this.entries.get(fingerprint) @@ -229,10 +223,26 @@ export class AgentModelCatalogStore { this.latestWrittenOrder.set(fingerprint, order) } // A live listing is not the account's answer: it neither clears a failure nor a probe's verdict. - if (source === 'discovery') { + if ( + source === 'discovery' && + (!independentAgentModelCatalog(agent) || success.complete === true) + ) { this.dueEntries.delete(fingerprint) this.failures.listed(fingerprint, agent, success.origin, success.unavailable) } + if (source === 'discovery' && independentAgentModelCatalog(agent)) { + this.partialAttempts.delete(fingerprint) + if (success.complete !== true) { + this.partialAttempts.set(fingerprint, { agent, at: this.now() }) + this.failures.listed(fingerprint, agent, success.origin, success.unavailable) + } + while (this.partialAttempts.size > AGENT_MODEL_CATALOG_MAX_ENTRIES) { + const oldest = this.partialAttempts.keys().next().value + if (oldest !== undefined) { + this.partialAttempts.delete(oldest) + } + } + } this.evictOverCap() // Live sessions re-list every turn; an unchanged listing only refreshes the in-memory age. if (!previous || agentModelCatalogListingKey(previous) !== agentModelCatalogListingKey(entry)) { @@ -245,76 +255,55 @@ export class AgentModelCatalogStore { this.failures.chatFailed(fingerprint, detail, agent) } - /** A discovery listing. Joins an in-flight refresh by the same lister rather than starting a second. Never - * joins another lister's: a probe or another chat's Codex that hangs must not decide - * whether this chat starts. Resolves with the entry on success, null on failure. */ + /** Independent discovery joins by scope; other agents join only their own lister. + * Resolves with the entry on success, null on failure. */ refresh( fingerprint: string, agent: string, lister: AgentModelCatalogLister, listModels: () => Promise ): Promise { - const listers: InFlightListings = this.refreshes.get(fingerprint) ?? new Map() - const inFlight = listers.get(lister) - if (inFlight) { - return inFlight - } - const settle = (): void => { - listers.delete(lister) - if (listers.size === 0 && this.refreshes.get(fingerprint) === listers) { - this.refreshes.delete(fingerprint) - this.latestWrittenOrder.delete(fingerprint) - } - this.listingWaiters.notify(fingerprint) - } - const order = ++this.nextListingOrder - // Agent, outcome and duration only: a slow listing shows in the trace log without its content. - const span = startSpan('agentModelCatalog.discovery', { - attributes: { agent, lister: typeof lister === 'function' ? 'probe' : 'session' } + return refreshAgentModelCatalogListing({ + fingerprint, + agent, + lister, + listModels, + refreshes: this.refreshes, + entries: this.entries, + latestWrittenOrder: this.latestWrittenOrder, + failures: this.failures, + listingWaiters: this.listingWaiters, + nextListingOrder: () => ++this.nextListingOrder, + entryFromSuccess: (...args) => this.entryFromSuccess(...args), + writeSuccess: (...args) => this.writeSuccess(...args), + recordFailure: (...args) => this.recordFailure(...args) }) - const run = listModels().then( - (success) => { - span.setAttribute('models', success.models.length) - span.end() - // An older lister still receives its own result, but cannot replace a newer discovery. - const superseded = - (this.latestWrittenOrder.get(fingerprint) ?? 0) > order && this.entries.has(fingerprint) - if (superseded && success.origin === 'probe') { - this.failures.listed(fingerprint, agent, success.origin, success.unavailable) - } - const entry = superseded - ? this.entryFromSuccess(fingerprint, agent, success, 'discovery') - : this.writeSuccess(fingerprint, agent, success, 'discovery', order) - settle() - return entry - }, - (error: unknown) => { - span.fail(error instanceof Error ? error : String(error)) - settle() - // Probes are functions; a live session lists through its access object. - if (typeof lister === 'function') { - if (!(error instanceof AgentModelCatalogListingStoppedError)) { - this.failures.probeFailed(fingerprint, agent, error) - } - } else { - this.recordFailure( - fingerprint, - error instanceof Error ? error.message : String(error), - agent - ) - } - return null - } + } + + isListing(fingerprint: string): boolean { + return this.refreshes.has(fingerprint) + } + + clearPartialAttempts(): void { + this.partialAttempts.clear() + } + + verified(entry: AgentModelCatalogEntry): boolean { + return ( + !this.isStale(entry) && + !this.hasActiveFailure(entry.fingerprint) && + !this.failure(entry.fingerprint)?.unavailable ) - listers.set(lister, run) - this.refreshes.set(fingerprint, listers) - return run } /** A picker follows the current account work until a catalog lands, all work ends, * or its fixed deadline expires. */ - pendingListing(fingerprint: string): Promise | null { - return this.listingWaiters.wait(fingerprint) + pendingListing( + fingerprint: string, + waitForChange = false, + terminal = false + ): Promise | null { + return this.listingWaiters.wait(fingerprint, waitForChange, terminal) } /** True when a read should kick a background refresh: nothing known or the @@ -323,6 +312,14 @@ export class AgentModelCatalogStore { if (this.refreshes.has(fingerprint) || this.hasActiveFailure(fingerprint)) { return false } + for (const [key, attempt] of this.partialAttempts) { + if (this.now() - attempt.at >= 2 * 60_000) { + this.partialAttempts.delete(key) + } + } + if (this.partialAttempts.has(fingerprint)) { + return false + } const entry = this.entries.get(fingerprint) return !entry || this.isStale(entry) } @@ -351,6 +348,7 @@ export class AgentModelCatalogStore { } this.entries.delete(key) this.dueEntries.delete(key) + this.partialAttempts.delete(key) } } } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts index a9e85c07f64..8b77dad5b6f 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts @@ -22,10 +22,10 @@ import type { AgentSessionAccountHome } from '../../../shared/agent-session-acco // later probe answers again, and a chat's own listing never replaces it. const HOME = '/homes/selected' -const ACCOUNT_HOME = { variable: 'CODEX_HOME', path: HOME } as const +const ACCOUNT_HOME = { variable: 'GROK_HOME', path: HOME } as const const FINGERPRINT = agentModelCatalogFingerprint({ - agent: 'codex', - accountHomeVariable: 'CODEX_HOME', + agent: 'grok', + accountHomeVariable: 'GROK_HOME', accountHomePath: HOME, wslDistro: null }) @@ -63,7 +63,7 @@ function rig(initialProbe: AgentModelCatalogProbe = signedOut) { getRecord: () => undefined, drivesRecord: () => true, resolveAccountHome: async () => ACCOUNT_HOME, - probes: { codex: probe } + probes: { grok: probe } }) const chat = { store, fingerprint: FINGERPRINT, accountHomePath: HOME } return { @@ -72,7 +72,7 @@ function rig(initialProbe: AgentModelCatalogProbe = signedOut) { service, chat, /** Lets the probe's verdict land. */ - probed: () => store.refresh(FINGERPRINT, 'codex', probe, () => probe(ACCOUNT_HOME)), + probed: () => store.refresh(FINGERPRINT, 'grok', probe, () => probe(ACCOUNT_HOME)), answerWith: (next: AgentModelCatalogProbe) => (answer = next), pastTtl: () => (now += AGENT_MODEL_CATALOG_FAILURE_TTL_MS) } @@ -86,18 +86,18 @@ describe('a reason the probe found', () => { it("survives the chat's own listing, success or failure; only a probe success clears it", async () => { const { store, chat, probed, answerWith, service } = rig() await probed() - // A signed-out Codex still answers model/list for a live chat. - await store.refresh(FINGERPRINT, 'codex', chat, async () => listing('gpt-live', 'live-session')) - expect(unavailableOf(await service.read({ agent: 'codex' }))).toEqual(SIGNED_OUT) - await store.refresh(FINGERPRINT, 'codex', { ...chat }, () => + // A signed-out Grok still answers model/list for a live chat. + await store.refresh(FINGERPRINT, 'grok', chat, async () => listing('gpt-live', 'live-session')) + expect(unavailableOf(await service.read({ agent: 'grok' }))).toEqual(SIGNED_OUT) + await store.refresh(FINGERPRINT, 'grok', { ...chat }, () => Promise.reject(new Error('401 Unauthorized')) ) - store.recordFailure(FINGERPRINT, 'codex model listing failed') - expect(unavailableOf(await service.read({ agent: 'codex' }))).toEqual(SIGNED_OUT) + store.recordFailure(FINGERPRINT, 'grok model listing failed') + expect(unavailableOf(await service.read({ agent: 'grok' }))).toEqual(SIGNED_OUT) answerWith(async () => listing('gpt-probe', 'probe')) await probed() - const result = await service.read({ agent: 'codex' }) + const result = await service.read({ agent: 'grok' }) expect(result).not.toHaveProperty('unavailable') expect(result.origin).toBe('probe') }) @@ -105,7 +105,7 @@ describe('a reason the probe found', () => { it('is replaced by a probe failure that names no reason', async () => { const { store, probed, answerWith } = rig() await probed() - answerWith(() => Promise.reject(new Error('codex probe timed out'))) + answerWith(() => Promise.reject(new Error('grok probe timed out'))) await probed() expect(store.failure(FINGERPRINT)).not.toHaveProperty('unavailable') }) @@ -120,8 +120,8 @@ describe('a reason the probe found', () => { }) it('is expired, not dropped, by a change to that agent’s account settings', async () => { - const { store, probed } = rig() - await probed() + const store = new AgentModelCatalogStore() + await store.refresh(FINGERPRINT, 'codex', signedOut, signedOut) expireAgentModelCatalogFailuresForSettings(store, { activeClaudeManagedAccountId: 'other' }) expect(store.hasActiveFailure(FINGERPRINT)).toBe(true) expireAgentModelCatalogFailuresForSettings(store, { activeCodexManagedAccountId: 'other' }) @@ -138,7 +138,7 @@ describe('a probe that lists models but finds the account signed out', () => { })) await probed() expect(store.get(FINGERPRINT)?.models.map((model) => model.id)).toEqual(['gpt-gateway']) - expect(await service.read({ agent: 'codex' })).toMatchObject({ + expect(await service.read({ agent: 'grok' })).toMatchObject({ origin: 'probe', models: [{ id: 'gpt-gateway' }], unavailable: SIGNED_OUT @@ -152,8 +152,8 @@ describe('a chat that starts under the account', () => { await probed() service.providerStarted({ ...agentSessionRecordFixture(), - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: HOME }, + provider: 'grok', + accountHome: { variable: 'GROK_HOME', path: HOME }, location: { executionHostId: LOCAL_EXECUTION_HOST_ID, wslDistro: null, @@ -163,7 +163,7 @@ describe('a chat that starts under the account', () => { }) expect(store.hasActiveFailure(FINGERPRINT)).toBe(false) answerWith(async () => listing('gpt-live', 'probe')) - expect(await service.read({ agent: 'codex', waitForListing: true })).not.toHaveProperty( + expect(await service.read({ agent: 'grok', waitForListing: true })).not.toHaveProperty( 'unavailable' ) expect(probe).toHaveBeenCalledTimes(2) @@ -173,7 +173,7 @@ describe('a chat that starts under the account', () => { describe('an expired failure that carries no reason', () => { it('is dropped, so failures cannot pile up', () => { const { store, pastTtl } = rig() - store.recordFailure(FINGERPRINT, 'listing timed out', 'codex') + store.recordFailure(FINGERPRINT, 'listing timed out', 'grok') pastTtl() expect(store.hasActiveFailure(FINGERPRINT)).toBe(false) expect(store.failure(FINGERPRINT)).toBeNull() @@ -187,7 +187,7 @@ describe('a catalog read that finds a held reason', () => { it('serves it from memory inside the TTL, with no new probe', async () => { const { probe, probed, service } = rig() await probed() - expect(await service.read({ agent: 'codex', waitForListing: true })).toEqual({ + expect(await service.read({ agent: 'grok', waitForListing: true })).toEqual({ origin: 'unknown', unavailable: SIGNED_OUT }) @@ -197,15 +197,15 @@ describe('a catalog read that finds a held reason', () => { it('past the TTL, serves it at once and starts one probe; a waiting read gets its answer', async () => { const { store, probe, probed, pastTtl, answerWith, service } = rig() await probed() - store.recordSuccess(FINGERPRINT, 'codex', listing('gpt-live', 'live-session'), 'live') + store.recordSuccess(FINGERPRINT, 'grok', listing('gpt-live', 'live-session'), 'live') pastTtl() const signIn = deferred() answerWith(() => signIn.promise) - const plain = await service.read({ agent: 'codex' }) + const plain = await service.read({ agent: 'grok' }) expect(plain).toMatchObject({ origin: 'live-session', listingInProgress: true }) expect(plain.unavailable).toEqual(SIGNED_OUT) - const waited = service.read({ agent: 'codex', waitForListing: true }) + const waited = service.read({ agent: 'grok', waitForListing: true }) expect(probe).toHaveBeenCalledTimes(2) signIn.resolve(listing('gpt-probe', 'probe')) @@ -223,17 +223,20 @@ describe('a catalog read that finds a held reason', () => { await probed() pastTtl() answerWith(next) - expect(unavailableOf(await service.read({ agent: 'codex', waitForListing: true }))).toEqual( + expect(unavailableOf(await service.read({ agent: 'grok', waitForListing: true }))).toEqual( expected ) }) - it('re-probes on the next read after an account change, inside the old TTL', async () => { + it('re-probes on the next read after a command change, inside the old TTL', async () => { const { store, probe, probed, answerWith, service } = rig() await probed() answerWith(async () => listing('gpt-new-account', 'probe')) - expireAgentModelCatalogFailuresForSettings(store, { codexManagedAccounts: [] }) - const answer = await service.read({ agent: 'codex', waitForListing: true }) + createAgentModelCatalogSettingsExpiry(store, {})( + { agentCmdOverrides: { grok: '/new/grok' } }, + { agentCmdOverrides: { grok: '/new/grok' } } + ) + const answer = await service.read({ agent: 'grok', waitForListing: true }) expect(probe).toHaveBeenCalledTimes(2) expect(answer).not.toHaveProperty('unavailable') }) @@ -243,7 +246,7 @@ describe('a catalog read that finds a held reason', () => { await probed() pastTtl() answerWith(() => new Promise(() => {})) - expect(await service.read({ agent: 'codex' })).toEqual({ + expect(await service.read({ agent: 'grok' })).toEqual({ origin: 'unknown', listingInProgress: true, unavailable: SIGNED_OUT @@ -262,7 +265,7 @@ describe('a probe the host stops', () => { options?.signal?.addEventListener('abort', () => reject(new Error('session stopped'))) ) ) - await service.read({ agent: 'codex' }) + await service.read({ agent: 'grok' }) service.stop() await new Promise((resolve) => setTimeout(resolve, 0)) expect(store.failure(FINGERPRINT)?.unavailable).toEqual(SIGNED_OUT) @@ -277,7 +280,7 @@ describe('a prewarm probe', () => { unavailable: SIGNED_OUT })) // A saved catalog marks the agent as used here. - store.recordSuccess(FINGERPRINT, 'codex', listing('gpt-live', 'live-session'), 'live') + store.recordSuccess(FINGERPRINT, 'grok', listing('gpt-live', 'live-session'), 'live') await service.prewarm() expect(store.failure(FINGERPRINT)?.unavailable).toEqual(SIGNED_OUT) answerWith(async () => listing('gpt-probe', 'probe')) @@ -296,13 +299,13 @@ describe("an agent's command or environment change", () => { const { store, probe, service, probed, answerWith } = rig(() => Promise.reject(new AgentModelCatalogUnavailableError({ reason: 'cliMissing' })) ) - store.recordSuccess(FINGERPRINT, 'codex', listing('gpt-saved', 'probe'), 'discovery') + store.recordSuccess(FINGERPRINT, 'grok', listing('gpt-saved', 'probe'), 'discovery') await probed() expect(store.failure(FINGERPRINT)?.unavailable).toEqual({ reason: 'cliMissing' }) await service.prewarm() expect(probe).toHaveBeenCalledTimes(1) const expire = createAgentModelCatalogSettingsExpiry(store, { agentCmdOverrides: {} }) - const fixed = { agentCmdOverrides: { codex: '/opt/codex/bin/codex' } } + const fixed = { agentCmdOverrides: { grok: '/opt/grok/bin/grok' } } expire(fixed, fixed) answerWith(async () => listing('gpt-new', 'probe')) await service.prewarm() @@ -318,17 +321,17 @@ describe("an agent's command or environment change", () => { expect( agentsWithChangedLaunchSettings( { - agentCmdOverrides: { codex: 'codex', claude: 'claude' }, + agentCmdOverrides: { grok: 'grok', claude: 'claude' }, agentDefaultEnv: { claude: { A: '1', B: '2' } } }, { - agentCmdOverrides: { codex: 'codex', claude: 'claude' }, + agentCmdOverrides: { grok: 'grok', claude: 'claude' }, agentDefaultEnv: { claude: { B: '2', A: '1' }, pi: { KEY: 'x' } } } ) ).toEqual(['pi']) - expect(agentsWithChangedLaunchSettings({ agentCmdOverrides: { codex: 'codex' } }, {})).toEqual([ - 'codex' + expect(agentsWithChangedLaunchSettings({ agentCmdOverrides: { grok: 'grok' } }, {})).toEqual([ + 'grok' ]) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 30cc9d4ee47..d4d50c052cd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -158,6 +158,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi setOption: StructuredAgentSessionAdapter['setOption'] = (input) => this.owner(input.sessionId).setOption(input) + projectOptions: StructuredAgentSessionAdapter['projectOptions'] = (sessionId, options) => + this.liveOwnerOrNull(sessionId)?.projectOptions?.(sessionId, options) + prepareReadOptions = (input: { sessionId: string; fence: number }) => this.liveOwnerOrNull(input.sessionId)?.prepareReadOptions?.(input) 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 85967eef660..5f531e831f4 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 @@ -33,10 +33,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAtRestCommands } from './structured-agent-session-at-rest-commands' -import type { - AgentSessionOptionsResult, - AgentSessionSlashCommand -} from '../../../shared/agent-session-wire' +import type { AgentSessionSlashCommand } from '../../../shared/agent-session-wire' import type { AgentSessionThreadGoalChange } from '../../../shared/agent-session-thread-goal-wire' import type { AgentSessionRefusalReason } from '../../../shared/agent-session-wire-refusals' import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' @@ -58,18 +55,16 @@ import type { StructuredAgentSessionAcquisitionReceipt } from './structured-agent-session-startup-attempt-contract' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' -import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' import type { StructuredAgentSessionProviderOpen, StructuredAgentSessionProviderOpenResult } from './structured-agent-session-provider-open-contract' -/** A live options read, plus what the child listed for its account's saved catalog. The host - * saves that listing and strips it before answering; an adapter whose listing reaches the - * catalog another way omits it. */ -export type StructuredAgentSessionLiveOptions = AgentSessionOptionsResult & { - catalogListing?: AgentModelCatalogLiveListing -} +import type { + StructuredAgentSessionLiveOptions, + StructuredAgentSessionOptionProjection +} from './structured-agent-session-option-models' +export type { StructuredAgentSessionLiveOptions } from './structured-agent-session-option-models' export class AgentSessionAcquisitionRefusal extends Error { readonly code = 'agent_session_operation_invalid' @@ -394,11 +389,16 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & setOption( input: StructuredAgentSessionSetOptionInput ): Promise>> + /** Synchronous capability projection; never acquires inventory or changes saved intent. */ + projectOptions?( + sessionId: string, + options: Readonly> + ): StructuredAgentSessionOptionProjection | undefined /** Fetch off the lane, then apply the result under the same child's fence. */ prepareReadOptions?(input: { sessionId: string fence: number - }): Promise<() => AgentSessionOptionsResult> | undefined + }): Promise<() => StructuredAgentSessionLiveOptions> | undefined readOptions?(input: { sessionId: string fence: number diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index 60968511fc6..eb7d743e34f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -197,13 +197,14 @@ async function statusRows(): Promise { } describe('a Stop that names no turn', () => { - it('does not let a stalled catalog hold an option pick, steer, Stop, or replacement send', async () => { + it('does not let independent discovery hold an option pick, Stop, or replacement send', async () => { const pending = Promise.withResolvers() const codex = fakeCodex({ 'model/list': () => pending.promise }) codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-1') codex.routes['turn/steer'] = () => ({ turn: { id: 'turn-1' } }) const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { - modelCatalog: new AgentModelCatalogStore() + modelCatalog: new AgentModelCatalogStore(), + demandModelCatalog: () => pending.promise }) await acquireReadyCodexForTest(adapter, { identity: identityFor(SESSION), @@ -255,7 +256,9 @@ describe('a Stop that names no turn', () => { codex.connections[0].calls.filter((call) => call.method === 'turn/start')[1]?.params ).toMatchObject({ serviceTier: 'priority' }) pending.resolve({ data: [], nextCursor: null }) - await expect(listing).rejects.toThrow('no available models') + await expect(listing).resolves.toMatchObject({ + current: { model: 'gpt-next', serviceTier: 'priority' } + }) }) // Codex can abort a turn before it starts: it answers the interrupt, then ends the turn it never @@ -390,7 +393,8 @@ describe('a Stop that names no turn', () => { return answerWithOpenedTurn(codex, 'turn-ok')(params) } const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { - modelCatalog: new AgentModelCatalogStore() + modelCatalog: new AgentModelCatalogStore(), + demandModelCatalog: () => pending.promise }) await acquireReadyCodexForTest(adapter, { identity: identityFor(SESSION), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts index 415cedef7a9..c2312ff8cba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts @@ -38,6 +38,7 @@ function launchPermission( return answer }) const modelCatalog = { + peek: () => null, read, retainedLiveModelIds: vi.fn(async () => []), recordLiveListing: vi.fn(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts index 2d48f055f29..cd3b8604066 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts @@ -12,6 +12,7 @@ import { savedAgentSessionCatalogModels, structuredAgentSessionPickReply } from './structured-agent-session-pick-reply' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { pickAgentSessionOption } from '../../runtime/agent-session-record-options' import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' @@ -56,6 +57,7 @@ export async function recordStructuredAgentSessionOptionIntent( 'getRecord' | 'replaceSessionOptions' | 'reviseSessionOptions' > agents: Pick + adapter?: Pick } & Parameters[0], ctx: Pick, input: { key: string; value: string } @@ -92,9 +94,17 @@ export async function recordStructuredAgentSessionOptionIntent( throw error } } + const displayModels = + models ?? + (await savedAgentSessionCatalogModels(deps, record.provider, sessionId, undefined, false)) ctx.publish() return { ok: true, - value: { ...input, options: structuredAgentSessionPickReply(options, models) } + value: { + ...input, + options: + deps.adapter?.projectOptions?.(sessionId, options)?.options ?? + structuredAgentSessionPickReply(options, displayModels) + } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-models.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-models.ts index 6cfd0866b41..e68c7a40944 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-models.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-models.ts @@ -2,9 +2,31 @@ // answers for a chat at rest: the catalog's rows, plus the current model when the catalog does not // list it. Nothing says which efforts an unlisted model takes, so its row offers none. -import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' +import type { + AgentSessionModelCatalogResult, + AgentSessionModelOption, + AgentSessionOptionsResult +} from '../../../shared/agent-session-wire' +import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' -/** `row` builds a provider's own row type from the shared one. */ +export type StructuredAgentSessionOptionProjection = { + models: readonly AgentSessionModelOption[] + options: Readonly> +} + +export type StructuredAgentSessionLiveOptions = AgentSessionOptionsResult & { + catalogListing?: AgentModelCatalogLiveListing +} + +export type StructuredAgentSessionRestingOptions = Pick< + AgentSessionOptionsResult, + 'models' | 'listedModelIds' | 'fastModeSupport' | 'current' +> & { + inventory: AgentSessionModelCatalogResult + fallback: AgentSessionOptionsResult['models'] +} + +/** A list plus the literal selection; an unlisted model proves no capability. */ export function structuredAgentSessionOptionModels( catalog: readonly TModel[], current: string | undefined, @@ -12,7 +34,37 @@ export function structuredAgentSessionOptionModels entry.id === current)) { - models.push(row({ id: current, label: current, isDefault: false, efforts: [] })) + models.push( + row({ id: current, label: current, isDefault: false, efforts: [], effortsKnown: false }) + ) } return models } + +/** Installed rows are the only choices; seeds stand in only when that list is unknown. */ +export function structuredAgentSessionCapabilityView( + installed: + | { + models: readonly AgentSessionModelOption[] + fastModeSupport?: AgentSessionOptionsResult['fastModeSupport'] + } + | { origin: 'unknown' }, + current: string | undefined, + fallback: readonly AgentSessionModelOption[] = [], + restriction?: AgentSessionOptionsResult['fastModeSupport'] +) { + const inventory = 'models' in installed ? installed : { models: fallback } + const models = structuredAgentSessionOptionModels(inventory.models, current, (row) => row) + const support = models.find((row) => row.id === current)?.supportsFastMode + return { + models, + fastModeSupport: + restriction ?? + (support !== undefined + ? { supported: support, ...(support ? {} : { reason: 'model-not-supported' as const }) } + : inventory.fastModeSupport), + listedServiceTiers: inventory.models.flatMap( + (row) => row.serviceTiers?.map((tier) => tier.value) ?? [] + ) + } +} 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 44abff43adc..79c785824fa 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 @@ -1,3 +1,14 @@ +import { structuredAgentSessionCapabilityView } from './structured-agent-session-option-models' +import { claudeFallbackModelOptions } from '../../claude/claude-structured-session-options' +import { acquired, fakeClaude } from '../../claude/claude-structured-session-test-support' +import { getAgentSessionOptionCatalog } from '../../../shared/agent-session-option-catalog' +import { + applyStructuredAgentSessionModelCatalog, + applyStructuredAgentSessionOptions, + createStructuredAgentSessionOptionState +} from '../../../shared/structured-agent-session-options' +import { structuredAgentSessionSendSelection } from '../../../shared/structured-agent-session-option-view' +import type { StructuredAgentSessionLiveOptions } from './structured-agent-session-adapter' // A chat's options at rest come from the host catalog without waiting on a listing. import { describe, expect, it, vi } from 'vitest' @@ -29,7 +40,7 @@ import { const SESSION = 'session-1' -type RestingFacts = Pick, 'options'> & { +type RestingFacts = Pick, 'options' | 'provider'> & { providerHandleChain?: Pick[] rewind?: Pick } @@ -95,7 +106,7 @@ describe('options at rest', () => { readsWithoutModel: true }) expect(current.current).toEqual({ defaulted: [], permissionMode: 'ask' }) - expect(read).toHaveBeenCalledTimes(1) + expect(read).toHaveBeenCalledTimes(2) const older = await readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: false }) @@ -105,7 +116,13 @@ describe('options at rest', () => { defaulted: [], permissionMode: 'ask' }) - expect(read).toHaveBeenLastCalledWith({ agent: 'codex', sessionId: SESSION, savedOnly: true }) + expect(read).toHaveBeenCalledWith({ + agent: 'codex', + sessionId: SESSION, + savedOnly: true, + legacyOptionsDisplay: true, + workspacePath: null + }) }) it("names the saved listing's ids and model for a live child that fell back to its default", async () => { @@ -145,7 +162,7 @@ describe('options at rest', () => { expect(running.current).toMatchObject({ model: 'gpt-unlisted', defaulted: [] }) }) - it.each(['codex', 'omp', 'grok', 'opencode'] as const)( + it.each(['omp', 'grok', 'opencode'] as const)( '%s keeps an unknown model and learns menus from later options', async (provider) => { const record = { ...restingRecord(), provider } @@ -189,8 +206,7 @@ describe('options at rest', () => { expect((await read()).current).toEqual({ model: 'later-model', effort: 'off', - defaulted: [], - ...(provider === 'codex' ? { permissionMode: 'ask' } : {}) + defaulted: [] }) for (const emptyStart of [false, true]) { modelCatalog.recordLiveListing(SESSION, { @@ -225,28 +241,33 @@ describe('a chat at rest, read by the host', () => { { 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 }) + const catalog = (models: AgentSessionModelOption[] | null = listed, verified = true) => ({ + read: async (params?: { verifiedOnly?: boolean }) => + models === null || (!verified && params?.verifiedOnly) + ? { origin: 'unknown' as const } + : { origin: 'probe' as const, models, fetchedAt: 1 } }) function readAtRest( facts: RestingFacts, items: AgentJournalItemBody[] = [], wroteBeforeOpen = false, - models = listed + models: AgentSessionModelOption[] | null = listed, + verified = true, + liveOptions?: StructuredAgentSessionLiveOptions ) { const record = { ...restingRecord(), ...facts } const resting = { - child: null, - params: { provider: 'codex' }, + child: liveOptions ? { fence: 1 } : null, + params: { provider: record.provider }, 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, + adapter: liveOptions ? { readOptions: async () => liveOptions } : {}, + agents: claudeAndCodexAgents(), store: { getRecord: () => record }, - modelCatalog: catalog(models) + modelCatalog: catalog(models, verified) }, serialize: (_sessionId: string, task: () => Promise) => task(), openConversation: async () => resting, @@ -268,6 +289,161 @@ describe('a chat at rest, read by the host', () => { expect(read.current).toMatchObject({ model: 'gpt-account', defaulted }) }) + it('uses seeds only for an unknown inventory, never beside known rows or a known empty list', () => { + const seed = claudeFallbackModelOptions() + expect( + structuredAgentSessionCapabilityView({ origin: 'unknown' }, undefined, seed).models + ).toEqual(seed) + expect(structuredAgentSessionCapabilityView({ models: [] }, undefined, seed).models).toEqual([]) + expect( + structuredAgentSessionCapabilityView({ models: listed }, undefined, seed).models + ).toEqual(listed) + }) + + it('uses installed choices for a live picker, admission and genuine first-run values', async () => { + const installed = [ + { + id: 'opus', + label: 'Opus', + isDefault: false, + efforts: [{ value: 'high', label: 'High' }], + supportsFastMode: true + } + ] + const answer: StructuredAgentSessionLiveOptions = { + models: claudeFallbackModelOptions(), + current: { model: 'opus', effort: 'low' } + } + const result = await readAtRest( + { provider: 'claude', options: { model: 'opus', effort: 'low' } }, + [], + false, + installed, + true, + answer + ) + expect(result.models).toEqual(installed) + expect(result.current).toMatchObject({ model: 'opus', effort: 'low' }) + const chat = picking({ model: 'opus' }, installed, undefined, 'claude') + expect(await chat.pick('effort', 'low')).toMatchObject({ ok: false }) + expect(await chat.pick('effort', 'high')).toMatchObject({ ok: true }) + const firstRun = picking({ model: 'opus' }, installed, undefined, 'claude') + firstRun.ran('opus', { effort: 'low' }) + expect(firstRun.record().options).toEqual({ model: 'opus', effort: 'low' }) + }) + + it.each<[string, AgentSessionModelOption[] | null, string | undefined]>([ + [ + 'known default', + [{ id: 'opus[1m]', label: 'Opus', isDefault: true, efforts: [] }], + 'opus[1m]' + ], + ['unknown list', null, 'sonnet'], + ['known empty', [], undefined], + [ + 'known without default', + [{ id: 'opus', label: 'Opus', isDefault: false, efforts: [] }], + undefined + ] + ])('composes %s for a live Claude child that names no model', async (_name, installed, model) => { + const adapter = await acquired(fakeClaude({ settings: { effective: {}, sources: [] } })) + const live = await adapter.readOptions({ sessionId: SESSION, fence: 7 }) + expect(live.current.model).toBeUndefined() + const answer = await readAtRest({ provider: 'claude' }, [], false, installed, true, live) + expect(answer.current.model).toBe(model) + expect(answer.current.defaulted).toEqual([]) + if (installed === null) { + const atRest = await readAtRest({ provider: 'claude' }, [], false, installed) + expect(atRest.current.model).toBeUndefined() + } + const seed = getAgentSessionOptionCatalog('claude')! + const launch = { newLaunch: true, coherentCatalog: true } + const state = applyStructuredAgentSessionOptions( + applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState('claude', seed), + seed, + installed === null + ? { origin: 'unknown' } + : { origin: 'probe', models: installed, fetchedAt: 1 }, + launch + ), + seed, + answer, + launch + ) + expect(structuredAgentSessionSendSelection(state)).toEqual(model ? { model } : undefined) + const resumed = await readAtRest( + { provider: 'claude', providerHandleChain: [{ origin: 'adopted' }] }, + [], + true, + installed, + true, + live + ) + expect(resumed.current.defaulted).toEqual(model ? ['model'] : []) + expect( + structuredAgentSessionSendSelection( + applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('claude', seed), + seed, + resumed + ) + ) + ).toBeUndefined() + }) + + it('resolves only an unsaved reported identity against installed rows', async () => { + const installed = [ + { + id: 'opus[1m]', + resolvedModel: 'claude-opus-current', + label: 'Installed Opus', + isDefault: false, + efforts: [{ value: 'high', label: 'High' }] + } + ] + for (const [reported, saved, shown] of [ + ['claude-opus-current', {}, 'opus[1m]'], + ['unknown-model', {}, 'unknown-model'], + ['claude-opus-current', { model: 'claude-opus-current' }, 'claude-opus-current'] + ] as const) { + const result = await readAtRest( + { provider: 'claude', options: saved }, + [], + false, + installed, + true, + { models: [], current: { model: reported } } + ) + expect(result.current.model).toBe(shown) + expect(result.models.find((row) => row.id === shown)).toMatchObject( + shown === 'opus[1m]' + ? { label: 'Installed Opus', efforts: installed[0]!.efforts } + : { label: shown, efforts: [] } + ) + } + }) + + it('uses held rows as menus without projecting an unverified default effort', async () => { + const answer = await readAtRest( + { provider: 'claude', options: { model: 'gpt-account' } }, + [], + false, + [{ ...listed[0]!, defaultEffort: 'high', efforts: [{ value: 'high', label: 'High' }] }], + false + ) + expect(answer.current.model).toBe('gpt-account') + expect(answer.current.effort).toBeUndefined() + expect(answer.models[0]?.defaultEffort).toBe('high') + const complete = await readAtRest( + { provider: 'claude', options: { model: 'gpt-account' } }, + [], + false, + answer.models + ) + expect(complete.current.effort).toBe('high') + }) + it.each([ ['another model offers', listed, 'default'], // Codex lists no Flex; a Flex from the user's config runs as configured. @@ -292,8 +468,19 @@ describe('a chat at rest, read by the host', () => { { 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 } + function picking( + options: Record, + models = efforts, + projectOptions?: ( + sessionId: string, + options: Readonly> + ) => { + models: AgentSessionModelOption[] + options: Record + }, + provider = 'codex' + ) { + let record: AgentSessionRecord = { ...restingRecord(), options, provider } const store = { getRecord: () => record, replaceSessionOptions: vi.fn(), @@ -302,7 +489,12 @@ describe('a chat at rest, read by the host', () => { args: A ) => (record = revise(record, args)) } - const deps = { store, agents: claudeAndCodexAgents(), modelCatalog: catalog(models) } + const deps = { + store, + agents: claudeAndCodexAgents(), + modelCatalog: catalog(models), + adapter: { projectOptions } + } const ctx = { sessionId: SESSION, fence: 1, publish: vi.fn(), now: () => 0 } return { pick: (key: string, value: string) => @@ -335,9 +527,25 @@ describe('a chat at rest, read by the host', () => { await chat.pick('serviceTier', 'priority') expect(chat.record().options).toEqual({ model: 'b', effort: 'high', serviceTier: 'priority' }) }) -}) -describe('live Codex option reads', () => { + it('keeps saved literal values after the installed list omits their model', async () => { + const inventory = [{ id: 'B', label: 'B', isDefault: false, efforts: [] }] + const chat = picking({ model: 'opus[1m]', effort: 'low' }, inventory, undefined, 'claude') + const answer = await readAtRest( + { provider: 'claude', options: { model: 'opus[1m]', effort: 'low' } }, + [], + false, + inventory, + true, + { models: [], current: { model: 'claude-opus-5-5[1m]' } } + ) + expect(answer.models.map((row) => row.id)).toEqual(['B', 'opus[1m]']) + expect(answer.models[1]).toMatchObject({ efforts: [], effortsKnown: false }) + expect(answer.current).toMatchObject({ model: 'opus[1m]', effort: 'low' }) + chat.ran('opus[1m]', { effort: 'high' }) + expect(chat.record().options).toEqual({ model: 'opus[1m]', effort: 'low' }) + }) + it('keeps newer saved picks after a cold live listing waits without holding the session lane', async () => { const listing = Promise.withResolvers() const queue = new StructuredAgentSessionTaskQueue() 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 6e2ce778ee6..792d57f114e 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 @@ -1,28 +1,24 @@ -// A chat's options, live or at rest. -// -// 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 { structuredAgentSessionThreadGoalReport } from './structured-agent-session-thread-goal' +// Saved choices, catalog defaults and actual execution reports remain separate facts. import type { AgentSessionModelCatalogResult, AgentSessionOptionsResult } from '../../../shared/agent-session-wire' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { - AgentJournalCursor, - AgentJournalThreadGoal -} from '../../../shared/agent-session-journal-types' import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' import { finishedListingIds } from '../../../shared/structured-agent-session-options' import { savedEffectiveServiceTier } from '../../../shared/structured-agent-session-effective-tier' import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' -import type { StructuredAgentDefinition } from './structured-agent-definition' -import type { StructuredAgentRegistry } from './structured-agent-registry' import type { StructuredAgentSessionLiveOptions } from './structured-agent-session-adapter' import { retainedAgentModelCatalogModels } from '../agent-model-catalog/agent-model-catalog-entry' import { saveStructuredAgentSessionListing } from './structured-agent-session-start-listing' +import { independentAgentModelCatalog } from '../agent-model-catalog/agent-model-catalog-discovery' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' -import { structuredAgentSessionOptionModels } from './structured-agent-session-option-models' +import { + structuredAgentSessionCapabilityView, + structuredAgentSessionOptionModels, + type StructuredAgentSessionRestingOptions as RestingOptions +} from './structured-agent-session-option-models' import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { structuredAgentSessionNeverSent } from './structured-agent-session-never-sent' @@ -32,11 +28,6 @@ export { structuredAgentSessionOptionOverridesRefusal } from './structured-agent-session-option-intent' -type RestingOptions = Pick< - AgentSessionOptionsResult, - 'models' | 'listedModelIds' | 'fastModeSupport' | 'current' -> - /** What the asking client takes: whether it reads an answer that names no model. */ export type StructuredAgentSessionOptionsReader = { readsWithoutModel: boolean } @@ -45,14 +36,6 @@ export const IN_PROCESS_OPTIONS_READER: StructuredAgentSessionOptionsReader = { readsWithoutModel: true } -/** The at-rest rules of the record's agent, as this runtime registered it; null for any other. */ -function restingOptionRules( - agents: Pick, - record: AgentSessionRecord -): StructuredAgentDefinition['restingOptions'] | null { - return agents.definition(record.provider)?.restingOptions ?? null -} - async function readStructuredAgentSessionOptionsAtRest( deps: Pick, sessionId: string, @@ -62,7 +45,7 @@ async function readStructuredAgentSessionOptionsAtRest( if (!record) { throw new Error('agent_session_identity_required') } - const rules = restingOptionRules(deps.agents, record) + const rules = deps.agents.definition(record.provider)?.restingOptions ?? null const readCatalog = async ( inSessionWorkspace: boolean ): Promise => @@ -71,7 +54,12 @@ async function readStructuredAgentSessionOptionsAtRest( agent: record.provider, sessionId, savedOnly: true, - ...(inSessionWorkspace ? { inSessionWorkspace: true as const } : {}) + ...(!reader.readsWithoutModel ? { legacyOptionsDisplay: true as const } : {}), + ...(inSessionWorkspace + ? { inSessionWorkspace: true as const } + : independentAgentModelCatalog(record.provider) + ? { workspacePath: null } + : {}) }) .catch(() => null)) ?? { origin: 'unknown' } const saved = record.options ?? {} @@ -90,7 +78,24 @@ async function readStructuredAgentSessionOptionsAtRest( // With no catalog for the account, the list a running child of this agent falls back to. const listed = catalog.origin === 'unknown' ? (rules?.fallbackModels() ?? null) : catalog.models const models = listed ?? [] - const listedModelIds = finishedListingIds(catalog) + const proof = independentAgentModelCatalog(record.provider) + ? await deps.modelCatalog + ?.read({ + agent: record.provider, + sessionId, + savedOnly: true, + verifiedOnly: true, + inSessionWorkspace: true + }) + .catch(() => null) + : catalog + const listedModelIds = proof ? finishedListingIds(proof) : undefined + const defaultModels = + independentAgentModelCatalog(record.provider) && reader.readsWithoutModel + ? proof && proof.origin !== 'unknown' + ? proof.models + : [] + : models const fastMode = saved.fastMode === undefined ? null @@ -105,7 +110,7 @@ async function readStructuredAgentSessionOptionsAtRest( const effort = saved.effort ?? (rules?.effortDefaultsToModel || (saved.model === undefined && folderDefault) - ? models.find((entry) => entry.id === model)?.defaultEffort + ? defaultModels.find((entry) => entry.id === model)?.defaultEffort : undefined) const defaulted = (['model', 'effort'] as const).filter( (key) => saved[key] === undefined && Boolean(key === 'model' ? model : effort) @@ -113,11 +118,13 @@ async function readStructuredAgentSessionOptionsAtRest( // 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 { + inventory: catalog, + fallback: catalog.origin === 'unknown' ? (listed ?? []) : [], models: listed ? structuredAgentSessionOptionModels(listed, model, (row) => row) : [], - ...(listedModelIds ? { listedModelIds } : {}), ...(catalog.origin !== 'unknown' && catalog.fastModeSupport ? { fastModeSupport: catalog.fastModeSupport } : {}), + ...(listedModelIds ? { listedModelIds } : {}), current: { ...(model ? { model } : {}), ...(effort ? { effort } : {}), @@ -156,6 +163,7 @@ export async function readStructuredAgentSessionOptions( } return { kind: 'live' as const, + child, options: await adapter.readOptions({ sessionId, fence: child.fence }) } }) @@ -183,7 +191,10 @@ export async function readStructuredAgentSessionOptions( const { listedModelIds } = await atRest() options = listedModelIds ? { ...answer, listedModelIds } : answer } - if (catalogListing) { + if ( + catalogListing && + !independentAgentModelCatalog(store.getRecord(sessionId)?.provider ?? '') + ) { // Check prior membership before saving: a launch-added row cannot verify itself. const listedModelIds = context.deps.modelCatalog ? await context.deps.modelCatalog.retainedLiveModelIds(sessionId, catalogListing) @@ -192,7 +203,8 @@ export async function readStructuredAgentSessionOptions( options = { ...answer, listedModelIds } } } else { - options = await atRest() + const { inventory: _inventory, fallback: _fallback, ...answer } = await atRest() + options = answer } // Re-acquired after the reads above: the handle they saw may have closed and reopened since. const session = await context.conversation(sessionId) @@ -206,15 +218,23 @@ export async function readStructuredAgentSessionOptions( const permissionMode = record ? await structuredAgentSessionLaunchPermission(context.deps, record) : null - const selected = live ? await atRest() : options + const resting = live ? await atRest() : null + const selected = resting ?? options + const independent = independentAgentModelCatalog(agent) + const displayDefault = + resting && independent + ? (resting.inventory.origin === 'unknown' ? resting.fallback : resting.inventory.models).find( + (row) => row.isDefault + )?.id + : undefined // The saved pick shows, except a model the running child could not take (its provider says it // does not exist): that shows what the child runs instead. const refusedModel = live && !!adapter.readOptionRestoreFailures?.(sessionId).includes('model') const saved = record?.options ?? {} const { model: _refused, ...selectedWithoutModel } = selected.current const model = refusedModel - ? options.current.model - : (saved.model ?? (options.current.model || selected.current.model)) + ? (options.current.model ?? displayDefault) + : (saved.model ?? (options.current.model || selected.current.model || displayDefault)) const current = { ...(refusedModel ? selectedWithoutModel : selected.current), ...options.current, @@ -230,21 +250,47 @@ export async function readStructuredAgentSessionOptions( ? 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. + // Defaults describe only values the chat has not saved; the first send pins them. const defaultedBy = (key: 'model' | 'effort') => - (key === 'model' && !options.current.model ? selected : options).current.defaulted + key === 'model' && !options.current.model + ? displayDefault !== undefined || selected.current.defaulted?.includes(key) === true + : options.current.defaulted?.includes(key) === true const defaulted = structuredAgentSessionNeverSent(record, session.journal) ? [] - : (['model', 'effort'] as const).filter( - (key) => saved[key] === undefined && defaultedBy(key)?.includes(key) === true - ) + : (['model', 'effort'] as const).filter((key) => saved[key] === undefined && defaultedBy(key)) const confirmed = options.current.confirmed?.filter((key) => key === 'model' || key === 'effort' || key === 'fastMode' || key === 'serviceTier' ? current[key] === options.current[key] : false ) + if (independent) { + const { inventory, fallback, listedModelIds } = await atRest() + if (saved.model === undefined && current.model && inventory.origin !== 'unknown') { + current.model = + inventory.models.find( + (row) => row.id === current.model || row.resolvedModel === current.model + )?.id ?? current.model + } + const { listedServiceTiers: _tiers, ...view } = structuredAgentSessionCapabilityView( + inventory, + current.model, + fallback, + live?.fastModeSupport?.supported === false && + live.fastModeSupport.reason !== 'model-not-supported' + ? live.fastModeSupport + : undefined + ) + options = { ...options, ...view, listedModelIds, current } + const projected = live + ? adapter.projectOptions?.(sessionId, { + ...saved, + ...(model ? { model } : {}) + }) + : undefined + if (projected?.options.serviceTier !== undefined) { + current.serviceTier = projected.options.serviceTier + } + } return { ...options, current: { @@ -275,15 +321,3 @@ export async function readStructuredAgentSessionOptions( : {}) } } - -/** A goal the host can change, or one only the agent's own `/goal` changes, reported apart. */ -function structuredAgentSessionThreadGoalReport( - hostChangesGoal: boolean, - current: AgentJournalThreadGoal | null, - contextFloor: { contextFloor?: AgentJournalCursor } -): Pick { - if (hostChangesGoal) { - return { threadGoal: { current, ...contextFloor } } - } - return current ? { reportedThreadGoal: { current, ...contextFloor } } : {} -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts index e163496614d..4b0a22d0ecd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts @@ -294,6 +294,7 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { { id: 'haiku', isDefault: false } ] const catalog = (rows: typeof listed) => ({ + peek: () => null, read: vi.fn(async () => ({ origin: 'probe' as const, fetchedAt: 1, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts index 66dbb918c23..f219fefadc5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts @@ -13,6 +13,11 @@ import { type RestTestRig } from './structured-agent-session-rest-test-rig' import { hostTestOperationId } from './structured-agent-session-host-test-data' +import * as loginShell from '../../startup/login-shell-environment' +import { createStructuredAgentEnvironmentResolvers } from '../../runtime/structured-agent-shell-environment' +import { codexModelCatalogDiscovery } from '../../runtime/structured-agent-model-catalog-discovery' +import { createAgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' +import { AgentModelCatalogStore } from '../agent-model-catalog/agent-model-catalog-store' describe('a chat permission mode', () => { let rig: RestTestRig @@ -88,6 +93,70 @@ describe('a chat permission mode', () => { expect((await rig.host.readOptions(SESSION)).current.permissionMode).toBe('full-access') }) + it('saves a permission pick with a missing CLI without starting a shell capture', async () => { + await foundRestTestChat(rig) + const shellReady = Promise.withResolvers() + const shell = vi.fn(() => shellReady.promise) + const resolveShell = loginShell.resolveLoginShellEnvironment + const capture = vi + .spyOn(loginShell, 'resolveLoginShellEnvironment') + .mockImplementation((options) => + resolveShell({ + ...options, + shellOverride: '/missing/catalog-shell', + env: {}, + spawner: shell + }) + ) + const environment = createStructuredAgentEnvironmentResolvers({}) + shell.mockClear() + capture.mockClear() + const discovery = codexModelCatalogDiscovery({ + environment, + deps: { + stateDirectory: rig.root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => rig.root, + resolveLaunchArgs: () => [], + resolveClaudeAuthPolicy: () => ({ account: 'system' }), + resolveCodexCommand: () => 'codex-pr-30107-missing', + logger: { warn: vi.fn(), error: vi.fn() } + } + }) + if (discovery.kind !== 'probe') { + throw new Error('Codex must have discovery') + } + const catalog = createAgentModelCatalogService({ + store: new AgentModelCatalogStore(), + getRecord: (id) => rig.store.getRecord(id) ?? undefined, + drivesRecord: () => true, + recordWorkspacePath: async () => rig.root, + resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/account' }), + probes: { codex: discovery.probe } + }) + try { + await rig.restart({ modelCatalog: catalog }) + expect(await pick('full-access')).toMatchObject({ + ok: true, + value: { options: { permissionMode: 'full-access' } } + }) + expect(rig.store.getRecord(SESSION)?.permissionMode).toBe('full-access') + shellReady.resolve({ PATH: '/missing/catalog-bin' }) + await environment.resolveBaseEnvironment() + capture.mockClear() + expect(await pick('ask')).toMatchObject({ ok: true }) + expect(capture).toHaveBeenCalled() + expect(capture.mock.calls.every(([options]) => options?.cachedOnly === true)).toBe(true) + expect(shell).not.toHaveBeenCalled() + } finally { + shellReady.resolve({ PATH: '/missing/catalog-bin' }) + catalog.stop() + capture.mockRestore() + loginShell.resetLoginShellEnvironmentCacheForTests() + } + }) + it('keeps Full access through a model pick on the running agent and the next start', async () => { await foundRestTestChat(rig) await pick('full-access') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts index 82b9219c24a..04df3e7d592 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pick-reply.ts @@ -1,6 +1,7 @@ // 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 { structuredAgentSessionCapabilityView } from './structured-agent-session-option-models' 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' @@ -11,13 +12,16 @@ export async function savedAgentSessionCatalogModels( deps: { modelCatalog?: Pick }, provider: string, sessionId: string, - workspacePath?: string | null + workspacePath?: string | null, + verifiedOnly = true ): Promise { const scope = workspacePath === undefined ? {} : { workspacePath } const listing = await deps.modelCatalog - ?.read({ agent: provider, sessionId, savedOnly: true, ...scope }) + ?.read({ agent: provider, sessionId, savedOnly: true, verifiedOnly, ...scope }) .catch(() => null) - return listing && listing.origin !== 'unknown' ? listing.models : undefined + return listing && listing.origin !== 'unknown' + ? structuredAgentSessionCapabilityView(listing, undefined).models + : undefined } /** The committed options with the tier the chat's model runs, as the at-rest answer names it. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts index a3f90ef4346..e6291333e8a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts @@ -101,6 +101,7 @@ async function startHost(): Promise { setOption }, modelCatalog: { + peek: () => null, read: vi.fn(async () => ({ origin: 'unknown' as const })), recordLiveListing, retainedLiveModelIds: vi.fn(async () => []), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index 8a886e5ab88..235a6dd7cdf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -7,10 +7,14 @@ import { type AgentSessionWireRefusal } from '../../../shared/agent-session-wire-refusals' import type { + AgentJournalCursor, AgentJournalItemIdentity, AgentJournalThreadGoal } from '../../../shared/agent-session-journal-types' -import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import type { + AgentSessionMutationEnvelope, + AgentSessionOptionsResult +} from '../../../shared/agent-session-wire' import type { AgentSessionThreadGoalChange, AgentSessionThreadGoalResult @@ -153,3 +157,15 @@ export function threadGoalPlan(params: { rerunWhenReplayMissing: () => true } } + +/** A goal the host can change, or one only the agent's own `/goal` changes, reported apart. */ +export function structuredAgentSessionThreadGoalReport( + hostChangesGoal: boolean, + current: AgentJournalThreadGoal | null, + contextFloor: { contextFloor?: AgentJournalCursor } +): Pick { + if (hostChangesGoal) { + return { threadGoal: { current, ...contextFloor } } + } + return current ? { reportedThreadGoal: { current, ...contextFloor } } : {} +} diff --git a/src/main/runtime/agent-session-record-options.ts b/src/main/runtime/agent-session-record-options.ts index e8086b4eb08..64f16256674 100644 --- a/src/main/runtime/agent-session-record-options.ts +++ b/src/main/runtime/agent-session-record-options.ts @@ -1,3 +1,5 @@ +import { independentAgentModelCatalog } from '../../shared/agent-model-catalog-ownership' +import { agentSessionEffortLevels } from '../../shared/agent-session-effort-levels' import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionOptionsReplacement, @@ -38,7 +40,7 @@ export function agentSessionOptionAdmitted( return true } if (key === 'effort') { - return row.efforts.some((entry) => entry.value === value) + return agentSessionEffortLevels(row)?.has(value) ?? true } return ( key !== 'fastMode' || @@ -83,7 +85,10 @@ export function pickAgentSessionOption( { 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') { + if ( + key !== 'model' && + (independentAgentModelCatalog(record.provider) || record.lease.claimStatus !== 'live') + ) { const { model } = record.options ?? {} assertAgentSessionOptionsAdmitted(record.provider, models, { ...(model ? { model } : {}), @@ -123,7 +128,8 @@ export function recordAgentSessionRanOptions( const added = Object.entries({ ...options, model }).filter( ([key, value]) => saved[key] === undefined && - agentSessionOptionAdmitted(record.provider, models, model, key, value) + (independentAgentModelCatalog(record.provider) || + agentSessionOptionAdmitted(record.provider, models, model, key, value)) ) return added.length === 0 ? record 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 afc39062670..786e2157feb 100644 --- a/src/main/runtime/claude-structured-retired-saved-model.test.ts +++ b/src/main/runtime/claude-structured-retired-saved-model.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' import { structuredAgentSessionSeedCatalog } from '../../shared/structured-agent-session-seed-catalog' import { @@ -71,10 +72,16 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { model: RETIRED, effort: 'high' }) - // The picker shows what the child runs now, so the next send moves the chat off the model. + const live = await host.deps.adapter.readOptions?.({ + sessionId: SESSION, + fence: host.deps.store.getRecord(SESSION)!.lease.runtimeFence + }) + expect(live).toMatchObject({ current: { model: undefined } }) + // The displayed replacement overrides the saved retired model on the next Send. const answer = await vi.waitFor(async () => { const read = await host.readOptions(SESSION, { readsWithoutModel: true }) expect(read.current.model).not.toBe(RETIRED) + expect(read.current).toMatchObject({ model: 'sonnet', defaulted: [] }) return read }) const seed = structuredAgentSessionSeedCatalog('claude') @@ -83,8 +90,35 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { seed, answer ) - expect(structuredAgentSessionSendSelection(reopened)).toMatchObject({ - model: answer.current.model + const options = structuredAgentSessionSendSelection(reopened) + expect(options).toEqual({ model: answer.current.model, effort: 'high' }) + const dispatch = vi.spyOn(host.deps.adapter, 'dispatch') + const setModel = vi.spyOn(claude.child(SESSION).connection, 'setModel') + const body = hostTestMessage('try the replacement') + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: `${Date.now()}-${'2'.padStart(32, '0')}`, + expectedRuntimeFence: host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body, options } + }) + }, + body, + options + }) + expect(sent).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + expect(dispatch.mock.calls[0]?.[0].executionOptions).toEqual({ + model: answer.current.model, + effort: 'high' + }) + expect(setModel.mock.calls.map(([model]) => model)).toEqual([answer.current.model]) + expect(host.deps.store.getRecord(SESSION)?.options).toMatchObject({ + model: RETIRED, + effort: 'high' }) }) 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 1b44570f643..475be94e4a7 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 @@ -125,7 +125,8 @@ describe('a new Claude chat seeded from a remembered model the saved listing no expect(read).toHaveBeenCalledWith({ agent: 'claude', workspacePath: '/repo/wt', - savedOnly: true + savedOnly: true, + verifiedOnly: true }) }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts index 17f4abd0538..badfcb19267 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts @@ -3,6 +3,11 @@ import '../unused-default-rpc-methods.test-fixture' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities' +import { createAgentModelCatalogService } from '../../../native-chat/agent-model-catalog/agent-model-catalog-service' +import { AgentModelCatalogStore } from '../../../native-chat/agent-model-catalog/agent-model-catalog-store' +import { createCodexModelCatalogProbe } from '../../../codex/codex-model-catalog-probe' +import type { runCodexAppServerSession } from '../../../codex/codex-app-server-session' import type { AgentSessionRecord } from '../../../../shared/agent-session-record' import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire' import { agentSessionRecordFixture } from '../../../../shared/agent-session-record.test-fixture' @@ -12,9 +17,14 @@ import { clearStructuredHostStub, hostStub, SESSION, - STRUCTURED_CLIENT + STRUCTURED_CLIENT as OLD_STRUCTURED_CLIENT } from './structured-agent-session-rpc.test-fixture' +const STRUCTURED_CLIENT = { + clientKind: 'runtime' as const, + clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES +} + afterEach(() => { clearStructuredHostStub() }) @@ -33,6 +43,69 @@ describe('agentSession.modelCatalog', () => { ) }) + it('lists the local desktop catalog under its pinned account, folder and launch arguments', async () => { + record = { + ...agentSessionRecordFixture(), + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/account/work' }, + launchDirectory: '/repo/work' + } + const listed = vi.fn() + const runSession: typeof runCodexAppServerSession = async (invocation, body) => { + listed(invocation) + expect(invocation).toMatchObject({ + command: '/installed/codex', + cwd: '/repo/work', + env: { CODEX_HOME: '/account/work' } + }) + expect(invocation.args).toEqual(expect.arrayContaining(['-c', 'model_provider="work"'])) + return body({ + notify: () => {}, + request: async (method) => + method === 'model/list' + ? { + data: [{ model: 'work-model', isDefault: true, supportedReasoningEfforts: [] }], + nextCursor: null + } + : { config: {} } + }) + } + const catalog = createAgentModelCatalogService({ + store: new AgentModelCatalogStore(), + getRecord: () => record ?? undefined, + drivesRecord: () => true, + recordWorkspacePath: async () => '/repo/work', + resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/account/other' }), + probes: { + codex: createCodexModelCatalogProbe({ + resolveCommand: () => '/installed/codex', + resolveEnvironment: async () => ({}), + resolveLaunchArgs: () => ['-c', 'model_provider="work"'], + runSession + }) + } + }) + setStructuredAgentSessionHost( + Object.assign(hostStub(), { + deps: { modelCatalog: catalog, store: { getRecord: () => record }, adapter: {} } + }) + ) + try { + const reply = await call( + 'agentSession.modelCatalog', + { agent: 'codex', sessionId: record.sessionId, waitForListing: true }, + STRUCTURED_CLIENT + ) + expect(reply).toMatchObject({ + ok: true, + result: { origin: 'probe', models: [{ id: 'work-model' }] } + }) + expect(listed).toHaveBeenCalledTimes(1) + } finally { + catalog.stop() + } + }) + it("reads a floating chat's catalog in the folder it was created in", async () => { const fixture = agentSessionRecordFixture() record = { @@ -187,7 +260,7 @@ describe('agentSession.modelCatalog for a chat on a model its CLI stopped listin it("lists the chat's own model, so an older client that replaces an unlisted one keeps it", async () => { const own = { id: 'opus[1m]', label: 'opus[1m]', isDefault: false, efforts: [] } const forChat = { agent: 'claude', sessionId: record.sessionId } - expect(await call('agentSession.modelCatalog', forChat, STRUCTURED_CLIENT)).toMatchObject({ + expect(await call('agentSession.modelCatalog', forChat, OLD_STRUCTURED_CLIENT)).toMatchObject({ ok: true, result: { models: [opus, own] } }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts index 2622fdeab35..38bbd099f5b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts @@ -1,3 +1,4 @@ +import { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from '../../../../shared/structured-agent-session-surface-capabilities' // The options read surface: what a session reports about itself, and the // host's stored model catalog behind the picker. // @@ -72,6 +73,10 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ : await requireInstalledStructuredHost(ctx, params.sessionId) const record = params.sessionId === undefined ? null : host.deps.store.getRecord(params.sessionId) + const coherent = + ctx.clientKind === undefined || + ctx.clientCapabilities?.includes(AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY) === true + const scope = coherent ? {} : { legacy: true } const read = async (): Promise => { const catalog = host.deps.modelCatalog if (!catalog) { @@ -80,21 +85,22 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ // A floating chat runs in the folder it was created in, not the floating setting's current one. const launchDirectory = record ? agentSessionPinnedLaunchDirectory(record) : undefined if (launchDirectory) { - return catalog.read({ ...params, workspacePath: launchDirectory }) + return catalog.read({ ...params, ...scope, workspacePath: launchDirectory }) } if (worktree === undefined) { - return catalog.read(params) + return catalog.read({ ...params, ...scope }) } const workspacePath = await ctx.runtime .resolveStructuredAgentSessionLocalWorkspacePath(worktree) .catch(() => null) - return catalog.read({ ...params, workspacePath }) + return catalog.read({ ...params, ...scope, workspacePath }) } const listing = await read() const own = record?.provider === params.agent ? record : null // A reopened chat's own model stays a row, so a client that replaces an unlisted one keeps // it. Not a new chat's read (it names a worktree): that answer seeds the next new chat's list. - return own && + return !coherent && + own && worktree === undefined && finishedListingIds(listing) && listing.origin !== 'unknown' diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts index 0f6ccc398fb..9d651421b2a 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts @@ -62,7 +62,10 @@ async function readOptions(client: typeof OLDER_CLIENT | typeof CURRENT_CLIENT) /** A Codex chat with no pick, at rest in a repo; `configured` gives the repo a `.codex/config.toml` * that sets the effort. */ -async function chatAtRest(repoConfig: { configured: boolean }): Promise { +async function chatAtRest(repoConfig: { + configured: boolean + complete?: boolean +}): Promise> { await foundRestTestChat(rig) // The chat never picked: its next start runs whatever its config resolves. await rig.store.replaceSessionOptions({ @@ -106,14 +109,34 @@ async function chatAtRest(repoConfig: { configured: boolean }): Promise { getRecord: (sessionId) => rig.store.getRecord(sessionId) ?? undefined, drivesRecord: () => true, resolveAccountHome: async () => rig.store.getRecord(SESSION)!.accountHome, + ...(repoConfig.complete + ? { + probes: { + codex: async () => ({ + ...listing, + complete: true, + configured: { model: 'gpt-5.5', effort: repoConfig.configured ? 'high' : 'medium' } + }) + } + } + : {}), listingNamesConfiguredModel: new Set(['codex']), recordWorkspacePath: async () => repo, agentReadsProjectModelConfig, workspaceMayOverrideDefaultModel }) + if (repoConfig.complete) { + await modelCatalog.read({ + agent: 'codex', + sessionId: SESSION, + inSessionWorkspace: true, + waitForListing: true + }) + } await rig.restart({ modelCatalog }) setStructuredAgentSessionHost(rig.host) rig.adapter.acquire.mockClear() + return modelCatalog } beforeEach(async () => { @@ -161,11 +184,34 @@ describe('options at rest in a repo whose config may pick the model', () => { describe('options at rest in a repo with no config of its own', () => { it('names the default model and its effort for a client that needs a model', async () => { - await chatAtRest({ configured: false }) + const catalog = await chatAtRest({ configured: false }) expect(await readOptions(OLDER_CLIENT)).toMatchObject({ ok: true, result: { current: { model: 'gpt-5.5', effort: 'medium' } } }) + const current = await readOptions(CURRENT_CLIENT) + expect(current).toMatchObject({ ok: true }) + expect(current).not.toHaveProperty('result.current.model') + expect(current).not.toHaveProperty('result.current.effort') + expect(await catalog.read({ agent: 'codex', savedOnly: true })).toMatchObject({ + models: [{ id: 'gpt-5.5', isDefault: false }], + listingNamesConfiguredModel: false + }) + expect(rig.store.getRecord(SESSION)!.options).toEqual({}) }) }) + +describe('completed discovery in the exact project scope', () => { + it.each([OLDER_CLIENT, CURRENT_CLIENT])( + 'shows what the project actually runs to $clientId', + async (client) => { + await chatAtRest({ configured: true, complete: true }) + expect(await readOptions(client)).toMatchObject({ + ok: true, + result: { current: { model: 'gpt-5.5', effort: 'high' } } + }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/runtime/scripted-codex-model-catalog.test-fixture.ts b/src/main/runtime/scripted-codex-model-catalog.test-fixture.ts new file mode 100644 index 00000000000..aece6d22a29 --- /dev/null +++ b/src/main/runtime/scripted-codex-model-catalog.test-fixture.ts @@ -0,0 +1,31 @@ +import type { CodexModelCatalogProbeDeps } from '../codex/codex-model-catalog-probe' + +/** Independent RPC transport for the scripted chat's installed-CLI discovery. */ +export const scriptedCodexCatalogSession: NonNullable< + CodexModelCatalogProbeDeps['runSession'] +> = async (_invocation, body) => + body({ + request: async (method) => { + if (method === 'config/read') { + return { config: { model: 'gpt-live', model_reasoning_effort: 'medium' } } + } + return method === 'model/list' + ? { + data: [ + { + model: 'gpt-live', + displayName: 'GPT Live', + isDefault: true, + defaultReasoningEffort: 'medium', + supportedReasoningEfforts: [ + { reasoningEffort: 'medium', description: 'Balanced' }, + { reasoningEffort: 'high', description: 'Deep reasoning' } + ] + } + ], + nextCursor: null + } + : {} + }, + notify: () => {} + }) diff --git a/src/main/runtime/session-tabs/structured-launch-seed-listing.ts b/src/main/runtime/session-tabs/structured-launch-seed-listing.ts index f723d04a8ea..348f16d598a 100644 --- a/src/main/runtime/session-tabs/structured-launch-seed-listing.ts +++ b/src/main/runtime/session-tabs/structured-launch-seed-listing.ts @@ -40,7 +40,9 @@ export async function structuredLaunchSeedOnSavedListing( } const catalog = await workspacePath() .catch(() => null) - .then((path) => catalogs.read({ agent, workspacePath: path, savedOnly: true })) + .then((path) => + catalogs.read({ agent, workspacePath: path, savedOnly: true, verifiedOnly: true }) + ) .catch(() => null) const listed = catalog ? finishedListingIds(catalog) : undefined if (!catalog || catalog.origin === 'unknown' || !listed || listed.includes(seed.model)) { diff --git a/src/main/runtime/structured-agent-model-catalog-discovery.test.ts b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts index daa82e277d3..15630046322 100644 --- a/src/main/runtime/structured-agent-model-catalog-discovery.test.ts +++ b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts @@ -30,8 +30,15 @@ function context(): StructuredAgentModelCatalogContext { throw new Error('building a probe resolves nothing') } return { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: building a probe only captures resolvers; none of these deps is read until a probe runs. - deps: { stateDirectory: '/state' } as StructuredAgentModelCatalogContext['deps'], + deps: { + stateDirectory: '/state', + hostId: 'local', + claimKeyId: 'catalog-test', + resolveWorkspacePath: unused, + resolveLaunchArgs: () => [], + resolveClaudeAuthPolicy: () => ({ account: 'system' }), + logger: { warn: vi.fn(), error: vi.fn() } + }, environment: { resolveBaseEnvironment: unused, resolveAgentEnvironment: unused, @@ -118,14 +125,15 @@ describe('the model catalog contract on every registration', () => { } const env = async () => ({ PATH: '/usr/bin' }) const { probes } = registeredModelCatalogDiscovery(STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a probe reads only the command, env and auth resolvers given here. deps: { + ...context().deps, stateDirectory: '/state', + resolveLaunchArgs: () => [], resolveClaudeCommand: () => resolveStructuredAgentCommand('claude', settings), resolveCodexCommand: (options) => resolveStructuredAgentCommand('codex', settings, options), resolveClaudeAuthPolicy: () => ({ account: 'system' as const }), resolveAgentCommandSettings: () => settings - } as StructuredAgentModelCatalogContext['deps'], + }, environment: { resolveBaseEnvironment: env, resolveAgentEnvironment: env, @@ -164,14 +172,15 @@ describe('the model catalog contract on every registration', () => { }) const settings = { agentCmdOverrides: { grok: installed('grok'), pi: installed('pi') } } const { probes } = registeredModelCatalogDiscovery(STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a probe reads only the command, env and auth resolvers given here. deps: { + ...context().deps, stateDirectory: '/state', + resolveLaunchArgs: () => [], resolveClaudeCommand: (options) => resolveCliCommand(installed('claude'), options), resolveCodexCommand: (options) => resolveCliCommand(installed('codex'), options), resolveClaudeAuthPolicy: () => ({ account: 'system' as const }), resolveAgentCommandSettings: () => settings - } as StructuredAgentModelCatalogContext['deps'], + }, environment: { resolveBaseEnvironment: env, resolveAgentEnvironment: env, diff --git a/src/main/runtime/structured-agent-model-catalog-discovery.ts b/src/main/runtime/structured-agent-model-catalog-discovery.ts index 8e9be6d6cb4..13c74a54b8e 100644 --- a/src/main/runtime/structured-agent-model-catalog-discovery.ts +++ b/src/main/runtime/structured-agent-model-catalog-discovery.ts @@ -31,10 +31,14 @@ export function codexModelCatalogDiscovery({ listingNamesConfiguredModel: true, probe: createCodexModelCatalogProbe({ resolveAccountKind: deps.resolveCodexAccountKind, + ...(deps.runCodexCatalogSession ? { runSession: deps.runCodexCatalogSession } : {}), + resolveLaunchArgs: () => deps.resolveLaunchArgs('codex'), ...(deps.prepareCodexCatalogProbeHome ? { prepareHome: deps.prepareCodexCatalogProbeHome } : {}), - resolveEnvironment: () => environment.resolveAgentEnvironment('codex'), + resolveEnvironment: environment.resolveBaseEnvironment, + resolveSavedEnvironment: () => environment.resolveBaseEnvironment(true), + resolveEnv: () => deps.resolveAgentLaunchEnv?.('codex') ?? {}, refreshEnvironmentAfterMiss: environment.refreshAfterMissingProgram, ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) }) @@ -48,6 +52,7 @@ export function claudeListingDeps({ }: StructuredAgentModelCatalogContext): ClaudeModelCatalogProbeDeps { return { resolveInheritedEnv: environment.resolveBaseEnvironment, + resolveSavedEnvironment: () => environment.resolveBaseEnvironment(true), refreshEnvironmentAfterMiss: environment.refreshAfterMissingProgram, resolveAuthPolicy: deps.resolveClaudeAuthPolicy, ...(deps.resolveClaudeCommand ? { resolveCommand: deps.resolveClaudeCommand } : {}), @@ -62,7 +67,10 @@ export function claudeModelCatalogDiscovery( kind: 'probe', // Claude's settings or env can pick a model other than the one its listing recommends. listingNamesConfiguredModel: false, - probe: createClaudeModelCatalogProbe(claudeListingDeps(context)) + probe: createClaudeModelCatalogProbe({ + ...claudeListingDeps(context), + resolveLaunchArgs: () => context.deps.resolveLaunchArgs('claude') + }) } } 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 4b43ab53019..03d1d9a845e 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 @@ -226,15 +226,4 @@ describe('a running chat’s listing, saved for the next chat', () => { [{ value: 'low', label: 'Low' }] ]) }) - - it('saves a Claude live listing through the same step as every other agent', async () => { - const store = new AgentModelCatalogStore() - const record = { ...grokRecord(), provider: 'claude', accountHome: HOMES.claude! } - const catalog = service(store, {}, [record]) - catalog.recordLiveListing(record.sessionId, { - models: [{ id: 'opus', label: 'Opus', isDefault: false, efforts: [] }] - }) - const read = await catalog.read({ agent: 'claude', sessionId: record.sessionId }) - expect(read.origin === 'unknown' ? null : read.models.map((m) => m.id)).toEqual(['opus']) - }) }) diff --git a/src/main/runtime/structured-agent-model-catalog-wiring.ts b/src/main/runtime/structured-agent-model-catalog-wiring.ts index 83cb796ba48..b1f46631eef 100644 --- a/src/main/runtime/structured-agent-model-catalog-wiring.ts +++ b/src/main/runtime/structured-agent-model-catalog-wiring.ts @@ -1,3 +1,4 @@ +import { agentSessionPinnedLaunchDirectory } from './agent-session-record-launch-directory' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { createAgentModelCatalogFilePersistence } from '../native-chat/agent-model-catalog/agent-model-catalog-persistence' import { @@ -20,22 +21,15 @@ import { agentDrivesSession } from '../native-chat/agent-session-wire/structured import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' // The store is process-global; hydrate it from disk at most once per process. -let persistenceAttached = false +let persistenceAttached: Promise | null = null export async function attachAgentModelCatalogPersistenceOnce( stateDirectory: string ): Promise { - if (persistenceAttached) { - return - } - persistenceAttached = true - try { - await agentModelCatalogStore.attachPersistence( - createAgentModelCatalogFilePersistence(stateDirectory) - ) - } catch { - // A missing or unreadable file only costs the warm start. - } + persistenceAttached ??= agentModelCatalogStore + .attachPersistence(createAgentModelCatalogFilePersistence(stateDirectory)) + .catch(() => {}) + await persistenceAttached } type CatalogRegistration = Pick @@ -92,7 +86,7 @@ export async function modelCatalogHostDeps(input: { drivesRecord: (record) => agentDrivesSession(input.agents, record), resolveAccountHome: deps.resolveAgentAccountHome, recordWorkspacePath: async (record) => - record.launchDirectory ?? + agentSessionPinnedLaunchDirectory(record) ?? (await deps.resolveWorkspacePath(record.location.workspaceId).catch(() => null)), agentReadsProjectModelConfig, workspaceMayOverrideDefaultModel, 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 215c4c99101..f64c174ee80 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 @@ -1,7 +1,7 @@ // A new chat reads its options at rest before its agent starts. That answer must name the model and // effort the catalog's first frame showed for its workspace, or the picker changes before any start. -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -191,6 +191,20 @@ function host(agent: string, record: AgentSessionRecord) { getRecord: (sessionId) => (sessionId === record.sessionId ? record : undefined), drivesRecord: () => true, resolveAccountHome: async (name) => HOMES[name]!, + probes: { + codex: async (_home, options) => { + const project = existsSync(join(options?.workspacePath ?? '', '.codex/config.toml')) + const effort = project ? 'high' : 'medium' + return { + models: CODEX_LISTING.map((row) => + row.isDefault ? { ...row, defaultEffort: effort } : row + ), + configured: { model: 'gpt-5.5', effort }, + complete: true, + origin: 'probe' + } + } + }, listingNamesConfiguredModel: new Set(['codex']), recordWorkspacePath: async (row) => row.launchDirectory ?? null, agentReadsProjectModelConfig, @@ -275,10 +289,7 @@ describe("a new chat's effort pill before its agent starts", () => { } ) - it.each([ - ['codex', '.codex/config.toml', 'model_reasoning_effort = "high"\n'], - ['pi', '.pi/settings.json', '{ "defaultThinkingLevel": "low" }'] - ])( + it.each([['pi', '.pi/settings.json', '{ "defaultThinkingLevel": "low" }']])( '%s names no default model or effort at rest where the project’s config may pick one', async (agent, file, text) => { const { atRest, first, settled } = await newChatFrames(agent, { @@ -292,14 +303,18 @@ describe("a new chat's effort pill before its agent starts", () => { } ) - it('keeps the account default model, with no effort, for a client that needs a model', async () => { + it('names the exact project default for a client that needs a model', async () => { const { atRest } = await newChatFrames('codex', { projectFiles: { '.codex/config.toml': 'model_reasoning_effort = "high"\n' }, readsWithoutModel: false }) - // The account default is not what this repo runs, so its effort is never claimed. - expect(atRest.current).toEqual({ model: 'gpt-5.5', defaulted: [], permissionMode: 'ask' }) + expect(atRest.current).toEqual({ + model: 'gpt-5.5', + effort: 'high', + defaulted: [], + permissionMode: 'ask' + }) expect(atRest.conversationCommands).toEqual(['clear', 'compact']) }) diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index d3b7f1ebe73..c0541d70172 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -73,6 +73,14 @@ function createCodexAdapter(context: StructuredAgentAdapterContext): StructuredA ...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), modelCatalog: agentModelCatalogStore, + readModelCatalog: (sessionId, verifiedOnly) => + host()?.deps.modelCatalog?.peek(sessionId, verifiedOnly) ?? null, + demandModelCatalog: async (sessionId, legacy) => + host()?.deps.modelCatalog?.read({ + agent: 'codex', + sessionId, + ...(legacy ? { legacy: true } : {}) + }), onChildWorkEvidence: (sessionId, evidence, producer) => host()?.publishChildWorkEvidence(sessionId, evidence, producer), onDispatchSettledLate: followUps.onDispatchSettledLate, diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 91f6c144f68..19d7ac02e16 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -1,4 +1,5 @@ import './rpc/unused-default-rpc-methods.test-fixture' +import { scriptedCodexCatalogSession } from './scripted-codex-model-catalog.test-fixture' // One structured Codex session driven end to end over `agentSession.*`. // // Nothing here is stubbed except the Codex child itself: the RPC dispatcher, the @@ -339,6 +340,7 @@ beforeEach(async () => { shellEnvironmentPolicy = { inheritAll: true, names: [] } const runtime = { getRuntimeId: () => 'runtime-1', + resolveStructuredAgentSessionLocalWorkspacePath: async () => `/repos/${WORKSPACE}`, getClientSettings: () => ({ experimentalNativeChat: true }), getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), structuredAgentSessionLaunchSeedOptions: () => undefined, @@ -359,6 +361,8 @@ beforeEach(async () => { claimKeyId: 'key-1', resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, resolveCodexCommand: () => '/usr/local/bin/codex', + runCodexCatalogSession: scriptedCodexCatalogSession, + resolveAgentAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/home/dev/.codex' }), resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveEnvironment: async () => { @@ -747,10 +751,8 @@ describe('a structured codex session over agentSession.*', () => { shellEnvironmentPolicy = { inheritAll: false, names: [] } const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) expect(codex.live().launch.env?.EXAMPLE_GATEWAY_TOKEN).toBeUndefined() - expect({ bootEnvironmentReads, codexOverrideReads }).toEqual({ - bootEnvironmentReads: 1, - codexOverrideReads: 1 - }) + expect(bootEnvironmentReads).toBe(1) + const beforeResume = codexOverrideReads configuredCodexProfile = 'updated' shellEnvironmentPolicy = { inheritAll: false, names: ['EXAMPLE_GATEWAY_TOKEN'] } @@ -762,10 +764,8 @@ describe('a structured codex session over agentSession.*', () => { CODEX_PROFILE: 'updated', EXAMPLE_GATEWAY_TOKEN: 'shell-exported' }) - expect({ bootEnvironmentReads, codexOverrideReads }).toEqual({ - bootEnvironmentReads: 1, - codexOverrideReads: 2 - }) + expect(bootEnvironmentReads).toBe(1) + expect(codexOverrideReads).toBeGreaterThan(beforeResume) }) it('refuses to build a host for a client that never advertised the capability', async () => { diff --git a/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts b/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts index 56b7d6df6a8..5c1ece5afe1 100644 --- a/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts @@ -35,6 +35,7 @@ afterEach(async () => { describe('the host catalog lifetime', () => { it('prewarms once the host is installed and stops its listings at teardown', async () => { const service = { + peek: () => null, read: vi.fn(async () => ({ origin: 'unknown' as const })), retainedLiveModelIds: vi.fn(async () => []), recordLiveListing: vi.fn(), diff --git a/src/main/runtime/structured-agent-session-transport-overrides.ts b/src/main/runtime/structured-agent-session-transport-overrides.ts index 6bf042ca960..eed64bb8ba1 100644 --- a/src/main/runtime/structured-agent-session-transport-overrides.ts +++ b/src/main/runtime/structured-agent-session-transport-overrides.ts @@ -1,3 +1,4 @@ +import type { CodexModelCatalogProbeDeps } from '../codex/codex-model-catalog-probe' import type { AcpStructuredSessionAdapterDeps } from '../acp/acp-structured-session-adapter-deps' import type { AcpStructuredLaunchPartDeps } from '../acp/acp-structured-launch-resolution' import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' @@ -13,6 +14,8 @@ export type StructuredAgentSessionTransportOverrides = { /** Scripted Pi children resolve their launch here, so no test reaches a real Pi binary. */ resolvePiLaunch?: PiRpcSessionAdapterDeps['resolveLaunch'] openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] + /** Independent discovery uses its own short-lived transport, separate from chat children. */ + runCodexCatalogSession?: CodexModelCatalogProbeDeps['runSession'] /** Scripted transports must also replace the real host's version probe. */ readCodexInstallation?: typeof readCodexCliInstallation openAcpConnection?: AcpStructuredSessionAdapterDeps['connect'] diff --git a/src/main/runtime/structured-agent-shell-environment.ts b/src/main/runtime/structured-agent-shell-environment.ts index b2165039b14..843d8c774f8 100644 --- a/src/main/runtime/structured-agent-shell-environment.ts +++ b/src/main/runtime/structured-agent-shell-environment.ts @@ -88,13 +88,14 @@ const SHELL_ENVIRONMENT_TTL_MS = 10_000 * background past the TTL. Windows keeps a loaded PowerShell profile for good (only a failed load * is retried) and lays the saved PATH, where installers add themselves, over it on every read. */ -async function hostShellEnvironment(): Promise { +async function hostShellEnvironment(cachedOnly = false): Promise { const windows = process.platform === 'win32' const env = await resolveLoginShellEnvironment({ + cachedOnly, refreshAfterMs: SHELL_ENVIRONMENT_TTL_MS, keepCaptured: windows }) - if (!windows) { + if (!windows || cachedOnly) { return env } const withSavedPath = { ...env } @@ -154,7 +155,7 @@ export function createStructuredAgentEnvironmentResolvers( sources: StructuredAgentEnvironmentSources ): { /** The shared base every agent's child env starts from, before its own overlay. */ - resolveBaseEnvironment: () => Promise> + resolveBaseEnvironment: (cachedOnly?: boolean) => Promise> /** The base with the user's overlay for `agent` laid over it. */ resolveAgentEnvironment: (agent: string) => Promise> refreshAfterMissingProgram: (askedAt: number) => Promise @@ -165,9 +166,9 @@ export function createStructuredAgentEnvironmentResolvers( if (!injected) { void hostShellEnvironment().catch(() => {}) } - const resolveBase = async (): Promise> => + const resolveBase = async (cachedOnly = false): Promise> => structuredAgentBaseEnvironment({ - shellEnv: await shellEnvironment(), + shellEnv: await shellEnvironment(cachedOnly), policy: sources.resolveShellEnvironmentPolicy?.() ?? nativeChatShellEnvironmentPolicy(null) }) return { diff --git a/src/main/startup/login-shell-environment.ts b/src/main/startup/login-shell-environment.ts index 0521b16a9e5..05f8be2b995 100644 --- a/src/main/startup/login-shell-environment.ts +++ b/src/main/startup/login-shell-environment.ts @@ -159,6 +159,8 @@ function spawnShellAndReadEnvironment( } export type ResolveLoginShellEnvironmentOptions = { + /** Reads the last capture or process fallback without starting or awaiting shell work. */ + cachedOnly?: boolean /** Only a run that started at or after this time answers; one is started if none has. */ startedAfter?: number /** Serves the last result, rerunning the shell in the background once the last run is older. */ @@ -221,6 +223,9 @@ export function captureLoginShellEnvironment( return capture } const { running, last } = entry + if (options.cachedOnly) { + return Promise.resolve(last ?? fellBack) + } if (options.startedAfter !== undefined) { return running && running.startedAt >= options.startedAfter ? running.capture : run() } diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx index a658e6af9b8..bdee04e4564 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -151,25 +151,29 @@ describe('NativeChatSessionOptionPickers', () => { it.each(['model', 'effort'])( 'opens the one picker when a slash command asks for the %s', async (id) => { + const demandModelCatalog = vi.fn() + const demanding = { ...surface, demandModelCatalog } const { rerender } = render( ) expect(isOpen(pill('Model Opus 4.8, Effort High'))).toBe(false) + expect(demandModelCatalog).not.toHaveBeenCalled() rerender( {} }} /> ) await waitFor(() => expect(isOpen(pill('Model Opus 4.8, Effort High'))).toBe(true)) + expect(demandModelCatalog).toHaveBeenCalledTimes(1) } ) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx index e243d223067..8ed091aca1c 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useState } from 'react' +import { memo, useEffect, useRef, useState } from 'react' import { ChevronDown } from 'lucide-react' import { Button } from '@/components/ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' @@ -127,6 +127,7 @@ function PickerTrigger(props: { } function SessionOptionPopover(props: { + demandModelCatalog?: () => void model: SessionOptionDescriptor options: readonly SessionOptionDescriptor[] isWorking: boolean @@ -137,7 +138,7 @@ function SessionOptionPopover(props: { onAction: (descriptor: SessionOptionDescriptor) => void focusComposer?: () => void }): React.JSX.Element { - const { model, options, pendingIds } = props + const { model, options, pendingIds, demandModelCatalog } = props // Why here: this unmounts with the pill, so a picker that went away comes back closed. const [open, setOpen] = useState(false) const [seenRequest, setSeenRequest] = useState(null) @@ -150,6 +151,13 @@ function SessionOptionPopover(props: { setRequestedOptionId(props.request.id === model.id ? null : props.request.id) } } + const wasOpen = useRef(false) + useEffect(() => { + if (open && !wasOpen.current) { + demandModelCatalog?.() + } + wasOpen.current = open + }, [open, demandModelCatalog]) const focusReturn = useNativeChatPickerFocusReturn(props.focusComposer) const modelTitle = translate('components.native-chat.composer.model', 'Model') const modelLabel = nativeChatModelPillLabel(model) @@ -320,6 +328,7 @@ function NativeChatSessionOptionPickersInner({ return (
void - -const waits = new Map>() - -/** Joins the chat's waiting read, started by `read` only when none is in flight. Returns `leave`. */ -export function joinHostModelListingWait( - key: string, - read: () => Promise, - onSettled: HostModelListingJoiner -): () => void { - let joiners = waits.get(key) - if (!joiners) { - const started = new Set() - joiners = started - waits.set(key, started) - const settle = (catalog: AgentSessionModelCatalogResult | null): void => { - try { - for (const joiner of started) { - joiner(catalog) - } - } finally { - waits.delete(key) - } - } - new Promise((resolve) => resolve(read())).then(settle, () => - settle(null) - ) - } - joiners.add(onSettled) - const joined = joiners - return () => { - joined.delete(onSettled) - } -} - -export function isHostModelListingWaitInFlight(key: string): boolean { - return waits.has(key) -} 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 0ce998b72b3..3f426d960ea 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 @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState, type MutableRefObject } from 'react' +import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react' import { readAgentSessionUnavailable, type AgentSessionUnavailable @@ -7,7 +7,6 @@ import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-se import type { AgentType } from '../../../../shared/agent-status-types' import type { AgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' import { - applyStructuredAgentSessionModelCatalog, settleStructuredAgentSessionBuiltinCatalog, type StructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' @@ -19,7 +18,7 @@ import type { StructuredAgentSessionOptionRecord } from '../../../../shared/stru import { isHostModelListingWaitInFlight, joinHostModelListingWait -} from './host-model-listing-waits' +} from '../../../../shared/host-model-listing-waits' /** * Replaces the quiet placeholder with the host's stored catalog without waiting on @@ -61,11 +60,15 @@ export function useHostModelCatalogUpgrade(args: { providerRunning?: boolean /** A listing landed after the chat's options were read, which answered without it. */ onListingLanded?: () => void + receiveCatalog: ( + catalog: AgentSessionModelCatalogResult | null, + event: 'demand' | 'progress' | 'settled' + ) => void activeOptionRecordRef: MutableRefObject updateOptionState: ( update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState ) => void -}): { unavailable: AgentSessionUnavailable | null } { +}): { unavailable: AgentSessionUnavailable | null; demandModelCatalog: () => void } { const { activeOptionRecordRef, agent, @@ -79,6 +82,10 @@ export function useHostModelCatalogUpgrade(args: { updateOptionState, worktree } = args + const receiver = useRef(args.receiveCatalog) + useEffect(() => { + receiver.current = args.receiveCatalog + }, [args.receiveCatalog]) const waitKey = `${structuredAgentSessionHostKey(target)}\u0000${agent}\u0000${sessionId}` const [verdict, setVerdict] = useState<{ key: string @@ -113,7 +120,14 @@ export function useHostModelCatalogUpgrade(args: { 'agentSession.modelCatalog', waitForListing ? { ...params, waitForListing } : params ) - const apply = (catalog: AgentSessionModelCatalogResult | null): void => { + const apply = ( + catalog: AgentSessionModelCatalogResult | null, + settled = false, + event: 'demand' | 'progress' = 'demand' + ): void => { + if (stale) { + return + } const next = readAgentSessionUnavailable(catalog?.unavailable) setVerdict((current) => { const shown = current?.key === waitKey ? current.unavailable : null @@ -128,15 +142,7 @@ export function useHostModelCatalogUpgrade(args: { if (catalog && newLaunch) { recordHostModelCatalogSnapshot(target, agent, worktree ?? '', catalog) } - updateOptionState((current) => - current.record !== activeOptionRecordRef.current - ? current - : catalog - ? applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, { - newLaunch - }) - : settleStructuredAgentSessionBuiltinCatalog(current) - ) + receiver.current(catalog, settled ? 'settled' : event) } let leave: (() => void) | null = null const waitForListing = (): void => { @@ -144,8 +150,12 @@ export function useHostModelCatalogUpgrade(args: { waitKey, () => read(true), (catalog) => { - apply(catalog) - if (catalog && catalog.origin !== 'unknown') { + apply( + catalog, + catalog !== null && !catalog.listingInProgress && catalog.origin !== 'unknown', + 'progress' + ) + if (catalog && catalog.origin !== 'unknown' && !catalog.listingInProgress) { onListingLanded?.() } } @@ -199,5 +209,5 @@ export function useHostModelCatalogUpgrade(args: { waitKey, worktree ]) - return { unavailable } + return { unavailable, demandModelCatalog: recheck } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-option-picks.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-option-picks.ts new file mode 100644 index 00000000000..4eb8b163596 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-option-picks.ts @@ -0,0 +1,21 @@ +import { useCallback } from 'react' +import type { AgentType } from '../../../../shared/agent-status-types' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { + structuredAgentSessionOptionPicks, + type StructuredAgentSessionOptionState +} from '../../../../shared/structured-agent-session-options' +import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option-settings-write' + +// What a settled pick must remember so the next launch starts where the user left off. +export function useRememberSessionOptionPicks(target: RuntimeClientTarget, agent: AgentType) { + return useCallback( + (view: StructuredAgentSessionOptionState, committed: Readonly>) => { + const picks = structuredAgentSessionOptionPicks(view, committed) + if (picks.length > 0) { + void enqueueSessionOptionSettingsWrite(target, { type: 'apply-picks', agent, picks }) + } + }, + [agent, target] + ) +} 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 61cd8ae87fb..38716fdfd06 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 @@ -1,20 +1,29 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { AgentSessionConversationCommand } from '../../../../shared/agent-session-conversation-command' -import type { AgentSessionOptionsResult } from '../../../../shared/agent-session-wire' +import type { + AgentSessionModelCatalogResult, + AgentSessionOptionsResult +} from '../../../../shared/agent-session-wire' import type { AgentSessionRewindSupport } from '../../../../shared/agent-session-rewind' import type { AgentType } from '../../../../shared/agent-status-types' import type { AgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import { usesInstalledAgentModelCatalog } from '../../../../shared/agent-model-catalog-ownership' import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' import { applyStructuredAgentSessionModelCatalog, applyStructuredAgentSessionOptions, + settleStructuredAgentSessionBuiltinCatalog, createStructuredAgentSessionOptionState, type StructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { structuredAgentSessionOptionView } from '../../../../shared/structured-agent-session-option-view' +import { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from '../../../../shared/structured-agent-session-surface-capabilities' import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities' -import { useStructuredAgentSessionHostCapability } from '@/runtime/structured-agent-session-host-capability' +import { + useStructuredAgentSessionHostCapability, + structuredAgentSessionHostKey +} from '@/runtime/structured-agent-session-host-capability' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { readHostModelCatalogSnapshot } from '@/runtime/host-model-catalog-snapshots' import { cloneStructuredAgentSessionOptionRecord } from '../../../../shared/structured-agent-session-option-state' @@ -99,7 +108,7 @@ export function useStructuredAgentSessionOptionState(args: { const firstFrameLaunch = useRef({ target, newLaunch, worktree, seedsModel }) useEffect(() => { firstFrameLaunch.current = { target, newLaunch, worktree, seedsModel } - }, [newLaunch, seedsModel, target, worktree]) + }, [target, newLaunch, worktree, seedsModel]) const optionStateRef = useRef(optionState) const activeOptionRecordRef = useRef(optionState.record) // A pick on its way to the host, which every send carries too. @@ -113,13 +122,17 @@ 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 hostKey = structuredAgentSessionHostKey(target) + const contextKey = JSON.stringify([identity, hostKey, newLaunch, worktree, fence]) + const contextRef = useRef(contextKey) + useEffect(() => { + contextRef.current = contextKey + }, [contextKey]) + const isCurrentContext = useCallback(() => contextRef.current === contextKey, [contextKey]) + const writes = useRef({ count: 0, readback: false }) + const coherentCatalog = usesInstalledAgentModelCatalog( + agent, + useStructuredAgentSessionHostCapability(target, AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY) ) const optionIdentityRef = useRef(identity) useEffect(() => { @@ -127,29 +140,27 @@ export function useStructuredAgentSessionOptionState(args: { const sameSession = optionIdentityRef.current === identity optionIdentityRef.current = identity const launch = firstFrameLaunch.current - const seeded = firstFrameOptionState(agent, launch.target, { - newLaunch: launch.newLaunch, - seedsModel: launch.seedsModel, - ...(launch.worktree ? { worktree: launch.worktree } : {}) - }) + const seeded = firstFrameOptionState(agent, launch.target, launch) // The chat's next runtime starts with the picks the host saved, which this view last showed, // and a starting agent is not read: keep them, and the account's list, until a read replaces them. const kept = sameSession && previous.catalogSource !== 'seed' && previous.catalogSource !== null ? { ...previous, + catalogSource: 'host' as const, record: cloneStructuredAgentSessionOptionRecord(previous.record), pendingId: null } : seeded // The chat's own, which a restart for a new mode keeps: the picker never blanks between them. const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept + writes.current = { count: 0, readback: false } optionMutationGeneration.current += 1 pendingOptionsRef.current = {} optionStateRef.current = next activeOptionRecordRef.current = next.record setOptionState(next) - }, [agent, fence, identity]) + }, [agent, contextKey, fence, identity]) const optionsReadRef = useRef(null) // Refresh options each turn to confirm which model the provider actually selected, and once @@ -160,13 +171,27 @@ export function useStructuredAgentSessionOptionState(args: { } let stale = false const runner = createCoalescedPollRunner(async () => { + if (!isCurrentContext()) { + return + } + if (writes.current.count) { + writes.current.readback = true + return + } + writes.current.readback = false + const record = activeOptionRecordRef.current const readGeneration = optionMutationGeneration.current const result = await callStructuredAgentSession( target, 'agentSession.options', { sessionId } ) - if (!stale && optionMutationGeneration.current === readGeneration) { + if ( + !stale && + isCurrentContext() && + activeOptionRecordRef.current === record && + optionMutationGeneration.current === readGeneration + ) { setConversationSupport({ sessionId, commands: result.conversationCommands ?? [], @@ -180,7 +205,10 @@ export function useStructuredAgentSessionOptionState(args: { }) updateOptionState((current) => current.record === activeOptionRecordRef.current - ? applyOptionsAnswer(current, result) + ? applyStructuredAgentSessionOptions(current, optionCatalog, result, { + newLaunch, + coherentCatalog + }) : current ) } @@ -190,9 +218,12 @@ export function useStructuredAgentSessionOptionState(args: { return () => { stale = true runner.dispose() + optionsReadRef.current = null } }, [ - applyOptionsAnswer, + newLaunch, + isCurrentContext, + coherentCatalog, fence, optionCatalog, // Another device's pick moves it. @@ -218,6 +249,36 @@ export function useStructuredAgentSessionOptionState(args: { // An options answer made before the account's listing landed listed none of it. const rereadOptions = useCallback(() => optionsReadRef.current?.run(), []) + const receiveCatalog = ( + catalog: AgentSessionModelCatalogResult | null, + _event: 'demand' | 'progress' | 'settled' + ) => { + updateOptionState((current) => + catalog && optionCatalog + ? applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, { + newLaunch, + coherentCatalog + }) + : settleStructuredAgentSessionBuiltinCatalog(current, false) + ) + if (!coherentCatalog && catalog && catalog.origin !== 'unknown' && !catalog.listingInProgress) { + rereadOptions() + } + } + const beginOptionWrite = (_id: string) => { + const record = activeOptionRecordRef.current + writes.current.count += 1 + return (readback: boolean) => { + if (!isCurrentContext() || activeOptionRecordRef.current !== record) { + return + } + writes.current.count -= 1 + writes.current.readback ||= readback + if (!writes.current.count && writes.current.readback) { + rereadOptions() + } + } + } const takesSendOptions = useStructuredAgentSessionHostCapability( target, AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY @@ -225,16 +286,20 @@ export function useStructuredAgentSessionOptionState(args: { return { // What the picker shows and a send carries. - optionView: structuredAgentSessionOptionView, + optionView: (...args: Parameters) => { + const view = structuredAgentSessionOptionView(...args) + return pendingOptionsRef.current.model === undefined ? view : { ...view, pendingId: 'model' } + }, takesSendOptions, conversationSupport, - rereadOptions, + receiveCatalog, + beginOptionWrite, + isCurrentContext, optionState, optionStateRef, activeOptionRecordRef, pendingOptionsRef, optionMutationGeneration, - updateOptionState, - applyOptionsAnswer + updateOptionState } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx index 41f5dacd44a..662d4c46d65 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx @@ -1,10 +1,16 @@ // @vitest-environment happy-dom -import { renderHook, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as HostCapability from '@/runtime/structured-agent-session-host-capability' -const mocks = vi.hoisted(() => ({ call: vi.fn() })) +const mocks = vi.hoisted(() => ({ call: vi.fn(), coherent: false })) +vi.mock('@/runtime/structured-agent-session-host-capability', async (original) => ({ + ...(await original()), + useStructuredAgentSessionHostCapability: (_target: unknown, capability: string) => + capability === 'agent-session.coherent-model-catalog.v1' && mocks.coherent +})) vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: mocks.call @@ -18,7 +24,10 @@ vi.mock('@/lib/structured-agent-session-launch-options', () => ({ })) import type { AgentType } from '../../../../shared/agent-status-types' -import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire' +import type { + AgentSessionModelCatalogResult, + AgentSessionOptionsResult +} from '../../../../shared/agent-session-wire' import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { @@ -135,8 +144,10 @@ function deferred(): { promise: Promise; resolve: (value: T) => void } { } describe('a new chat picker’s first frame', () => { + afterEach(() => vi.useRealTimers()) beforeEach(() => { mocks.call.mockReset() + mocks.coherent = false resetHostModelCatalogSnapshotsForTests() }) @@ -155,6 +166,63 @@ describe('a new chat picker’s first frame', () => { unmount() }) + it('uses installed controls and keeps saved values through a quiet refresh', async () => { + mocks.coherent = true + const child: AgentSessionOptionsResult = { + models: [{ id: 'A', label: 'Old child A', isDefault: false, efforts: EFFORTS }], + current: { model: 'A', effort: 'low', permissionMode: 'ask', defaulted: [] } + } + let raw: Listed = listed([{ id: 'A', label: 'Installed A' }]) + const next = deferred() + mocks.call.mockImplementation( + (_target: unknown, method: string, params: { waitForListing?: boolean }) => + method === 'agentSession.options' + ? Promise.resolve(child) + : method === 'agentSession.modelCatalog' + ? params.waitForListing + ? next.promise + : Promise.resolve(raw) + : new Promise(() => {}) + ) + const view = renderHook(() => + useStructuredAgentSessionOptions({ + agent: 'codex', + sessionId: 'session-1', + target: LOCAL_TARGET, + transportEnabled: true, + isVisible: true, + providerVisible: true, + fence: 1, + turnId: null, + unloadedTurnRevisions: undefined, + mutate: vi.fn() + }) + ) + const effort = () => view.result.current.optionSnapshot.find((row) => row.id === 'effort') + await waitFor(() => expect(effort()?.settable).toBe(true)) + raw = { ...raw, listingInProgress: true } + await act(async () => view.result.current.optionSurface.demandModelCatalog?.()) + expect(effort()?.settable).toBe(false) + await act(async () => + next.resolve({ + ...raw, + listingInProgress: undefined, + models: raw.models.map((row) => ({ ...row, efforts: [EFFORTS[1]!] })) + }) + ) + await waitFor(() => + expect(effort()).toMatchObject({ + settable: true, + kind: { currentValue: 'low', choices: expect.arrayContaining([EFFORTS[1]]) } + }) + ) + expect(view.result.current.sendSource.optionStateRef.current.record.values).toMatchObject({ + model: { value: 'A' }, + effort: { value: 'low' } + }) + view.unmount() + }) + it('codex: the saved list names a saved pick and its effort on the first frame', async () => { catalogAnswers(() => Promise.resolve(CODEX_LIST)) await preloadHostModelCatalogSnapshots(LOCAL_TARGET, ['codex']) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx index 479563a10b8..da6e34ca58b 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx @@ -50,11 +50,11 @@ function permission(snapshot: readonly SessionOptionDescriptor[]): string | null return entry?.kind.type === 'select' ? entry.kind.currentValue : null } -function renderReopened(fence: number) { +function renderReopened(fence: number, agent: 'codex' | 'claude' = 'codex') { return renderHook( (props: { fence: number; stamp: string }) => useStructuredAgentSessionOptions({ - agent: 'codex', + agent, sessionId: 'session-1', target: LOCAL_TARGET, transportEnabled: true, @@ -142,6 +142,53 @@ describe('a permission pick on this device', () => { await act(async () => first.resolve({ options: { permissionMode: 'full-access' } })) expect(permission(result.current.optionSnapshot)).toBe('auto') unmount() + + // A permission commit clears generic pendingId while the model request still runs. + let model = 'model-a' + mocks.call.mockImplementation((_target: unknown, method: string) => + Promise.resolve( + method === 'agentSession.options' + ? { + models: ['model-a', 'model-b'].map((id) => ({ + id, + label: id, + isDefault: false, + efforts: [], + supportsAutoMode: true + })), + current: { model, permissionMode: model === 'model-a' ? 'ask' : 'full-access' } + } + : { origin: 'unknown' } + ) + ) + const modelWrite = Promise.withResolvers<{ options: Record }>() + const modeWrite = Promise.withResolvers<{ options: Record }>() + vi.mocked(mutate).mockReturnValueOnce(modelWrite.promise).mockReturnValueOnce(modeWrite.promise) + const claude = renderReopened(1, 'claude') + await waitFor(() => expect(permission(claude.result.current.optionSnapshot)).toBe('ask')) + const choices = (snapshot: readonly SessionOptionDescriptor[]) => { + const kind = snapshot.find((row) => row.id === 'permissionMode')?.kind + return kind?.type === 'select' ? kind.choices.map((choice) => choice.value) : [] + } + expect(choices(claude.result.current.optionSnapshot)).toContain('auto') + const captured = claude.result.current.setStructuredOption + await act(async () => { + void captured('model', 'model-b') + }) + await act(async () => { + void captured('permissionMode', 'full-access') + }) + expect(permission(claude.result.current.optionSnapshot)).toBe('full-access') + expect(choices(claude.result.current.optionSnapshot)).not.toContain('auto') + expect(await captured('permissionMode', 'auto')).toBe(false) + const returned = await claude.result.current.optionSurface.setOption('permissionMode', 'auto') + expect(choices(returned.snapshot)).not.toContain('auto') + expect(vi.mocked(mutate)).toHaveBeenCalledTimes(4) + model = 'model-b' + await act(async () => modeWrite.resolve({ options: { permissionMode: 'full-access' } })) + await act(async () => modelWrite.resolve({ options: { model } })) + expect(choices(claude.result.current.optionSnapshot)).toContain('auto') + claude.unmount() }) it("shows the host's mode again when the write is refused", async () => { 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 c337c7d83f1..c5aed2a00d9 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 @@ -270,24 +270,34 @@ describe('useStructuredAgentSessionOptions', () => { } }) - it('lets the live options result win over a later host catalog answer', async () => { - let settleCatalog!: (value: unknown) => void + it('refreshes inventory after a live answer while keeping the chat choice', async () => { + let settleCatalog: ((value: unknown) => void) | undefined answer({ - options: () => Promise.resolve(LIVE_OPTIONS), + options: () => + Promise.resolve( + settleCatalog + ? { ...LIVE_OPTIONS, models: [...HOST_CATALOG.models, ...LIVE_OPTIONS.models] } + : LIVE_OPTIONS + ), modelCatalog: () => new Promise((resolve) => (settleCatalog = resolve)) }) const { result, unmount } = renderOptions(ATTACHED, mutateWith(async () => null).mutate) await waitFor(() => expect(currentValue(result.current.optionSnapshot, 'model')).toBe('gpt-5.5') ) - settleCatalog(HOST_CATALOG) - await tick() + await act(async () => { + if (!settleCatalog) { + throw new Error('Host catalog request has not started') + } + settleCatalog(HOST_CATALOG) + await tick() + }) const model = descriptor(result.current.optionSnapshot, 'model') expect(model?.kind.type === 'select' ? model.kind.currentValue : null).toBe('gpt-5.5') expect( model?.kind.type === 'select' && model.kind.choices.some((choice) => choice.value === 'gpt-hosted') - ).toBe(false) + ).toBe(true) unmount() }) @@ -499,7 +509,7 @@ describe('useStructuredAgentSessionOptions', () => { await result.current.setStructuredOption('model', 'gpt-5.6-luna') }) expect(setOptionCalls(calls)).toEqual([{ key: 'model', value: 'gpt-5.6-luna' }]) - expect(mocks.hold).toHaveBeenCalledTimes(1) + expect(mocks.hold).not.toHaveBeenCalled() unmount() }) }) @@ -507,6 +517,7 @@ describe('useStructuredAgentSessionOptions', () => { describe('before the provider starts', () => { it('keeps a picked effort visible after a model pick during startup', async () => { answer({ + modelCatalog: async () => ({ origin: 'unknown' }), options: async () => ({ models: ['opus', 'sonnet'].map((id) => ({ id, 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 86a611c6102..cb772241da4 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 @@ -1,9 +1,6 @@ import { toast } from 'sonner' -import { useCallback, useMemo } from 'react' -import type { - AgentSessionOptionResult, - AgentSessionOptionsResult -} from '../../../../shared/agent-session-wire' +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' +import type { AgentSessionOptionResult } from '../../../../shared/agent-session-wire' import type { AgentType } from '../../../../shared/agent-status-types' import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' @@ -12,13 +9,10 @@ import { canSetStructuredAgentSessionOption, commitStructuredAgentSessionOptionValues, lockedStructuredAgentSessionOptionSnapshot, - structuredAgentSessionOptionPicks, - structuredAgentSessionOptionSnapshot, - type StructuredAgentSessionOptionState + structuredAgentSessionOptionSnapshot } from '../../../../shared/structured-agent-session-options' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' -import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' -import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option-settings-write' +import { useRememberSessionOptionPicks } from './use-structured-agent-session-option-picks' import { encodeStructuredAgentSessionOptionValue } from '../../../../shared/structured-agent-session-option-codec' import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' import { useHostModelCatalogUpgrade } from './use-host-model-catalog-upgrade' @@ -65,7 +59,6 @@ export function useStructuredAgentSessionOptions(args: { 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. - const acceptsPicks = (!transportEnabled || fence !== null) && launch?.seedPending !== true const optionCatalog = useMemo(() => structuredAgentSessionSeedCatalog(agent), [agent]) const { optionView, @@ -76,8 +69,9 @@ export function useStructuredAgentSessionOptions(args: { pendingOptionsRef, optionMutationGeneration, updateOptionState, - applyOptionsAnswer, - rereadOptions, + receiveCatalog, + beginOptionWrite, + isCurrentContext, conversationSupport } = useStructuredAgentSessionOptionState({ agent, @@ -99,6 +93,11 @@ export function useStructuredAgentSessionOptions(args: { unloadedTurnRevisions: args.unloadedTurnRevisions }) + const awaitingOptions = + conversationSupport?.sessionId === sessionId && conversationSupport.fence !== fence + const acceptsPicks = + (!transportEnabled || fence !== null) && launch?.seedPending !== true && !awaitingOptions + const hostCatalog = useHostModelCatalogUpgrade({ agent, sessionId, @@ -115,25 +114,18 @@ export function useStructuredAgentSessionOptions(args: { ...(args.providerRunning ? { providerRunning: true } : {}), activeOptionRecordRef, updateOptionState, - onListingLanded: rereadOptions + receiveCatalog }) - // What a settled pick must remember so the next launch starts where the user left off. - const rememberOptionPicks = useCallback( - (view: StructuredAgentSessionOptionState, committed: Readonly>) => { - const picks = structuredAgentSessionOptionPicks(view, committed) - if (picks.length > 0) { - void enqueueSessionOptionSettingsWrite(target, { type: 'apply-picks', agent, picks }) - } - }, - [agent, target] - ) + const rememberOptionPicks = useRememberSessionOptionPicks(target, agent) const sendStructuredOption = useCallback( async (id: string, encoded: string): Promise => { const currentState = optionStateRef.current const targetRecord = currentState.record + const finishWrite = beginOptionWrite(id) const mutationGeneration = ++optionMutationGeneration.current - const sameRecord = (): boolean => activeOptionRecordRef.current === targetRecord + const sameRecord = (): boolean => + isCurrentContext() && activeOptionRecordRef.current === targetRecord const isCurrent = (): boolean => sameRecord() && optionMutationGeneration.current === mutationGeneration const currentPick = (): boolean => isCurrent() || pendingOptionsRef.current[id] === encoded @@ -173,22 +165,6 @@ export function useStructuredAgentSessionOptions(args: { if (sameRecord() && pendingOptionsRef.current[id] === encoded) { delete pendingOptionsRef.current[id] } - // A settled write, or a shown one that failed, reads back what the host now holds. - if (isCurrent() && (result || shownAtOnce)) { - void callStructuredAgentSession( - target, - 'agentSession.options', - { sessionId } - ) - .then((refreshed) => { - if (isCurrent()) { - updateOptionState((latest) => - latest.record === targetRecord ? applyOptionsAnswer(latest, refreshed) : latest - ) - } - }) - .catch(() => {}) - } if (isCurrent()) { updateOptionState((current) => current.record === targetRecord && current.pendingId === id @@ -196,11 +172,13 @@ export function useStructuredAgentSessionOptions(args: { : current ) } + finishWrite(isCurrent() && Boolean(result || shownAtOnce)) } }, [ activeOptionRecordRef, - applyOptionsAnswer, + beginOptionWrite, + isCurrentContext, launchSeedOptions, mutate, optionMutationGeneration, @@ -209,8 +187,6 @@ export function useStructuredAgentSessionOptions(args: { pendingOptionsRef, providerStarting, rememberOptionPicks, - sessionId, - target, updateOptionState ] ) @@ -227,12 +203,17 @@ export function useStructuredAgentSessionOptions(args: { }, [launchSeedOptions, optionStateRef, optionView, rememberOptionPicks] ) - const optionSnapshot = useMemo(() => { - const snapshot = structuredAgentSessionOptionSnapshot( - optionView(optionState, launchSeedOptions, held) - ) - return acceptsPicks ? snapshot : lockedStructuredAgentSessionOptionSnapshot(snapshot) - }, [acceptsPicks, held, launchSeedOptions, optionState, optionView]) + const lockSnapshotRef = useRef( + (snapshot: ReturnType) => snapshot + ) + const lockSnapshot = (snapshot: ReturnType) => + !acceptsPicks ? lockedStructuredAgentSessionOptionSnapshot(snapshot) : snapshot + useLayoutEffect(() => { + lockSnapshotRef.current = lockSnapshot + }) + const optionSnapshot = lockSnapshot( + structuredAgentSessionOptionSnapshot(optionView(optionState, launchSeedOptions, held)) + ) const setStructuredOption = useCallback( async (id: string, value: string | boolean): Promise => { const view = optionView(optionStateRef.current, launchSeedOptions, held) @@ -242,9 +223,15 @@ export function useStructuredAgentSessionOptions(args: { } const encoded = encodeStructuredAgentSessionOptionValue(id, value) if ( + !isCurrentContext() || !optionCatalog || encoded === null || - !canSetStructuredAgentSessionOption(pickState, id, value) + !canSetStructuredAgentSessionOption( + pickState, + id, + value, + lockSnapshotRef.current(structuredAgentSessionOptionSnapshot(view)) + ) ) { return false } @@ -261,6 +248,7 @@ export function useStructuredAgentSessionOptions(args: { held, launchSeedOptions, optionCatalog, + isCurrentContext, optionStateRef, optionView, pendingOptionsRef, @@ -277,8 +265,10 @@ export function useStructuredAgentSessionOptions(args: { // Read, not rendered: a pick the launch just took is not in this render's props yet. const currentHeld = getStructuredAgentSessionLaunchSelection(sessionId)?.held return { - snapshot: structuredAgentSessionOptionSnapshot( - optionView(optionStateRef.current, launchSeedOptions, currentHeld ?? NO_HELD_OPTIONS) + snapshot: lockSnapshotRef.current( + structuredAgentSessionOptionSnapshot( + optionView(optionStateRef.current, launchSeedOptions, currentHeld ?? NO_HELD_OPTIONS) + ) ) } }, @@ -287,11 +277,12 @@ export function useStructuredAgentSessionOptions(args: { const optionSurface = useMemo( () => ({ getSnapshot: () => optionSnapshot, + demandModelCatalog: hostCatalog.demandModelCatalog, setOption, invokeAction: async () => ({ snapshot: optionSnapshot }), subscribe: () => () => {} }), - [setOption, optionSnapshot] + [setOption, optionSnapshot, hostCatalog.demandModelCatalog] ) const support = diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx index 84c3897fcfc..1c0aa2dfa48 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx @@ -119,7 +119,9 @@ describe('useStructuredAgentSession provisional launch gate', () => { mocks.pending = [] readState = sessionState() mocks.send.mockReturnValue(true) - mocks.call.mockResolvedValue(OPTIONS) + mocks.call.mockImplementation(async (_target, method) => + method === 'agentSession.modelCatalog' ? { origin: 'unknown' } : OPTIONS + ) resetHostModelCatalogSnapshotsForTests() }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx index d18fad4a2a5..ed8eb30ceac 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx @@ -113,14 +113,6 @@ const FAST_OPTIONS = { current: { ...OPTIONS.current, fastMode: false, confirmed: ['fastMode'] } } -function deferred() { - let resolve!: (value: T) => void - const promise = new Promise((accept) => { - resolve = accept - }) - return { promise, resolve } -} - describe('useStructuredAgentSession working state', () => { beforeEach(() => { vi.clearAllMocks() @@ -671,9 +663,12 @@ describe('useStructuredAgentSession options', () => { }) it('discards an option read that began before a Fast mutation', async () => { - const staleRead = deferred() + const staleRead = Promise.withResolvers() let optionReads = 0 mocks.call.mockImplementation((_target, method) => { + if (method === 'agentSession.modelCatalog') { + return Promise.resolve({ origin: 'unknown' }) + } if (method === 'agentSession.options') { optionReads += 1 if (optionReads === 1) { diff --git a/src/renderer/src/web/web-runtime-client-capabilities.ts b/src/renderer/src/web/web-runtime-client-capabilities.ts index b994f1653ea..539bebff1f7 100644 --- a/src/renderer/src/web/web-runtime-client-capabilities.ts +++ b/src/renderer/src/web/web-runtime-client-capabilities.ts @@ -1,3 +1,4 @@ +import { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from '../../../shared/structured-agent-session-surface-capabilities' import { AGENT_SESSION_CURRENT_REQUESTS_CLIENT_CAPABILITY } from '../../../shared/agent-session-current-requests-capability' import { AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, @@ -20,6 +21,7 @@ import { TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY } from '../../.. /** What the browser client advertises in its handshake with the host it is paired to. */ export const WEB_RUNTIME_CLIENT_CAPABILITIES = [ + AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_CURRENT_REQUESTS_CLIENT_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index b79247ec1d6..bda64a09e92 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -1,3 +1,4 @@ +import { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from '../../../shared/structured-agent-session-surface-capabilities' import { pairedRuntimeConnectionFromOffer } from '../../../shared/paired-runtime-connection' import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest' import type { PairedE2EEClientHandshake } from '../../../shared/paired-e2ee-client-handshake' @@ -130,6 +131,7 @@ describe('WebRuntimeClient', () => { type: 'e2ee_auth', deviceToken: 'token', clientCapabilities: [ + AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_CURRENT_REQUESTS_CLIENT_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, diff --git a/src/shared/agent-model-catalog-ownership.ts b/src/shared/agent-model-catalog-ownership.ts new file mode 100644 index 00000000000..6cc93e0d6ed --- /dev/null +++ b/src/shared/agent-model-catalog-ownership.ts @@ -0,0 +1,7 @@ +export function independentAgentModelCatalog(agent: string): boolean { + return agent === 'claude' || agent === 'codex' +} + +export function usesInstalledAgentModelCatalog(agent: string, coherentCatalog?: boolean): boolean { + return coherentCatalog === true && independentAgentModelCatalog(agent) +} diff --git a/src/shared/agent-session-effort-levels.ts b/src/shared/agent-session-effort-levels.ts new file mode 100644 index 00000000000..051cf693455 --- /dev/null +++ b/src/shared/agent-session-effort-levels.ts @@ -0,0 +1,10 @@ +import type { AgentSessionModelOption } from './agent-session-wire' + +/** Unknown metadata admits a scalar; an explicit empty menu admits none. */ +export function agentSessionEffortLevels( + model: Pick | undefined +): ReadonlySet | null { + return !model || model.effortsKnown === false + ? null + : new Set(model.efforts.map((choice) => choice.value)) +} diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index fc6bba7edb8..4001d73d5bf 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -390,6 +390,8 @@ export type AgentSessionOptionChoice = { } export type AgentSessionModelOption = { + resolvedModel?: string | null + effortsKnown?: false id: string label: string description?: string diff --git a/src/shared/electron-remote-runtime-client-capabilities.ts b/src/shared/electron-remote-runtime-client-capabilities.ts index 21f3c133e71..deb5cc59046 100644 --- a/src/shared/electron-remote-runtime-client-capabilities.ts +++ b/src/shared/electron-remote-runtime-client-capabilities.ts @@ -1,3 +1,4 @@ +import { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from './structured-agent-session-surface-capabilities' import { AGENT_SESSION_CURRENT_REQUESTS_CLIENT_CAPABILITY } from './agent-session-current-requests-capability' import { TERMINAL_IMAGE_VIEWER_CAPABILITY } from './terminal-image-viewer-capability' import { @@ -24,6 +25,7 @@ import { AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY } from './agent-session- // Electron clients can decode client-hosted page placement; becoming a page // host still requires the separate authenticated browser-client lease. export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ + AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY, ...NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, TERMINAL_IMAGE_VIEWER_CAPABILITY, AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY, diff --git a/src/shared/host-model-listing-waits.ts b/src/shared/host-model-listing-waits.ts new file mode 100644 index 00000000000..6670990b2d1 --- /dev/null +++ b/src/shared/host-model-listing-waits.ts @@ -0,0 +1,81 @@ +import type { AgentSessionModelCatalogResult } from './agent-session-wire' + +// One waiting catalog read per chat, shared by every mount and effect run of that chat's picker: +// a remote read cannot be withdrawn once sent, so a re-run joins the one in flight instead of +// sending another. Bounded replies follow accepted work while a picker remains interested. + +/** Null when the read failed or timed out. */ +export type HostModelListingJoiner = (catalog: AgentSessionModelCatalogResult | null) => void + +type ListingWait = { + joiners: Set + continuation: ReturnType | null +} +type HostModelListingTransport = { close: () => void } +const waits = new Map() +const ownerWaits = new WeakMap>() + +function listingWaits(owner?: HostModelListingTransport): Map { + if (!owner) { + return waits + } + let owned = ownerWaits.get(owner) + if (!owned) { + owned = new Map() + ownerWaits.set(owner, owned) + } + return owned +} + +/** Joins the chat's waiting read, started by `read` only when none is in flight. Returns `leave`. */ +export function joinHostModelListingWait( + key: string, + read: () => Promise, + onSettled: HostModelListingJoiner, + owner?: HostModelListingTransport +): () => void { + const waits = listingWaits(owner) + let wait = waits.get(key) + if (!wait) { + const started: ListingWait = { joiners: new Set(), continuation: null } + wait = started + waits.set(key, started) + const settle = (catalog: AgentSessionModelCatalogResult | null): void => { + try { + for (const joiner of started.joiners) { + joiner(catalog) + } + } finally { + if (catalog?.listingInProgress && started.joiners.size > 0) { + // A fast host reply must not turn a queued listing into a request spin. + started.continuation = setTimeout(run, 1_000) + } else { + waits.delete(key) + } + } + } + const run = (): void => { + started.continuation = null + new Promise((resolve) => resolve(read())).then(settle, () => + settle(null) + ) + } + run() + } + wait.joiners.add(onSettled) + const joined = wait + return () => { + joined.joiners.delete(onSettled) + if (joined.joiners.size === 0 && joined.continuation !== null) { + clearTimeout(joined.continuation) + waits.delete(key) + } + } +} + +export function isHostModelListingWaitInFlight( + key: string, + owner?: HostModelListingTransport +): boolean { + return listingWaits(owner).has(key) +} diff --git a/src/shared/native-chat-session-options.ts b/src/shared/native-chat-session-options.ts index 150786f1431..eeec124e33c 100644 --- a/src/shared/native-chat-session-options.ts +++ b/src/shared/native-chat-session-options.ts @@ -124,6 +124,7 @@ export type NativeChatSessionOptionSettingsMutation = | { type: 'clear-model-if-missing'; agent: AgentType; availableModelIds: readonly string[] } export type SessionOptionsSurface = { + demandModelCatalog?: () => void getSnapshot(): SessionOptionDescriptor[] /** Apply an absolute target; known flip-only options use their tracked baseline. */ setOption(id: string, value: SessionOptionValue): Promise diff --git a/src/shared/structured-agent-session-discovered-model.ts b/src/shared/structured-agent-session-discovered-model.ts index 6419e85e9cb..54b24cc27e0 100644 --- a/src/shared/structured-agent-session-discovered-model.ts +++ b/src/shared/structured-agent-session-discovered-model.ts @@ -4,7 +4,7 @@ import type { AgentSessionOptionChoice, AgentSessionOptionsResult } from './agen // The picker options one listed model offers, built from what its host reported. function effortOption(model: AgentSessionOptionsResult['models'][number]): CatalogOption | null { - if (model.efforts.length <= 1) { + if (model.efforts.length === 0) { return null } return { 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 dbdd90881ed..d6b93e71f65 100644 --- a/src/shared/structured-agent-session-first-frame-picker.spec.ts +++ b/src/shared/structured-agent-session-first-frame-picker.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import type { AgentSessionModelCatalogResult } from './agent-session-wire' +import { independentAgentModelCatalog } from './agent-model-catalog-ownership' import { structuredAgentSessionSeedCatalog } from './structured-agent-session-seed-catalog' import { applyStructuredAgentSessionModelCatalog, @@ -124,9 +125,22 @@ describe('the first frame of every agent’s picker', () => { }) expect(live.catalogSource).toBe('live') expect(live.catalog?.models.map((model) => model.id)).toEqual(['model-live']) - // A later host answer never downgrades what the session reported. + // A later host answer never downgrades what the session reported on older hosts. expect( applyStructuredAgentSessionModelCatalog(live, seed, HOST_CATALOG, { newLaunch: true }) ).toBe(live) + const refreshed = applyStructuredAgentSessionModelCatalog(live, seed, HOST_CATALOG, { + newLaunch: true, + coherentCatalog: true + }) + if (independentAgentModelCatalog(agent)) { + expect(refreshed.catalog?.models.map((model) => model.id)).toContain('model-listed') + expect(refreshed.record).toBe(live.record) + expect(currentValue(structuredAgentSessionOptionSnapshot(refreshed), 'model')).toBe( + 'model-live' + ) + } else { + expect(refreshed).toBe(live) + } }) }) diff --git a/src/shared/structured-agent-session-model-catalog-application.test.ts b/src/shared/structured-agent-session-model-catalog-application.test.ts index 8707fb11613..214a42fd025 100644 --- a/src/shared/structured-agent-session-model-catalog-application.test.ts +++ b/src/shared/structured-agent-session-model-catalog-application.test.ts @@ -1,12 +1,16 @@ import { describe, expect, it } from 'vitest' +import { structuredAgentSessionSendSelection } from './structured-agent-session-option-view' import { getAgentSessionOptionCatalog } from './agent-session-option-catalog' +import { structuredAgentSessionSeedCatalog } from './structured-agent-session-seed-catalog' import type { AgentSessionModelCatalogResult } from './agent-session-wire' import { applyStructuredAgentSessionModelCatalog, applyStructuredAgentSessionOptions, createStructuredAgentSessionOptionState, settleStructuredAgentSessionBuiltinCatalog, - structuredAgentSessionOptionSnapshot + structuredAgentSessionOptionSnapshot, + canSetStructuredAgentSessionOption, + commitStructuredAgentSessionOption } from './structured-agent-session-options' const SEED = getAgentSessionOptionCatalog('codex')! @@ -123,12 +127,9 @@ describe('structured option state from the host model catalog', () => { expect(silent.valueSource).toBe('unknown') }) - it('settles on the built-in list on an unknown or empty host answer, and keeps a host list', () => { + it('settles on the built-in list on an unknown host answer, and keeps a host list', () => { const seeded = createStructuredAgentSessionOptionState('codex', SEED) - for (const answer of [ - { origin: 'unknown' as const }, - { origin: 'probe' as const, models: [], fetchedAt: 1 } - ]) { + for (const answer of [{ origin: 'unknown' as const }]) { const settled = applyStructuredAgentSessionModelCatalog(seeded, SEED, answer, LAUNCH) expect(settled.catalogSource).toBe('builtin') expect(settled.catalog).toBe(seeded.catalog) @@ -138,7 +139,23 @@ describe('structured option state from the host model catalog', () => { } }) - it('never downgrades a live catalog to a host one', () => { + it('keeps known empty inventory distinct from an unavailable observation', () => { + const seeded = createStructuredAgentSessionOptionState('codex', SEED) + const hosted = applyStructuredAgentSessionModelCatalog(seeded, SEED, HOST_CATALOG, LAUNCH) + const empty = applyStructuredAgentSessionModelCatalog( + hosted, + SEED, + { origin: 'probe', models: [], fetchedAt: 2 }, + { ...LAUNCH, coherentCatalog: true } + ) + expect(empty.catalog?.models).toEqual([]) + expect(empty.record).toBe(hosted.record) + expect( + applyStructuredAgentSessionModelCatalog(hosted, SEED, { origin: 'unknown' }, LAUNCH) + ).toBe(hosted) + }) + + it('refreshes inventory after a live answer while retaining the chat’s own selection', () => { const live = applyStructuredAgentSessionOptions( createStructuredAgentSessionOptionState('codex', SEED), SEED, @@ -148,9 +165,133 @@ describe('structured option state from the host model catalog', () => { } ) expect(live.catalogSource).toBe('live') - expect(applyStructuredAgentSessionModelCatalog(live, SEED, HOST_CATALOG, LAUNCH)).toBe(live) + const refreshed = applyStructuredAgentSessionModelCatalog(live, SEED, HOST_CATALOG, { + ...LAUNCH, + coherentCatalog: true + }) + for (const agent of ['grok', 'pi']) { + const sourced = { ...live, record: { ...live.record, agent } } + expect(applyStructuredAgentSessionModelCatalog(sourced, SEED, HOST_CATALOG, LAUNCH)).toBe( + sourced + ) + } + expect(refreshed.catalog?.models.map((row) => row.id)).toEqual(['gpt-hosted']) + expect(refreshed.record).toBe(live.record) const snapshot = structuredAgentSessionOptionSnapshot(live) const model = snapshot.find((descriptor) => descriptor.id === 'model')! expect(model.kind.type === 'select' ? model.kind.currentValue : null).toBe('gpt-live') }) + + it('shows Pi live choices immediately and keeps them after a late, narrower host list', () => { + const seed = structuredAgentSessionSeedCatalog('pi')! + const models = [ + { id: 'A', label: 'A', isDefault: true, efforts: [] }, + { id: 'B', label: 'B', isDefault: false, efforts: [] } + ] + const launch = { ...LAUNCH, coherentCatalog: true } + const live = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('pi', seed), + seed, + { models, current: { model: 'A', defaulted: [] } }, + launch + ) + const choices = (state: typeof live) => structuredAgentSessionOptionSnapshot(state)[0]?.kind + expect(choices(live)).toMatchObject({ choices: [{ value: 'A' }, { value: 'B' }] }) + const settled = applyStructuredAgentSessionModelCatalog( + live, + seed, + { origin: 'probe', models: [models[0]!], fetchedAt: 1 }, + launch + ) + expect(choices(settled)).toEqual(choices(live)) + expect(canSetStructuredAgentSessionOption(settled, 'model', 'B')).toBe(true) + }) + + it.each([ + ['codex', false], + ['pi', true] + ] as const)( + '%s keeps live controls usable after a warm listing, options, and terminal receipt', + (agent, coherentCatalog) => { + const seed = structuredAgentSessionSeedCatalog(agent)! + const efforts = [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + const models = [ + { + id: 'A', + label: 'A', + isDefault: true, + efforts, + serviceTiers: [{ value: 'priority', label: 'Fast' }] + }, + { id: 'B', label: 'B', isDefault: false, efforts, supportsFastMode: true } + ] + const launch = { ...LAUNCH, coherentCatalog } + const pending = applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState(agent, seed), + seed, + { origin: 'probe', models, fetchedAt: 1, listingInProgress: true }, + launch + ) + const live = applyStructuredAgentSessionOptions( + pending, + seed, + { + models, + fastModeSupport: { supported: true }, + current: { model: 'A', effort: 'low', serviceTier: 'default', defaulted: [] } + }, + launch + ) + const settled = applyStructuredAgentSessionModelCatalog( + live, + seed, + { origin: 'probe', models: [models[0]!], fetchedAt: 2 }, + launch + ) + expect(settled.listingInProgress).not.toBe(true) + expect(canSetStructuredAgentSessionOption(settled, 'effort', 'high')).toBe(true) + expect(canSetStructuredAgentSessionOption(settled, 'serviceTier', 'priority')).toBe(true) + const fast = commitStructuredAgentSessionOption(settled, 'model', 'B') + expect(canSetStructuredAgentSessionOption(fast, 'fastMode', true)).toBe(true) + } + ) +}) + +it('keeps one flat selection across inventory/answer arrival order and child replacement', () => { + for (const inventoryFirst of [true, false]) { + let state = commitStructuredAgentSessionOption( + applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState('claude', SEED), + SEED, + HOST_CATALOG, + LAUNCH + ), + 'model', + 'omitted' + ) + state = commitStructuredAgentSessionOption(state, 'effort', 'high') + const inventory = (current: typeof state) => + applyStructuredAgentSessionModelCatalog(current, SEED, HOST_CATALOG, LAUNCH) + const answer = (current: typeof state) => + applyStructuredAgentSessionOptions(current, SEED, { + models: HOST_CATALOG.models, + listedModelIds: ['gpt-hosted'], + current: { model: 'omitted', effort: 'high' } + }) + state = inventoryFirst ? answer(inventory(state)) : inventory(answer(state)) + expect(structuredAgentSessionSendSelection(state)).toMatchObject({ + model: 'omitted', + effort: 'high' + }) + // A replacement child drops live capabilities while the conversation keeps its flat record. + state = { ...createStructuredAgentSessionOptionState('claude', SEED), record: state.record } + state = inventory(state) + expect(structuredAgentSessionSendSelection(state)).toMatchObject({ + model: 'omitted', + effort: 'high' + }) + } }) diff --git a/src/shared/structured-agent-session-option-snapshot.ts b/src/shared/structured-agent-session-option-snapshot.ts index 0dce7a95ac0..9978ffcccfb 100644 --- a/src/shared/structured-agent-session-option-snapshot.ts +++ b/src/shared/structured-agent-session-option-snapshot.ts @@ -194,29 +194,37 @@ export function structuredAgentSessionOptionSnapshot( state: StructuredAgentSessionOptionState ): SessionOptionDescriptor[] { const permission = structuredAgentSessionPermissionDescriptor(state) - const options = modelOptionSnapshot(state) + const options = modelOptionSnapshot(state).map((option) => + (state.listingInProgress && ['effort', 'serviceTier', 'fastMode'].includes(option.id)) || + (state.fastModeRestricted && option.id === 'fastMode') + ? { ...option, settable: false } + : option + ) 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[] + snapshot: readonly SessionOptionDescriptor[], + capabilitiesOnly = false ): SessionOptionDescriptor[] { - return snapshot.map((descriptor) => ({ - ...descriptor, - settable: false, - disabledReason: 'available-after-session-start' - })) + return snapshot.map((descriptor) => + capabilitiesOnly && !['effort', 'serviceTier', 'fastMode'].includes(descriptor.id) + ? descriptor + : { ...descriptor, settable: false, disabledReason: 'available-after-session-start' } + ) } export function canSetStructuredAgentSessionOption( state: StructuredAgentSessionOptionState, id: string, - value: SessionOptionValue + value: SessionOptionValue, + snapshot = structuredAgentSessionOptionSnapshot(state) ): boolean { - const descriptor = structuredAgentSessionOptionSnapshot(state).find((entry) => entry.id === id) + const descriptor = snapshot.find((entry) => entry.id === id) return Boolean( state.catalog && + descriptor?.settable && state.pendingId !== id && ((typeof value === 'string' && descriptor?.kind.type === 'select' && diff --git a/src/shared/structured-agent-session-option-view.test.ts b/src/shared/structured-agent-session-option-view.test.ts index ebee6f88400..416f3b4cc79 100644 --- a/src/shared/structured-agent-session-option-view.test.ts +++ b/src/shared/structured-agent-session-option-view.test.ts @@ -82,7 +82,7 @@ describe('a Claude model its CLI no longer lists', () => { } }) - it('keeps a remembered model for a new chat, and offers no effort under a listed model with none', () => { + it('keeps a remembered model and effort, including under a model with no listed effort', () => { // The placeholder stands until the host answers with the chat's own model; create seeds it. expect(carried(withHostList(), remembered)).toBeUndefined() const haiku = { id: 'haiku', label: 'Haiku', isDefault: false, efforts: [] } @@ -91,7 +91,7 @@ describe('a Claude model its CLI no longer lists', () => { listedModelIds: ['opus[1m]', 'haiku'], current: { model: 'haiku', effort: 'high' } }) - expect(shown(listed, undefined, {})).toEqual({ model: 'haiku', effort: undefined }) + expect(shown(listed, undefined, {})).toEqual({ model: 'haiku', effort: 'high' }) }) }) diff --git a/src/shared/structured-agent-session-options.test.ts b/src/shared/structured-agent-session-options.test.ts index cbdc035fd63..72177778fd6 100644 --- a/src/shared/structured-agent-session-options.test.ts +++ b/src/shared/structured-agent-session-options.test.ts @@ -291,7 +291,13 @@ describe('structured agent session options', () => { ).toEqual([expect.objectContaining({ id: 'model', settable: false, valueSource: 'unknown' })]) // Before the host answers, and on the built-in list, no saved pick is named. expect(viewModel(cold, seed, {})).toBeUndefined() - expect(viewModel(settleStructuredAgentSessionBuiltinCatalog(cold), seed, {})).toBeUndefined() + const settled = settleStructuredAgentSessionBuiltinCatalog(cold, false) + expect(viewModel(settled, seed, {})).toBeUndefined() + expect(settleStructuredAgentSessionBuiltinCatalog(settled, false)).toBe(settled) + expect( + settleStructuredAgentSessionBuiltinCatalog({ ...settled, listingInProgress: true }, false) + .listingInProgress + ).toBe(false) // Derived only: the record itself never takes the seed. expect(seeded.record.values.model).toBeUndefined() const live = applyStructuredAgentSessionOptions(seeded, CODEX_SESSION_OPTION_CATALOG, { diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index ed5ae5720d1..efe9179454a 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -10,6 +10,7 @@ import { STRUCTURED_LAUNCH_SEED_OPTION_IDS } from './native-chat-session-option-defaults' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from './agent-chat-permission-mode' +import { usesInstalledAgentModelCatalog } from './agent-model-catalog-ownership' import { structuredAgentSessionDescribedModel, structuredAgentSessionShownModel @@ -49,7 +50,7 @@ export function structuredAgentSessionOptionCatalog( export type StructuredAgentSessionOptionState = { catalog: AgentSessionOptionCatalog | null - /** What produced `catalog`; a weaker source never replaces a stronger one. `seed` is the + /** What answered `catalog`, not proof of current child eligibility. `seed` is the * built-in list before the host answered (shown as the quiet placeholder); `builtin` is the * same list once the host said it has none, usable but naming nothing the host may replace. */ catalogSource: 'seed' | 'builtin' | 'host' | 'live' | null @@ -65,6 +66,8 @@ export type StructuredAgentSessionOptionState = { /** The permission mode the host or launch last stated; null shows no picker. */ permissionMode: string | null pendingId: string | null + listingInProgress?: boolean + fastModeRestricted?: boolean } /** With `seedCatalog`, the state holds the static seed from the first frame, but the picker shows @@ -85,9 +88,13 @@ export function createStructuredAgentSessionOptionState( /** The host answered with no list (none saved, a failed read, or an older host): the built-in * list becomes usable, still naming nothing until a host or session list does. */ export function settleStructuredAgentSessionBuiltinCatalog( - state: StructuredAgentSessionOptionState + state: StructuredAgentSessionOptionState, + listingInProgress = state.listingInProgress ): StructuredAgentSessionOptionState { - return state.catalogSource === 'seed' ? { ...state, catalogSource: 'builtin' } : state + const catalogSource = state.catalogSource === 'seed' ? 'builtin' : state.catalogSource + return catalogSource === state.catalogSource && state.listingInProgress === listingInProgress + ? state + : { ...state, catalogSource, listingInProgress } } function isListedModelRow(row: unknown): row is { id: string } { @@ -116,16 +123,20 @@ export function finishedListingIds(catalog: AgentSessionModelCatalogResult): str /** * Applies the host's stored model catalog: models only, no current selection * and no record writes, so nothing here reads as a committed value — the pick - * stays provisional until a live options result confirms it. A live catalog - * is never replaced by this. + * stays provisional until an options result confirms it. */ export function applyStructuredAgentSessionModelCatalog( state: StructuredAgentSessionOptionState, seed: AgentSessionOptionCatalog, catalog: AgentSessionModelCatalogResult, - options: { newLaunch: boolean } + options: { newLaunch: boolean; coherentCatalog?: boolean } ): StructuredAgentSessionOptionState { - if (state.catalogSource === 'live') { + const installed = usesInstalledAgentModelCatalog(state.record.agent, options.coherentCatalog) + const listingInProgress = installed && catalog.listingInProgress === true + if (Boolean(state.listingInProgress) !== listingInProgress) { + state = { ...state, listingInProgress } + } + if (state.catalogSource === 'live' && !installed) { return state } const models = @@ -134,7 +145,7 @@ export function applyStructuredAgentSessionModelCatalog( : catalog.models.map((model) => discoveredModel(model, catalog.fastModeSupport?.supported === true) ) - if (catalog.origin === 'unknown' || models.length === 0) { + if (catalog.origin === 'unknown' || (!installed && models.length === 0)) { return settleStructuredAgentSessionBuiltinCatalog(state) } // The host says whether its listed default is what this launch runs; an older host never says, @@ -147,11 +158,12 @@ export function applyStructuredAgentSessionModelCatalog( // `isDefault` came from a real listing, so a launch's CLI default is nameable — // as a provisional `default`-source value, never a confirmed one. catalog: { - ...seed, + ...(state.catalogSource === 'live' ? (state.catalog ?? seed) : seed), models, ...(namesDefault ? { defaultModelIsCliDefault: true } : {}) }, - catalogSource: 'host' + ...(installed ? { verifiedModelIds: finishedListingIds(catalog) } : {}), + catalogSource: state.catalogSource === 'live' ? 'live' : 'host' } } @@ -161,9 +173,10 @@ export function applyStructuredAgentSessionOptions( 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 } + launch: { newLaunch: boolean; coherentCatalog?: boolean } = { newLaunch: false } ): StructuredAgentSessionOptionState { const { current } = result + const installed = usesInstalledAgentModelCatalog(state.record.agent, launch.coherentCatalog) // A value the host supplied from a catalog default is context for the picker, not the chat's own. replaceStructuredAgentSessionAnsweredValues( state.record, @@ -182,11 +195,19 @@ export function applyStructuredAgentSessionOptions( ) return { ...state, - catalog: structuredAgentSessionOptionCatalog(seed, result), - catalogSource: 'live', + catalog: installed ? state.catalog : structuredAgentSessionOptionCatalog(seed, result), + fastModeRestricted: + installed && + result.fastModeSupport?.supported === false && + result.fastModeSupport.reason !== 'model-not-supported', + catalogSource: installed && state.catalogSource === 'seed' ? 'seed' : 'live', olderHost: current.defaulted === undefined ? true : undefined, defaultsUnsent: current.defaulted !== undefined && !launch.newLaunch ? true : undefined, - verifiedModelIds: result.listedModelIds?.length ? result.listedModelIds : undefined, + verifiedModelIds: installed + ? state.verifiedModelIds + : result.listedModelIds?.length + ? result.listedModelIds + : undefined, permissionMode: result.current.permissionMode ?? null } } diff --git a/src/shared/structured-agent-session-permission-option.test.ts b/src/shared/structured-agent-session-permission-option.test.ts index 235a2735d5d..c55d57a63f7 100644 --- a/src/shared/structured-agent-session-permission-option.test.ts +++ b/src/shared/structured-agent-session-permission-option.test.ts @@ -90,6 +90,14 @@ describe('the permission mode picker', () => { kind: { currentValue: 'auto' } }) expect(offered(false, 'auto')).toEqual(['ask', 'accept-edits', 'auto', 'full-access']) + const changing = { ...live('claude', { permissionMode: 'ask' }, true), pendingId: 'model' } + expect(permission(changing)?.kind).toMatchObject({ + choices: expect.not.arrayContaining([expect.objectContaining({ value: 'auto' })]) + }) + expect(permission({ ...changing, permissionMode: 'auto' })?.kind).toMatchObject({ + currentValue: 'auto', + choices: expect.arrayContaining([expect.objectContaining({ value: 'auto' })]) + }) }) // A new chat names no model: the host's catalog row for the CLI default decides, as the host's does. diff --git a/src/shared/structured-agent-session-permission-option.ts b/src/shared/structured-agent-session-permission-option.ts index 53f3e3e7cb4..a19f2dbfc00 100644 --- a/src/shared/structured-agent-session-permission-option.ts +++ b/src/shared/structured-agent-session-permission-option.ts @@ -70,6 +70,7 @@ export function structuredAgentSessionPermissionDescriptor(state: { catalog: AgentSessionOptionCatalog | null record: StructuredAgentSessionOptionRecord permissionMode: string | null + pendingId?: string | null }): SessionOptionDescriptor | null { const support = agentChatPermissionModes(state.record.agent) const current = support?.modes.find((mode) => mode === state.permissionMode) @@ -78,7 +79,8 @@ export function structuredAgentSessionPermissionDescriptor(state: { } const offered = offeredChatPermissionModes( support, - current === 'auto' || currentModelSupportsAutoMode(state.catalog, state.record) + current === 'auto' || + (state.pendingId !== 'model' && currentModelSupportsAutoMode(state.catalog, state.record)) ) return { id: AGENT_CHAT_PERMISSION_MODE_OPTION_ID, diff --git a/src/shared/structured-agent-session-surface-capabilities.ts b/src/shared/structured-agent-session-surface-capabilities.ts index d3f6758eeeb..158333cb4a6 100644 --- a/src/shared/structured-agent-session-surface-capabilities.ts +++ b/src/shared/structured-agent-session-surface-capabilities.ts @@ -45,7 +45,11 @@ export const STRUCTURED_AGENT_SESSION_CLI_SOURCE_RUNTIME_CAPABILITY = export const STRUCTURED_AGENT_SESSION_AVAILABILITY_RUNTIME_CAPABILITY = 'agent-session.structured.availability.v1' as const +export const AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY = + 'agent-session.coherent-model-catalog.v1' as const + export const STRUCTURED_AGENT_SESSION_SURFACE_RUNTIME_CAPABILITIES = [ + AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, diff --git a/src/shared/structured-agent-session-unlisted-effort.ts b/src/shared/structured-agent-session-unlisted-effort.ts index a52067f8bc7..b1fb71c566b 100644 --- a/src/shared/structured-agent-session-unlisted-effort.ts +++ b/src/shared/structured-agent-session-unlisted-effort.ts @@ -13,17 +13,29 @@ export function withUnlistedEffort( listed: readonly string[] | undefined, snapshot: SessionOptionDescriptor[] ): SessionOptionDescriptor[] { - // A listed model with no effort menu takes none: only an unlisted one keeps its own. if ( !modelId || - !listed || - listed.includes(modelId) || typeof effort?.value !== 'string' || - snapshot.some((entry) => entry.id === 'effort') + snapshot.some( + (entry) => + entry.id === 'effort' && + entry.kind.type === 'select' && + entry.kind.choices.some((choice) => choice.value === effort.value) + ) ) { return snapshot } - // Named as a listed model's effort row names the same level. + const offered = catalog.models + .find((model) => model.id === modelId) + ?.options.find((option) => option.id === 'effort') + if ( + listed?.includes(modelId) && + offered?.kind.type === 'select' && + offered.kind.choices.some((choice) => choice.value === effort.value) + ) { + return snapshot + } + // An old saved level remains visible without claiming it is offered now. const listedEfforts = catalog.models .flatMap((model) => model.options) .filter((option) => option.id === 'effort') @@ -43,5 +55,5 @@ export function withUnlistedEffort( transport: 'agent-session', settable: false } - return [...snapshot, descriptor] + return [...snapshot.filter((entry) => entry.id !== 'effort'), descriptor] } diff --git a/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts index 516263c0ec6..365b2be067e 100644 --- a/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts +++ b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts @@ -82,7 +82,7 @@ const LISTING = `${JSON.stringify({ ] } } -})}\n` +})}\n${JSON.stringify({ type: 'control_response', response: { subtype: 'success', request_id: 'orca-model-settings', response: { applied: { model: 'claude-opus-5-5[1m]' } } } })}\n` let dir: string let release: () => void @@ -216,8 +216,8 @@ describe('Approve for me on a new Claude chat', () => { const first = await hostCatalog(Promise.resolve()) await mocks.rpc(undefined, 'agentSession.modelCatalog', { waitForListing: true }) await first.persistence.flush() - // A restarted host loads the saved file: the chat opens warm. - await hostCatalog(new Promise(() => {})) + // A restarted host retains the rows and revalidates its configured default before naming it. + await hostCatalog(Promise.resolve()) const { result, unmount } = openNewChat() await waitFor(() => expect(offered(result.current.optionSnapshot)).toContain('auto')) unmount() diff --git a/tests/e2e/cross-version-wire/host-model-catalog-released-phone.unit.test.ts b/tests/e2e/cross-version-wire/host-model-catalog-released-phone.unit.test.ts new file mode 100644 index 00000000000..47ec2f4ee69 --- /dev/null +++ b/tests/e2e/cross-version-wire/host-model-catalog-released-phone.unit.test.ts @@ -0,0 +1,248 @@ +import '../../../src/main/runtime/rpc/unused-default-rpc-methods.test-fixture' +import { execFileSync } from 'node:child_process' +import { stripTypeScriptTypes } from 'node:module' +import { expect, it, vi } from 'vitest' +import { + call, + hostStub, + STRUCTURED_CLIENT +} from '../../../src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture' +import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' +import { createAgentModelCatalogService } from '../../../src/main/native-chat/agent-model-catalog/agent-model-catalog-service' +import { AgentModelCatalogStore } from '../../../src/main/native-chat/agent-model-catalog/agent-model-catalog-store' +import { + runAgentModelCatalogSession, + AgentModelCatalogProbeError +} from '../../../src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner' +import { createClaudeModelCatalogProbe } from '../../../src/main/claude/claude-model-catalog-probe' + +import { REPO_ROOT } from './release-checkout' + +// Local acceptance rig: execute the pinned source, with only React's effect scheduler and transport replaced. +function released(path: string, names: string[], dependencies: Record = {}) { + const source = execFileSync('git', ['show', `v1.4.229:${path}`], { + cwd: REPO_ROOT, + encoding: 'utf8' + }) + .replace( + 'constructor(private readonly options: RequestTrackerOptions) {}', + 'constructor(options: RequestTrackerOptions) { this.options = options; }' + ) + .replace( + /constructor\(\s*readonly code: string,[\s\S]*?super\(message\)\s*\}/, + 'constructor(code: string, message: string, data?: unknown) { super(message); this.code = code; this.data = data; }' + ) + const javascript = stripTypeScriptTypes(source) + .replace(/import[\s\S]*?from\s*['"][^'"]+['"];?/g, '') + .replace(/export /g, '') + return new Function( + ...Object.keys(dependencies), + `${javascript}; return { ${names.join(', ')} };` + )(...Object.values(dependencies)) +} + +it.each(['success', 'budget'] as const)( + 'released one-follow tracker accepts direct legacy %s with enterprise retry and cleanup under modern saturation', + async (mode) => { + vi.useFakeTimers() + const start = Date.now() + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + const attempts: boolean[] = [] + let closes = 0 + let output = [ + { + response: { + subtype: 'success', + request_id: 'orca-model-discovery', + response: { models: [{ value: 'opus', supportsFastMode: true }] } + } + }, + { + response: { + subtype: 'success', + request_id: 'orca-model-settings', + response: { applied: {} } + } + } + ] + .map((row) => JSON.stringify(row)) + .join('\n') + const probe = createClaudeModelCatalogProbe({ + resolveCommand: () => process.execPath, + resolveEnv: () => ({}), + resolveInheritedEnv: async () => ({}), + resolveAuthPolicy: () => ({ account: 'system' }), + runListing: (launch, options) => { + const strict = launch.args.includes('--strict-mcp-config') + attempts.push(strict) + return runAgentModelCatalogSession( + () => ({ + close: async () => { + if (!strict) { + await wait(6_000) + } + closes++ + } + }), + async () => { + await wait(strict ? 14_000 : mode === 'success' ? 14_000 : 60_000) + if (strict) { + throw new AgentModelCatalogProbeError('enterprise policy', 'exit', false, { + exitCode: 1, + stdout: '', + stderr: + 'You cannot use --strict-mcp-config when an enterprise MCP config is present' + }) + } + return output + }, + { label: 'released-acceptance', signal: options.signal } + ) + } + }) + const modern = vi.fn( + (signal?: AbortSignal) => + new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(new Error('stopped')), { once: true }) + }) + ) + const home = { variable: 'CLAUDE_CONFIG_DIR', path: '/qa-account' } + const scoped = Object.assign(probe, { + resolve: async (_home: unknown, cwd: string) => ({ + identity: { command: 'qa-modern', cwd }, + run: modern + }) + }) + const service = createAgentModelCatalogService({ + store: new AgentModelCatalogStore(), + getRecord: () => undefined, + drivesRecord: () => true, + resolveAccountHome: async () => home, + probes: { claude: scoped } + }) + setStructuredAgentSessionHost( + Object.assign(hostStub(), { + deps: { + modelCatalog: service, + store: { getRecord: () => undefined }, + adapter: {} + } + }) + ) + const budget = released('mobile/src/transport/rpc-request-budget.ts', [ + 'openRpcRequestBudget', + 'resolvePostConnectRequestTimeout' + ]) + const ambiguity = released('mobile/src/transport/rpc-delivery-ambiguity.ts', [ + 'markRpcDeliveryUnknown' + ]) + const { RpcClientRequestTracker } = released( + 'mobile/src/transport/rpc-client-request-tracker.ts', + ['RpcClientRequestTracker'], + { ...budget, ...ambiguity } + ) + let sequence = 0 + const frames: { params?: unknown }[] = [] + const receipts: boolean[] = [] + const tracker = new RpcClientRequestTracker({ + nextId: () => String(++sequence), + getState: () => 'connected', + waitForConnected: async () => {}, + sendFrame: (frame: { id: string; method: string; params?: unknown }) => { + frames.push(frame) + void call(frame.method, frame.params, STRUCTURED_CLIENT).then((response) => { + receipts.push(tracker.resolve({ ...response, id: frame.id })) + }) + return true + } + }) + const { callAgentSession } = released( + 'mobile/src/session/mobile-structured-agent-session-rpc.ts', + ['callAgentSession'] + ) + let cleanup: (() => void) | undefined + const applied: unknown[] = [] + let landed = 0 + const record = {} + const { useMobileHostModelCatalogUpgrade } = released( + 'mobile/src/session/use-mobile-host-model-catalog-upgrade.ts', + ['useMobileHostModelCatalogUpgrade'], + { + callAgentSession, + useEffect: (effect: () => () => void) => { + cleanup = effect() + }, + applyStructuredAgentSessionModelCatalog: ( + _state: unknown, + _seed: unknown, + catalog: unknown + ) => { + applied.push(catalog) + return { record } + }, + settleStructuredAgentSessionBuiltinCatalog: () => { + throw new Error('released caller timed out') + } + } + ) + try { + await Promise.all( + ['/1', '/2', '/3', '/4', '/5'].map((workspacePath) => + service.read({ agent: 'claude', workspacePath }) + ) + ) + expect(modern).toHaveBeenCalledTimes(2) + useMobileHostModelCatalogUpgrade({ + agent: 'claude', + client: tracker, + sessionId: 'released', + enabled: true, + newLaunch: false, + fence: 1, + optionCatalog: {}, + activeOptionRecordRef: { current: record }, + updateOptionState: (update: (state: { record: object }) => unknown) => update({ record }), + onListingLanded: () => landed++ + }) + await vi.advanceTimersByTimeAsync(0) + expect(frames).toHaveLength(2) + await vi.advanceTimersByTimeAsync(36_000) + expect(attempts).toEqual([true, false]) + expect(closes).toBe(2) + expect(receipts).toEqual([true, true]) + expect(tracker.size()).toBe(0) + expect(applied.at(-1)).not.toHaveProperty('listingInProgress') + expect(landed).toBe(mode === 'success' ? 1 : 0) + expect(Date.now() - start).toBeLessThan(45_000) + if (mode === 'success') { + cleanup?.() + await vi.advanceTimersByTimeAsync(120_001) + output = output.replace('opus', 'sonnet') + useMobileHostModelCatalogUpgrade({ + agent: 'claude', + client: tracker, + sessionId: 'released', + enabled: true, + newLaunch: false, + fence: 1, + optionCatalog: {}, + activeOptionRecordRef: { current: record }, + updateOptionState: (update: (state: { record: object }) => unknown) => update({ record }) + }) + await vi.advanceTimersByTimeAsync(0) + expect(frames).toHaveLength(3) + expect(applied.at(-1)).toMatchObject({ models: [{ id: 'opus' }] }) + await vi.advanceTimersByTimeAsync(34_000) + expect(frames).toHaveLength(3) + expect( + await service.read({ agent: 'claude', legacy: true, savedOnly: true }) + ).toMatchObject({ models: [{ id: 'sonnet' }] }) + } + } finally { + cleanup?.() + service.stop() + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + } +)