From d085427628be08ba065edc2e6a09a4c9b040f6a9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:42:27 -0700 Subject: [PATCH] Keep model choices coherent across Claude updates (#30107) * Use one model catalog per execution scope * Accept a missing session record in host scope * Merge duplicate launch-resolution import * Fix catalog typecheck errors * Align catalog tests with the one-list design * Type the integration fixture's send without a cast * Give the discovery test complete runtime dependencies * Fix model catalog completion and scoped configured display * Preserve model catalog authority across released clients * Fix mobile first-frame catalog fixture narrowing * Keep startup catalog persistence with one completion owner * fix: preserve chat selections across CLI model updates A newer CLI can omit a model that an existing chat still runs. Keep that chat's selected model and effort instead of replacing them from the available list. Discover one model list per host, account, launch command and folder, refreshing after two minutes. Keep current values and the running model's exact effort choices with its chat. Show only the known effort when Claude supplies no matching menu. Preserve older paired-client delivery and configured launch arguments. Make Codex use independent discovery and retain the established ACP and Pi paths. Tracked in STA-10947. * fix: keep the Pi options read within its file limit after main's growth Tracked in STA-10947. * fix: import the option snapshot's shared module once Tracked in STA-10947. * fix: keep Grok and Pi model menus and fast controls across restarts Restore the established ACP and Pi catalog route: newer listed models and qualified startup defaults stay visible, and genuine retained effort menus survive later reads. Keep exact-child Fast and Speed controls. Update the phone option contracts and the remaining stale expectations to the selection rules. Tracked in STA-10947. * fix: share each workspace's model list and keep live Grok and Pi menus New chats read their workspace's own model list through the one shared request for that workspace, so a second chat in the same folder starts on the model and effort the agent actually runs there, and a slow first answer can no longer overwrite a newer list. A running Grok, OpenCode or Pi chat keeps its live model menu when an older shared list arrives later. Update the tests that still expected the replaced behavior. Tracked in STA-10947. * fix: keep separate agent and chat model lists for Grok, OpenCode and Pi A brand-new Claude chat now offers the selected model's effort levels before the first message, as it did before. Grok, OpenCode, omp and Pi again keep the list read from the installed agent apart from the list a running chat reports, and each newer list replaces only its own source. So a chat shows the model the agent is actually running with that model's own effort menu, and an effort change can no longer act on an older model's menu. Saved model lists on WSL are readable again without starting the agent. Tracked in STA-10947. * fix: keep desktop catalog storage out of the phone's type graph and show Grok's late model answer The phone app's type check no longer pulls in desktop-only catalog storage. The model-list code no longer reaches the saved-catalog file through a type import. When a Grok model change times out and Grok later confirms the model, the chat shows the model Grok actually runs. Tracked in STA-10947. * Remove unrelated merge formatter and package manager churn * Clarify independent catalog ownership and refresh contracts * Declare picker demand callback as an effect dependency * Complete mobile catalog surface and catalog merge types * Complete catalog contract fixtures for current host types * fix: preserve catalog source contracts and account fallback * test: inject independent Codex discovery transport * test: remove accidental client fixture assertion * test: qualify the saved account fallback read * fix(chat): preserve catalog evidence and current child controls * Fix catalog CI types and mobile option readiness * Reconcile session controls after inventory changes * Fix catalog import and deferred test resolver types * Keep model capability observations separate from picker views * Use installed model choices for chat options * Fix model catalog test fixture contracts * Restore model option compatibility and stable settlement * Return a catalog reply in provisional launch fixture * Compose unreported live model defaults in the host * Preserve explicit replacement model on recovery send * fix(chat): use stable scoped catalogs on local desktop * test(chat): preserve generic catalog runner type * fix: scope catalogs to configured environment and keep saved reads passive * fix: satisfy catalog regression and controller CI checks --- ...-code-change-scope-released-phone.test.mjs | 19 + config/scripts/pr-code-change-scope.mjs | 8 + .../MobileNativeChatSessionOptionPickers.tsx | 9 + ...uctured-agent-session-host-support.test.ts | 5 + ...e-structured-agent-session-host-support.ts | 3 + .../use-mobile-host-model-catalog-upgrade.ts | 96 +++-- .../use-mobile-native-chat-controller.ts | 5 +- ...e-native-chat-session-option-controller.ts | 8 +- .../use-mobile-native-chat-session-options.ts | 2 + ...ructured-agent-options.first-frame.test.ts | 49 ++- .../use-mobile-structured-agent-options.ts | 217 ++++------- ...e-mobile-structured-agent-session.test.tsx | 14 +- .../use-mobile-structured-option-state.ts | 210 ++++++++++ .../use-mobile-structured-option-surface.ts | 13 +- .../mobile-runtime-client-capabilities.ts | 2 + .../acp-structured-host-live-listing.test.ts | 2 + .../claude/claude-model-catalog-probe.test.ts | 93 ++++- src/main/claude/claude-model-catalog-probe.ts | 181 ++++++--- .../claude-stream-json-connection.test.ts | 45 --- .../claude-structured-applied-options.test.ts | 14 +- .../claude-structured-catalog-listing.ts | 70 +--- ...aude-structured-configured-default.test.ts | 266 ------------- ...-structured-effort-default-at-rest.test.ts | 67 ++-- ...claude-structured-effort-reporting.test.ts | 108 +----- .../claude-structured-launch-resolution.ts | 2 +- .../claude/claude-structured-model-catalog.ts | 33 +- ...aude-structured-model-confirmation.test.ts | 71 +--- ...ude-structured-option-confirmation.test.ts | 2 +- .../claude/claude-structured-options.test.ts | 70 +--- src/main/claude/claude-structured-options.ts | 62 --- .../claude-structured-retired-model.test.ts | 40 +- ...e-structured-session-adapter-turns.test.ts | 24 +- .../claude-structured-session-adapter.ts | 11 +- .../claude-structured-session-options.ts | 106 +----- .../claude-structured-session-startup.ts | 33 +- .../codex/codex-configured-launch-defaults.ts | 25 +- .../codex/codex-model-catalog-probe.test.ts | 29 ++ src/main/codex/codex-model-catalog-probe.ts | 149 +++++--- .../codex/codex-structured-acquire-catalog.ts | 27 +- .../codex-structured-background-catalog.ts | 83 ---- .../codex/codex-structured-catalog-entry.ts | 36 +- .../codex-structured-launch-resolution.ts | 2 +- .../codex-structured-message-options.test.ts | 19 +- .../codex/codex-structured-message-options.ts | 17 +- .../codex/codex-structured-model-catalog.ts | 62 ++- ...odex-structured-service-tier-shown.test.ts | 81 +++- .../codex-structured-service-tier.test.ts | 109 +----- .../codex/codex-structured-service-tier.ts | 49 ++- .../codex-structured-session-adapter.test.ts | 35 +- .../codex/codex-structured-session-adapter.ts | 4 + ...structured-session-options-catalog.test.ts | 359 +++--------------- .../codex-structured-session-options.test.ts | 76 ++-- .../codex/codex-structured-session-options.ts | 120 ++---- .../codex/codex-structured-session-start.ts | 10 +- .../codex/codex-structured-session-state.ts | 10 +- .../desktop-renderer-runtime-capabilities.ts | 2 + .../agent-model-catalog-discovery.ts | 22 +- .../agent-model-catalog-entry.ts | 64 +++- .../agent-model-catalog-fingerprint.ts | 10 + ...odel-catalog-independent-discovery.test.ts | 186 +++++++++ .../agent-model-catalog-lister.ts | 162 ++++++++ .../agent-model-catalog-listing-waiters.ts | 19 +- .../agent-model-catalog-persistence.ts | 10 +- .../agent-model-catalog-prewarm.test.ts | 18 +- .../agent-model-catalog-prewarm.ts | 75 ++++ .../agent-model-catalog-scope.test.ts | 64 ++++ .../agent-model-catalog-scope.ts | 69 ++++ .../agent-model-catalog-service-contract.ts | 65 ++++ .../agent-model-catalog-service.test.ts | 233 ++++++++---- .../agent-model-catalog-service.ts | 290 +++++++------- .../agent-model-catalog-store.test.ts | 98 ++--- .../agent-model-catalog-store.ts | 180 +++++---- .../agent-model-catalog-unavailable.test.ts | 77 ++-- ...structured-agent-session-adapter-router.ts | 3 + .../structured-agent-session-adapter.ts | 24 +- ...ed-agent-session-conversation-stop.test.ts | 12 +- ...ed-agent-session-launch-permission.test.ts | 1 + .../structured-agent-session-option-intent.ts | 12 +- .../structured-agent-session-option-models.ts | 58 ++- ...uctured-agent-session-options-read.test.ts | 246 +++++++++++- .../structured-agent-session-options-read.ts | 142 ++++--- ...tured-agent-session-outgrown-child.test.ts | 1 + ...ured-agent-session-permission-mode.test.ts | 69 ++++ .../structured-agent-session-pick-reply.ts | 10 +- ...agent-session-startup-attempt-host.test.ts | 1 + .../structured-agent-session-thread-goal.ts | 18 +- .../runtime/agent-session-record-options.ts | 12 +- ...ude-structured-retired-saved-model.test.ts | 40 +- ...uctured-worker-launch-seed-options.test.ts | 3 +- ...uctured-agent-session-options-read.test.ts | 77 +++- .../structured-agent-session-options-read.ts | 14 +- ...nt-session-options-workspace-scope.test.ts | 50 ++- ...ripted-codex-model-catalog.test-fixture.ts | 31 ++ .../structured-launch-seed-listing.ts | 4 +- ...ured-agent-model-catalog-discovery.test.ts | 21 +- ...tructured-agent-model-catalog-discovery.ts | 12 +- ...ed-agent-model-catalog-first-frame.test.ts | 11 - .../structured-agent-model-catalog-wiring.ts | 20 +- ...ured-agent-new-chat-effort-at-rest.test.ts | 31 +- .../structured-agent-runtime-registrations.ts | 8 + ...ructured-agent-session-integration.test.ts | 16 +- ...ion-runtime-model-catalog-lifetime.test.ts | 1 + ...tured-agent-session-transport-overrides.ts | 3 + .../structured-agent-shell-environment.ts | 11 +- src/main/startup/login-shell-environment.ts | 5 + .../NativeChatSessionOptionPickers.test.tsx | 8 +- .../NativeChatSessionOptionPickers.tsx | 13 +- .../native-chat/host-model-listing-waits.ts | 45 --- .../use-host-model-catalog-upgrade.ts | 44 ++- ...e-structured-agent-session-option-picks.ts | 21 + ...e-structured-agent-session-option-state.ts | 111 ++++-- ...agent-session-options.first-frame.test.tsx | 76 +++- ...t-session-options.permission-mode.test.tsx | 51 ++- ...-structured-agent-session-options.test.tsx | 25 +- .../use-structured-agent-session-options.ts | 99 +++-- ...uctured-agent-session-provisional.test.tsx | 4 +- .../use-structured-agent-session.test.tsx | 13 +- .../web/web-runtime-client-capabilities.ts | 2 + .../src/web/web-runtime-client.test.ts | 2 + src/shared/agent-model-catalog-ownership.ts | 7 + src/shared/agent-session-effort-levels.ts | 10 + src/shared/agent-session-wire.ts | 2 + ...tron-remote-runtime-client-capabilities.ts | 2 + src/shared/host-model-listing-waits.ts | 81 ++++ src/shared/native-chat-session-options.ts | 1 + ...ructured-agent-session-discovered-model.ts | 2 +- ...d-agent-session-first-frame-picker.spec.ts | 16 +- ...-session-model-catalog-application.test.ts | 157 +++++++- ...tructured-agent-session-option-snapshot.ts | 26 +- ...ructured-agent-session-option-view.test.ts | 4 +- .../structured-agent-session-options.test.ts | 8 +- .../structured-agent-session-options.ts | 49 ++- ...ed-agent-session-permission-option.test.ts | 8 + ...uctured-agent-session-permission-option.ts | 4 +- ...ured-agent-session-surface-capabilities.ts | 4 + ...tructured-agent-session-unlisted-effort.ts | 24 +- ...claude-auto-mode-host-catalog.unit.test.ts | 6 +- ...-model-catalog-released-phone.unit.test.ts | 248 ++++++++++++ 138 files changed, 4362 insertions(+), 2684 deletions(-) create mode 100644 config/scripts/pr-code-change-scope-released-phone.test.mjs create mode 100644 mobile/src/session/use-mobile-structured-option-state.ts delete mode 100644 src/main/claude/claude-structured-configured-default.test.ts delete mode 100644 src/main/codex/codex-structured-background-catalog.ts create mode 100644 src/main/native-chat/agent-model-catalog/agent-model-catalog-independent-discovery.test.ts create mode 100644 src/main/native-chat/agent-model-catalog/agent-model-catalog-lister.ts create mode 100644 src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.ts create mode 100644 src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.test.ts create mode 100644 src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.ts create mode 100644 src/main/native-chat/agent-model-catalog/agent-model-catalog-service-contract.ts create mode 100644 src/main/runtime/scripted-codex-model-catalog.test-fixture.ts delete mode 100644 src/renderer/src/components/native-chat/host-model-listing-waits.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-option-picks.ts create mode 100644 src/shared/agent-model-catalog-ownership.ts create mode 100644 src/shared/agent-session-effort-levels.ts create mode 100644 src/shared/host-model-listing-waits.ts create mode 100644 tests/e2e/cross-version-wire/host-model-catalog-released-phone.unit.test.ts 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() + } + } +)