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
This commit is contained in:
Brennan Benson
2026-10-21 16:42:27 -07:00
committed by GitHub
parent 41fb3c7a9c
commit d085427628
138 changed files with 4362 additions and 2684 deletions
@@ -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)
})
+8
View File
@@ -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',
@@ -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(
@@ -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 })
@@ -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),
@@ -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<StructuredAgentSessionOptionRecord>
/** 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
}
@@ -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,
@@ -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<MobileNativeChatSessionOptionPickersProps | null>(
@@ -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
@@ -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<AgentSessionModelCatalogResult>): RpcClient {
function client(
catalog: () => Promise<AgentSessionModelCatalogResult>,
options?: () => Promise<AgentSessionOptionsResult>
): 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<typeof useMobileStructuredAgentOptions>
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()
})
})
@@ -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<StructuredAgentSessionHostSupport, 'permissionModes'> | 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<string | null>(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<AgentSessionOptionsResult>(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<typeof structuredAgentSessionShownSnapshot>) => snapshot
)
const lockSnapshot = (snapshot: ReturnType<typeof structuredAgentSessionShownSnapshot>) =>
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<boolean> => {
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<AgentSessionOptionsResult>(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 {
@@ -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'))
@@ -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<string | null>(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<void>()
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<AgentSessionOptionsResult>(
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
}
}
@@ -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<boolean>
/** The latest option state, which a settled pick's snapshot is read from. */
optionStateRef: RefObject<StructuredAgentSessionOptionState>
}) {
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<SessionOptionsSurface>(
() => ({
getSnapshot: () => optionSnapshot,
demandModelCatalog,
setOption,
invokeAction: async () => ({ snapshot: optionSnapshot }),
subscribe: () => () => {}
}),
[optionSnapshot, setOption]
[optionSnapshot, setOption, demandModelCatalog]
)
return { optionPickerRequest, invokeStructuredOption, optionSurface }
@@ -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,
@@ -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(() => {
@@ -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)
}
})
+138 -43
View File
@@ -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<ClaudeStructuredLaunchPartDeps, 'resolveAuthPolicy'> & {
/** Existing captured environment only: saved preference reads never recover a missing CLI. */
resolveSavedEnvironment?: () => Promise<Record<string, string>>
resolveLaunchArgs?: () => Promise<string[]> | 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<AgentModelCatalogSuccess> => {
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<AgentModelCatalogProbe>[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<AgentModelCatalogSuccess> => {
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<AgentModelCatalogProbe, 'resolve' | 'resolveLegacy'>
>(
async (home, options) =>
(await resolve(home, options?.cwd ?? options?.workspacePath ?? process.cwd())).run(
options?.signal
),
{ resolve, resolveLegacy: (home) => resolve(home) }
)
}
@@ -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<string, string>(),
appliedInputOptions: new Map<string, string>(),
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)
@@ -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')
})
@@ -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 })
}
}
@@ -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<string, string>,
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<string, string>, 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)
})
})
@@ -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'
)
@@ -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)
})
})
@@ -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(
@@ -30,6 +30,15 @@ function listedEfforts(row: Record<string, unknown>): 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<string>()
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
)
}
@@ -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<string, unknown> {
@@ -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'])
})
})
@@ -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')
})
@@ -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<unknown[]> => []
@@ -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', () => {
@@ -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)
@@ -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()
})
})
@@ -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 () => {
@@ -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
@@ -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<ListedModel[]> {
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<string> | 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<StructuredAgentSessionLiveOptions> {
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 } : {})
}
}
}
@@ -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<StructuredAgentSessionProviderOpenResult, 'reportedOptions'>
/** 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<ClaudeOpenFacts, 'reportedOptions' | 'catalogListing'> {
const { current, catalogListing } = claudeStructuredSessionOptionsFrom(
session,
catalog,
readMutationSequence
)
const listing = catalogListing ? { catalogListing } : {}
catalog: unknown[]
): Pick<ClaudeOpenFacts, 'reportedOptions'> {
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
})
}
@@ -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<CodexAppServerConnection, 'request'>,
timeoutMs = CONFIG_READ_TIMEOUT_MS
timeoutMs = CONFIG_READ_TIMEOUT_MS,
cwd?: string
): Promise<CodexConfiguredLaunchDefaults | null> {
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
}
}
@@ -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)
)
}
})
+97 -52
View File
@@ -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<NodeJS.ProcessEnv>
resolveEnv?: () => Record<string, string>
resolveLaunchArgs?: () => Promise<string[]> | 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<string, string>
export function createCodexModelCatalogProbe(
deps: CodexModelCatalogProbeDeps
): AgentModelCatalogProbe {
return async (accountHome, options): Promise<AgentModelCatalogSuccess> => {
const resolve = async (
accountHome: Parameters<AgentModelCatalogProbe>[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<AgentModelCatalogSuccess> => {
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<AgentModelCatalogProbe, 'resolve' | 'resolveLegacy'>
>(
async (home, options) =>
(await resolve(home, options?.cwd ?? process.cwd())).run(options?.signal),
{ resolve, resolveLegacy: (home) => resolve(home) }
)
}
@@ -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<CodexStructuredSessionAdapterDeps, 'modelCatalog'>,
launch: Pick<CodexStructuredLaunch, 'codexHome'>
deps: Pick<
CodexStructuredSessionAdapterDeps,
'modelCatalog' | 'readModelCatalog' | 'demandModelCatalog'
>,
launch: Pick<CodexStructuredLaunch, 'codexHome'>,
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
}
@@ -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<string, CodexSession>
timeoutMs: number | undefined
logger: StructuredAgentSessionLogger | undefined
}
async function refresh(input: BackgroundCatalogInput): Promise<void> {
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.
}
})
}
@@ -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<CodexSession, 'catalogAccess' | 'options'> &
Partial<Pick<CodexSession, 'reportedOptions'>>,
model = session.options.get('model') ?? session.reportedOptions?.model
): ReturnType<typeof structuredAgentSessionCapabilityView> {
return structuredAgentSessionCapabilityView(
codexAcquireCatalogListing(session.catalogAccess) ?? { origin: 'unknown' },
model
)
}
@@ -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(
@@ -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<string, unknown>) => ({
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<string, unknown>) =>
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<string, unknown>) =>
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'])
@@ -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)
@@ -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<CodexAppServerConnection, 'request'>
timeoutMs?: number
deadlineMs?: number
cwd?: string
}): Promise<CodexModelCatalogListing> {
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,
@@ -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<string, string>) {
const store = new AgentModelCatalogStore()
store.recordSuccess('codex-account', 'codex', { models: MODELS, origin: 'probe' }, 'discovery')
const request = vi.fn(async (_method: string, _params?: Record<string, unknown>) => ({
turn: { id: 'turn-1' }
}))
async function hostRuns(
saved: Record<string, string>,
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<string, unknown>) => {
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<string, string>) {
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(
@@ -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<typeof fakeCodex>, 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<unknown>()
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<unknown>()
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<unknown>()
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<Route> = vi
.fn<Route>()
.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<unknown>()
const newListing = Promise.withResolvers<unknown>()
const codex = fakeCodex({
'model/list': vi
.fn<Route>()
.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'
)
+30 -19
View File
@@ -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<CodexSession, 'reportedOptions'>,
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<CodexSession, 'options' | 'catalogAccess'> & {
reportedOptions?: CodexSession['reportedOptions']
}
session: Parameters<typeof codexSessionCapabilities>[0]
): Record<string, string> {
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<Record<string, string>>
) {
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)
}
}
@@ -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)
})
@@ -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<Record<string, string>>) =>
projectCodexSessionOptions(this.sessions.get(sessionId), options)
prepareReadOptions = (input: { sessionId: string; fence: number }) =>
prepareLiveCodexSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs)
@@ -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<string, unknown> {
function modelRow(id: string): Record<string, unknown> {
return {
model: id,
displayName: id.toUpperCase(),
hidden: false,
supportedReasoningEfforts: [{ reasoningEffort: 'high' }],
defaultReasoningEffort: 'high',
isDefault
supportedReasoningEfforts: [{ reasoningEffort: 'high' }]
}
}
function listAnswer(...ids: string[]): { data: Record<string, unknown>[]; 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<never>(() => {})
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<AgentModelCatalogProbe>(() => new Promise<never>(() => {}))
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<never>(() => {})),
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<void>((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<never>(() => {}))
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()
})
@@ -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<void> {
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<string, unknown> }[] = []
const session = optionSession(
vi.fn(async (method: string, params?: Record<string, unknown>) => {
@@ -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 })
})
@@ -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<CodexModelCatalogListing> {
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<CodexModelCatalogListing> {
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<CodexAppServerConnection, 'request'>
current: { model?: string; effort?: string; serviceTier?: string }
@@ -101,12 +61,12 @@ export async function readCodexStructuredSessionOptions(input: {
function composeLiveCodexCatalog(
session: CodexSession,
listing: CodexModelCatalogListing
listing: ReturnType<typeof codexSessionCapabilities>
): 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<AgentSessionOptionsResult> {
const listing = await codexSessionCatalogListingForPicker(session, timeoutMs)
return applyLiveCodexCatalog(session, listing)
): Promise<StructuredAgentSessionLiveOptions> {
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<Record<string, string>> {
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)
}
@@ -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,
@@ -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<AgentModelCatalogStore['get']>
demand?: (legacy?: boolean) => Promise<unknown>
}
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<AgentModelCatalogStore['get']>
demandModelCatalog?: (sessionId: string, legacy?: boolean) => Promise<unknown>
}
export type CodexSession = {
@@ -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,
@@ -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<AgentModelCatalogSuccess>
}
export type AgentModelCatalogProbe = ((
accountHome: AgentSessionAccountHome,
options?: { signal?: AbortSignal; workspacePath?: string; cwd?: string }
) => Promise<AgentModelCatalogSuccess>) & {
resolveLegacy?: (home: AgentSessionAccountHome) => Promise<AgentModelCatalogResolvedProbe>
resolve?: (
home: AgentSessionAccountHome,
cwd: string,
savedOnly?: boolean
) => Promise<AgentModelCatalogResolvedProbe>
}
export { independentAgentModelCatalog } from '../../../shared/agent-model-catalog-ownership'
/**
* What every agent registration must say about its models: a probe that lists them without
@@ -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<AgentSessionModelCatalogResult, { origin: 'unknown' }> {
// 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),
@@ -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')
}
@@ -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()
})
})
@@ -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<AgentModelCatalogLister, Promise<AgentModelCatalogEntry | null>>
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<AgentModelCatalogSuccess>
refreshes: Map<string, InFlightListings>
entries: ReadonlyMap<string, AgentModelCatalogEntry>
latestWrittenOrder: Map<string, number>
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<AgentModelCatalogEntry | null> {
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<AgentModelCatalogSuccess> {
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<AgentModelCatalogSuccess> =>
probe(home, { signal, workspacePath }).catch((error: unknown) => {
throw signal.aborted ? new AgentModelCatalogListingStoppedError(String(error)) : error
})
}
@@ -12,10 +12,15 @@ export class AgentModelCatalogListingWaiters {
private readonly listing: (fingerprint: string) => boolean
) {}
wait(fingerprint: string): Promise<AgentModelCatalogEntry | null> | null {
wait(
fingerprint: string,
waitForChange = false,
terminal = false
): Promise<AgentModelCatalogEntry | null> | 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()
@@ -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)
@@ -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'])
})
})
@@ -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<T>(
items: readonly T[],
limit: number,
stopped: AbortSignal,
run: (item: T) => Promise<unknown>
): Promise<void> {
const queue = [...items]
const worker = async (): Promise<void> => {
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<AgentModelCatalogSuccess>
): Promise<void> {
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))
})
}
@@ -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)
}
)
})
@@ -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<AgentModelCatalogService['read']>[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 } : {})
}
}
@@ -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<AgentSessionAccountHome>
/** Session-less listers, one per agent that has one on this host. */
probes?: Readonly<Partial<Record<string, AgentModelCatalogProbe>>>
/** Agents whose listing marks the model the account is configured to run as its default. */
listingNamesConfiguredModel?: ReadonlySet<string>
/** 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<AgentSessionModelCatalogResult>
/** Incoming listing ids, checking only a launch-added row against the pinned account. */
retainedLiveModelIds: (
sessionId: string,
listing: AgentModelCatalogLiveListing
) => Promise<string[]>
/** 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<void>
/** 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
}
@@ -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<AgentModelCatalogSuccess>((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()
}
}
)
@@ -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<AgentSessionAccountHome>
/** Session-less listers, one per agent that has one on this host. */
probes?: Readonly<Partial<Record<string, AgentModelCatalogProbe>>>
/** Agents whose listing marks the model the account is configured to run as its default. */
listingNamesConfiguredModel?: ReadonlySet<string>
/** 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<AgentSessionModelCatalogResult>
/** Incoming listing ids, checking only a launch-added row against the pinned account. */
retainedLiveModelIds: (
sessionId: string,
listing: AgentModelCatalogLiveListing
) => Promise<string[]>
/** 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<void>
/** 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<T>(
items: readonly T[],
limit: number,
stopped: AbortSignal,
run: (item: T) => Promise<unknown>
): Promise<void> {
const queue = [...items]
const worker = async (): Promise<void> => {
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<string, string>()
// 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<string, Promise<void>>()
const listWith =
(probe: AgentModelCatalogProbe, home: AgentSessionAccountHome, workspacePath?: string) =>
(): Promise<AgentModelCatalogSuccess> =>
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<string, Promise<void>>()
/** 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<AgentSessionModelCatalogResult> => {
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()
}
}
}
@@ -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<AgentModelCatalogSuccess>((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<AgentModelCatalogSuccess>((_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<AgentModelCatalogSuccess>((_resolve, reject) => (failFirst = reject))
)
void store.refresh(
'fp-1',
'codex',
'grok',
liveLister(store),
() => new Promise<AgentModelCatalogSuccess>((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<AgentModelCatalogSuccess>((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<AgentModelCatalogSuccess>((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<AgentModelCatalogSuccess>((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<AgentModelCatalogSuccess>((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<AgentModelCatalogSuccess>((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<AgentModelCatalogSuccess>((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<AgentSessionRecord, 'provider' | 'accountHome' | 'location'> = {
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'])
@@ -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<AgentModelCatalogSuccess>
/** Who lists, by identity: a live session's per-spawn handle, or the session-less probe. */
export type AgentModelCatalogLister = AgentModelCatalogSessionAccess | AgentModelCatalogProbe
type InFlightListings = Map<AgentModelCatalogLister, Promise<AgentModelCatalogEntry | null>>
/** 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<string, AgentModelCatalogEntry>()
@@ -81,6 +61,7 @@ export class AgentModelCatalogStore {
private readonly latestWrittenOrder = new Map<string, number>()
// Catalogs listed under a command or env the agent no longer launches with; cleared by a discovery.
private readonly dueEntries = new Set<string>()
private readonly partialAttempts = new Map<string, { agent: string; at: number }>()
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<AgentModelCatalogSuccess>
): Promise<AgentModelCatalogEntry | null> {
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<AgentModelCatalogEntry | null> | null {
return this.listingWaiters.wait(fingerprint)
pendingListing(
fingerprint: string,
waitForChange = false,
terminal = false
): Promise<AgentModelCatalogEntry | null> | 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)
}
}
}
@@ -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<AgentModelCatalogSuccess>()
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<AgentModelCatalogSuccess>(() => {}))
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'
])
})
})
@@ -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)
@@ -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<void | Readonly<Record<string, string>>>
/** Synchronous capability projection; never acquires inventory or changes saved intent. */
projectOptions?(
sessionId: string,
options: Readonly<Record<string, string>>
): 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
@@ -197,13 +197,14 @@ async function statusRows(): Promise<string[]> {
}
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<unknown>()
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),
@@ -38,6 +38,7 @@ function launchPermission(
return answer
})
const modelCatalog = {
peek: () => null,
read,
retainedLiveModelIds: vi.fn(async () => []),
recordLiveListing: vi.fn(),
@@ -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<StructuredAgentRegistry, 'definition'>
adapter?: Pick<StructuredAgentSessionAdapter, 'projectOptions'>
} & Parameters<typeof savedAgentSessionCatalogModels>[0],
ctx: Pick<AgentSessionTurnContext, 'sessionId' | 'fence' | 'publish' | 'now'>,
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)
}
}
}
@@ -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<Record<string, string>>
}
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<TModel extends AgentSessionModelOption>(
catalog: readonly TModel[],
current: string | undefined,
@@ -12,7 +34,37 @@ export function structuredAgentSessionOptionModels<TModel extends AgentSessionMo
): TModel[] {
const models = [...catalog]
if (current && !models.some((entry) => 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) ?? []
)
}
}
@@ -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<Partial<AgentSessionRecord>, 'options'> & {
type RestingFacts = Pick<Partial<AgentSessionRecord>, 'options' | 'provider'> & {
providerHandleChain?: Pick<AgentSessionProviderHandleLink, 'origin'>[]
rewind?: Pick<AgentSessionRewindRecord, 'phase'>
}
@@ -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<unknown>) => 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<string, string>, models = efforts) {
let record: AgentSessionRecord = { ...restingRecord(), options }
function picking(
options: Record<string, string>,
models = efforts,
projectOptions?: (
sessionId: string,
options: Readonly<Record<string, string>>
) => {
models: AgentSessionModelOption[]
options: Record<string, string>
},
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<void>()
const queue = new StructuredAgentSessionTaskQueue()
@@ -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<StructuredAgentRegistry, 'definition'>,
record: AgentSessionRecord
): StructuredAgentDefinition['restingOptions'] | null {
return agents.definition(record.provider)?.restingOptions ?? null
}
async function readStructuredAgentSessionOptionsAtRest(
deps: Pick<StructuredAgentSessionHostDeps, 'store' | 'agents' | 'modelCatalog'>,
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<AgentSessionModelCatalogResult> =>
@@ -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<AgentSessionOptionsResult, 'threadGoal' | 'reportedThreadGoal'> {
if (hostChangesGoal) {
return { threadGoal: { current, ...contextFloor } }
}
return current ? { reportedThreadGoal: { current, ...contextFloor } } : {}
}
@@ -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,
@@ -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<NodeJS.ProcessEnv>()
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')
@@ -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<AgentModelCatalogService, 'read'> },
provider: string,
sessionId: string,
workspacePath?: string | null
workspacePath?: string | null,
verifiedOnly = true
): Promise<readonly AgentSessionModelOption[] | undefined> {
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. */
@@ -101,6 +101,7 @@ async function startHost(): Promise<void> {
setOption
},
modelCatalog: {
peek: () => null,
read: vi.fn(async () => ({ origin: 'unknown' as const })),
recordLiveListing,
retainedLiveModelIds: vi.fn(async () => []),
@@ -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<AgentSessionOptionsResult, 'threadGoal' | 'reportedThreadGoal'> {
if (hostChangesGoal) {
return { threadGoal: { current, ...contextFloor } }
}
return current ? { reportedThreadGoal: { current, ...contextFloor } } : {}
}
@@ -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
@@ -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'
})
})
@@ -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
})
})
})
@@ -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] }
})
@@ -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<AgentSessionModelCatalogResult> => {
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'
@@ -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<void> {
async function chatAtRest(repoConfig: {
configured: boolean
complete?: boolean
}): Promise<ReturnType<typeof createAgentModelCatalogService>> {
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<void> {
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()
}
)
})
@@ -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: () => {}
})
@@ -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)) {
@@ -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,
@@ -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')
})
}
}
@@ -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'])
})
})
@@ -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<void> | null = null
export async function attachAgentModelCatalogPersistenceOnce(
stateDirectory: string
): Promise<void> {
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<StructuredAgentRuntimeRegistration, 'definition' | 'modelCatalog'>
@@ -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,
@@ -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'])
})
@@ -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,

Some files were not shown because too many files have changed in this diff Show More