fix: keep chat option actions independent of inventory (#31927)

* fix: separate chat option input from inventory observations

* fix: preserve scoped chat choices and publication ownership

* fix: complete scoped catalog and applied control ownership

* test: make catalog fixture dependency boundaries explicit

* test: request catalog discovery explicitly in demand contracts

* fix(chat): preserve option observations and operation boundaries

* fix(chat): finish pending option and catalog lifetimes

* fix(chat): project saved permissions without launch preparation

* fix(chat): correct option rejection and catalog retirement

* Preserve Claude effort validation advice through chat rejection

* test: align refresh mocks and preserve fixture type evidence

* fix: share catalog claims only across equivalent owners

* test: use typed catalog corpus instrumentation

* fix: consolidate structured launch imports

* Validate SDK queue regression envelopes without assertions

* fix: complete catalog runtime and fixture type contracts

* Align native option fixtures with explicit discovery and applied facts

* Keep initial compatibility catalog reads on held memory

* Complete the installed catalog fixture envelope

* Observe learned defaults after asynchronous projection settles

* Consolidate host fixture journal type imports

* test(native-chat): align goal and seed fixtures with applied inputs

* fix(codex): retain applied proof when goal settings skip a write

* test: load rejection wording before timed queue regression

* Fix catalog fixtures and queued option snapshot typing

* Align permission publication fixture with passive option reads

* Separate startup config from held turn permission fixture

* Align input lifecycle fixtures with sparse applied choices

* Simplify scoped catalog refresh ownership and coalesce cache snapshots

* Load catalog persistence real-module fixture during collection

* Compose catalog availability fixtures from valid session records

* Align catalog and startup fixtures with current option contracts

* Fix permission read types and separate catalog fixture preparation

* Align option and restart fixtures with current lifecycle owners

* test(mobile): retain reported model alongside discovery

* test(mobile): recalibrate the measured route script envelope

* test: let the preflight shell-capture mock run its detection callback
This commit is contained in:
Brennan Benson
2026-10-28 16:08:01 -07:00
committed by GitHub
parent a678688f68
commit 78ad9f4255
220 changed files with 8668 additions and 2847 deletions
@@ -422,9 +422,9 @@ describe('the Phase C budget', () => {
key
).toBeGreaterThanOrEqual(measured)
}
// Observed 1–15 scripts/route rules out a four-per-route bound or linear fit.
// Observed 1–14 scripts/route rules out a four-per-route bound or linear fit.
expect(Math.min(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD)).toBe(1)
expect(Math.max(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD)).toBe(15)
expect(Math.max(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD)).toBe(14)
})
it('sits exactly one margin over the swept tree and grants the worst route beyond it', () => {
@@ -476,11 +476,11 @@ describe('the Phase C budget', () => {
)
it('fails the build when the derived ceiling passes what the phone will accept', async () => {
// Fail build before derived assets exceed phone’s 256 limit; current 42-image envelope crosses at 24 routes, requiring remeasurement.
// Fail build before derived assets exceed phone’s 256 limit; current 42-image envelope crosses at 25 routes, requiring remeasurement.
expect(await readMobileWebBundleMaxAssets()).toBe(MOBILE_WEB_BUNDLE_MAX_ASSETS)
expect(assertAssetCeilingFitsShell(23, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(248)
expect(() => assertAssetCeilingFitsShell(24, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toThrow(
/263 .*256/
expect(assertAssetCeilingFitsShell(24, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(254)
expect(() => assertAssetCeilingFitsShell(25, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toThrow(
/268 .*256/
)
})
})
@@ -75,8 +75,8 @@ export const MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP = [
['./h/[hostId]/review/[worktreeId].tsx', 60],
['./h/[hostId]/session/[worktreeId].tsx', 72],
['./h/[hostId]/source-control/[worktreeId].tsx', 77],
['./h/[hostId]/tasks.tsx', 92],
['./h/_layout.tsx', 96]
['./h/[hostId]/tasks.tsx', 91],
['./h/_layout.tsx', 95]
]
const sweptScripts = MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP.map(([, scripts]) => scripts)
@@ -58,7 +58,7 @@ const listed = {
}
describe('mobile OMP model discovery and picker choices', () => {
it('queries the execution workspace and untracks a hook model the configured list lacks', async () => {
it('queries the execution workspace and retains the reported current model', async () => {
const sendRequest = vi.fn(async () => reply(listed))
await mount({ sendRequest })
expect(sendRequest).toHaveBeenCalledWith(
@@ -69,10 +69,14 @@ describe('mobile OMP model discovery and picker choices', () => {
},
undefined
)
// As on desktop: an authoritative list retires models it omits, even one the hook reported.
// Discovery retires future launch defaults, not the live chat's reported model.
expect(modelChoices()).toEqual({
type: 'select',
choices: [expect.objectContaining({ value: 'provider/new', label: 'New model' })]
currentValue: reportedModel,
choices: [
expect.objectContaining({ value: 'provider/new', label: 'New model' }),
{ value: reportedModel, label: reportedModel }
]
})
})
it('makes discovered choices selectable only after the live extension advertises support', async () => {
@@ -260,8 +260,12 @@ describe('useMobileStructuredAgentOptions fast mode', () => {
await act(async () => {
outcome = await harness.current().setStructuredOption('fastMode', true)
})
expect(outcome).toBe(false)
expect(calls).toEqual([])
expect(outcome).toBe(true)
expect(calls).toEqual([
expect.objectContaining({
fields: expect.objectContaining({ key: 'fastMode', value: 'true' })
})
])
await harness.unmount()
})
@@ -721,3 +725,30 @@ describe('useMobileStructuredAgentOptions after a lost pick reply', () => {
await harness.unmount()
})
})
it('refuses a typed literal at an unresolved fence and accepts it when that fence is ready with no menu', async () => {
const pending = deferred<AgentSessionOptionsResult>()
let reads = 0
const client = optionsClient(() =>
++reads === 1 ? Promise.resolve({ models: [], current: {} }) : pending.promise
)
const { calls, mutate } = recordingMutate(async () =>
accepted({ key: 'model', value: 'literal', options: { model: 'literal' } }, true)
)
const view = await mountOptions({ ...BASE, client: client.client, mutate })
await view.rerender({ fence: 2 })
let result = true
await act(async () => {
result = await view.current().setStructuredOption('model', 'literal')
})
expect(result).toBe(false)
expect(calls).toHaveLength(0)
await act(async () => pending.resolve({ models: [], current: {} }))
await settle()
await act(async () => {
result = await view.current().setStructuredOption('model', 'literal')
})
expect(result).toBe(true)
expect(calls).toHaveLength(1)
await view.unmount()
})
@@ -115,12 +115,14 @@ export function useMobileStructuredAgentOptions(args: {
imageSupport?.sessionId === sessionId &&
!imageSupport.statesPermissionMode &&
optionState.permissionMode === null
const interactionReadyRef = useRef(!awaitingOptions)
const lockSnapshotRef = useRef(
(snapshot: ReturnType<typeof structuredAgentSessionShownSnapshot>) => snapshot
)
const lockSnapshot = (snapshot: ReturnType<typeof structuredAgentSessionShownSnapshot>) =>
awaitingOptions ? lockedStructuredAgentSessionOptionSnapshot(snapshot) : snapshot
useLayoutEffect(() => {
interactionReadyRef.current = !awaitingOptions
lockSnapshotRef.current = lockSnapshot
})
const shown = lockSnapshot(
@@ -158,7 +160,8 @@ export function useMobileStructuredAgentOptions(args: {
},
id,
value,
lockSnapshotRef.current(structuredAgentSessionShownSnapshot(currentState))
lockSnapshotRef.current(structuredAgentSessionShownSnapshot(currentState)),
interactionReadyRef.current
)
) {
return false
+2
View File
@@ -70,6 +70,8 @@ export type AcpCompactionReply = { outcome: 'skipped' | 'failed'; detail: string
/** Hooks interpret extensions; lifecycle and row identity stay shared. */
export type AcpDialect = {
/** Effort replaces part of the native composite model selection. */
effortChangesModel?: true
subagentStop?: {
/** Probe without a target to establish support without cancelling a child. */
probe: { method: string; params: unknown }
@@ -77,6 +77,7 @@ function normalizeToolUpdate(update: ToolCallUpdate): ToolCallUpdate {
}
export const OPENCODE_ACP_DIALECT: AcpDialect = {
effortChangesModel: true,
normalizeToolUpdate,
promptFailure
}
+2 -17
View File
@@ -15,24 +15,9 @@ export async function applyAcpMessageOptions(
if (!options) {
return true
}
const initial = session.options.messageDefault()
const model = options?.model ?? initial.model
const defaultEffort =
model === initial.model
? initial.effort
: session.options.read().models.find((entry) => entry.id === model)?.defaultEffort
for (const key of ['model', 'effort'] as const) {
const value = options?.[key] ?? (key === 'model' ? initial.model : defaultEffort)
if (value === undefined) {
const applied = session.options.applied.get(key)
if (applied !== undefined && applied !== initial[key]) {
throw new AgentSessionOptionRejectedError(
new Error(`The default ${key} for this model is unknown`)
)
}
continue
}
if (session.options.currentProof.has(key) && session.options.reported()[key] === value) {
const value = options[key]
if (value === undefined || session.options.appliedInput.get(key) === value) {
continue
}
try {
+19 -3
View File
@@ -1,3 +1,7 @@
import {
assertStructuredAgentResolutionCurrent,
type StructuredAgentResolutionOperation
} from '../native-chat/structured-agent-command-resolution'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { AgentCliVersionReader } from '../agent-cli-version-probe'
@@ -57,6 +61,8 @@ export function createAcpModelCatalogProbe(
deps: AcpModelCatalogProbeDeps
): AgentModelCatalogProbe {
return async (accountHome, options): Promise<AgentModelCatalogSuccess> => {
const operation = options?.operation ?? { signal: options?.signal }
assertStructuredAgentResolutionCurrent(operation)
const discovery = spec.modelDiscovery
if (discovery.kind === 'unavailable') {
throw new Error(discovery.reason)
@@ -70,14 +76,17 @@ export function createAcpModelCatalogProbe(
const cwd = deps.homePath ?? homedir()
const { command, env, envToDelete } = await resolveAcpLaunchInvocation(spec, accountHome, {
...deps,
operation,
resolveWorkingDirectory: () => cwd
})
assertStructuredAgentResolutionCurrent(operation)
const release = await checkAcpLaunchVersion(
spec,
{ command, cwd, env },
discovery.kind === 'command' && discovery.supportsVersion !== undefined,
deps.probeVersion
)
assertStructuredAgentResolutionCurrent(operation)
if (release.status === 'tooOld') {
throw new Error(`${spec.agent} at ${command} does not run structured chats`)
}
@@ -110,11 +119,12 @@ export function createAcpModelCatalogProbe(
? await acpRunFolder(join(deps.dataDirectory, 'acp-listing-sessions', spec.agent))
: null
try {
assertStructuredAgentResolutionCurrent(operation)
const args =
folderArgs && folder ? [...launch.args, ...folderArgs(folder.path)] : launch.args
models = await runAgentModelCatalogSession(
() => (deps.connectSession ?? createAcpAgentConnection)({ ...launch, args }),
(connection) => readSessionModels(spec, listing, connection, workspace),
(connection) => readSessionModels(spec, listing, connection, workspace, operation),
run
)
} finally {
@@ -123,7 +133,11 @@ export function createAcpModelCatalogProbe(
} else {
models = await runAgentModelCatalogSession(
() => (deps.connect ?? createAcpAgentConnection)(launch),
async (connection) => listing.read(await connection.initialize(), connection),
async (connection) => {
const initialized = await connection.initialize()
assertStructuredAgentResolutionCurrent(operation)
return listing.read(initialized, connection)
},
run
)
}
@@ -155,9 +169,11 @@ async function readSessionModels(
spec: AcpLaunchSpec,
listing: AcpSessionModelDiscovery,
connection: AcpModelCatalogSessionConnection,
workspace: string
workspace: string,
operation?: StructuredAgentResolutionOperation
): Promise<AgentSessionModelOption[]> {
const initialized = await connection.initialize()
assertStructuredAgentResolutionCurrent(operation)
const deletes = Boolean(initialized.agentCapabilities?.sessionCapabilities?.delete)
if (!deletes && !listing.sessionFolderArgs) {
throw new Error(`${spec.agent} cannot delete a session, so it lists its models only in chats`)
+2 -6
View File
@@ -14,17 +14,13 @@ export function writeAcpSessionOption(
return Promise.reject(abortSignalReason(signal))
}
const revision = ++options.writeRevision
options.currentProof.delete(bound.key)
if (bound.key === 'model') {
options.currentProof.delete('effort')
}
options.invalidateInput(bound.key)
const adopt = (apply: () => void): void => {
if (options.writeRevision !== revision) {
throw new Error(`${agent} option write was superseded`)
}
apply()
options.currentProof.add(bound.key)
options.applied.set(bound.key, bound.value)
options.appliedInput.set(bound.key, write.method === 'config' ? write.value : write.modelId)
}
const applied =
write.method === 'config'
+2 -1
View File
@@ -181,7 +181,8 @@ export async function acquireAcpStructuredSession(input: {
sessionId,
fence: acquire.fence,
acquisitionGeneration: generation,
reportedOptions: options.observedCurrent(spec.permissionModeConfig?.values)
reportedOptions: options.observedCurrent(spec.permissionModeConfig?.values),
catalogListing: { models: options.read().models }
})
}
})
@@ -167,7 +167,11 @@ describe('Grok’s default comes from what a chat with no pick runs', () => {
const { catalog, host } = await openGrokHost('grok-4.6')
expect((await host.readOptions(SESSION)).current).toMatchObject({ model: 'grok-4.6' })
const answer = await catalog.read({ agent: 'grok', workspacePath: '/elsewhere' })
const answer = await catalog.read({
agent: 'grok',
workspacePath: '/elsewhere',
waitForListing: true
})
expect(answer).toMatchObject({
listingNamesConfiguredModel: true,
defaultHoldsInEveryWorkspace: true
@@ -184,13 +188,21 @@ describe('Grok’s default comes from what a chat with no pick runs', () => {
const prompt = await rig.frame('session/prompt')
rig.child().agent.reply(prompt, { stopReason: 'end_turn' })
// The start taught the catalog before any options read.
expect(firstFrame(await catalog.read({ agent: 'grok', workspacePath: '/elsewhere' }))).toEqual({
expect(
firstFrame(
await catalog.read({ agent: 'grok', workspacePath: '/elsewhere', waitForListing: true })
)
).toEqual({
model: 'grok-4.6',
effort: 'high'
})
expect((await host.readOptions(SESSION)).current).toMatchObject({ model: 'grok-4.6' })
const answer = await catalog.read({ agent: 'grok', workspacePath: '/elsewhere' })
const answer = await catalog.read({
agent: 'grok',
workspacePath: '/elsewhere',
waitForListing: true
})
expect(answer).toMatchObject({ listingNamesConfiguredModel: true })
expect(firstFrame(answer)).toEqual({ model: 'grok-4.6', effort: 'high' })
})
@@ -205,7 +217,11 @@ describe('Grok’s default comes from what a chat with no pick runs', () => {
)
catalog.recordLiveListing(OTHER_CHAT, newer.startListing())
await host.readOptions(SESSION)
const answer = await catalog.read({ agent: 'grok', workspacePath: '/elsewhere' })
const answer = await catalog.read({
agent: 'grok',
workspacePath: '/elsewhere',
waitForListing: true
})
expect(firstFrame(answer)).toEqual({ model: 'grok-4.7', effort: 'high' })
})
})
@@ -1,5 +1,9 @@
import { AcpStructuredOptions } from './acp-structured-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 {
AgentModelCatalogStore,
type AgentModelCatalogEntry
} from '../native-chat/agent-model-catalog/agent-model-catalog-store'
import {
agentReadsProjectModelConfig,
workspaceMayOverrideDefaultModel
@@ -19,7 +23,7 @@ afterEach(async () => {
})
describe('a live Grok listing', () => {
it('saves a changed live menu for the next chat when options are read, despite a failed probe', async () => {
it('saves a changed live menu for the next chat from its notification despite a failed probe', async () => {
let records: AgentSessionRecordStore | undefined
const catalog = createAgentModelCatalogService({
store: new AgentModelCatalogStore(),
@@ -69,11 +73,9 @@ describe('a live Grok listing', () => {
}
})
await rig.settle()
expect(await saved()).toEqual(initial)
await expect(host.readOptions(SESSION)).resolves.toMatchObject({
current: { model: 'grok-4.6', effort: 'high' }
})
expect(await saved()).toMatchObject({
await vi.waitFor(async () => expect(await saved()).toHaveProperty('models.length', 3))
const observed = await saved()
expect(observed).toMatchObject({
origin: 'live-session',
models: [
{ id: 'grok-4.7', isDefault: true },
@@ -81,14 +83,22 @@ describe('a live Grok listing', () => {
{ id: 'grok-new', label: 'New model', isDefault: false }
]
})
await expect(host.readOptions(SESSION)).resolves.toMatchObject({
current: { model: 'grok-4.6', effort: 'high' }
})
expect(await saved()).toEqual(observed)
expect(store.getRecord(SESSION)?.options).toBeUndefined()
})
it('starts the chat even when saving its start listing throws', async () => {
const modelCatalog: AgentModelCatalogService = {
snapshot: vi.fn(() => ({ origin: 'unknown' as const })),
demand: vi.fn(async () => {}),
prepareSaved: vi.fn(async () => ({ origin: 'unknown' as const })),
captureSource: () => () => true,
retireContext: vi.fn(),
peek: () => null,
read: vi.fn(async () => ({ origin: 'unknown' as const })),
retainedLiveModelIds: vi.fn(async () => []),
recordLiveListing: vi.fn().mockImplementationOnce(() => {
throw new Error('catalog unavailable')
}),
@@ -100,7 +110,7 @@ describe('a live Grok listing', () => {
expect(modelCatalog.recordLiveListing).toHaveBeenCalledTimes(1)
expect(store.getRecord(SESSION)?.lease.ownerProcess).toEqual(expect.any(Object))
await expect(host.readOptions(SESSION)).resolves.toMatchObject({ current: expect.any(Object) })
expect(modelCatalog.recordLiveListing).toHaveBeenCalledTimes(2)
expect(modelCatalog.recordLiveListing).toHaveBeenCalledTimes(1)
expect(
(await rig.adapter.readOptions({ sessionId: SESSION, fence: fence() })).current
).toMatchObject({
@@ -110,3 +120,115 @@ describe('a live Grok listing', () => {
expect(store.getRecord(SESSION)?.options).toBeUndefined()
})
})
it.each([
[5, 20],
[50, 20],
[1, 200]
] as const)(
'catalog-scale config notifications updates=%i models=%i',
async (updates, modelRows) => {
let records: AgentSessionRecordStore | undefined
const store = new AgentModelCatalogStore()
let saves = 0
let savedBytes = 0
let pending: (() => readonly AgentModelCatalogEntry[]) | undefined
await store.attachPersistence({
load: async () => [],
save: (snapshot) => {
saves++
pending = snapshot
},
flush: async () => {
const snapshot = pending
pending = undefined
if (snapshot) {
savedBytes += Buffer.byteLength(JSON.stringify(snapshot()))
}
}
})
const catalog = createAgentModelCatalogService({
store,
getRecord: (id) => records?.getRecord(id) ?? undefined,
getConversation: (id) => records?.getConversation(id) ?? undefined,
drivesRecord: () => true,
resolveAccountHome: async () => ({ variable: 'GROK_HOME', path: '/grok' }),
recordWorkspacePath: async () => '/generated-project',
agentReadsProjectModelConfig,
workspaceMayOverrideDefaultModel: async () => false
})
const reads = vi.spyOn(AcpStructuredOptions.prototype, 'read')
const publications = vi.spyOn(store, 'recordSuccess')
try {
const { host, rig, store: recordsStore, ready } = await openHostRig({ modelCatalog: catalog })
records = recordsStore
expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true })
await ready()
await rig.settle()
await store.flushPersistence()
saves = 0
savedBytes = 0
reads.mockClear()
publications.mockClear()
const configOptions = GROK_CONFIG_OPTIONS.map((option) =>
option.id === 'model'
? {
...option,
currentValue: 'grok-4.7',
options: Array.from({ length: modelRows }, (_, index) => ({
value: index === 0 ? 'grok-4.7' : `grok-model-${index}`,
name: `Grok model ${index}`
}))
}
: option
)
const notificationBytes = Buffer.byteLength(JSON.stringify(configOptions))
const start = performance.now()
for (let update = 0; update < updates; update++) {
rig.child().agent.notify('session/update', {
sessionId: PROVIDER_SESSION,
update: { sessionUpdate: 'config_option_update', configOptions }
})
}
await rig.settle()
await vi.waitFor(() => expect(publications).toHaveBeenCalledTimes(updates))
const projectionReads = reads.mock.calls.length
const projectedModelRows = reads.mock.results.reduce(
(count, result) => count + (result.type === 'return' ? result.value.models.length : 0),
0
)
expect(projectionReads).toBe(updates * 2)
expect(projectedModelRows).toBe(updates * modelRows * 2)
expect(saves).toBe(1)
await store.flushPersistence()
const saved = await catalog.prepareSaved({
agent: 'grok',
sessionId: SESSION,
inSessionWorkspace: true
})
if (saved.origin === 'unknown') {
throw new Error('missing saved live catalog')
}
expect(saved.models).toHaveLength(modelRows)
expect(saved.models[0]).toMatchObject({ id: 'grok-4.7', isDefault: true })
process.stdout.write(
`CATALOG_NOTIFICATIONS ${JSON.stringify({
updates,
modelRows,
projectionReads,
projectedModelRows,
publicationCalls: publications.mock.calls.length,
saves,
savedBytes,
notificationBytes,
elapsedMs: performance.now() - start
})}\n`
)
} finally {
catalog.stop()
reads.mockRestore()
publications.mockRestore()
await closeProviderTimelineRigs()
}
}
)
@@ -128,11 +128,12 @@ describe('a Grok model pick Grok never answers during send', () => {
await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(2))
const [old, current] = framesOf(rig.child(), 'session/set_config_option')
rig.child().agent.reply(current!, { configOptions: GROK_CONFIG_OPTIONS })
await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(3))
rig.child().agent.reply(framesOf(rig.child(), 'session/set_config_option')[2]!, {
configOptions: GROK_CONFIG_OPTIONS
})
await restore
expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(2)
expect([old?.params, current?.params]).toEqual([
{ sessionId: expect.any(String), configId: 'model', value: 'grok-4.6' },
{ sessionId: expect.any(String), configId: 'model', value: 'grok-4.7' }
])
expect(framesOf(rig.child(), 'session/prompt')).toHaveLength(1)
rig.child().agent.reply(old!, {
configOptions: GROK_CONFIG_OPTIONS.map((option) =>
@@ -1,3 +1,10 @@
import {
assertStructuredAgentResolutionCurrent,
resolveAgainAfterRefreshOnMissingProgram,
resolveStructuredAgentCommand,
type StructuredAgentCommandSettings,
type StructuredAgentResolutionOperation
} from '../native-chat/structured-agent-command-resolution'
// Launch from durable record values for the working directory, pinned account and resume session.
import { delimiter } from 'node:path'
@@ -10,11 +17,6 @@ import {
} from '../../shared/agent-session-provider-handle'
import type { AgentSessionAccountHome } from '../../shared/agent-session-account-home'
import type { resolveCliCommand } from '../../shared/node-cli-command-resolution'
import {
resolveAgainAfterRefreshOnMissingProgram,
resolveStructuredAgentCommand,
type StructuredAgentCommandSettings
} from '../native-chat/structured-agent-command-resolution'
import {
checkAgentCliVersion,
type AgentCliVersionCheck,
@@ -74,6 +76,7 @@ export type AcpStructuredLaunchPartDeps = {
} & Pick<AcpLaunchVisualsDeps, 'logger'>
export type AcpLaunchInvocationDeps = {
operation?: StructuredAgentResolutionOperation
/** Reread per acquisition. */
resolveEnvironment: () => Promise<Record<string, string>>
resolveLaunchEnv?: (agent: string) => Record<string, string>
@@ -153,7 +156,8 @@ export function resolveAcpLaunchInvocation(
return resolveAgainAfterRefreshOnMissingProgram(
() => readAcpLaunchInvocation(spec, accountHome, deps),
deps.refreshEnvironmentAfterMiss,
{ env: ({ env }) => env, cwd: deps.resolveWorkingDirectory }
{ env: ({ env }) => env, cwd: deps.resolveWorkingDirectory },
deps.operation
)
}
@@ -162,7 +166,9 @@ async function readAcpLaunchInvocation(
accountHome: AgentSessionAccountHome,
deps: AcpLaunchInvocationDeps
): Promise<{ command: string; env: Record<string, string>; envToDelete: string[] }> {
assertStructuredAgentResolutionCurrent(deps.operation)
const base = await deps.resolveEnvironment()
assertStructuredAgentResolutionCurrent(deps.operation)
const env: Record<string, string> = spec.account.environment(accountHome, {
...base,
...deps.resolveLaunchEnv?.(spec.agent)
+14 -34
View File
@@ -51,7 +51,7 @@ export class AcpStructuredOptions {
constructor(
private readonly dialect: Pick<
AcpDialect,
'modelEfforts' | 'clampsUnlistedEffort' | 'withheldCommands'
'modelEfforts' | 'clampsUnlistedEffort' | 'withheldCommands' | 'effortChangesModel'
> = {}
) {}
@@ -61,12 +61,21 @@ export class AcpStructuredOptions {
// Only new sessions resolve configured defaults; loaded sessions retain their saved choices.
private resolvesConfig = false
readonly picked = new Set<string>()
/** Installed Orca policy and successful writes, distinct from native agent reports. */
readonly appliedInput = new Map<string, string>()
readonly applied = new Map<string, string>()
private initialChoice: Readonly<Record<string, string>> = {}
currentProof = new Set<string>()
writeRevision = 0
invalidateInput(key: string): void {
this.appliedInput.delete(key)
if (key === 'model') {
this.appliedInput.delete('effort')
}
if (key === 'effort' && this.dialect.effortChangesModel) {
this.appliedInput.delete('model')
}
}
/** The session state a new, load or resume response reported. */
adoptSession(
response: {
configOptions?: SessionConfigOption[] | null
@@ -77,13 +86,7 @@ export class AcpStructuredOptions {
this.configOptions = response.configOptions ?? []
this.models = response.models ?? null
this.resolvesConfig = origin === 'new'
this.initialChoice = this.reported()
this.currentProof = new Set(Object.keys(this.initialChoice))
}
/** Native selection before any message changed this child. */
messageDefault(): Readonly<Record<string, string>> {
return this.initialChoice
this.appliedInput.clear()
}
notePick(key: string): void {
@@ -93,32 +96,9 @@ export class AcpStructuredOptions {
adoptConfigOptions(configOptions: SessionConfigOption[] | null | undefined): void {
if (configOptions) {
this.configOptions = configOptions
this.adoptOwnChoice()
}
}
/** Until Orca writes a key, a later report is still the agent's own (OpenCode's start answers
* before its catalog loads); an effort belongs to its model, so it stops with either write. */
private adoptOwnChoice(): void {
if (this.picked.has('model')) {
return
}
const reported = this.reported()
const own = this.picked.has('effort') ? ['model'] : ['model', 'effort']
const choice: Record<string, string> = { ...this.initialChoice }
for (const key of own) {
const value = reported[key]
if (value === undefined) {
delete choice[key]
this.currentProof.delete(key)
} else {
choice[key] = value
this.currentProof.add(key)
}
}
this.initialChoice = choice
}
adoptModel(modelId: string): void {
if (this.models) {
this.models = { ...this.models, currentModelId: modelId }
@@ -5,7 +5,10 @@ import {
SESSION
} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { acpLaunchSpecFor } from './acp-launch-specs'
import { GENERIC_ACP_DIALECT } from './acp-dialects/acp-dialect'
import { OPENCODE_ACP_DIALECT } from './acp-dialects/opencode-dialect'
import { OMP_ACP_DIALECT } from './acp-dialects/omp-dialect'
import {
GROK,
GROK_CONFIG_OPTIONS,
@@ -208,6 +211,151 @@ describe('ACP structured session adapter: turns', () => {
})
})
it.each([
['composite', OPENCODE_ACP_DIALECT, 2],
['independent', OMP_ACP_DIALECT, 1]
] as const)(
'keeps applied model proof consistent with the %s dialect effort writer',
async (_name, dialect, modelWrites) => {
const rig = await openAcpAdapterRig({ spec: { ...GROK, dialect } })
await rig.acquire()
const inputs = [
{ model: 'openai/gpt-5/high' },
{ effort: 'low' },
{ model: 'openai/gpt-5/high' }
]
for (const [index, executionOptions] of inputs.entries()) {
await rig.adapter.dispatch({
sessionId: SESSION,
fence: 1,
clientMessageId: `variant-${index}`,
body: hello,
executionOptions
})
const prompt = await rig.frame('session/prompt', index)
rig.child().agent.notify('session/update', chunk(`prompt:variant-${index}`, 'Reply'))
rig.child().agent.reply(prompt, { stopReason: 'end_turn' })
await waitFor(async () => {
const turns = (await rig.rig.rows()).flatMap(
(row) => readAgentJournalTurn(row.body) ?? []
)
expect(turns.filter((turn) => turn.state === 'completed')).toHaveLength(index + 1)
})
}
const controls = rig
.child()
.agent.frames.filter((frame) => frame.method === 'session/set_config_option')
expect(
controls.filter(
(frame) =>
typeof frame.params === 'object' &&
frame.params !== null &&
'configId' in frame.params &&
frame.params.configId === 'model'
)
).toHaveLength(modelWrites)
expect(
controls.filter(
(frame) =>
typeof frame.params === 'object' &&
frame.params !== null &&
'configId' in frame.params &&
frame.params.configId === 'reasoning_effort'
)
).toHaveLength(1)
}
)
it('inherits omitted effort after acquired and applied high when OMP offers no default effort', async () => {
const configOptions = [
{
id: 'model',
name: 'Model',
category: 'model',
type: 'select',
currentValue: 'google/gemini-3.1-pro-preview',
options: [
{ value: 'google/gemini-3.1-pro-preview', name: 'Gemini' },
{ value: 'team_lb/gpt-6.1-sol', name: 'GPT' }
]
},
{
id: 'reasoning_effort',
name: 'Effort',
category: 'thought_level',
type: 'select',
currentValue: 'high',
options: [{ value: 'high', name: 'High' }]
}
]
const rig = await openAcpAdapterRig({
spec: acpLaunchSpecFor('omp')!,
script: (agent) => {
agent.on('session/new', (frame) =>
agent.reply(frame, { sessionId: PROVIDER_SESSION, configOptions })
)
// The unchanged report does not echo or confirm the supplied control.
agent.on('session/set_config_option', (frame) => agent.reply(frame, { configOptions }))
}
})
await rig.acquire()
let promptIndex = 0
const dispatch = async (id: string, executionOptions: Record<string, string>) => {
await rig.adapter.dispatch({
sessionId: SESSION,
fence: 1,
clientMessageId: id,
executionOptions,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }
})
const prompt = await rig.frame('session/prompt', promptIndex++)
rig.child().agent.notify('session/update', chunk(`prompt:${id}`, 'Reply'))
rig.child().agent.reply(prompt, { stopReason: 'end_turn' })
await waitFor(async () => {
const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? [])
expect(turns.filter((turn) => turn.state === 'completed')).toHaveLength(promptIndex)
})
}
const observed = await rig.adapter.readOptions({ sessionId: SESSION, fence: 1 })
expect(observed.current).toMatchObject({
model: 'google/gemini-3.1-pro-preview',
effort: 'high'
})
expect(observed.models?.find((model) => model.id === 'team_lb/gpt-6.1-sol')).not.toHaveProperty(
'defaultEffort'
)
await dispatch('acquired', { model: 'team_lb/gpt-6.1-sol' })
expect(rig.sent('session/set_config_option').map((frame) => frame.params)).toEqual([
expect.objectContaining({ configId: 'model', value: 'team_lb/gpt-6.1-sol' })
])
await dispatch('B', { model: 'unlisted-model', effort: 'high' })
await rig.frame('session/prompt')
let before = rig.child().agent.frames.length
await dispatch('A', { model: 'team_lb/gpt-6.1-sol' })
expect(
rig
.child()
.agent.frames.slice(before)
.map((frame) => frame.method)
).toEqual(['session/set_config_option', 'session/prompt'])
before = rig.child().agent.frames.length
await dispatch('empty', {})
expect(
rig
.child()
.agent.frames.slice(before)
.map((frame) => frame.method)
).toEqual(['session/prompt'])
before = rig.child().agent.frames.length
await dispatch('explicit', { effort: 'high' })
expect(
rig
.child()
.agent.frames.slice(before)
.map((frame) => frame.method)
).toEqual(['session/set_config_option', 'session/prompt'])
})
it('sends model-only choices away from the opening model, back, and away again', async () => {
const rig = await openAcpAdapterRig()
await rig.acquire()
@@ -228,11 +376,10 @@ describe('ACP structured session adapter: turns', () => {
rig.child().agent.reply(await rig.frame('session/prompt', index), { stopReason: 'end_turn' })
await waitFor(() => expect(rig.adapter.holdsDispatch(SESSION)).toBe(false))
}
// Restore opening effort only when returning to the opening model.
// Model-only sends inherit native effort on each model.
expect(rig.sent('session/set_config_option').map((frame) => frame.params)).toMatchObject([
{ configId: 'model', value: 'grok-4.6' },
{ configId: 'model', value: 'grok-4.7' },
{ configId: 'reasoning_effort', value: 'high' },
{ configId: 'model', value: 'grok-4.6' }
])
})
@@ -1,3 +1,8 @@
import { structuredAgentHostLaunchSources } from '../runtime/structured-agent-launch-support'
import { captureHostShellEnvironment } from '../runtime/structured-agent-shell-environment'
import * as profileStorage from '../orca-profiles/profile-storage-paths'
// The chat sign-in against a scripted Antigravity server.
import { createServer } from 'node:http'
import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -34,6 +39,16 @@ vi.mock('../provider-process/provider-process-teardown', () => ({
)
}))
const checkingShell = vi.hoisted(() => ({ path: '/fixture', next: '/fixture' }))
vi.mock('../startup/login-shell-environment', () => ({
resolveLoginShellEnvironment: async (options?: { startedAfter?: number }) => {
if (options?.startedAfter !== undefined) {
checkingShell.path = checkingShell.next
}
return { PATH: checkingShell.path }
}
}))
const GOOGLE_LINK =
'https://accounts.google.com/o/oauth2/v2/auth?response_type=code&state=s1&redirect_uri=http%3A%2F%2F127.0.0.1%3A41234%2F'
// Use a fake pin even on hosts without a published server build.
@@ -81,6 +96,7 @@ async function signInAgainst(
stream?: 'stdout' | 'stderr'
metaCwd?: string
sessionResult?: Record<string, unknown>
realLaunchSources?: boolean
} = {}
) {
vi.spyOn(ANTIGRAVITY_LAUNCH_SPEC.managedRuntime, 'pin').mockReturnValue(PIN)
@@ -110,13 +126,36 @@ async function signInAgainst(
const bin = join(stateDirectory, 'bin')
await mkdir(bin)
await writeFile(join(bin, ANTIGRAVITY_LAUNCH_SPEC.command), '', { mode: 0o755 })
if (options.realLaunchSources) {
checkingShell.path = bin
checkingShell.next = bin
vi.spyOn(profileStorage, 'getProfileUserDataPath').mockReturnValue(stateDirectory)
}
const host = {
launchSources: async () => ({
accountHome: { variable: 'GEMINI_HOME', path: folder },
env: { PATH: bin },
commandSettings: {},
dataDirectory: stateDirectory
}),
launchSources: options.realLaunchSources
? (signal: AbortSignal) =>
structuredAgentHostLaunchSources(
'antigravity',
{
requireStore: () => ({
getSettings: () => ({
nativeChatInheritShellEnvironment: true,
agentDefaultEnv: {}
})
}),
resolveStructuredAgentAccountHome: async () => ({
variable: 'GEMINI_HOME',
path: folder
})
},
signal
)
: async () => ({
accountHome: { variable: 'GEMINI_HOME', path: folder },
env: { PATH: bin },
commandSettings: {},
dataDirectory: stateDirectory
}),
stopLiveChats: async () => {},
connect: vi.fn<ConnectAcpAgent<AcpAgentConnection>>((launch, connectionOptions) => {
const connection = createAcpAgentConnection(launch, connectionOptions, () => child)
@@ -526,3 +565,92 @@ it('expires a waiting sign-in quietly and releases its launch gate', async () =>
vi.useRealTimers()
}
})
it('drops checking-session models when cancellation wins while start is pending', async () => {
const started = deferred<Awaited<ReturnType<AcpAgentConnection['start']>>>()
const enter = vi.fn()
const run = await signInAgainst(() => helperOutput(GOOGLE_LINK), {
configure: (connection) => {
vi.spyOn(connection, 'start').mockImplementation(() => {
enter()
return started.promise
})
}
})
await stateAfter(run.flowId, 'starting')
run.finishBrowser()
await vi.waitFor(() => expect(enter).toHaveBeenCalledTimes(1))
const cancelling = cancelAntigravityChatSignIn(DESKTOP, run.flowId)
started.resolve({
kind: 'new',
sessionId: SESSION,
response: {
sessionId: SESSION,
configOptions: [
{
id: 'model',
name: 'Model',
category: 'model',
type: 'select',
currentValue: 'late',
options: [{ value: 'late', name: 'Late' }]
}
]
}
})
expect((await cancelling)?.phase).toBe('cancelled')
const key = agentModelCatalogFingerprint({
agent: 'antigravity',
accountHome: { variable: 'GEMINI_HOME', path: run.folder },
wslDistro: null
})
expect(agentModelCatalogStore.get(key)).toBeNull()
})
it('retires a cold checking producer on effective Refresh while valid sign-in still completes', async () => {
const configOptions = [
{
id: 'model',
name: 'Model',
category: 'model',
type: 'select',
currentValue: 'old-A',
options: [{ value: 'old-A', name: 'A' }]
}
]
const run = await signInAgainst(printed(GOOGLE_LINK), {
realLaunchSources: true,
sessionResult: { configOptions }
})
const key = agentModelCatalogFingerprint({
agent: 'antigravity',
accountHome: { variable: 'GEMINI_HOME', path: run.folder },
wslDistro: null
})
try {
await stateAfter(run.flowId, 'starting')
const before = agentModelCatalogStore.retirementRevision('antigravity')
await captureHostShellEnvironment(async () => {
checkingShell.next = '/changed-install'
})
expect(agentModelCatalogStore.retirementRevision('antigravity')).not.toBe(before)
run.finishBrowser()
await stateAfter(run.flowId, 'waiting')
run.openSession()
expect(await stateAfter(run.flowId, 'verifying')).toMatchObject({ phase: 'signedIn' })
expect(agentModelCatalogStore.get(key)).toBeNull()
// The completed checking lifetime no longer participates in Refresh.
const released = agentModelCatalogStore.retirementRevision('antigravity')
await captureHostShellEnvironment(async () => {
checkingShell.next = '/another-install'
})
expect(agentModelCatalogStore.retirementRevision('antigravity')).toBe(released)
} finally {
run.finishBrowser()
run.openSession()
const shown = readAntigravityChatSignInStatus(DESKTOP).flow
if (shown && ['starting', 'waiting', 'verifying'].includes(shown.phase)) {
await cancelAntigravityChatSignIn(DESKTOP, run.flowId)
}
}
})
@@ -15,7 +15,7 @@ import type { ConnectAcpAgent } from '../acp/acp-structured-connection'
import { resolveAcpLaunchInvocation } from '../acp/acp-structured-launch-resolution'
import { prepareAcpLaunch } from '../acp/acp-structured-launch-preparation'
import { AcpAgentError, AcpConnectionClosedError } from '../acp/acp-errors'
import { saveSignInModels } from './antigravity-sign-in-models'
import { captureSignInModelPublisher } from './antigravity-sign-in-models'
import { isAgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { structuredAgentHostLaunchSources } from '../runtime/structured-agent-launch-support'
import {
@@ -55,7 +55,7 @@ type Flow = {
}
export type AntigravityChatSignInHost = {
launchSources(): ReturnType<typeof structuredAgentHostLaunchSources>
launchSources(signal: AbortSignal): ReturnType<typeof structuredAgentHostLaunchSources>
/** Rejects when a chat is not proven stopped. */
stopLiveChats(): Promise<void>
connect?: ConnectAcpAgent<AcpAgentConnection>
@@ -157,7 +157,11 @@ async function signIn(current: Flow, host: AntigravityChatSignInHost): Promise<v
const spec: AcpLaunchSpec = ANTIGRAVITY_LAUNCH_SPEC
const { signal } = current.ended
const closed = (): void => signal.throwIfAborted()
const { accountHome, env, commandSettings, dataDirectory } = await host.launchSources()
const publishModels = captureSignInModelPublisher(
spec,
() => active === current && running(current) && !signal.aborted
)
const { accountHome, env, commandSettings, dataDirectory } = await host.launchSources(signal)
const deps = { spec, dataDirectory, connect: host.connect ?? createAcpAgentConnection }
const resolve = () =>
resolveAcpLaunchInvocation(spec, accountHome, {
@@ -210,7 +214,7 @@ async function signIn(current: Flow, host: AntigravityChatSignInHost): Promise<v
})
owned.sessionId = started.sessionId
current.pending = undefined
saveSignInModels(spec, accountHome, started)
publishModels(accountHome, started, !connection.exited)
} finally {
signal.removeEventListener('abort', stop)
await closeAntigravitySignInOwner(home, server)
@@ -23,3 +23,20 @@ export function saveSignInModels(
)
}
}
export function captureSignInModelPublisher(spec: AcpLaunchSpec, current: () => boolean) {
const revision = agentModelCatalogStore.retirementRevision(spec.agent)
return (
accountHome: AgentSessionAccountHome,
started: AcpSessionStarted,
connected: boolean
): void => {
if (
connected &&
current() &&
agentModelCatalogStore.retirementRevision(spec.agent) === revision
) {
saveSignInModels(spec, accountHome, started)
}
}
}
+28 -8
View File
@@ -1,4 +1,8 @@
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import {
assertStructuredAgentResolutionCurrent,
type StructuredAgentResolutionOperation
} from '../native-chat/structured-agent-command-resolution'
import {
buildClaudeModelListArgs,
CLAUDE_MODEL_LIST_STDIN
@@ -103,11 +107,14 @@ export function createClaudeModelCatalogProbe(
const resolve = async (
accountHome: Parameters<AgentModelCatalogProbe>[0],
cwd?: string,
lookup: AgentModelCatalogLookup = 'recover'
lookup: AgentModelCatalogLookup = 'recover',
operation?: StructuredAgentResolutionOperation
) => {
assertStructuredAgentResolutionCurrent(operation)
const configuredArgs = claudeStructuredLaunchArgs(
cwd ? ((await deps.resolveLaunchArgs?.()) ?? []) : []
)
assertStructuredAgentResolutionCurrent(operation)
const userArgs = [
...Object.entries(configuredArgs.extraArgs).flatMap(([key, value]) =>
value === null ? [`--${key}`] : [`--${key}`, value]
@@ -122,11 +129,13 @@ export function createClaudeModelCatalogProbe(
const args = argsFor(true)
const launchDeps = {
...deps,
operation,
...(lookup === 'saved' ? { resolveInheritedEnv: deps.resolveSavedEnvironment } : {}),
...(lookup === 'recover' ? {} : { refreshEnvironmentAfterMiss: undefined }),
...(cwd ? { resolveWorkingDirectory: () => cwd } : {})
}
const sources = await resolveClaudeChildEnvSources(launchDeps)
assertStructuredAgentResolutionCurrent(operation)
const { command, env } = await resolveClaudeStructuredInvocation(
launchDeps,
(base) => ({
@@ -137,13 +146,18 @@ export function createClaudeModelCatalogProbe(
}),
sources
)
assertStructuredAgentResolutionCurrent(operation)
return {
identity: { command, args, cwd, overlay: Object.entries(sources.overlay ?? {}).sort() },
run: async (signal?: AbortSignal): Promise<AgentModelCatalogSuccess> => {
assertStructuredAgentResolutionCurrent(operation)
signal?.throwIfAborted()
const outcome = await runClaudeBackgroundAttempts({
agentId: 'claude',
run: (strictMcp) =>
(deps.runListing ?? runAgentModelCatalogListing)(
run: (strictMcp) => {
assertStructuredAgentResolutionCurrent(operation)
signal?.throwIfAborted()
return (deps.runListing ?? runAgentModelCatalogListing)(
{
command,
args: argsFor(strictMcp),
@@ -156,7 +170,8 @@ export function createClaudeModelCatalogProbe(
return error
}
throw error
}),
})
},
failedExit: (outcome) =>
outcome instanceof AgentModelCatalogProbeError && outcome.reason === 'exit'
? outcome.failedExit
@@ -219,9 +234,14 @@ export function createClaudeModelCatalogProbe(
Pick<AgentModelCatalogProbe, 'resolve' | 'resolveLegacy'>
>(
async (home, options) =>
(await resolve(home, options?.cwd ?? options?.workspacePath ?? process.cwd())).run(
options?.signal
),
{ resolve, resolveLegacy: (home) => resolve(home) }
(
await resolve(
home,
options?.cwd ?? options?.workspacePath ?? process.cwd(),
'recover',
options?.operation
)
).run(options?.signal),
{ resolve, resolveLegacy: (home, operation) => resolve(home, undefined, 'recover', operation) }
)
}
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { ClaudeStructuredSessionEvent } from './structured/claude-structured-session-state'
import {
PROVIDER_SESSION_ID,
@@ -141,7 +141,7 @@ describe('Claude model before the first turn', () => {
expect(current.effort).toBe('medium')
})
it('re-reads what Claude will apply after a model write', async () => {
it('refreshes what Claude applies at turn completion, without work on an options read', async () => {
const settings = structuredClone(SETTINGS.settingsHaiku)
const claude = startedWithoutATurn(settings)
const adapter = await acquired(claude)
@@ -149,8 +149,27 @@ describe('Claude model before the first turn', () => {
await adapter.setOption({ sessionId: 'session-1', fence: 7, key: 'model', value: 'sonnet' })
Object.assign(settings.applied, { model: 'claude-sonnet-5', effort: 'high' })
const reads = claude.connections[0]!.calls.filter(
(call) => call.subtype === 'get_settings'
).length
const { current } = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
expect(current).toMatchObject({ model: 'sonnet', effort: 'high' })
expect(current.model).toBe('sonnet')
expect(current.effort).toBeUndefined()
expect(
claude.connections[0]!.calls.filter((call) => call.subtype === 'get_settings')
).toHaveLength(reads)
claude.connections[0]!.handlers.onMessage?.({
type: 'result',
subtype: 'success',
uuid: 'finished',
session_id: PROVIDER_SESSION_ID,
is_error: false
})
await vi.waitFor(async () => {
expect(
(await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).current
).toMatchObject({ model: 'sonnet', effort: 'high' })
})
})
it('drops the applied effort once a restored model replaces the model it described', async () => {
+14 -2
View File
@@ -1,8 +1,15 @@
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import {
assertStructuredAgentResolutionCurrent,
resolveAgainAfterRefreshOnMissingProgram,
type StructuredAgentResolutionOperation
} from '../native-chat/structured-agent-command-resolution'
// What a structured Claude child's environment is built from, shared by its launch and the
// `--version` probe that runs the same binary.
import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
import { applyClaudeEnvPatch } from '../claude-accounts/environment'
import { resolveClaudeCommand } from '../codex-cli/command'
import { resolveAgainAfterRefreshOnMissingProgram } from '../native-chat/structured-agent-command-resolution'
import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin'
function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record<string, string>): Record<string, string> {
@@ -17,6 +24,7 @@ function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record<string, string>): Recor
export type ClaudeEnvDeps = {
signal?: AbortSignal
operation?: StructuredAgentResolutionOperation
resolveCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string
/** Refresh once after command lookup misses; absent disables retry. */
refreshEnvironmentAfterMiss?: (askedAt: number) => Promise<void>
@@ -44,15 +52,19 @@ export function resolveClaudeChildEnvSources(deps: ClaudeEnvDeps): Promise<Claud
env: ({ inheritedEnv, overlay }) => ({ ...inheritedEnv, ...overlay }),
cwd: deps.resolveWorkingDirectory,
signal: deps.signal
}
},
deps.operation
)
}
async function readClaudeChildEnvSources(deps: ClaudeEnvDeps): Promise<ClaudeChildEnvSources> {
assertStructuredAgentResolutionCurrent(deps.operation)
const overlay = await waitForPromiseWithSignal(Promise.resolve(deps.resolveEnv?.()), deps.signal)
assertStructuredAgentResolutionCurrent(deps.operation)
const inheritedEnv = deps.resolveInheritedEnv
? await waitForPromiseWithSignal(deps.resolveInheritedEnv(), deps.signal)
: cloneDefinedEnv(process.env)
assertStructuredAgentResolutionCurrent(deps.operation)
deps.signal?.throwIfAborted()
const effectiveEnv = { ...inheritedEnv, ...overlay }
const command = (deps.resolveCommand ?? resolveClaudeCommand)({
+10 -1
View File
@@ -35,7 +35,10 @@ import {
claudeUserMessageWasProvablyUnwritten
} from './claude-agent-sdk-user-message-queue'
import { AgentSessionPreDispatchError } from '../native-chat/agent-session-wire/structured-agent-session-operation-settlement'
import { isAgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error'
import {
ClaudeEffortValidationError,
isAgentSessionOptionRejectedError
} from '../native-chat/agent-session-wire/structured-agent-session-option-error'
import { ClaudeControlRequestError } from './claude-stream-json-connection'
const MAX_ACTIVE_DISPATCH_WAITERS = 64
@@ -190,6 +193,12 @@ export async function dispatchClaudeTurn(
) {
throw error
}
if (error instanceof ClaudeEffortValidationError) {
return {
state: 'rejected',
...claudeDispatchRejection(agentSessionFailureFact('claudeEffortInvalid'))
}
}
if (isAgentSessionOptionRejectedError(error)) {
return {
state: 'rejected',
@@ -191,8 +191,15 @@ function catalogService(store: AgentModelCatalogStore, record: AgentSessionRecor
})
}
function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) {
/** The options a client reads for the chat once its child is gone. */
async function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) {
const modelCatalog = catalogService(store, record)
// Acquiring this scope is separate from the passive picker read below.
await modelCatalog.prepareSaved({
agent: record.provider,
sessionId: SESSION,
inSessionWorkspace: true
})
const resting = {
child: null,
params: { provider: 'claude' },
@@ -220,7 +227,11 @@ function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) {
openConversation: async () => resting,
conversation: async () => resting
} as unknown as StructuredAgentSessionMutationContext
return readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true })
try {
return await readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true })
} finally {
modelCatalog.stop()
}
}
function pickerEffortRow(result: Awaited<ReturnType<typeof readAtRest>>, agent = 'claude') {
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { setClaudeStructuredOption } from './claude-structured-options'
import { readClaudeSettingsEffort } from './claude-structured-session-options'
import type {
@@ -115,12 +115,14 @@ describe('Claude effort readback', () => {
it('records an effort the child did not adopt without vouching for it', async () => {
const { session, calls } = sessionWith('high')
// The native enum is valid; a different readback cannot veto a successful control.
await expect(
setClaudeStructuredOption(session, { key: 'effort', value: 'bogus-effort-xyz' }, undefined)
).resolves.toEqual({ effort: 'bogus-effort-xyz' })
setClaudeStructuredOption(session, { key: 'effort', value: 'low' }, undefined)
).resolves.toEqual({ effort: 'low' })
expect(session.confirmedOptions.has('effort')).toBe(false)
expect(session.reportedOptions.effort).toBe('high')
expect(calls).toEqual(['apply:bogus-effort-xyz', 'get_settings'])
// The child's own answer is kept rather than discarded with the refusal.
await vi.waitFor(() => expect(session.reportedOptions.effort).toBe('high'))
expect(calls).toEqual(['apply:low', 'get_settings'])
})
it('records an effort the child confirms', async () => {
@@ -70,6 +70,7 @@ it.each(['low', 'high', undefined])(
}
).options
).toEqual({ model: 'sonnet', effort: 'high' })
claudeTurnEnds(claude.connections[0]!)
await adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'again',
@@ -82,7 +83,7 @@ it.each(['low', 'high', undefined])(
(call) =>
call.subtype === 'apply_flag_settings' && JSON.stringify(call.params).includes('high')
)
).toHaveLength(1)
).toHaveLength(2)
expect(claude.connections[0]!.sent).toHaveLength(2)
} finally {
await adapter.closeAll()
@@ -131,7 +132,10 @@ it('retires earlier Fast routing when an effort read reports Fast off', async ()
fastMode = false
expect(await send('low')).toMatchObject({ state: 'admitted', ranOptions: { effort: 'low' } })
claudeTurnEnds(claude.connections[0]!)
expect(await send('low')).toEqual({ state: 'admitted', ranOptions: { effort: 'low' } })
expect(await send('low')).toEqual({
state: 'admitted',
ranOptions: { model: 'sonnet', effort: 'low' }
})
} finally {
await adapter.closeAll()
}
@@ -1,3 +1,4 @@
import { assertStructuredAgentResolutionCurrent } from '../native-chat/structured-agent-command-resolution'
import { activeProviderContext } from '../../shared/agent-session-provider-context'
import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router'
import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home'
@@ -144,8 +145,10 @@ export async function resolveClaudeStructuredInvocation(
options?: Parameters<typeof claudeChildEnv>[2]
): Promise<ClaudeStructuredInvocation> {
const sources = resolvedSources ?? (await resolveClaudeChildEnvSources(deps))
assertStructuredAgentResolutionCurrent(deps.operation)
deps.signal?.throwIfAborted()
const auth = await deps.resolveAuthPolicy()
assertStructuredAgentResolutionCurrent(deps.operation)
deps.signal?.throwIfAborted()
// Preserve proxy credentials alongside ANTHROPIC_BASE_URL for every account.
return {
@@ -88,7 +88,8 @@ describe('Claude accepted message choices', () => {
)
).toEqual([
{ subtype: 'set_model', params: { model: 'custom[1m]' } },
{ subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'low' } } }
{ subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'low' } } },
{ subtype: 'apply_flag_settings', params: { settings: { fastMode: false } } }
])
const failure = new Error('control refused')
connection.setModel = async () => {
@@ -149,7 +150,7 @@ describe('Claude accepted message choices', () => {
})
expect(outcome).toEqual({
state: 'admitted',
ranOptions: applied ? { model: 'claude-sonnet-5', ...ranOptions } : {}
ranOptions: { model: 'claude-sonnet-5', ...ranOptions }
})
})
@@ -349,7 +350,7 @@ describe('Claude accepted message choices', () => {
expect(connection.sent).toHaveLength(2)
})
it('restores the configured model and effort for an empty snapshot', async () => {
it('inherits native state for an empty snapshot after an explicit effort', async () => {
let currentEffort = 'high'
const claude = fakeClaude({
routes: {
@@ -383,6 +384,7 @@ describe('Claude accepted message choices', () => {
claudeTurnEnds(connection)
claudeFrame(connection, { type: 'system', subtype: 'init', model: 'claude-opus-5' })
connection.calls.length = 0
connection.getSettings = () => new Promise(() => {})
await expect(
adapter.dispatch({
sessionId: 'session-1',
@@ -394,10 +396,7 @@ describe('Claude accepted message choices', () => {
).resolves.toMatchObject({ state: 'admitted' })
expect(
connection.calls.filter((call) => ['set_model', 'apply_flag_settings'].includes(call.subtype))
).toEqual([
{ subtype: 'set_model', params: { model: 'claude-sonnet-5' } },
{ subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'high' } } }
])
).toEqual([])
expect(connection.sent).toHaveLength(2)
})
@@ -486,14 +485,21 @@ describe('Claude accepted message choices', () => {
}
try {
await expect(send('starts', 'opus')).resolves.toMatchObject({ state: 'admitted' })
expect(models()).toEqual(['sonnet', 'opus', 'opus'])
expect(settings()).toEqual([
...settingsAfterTurn,
{ subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'low' } } },
{ subtype: 'apply_flag_settings', params: { settings: { fastMode: true } } }
])
const settingsAtStart = [...settings()]
claudeFrame(connection, {
type: 'command_lifecycle',
state: 'started',
command_uuid: connection.sent.at(-1)?.uuid
})
await expect(send('before-echo', 'sonnet')).resolves.toMatchObject({ state: 'admitted' })
expect(models()).toEqual(['sonnet', 'opus'])
expect(settings()).toEqual(settingsAfterTurn)
expect(models()).toEqual(['sonnet', 'opus', 'opus'])
expect(settings()).toEqual(settingsAtStart)
} finally {
connection.send = originalSend
await adapter.closeAll()
@@ -9,7 +9,6 @@ import {
neverThrowingStructuredAgentSessionLogger,
type StructuredAgentSessionLogger
} from '../native-chat/agent-session-wire/structured-agent-session-logger'
import { listedModels } from './claude-structured-model-catalog'
import { setClaudeStructuredOption } from './claude-structured-options'
import type { ClaudeSession } from './structured/claude-structured-session-state'
import {
@@ -27,7 +26,7 @@ export async function applyClaudeMessageOptions(
signal?: AbortSignal
): Promise<void> {
throwIfSignalAborted(signal)
const model = options.model ?? (await defaultModelForMessage(session, timeoutMs, signal))
const model = options.model
if (model && session.appliedInputOptions.get('model') !== model) {
await waitForPromiseWithSignal(
setClaudeStructuredOption(session, { key: 'model', value: model }, timeoutMs),
@@ -36,15 +35,12 @@ export async function applyClaudeMessageOptions(
}
throwIfSignalAborted(signal)
const effort = options.effort ?? session.configuredDefaults?.effort
const effort = options.effort
if (effort && session.appliedInputOptions.get('effort') !== effort) {
await waitForPromiseWithSignal(
setClaudeStructuredOption(session, { key: 'effort', value: effort }, timeoutMs),
signal
)
} else if (!effort && session.options.has('effort')) {
// Omission restores configuration; without its value a live control cannot clear the override.
throw new AgentSessionOptionRejectedError('Claude did not report its configured effort')
}
throwIfSignalAborted(signal)
@@ -58,30 +54,6 @@ export async function applyClaudeMessageOptions(
throwIfSignalAborted(signal)
}
async function defaultModelForMessage(
session: ClaudeSession,
timeoutMs: number | undefined,
signal?: AbortSignal
): Promise<string | undefined> {
if (session.configuredDefaults?.model) {
return session.configuredDefaults.model
}
if (!session.options.has('model')) {
return undefined
}
const response = await waitForPromiseWithSignal(
session.connection.supportedModels({ timeoutMs }),
signal
).catch(() => null)
throwIfSignalAborted(signal)
const listed = listedModels(response ? { models: response } : null)
const fallback = listed.find((model) => model.isDefault)?.id
if (!fallback) {
throw new AgentSessionOptionRejectedError('Claude did not report its default model')
}
return fallback
}
export async function dispatchClaudeMessage(
session: ClaudeSession,
input: Parameters<StructuredAgentSessionAdapter['dispatch']>[0],
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { setClaudeStructuredOption } from './claude-structured-options'
import type { ClaudeSession } from './structured/claude-structured-session-state'
import {
@@ -107,8 +107,10 @@ 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: 'claude-sonnet-5', effort: 'high', confirmed: ['model', 'effort'] }
await vi.waitFor(async () => {
expect(await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).toMatchObject({
current: { model: 'claude-sonnet-5', effort: 'high', confirmed: ['model', 'effort'] }
})
})
await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 })
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import {
applyStructuredAgentSessionOptions,
createStructuredAgentSessionOptionState,
@@ -104,8 +104,10 @@ describe('structured option confirmation reaches the pill', () => {
const adapter = await acquired(claude)
await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'low', fence: 7 })
const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
expect(result.current.confirmed).toContain('effort')
await vi.waitFor(async () => {
const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
expect(result.current.confirmed).toContain('effort')
})
})
it('treats a host that reports no confirmation as unconfirmed', () => {
@@ -179,7 +181,7 @@ describe('confirmation never outlives the write it belongs to', () => {
} as unknown as ClaudeSession
await setClaudeStructuredOption(session, { key: 'effort', value: 'low' }, undefined)
expect(session.confirmedOptions.has('effort')).toBe(true)
await vi.waitFor(() => expect(session.confirmedOptions.has('effort')).toBe(true))
await setClaudeStructuredOption(session, { key: 'effort', value: 'max' }, undefined)
expect(session.options.get('effort')).toBe('max')
@@ -1,5 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { setClaudeStructuredOption } from './claude-structured-options'
import { applyClaudeMessageOptions } from './claude-structured-message-options'
import { resetClaudeRetiredModel } from './claude-structured-retired-model'
import type { ClaudeSession } from './structured/claude-structured-session-state'
import { ClaudeControlRequestTimeoutError } from './claude-agent-sdk-control-requests'
import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker'
@@ -9,7 +11,8 @@ import { createClaudeInitProof } from './structured/claude-structured-init-proof
import { claudeProviderHandleLink } from './structured/claude-structured-owner-identity'
import {
observeClaudeFastModeFacts,
readClaudeStructuredSessionOptions
readClaudeStructuredSessionOptions,
refreshClaudeStructuredSessionOptions
} from './claude-structured-session-options'
function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSession {
@@ -222,6 +225,12 @@ describe('Claude structured Fast mode', () => {
const { session } = fastModeSession(true)
session.options.set('fastMode', 'true')
session.connection.getSettings = async () => ({ effective: { fastMode: false } })
await refreshClaudeStructuredSessionOptions(
session,
undefined,
() => true,
() => {}
)
await expect(readClaudeStructuredSessionOptions(session, undefined)).resolves.toMatchObject({
current: { fastMode: false, confirmed: ['fastMode'] }
@@ -235,6 +244,12 @@ describe('Claude structured Fast mode', () => {
session.options.set('fastMode', 'false')
session.connection.getSettings = async () => ({ effective: { fastMode: true } })
observeClaudeFastModeFacts(session, { fast_mode_state: 'cooldown' })
await refreshClaudeStructuredSessionOptions(
session,
undefined,
() => true,
() => {}
)
await expect(readClaudeStructuredSessionOptions(session, undefined)).resolves.toMatchObject({
current: { fastMode: true, fastModeState: 'cooldown', confirmed: ['fastMode'] }
@@ -249,6 +264,12 @@ describe('Claude structured Fast mode', () => {
fast_mode_state: 'cooldown',
fast_mode_disabled_reason: null
})
await refreshClaudeStructuredSessionOptions(
session,
undefined,
() => true,
() => {}
)
await expect(readClaudeStructuredSessionOptions(session, undefined)).resolves.toMatchObject({
current: {
model: 'opus',
@@ -296,6 +317,12 @@ describe('Claude structured Fast mode', () => {
{ value: 'opus', displayName: 'Opus', supportsFastMode: true },
{ value: 'haiku', displayName: 'Haiku', supportsFastMode: false }
]
await refreshClaudeStructuredSessionOptions(
session,
undefined,
() => true,
() => {}
)
await expect(readClaudeStructuredSessionOptions(session, undefined)).resolves.toMatchObject({
current: { model: 'haiku', fastMode: false }
@@ -381,6 +408,32 @@ describe("Claude's model the chat never chose", () => {
})
describe('Claude structured option write under the request deadline', () => {
it('completes successful literal effort writes without optional settings confirmation', async () => {
const { session, applyFlagSettings } = fastModeSession(true)
session.connection.getSettings = () => new Promise(() => {})
const held = [...session.options]
const applied = [...session.appliedInputOptions]
const sequence = session.optionMutationSequence
await expect(
setClaudeStructuredOption(session, { key: 'effort', value: 'banana' }, undefined)
).rejects.toThrow(
'Claude effort is invalid; choose low, medium, high, xhigh, or max and send again.'
)
expect(applyFlagSettings).not.toHaveBeenCalled()
expect([...session.options]).toEqual(held)
expect([...session.appliedInputOptions]).toEqual(applied)
expect(session.optionMutationSequence).toBe(sequence)
await expect(
setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined)
).resolves.toMatchObject({ effort: 'high' })
expect(applyFlagSettings).toHaveBeenCalledWith(
{ effortLevel: 'high' },
{ timeoutMs: undefined }
)
expect(session.appliedInputOptions.get('effort')).toBe('high')
expect(session.confirmedOptions.has('effort')).toBe(false)
})
it("keeps a timed-out client write as the deadline's own error, not a rejection", async () => {
const session = sessionFor(async () => {
throw new ClaudeControlRequestTimeoutError('set_model')
@@ -391,3 +444,52 @@ describe('Claude structured option write under the request deadline', () => {
).rejects.toBeInstanceOf(ClaudeControlRequestTimeoutError)
})
})
describe('Claude applied inputs across native writer effects', () => {
it('retires old-model Fast refusal and reissues explicit inputs after promotion, reset and failure', async () => {
const setModel = vi.fn<ClaudeSession['connection']['setModel']>().mockResolvedValue(undefined)
const session = sessionFor(setModel)
const flags = vi
.fn<ClaudeSession['connection']['applyFlagSettings']>()
.mockResolvedValue(undefined)
session.connection.applyFlagSettings = flags
session.connection.getSettings = () => new Promise(() => {})
session.fastModeDisabledReason = 'model_not_allowed'
await applyClaudeMessageOptions(
session,
{ model: 'opus[1m]', effort: 'high', fastMode: 'true' },
undefined
)
expect(flags.mock.calls.map(([settings]) => settings)).toEqual([
{ effortLevel: 'high' },
{ fastMode: true }
])
expect(session.fastModeDisabledReason).toBeUndefined()
// Fast can promote the native model; the next supplied model/effort must reach their controls.
await applyClaudeMessageOptions(session, { model: 'opus[1m]', effort: 'high' }, undefined)
expect(setModel).toHaveBeenCalledTimes(2)
expect(flags.mock.calls.filter(([settings]) => settings.effortLevel === 'high')).toHaveLength(2)
resetClaudeRetiredModel(session, {}, 'chat', 'opus[1m]')
await vi.waitFor(() =>
expect(setModel).toHaveBeenLastCalledWith(undefined, { timeoutMs: undefined })
)
await applyClaudeMessageOptions(session, { effort: 'high' }, undefined)
expect(flags.mock.calls.filter(([settings]) => settings.effortLevel === 'high')).toHaveLength(3)
flags.mockRejectedValueOnce(new Error('native effort failure'))
await expect(applyClaudeMessageOptions(session, { effort: 'low' }, undefined)).rejects.toThrow(
'native effort failure'
)
await applyClaudeMessageOptions(session, { effort: 'low' }, undefined)
expect(flags.mock.calls.filter(([settings]) => settings.effortLevel === 'low')).toHaveLength(2)
const count = setModel.mock.calls.length + flags.mock.calls.length
await applyClaudeMessageOptions(session, {}, undefined)
expect(setModel.mock.calls.length + flags.mock.calls.length).toBe(count)
setModel.mockImplementationOnce(async () => {
observeClaudeFastModeFacts(session, { fast_mode_disabled_reason: 'model_not_allowed' })
})
await applyClaudeMessageOptions(session, { model: 'haiku' }, undefined)
await expect(
applyClaudeMessageOptions(session, { fastMode: 'true' }, undefined)
).rejects.toThrow('Fast mode is unavailable')
})
})
+79 -42
View File
@@ -1,6 +1,8 @@
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 {
AgentSessionOptionRejectedError,
ClaudeEffortValidationError
} from '../native-chat/agent-session-wire/structured-agent-session-option-error'
import {
observeClaudeSettingsApplied,
readClaudeSettingsEffort,
@@ -12,6 +14,8 @@ import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured
import { beginClaudeOptionMutation } from './claude-structured-execution-observations'
const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
const OPTION_ORDER = ['model', 'effort', 'fastMode'] as const
/** Approval mode is fixed at startup: a fallback mode is dropped only by reopening for a newly picked model.
@@ -53,6 +57,30 @@ export function isClaudeStructuredOptionKey(key: string): boolean {
return CLAUDE_STRUCTURED_OPTION_KEYS.has(key)
}
/** Native writers can replace more than their named input; uncertain writes retire that proof. */
export function beginClaudeInputWrite(session: ClaudeSession, key: string, value?: string): number {
const affected =
key === 'model'
? ['model', 'effort', 'fastMode']
: key === 'fastMode' && value === 'true'
? ['model', 'effort', 'fastMode']
: [key]
for (const input of affected) {
session.appliedInputOptions.delete(input)
session.confirmedOptions.delete(input)
}
const sequence = beginClaudeOptionMutation(session, key)
session.settingsObservation = { pending: true }
return sequence
}
export function finishClaudeInputWrite(session: ClaudeSession, sequence: number): void {
if (sequence === session.optionMutationSequence) {
delete session.settingsObservation
}
}
/** A client's write to a live session. */
export function setClaudeStructuredSessionOption(
session: ClaudeSession,
input: { key: string; value: string },
@@ -70,15 +98,15 @@ export async function setClaudeStructuredOption(
input.key === 'fastMode'
? decodeStructuredAgentSessionOptionValue('fastMode', input.value)
: null
const effort = EFFORT_LEVELS.find((value) => value === input.value)
if (input.key === 'effort' && effort === undefined) {
throw new ClaudeEffortValidationError()
}
const apply =
input.key === 'model'
? () => session.connection.setModel(input.value, { timeoutMs })
: input.key === 'effort'
? () =>
session.connection.applyFlagSettings(
{ effortLevel: input.value as EffortLevel },
{ timeoutMs }
)
: input.key === 'effort' && effort !== undefined
? () => session.connection.applyFlagSettings({ effortLevel: effort }, { timeoutMs })
: input.key === 'fastMode' && typeof fastMode === 'boolean'
? () => session.connection.applyFlagSettings({ fastMode }, { timeoutMs })
: null
@@ -101,14 +129,17 @@ export async function setClaudeStructuredOption(
)
}
}
session.appliedInputOptions.delete(input.key)
const mutationSequence = beginClaudeOptionMutation(session, input.key)
// Capture the held value before yielding so overlapping writes compare against their own predecessor.
const mutationSequence = beginClaudeInputWrite(session, input.key, input.value)
// Read with the fence bump, so a write that lands after an earlier one's bookkeeping compares against it.
const held = session.options.get(input.key)
try {
await apply()
if (mutationSequence === session.optionMutationSequence) {
session.appliedInputOptions.set(input.key, input.value)
if (input.key === 'model' && session.fastModeDisabledMutation !== mutationSequence) {
delete session.fastModeDisabledReason
delete session.fastModeDisabledMutation
}
}
// Notify the translator even if a newer write superseded this applied control.
if (input.key === 'model' && input.value !== held) {
@@ -122,53 +153,59 @@ export async function setClaudeStructuredOption(
throw new AgentSessionOptionRejectedError(error)
}
throw error
} finally {
finishClaudeInputWrite(session, mutationSequence)
}
// Readback confirms presentation without vetoing an accepted control.
// Max is session-only; settings report the underlying level.
if (mutationSequence === session.optionMutationSequence) {
session.options.set(input.key, input.value)
session.confirmedOptions.delete(input.key)
if (input.key === 'model') {
session.confirmedOptions.delete('effort')
session.confirmedOptions.delete('fastMode')
}
void confirmClaudeOption(session, input, mutationSequence, timeoutMs).catch(() => {})
}
return Object.fromEntries(session.options)
}
async function confirmClaudeOption(
session: ClaudeSession,
input: { key: string; value: string },
mutationSequence: number,
timeoutMs: number | undefined
): Promise<void> {
const token = { pending: false }
session.settingsObservation = token
const settings =
(input.key === 'effort' && input.value !== 'max') || input.key === 'fastMode'
? await session.connection.getSettings({ timeoutMs }).catch(() => null)
: null
if (mutationSequence !== session.optionMutationSequence) {
return Object.fromEntries(session.options)
}
if (settings !== null) {
observeClaudeSettingsApplied(session, settings)
if (readClaudeSettingsFastMode(settings) === false) {
delete session.fastModeReportedMutation
}
if (
session.connection.closed ||
session.settingsObservation !== token ||
mutationSequence !== session.optionMutationSequence ||
settings === null
) {
return
}
observeClaudeSettingsApplied(session, settings)
const adopted =
input.key === 'fastMode'
? readClaudeSettingsFastMode(settings)
: readClaudeSettingsEffort(settings)
const decoded =
input.key === 'fastMode'
? decodeStructuredAgentSessionOptionValue('fastMode', input.value)
: input.value
if (input.key === 'fastMode' && typeof adopted === 'boolean') {
session.reportedOptions.fastMode = adopted
if (!adopted) {
delete session.fastModeReportedMutation
}
} else if (typeof adopted === 'string' && adopted !== decoded) {
session.reportedOptions.effort = adopted
}
// Preserve the provider's differing effort for later reads without vetoing the accepted write.
const decodedInput = input.key === 'fastMode' ? fastMode : input.value
if (adopted !== null && adopted !== decodedInput) {
if (typeof adopted === 'string') {
session.reportedOptions.effort = adopted
}
}
session.options.set(
input.key,
input.key === 'fastMode' && typeof adopted === 'boolean' ? String(adopted) : input.value
)
// Only matching readback confirms adoption.
if (adopted !== null && adopted === decodedInput) {
if (adopted !== null && adopted === decoded) {
session.confirmedOptions.add(input.key)
} else {
session.confirmedOptions.delete(input.key)
}
// A model switch invalidates effort and Fast confirmations from the old model.
if (input.key === 'model') {
session.confirmedOptions.delete('effort')
session.confirmedOptions.delete('fastMode')
}
return Object.fromEntries(session.options)
}
@@ -0,0 +1,97 @@
import { expect, it, vi } from 'vitest'
import { ClaudeControlRequestTimeoutError } from './claude-agent-sdk-control-requests'
import type { ClaudeStructuredSessionEvent } from './structured/claude-structured-session-state'
import {
acquired,
claudeFrame,
fakeClaude,
tick,
USER_MESSAGE
} from './claude-structured-session-test-support'
it.each(['before-settlement', 'after-settlement'])(
'retires result settings begun during an uncertain control: %s',
async (release) => {
const control = Promise.withResolvers<void>()
const report = Promise.withResolvers<unknown>()
const events: ClaudeStructuredSessionEvent[] = []
const native = fakeClaude({ routes: { set_model: () => control.promise } })
const adapter = await acquired(native, {}, events)
const connection = native.connections[0]!
const session = adapter['sessions'].get('session-1')!
const read = () => adapter.readOptions({ sessionId: 'session-1', fence: 7 })
const finishTurn = () =>
claudeFrame(connection, {
type: 'result',
subtype: 'success',
result: 'previous turn completed',
is_error: false
})
try {
await adapter.setOption({ sessionId: 'session-1', fence: 7, key: 'fastMode', value: 'true' })
await tick()
await adapter.dispatch({
sessionId: 'session-1',
fence: 7,
clientMessageId: 'first',
body: USER_MESSAGE
})
finishTurn()
await tick()
const writing = adapter.dispatch({
sessionId: 'session-1',
fence: 7,
clientMessageId: 'next',
body: USER_MESSAGE,
executionOptions: { model: 'claude-opus-5' }
})
await vi.waitFor(() =>
expect(connection.calls.some((call) => call.subtype === 'set_model')).toBe(true)
)
const settings = vi.spyOn(connection, 'getSettings').mockReturnValueOnce(report.promise)
finishTurn()
await vi.waitFor(() => expect(settings).toHaveBeenCalledOnce())
const stale = {
applied: { model: 'claude-sonnet-5', effort: 'low' },
effective: { effortLevel: 'low', fastMode: false }
}
if (release === 'before-settlement') {
report.resolve(stale)
await tick()
expect((await read()).current.fastMode).toBe(true)
expect(session.appliedOptions).toBeUndefined()
}
control.reject(new ClaudeControlRequestTimeoutError('set_model'))
expect(await writing).toMatchObject({ state: 'rejected' })
report.resolve(stale)
await tick()
const current = (await read()).current
expect(current).toMatchObject({ effort: 'high', fastMode: true })
expect(current.confirmed ?? []).not.toContain('fastMode')
expect(session.appliedOptions).toBeUndefined()
expect(Object.fromEntries(session.appliedInputOptions)).toEqual({})
expect(
events.some(
(event) => event.type === 'options-reported' && event.reportedOptions.effort === 'low'
)
).toBe(false)
native.routes.set_model = () => undefined
await adapter.setOption({ sessionId: 'session-1', fence: 7, key: 'model', value: 'opus' })
settings.mockResolvedValueOnce({
applied: { model: 'claude-opus-5', effort: 'medium' },
effective: { effortLevel: 'medium', fastMode: true }
})
finishTurn()
await vi.waitFor(async () =>
expect((await read()).current).toMatchObject({ effort: 'medium', fastMode: true })
)
expect(session.appliedInputOptions.get('model')).toBe('opus')
expect(session.appliedOptions).toEqual({ model: 'claude-opus-5', effort: 'medium' })
} finally {
report.resolve(null)
control.resolve()
await adapter.closeSession('session-1')
}
}
)
@@ -1,4 +1,8 @@
import { beginClaudeOptionMutation } from './claude-structured-execution-observations'
// A chat's saved model is passed to the CLI as `--model` unchecked, so one the provider has since
// retired fails the turn. The CLI says so itself: a synthetic reply whose `error` is
// `model_not_found` (Claude Code 2.1.280). That reply is the evidence the saved model cannot run.
import { beginClaudeInputWrite, finishClaudeInputWrite } from './claude-structured-options'
import type {
ClaudeSession,
ClaudeStructuredSessionAdapterDeps
@@ -31,27 +35,33 @@ export function resetClaudeRetiredModel(
sessionId: string,
retired: string
): void {
session.appliedInputOptions.delete('model')
const sequence = beginClaudeOptionMutation(session, 'model')
void session.connection.setModel(undefined, { timeoutMs: deps.requestTimeoutMs }).then(
() => {
if (sequence !== session.optionMutationSequence) {
return
}
// The retired id is no longer what runs; the next turn's own report names the default.
if (session.reportedOptions.model === retired) {
delete session.reportedOptions.model
}
if (session.appliedOptions?.model === retired) {
const { model: _retired, ...applied } = session.appliedOptions
session.appliedOptions = applied
}
},
(error: unknown) =>
deps.logger?.warn('putting a retired Claude model back on the default failed', {
scope: 'claude-retired-model-reset',
sessionId,
error
})
)
const sequence = beginClaudeInputWrite(session, 'model')
void session.connection
.setModel(undefined, { timeoutMs: deps.requestTimeoutMs })
.then(
() => {
if (sequence !== session.optionMutationSequence) {
return
}
if (session.fastModeDisabledMutation !== sequence) {
delete session.fastModeDisabledReason
delete session.fastModeDisabledMutation
}
// The retired id is no longer what runs; the next turn's own report names the default.
if (session.reportedOptions.model === retired) {
delete session.reportedOptions.model
}
if (session.appliedOptions?.model === retired) {
const { model: _retired, ...applied } = session.appliedOptions
session.appliedOptions = applied
}
},
(error: unknown) =>
deps.logger?.warn('putting a retired Claude model back on the default failed', {
scope: 'claude-retired-model-reset',
sessionId,
error
})
)
.finally(() => finishClaudeInputWrite(session, sequence))
}
@@ -231,7 +231,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
}
}
readOptions = (input: { sessionId: string; fence: number }) =>
readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs)
readClaudeStructuredSessionOptions(this.session(input.sessionId))
readOptionRestoreFailures = (sessionId: string): readonly string[] => {
const session = this.sessions.get(sessionId)
return session?.retiredModel !== undefined && !session.options.has('model') ? ['model'] : []
@@ -79,9 +79,11 @@ export function observeClaudeFastModeFacts(session: ClaudeSession, value: unknow
}
if (facts.disabledReason) {
session.fastModeDisabledReason = facts.disabledReason
session.fastModeDisabledMutation = session.optionMutationSequence
} else if (facts.state || facts.disabledReasonReported) {
// A state frame without a reason clears the block; the child omits the key instead of sending null.
delete session.fastModeDisabledReason
delete session.fastModeDisabledMutation
}
}
@@ -118,14 +120,36 @@ export function claudeFallbackModelOptions(): WireClaudeModel[] {
export async function readClaudeStructuredSessionOptions(
session: ClaudeSession,
timeoutMs: number | undefined
_timeoutMs?: number
): Promise<StructuredAgentSessionLiveOptions> {
const readMutationSequence = session.optionMutationSequence
const settings = await session.connection.getSettings({ timeoutMs }).catch(() => null)
observeClaudeSettingsReadback(session, settings, readMutationSequence)
return claudeStructuredSessionOptionsFrom(session, null)
}
/** Native turn completion owns optional settings observations, independently of user actions. */
export async function refreshClaudeStructuredSessionOptions(
session: ClaudeSession,
timeoutMs: number | undefined,
current: () => boolean,
report: (options: StructuredAgentSessionLiveOptions) => void
): Promise<void> {
const sequence = session.optionMutationSequence
const token = { pending: session.settingsObservation?.pending ?? false }
session.settingsObservation = token
const connection = session.connection
const settings = await connection.getSettings({ timeoutMs }).catch(() => null)
if (
!current() ||
connection.closed ||
token.pending ||
session.settingsObservation !== token ||
sequence !== session.optionMutationSequence
) {
return
}
observeClaudeSettingsReadback(session, settings, sequence)
report(claudeStructuredSessionOptionsFrom(session, null))
}
function observeClaudeSettingsReadback(
session: ClaudeSession,
settings: unknown,
@@ -18,7 +18,11 @@ import { ClaudePromptRegistry } from '../claude-prompt-registry'
import { adoptClaudeStructuredSpawnOptions } from '../claude-structured-spawn-options'
import { observeClaudeExecutionModel } from '../claude-structured-execution-observations'
import { observeClaudeFastModeFacts } from '../claude-structured-session-options'
import { createClaudeSessionPublication } from './claude-structured-session-publication'
import {
claudeSessionInitialization,
createClaudeSessionPublication,
reportClaudeOptions
} from './claude-structured-session-publication'
import * as sessionState from './claude-structured-session-state'
import { resolveClaudeAcquisitionError } from '../claude-structured-session-close'
import { withObservedProviderExit } from '../../native-chat/agent-session-wire/structured-agent-session-failure-text'
@@ -39,7 +43,10 @@ export async function acquireClaudeSession({
acquisitions,
exits,
callbacks
}: sessionState.ClaudeSessionAcquisitionInput): Promise<AgentSessionAcquisition> {
}: Omit<
Parameters<typeof resolveClaudeAcquisitionLaunch>[0],
'previous' | 'attempt'
>): Promise<AgentSessionAcquisition> {
const sessionId = input.identity.sessionId
const prompts = new ClaudePromptRegistry()
const { previous, attempt } = acquisitions.start(sessionId, prompts)
@@ -97,6 +104,9 @@ export async function acquireClaudeSession({
if (liveSession) {
liveSession.leafUuid = observedLeafUuid
observeClaudeFastModeFacts(liveSession, message)
if (message.type === 'result' && sessions.get(sessionId) === liveSession) {
reportClaudeOptions(liveSession, sessions, input, deps.requestTimeoutMs, attempt, callbacks)
}
if (claudeSessionStateEndsTurn(message) && sessions.get(sessionId) === liveSession) {
settleClaudeTurnEndWaiters(liveSession)
}
@@ -257,19 +267,9 @@ export async function acquireClaudeSession({
process,
acquisitionGeneration: sessionState.mintClaudeAcquisitionGeneration(deps),
options: launch.savedOptions.options,
initialization: {
proof: initProof,
account: launch.account,
resumesTranscript: launch.resumesTranscript,
claudeConfigDir: launch.claudeConfigDir,
cwd: launch.cwd,
history: {
leafUuid: launch.resumeLeafUuid,
covered: !launch.resumesTranscript || launch.resumeLeafUuid === null,
contextActivity
},
onLateRefusal: (error) => callbacks.handleExit(sessionId, attempt, error)
},
initialization: claudeSessionInitialization(launch, initProof, contextActivity, (error) =>
callbacks.handleExit(sessionId, attempt, error)
),
...(deps.mintLinkId ? { linkId: deps.mintLinkId() } : {}),
observedAt: deps.now?.() ?? Date.now()
})
@@ -1,11 +1,41 @@
import type { AgentSessionAcquisition } from '../../native-chat/agent-session-wire/structured-agent-session-adapter'
import { refreshClaudeStructuredSessionOptions } from '../claude-structured-session-options'
import type {
AgentSessionAcquisition,
StructuredAgentSessionAcquireInput
} from '../../native-chat/agent-session-wire/structured-agent-session-adapter'
import { claudeProviderHandleLink } from './claude-structured-owner-identity'
import type { ClaudePromptRegistry } from '../claude-prompt-registry'
import type { ClaudeJournalTranslator } from '../claude-journal-translator-contract'
import type { ClaudeSession } from './claude-structured-session-state'
import type {
ClaudeSession,
ClaudeAcquisitionAttempt,
ClaudeAcquireCallbacks
} from './claude-structured-session-state'
import { ClaudeBackgroundTaskTracker } from '../claude-background-task-tracker'
import { ClaudeChildWorkDecoder } from '../claude-child-work-decoder'
import { ClaudeSlashCommandCatalog } from '../claude-slash-command-catalog'
import type { ClaudeStructuredLaunch } from '../claude-structured-launch-resolution'
export function claudeSessionInitialization(
launch: ClaudeStructuredLaunch,
proof: ClaudeSession['initialization']['proof'],
contextActivity: number,
onLateRefusal: ClaudeSession['initialization']['onLateRefusal']
): ClaudeSession['initialization'] {
return {
proof,
account: launch.account,
resumesTranscript: launch.resumesTranscript,
claudeConfigDir: launch.claudeConfigDir,
cwd: launch.cwd,
history: {
leafUuid: launch.resumeLeafUuid,
covered: !launch.resumesTranscript || launch.resumeLeafUuid === null,
contextActivity
},
onLateRefusal
}
}
export function createClaudeSessionPublication(input: {
connection: ClaudeSession['connection']
@@ -77,3 +107,30 @@ export function createClaudeSessionPublication(input: {
}
}
}
/** Optional reporting keeps the same acquisition and connection as native result publication. */
export function reportClaudeOptions(
session: ClaudeSession,
sessions: Map<string, ClaudeSession>,
input: StructuredAgentSessionAcquireInput,
timeoutMs: number | undefined,
attempt: ClaudeAcquisitionAttempt,
callbacks: ClaudeAcquireCallbacks
): void {
const sessionId = input.identity.sessionId
void refreshClaudeStructuredSessionOptions(
session,
timeoutMs,
() => sessions.get(sessionId) === session,
(options) =>
callbacks.deliver(attempt, sessionId, () =>
callbacks.emit(session, input.events, {
type: 'options-reported',
sessionId,
fence: session.fence,
acquisitionGeneration: session.acquisitionGeneration,
reportedOptions: options.current
})
)
).catch(() => {})
}
@@ -188,7 +188,6 @@ describe('Claude host-awaited open', () => {
})
).resolves.toMatchObject({ state: 'admitted' })
expect(connection.calls.filter((call) => call.subtype === 'apply_flag_settings')).toEqual([
{ subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'high' } } },
{ subtype: 'apply_flag_settings', params: { settings: { fastMode: true } } }
])
expect(connection.sent).toHaveLength(1)
@@ -236,8 +236,10 @@ async function settleClaudeStartupSettings(
): Promise<void> {
const { session } = input
const sequence = session.optionMutationSequence
const token = { pending: session.settingsObservation?.pending ?? false }
session.settingsObservation = token
const settings = await input.readSettings()
if (!input.isCurrent()) {
if (!input.isCurrent() || token.pending || session.settingsObservation !== token) {
return
}
let init: ClaudeInitObservation | null
@@ -173,6 +173,7 @@ export type ClaudeSession = {
fastModeReportedMutation?: number
fastModeState?: AgentSessionFastModeState
fastModeDisabledReason?: string
fastModeDisabledMutation?: number
fastModePerSessionOptIn?: boolean
/** Mutation sequence of the last model report; newer writes outrank it. */
reportedModelMutation: number
@@ -198,6 +199,7 @@ export type ClaudeSession = {
/** Monotonic fence advanced when a dispatch starts, including unresolved dispatches. */
dispatchSequence: number
/** Fences overlapping option writes so a late completion cannot restore stale state. */
settingsObservation?: { pending: boolean }
optionMutationSequence: number
/** Latest resume point written at a turn end; close and exit persist after it settles. */
resumePointWrite?: { leafUuid: string; settled: Promise<void> }
@@ -351,15 +353,6 @@ export async function cancelClaudeAcquisitionAttempt(
})
}
export type ClaudeSessionAcquisitionInput = {
input: SessionAdapter.StructuredAgentSessionAcquireInput
deps: ClaudeStructuredSessionAdapterDeps
sessions: Map<string, ClaudeSession>
acquisitions: ClaudeAcquisitionRegistry
exits: Map<string, ClaudeSessionExit>
callbacks: ClaudeAcquireCallbacks
}
export type ClaudeAcquireCallbacks = {
deliver: (attempt: ClaudeAcquisitionAttempt, sessionId: string, event: () => void) => void
emit: (
+22 -4
View File
@@ -1,3 +1,7 @@
import {
assertStructuredAgentResolutionCurrent,
type StructuredAgentResolutionOperation
} from '../native-chat/structured-agent-command-resolution'
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'
@@ -46,26 +50,33 @@ export function createCodexModelCatalogProbe(
const resolve = async (
accountHome: Parameters<AgentModelCatalogProbe>[0],
cwd?: string,
lookup: AgentModelCatalogLookup = 'recover'
lookup: AgentModelCatalogLookup = 'recover',
operation?: StructuredAgentResolutionOperation
) => {
assertStructuredAgentResolutionCurrent(operation)
const accountHomePath = requireLegacyAgentSessionAccountHome(accountHome).path
const overlay = { ...deps.resolveEnv?.() }
const readEnvironment =
lookup === 'saved' ? deps.resolveSavedEnvironment : deps.resolveEnvironment
const { command, environment } = await resolveCodexStructuredInvocation({
...deps,
operation,
resolveEnvironment: async () => ({ ...(await readEnvironment?.()), ...overlay }),
...(lookup === 'recover' ? {} : { refreshEnvironmentAfterMiss: undefined }),
...(cwd ? { resolveWorkingDirectory: () => cwd } : {})
})
assertStructuredAgentResolutionCurrent(operation)
const args = [
...(cwd ? codexStructuredLaunchArgs((await deps.resolveLaunchArgs?.()) ?? []) : []),
...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS
]
assertStructuredAgentResolutionCurrent(operation)
const env = { ...definedEnv(environment), CODEX_HOME: accountHomePath }
return {
identity: { command, args, cwd, overlay: Object.entries(overlay).sort() },
run: async (signal?: AbortSignal): Promise<AgentModelCatalogSuccess> => {
assertStructuredAgentResolutionCurrent(operation)
signal?.throwIfAborted()
deps.prepareHome?.(accountHomePath)
const run = deps.runSession ?? runCodexAppServerSession
const { listing, unavailable } = await run(
@@ -79,13 +90,16 @@ export function createCodexModelCatalogProbe(
...(signal ? { signal } : {})
},
async (rpc) => {
assertStructuredAgentResolutionCurrent(operation)
// Overlap model and account reads to avoid serial probe latency.
const listed = fetchCodexModelCatalogListing({ connection: rpc, cwd })
const listed = fetchCodexModelCatalogListing({ connection: rpc, cwd, operation })
void listed.catch(() => {})
assertStructuredAgentResolutionCurrent(operation)
const response: unknown = await rpc
.request('account/read', { refreshToken: false }, { timeoutMs: 2_000 })
// Failed account reads do not establish sign-out.
.catch(() => undefined)
assertStructuredAgentResolutionCurrent(operation)
if (
typeof response !== 'object' ||
response === null ||
@@ -101,6 +115,7 @@ export function createCodexModelCatalogProbe(
const signedOut = { reason: 'notSignedIn' as const, ...(account ? { account } : {}) }
// Gateways may misreport sign-out; keep models that were successfully listed.
const inHand = await listed.catch(() => null)
assertStructuredAgentResolutionCurrent(operation)
if (!inHand?.models.length) {
throw new AgentModelCatalogUnavailableError(signedOut)
}
@@ -112,6 +127,7 @@ export function createCodexModelCatalogProbe(
}
throw error
})
assertStructuredAgentResolutionCurrent(operation)
if (listing.models.length === 0) {
throw new Error('codex app-server listed no models')
}
@@ -135,7 +151,9 @@ export function createCodexModelCatalogProbe(
Pick<AgentModelCatalogProbe, 'resolve' | 'resolveLegacy'>
>(
async (home, options) =>
(await resolve(home, options?.cwd ?? process.cwd())).run(options?.signal),
{ resolve, resolveLegacy: (home) => resolve(home) }
(await resolve(home, options?.cwd ?? process.cwd(), 'recover', options?.operation)).run(
options?.signal
),
{ resolve, resolveLegacy: (home, operation) => resolve(home, undefined, 'recover', operation) }
)
}
@@ -0,0 +1,78 @@
import { agentSessionConversationView } from '../../shared/agent-session-conversation-view'
import { expect, it } from 'vitest'
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 { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture'
import { createCodexModelCatalogProbe } from './codex-model-catalog-probe'
it.each([null, 'second-page'])(
'ends the actual retired probe before a later catalog stage: cursor=%s',
async (nextCursor) => {
const ready = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const done = Promise.withResolvers<void>()
const requests: string[] = []
let signal: AbortSignal | undefined
const probe = createCodexModelCatalogProbe({
resolveEnvironment: async () => ({ PATH: '/fixture', HOME: '/fixture' }),
resolveCommand: () => '/fixture/codex',
runSession: async (invocation, body) => {
signal = invocation.signal
try {
return await body({
notify: () => {},
request: async (method) => {
requests.push(method)
if (method === 'model/list') {
ready.resolve()
await release.promise
return {
data: [{ model: 'gpt-live', supportedReasoningEfforts: [], isDefault: true }],
nextCursor
}
}
return method === 'config/read' ? { config: { model: 'gpt-live' } } : {}
}
})
} finally {
done.resolve()
}
}
})
const record = agentSessionRecordFixture({
sessionId: 'retired-codex',
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/fixture/codex-home' }
})
const store = new AgentModelCatalogStore()
const service = createAgentModelCatalogService({
store,
getConversation: () => {
const held = record
return held ? agentSessionConversationView(held) : undefined
},
getRecord: () => record,
drivesRecord: () => true,
resolveAccountHome: async () => record.accountHome,
recordWorkspacePath: async () => '/fixture/workspace',
probes: { codex: probe }
})
try {
const demand = service.demand({ agent: 'codex', sessionId: record.sessionId })
await ready.promise
expect(requests).toEqual(['model/list', 'account/read'])
store.expireAgent('codex')
await demand
expect(signal?.aborted).toBe(false)
release.resolve()
await done.promise
expect(requests).toEqual(['model/list', 'account/read'])
expect(service.snapshot({ agent: 'codex', sessionId: record.sessionId })).toEqual({
origin: 'unknown'
})
} finally {
release.resolve()
service.stop()
}
}
)
@@ -109,7 +109,10 @@ export class CodexAppliedSettings {
threadId: string,
choices: Settings,
timeoutMs = 30_000,
signal?: AbortSignal
signal?: AbortSignal,
onApplied?: () => void,
onRefused?: () => void,
onWriting?: () => void
): Promise<{ ok: true } | { ok: false; rejected: string }> {
throwIfSignalAborted(signal)
if (this.unsupported || this.equivalent(choices)) {
@@ -137,6 +140,7 @@ export class CodexAppliedSettings {
try {
const work = (async () => {
try {
onWriting?.()
await connection.request(
'thread/settings/update',
{ threadId, ...choices },
@@ -150,6 +154,7 @@ export class CodexAppliedSettings {
codexMethodUnavailable(error, 'thread/settings/update')
) {
discard()
onRefused?.()
if (codexMethodUnavailable(error, 'thread/settings/update')) {
this.unsupported = true
return
@@ -158,6 +163,7 @@ export class CodexAppliedSettings {
}
throw error
}
onApplied?.()
replied = true
check()
await changed.promise
@@ -34,8 +34,8 @@ export function codexAcquireCatalogListing(
export function codexSessionCapabilities(
session: Pick<CodexSession, 'catalogAccess' | 'options'> &
Partial<Pick<CodexSession, 'reportedOptions'>>,
model = session.options.get('model') ?? session.reportedOptions?.model
Partial<Pick<CodexSession, 'reportedOptions' | 'appliedTurnOptions'>>,
model = session.appliedTurnOptions?.model
): ReturnType<typeof structuredAgentSessionCapabilityView> {
return structuredAgentSessionCapabilityView(
codexAcquireCatalogListing(session.catalogAccess) ?? { origin: 'unknown' },
@@ -1,9 +1,21 @@
import { delimiter, isAbsolute, resolve } from 'node:path'
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import {
assertStructuredAgentResolutionCurrent,
resolveAgainAfterRefreshOnMissingProgram,
type StructuredAgentResolutionOperation
} from '../native-chat/structured-agent-command-resolution'
// How a durable session record becomes a Codex process launch.
//
// Every input is read back from the record the store already made durable, not
// from the call that triggered spawn. A client that attaches twice must
// land in the same working directory under the same account home, and a resume
// must name the thread this session actually proved — never one a caller asks
// for, which is how a resume becomes a fork wearing a resume's name.
import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home'
import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle'
import { resolveCodexCommand } from '../codex-cli/command'
import { resolveAgainAfterRefreshOnMissingProgram } from '../native-chat/structured-agent-command-resolution'
import type { StructuredAgentLaunchPartResolver } from '../runtime/structured-agent-launch-composition'
import type { CodexStructuredLaunch } from './codex-structured-session-adapter'
import { codexStructuredPermissionPolicy } from '../../shared/codex-permission-policy'
@@ -24,6 +36,7 @@ export type CodexStructuredInvocationDeps = Pick<
'resolveCommand'
> & {
signal?: AbortSignal
operation?: StructuredAgentResolutionOperation
/** Fresh spawn environment, never persisted in the session record. */
resolveEnvironment?: () => Promise<NodeJS.ProcessEnv>
/** Refresh after a missing binary; absence disables retry. */
@@ -48,21 +61,25 @@ export function resolveCodexStructuredInvocation(
env: ({ environment }) => environment,
cwd: deps.resolveWorkingDirectory,
signal: deps.signal
}
},
deps.operation
)
}
async function readCodexStructuredInvocation(
deps: CodexStructuredInvocationDeps
): Promise<CodexStructuredInvocation> {
assertStructuredAgentResolutionCurrent(deps.operation)
const environment = await waitForPromiseWithSignal(
Promise.resolve(deps.resolveEnvironment?.()),
deps.signal
)
assertStructuredAgentResolutionCurrent(deps.operation)
const cwd = await waitForPromiseWithSignal(
Promise.resolve(deps.resolveWorkingDirectory?.()),
deps.signal
)
assertStructuredAgentResolutionCurrent(deps.operation)
deps.signal?.throwIfAborted()
const pathEnv =
(environment?.PATH ?? environment?.Path)
@@ -120,7 +120,7 @@ describe('Codex captured message choices', () => {
}
})
it('resets an explicit empty choice after a thread launch and earlier turn override', async () => {
it('keeps captured omissions sparse after an earlier explicit override', async () => {
const request = vi.fn(async (method: string, _params?: Record<string, unknown>) =>
method === 'turn/start'
? { turn: { id: 'opened' } }
@@ -163,21 +163,32 @@ describe('Codex captured message choices', () => {
request.mock.calls.filter(([method]) => method === 'turn/start').map(([, params]) => params)
).toMatchObject([
{ model: 'model-a', effort: 'high', personality: 'friendly', serviceTier: 'priority' },
{ model: 'model-a', effort: 'high', personality: 'pragmatic', serviceTier: 'flex' },
{ model: 'model-a', effort: 'high' },
{ model: 'model-a', effort: 'low' }
{},
{ model: 'model-a' },
{ effort: 'low' }
])
expect(request.mock.calls.map(([method]) => method)).toEqual([
'config/read',
'turn/start',
'turn/start',
'turn/start',
'turn/start'
])
expect(Object.fromEntries(session.options)).toEqual({
model: 'model-a',
effort: 'low'
})
expect(Object.fromEntries(session.options)).toEqual({ effort: 'low' })
})
it('compiles legacy Fast without a catalog and gives an explicit tier precedence', async () => {
const request = vi.fn(async (_method: string, _params?: Record<string, unknown>) => ({
turn: { id: 'turn-fast' }
}))
const session = optionSession(request)
for (const [id, choices, tier] of [
['legacy', { fastMode: 'true' }, 'fast'],
['explicit', { fastMode: 'true', serviceTier: 'flex' }, 'flex']
] as const) {
const turnOptions = await resolvedCodexMessageOptions(session, choices)
await startCodexTurn(session, { clientMessageId: id, body: USER_MESSAGE, turnOptions })
expect(request.mock.calls.at(-1)?.[1]).toMatchObject({ serviceTier: tier })
}
})
it('reports what a turn ran: its thread effort, and a steer its turn', async () => {
@@ -186,6 +197,7 @@ describe('Codex captured message choices', () => {
)
const session = optionSession(request)
session.reportedOptions = { model: 'model-a', effort: 'xhigh' }
session.appliedTurnOptions = { model: 'model-a', effort: 'low' }
seedMessageDefaults(session)
const send = async (id: string, choices: Record<string, string>) =>
dispatchCodexTurn(
@@ -198,9 +210,10 @@ describe('Codex captured message choices', () => {
},
undefined
)
const ran = { model: 'model-a', effort: 'xhigh' }
const ran = { model: 'model-a', effort: 'low' }
expect(await send('first', { model: 'model-a' })).toMatchObject({ ranOptions: ran })
expect(request.mock.calls[0]?.[1]).toMatchObject(ran)
expect(request.mock.calls[0]?.[1]).toMatchObject({ model: 'model-a' })
expect(request.mock.calls[0]?.[1]).not.toHaveProperty('effort')
session.activeTurnIds = new Set(['turn-1'])
const steered = await send('steer', { model: 'model-a', serviceTier: 'priority' })
expect(request.mock.calls.at(-1)?.[0]).toBe('turn/steer')
@@ -209,7 +222,9 @@ describe('Codex captured message choices', () => {
})
it('inherits untouched native tier and personality without a configuration probe', async () => {
const request = vi.fn(async (_method: string) => ({ turn: { id: 'native' } }))
const request = vi.fn(async (_method: string, _params?: Record<string, unknown>) => ({
turn: { id: 'native' }
}))
const session = optionSession(request)
session.reportedOptions = { model: 'model-a', serviceTier: 'flex', serviceTierKnown: true }
const turnOptions = await resolvedCodexMessageOptions(session, {})
@@ -259,16 +274,20 @@ describe('Codex captured message choices', () => {
expect(request.mock.calls[0]?.[1]).toMatchObject({ model: 'saved-next', effort: 'high' })
})
it('refuses a default reset with no trustworthy default before prompt input', async () => {
const request = vi.fn(async () => ({ turn: { id: 'unexpected' } }))
it('sends an exact empty snapshot without consulting unknown defaults', async () => {
const request = vi.fn(async (_method: string, _params?: Record<string, unknown>) => ({
turn: { id: 'unexpected' }
}))
const session = optionSession(request)
session.reportedOptions = {}
session.options.set('model', 'old-override')
session.options.set('effort', 'high')
await expect(resolvedCodexMessageOptions(session, {})).rejects.toThrow(
'default model is unavailable'
)
expect(request).not.toHaveBeenCalled()
const turnOptions = await resolvedCodexMessageOptions(session, {})
expect(turnOptions).toEqual({})
await startCodexTurn(session, { clientMessageId: 'sparse', body: USER_MESSAGE, turnOptions })
expect(request).toHaveBeenCalledOnce()
expect(request.mock.calls[0]?.[1]).not.toHaveProperty('model')
expect(request.mock.calls[0]?.[1]).not.toHaveProperty('effort')
})
it('restores native options after a known refusal and retains them after an unknown answer', async () => {
@@ -283,16 +302,19 @@ describe('Codex captured message choices', () => {
})
)
known.options.set('model', 'prior')
known.appliedTurnOptions = { model: 'prior', effort: 'low', serviceTier: 'flex' }
await expect(
startCodexTurn(known, { clientMessageId: 'known', body, turnOptions: { model: 'attempted' } })
).rejects.toThrow('invalid model')
expect(Object.fromEntries(known.options)).toEqual({ model: 'prior' })
expect(known.appliedTurnOptions).toEqual({ model: 'prior', effort: 'low', serviceTier: 'flex' })
const unknown = optionSession(
vi.fn(async () => {
throw new Error('connection lost after write')
})
)
unknown.options.set('model', 'prior')
unknown.appliedTurnOptions = { model: 'prior', effort: 'low', serviceTier: 'flex' }
await expect(
startCodexTurn(unknown, {
clientMessageId: 'unknown',
@@ -301,5 +323,9 @@ describe('Codex captured message choices', () => {
})
).rejects.toThrow('connection lost after write')
expect(Object.fromEntries(unknown.options)).toEqual({ model: 'attempted' })
expect(unknown.appliedTurnOptions).toEqual({
effort: 'low',
serviceTier: 'flex'
})
})
})
@@ -6,37 +6,24 @@ import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import { StructuredAgentSessionInputNotAdmitted } from '../native-chat/agent-session-wire/structured-agent-session-input-admission'
import { agentSessionFailureFact } from '../../shared/agent-session-failure'
import { codexDispatchRejection } from './codex-structured-turn-end-settlement'
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options'
import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error'
import { readCodexConfiguredLaunchDefaults } from './codex-configured-launch-defaults'
import { codexAcquireCatalogListing } from './codex-structured-catalog-entry'
import { CODEX_DEFAULT_SERVICE_TIER } from './codex-structured-service-tier'
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { CodexSession } from './codex-structured-session-state'
import { applyCodexStructuredSessionOption } from './codex-structured-session-options'
function codexModelDefaults(session: CodexSession, selected?: string) {
const listing = codexAcquireCatalogListing(session.catalogAccess)
const model =
selected ??
session.reportedOptions.model ??
listing?.models.find((entry) => entry.isDefault)?.id
const defaultEffort =
(model === session.reportedOptions.model ? session.reportedOptions.effort : undefined) ??
listing?.models.find((entry) => entry.id === model)?.defaultEffort
return { model, defaultEffort }
}
import { codexSessionCapabilities } from './codex-structured-catalog-entry'
import { codexServiceTierOfFastMode } from './codex-structured-session-options'
import { codexEffectiveModel, codexTurnRequestOptions } from './codex-structured-service-tier'
export function resolvedCodexGoalSettings(
session: CodexSession,
_session: CodexSession,
choices: AgentSessionExecutionOptions = {}
) {
const { model, defaultEffort } = codexModelDefaults(session, choices.model)
const effort = choices.effort ?? defaultEffort
return { ...(model ? { model } : {}), ...(effort ? { effort } : {}) }
return {
...(choices.model !== undefined ? { model: choices.model } : {}),
...(choices.effort !== undefined ? { effort: choices.effort } : {})
}
}
/** Resolve per-message overrides without replacing the session's saved picks. */
/** Captured fields are overrides; omitted fields inherit the native thread's state. */
export async function resolvedCodexMessageOptions(
session: CodexSession,
choices: AgentSessionExecutionOptions | undefined
@@ -44,88 +31,47 @@ export async function resolvedCodexMessageOptions(
if (choices === undefined) {
return undefined
}
const priorModel = session.options.get('model')
const priorEffort = session.options.get('effort')
// Keep the thread's effort unless switching models.
const { model, defaultEffort } = codexModelDefaults(session, choices.model)
if (!model && (choices.effort || priorModel || priorEffort)) {
throw new AgentSessionOptionRejectedError(new Error('codex default model is unavailable'))
}
if (choices.effort === undefined && priorEffort && !defaultEffort) {
throw new AgentSessionOptionRejectedError(new Error('codex default effort is unavailable'))
}
// Capture configuration before our first override, never from mutable turn reports.
if (
(choices.personality && !session.nativeDefaults?.personality) ||
((choices.serviceTier || choices.fastMode) && !session.nativeDefaults?.serviceTier)
) {
const configured = await readCodexConfiguredLaunchDefaults(session.connection)
session.nativeDefaults = {
...session.nativeDefaults,
...(configured?.personality && !session.nativeDefaults?.personality
? { personality: configured.personality }
: {}),
...(configured?.serviceTier && !session.nativeDefaults?.serviceTier
? { serviceTier: configured.serviceTier }
: {})
}
}
const restored: Record<string, string> = {}
for (const key of ['personality', 'serviceTier'] as const) {
const applied = session.appliedTurnOptions?.[key]
if (
choices[key] !== undefined ||
(key === 'serviceTier' && choices.fastMode !== undefined) ||
!applied
) {
continue
}
const baseline = session.nativeDefaults?.[key]
if (!baseline) {
throw new AgentSessionOptionRejectedError(new Error(`codex default ${key} is unavailable`))
}
if (applied !== baseline) {
restored[key] = baseline
}
}
const selected = {
...session,
options: new Map<string, string>(Object.entries(restored)),
reportedOptions: { ...session.reportedOptions, effort: undefined }
}
if (
model &&
(choices.model ||
priorModel ||
priorEffort ||
choices.effort ||
choices.fastMode === 'true' ||
(choices.serviceTier && choices.serviceTier !== CODEX_DEFAULT_SERVICE_TIER))
) {
// Preserve previously admitted choices across installation changes.
selected.options.set('model', model)
if (!defaultEffort && choices.effort === undefined) {
selected.options.delete('effort')
}
if (
choices.effort === undefined &&
defaultEffort &&
(priorEffort || choices.model || selected.options.has('effort'))
) {
selected.options.set('effort', defaultEffort)
}
}
for (const key of ['effort', 'personality', 'fastMode', 'serviceTier'] as const) {
return compileCodexMessageOptions(session, choices)
}
function compileCodexMessageOptions(
session: CodexSession,
choices: Readonly<Record<string, string | undefined>>
): Readonly<Record<string, string>> {
const compiled: Record<string, string> = {}
for (const key of ['model', 'effort', 'personality', 'serviceTier'] as const) {
const value = choices[key]
if (value !== undefined) {
if (key === 'fastMode') {
await applyCodexStructuredSessionOption(selected, key, value)
} else {
selected.options.set(key, value)
}
compiled[key] = value
}
}
return Object.fromEntries(selected.options)
if (choices.serviceTier === undefined && choices.fastMode !== undefined) {
const model = codexSessionCapabilities(session).models.find(
(row) => row.id === codexEffectiveModel(session, compiled)
)
compiled.serviceTier = codexServiceTierOfFastMode(choices.fastMode, model)
}
return codexTurnRequestOptions(session, compiled)
}
/** The same sparse controls as dispatch, compared under the exact acquisition that took them. */
export function readCodexAppliedInputOptions(
session: CodexSession | undefined,
input: Parameters<NonNullable<StructuredAgentSessionAdapter['readAppliedInputOptions']>>[0]
): ReturnType<NonNullable<StructuredAgentSessionAdapter['readAppliedInputOptions']>> {
if (
!session ||
session.ended ||
session.connection.closed ||
session.fence !== input.fence ||
session.acquisitionGeneration !== input.acquisitionGeneration ||
!session.appliedTurnOptions
) {
return null
}
const active = session.appliedTurnOptions
const requested = input.next ?? Object.fromEntries(session.options)
return { active, next: { ...active, ...compileCodexMessageOptions(session, requested) } }
}
/** Check the live binding after options settle and again whenever the provider is about to write. */
@@ -5,6 +5,10 @@ import type {
} from '../../shared/agent-session-wire'
import { effectiveServiceTier } from '../../shared/structured-agent-session-effective-tier'
import type { CodexAppServerConnection } from './app-server/codex-app-server-connection-types'
import {
assertStructuredAgentResolutionCurrent,
type StructuredAgentResolutionOperation
} from '../native-chat/structured-agent-command-resolution'
import { structuredAgentSessionOptionModels } from '../native-chat/agent-session-wire/structured-agent-session-option-models'
import {
CODEX_DEFAULT_SERVICE_TIER,
@@ -86,7 +90,7 @@ function modelOption(value: unknown): AgentSessionModelOption | null {
}
}
export type CodexSessionOptionCatalog = AgentSessionOptionsResult & { current: { model: string } }
export type CodexSessionOptionCatalog = AgentSessionOptionsResult
export type CodexModelCatalogListing = {
models: AgentSessionModelOption[]
@@ -101,10 +105,15 @@ export async function fetchCodexModelCatalogListing(input: {
timeoutMs?: number
deadlineMs?: number
cwd?: string
operation?: StructuredAgentResolutionOperation
}): Promise<CodexModelCatalogListing> {
const deadline = input.deadlineMs === undefined ? null : Date.now() + input.deadlineMs
const deadline = Math.min(
input.operation?.deadline ?? Infinity,
input.deadlineMs === undefined ? Infinity : Date.now() + input.deadlineMs
)
const remainingTimeout = (): number | undefined => {
if (deadline === null) {
assertStructuredAgentResolutionCurrent(input.operation)
if (!Number.isFinite(deadline)) {
return input.timeoutMs
}
const remaining = deadline - Date.now()
@@ -124,6 +133,7 @@ export async function fetchCodexModelCatalogListing(input: {
{ timeoutMs: remainingTimeout() }
)
)
assertStructuredAgentResolutionCurrent(input.operation)
if (!Array.isArray(response?.data)) {
complete = false
}
@@ -154,6 +164,7 @@ export async function fetchCodexModelCatalogListing(input: {
remainingTimeout(),
input.cwd
)
assertStructuredAgentResolutionCurrent(input.operation)
return {
models: applyCodexConfiguredLaunchDefaults(parsedModels, configured),
complete: complete && cursor === null && configured !== null,
@@ -186,10 +197,7 @@ export function composeCodexSessionOptionCatalog(
input.current.model,
(row) => row
)
const model = input.current.model ?? models.find((entry) => entry.isDefault)?.id ?? models[0]?.id
if (!model) {
throw new Error('codex app-server returned no available models')
}
const model = input.current.model
const reportedTier = input.reportedServiceTierKnown
? (input.reportedServiceTier ?? CODEX_DEFAULT_SERVICE_TIER)
: undefined
@@ -216,28 +224,13 @@ export function composeCodexSessionOptionCatalog(
models,
...(support ? { fastModeSupport: support } : {}),
current: {
model,
...(model ? { model } : {}),
...(input.current.effort ? { effort: input.current.effort } : {}),
...(serviceTier ? { serviceTier } : {}),
...(fastMode !== undefined ? { fastMode } : {}),
...(reportedTier !== undefined && input.current.serviceTier === undefined
? { confirmed: fastMode === undefined ? ['serviceTier'] : ['serviceTier', 'fastMode'] }
: {}),
...(input.current.model ? {} : { defaulted: ['model'] as const })
: {})
}
}
}
export async function readCodexStructuredSessionOptionCatalog(input: {
connection: Pick<CodexAppServerConnection, 'request'>
current: { model?: string; effort?: string; serviceTier?: string }
reportedServiceTier?: string | null
reportedServiceTierKnown?: boolean
timeoutMs?: number
}): Promise<CodexSessionOptionCatalog> {
const listing = await fetchCodexModelCatalogListing({
connection: input.connection,
...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs })
})
return composeCodexSessionOptionCatalog(listing, input)
}
@@ -24,6 +24,7 @@ export function optionSession(request: CodexAppServerConnection['request']): Cod
prompts: new CodexAcquisitionWindow().prompts,
options: new Map(),
reportedOptions: { model: 'gpt-live', effort: 'high' },
appliedTurnOptions: { model: 'gpt-live', effort: 'high' },
dispatchEchoes: createCodexDispatchEchoes(),
turnOpenWaits: createCodexTurnOpenWaits(),
translator: null
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import { CODEX_SESSION_OPTION_CATALOG } from '../../shared/agent-session-option-catalog-claude-codex'
import type { AgentSessionModelOption } from '../../shared/agent-session-wire'
import {
applyStructuredAgentSessionModelCatalog,
applyStructuredAgentSessionOptions,
commitStructuredAgentSessionOption,
createStructuredAgentSessionOptionState,
@@ -14,6 +15,7 @@ import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo'
import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait'
import type { CodexSession } from './codex-structured-session-state'
import { resolvedCodexMessageOptions } from './codex-structured-message-options'
import { startCodexTurn } from './codex-structured-turn-start'
import { readLiveCodexSessionOptions } from './codex-structured-session-options'
@@ -26,7 +28,8 @@ const MODELS: AgentSessionModelOption[] = [
async function hostRuns(
saved: Record<string, string>,
ageMs = 0,
child?: { tiers?: readonly string[]; reported?: string }
child?: { tiers?: readonly string[]; reported?: string },
observeProjection?: (projected: Readonly<Record<string, string>> | undefined) => void
) {
let now = 1
const store = new AgentModelCatalogStore({ now: () => now })
@@ -74,6 +77,10 @@ async function hostRuns(
prompts: new CodexAcquisitionWindow().prompts,
options: new Map(Object.entries(saved)),
reportedOptions: child?.reported ? { serviceTier: child.reported, serviceTierKnown: true } : {},
appliedTurnOptions: {
...(saved.model ? { model: saved.model } : {}),
...(child?.reported ? { serviceTier: child.reported } : {})
},
catalogAccess: { store, fingerprint: 'codex-account', accountHomePath: '/account' },
dispatchEchoes: createCodexDispatchEchoes(),
turnOpenWaits: createCodexTurnOpenWaits(),
@@ -84,8 +91,9 @@ async function hostRuns(
: undefined
if (child) {
offline = true
store.recordFailure('codex-account', 'offline', 'codex')
await store.refresh('codex-account', 'codex', () => Promise.reject(new Error('offline')))
}
observeProjection?.(await resolvedCodexMessageOptions(session, saved))
await startCodexTurn(session, {
clientMessageId: 'message-1',
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }
@@ -102,7 +110,12 @@ async function hostRuns(
/** Older hosts omit the defaulted marker. */
function answered(saved: Record<string, string>, marksDefaults: boolean) {
return applyStructuredAgentSessionOptions(
createStructuredAgentSessionOptionState('codex', CODEX_SESSION_OPTION_CATALOG),
applyStructuredAgentSessionModelCatalog(
createStructuredAgentSessionOptionState('codex', CODEX_SESSION_OPTION_CATALOG),
CODEX_SESSION_OPTION_CATALOG,
{ origin: 'probe', models: MODELS, fetchedAt: 1 },
{ newLaunch: false, coherentCatalog: true }
),
CODEX_SESSION_OPTION_CATALOG,
{ models: MODELS, current: { ...saved, ...(marksDefaults ? { defaulted: [] } : {}) } }
)
@@ -127,7 +140,7 @@ describe("a Codex chat's speed, shown and sent", () => {
] as const) {
await expect(hostRuns({ model: 'm2', serviceTier: tier }, 120_000, child)).resolves.toEqual({
firstTurn: runs,
shown: runs,
shown: 'reported' in child ? child.reported : undefined,
afterLiveRead: tier
})
}
@@ -138,10 +151,14 @@ describe("a Codex chat's speed, shown and sent", () => {
[{ model: 'm2', serviceTier: 'flex' }, 'default', 'flex', 'default'],
[{ model: 'm1', serviceTier: 'priority' }, 'priority', 'priority', 'priority']
] as const) {
expect(shownTier(answered(saved, true))).toBe(shown)
expect(shownTier(answered(saved, false))).toBe(olderShown)
for (const ageMs of [0, 2 * 60_000]) {
await expect(hostRuns(saved, ageMs)).resolves.toEqual({
await expect(
hostRuns(saved, ageMs, undefined, (projected) => {
expect(projected).toBeDefined()
expect(shownTier(answered({ ...saved, ...projected }, true))).toBe(shown)
})
).resolves.toEqual({
firstTurn: runs,
afterLiveRead: saved.serviceTier
})
@@ -124,6 +124,74 @@ describe('Codex structured service tier without send-path catalog waits', () =>
})
describe('Codex service tier across clients and rest', () => {
it.each([
['opening null', null, 'default'],
['configured tier', undefined, 'priority'],
['configuration failure', undefined, undefined]
] as const)(
'seeds exact-child tier knowledge from %s without filling a sparse request',
async (name, openingTier, expected) => {
const codex = fakeCodex({
'thread/start': () => ({
thread: { id: THREAD_ID },
model: 'gpt-live',
reasoningEffort: 'high',
...(openingTier === null ? { serviceTier: null } : {})
}),
'config/read': () => {
if (name === 'configuration failure') {
throw new Error('config unavailable')
}
return {
config: {
service_tier: 'priority',
model: 'unrelated-config-model',
model_reasoning_effort: 'low'
}
}
}
})
codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'sparse')
const adapter = adapterFor(codex)
try {
const acquired = await acquireReadyCodexForTest(adapter, {
identity: identityFor('session-1'),
fence: 7,
spawnToken: 'spawn-1'
})
const applied = adapter.readAppliedInputOptions({
sessionId: 'session-1',
fence: 7,
acquisitionGeneration: acquired.acquisitionGeneration,
next: {}
})
expect(applied?.active).toEqual({
model: 'gpt-live',
effort: 'high',
...(expected ? { serviceTier: expected } : {})
})
const config = codex.connections[0].calls.filter((call) => call.method === 'config/read')
expect(config).toHaveLength(openingTier === null ? 0 : 1)
if (config.length) {
expect(config[0].params).toEqual({ cwd: '/work/repo' })
}
await adapter.dispatch({
sessionId: 'session-1',
fence: 7,
clientMessageId: 'sparse',
body: USER_MESSAGE,
executionOptions: {}
})
const sent = codex.connections[0].calls.find((call) => call.method === 'turn/start')?.params
expect(sent).not.toHaveProperty('model')
expect(sent).not.toHaveProperty('effort')
expect(sent).not.toHaveProperty('serviceTier')
} finally {
await adapter.closeAll()
}
}
)
it('applies an older client Fast toggle through the adapter as the Fast tier', async () => {
const codex = fakeCodex({ 'model/list': () => listing() })
codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-fast')
@@ -168,6 +236,21 @@ describe('Codex service tier across clients and rest', () => {
const turn = codex.connections[0].calls.find((call) => call.method === 'turn/start')?.params
expect(turn).toMatchObject({ serviceTier: 'priority' })
expect(turn).not.toHaveProperty('fastMode')
finishTurn(codex, 'turn-fast')
codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'captured-fast')
await adapter.dispatch({
sessionId: 'session-1',
fence: 7,
clientMessageId: 'captured-fast',
body: USER_MESSAGE,
executionOptions: { fastMode: 'true' }
})
const captured = codex.connections[0].calls.findLast(
(call) => call.method === 'turn/start'
)?.params
expect(captured).toMatchObject({ serviceTier: 'priority' })
expect(captured).not.toHaveProperty('model')
expect(captured).not.toHaveProperty('effort')
})
it('takes a tier at rest but not an older client Fast toggle', () => {
@@ -106,15 +106,21 @@ export function codexOffersServiceTier(
)
}
/** Fall back to standard for unoffered tiers without changing saved intent. */
export const codexEffectiveModel = (
session: { appliedTurnOptions?: Readonly<Record<string, string>> },
options: Readonly<Record<string, string>>
): string | undefined => options.model ?? session.appliedTurnOptions?.model
/** A turn's options: an unoffered tier runs as standard; the chat's saved tier is kept. */
export function codexTurnRequestOptions(
session: Parameters<typeof codexSessionCapabilities>[0]
session: Parameters<typeof codexSessionCapabilities>[0],
supplied: Readonly<Record<string, string>> = Object.fromEntries(session.options)
): Record<string, string> {
const options = Object.fromEntries(session.options)
const options = { ...supplied }
const reportedOptions = session.reportedOptions ?? {}
const capabilities = codexSessionCapabilities(session)
const models = capabilities.models
const model = models.find((entry) => entry.id === (options.model ?? reportedOptions.model))
const model = models.find((entry) => entry.id === codexEffectiveModel(session, options))
const tier = options.serviceTier
if (
tier &&
@@ -45,6 +45,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
expect(codex.connections[0].calls.map((call) => call.method)).toEqual([
'initialize',
'thread/start',
'config/read',
'skills/list'
])
})
@@ -15,10 +15,12 @@ import { supportsCodexStructuredLocation } from './codex-structured-location-sup
import { CodexStructuredSessionTeardown } from './codex-structured-session-teardown'
import {
applyCodexStructuredSessionOption,
prepareLiveCodexSessionOptions,
readLiveCodexSessionOptions
} from './codex-structured-session-options'
import { prepareCodexMessageDispatch } from './codex-structured-message-options'
import {
prepareCodexMessageDispatch,
readCodexAppliedInputOptions
} from './codex-structured-message-options'
import {
CodexAcquisitionRegistry,
requireLiveCodexSession,
@@ -308,12 +310,13 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
readOptions = (input: { sessionId: string; fence: number }) =>
readLiveCodexSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs)
readAppliedInputOptions: NonNullable<StructuredAgentSessionAdapter['readAppliedInputOptions']> = (
input
) => readCodexAppliedInputOptions(this.sessions.get(input.sessionId), input)
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)
closeSession: NonNullable<StructuredAgentSessionAdapter['closeSession']> = (...args) =>
this.teardown.close(...args)
forceCloseSession = (sessionId: string): Promise<boolean> => this.teardown.forceClose(sessionId)
@@ -57,6 +57,7 @@ it('uses installed choices after an update and keeps saved values for the next t
['model', 'A'],
['effort', 'low']
])
session.appliedTurnOptions = { model: 'A', effort: 'low' }
const store = new AgentModelCatalogStore()
const install = (models: AgentSessionOptionsResult['models']) =>
store.recordSuccess('scope', 'codex', { origin: 'probe', complete: true, models }, 'discovery')
@@ -70,9 +71,9 @@ it('uses installed choices after an update and keeps saved values for the next t
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'
)
await expect(applyCodexStructuredSessionOption(session, 'effort', 'low')).resolves.toMatchObject({
effort: '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'])
@@ -9,8 +9,6 @@ import {
import { describe, expect, it, vi } from 'vitest'
import {
applyCodexStructuredSessionOption,
readCodexStructuredSessionOptions,
readCodexStructuredSessionOptionCatalog,
readLiveCodexSessionOptions,
restoredCodexSessionOptions
} from './codex-structured-session-options'
@@ -20,6 +18,19 @@ import { startCodexTurn } from './codex-structured-turn-start'
import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store'
import { optionSession } from './codex-structured-option-session.test-fixture'
import {
fetchCodexModelCatalogListing,
composeCodexSessionOptionCatalog
} from './codex-structured-model-catalog'
async function readCodexStructuredSessionOptionCatalog(
input: Parameters<typeof fetchCodexModelCatalogListing>[0] &
Parameters<typeof composeCodexSessionOptionCatalog>[1]
) {
return composeCodexSessionOptionCatalog(await fetchCodexModelCatalogListing(input), input)
}
const readCodexStructuredSessionOptions = readCodexStructuredSessionOptionCatalog
async function primePicker(session: CodexSession): Promise<void> {
session.catalogAccess = {
store: new AgentModelCatalogStore(),
@@ -58,7 +69,7 @@ describe('structured Codex session options', () => {
expect(request).not.toHaveBeenCalled()
})
it('drops an effort saved under another model on a cold model change', async () => {
it('preserves the saved effort through a cold model change', async () => {
const session = optionSession(vi.fn(async () => ({ data: [] })))
session.options.set('model', 'gpt-live')
session.options.set('effort', 'xhigh')
@@ -68,7 +79,8 @@ describe('structured Codex session options', () => {
effort: 'xhigh'
})
await expect(applyCodexStructuredSessionOption(session, 'model', 'gpt-next')).resolves.toEqual({
model: 'gpt-next'
model: 'gpt-next',
effort: 'xhigh'
})
})
@@ -83,13 +95,15 @@ describe('structured Codex session options', () => {
expect(request).not.toHaveBeenCalled()
})
it('refuses an older client Fast on while nothing names the Fast tier', async () => {
it('encodes legacy Fast with the native value when no menu names its tier', async () => {
const session = optionSession(vi.fn(async () => ({ data: [] })))
await expect(applyCodexStructuredSessionOption(session, 'fastMode', 'true')).rejects.toThrow(
'does not support Fast mode'
)
expect(session.options.has('serviceTier')).toBe(false)
await expect(
applyCodexStructuredSessionOption(session, 'fastMode', 'true')
).resolves.toMatchObject({
serviceTier: 'fast'
})
expect(session.options.get('serviceTier')).toBe('fast')
})
it('filters restored records to recognized turn options', () => {
@@ -193,7 +207,7 @@ describe('structured Codex session options', () => {
).toEqual({ model: 'gpt-live', effort: 'high' })
})
it('reconciles an incompatible effort when only the model changes', async () => {
it('does not invent effort when only the model changes', async () => {
const session = optionSession(
vi.fn(async () => ({
data: [
@@ -214,12 +228,11 @@ describe('structured Codex session options', () => {
await primePicker(session)
await expect(applyCodexStructuredSessionOption(session, 'model', 'gpt-fast')).resolves.toEqual({
model: 'gpt-fast',
effort: 'low'
model: 'gpt-fast'
})
})
it('rejects values absent from the provider catalog', async () => {
it('keeps literal values absent from the provider catalog', async () => {
const session = optionSession(
vi.fn(async () => ({
data: [{ model: 'gpt-live', supportedReasoningEfforts: [] }],
@@ -230,10 +243,10 @@ describe('structured Codex session options', () => {
await primePicker(session)
await expect(
applyCodexStructuredSessionOption(session, 'model', 'not-entitled')
).rejects.toThrow('does not offer model not-entitled')
await expect(applyCodexStructuredSessionOption(session, 'effort', 'high')).rejects.toThrow(
'does not support high'
)
).resolves.toMatchObject({ model: 'not-entitled' })
await expect(
applyCodexStructuredSessionOption(session, 'effort', 'high')
).resolves.toMatchObject({ effort: 'high' })
})
it('maps canonical Fast on and off to the exact advertised tier and Standard', async () => {
@@ -318,13 +331,13 @@ describe('structured Codex session options', () => {
serviceTier: 'unrecognized-tier',
confirmed: ['serviceTier']
})
// Listing defaults are not saved user intent.
// A menu default cannot fill an unknown applied model.
await expect(
readCodexStructuredSessionOptions({ connection, current: {} })
).resolves.toMatchObject({ current: { model: 'gpt-live', defaulted: ['model'] } })
).resolves.toMatchObject({ current: {} })
})
it('hides and rejects Fast mode when the running catalog does not advertise it', async () => {
it('keeps catalog presentation separate from legacy native Fast input', async () => {
const session = optionSession(
vi.fn(async () => ({
data: [
@@ -343,9 +356,11 @@ describe('structured Codex session options', () => {
models: [expect.objectContaining({ supportsFastMode: false })],
fastModeSupport: { supported: false }
})
await expect(applyCodexStructuredSessionOption(session, 'fastMode', 'true')).rejects.toThrow(
'does not support Fast mode'
)
await expect(
applyCodexStructuredSessionOption(session, 'fastMode', 'true')
).resolves.toMatchObject({
serviceTier: 'fast'
})
})
it('runs a restored tier as Standard without erasing the pick when the selected model no longer lists it', async () => {
@@ -370,9 +385,9 @@ 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((await readLiveCodexSessionOptions(session, undefined)).current).not.toHaveProperty(
'serviceTier'
)
expect(Object.fromEntries(session.options)).toEqual({ serviceTier: 'priority' })
await startCodexTurn(session, {
@@ -397,7 +412,7 @@ describe('structured Codex session options', () => {
session.options.set('serviceTier', 'priority-saved')
const options = await readLiveCodexSessionOptions(session, undefined)
expect(options.current).toMatchObject({ serviceTier: 'priority-saved' })
expect(options.current).not.toHaveProperty('serviceTier')
expect(options.current).not.toHaveProperty('fastMode')
expect(options.models[0]?.serviceTiers).toBeUndefined()
await startCodexTurn(session, {
@@ -553,9 +568,9 @@ describe('Codex service tiers', () => {
await expect(
applyCodexStructuredSessionOption(session, 'serviceTier', 'ultrafast')
).resolves.toMatchObject({ serviceTier: 'ultrafast' })
const options = await readLiveCodexSessionOptions(session, undefined)
expect(options.current).toMatchObject({ serviceTier: 'ultrafast' })
expect(options.current).not.toHaveProperty('fastMode')
expect((await readLiveCodexSessionOptions(session, undefined)).current).not.toHaveProperty(
'serviceTier'
)
await startCodexTurn(session, {
clientMessageId: 'message-ultrafast',
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }
@@ -563,16 +578,19 @@ describe('Codex service tiers', () => {
expect(requests.find((entry) => entry.method === 'turn/start')?.params).toMatchObject({
serviceTier: 'ultrafast'
})
const options = await readLiveCodexSessionOptions(session, undefined)
expect(options.current).toMatchObject({ serviceTier: 'ultrafast' })
expect(options.current).not.toHaveProperty('fastMode')
})
it('refuses a tier the model does not list and drops to Standard on a switch to one', async () => {
it('keeps an unlisted tier pick and preserves it on model changes', async () => {
const session = optionSession(vi.fn(async () => tierListing))
session.reportedOptions = { model: 'gpt-6-luna' }
await primePicker(session)
await expect(
applyCodexStructuredSessionOption(session, 'serviceTier', 'ultrafast')
).rejects.toThrow('does not offer the ultrafast tier')
).resolves.toMatchObject({ serviceTier: 'ultrafast' })
await applyCodexStructuredSessionOption(session, 'model', 'gpt-6.1-sol')
await applyCodexStructuredSessionOption(session, 'serviceTier', 'ultrafast')
await expect(
@@ -604,6 +622,7 @@ describe('Codex reported tier', () => {
it('never rewrites a tier Codex reports, such as a configured Flex', async () => {
const session = optionSession(vi.fn(async () => listing))
session.reportedOptions = { model: 'gpt-6.1-sol', serviceTier: 'flex', serviceTierKnown: true }
session.appliedTurnOptions = { model: 'gpt-6.1-sol', serviceTier: 'flex' }
await primePicker(session)
await expect(readLiveCodexSessionOptions(session, undefined)).resolves.toMatchObject({
@@ -623,6 +642,7 @@ describe('Codex reported tier', () => {
})
)
session.reportedOptions = { model: 'gpt-6.1-sol', serviceTier: 'flex', serviceTierKnown: true }
session.appliedTurnOptions = { model: 'gpt-6.1-sol', serviceTier: 'flex' }
await primePicker(session)
await applyCodexStructuredSessionOption(session, 'serviceTier', 'ultrafast')
@@ -667,7 +687,7 @@ describe('Codex reported tier', () => {
})
await expect(
applyCodexStructuredSessionOption(session, 'serviceTier', 'ultrafast')
).rejects.toThrow('does not offer the ultrafast tier')
).resolves.toMatchObject({ serviceTier: 'ultrafast' })
})
})
@@ -1,25 +1,15 @@
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 './app-server/codex-app-server-connection-types'
import type { CodexSession } from './codex-structured-session-state'
import { isCodexTurnOptionKey } from './codex-structured-turn-start'
import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error'
import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec'
import {
CODEX_DEFAULT_SERVICE_TIER,
codexFastServiceTier,
codexOffersServiceTier
} from './codex-structured-service-tier'
import { CODEX_DEFAULT_SERVICE_TIER, codexFastServiceTier } from './codex-structured-service-tier'
import {
composeCodexSessionOptionCatalog,
readCodexStructuredSessionOptionCatalog,
type CodexSessionOptionCatalog
} from './codex-structured-model-catalog'
import {
codexAcquireCatalogListing,
codexSessionCapabilities
} from './codex-structured-catalog-entry'
import { codexSessionCapabilities } from './codex-structured-catalog-entry'
export { codexAcquireCatalogListing } from './codex-structured-catalog-entry'
export function restoredCodexSessionOptions(
@@ -47,37 +37,18 @@ export function reportedCodexSessionOptions(
export type { CodexSessionOptionCatalog } from './codex-structured-model-catalog'
export { readCodexStructuredSessionOptionCatalog } from './codex-structured-model-catalog'
export async function readCodexStructuredSessionOptions(input: {
connection: Pick<CodexAppServerConnection, 'request'>
current: { model?: string; effort?: string; serviceTier?: string }
reportedServiceTier?: string | null
reportedServiceTierKnown?: boolean
timeoutMs?: number
}): Promise<AgentSessionOptionsResult> {
return readCodexStructuredSessionOptionCatalog(input)
}
function composeLiveCodexCatalog(
session: CodexSession,
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')
const { model, effort, serviceTier } = session.appliedTurnOptions ?? {}
const catalog = composeCodexSessionOptionCatalog(listing, {
current: {
...(model ? { model } : {}),
...(effort ? { effort } : {}),
...(serviceTier ? { serviceTier } : {})
},
...(session.reportedOptions.serviceTierKnown
? {
reportedServiceTier: session.reportedOptions.serviceTier ?? null,
reportedServiceTierKnown: true
}
: {})
...(serviceTier ? { reportedServiceTier: serviceTier, reportedServiceTierKnown: true } : {})
})
return {
...catalog,
@@ -89,20 +60,12 @@ function composeLiveCodexCatalog(
}
}
/** Current options compose only from the installed catalog and exact-child facts. */
export async function readLiveCodexSessionOptions(
session: CodexSession,
timeoutMs: number | undefined
): Promise<StructuredAgentSessionLiveOptions> {
return (await prepareLiveCodexSessionOptions(session, timeoutMs))()
}
/** Demand discovery outside the session lane; compose from its installed snapshot inside it. */
export async function prepareLiveCodexSessionOptions(
session: CodexSession,
_timeoutMs: number | undefined
): Promise<() => StructuredAgentSessionLiveOptions> {
await session.catalogAccess?.demand?.()
return () => composeLiveCodexCatalog(session, codexSessionCapabilities(session))
): Promise<StructuredAgentSessionLiveOptions> {
return composeLiveCodexCatalog(session, codexSessionCapabilities(session))
}
export async function applyCodexStructuredSessionOption(
@@ -117,102 +80,30 @@ export async function applyCodexStructuredSessionOption(
}
}
/** Save intent without a catalog; dispatch applies current capabilities before Codex validates it. */
function applyUnlistedCodexSessionOption(
session: CodexSession,
key: 'model' | 'effort' | 'serviceTier',
value: string
): Readonly<Record<string, string>> {
if (
key === 'model' &&
value !== (session.options.get('model') ?? session.reportedOptions.model)
) {
// An undiscovered model cannot validate the previous model's effort.
session.options.delete('effort')
}
session.options.set(key, value)
return Object.fromEntries(session.options)
}
function applyValidatedCodexStructuredSessionOption(
session: CodexSession,
requestedKey: string,
requestedValue: string
): Readonly<Record<string, string>> {
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, {
current: priorModel ? { model: priorModel } : {}
})
: null
if (!isCodexTurnOptionKey(requestedKey) && requestedKey !== 'fastMode') {
throw new Error(`codex has no session option named ${requestedKey}`)
}
const key = requestedKey === 'fastMode' ? 'serviceTier' : requestedKey
const value =
requestedKey === 'fastMode'
? codexServiceTierOfFastMode(
requestedValue,
catalog?.models.find((entry) => entry.id === catalog.current.model)
codexSessionCapabilities(session).models.find(
(row) => row.id === (session.options.get('model') ?? session.reportedOptions.model)
)
)
: requestedValue
if (key !== 'model' && key !== 'effort' && key !== 'serviceTier') {
session.options.set(key, value)
return Object.fromEntries(session.options)
}
if (key === 'serviceTier' && value === CODEX_DEFAULT_SERVICE_TIER) {
session.options.set(key, value)
return Object.fromEntries(session.options)
}
if (!catalog) {
return applyUnlistedCodexSessionOption(session, key, value)
}
if (key === 'model' && !catalog.models.some((entry) => entry.id === value)) {
throw new Error(`codex app-server does not offer model ${value}`)
}
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,
capabilities.listedServiceTiers
)
) {
throw new Error(`codex app-server model ${modelId} does not offer the ${value} tier`)
}
session.options.set(key, value)
return Object.fromEntries(session.options)
}
const priorEffort = session.options.get('effort') ?? session.reportedOptions.effort
const requestedEffort = key === 'effort' ? value : priorEffort
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 =
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')
}
session.options.set(key, value)
return Object.fromEntries(session.options)
}
/** Legacy Fast-on requests require a known Fast tier. */
function codexServiceTierOfFastMode(
/** Older clients' boolean uses the native Fast encoding when no menu names a tier. */
export function codexServiceTierOfFastMode(
encoded: string,
model: Parameters<typeof codexFastServiceTier>[0]
): string {
@@ -220,9 +111,5 @@ function codexServiceTierOfFastMode(
if (typeof fastMode !== 'boolean') {
throw new Error('codex fast mode must be encoded as true or false')
}
const tier = fastMode ? codexFastServiceTier(model) : CODEX_DEFAULT_SERVICE_TIER
if (!tier) {
throw new Error('codex app-server does not support Fast mode for this model')
}
return tier
return fastMode ? (codexFastServiceTier(model) ?? 'fast') : CODEX_DEFAULT_SERVICE_TIER
}
@@ -23,7 +23,11 @@ import {
} from './codex-structured-session-options'
import { CodexSkillReport } from './codex-structured-skill-report'
import { codexAcquireCatalogAccess } from './codex-structured-acquire-catalog'
import { reportedCodexThreadOptions } from './codex-structured-service-tier'
import {
CODEX_DEFAULT_SERVICE_TIER,
reportedCodexThreadOptions
} from './codex-structured-service-tier'
import { readCodexConfiguredLaunchDefaults } from './codex-configured-launch-defaults'
import {
assertCodexConnectionOpen,
codexSessionLifecycle,
@@ -135,6 +139,20 @@ export async function openCodexStructuredSession(
)
current()
}
const configured =
opened.serviceTier === undefined
? await waitForPromiseWithSignal(
readCodexConfiguredLaunchDefaults(connection, deps.requestTimeoutMs, launch.cwd),
openInput.signal
)
: null
current()
const serviceTier =
opened.serviceTier === undefined
? configured
? (configured.serviceTier ?? CODEX_DEFAULT_SERVICE_TIER)
: undefined
: (opened.serviceTier ?? CODEX_DEFAULT_SERVICE_TIER)
const catalogAccess = codexAcquireCatalogAccess(deps, launch, sessionId)
const session: CodexSession = {
events: input.events,
@@ -155,6 +173,12 @@ export async function openCodexStructuredSession(
prompts: attempt.window.prompts,
options: restoredCodexSessionOptions(input.options),
reportedOptions: reportedCodexThreadOptions(opened),
nativeDefaults: serviceTier ? { serviceTier } : {},
appliedTurnOptions: {
...(opened.model ? { model: opened.model } : {}),
...(opened.effort ? { effort: opened.effort } : {}),
...(serviceTier ? { serviceTier } : {})
},
appliedSettings: attempt.window.appliedSettings,
...(catalogAccess ? { catalogAccess } : {}),
dispatchEchoes: start.dispatchEchoes,
@@ -168,9 +192,6 @@ export async function openCodexStructuredSession(
start.forceCloseUnexpected(sessionId, input.fence, generation, reason),
...(start.unbindReadingControl ? { unbindReadingControl: start.unbindReadingControl } : {})
}
if (opened.serviceTier) {
session.nativeDefaults = { ...session.nativeDefaults, serviceTier: opened.serviceTier }
}
current()
sessions.set(sessionId, session)
for (const event of attempt.window.drain()) {
@@ -174,7 +174,7 @@ export type CodexSession = {
}
/** The configured personality and tier a reset returns to, read before the first override. */
nativeDefaults?: { personality?: string; serviceTier?: string }
/** Overrides actually attempted on this child, independently of future saved picks. */
/** Acquisition facts overlaid by controls actually sent, independently of reports or saved picks. */
appliedTurnOptions?: Readonly<Record<string, string>>
appliedSettings?: CodexAppliedSettings
/** Optional for adapters without a host catalog store. */
@@ -4,6 +4,7 @@ import { CodexAppliedSettings } from './codex-structured-applied-settings'
import { CodexAppServerRequestError } from './app-server/codex-app-server-request-error'
import { changeCodexThreadGoal, codexThreadGoalRequests } from './codex-structured-thread-goal'
import type { CodexSession } from './codex-structured-session-state'
import { waitsForCodexTurnOptions } from '../native-chat/agent-session-wire/structured-agent-session-message-options'
const THREAD = 'thread-1'
@@ -177,7 +178,7 @@ describe('direct goal settings application', () => {
await import('./codex-structured-session-adapter-fixture')
const codex = fakeCodex()
const adapter = adapterFor(codex, {}, [], { requestTimeoutMs: timeout })
await acquireReadyCodexForTest(adapter, {
const acquisition = await acquireReadyCodexForTest(adapter, {
identity: identityFor('session-1'),
fence: 7,
spawnToken: 'spawn'
@@ -196,9 +197,121 @@ describe('direct goal settings application', () => {
executionOptions: { model, ...(effort ? { effort } : {}) }
})
const state = acquiredSettingsTestSeam(adapter)
return { codex, adapter, apply, goal, settings: state.settings, state }
return { codex, adapter, apply, goal, settings: state.settings, state, acquisition }
}
it('keeps literal goal settings separate from exact applied turn proof and recovers on a successful turn', async () => {
const { codex, adapter, apply, goal, acquisition } = await fixture()
const choices = { model: 'unlisted-model-a', effort: 'high' }
const dispatch = () =>
adapter.dispatch({
sessionId: 'session-1',
fence: 7,
clientMessageId: `message-${codex.connections[0].calls.length}`,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Continue' }] },
executionOptions: choices
})
const held = (options = choices) =>
waitsForCodexTurnOptions(
{ provider: 'codex', sessionId: 'session-1' },
{
newestTurn: () => ({ turnId: 'goal-turn', state: 'running' }),
itemBody: () => ({
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'Continue' }]
})
},
{
clientMessageId: 'follow-up',
fence: 7,
payloadFingerprint: 'fingerprint',
dispatchState: 'pending',
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: null,
options
},
{ fence: 7, generation: acquisition.acquisitionGeneration, phase: 'ready' },
adapter
)
const proof = () =>
adapter.readAppliedInputOptions({
sessionId: 'session-1',
fence: 7,
acquisitionGeneration: acquisition.acquisitionGeneration,
next: choices
})
try {
expect(await dispatch()).toMatchObject({ state: 'admitted' })
expect(proof()?.active).toMatchObject(choices)
codex.routes['thread/settings/update'] = (params) => {
apply(String(params?.model), String(params?.effort))
return {}
}
expect(await goal('unlisted-model-b', 'low')).toEqual({ ok: true })
expect(
codex.connections[0].calls.filter((call) => call.method === 'thread/settings/update')
).toEqual([
{
method: 'thread/settings/update',
params: { threadId: 'thread-abc', model: 'unlisted-model-b', effort: 'low' }
}
])
const appliedGoal = { model: 'unlisted-model-b', effort: 'low' }
expect(proof()).toEqual({ active: appliedGoal, next: choices })
apply('observation-only', 'medium')
expect(proof()).toEqual({ active: appliedGoal, next: choices })
codex.routes['thread/settings/update'] = () => {
throw new CodexAppServerRequestError('thread/settings/update', -32602, 'invalid model')
}
expect(await goal('refused-model', 'high')).toMatchObject({ ok: false })
expect(proof()).toEqual({ active: appliedGoal, next: choices })
expect(
codex.connections[0].calls.filter((call) => call.method === 'thread/goal/set')
).toHaveLength(1)
apply('observation-only-c', 'medium')
const callsBeforeEquivalent = codex.connections[0].calls.filter(
(call) => call.method === 'thread/settings/update'
).length
expect(await goal('observation-only-c', 'medium')).toEqual({ ok: true })
expect(proof()).toEqual({ active: appliedGoal, next: choices })
expect(held(appliedGoal)).toBe(false)
expect(held({ model: 'observation-only-c', effort: 'medium' })).toBe(true)
expect(
codex.connections[0].calls.filter((call) => call.method === 'thread/settings/update')
).toHaveLength(callsBeforeEquivalent)
codex.routes['thread/settings/update'] = () => {
throw new Error('reply lost after settings enqueue')
}
expect(await goal('uncertain-model', 'low')).toMatchObject({ ok: false })
expect(proof()).toEqual({ active: { effort: 'low' }, next: choices })
expect(held()).toBe(true)
expect(await dispatch()).toMatchObject({ state: 'admitted' })
expect(proof()).toEqual({ active: choices, next: choices })
codex.routes['thread/settings/update'] = () => {
throw new CodexAppServerRequestError('thread/settings/update', -32601, 'method not found')
}
expect(await goal('unlisted-model-b', 'low')).toEqual({ ok: true })
expect(proof()).toEqual({ active: choices, next: choices })
const callsBeforeCached = codex.connections[0].calls.filter(
(call) => call.method === 'thread/settings/update'
).length
expect(await goal('unlisted-model-b', 'low')).toEqual({ ok: true })
expect(proof()).toEqual({ active: choices, next: choices })
expect(held()).toBe(false)
expect(held(appliedGoal)).toBe(true)
expect(
codex.connections[0].calls.filter((call) => call.method === 'thread/settings/update')
).toHaveLength(callsBeforeCached)
expect(await dispatch()).toMatchObject({ state: 'admitted' })
expect(proof()).toEqual({ active: choices, next: choices })
} finally {
await adapter.closeAll()
}
})
it('retains authoritative A after a definitely rejected turn B, so a no-op Resume A needs no notification', async () => {
const { codex, adapter, goal } = await fixture()
codex.routes['turn/start'] = () => {
+19 -2
View File
@@ -78,12 +78,29 @@ export async function changeCodexThreadGoalWithSettings(
(input.change.kind === 'status' && input.change.status === 'active')
try {
if (startsWork) {
const requested = resolvedCodexGoalSettings(session, input.executionOptions)
const previousApplied = session.appliedTurnOptions
const installed = await session.appliedSettings?.install(
session.connection,
session.threadId,
resolvedCodexGoalSettings(session, input.executionOptions),
requested,
timeoutMs,
input.signal
input.signal,
() => {
session.appliedTurnOptions = { ...session.appliedTurnOptions, ...requested }
},
() => {
session.appliedTurnOptions = previousApplied
},
() => {
const known = { ...previousApplied }
for (const key of ['model', 'effort'] as const) {
if (requested[key] !== undefined && requested[key] !== known[key]) {
delete known[key]
}
}
session.appliedTurnOptions = known
}
)
if (installed && !installed.ok) {
return installed
@@ -38,7 +38,7 @@ it('rereads the host choice after preparation and keeps the exact configured Aut
let nextTurn = 0
const configuration = Promise.withResolvers<unknown>()
const codex = fakeCodexAppServer({
'config/read': () => configuration.promise,
'config/read': () => ({ config: { sandbox_workspace_write: workspace } }),
'configRequirements/read': () => ({ requirements: null }),
'turn/start': () => ({ turn: { id: `fresh-${++nextTurn}` } })
})
@@ -48,6 +48,7 @@ it('rereads the host choice after preparation and keeps the exact configured Aut
permissionMode: 'full-access'
})
const connection = codex.connections[0]!
codex.routes['config/read'] = () => configuration.promise
let mode: AgentChatPermissionMode = 'auto'
const send = (id: string) =>
adapter.dispatch({
+19 -21
View File
@@ -152,8 +152,9 @@ export async function startCodexTurn(
if (steered) {
return steered
}
const previousApplied = host.appliedTurnOptions
const previousOptions = input.turnOptions ? new Map(host.options) : null
let previousApplied = host.appliedTurnOptions
let previousOptions: Map<string, string> | null | undefined
let requested: Readonly<Record<string, string>> = {}
let enqueued = false
let discardSettingsAttempt: (() => void) | undefined
const enqueue = (
@@ -161,14 +162,20 @@ export async function startCodexTurn(
body = input.body
) => {
checkDispatchAuthority(host, input.clientMessageId, input.signal, input.isCurrent)
previousApplied = host.appliedTurnOptions
previousOptions = input.turnOptions ? new Map(host.options) : null
if (input.turnOptions) {
host.options.clear()
for (const [key, value] of Object.entries(input.turnOptions)) {
host.options.set(key, value)
}
}
host.appliedTurnOptions = input.turnOptions ?? Object.fromEntries(host.options)
const requested = codexTurnRequestOptions(host)
requested = codexTurnRequestOptions(host)
const known = { ...previousApplied }
for (const key of Object.keys(requested)) {
delete known[key]
}
host.appliedTurnOptions = known
discardSettingsAttempt = host.appliedSettings?.attempt(requested)
enqueued = true
return {
@@ -209,18 +216,18 @@ export async function startCodexTurn(
if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) {
discardSettingsAttempt?.()
}
if (
previousOptions &&
(isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error))
) {
if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) {
host.appliedTurnOptions = previousApplied
host.options.clear()
for (const [key, value] of previousOptions) {
host.options.set(key, value)
if (previousOptions) {
host.options.clear()
for (const [key, value] of previousOptions) {
host.options.set(key, value)
}
}
}
throw error
}
host.appliedTurnOptions = { ...previousApplied, ...requested }
return { turnId: readCodexTurnId(answer), via: 'start' }
}
@@ -303,14 +310,5 @@ export async function dispatchCodexTurn(
/** Steers retain running-turn options; starts use request overrides over thread defaults. */
function codexRanOptions(session: CodexTurnHost): Record<string, string> {
const requested = codexTurnRequestOptions(session)
const thread = session.reportedOptions ?? {}
const model = requested.model ?? thread.model
const effort = requested.effort ?? (model === thread.model ? thread.effort : undefined)
const { serviceTier } = requested
return {
...(model ? { model } : {}),
...(effort ? { effort } : {}),
...(serviceTier ? { serviceTier } : {})
}
return { ...session.appliedTurnOptions }
}
@@ -52,7 +52,9 @@ vi.mock('child_process', () => {
})
vi.mock('../runtime/structured-agent-shell-environment', () => ({
captureHostShellEnvironment: vi.fn(async () => undefined)
captureHostShellEnvironment: vi.fn(async (beforeCapture?: () => Promise<unknown>) =>
beforeCapture?.()
)
}))
vi.mock('../startup/hydrate-shell-path', () => ({
@@ -71,6 +71,7 @@ vi.mock('./command-path-resolver', async (importOriginal) => ({
}))
vi.mock('../pty/windows-environment-path', () => ({
cachedPersistedWindowsPathSegments: () => [],
readPersistedWindowsPathSegmentsAsync: async () => [],
mergePersistedWindowsPathAsync: mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPath: mergePersistedWindowsPathMock
@@ -25,7 +25,19 @@ export function expireAgentModelCatalogFailuresForSettings(
}
}
type AgentLaunchSettings = Partial<Pick<GlobalSettings, 'agentCmdOverrides' | 'agentDefaultEnv'>>
// The settings that pick the binary and environment an agent's probe and chats launch with.
type AgentLaunchSettings = Partial<
Pick<
GlobalSettings,
| 'agentCmdOverrides'
| 'agentDefaultEnv'
| 'agentDefaultArgs'
| 'nativeChatInheritShellEnvironment'
| 'nativeChatShellEnvironmentVariables'
| 'terminalWindowsShell'
| 'terminalWindowsPowerShellImplementation'
>
>
function launchKeys(settings: AgentLaunchSettings): Map<string, string> {
const commands = new Map<string, string | undefined>(
@@ -35,9 +47,16 @@ function launchKeys(settings: AgentLaunchSettings): Map<string, string> {
Object.entries(settings.agentDefaultEnv ?? {})
)
const keys = new Map<string, string>()
for (const agent of new Set([...commands.keys(), ...envs.keys()])) {
for (const agent of new Set([
...commands.keys(),
...envs.keys(),
...Object.keys(settings.agentDefaultArgs ?? {})
])) {
const env = Object.entries(envs.get(agent) ?? {}).sort(([a], [b]) => a.localeCompare(b, 'en'))
keys.set(agent, JSON.stringify([commands.get(agent) ?? null, env]))
keys.set(
agent,
JSON.stringify([commands.get(agent) ?? null, env, settings.agentDefaultArgs?.[agent]])
)
}
return keys
}
@@ -55,22 +74,23 @@ export function agentsWithChangedLaunchSettings(
/** Account changes expire failures; command/environment changes also expire saved listings. */
export function createAgentModelCatalogSettingsExpiry(
store: Pick<AgentModelCatalogStore, 'expireFailures' | 'expireAgent'>,
store: Pick<AgentModelCatalogStore, 'expireFailures' | 'expireAgent' | 'expireLaunchScopes'>,
initial: AgentLaunchSettings
): (updates: Partial<GlobalSettings>, settings: AgentLaunchSettings) => void {
let launch: AgentLaunchSettings = {
agentCmdOverrides: initial.agentCmdOverrides,
agentDefaultEnv: initial.agentDefaultEnv
}
let launch = initial
return (updates, settings) => {
expireAgentModelCatalogFailuresForSettings(store, updates)
if (!('agentCmdOverrides' in updates) && !('agentDefaultEnv' in updates)) {
return
}
const next = {
agentCmdOverrides: settings.agentCmdOverrides,
agentDefaultEnv: settings.agentDefaultEnv
const environment = (value: AgentLaunchSettings): string =>
JSON.stringify([
value.nativeChatInheritShellEnvironment,
value.nativeChatShellEnvironmentVariables,
value.terminalWindowsShell,
value.terminalWindowsPowerShellImplementation
])
if (environment(launch) !== environment(settings)) {
store.expireLaunchScopes()
}
const next = settings
for (const agent of agentsWithChangedLaunchSettings(launch, next)) {
store.expireAgent(agent)
}
@@ -82,11 +82,14 @@ describe('a live listing beside the account-level discovery', () => {
it('merges a discovery that resolves after a live save instead of dropping it', async () => {
let settle!: (success: AgentModelCatalogSuccess) => void
const run = harness(() => new Promise((resolve) => (settle = resolve)))
// Explicit demand starts discovery; a chat lists while it is still running.
void run.catalog.demand({ agent: 'grok' })
expect(await run.catalog.read({ agent: 'grok' })).toEqual({
origin: 'unknown',
listingInProgress: true
})
run.saveLive()
await vi.waitFor(() => expect(run.probes).toHaveBeenCalledTimes(1))
settle(discovery('grok-4'))
await vi.waitFor(() =>
expect(run.store.get(agentModelCatalogFingerprintForRecord(RECORD))?.discovered).not.toBe(
@@ -105,9 +108,9 @@ describe('a live listing beside the account-level discovery', () => {
it('names the default on a cold account whose first listing came from a live chat', async () => {
const run = harness(async () => discovery('grok-4'))
run.saveLive()
// Live membership cannot establish discovery freshness or the configured default.
await run.catalog.read({ agent: 'grok' })
expect(run.probes).toHaveBeenCalledTimes(1)
// A live listing alone names no default and does not count as fresh, so explicit demand lists the account.
await run.catalog.demand({ agent: 'grok' })
await vi.waitFor(() => expect(run.probes).toHaveBeenCalledTimes(1))
await vi.waitFor(async () => expect(await run.defaultOf()).toBe('grok-4'))
})
@@ -123,8 +126,8 @@ describe('a live listing beside the account-level discovery', () => {
run.saveLive()
}
expect(run.store.shouldRefresh(agentModelCatalogFingerprintForRecord(RECORD))).toBe(true)
await run.catalog.read({ agent: 'grok' })
expect(run.probes).toHaveBeenCalledTimes(2)
await run.catalog.demand({ agent: 'grok' })
await vi.waitFor(() => expect(run.probes).toHaveBeenCalledTimes(2))
await vi.waitFor(async () => expect(await run.defaultOf()).toBe('grok-3-mini'))
})
@@ -1,3 +1,4 @@
import type { StructuredAgentResolutionOperation } from '../structured-agent-command-resolution'
import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home'
import type { AgentModelCatalogSuccess } from './agent-model-catalog-entry'
@@ -11,13 +12,22 @@ export type AgentModelCatalogLookup = 'saved' | 'snapshot' | 'recover'
export type AgentModelCatalogProbe = ((
accountHome: AgentSessionAccountHome,
options?: { signal?: AbortSignal; workspacePath?: string; cwd?: string }
options?: {
signal?: AbortSignal
workspacePath?: string
cwd?: string
operation?: StructuredAgentResolutionOperation
}
) => Promise<AgentModelCatalogSuccess>) & {
resolveLegacy?: (home: AgentSessionAccountHome) => Promise<AgentModelCatalogResolvedProbe>
resolveLegacy?: (
home: AgentSessionAccountHome,
operation?: StructuredAgentResolutionOperation
) => Promise<AgentModelCatalogResolvedProbe>
resolve?: (
home: AgentSessionAccountHome,
cwd: string,
lookup?: AgentModelCatalogLookup
lookup?: AgentModelCatalogLookup,
operation?: StructuredAgentResolutionOperation
) => Promise<AgentModelCatalogResolvedProbe>
}
@@ -0,0 +1,99 @@
import {
agentModelCatalogEntryWithSuccess,
type AgentModelCatalogEntry,
type AgentModelCatalogSuccess,
type AgentModelCatalogSource
} from './agent-model-catalog-entry'
import { independentAgentModelCatalog } from './agent-model-catalog-discovery'
export const AGENT_MODEL_CATALOG_FRESH_MS = 10 * 60_000
export function agentModelCatalogEntryIsStale(
entry: AgentModelCatalogEntry,
due: boolean,
now: number
): boolean {
return (
due ||
!entry.discovered ||
(independentAgentModelCatalog(entry.agent) && entry.discovered.complete !== true) ||
now - entry.discovered.at >=
(independentAgentModelCatalog(entry.agent) ? 2 * 60_000 : AGENT_MODEL_CATALOG_FRESH_MS)
)
}
export function pruneAgentModelCatalogPartialAttempts(
attempts: Map<string, { agent: string; at: number }>,
now: number
): void {
for (const [key, attempt] of attempts) {
if (now - attempt.at >= 2 * 60_000) {
attempts.delete(key)
}
}
}
export function evictAgentModelCatalogEntries(
entries: Map<string, AgentModelCatalogEntry>,
dueEntries: Set<string>,
partialAttempts: Map<string, { agent: string; at: number }>,
cap: number,
retiredObservations: Set<string>
): void {
for (const key of entries.keys()) {
if (entries.size <= cap) {
return
}
entries.delete(key)
dueEntries.delete(key)
partialAttempts.delete(key)
retiredObservations.delete(key)
}
}
export function retireAgentModelCatalogObservations(
entries: ReadonlyMap<string, AgentModelCatalogEntry>,
dueEntries: Set<string>,
retiredObservations: Set<string>,
partialAttempts: Map<string, { agent: string; at: number }>,
agent: string
): void {
for (const entry of entries.values()) {
if (entry.agent === agent) {
dueEntries.add(entry.fingerprint)
retiredObservations.add(entry.fingerprint)
}
}
for (const [key, attempt] of partialAttempts) {
if (attempt.agent === agent) {
partialAttempts.delete(key)
}
}
}
export function agentModelCatalogLaunchAgents(
revisions: ReadonlyMap<string, number>,
entries: ReadonlyMap<string, AgentModelCatalogEntry>
): Set<string> {
return new Set([...revisions.keys(), ...[...entries.values()].map((entry) => entry.agent)])
}
export function createAgentModelCatalogEntryFactory(
entries: ReadonlyMap<string, AgentModelCatalogEntry>,
retiredObservations: ReadonlySet<string>,
now: () => number
) {
return (
fingerprint: string,
agent: string,
success: AgentModelCatalogSuccess,
source: AgentModelCatalogSource
) =>
agentModelCatalogEntryWithSuccess(
retiredObservations.has(fingerprint) ? undefined : entries.get(fingerprint),
{ agent, fingerprint },
success,
source,
now()
)
}
@@ -203,13 +203,10 @@ export function agentModelCatalogEntry(
}
export function retainedAgentModelCatalogModels(
previous: Pick<AgentModelCatalogEntry, 'models'> | null | undefined,
_previous: Pick<AgentModelCatalogEntry, 'models'> | null | undefined,
listing: AgentModelCatalogLiveListing
): AgentSessionModelOption[] {
const launchOnly = listing.launchOnlyModelId
return launchOnly !== undefined && !previous?.models.some((model) => model.id === launchOnly)
? listing.models.filter((model) => model.id !== launchOnly)
: listing.models
return listing.models
}
export function agentModelCatalogEntryWithSuccess(
@@ -25,7 +25,16 @@ export class AgentModelCatalogFailures {
return this.failures.get(fingerprint) ?? null
}
/** Expired unavailability stays readable during reprobe; expired plain failures disappear. */
/** Inside its TTL. An expired reason stays readable until the next probe replaces it, so a read
* can still serve it while the probe that re-derives it runs; an expired plain failure dies. */
detail(fingerprint: string): string | null {
return this.isActive(fingerprint) ? (this.get(fingerprint)?.detail ?? null) : null
}
unavailableDue(fingerprint: string): boolean {
return this.get(fingerprint)?.unavailable !== undefined && !this.isActive(fingerprint)
}
isActive(fingerprint: string): boolean {
const failure = this.failures.get(fingerprint)
if (!failure) {
@@ -74,14 +83,6 @@ export class AgentModelCatalogFailures {
}
}
/** Chat listing failures cannot replace a probe's sign-in/CLI verdict. */
chatFailed(fingerprint: string, detail: string, agent?: string): void {
if (this.failures.get(fingerprint)?.unavailable) {
return
}
this.failures.set(fingerprint, { ...(agent ? { agent } : {}), detail, failedAt: this.now() })
}
/** Failed probes replace prior verdicts without inventing unavailability reasons. */
probeFailed(fingerprint: string, agent: string, error: unknown): void {
this.failures.set(fingerprint, {
@@ -1,6 +1,13 @@
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import {
agentModelCatalogFingerprintForRecord,
agentModelCatalogScopeFingerprint
} from './agent-model-catalog-fingerprint'
import { retireAtRestCommandContext } from '../agent-session-wire/at-rest-command-context'
import { structuredAgentSessionLaunchPermission } from '../agent-session-wire/structured-agent-session-launch-permission'
import { claudeAndCodexDeclared } from '../agent-session-wire/structured-agent-session-adapter-router-test-support'
import { agentSessionRecordFixture } from '../agent-session-record-test-fixture'
import { createAgentModelCatalogService } from './agent-model-catalog-service'
import {
@@ -64,6 +71,7 @@ function harness() {
return {
store,
service,
resolve,
run,
update: (next: AgentModelCatalogSuccess) => {
now += 120001
@@ -82,6 +90,7 @@ describe('independent installed CLI discovery', () => {
const h = harness()
await h.read('a', { waitForListing: true })
h.update(listing('new', 'new'))
void h.service.demand({ agent: 'claude', sessionId: 'a' })
const warm = await h.read()
expect(warm).toMatchObject({ models: [{ id: 'old' }], listingInProgress: true })
await h.read('a', { waitForListing: true })
@@ -107,6 +116,16 @@ describe('independent installed CLI discovery', () => {
h.read('b', { waitForListing: true })
])
expect(h.run).toHaveBeenCalledTimes(1)
expect(h.resolve).toHaveBeenCalledWith(
HOME,
'/project',
'snapshot',
expect.objectContaining({
deadline: expect.any(Number),
current: expect.any(Function),
signal: expect.any(AbortSignal)
})
)
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' }]
@@ -187,3 +206,94 @@ describe('independent installed CLI discovery', () => {
h.service.stop()
})
})
it('prepares hydrated catalog evidence independently of launch permission and shares it with an equivalent chat', async () => {
const first = record('cold-a', 'opus')
first.permissionMode = 'auto'
const seed = new AgentModelCatalogStore()
const probe = Object.assign(
vi.fn(async () => listing('opus')),
{
resolve: vi.fn(async () => ({
identity: 'saved-launch',
run: vi.fn(async () => listing('opus'))
}))
}
)
const deps = {
getConversation: (id: string) => {
const held = { ...first, sessionId: id }
return held ? agentSessionConversationView(held) : undefined
},
getRecord: (id: string) => ({ ...first, sessionId: id }),
drivesRecord: () => true,
resolveAccountHome: vi.fn(async () => HOME),
recordWorkspacePath: vi.fn(async () => '/project'),
probes: { claude: probe }
}
const key = agentModelCatalogScopeFingerprint(
agentModelCatalogFingerprintForRecord(first),
'/project',
'saved-launch'
)
const saved = seed.recordSuccess(
key,
'claude',
{
...listing('opus'),
models: [{ id: 'opus', label: 'Opus', isDefault: true, efforts: [], supportsAutoMode: true }]
},
'discovery'
)!
const store = new AgentModelCatalogStore()
await store.attachPersistence({
load: async () => [saved],
save: () => {},
flush: async () => {}
})
const catalog = createAgentModelCatalogService({ ...deps, store })
probe.resolve.mockClear()
deps.resolveAccountHome.mockClear()
const params = {
agent: 'claude',
sessionId: first.sessionId,
inSessionWorkspace: true as const,
savedOnly: true
}
expect(await catalog.read(params)).toEqual({ origin: 'unknown' })
expect(probe.resolve).not.toHaveBeenCalled()
expect(
await structuredAgentSessionLaunchPermission({ agents: claudeAndCodexDeclared() }, first)
).toBe('auto')
expect(probe.resolve).not.toHaveBeenCalled()
expect(await catalog.prepareSaved(params)).toMatchObject({
models: [{ id: 'opus', supportsAutoMode: true }]
})
expect(probe.resolve).toHaveBeenCalledWith(
HOME,
'/project',
'saved',
expect.objectContaining({
current: expect.any(Function),
signal: expect.any(AbortSignal),
deadline: expect.any(Number)
})
)
const resolutions = probe.resolve.mock.calls.length
expect(
await catalog.read({ ...params, workspacePath: '/project', inSessionWorkspace: undefined })
).toMatchObject({
models: [{ id: 'opus', supportsAutoMode: true }]
})
retireAtRestCommandContext('unrelated-folder')
expect(await catalog.read({ ...params, sessionId: 'cold-b' })).toMatchObject({
models: [{ id: 'opus', supportsAutoMode: true }]
})
expect(
await structuredAgentSessionLaunchPermission({ agents: claudeAndCodexDeclared() }, first)
).toBe('auto')
expect(probe.resolve).toHaveBeenCalledTimes(resolutions)
expect(probe).not.toHaveBeenCalled()
expect(deps.resolveAccountHome).not.toHaveBeenCalled()
catalog.stop()
})
@@ -3,7 +3,7 @@ import { agentSessionConversationView } from '../../../shared/agent-session-conv
import { 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'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
AgentSessionAccountHome,
AgentSessionRecord
@@ -61,7 +61,12 @@ function record(agent: string, workspacePath: string): AgentSessionRecord {
})
}
async function afterNoPickChat(agent: string, workspacePath: string, picked = false) {
async function afterNoPickChat(
agent: string,
workspacePath: string,
picked = false,
inspectWorkspace = workspaceMayOverrideDefaultModel
) {
const chat = record(agent, workspacePath)
const store = new AgentModelCatalogStore()
store.recordSuccess(
@@ -89,7 +94,7 @@ async function afterNoPickChat(agent: string, workspacePath: string, picked = fa
resolveAccountHome: async () => HOMES[agent]!,
recordWorkspacePath: async (row) => row.launchDirectory ?? null,
agentReadsProjectModelConfig,
workspaceMayOverrideDefaultModel
workspaceMayOverrideDefaultModel: inspectWorkspace
})
const options = new AcpStructuredOptions()
options.adoptSession(
@@ -130,14 +135,37 @@ describe('a default learned from a chat with no pick', () => {
'%s: the next new chat in that workspace names the model the first one ran',
async (agent) => {
const clean = workspace('clean')
const catalog = await afterNoPickChat(agent, clean)
const answer = await catalog.read({ agent, workspacePath: clean })
expect(named(answer)).toBe('github-copilot/claude-fable-5')
const learningStarted = Promise.withResolvers<void>()
const releaseLearning = Promise.withResolvers<void>()
let firstInspection = true
const catalog = await afterNoPickChat(agent, clean, false, async (input) => {
if (firstInspection) {
firstInspection = false
learningStarted.resolve()
await releaseLearning.promise
}
return workspaceMayOverrideDefaultModel(input)
})
await learningStarted.promise
try {
const pending = await catalog.read({ agent, workspacePath: clean, waitForListing: true })
expect(named(pending)).toBeNull()
} finally {
releaseLearning.resolve()
}
await vi.waitFor(() => {
expect(named(catalog.snapshot({ agent, workspacePath: clean }))).toBe(
'github-copilot/claude-fable-5'
)
})
const answer = catalog.snapshot({ agent, workspacePath: clean })
// Project config can override this default; do not promote it to every workspace.
expect(answer).not.toHaveProperty('defaultHoldsInEveryWorkspace')
expect(named(await catalog.read({ agent, workspacePath: workspace('other') }))).toBe(
'github-copilot/claude-fable-5'
)
expect(
named(
await catalog.read({ agent, workspacePath: workspace('other'), waitForListing: true })
)
).toBe('github-copilot/claude-fable-5')
}
)
@@ -1,7 +1,7 @@
import type { StructuredAgentResolutionOperation } from '../structured-agent-command-resolution'
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
@@ -18,8 +18,6 @@ import type {
} from './agent-model-catalog-discovery'
import type { AgentModelCatalogStore } from './agent-model-catalog-store'
export type AgentModelCatalogLister = AgentModelCatalogSessionAccess | AgentModelCatalogProbe
/** Pin listings to the spawned child's account so later selections cannot receive its results. */
export type AgentModelCatalogSessionAccess = {
store: AgentModelCatalogStore
@@ -27,97 +25,96 @@ export type AgentModelCatalogSessionAccess = {
accountHomePath: string
}
export type InFlightListings = Map<AgentModelCatalogLister, Promise<AgentModelCatalogEntry | null>>
export type InFlightListing = {
agent: string
current?: () => boolean
promise: Promise<AgentModelCatalogEntry | null>
finish: (entry: AgentModelCatalogEntry | null) => void
cleanup: () => void
}
type CatalogSuccessWriter = (
/** Only the exact owner may detach a flight and release its waiters. */
export function retireAgentModelCatalogListing(
refreshes: Map<string, InFlightListing>,
listingWaiters: AgentModelCatalogListingWaiters,
fingerprint: string,
agent: string,
success: AgentModelCatalogSuccess,
source: AgentModelCatalogSource,
order: number
) => AgentModelCatalogEntry | null
expected: InFlightListing
): void {
if (refreshes.get(fingerprint) !== expected) {
return
}
refreshes.delete(fingerprint)
expected.cleanup()
expected.finish(null)
listingWaiters.notify(fingerprint)
}
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>
current?: () => boolean
signal?: AbortSignal
refreshes: Map<string, InFlightListing>
failures: AgentModelCatalogFailures
listingWaiters: AgentModelCatalogListingWaiters
nextListingOrder: () => number
entryFromSuccess: (
writeSuccess: (
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 { fingerprint, agent, listModels, signal } = input
const existing = input.refreshes.get(fingerprint)
if (existing) {
return existing.promise
}
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 result = Promise.withResolvers<AgentModelCatalogEntry | null>()
const flight: InFlightListing = {
agent,
current: input.current,
promise: result.promise,
finish: result.resolve,
cleanup: () => signal?.removeEventListener('abort', retire)
}
const order = input.nextListingOrder()
// Trace timing/outcome without exposing listing contents.
const span = startSpan('agentModelCatalog.discovery', {
attributes: { agent, lister: typeof lister === 'function' ? 'probe' : 'session' }
})
const run = listModels().then(
(success) => {
span.setAttribute('models', success.models.length)
const retire = (): void =>
retireAgentModelCatalogListing(input.refreshes, input.listingWaiters, fingerprint, flight)
const current = (): boolean =>
input.refreshes.get(fingerprint) === flight && !signal?.aborted && flight.current?.() !== false
input.refreshes.set(fingerprint, flight)
signal?.addEventListener('abort', retire, { once: true })
if (signal?.aborted) {
retire()
}
const span = startSpan('agentModelCatalog.discovery', { attributes: { agent, lister: 'probe' } })
void Promise.resolve().then(async () => {
try {
if (!current()) {
span.end()
return
}
const success = await listModels()
if (current()) {
span.setAttribute('models', success.models.length)
flight.finish(input.writeSuccess(fingerprint, agent, success, 'discovery'))
}
span.end()
// Superseded callers receive their results without overwriting 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) => {
} catch (error) {
span.fail(error instanceof Error ? error : String(error))
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
)
if (current() && !(error instanceof AgentModelCatalogListingStoppedError)) {
input.failures.probeFailed(fingerprint, agent, error)
}
settle()
return null
} finally {
retire()
}
)
listers.set(lister, run)
input.refreshes.set(fingerprint, listers)
return run
})
return flight.promise
}
export function scopedAgentModelCatalogLister(input: {
legacy?: boolean
operation?: StructuredAgentResolutionOperation
canList: () => boolean
admission: PrioritySemaphore
signal: AbortSignal
@@ -143,7 +140,11 @@ export function scopedAgentModelCatalogLister(input: {
if (!probe || !probeHome) {
throw new AgentModelCatalogListingStoppedError('catalog scope unavailable')
}
return await probe(probeHome, { signal: attemptSignal, workspacePath })
return await probe(probeHome, {
signal: attemptSignal,
workspacePath,
operation: input.operation
})
} catch (error) {
throw signal.aborted ? new AgentModelCatalogListingStoppedError(String(error)) : error
} finally {
@@ -152,11 +153,3 @@ export function scopedAgentModelCatalogLister(input: {
}
}
}
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
})
}
@@ -1,3 +1,8 @@
import type { AgentModelCatalogStore } from './agent-model-catalog-store'
import type { AgentModelCatalogReadParams } from './agent-model-catalog-service-contract'
import type { AgentModelCatalogBinding } from './agent-model-catalog-snapshot'
import { independentAgentModelCatalog } from './agent-model-catalog-discovery'
import { withTimeout } from '../../../shared/promise-timeout-fallback'
import type { AgentModelCatalogEntry } from './agent-model-catalog-entry'
export const AGENT_MODEL_CATALOG_PICKER_WAIT_MS = 30_000
@@ -59,3 +64,60 @@ export class AgentModelCatalogListingWaiters {
}
}
}
/** Compatibility discovery waits share the deadline captured before any scope resolution. */
export async function waitForAgentModelCatalogRead(input: {
params: AgentModelCatalogReadParams & { deadline: number }
store: AgentModelCatalogStore
resolve: (params: AgentModelCatalogReadParams) => Promise<void>
claim: () => { ready: Promise<void>; promise: Promise<void> } | undefined
binding: () => AgentModelCatalogBinding | undefined
}): Promise<void> {
const { params, store } = input
const wait = async (): Promise<void> => {
void input.resolve(params)
await input.claim()?.ready
const bound = input.binding()
await bound?.projecting
if (Date.now() >= params.deadline) {
return
}
if (
bound &&
(!store.get(bound.fingerprint) ||
independentAgentModelCatalog(params.agent) ||
store.heldReasonDue(bound.fingerprint))
) {
await store.pendingListing(bound.fingerprint, true, params.legacy)
}
if (bound && !store.isListing(bound.fingerprint)) {
await input.claim()?.promise
}
}
const remaining = params.deadline - Date.now()
if (remaining > 0) {
await withTimeout(wait(), remaining, undefined)
}
}
/** The operation owns its original budget even while a resolver cannot be cancelled. */
export function runAgentModelCatalogOperation(
run: () => Promise<void>,
remaining: number,
controller: AbortController,
finished: () => void
): Promise<void> {
let settled = false
const operation = Promise.resolve()
.then(run)
.catch(() => {})
.finally(() => {
settled = true
})
return withTimeout(operation, remaining, undefined).finally(() => {
if (!settled) {
controller.abort()
}
finished()
})
}
@@ -0,0 +1,182 @@
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentModelCatalogLiveListing } from './agent-model-catalog-store'
import type {
AgentModelCatalogServiceDeps,
AgentModelCatalogSource
} from './agent-model-catalog-service-contract'
import { agentModelCatalogFingerprintForRecord } from './agent-model-catalog-fingerprint'
import { catalogFolder, recordConfiguredDefault } from './agent-model-catalog-workspace'
import { independentAgentModelCatalog } from './agent-model-catalog-discovery'
export function createAgentModelCatalogLiveListings(
deps: AgentModelCatalogServiceDeps,
signal: AbortSignal
) {
const workspaces = new Map<string, { holders: number }>()
const retireContext = (workspaceId?: string): void => {
if (workspaceId === undefined) {
workspaces.clear()
} else {
workspaces.delete(workspaceId)
}
}
const leaseWorkspace = (id?: string) => {
const epoch = id === undefined ? undefined : (workspaces.get(id) ?? { holders: 0 })
if (epoch && id !== undefined) {
epoch.holders += 1
workspaces.set(id, epoch)
}
let released = false
return {
current: () =>
!released && !signal.aborted && (id === undefined || workspaces.get(id) === epoch),
release: () => {
if (released) {
return
}
released = true
if (epoch && id !== undefined && --epoch.holders === 0 && workspaces.get(id) === epoch) {
workspaces.delete(id)
}
}
}
}
const recordIdentity = (record: AgentSessionRecord): string =>
JSON.stringify([
record.provider,
record.accountHome,
record.location,
record.launchDirectory,
deps.drivesRecord(record)
])
const captureSource = (
origin: Pick<AgentSessionRecord, 'provider' | 'location'>
): AgentModelCatalogSource => {
const lease = leaseWorkspace(
deps.listsInChatFolder?.has(origin.provider) ? origin.location.workspaceId : undefined
)
const revision = deps.store.retirementRevision(origin.provider)
let bound: AgentSessionRecord | undefined =
'sessionId' in origin ? deps.getRecord(String(origin.sessionId)) : undefined
let identity = bound && recordIdentity(bound)
return Object.assign(
() =>
lease.current() &&
deps.store.retirementRevision(origin.provider) === revision &&
(!bound || recordIdentity(deps.getRecord(bound.sessionId) ?? bound) === identity),
{
release: lease.release,
bind: (record: AgentSessionRecord) => {
if (!bound) {
bound = record
identity = recordIdentity(record)
}
}
}
)
}
// Default projections retain report order without delaying inventory observations.
const recordingDefaults = new Map<string, Promise<void>>()
// 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
* for an agent kept per folder. */
const sessionCatalogKey = async (record: AgentSessionRecord): Promise<string> =>
agentModelCatalogFingerprintForRecord(
record,
await catalogFolder(deps, record.provider, undefined, record)
)
const saveLiveListing = (
record: AgentSessionRecord,
listing: AgentModelCatalogLiveListing,
fingerprint: string,
current: () => boolean
): Promise<void> => {
if (!current()) {
return Promise.resolve()
}
const { configuredDefault, ...listed } = listing
const saved = deps.store.recordSuccess(
fingerprint,
record.provider,
{ ...listed, origin: 'live-session' },
'live'
)
if (saved && configuredDefault !== undefined) {
// In report order, so the latest chat's resolution is the one kept.
const recording = (recordingDefaults.get(fingerprint) ?? Promise.resolve())
.then(() => recordConfiguredDefault(deps, record, fingerprint, configuredDefault, current))
.catch(() => {})
.then(() => {
if (recordingDefaults.get(fingerprint) === recording) {
recordingDefaults.delete(fingerprint)
}
})
recordingDefaults.set(fingerprint, recording)
return recording
}
return Promise.resolve()
}
return {
captureSource,
retireContext,
providerStarted(record: AgentSessionRecord, source?: () => boolean) {
const receipt = leaseWorkspace(record.location.workspaceId)
const current = () =>
receipt.current() &&
source?.() !== false &&
deps.getRecord(record.sessionId)?.lease.runtimeFence === record.lease.runtimeFence
void sessionCatalogKey(record)
.then((fingerprint) => {
if (current()) {
deps.store.expireFailure(fingerprint)
}
})
.catch(() => {})
.finally(receipt.release)
},
recordLiveListing(
sessionId: string,
listing: AgentModelCatalogLiveListing,
source?: () => boolean
) {
const record = deps.getRecord(sessionId)
if (!record || !deps.drivesRecord(record) || independentAgentModelCatalog(record.provider)) {
return
}
const receipt = leaseWorkspace(record.location.workspaceId)
const current = () =>
receipt.current() &&
source?.() !== false &&
deps.getRecord(record.sessionId)?.lease.runtimeFence === record.lease.runtimeFence
if (!current()) {
receipt.release()
return
}
if (!deps.listsInChatFolder?.has(record.provider)) {
void saveLiveListing(
record,
listing,
agentModelCatalogFingerprintForRecord(record),
current
).finally(receipt.release)
return
}
// In report order: a folder lookup that resolves late must not let an older listing land last.
const writing = (liveWrites.get(sessionId) ?? Promise.resolve())
.then(async () =>
saveLiveListing(record, listing, await sessionCatalogKey(record), current)
)
.catch((error: unknown) => {
console.warn('[agent-model-catalog] could not save a chat model list:', error)
})
.finally(() => {
receipt.release()
if (liveWrites.get(sessionId) === writing) {
liveWrites.delete(sessionId)
}
})
liveWrites.set(sessionId, writing)
}
}
}
@@ -0,0 +1,168 @@
import { mkdtempSync } from 'node:fs'
import * as fsPromises from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { createAgentModelCatalogFilePersistence } from './agent-model-catalog-persistence'
import {
AgentModelCatalogStore,
AGENT_MODEL_CATALOG_MAX_ENTRIES
} from './agent-model-catalog-store'
import type { AgentModelCatalogEntry } from './agent-model-catalog-entry'
vi.mock('node:fs/promises', async (original) => {
const actual = await original<typeof fsPromises>()
return { ...actual, writeFile: vi.fn(actual.writeFile) }
})
const actual = await vi.importActual<typeof fsPromises>('node:fs/promises')
afterEach(() => {
vi.restoreAllMocks()
})
it('materializes once per flush, freezes A before queued B, and returns the no-pending writing barrier', async () => {
const directory = mkdtempSync(join(tmpdir(), 'pr-31927-catalog-snapshot-'))
const persistence = createAgentModelCatalogFilePersistence(directory)
const store = new AgentModelCatalogStore()
let materializations = 0
await store.attachPersistence({
...persistence,
save: (snapshot) =>
persistence.save(() => {
materializations++
return snapshot()
})
})
const publish = (id: string) =>
store.recordSuccess(
'fp',
'grok',
{
origin: 'probe',
models: [{ id, label: id, efforts: [], isDefault: true }]
},
'discovery'
)
for (let index = 0; index < 10; index++) {
publish(`burst-${index}`)
}
publish('A')
store.recordConfiguredDefault('fp', { modelId: 'A', effort: 'high' })
expect(materializations).toBe(0)
const held = Promise.withResolvers<void>()
const entered = Promise.withResolvers<void>()
vi.mocked(fsPromises.writeFile).mockImplementationOnce(async (...args) => {
entered.resolve()
await held.promise
await actual.writeFile(...args)
})
const a = persistence.flush()
expect(materializations).toBe(1)
await entered.promise
publish('B')
const b = persistence.flush()
expect(materializations).toBe(2)
expect(persistence.flush()).toBe(b)
expect(materializations).toBe(2)
expect(fsPromises.writeFile).toHaveBeenCalledTimes(1)
held.resolve()
await a
await b
const writes = vi.mocked(fsPromises.writeFile).mock.calls
expect(writes[0]?.[1]).toContain('"id":"A"')
expect(writes[0]?.[1]).not.toContain('"id":"B"')
expect(writes[1]?.[1]).toContain('"id":"B"')
expect(
JSON.parse(await fsPromises.readFile(join(directory, 'agent-model-catalog.json'), 'utf8'))
.version
).toBe(3)
const reloaded = new AgentModelCatalogStore()
await reloaded.attachPersistence(createAgentModelCatalogFilePersistence(directory))
expect(reloaded.get('fp')).toMatchObject({
discovered: { models: [{ id: 'B' }] },
configured: { modelId: 'A', effort: 'high' }
})
// Explicit quit-style flush materializes pending work without the unref'd timer firing.
publish('quit')
await store.flushPersistence()
expect(materializations).toBe(3)
expect((await persistence.load())[0]?.models[0]?.id).toBe('quit')
})
it('captures committed hydration, cap, timestamps and LRU order at flush without arming reads', async () => {
let now = 1
const directory = mkdtempSync(join(tmpdir(), 'pr-31927-catalog-hydration-'))
const persistence = createAgentModelCatalogFilePersistence(directory)
const store = new AgentModelCatalogStore({ now: () => now })
const loaded = Promise.withResolvers<AgentModelCatalogEntry[]>()
const attach = store.attachPersistence({ ...persistence, load: () => loaded.promise })
const publish = (key: string) =>
store.recordSuccess(
key,
'grok',
{
origin: 'probe',
models: [{ id: key, label: key, efforts: [], isDefault: true }]
},
'discovery'
)!
const hydrated = new AgentModelCatalogStore()
const rows = Array.from({ length: AGENT_MODEL_CATALOG_MAX_ENTRIES }, (_, index) =>
hydrated.recordSuccess(
`saved-${index}`,
'grok',
{
origin: 'probe',
models: [{ id: 'saved', label: 'Saved', efforts: [], isDefault: true }]
},
'discovery'
)!
)
publish('current')
now = 2
publish('current') // Same content updates age, but does not request another save.
loaded.resolve(rows)
await attach
expect(store.get('current')).toBeNull()
now = 3
store.recordSuccess(
'saved-1',
'grok',
{ origin: 'probe', models: [{ id: 'saved', label: 'Saved', efforts: [], isDefault: true }] },
'discovery'
)
store.get('saved-1')
await store.flushPersistence()
const saved = await persistence.load()
expect(saved).toHaveLength(AGENT_MODEL_CATALOG_MAX_ENTRIES)
expect(saved.at(-1)?.fingerprint).toBe('saved-1')
expect(saved.at(-1)?.discovered?.at).toBe(3)
expect(saved.some((entry) => entry.fingerprint === 'current')).toBe(false)
const writes = vi.mocked(fsPromises.writeFile).mock.calls.length
store.get('saved-2')
await store.flushPersistence()
expect(fsPromises.writeFile).toHaveBeenCalledTimes(writes)
})
it('keeps optional cache write failure from poisoning subsequent serialized writes', async () => {
const directory = mkdtempSync(join(tmpdir(), 'pr-31927-catalog-failure-'))
const store = new AgentModelCatalogStore()
const persistence = createAgentModelCatalogFilePersistence(directory)
await store.attachPersistence(persistence)
const publish = (id: string) =>
store.recordSuccess(
'fp',
'grok',
{
origin: 'probe',
models: [{ id, label: id, efforts: [], isDefault: true }]
},
'discovery'
)
vi.mocked(fsPromises.writeFile).mockRejectedValueOnce(new Error('cache unavailable'))
publish('A')
await store.flushPersistence()
expect(store.get('fp')?.models[0]?.id).toBe('A')
publish('B')
await store.flushPersistence()
expect((await persistence.load())[0]?.models[0]?.id).toBe('B')
})
@@ -19,7 +19,7 @@ const SAVE_COALESCE_MS = 500
export type AgentModelCatalogPersistence = {
load: () => Promise<AgentModelCatalogEntry[]>
/** Coalesce saves; failures leave the in-memory store authoritative. */
save: (entries: readonly AgentModelCatalogEntry[]) => void
save: (snapshot: () => readonly AgentModelCatalogEntry[]) => void
/** Flush at quit because the delayed-save timer is unref'd. */
flush: () => Promise<void>
}
@@ -162,7 +162,7 @@ export function createAgentModelCatalogFilePersistence(
directory: string
): AgentModelCatalogPersistence {
const filePath = join(directory, 'agent-model-catalog.json')
let pending: readonly AgentModelCatalogEntry[] | null = null
let pending: (() => readonly AgentModelCatalogEntry[]) | null = null
let timer: ReturnType<typeof setTimeout> | null = null
let writing = Promise.resolve()
@@ -171,11 +171,12 @@ export function createAgentModelCatalogFilePersistence(
clearTimeout(timer)
timer = null
}
const entries = pending
const snapshot = pending
pending = null
if (!entries) {
if (!snapshot) {
return writing
}
const entries = snapshot()
writing = writing.then(async () => {
try {
await mkdir(dirname(filePath), { recursive: true })
@@ -211,8 +212,8 @@ export function createAgentModelCatalogFilePersistence(
return []
}
},
save(entries) {
pending = [...entries]
save(snapshot) {
pending = snapshot
if (timer === null) {
timer = setTimeout(flush, SAVE_COALESCE_MS)
timer.unref?.()
@@ -84,10 +84,18 @@ describe('model catalog prewarm', () => {
await prewarmed
expect(codex).toHaveBeenCalledTimes(1)
expect(codex).toHaveBeenCalledWith(HOME('CODEX_HOME', '/homes/a'), {
workspacePath: expect.any(String),
signal: expect.any(AbortSignal)
})
expect(codex).toHaveBeenCalledWith(
HOME('CODEX_HOME', '/homes/a'),
expect.objectContaining({
workspacePath: expect.any(String),
signal: expect.any(AbortSignal),
operation: expect.objectContaining({
current: expect.any(Function),
deadline: expect.any(Number),
signal: expect.any(AbortSignal)
})
})
)
expect(grok).toHaveBeenCalledTimes(1)
expect((await service.read({ agent: 'codex' })).origin).toBe('probe')
expect((await service.read({ agent: 'grok' })).origin).toBe('probe')
@@ -129,8 +137,14 @@ describe('model catalog prewarm', () => {
service.recordLiveListing('chat-b', { models: listing('newer').models })
lookups[1]!.resolve('/work/b')
lookups[0]!.resolve('/work/b')
const read = await service.read({ agent: 'opencode2', sessionId: 'chat-b' })
expect(read.origin !== 'unknown' && read.models.map((model) => model.id)).toEqual(['newer'])
await vi.waitFor(async () => {
const read = await service.read({
agent: 'opencode2',
sessionId: 'chat-b',
waitForListing: true
})
expect(read.origin !== 'unknown' && read.models.map((model) => model.id)).toEqual(['newer'])
})
})
it('keeps one catalog per folder for an agent whose project config changes its models', async () => {
@@ -211,20 +225,34 @@ describe('model catalog prewarm', () => {
const prewarmed = service.prewarm()
const opened = await service.read({ agent: 'opencode2', workspacePath: '/work/project' })
expect(opened).toEqual({ origin: 'unknown' })
expect(probe).not.toHaveBeenCalled()
const waited = service.read({
agent: 'opencode2',
workspacePath: '/work/project',
waitForListing: true
})
await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(1))
expect(await service.read({ agent: 'opencode2', workspacePath: '/work/project' })).toEqual({
origin: 'unknown',
listingInProgress: true
})
versionChecked.resolve()
await prewarmed
expect(opened).toEqual({ origin: 'unknown', listingInProgress: true })
expect((await waited).origin).toBe('probe')
expect(probe).toHaveBeenCalledTimes(1)
expect(probe).toHaveBeenCalledWith(HOME('OPENCODE_HOME', '/homes/a'), {
signal: expect.any(AbortSignal),
workspacePath: '/work/project'
})
expect(probe).toHaveBeenCalledWith(
HOME('OPENCODE_HOME', '/homes/a'),
expect.objectContaining({
signal: expect.any(AbortSignal),
workspacePath: '/work/project',
operation: expect.objectContaining({
current: expect.any(Function),
deadline: expect.any(Number),
signal: expect.any(AbortSignal)
})
})
)
})
it('lists the new account after a switch and leaves the old account’s catalog alone', async () => {
@@ -292,8 +320,11 @@ describe('model catalog prewarm', () => {
it('lists only agents used here: a saved catalog or a chat of it', async () => {
const store = new AgentModelCatalogStore()
const piListed = deferred<AgentModelCatalogSuccess>()
const probeFor = (agent: string) =>
vi.fn(async (_home: AgentSessionAccountHome) => listing(`${agent}-model`))
vi.fn(async (_home: AgentSessionAccountHome) =>
agent === 'pi' ? piListed.promise : listing(`${agent}-model`)
)
const probes = { codex: probeFor('codex'), claude: probeFor('claude'), pi: probeFor('pi') }
store.recordSuccess('claude-other-account', 'claude', listing('opus'), 'discovery')
const service = createAgentModelCatalogService({
@@ -310,11 +341,18 @@ describe('model catalog prewarm', () => {
expect(probes.codex).toHaveBeenCalledTimes(1)
expect(probes.claude).toHaveBeenCalledTimes(1)
expect(probes.pi).not.toHaveBeenCalled()
expect(await service.read({ agent: 'pi' })).toEqual({ origin: 'unknown' })
expect(probes.pi).not.toHaveBeenCalled()
// Its first chat still lists it on demand.
const demand = service.demand({ agent: 'pi' })
await vi.waitFor(() => expect(probes.pi).toHaveBeenCalledTimes(1))
expect(await service.read({ agent: 'pi' })).toEqual({
origin: 'unknown',
listingInProgress: true
})
expect(probes.pi).toHaveBeenCalledTimes(1)
piListed.resolve(listing('pi-model'))
await demand
expect(await service.read({ agent: 'pi' })).toMatchObject({ models: [{ id: 'pi-model' }] })
})
it('on stop, starts no queued agent, stops the running listings and lists nothing more', async () => {
@@ -417,11 +455,6 @@ describe('one account, one catalog key', () => {
resolveAccountHome: async () => HOME('GROK_HOME', managedHome)
})
service.recordLiveListing('session-1', { models: listing('gpt-live').models })
const incoming = { models: [...listing('gpt-live').models, ...listing('gpt-new').models] }
const retained = (launchOnlyModelId: string) =>
service.retainedLiveModelIds('session-1', { ...incoming, launchOnlyModelId })
expect(await retained('gpt-live')).toEqual(['gpt-live', 'gpt-new'])
expect(await retained('gpt-new')).toEqual(['gpt-live'])
const adapterAccess = agentModelCatalogSessionAccess(
store,
{ agent: 'grok', accountHomeVariable: 'GROK_HOME' },
@@ -429,7 +462,123 @@ describe('one account, one catalog key', () => {
)
expect(adapterAccess?.fingerprint).toBe(agentModelCatalogFingerprintForRecord(launched))
const read = await service.read({ agent: 'grok' })
// …is what the next new chat reads for the same home: no cold start for this account.
const read = await service.read({ agent: 'grok', waitForListing: true })
expect(read.origin !== 'unknown' && read.models.map((model) => model.id)).toEqual(['gpt-live'])
})
})
it('keeps old producers from publishing rows, defaults or failure expiry after retirement and stop', async () => {
const store = new AgentModelCatalogStore()
const record = agentSessionRecordFixture({
sessionId: 'source-chat',
provider: 'opencode2',
accountHome: HOME('OPENCODE_HOME', '/account'),
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'folder',
workspaceKind: 'folder'
}
})
const folder = deferred<string | null>()
const defaults = deferred<boolean>()
const lookup = vi.fn(async () => folder.promise)
const defaultLookup = vi.fn(async () => defaults.promise)
const service = createAgentModelCatalogService({
store,
getConversation: () => {
const held = record
return held ? agentSessionConversationView(held) : undefined
},
getRecord: () => record,
drivesRecord: () => true,
resolveAccountHome: async () => record.accountHome,
recordWorkspacePath: lookup,
workspaceMayOverrideDefaultModel: defaultLookup,
listsInChatFolder: new Set(['opencode2'])
})
const oldSource = service.captureSource(record)
service.recordLiveListing(
record.sessionId,
{ ...listing('old'), configuredDefault: { modelId: 'old' } },
oldSource
)
service.providerStarted(record, oldSource)
await vi.waitFor(() => expect(lookup).toHaveBeenCalled())
store.expireAgent(record.provider)
const key = agentModelCatalogFingerprintForRecord(record, '/project')
const current = store.recordSuccess(key, record.provider, listing('current'), 'discovery')
await store.refresh(key, record.provider, () => Promise.reject(new Error('current failure')))
const save = vi.fn()
await store.attachPersistence({ load: async () => [], save, flush: async () => {} })
folder.resolve('/project')
await vi.waitFor(() => expect(lookup).toHaveBeenCalledTimes(2))
// Receipt after retirement cannot grant the old child a new launch identity.
service.recordLiveListing(record.sessionId, listing('late-receipt'), oldSource)
await Promise.resolve()
expect(store.get(key)).toBe(current)
expect(store.failureDetail(key)).toBe('current failure')
expect(save).not.toHaveBeenCalled()
const source = service.captureSource(record)
service.recordLiveListing(
record.sessionId,
{ ...listing('valid'), configuredDefault: { modelId: 'valid' } },
source
)
await vi.waitFor(() => expect(defaultLookup).toHaveBeenCalledTimes(1))
const beforeStop = store.get(key)
save.mockClear()
service.stop()
defaults.resolve(false)
await Promise.resolve()
await Promise.resolve()
expect(store.get(key)).toBe(beforeStop)
expect(store.get(key)?.configured).toBeNull()
expect(save).not.toHaveBeenCalled()
})
it('withholds a known retired Pi invocation until a current live producer lists', async () => {
const store = new AgentModelCatalogStore()
const record = agentSessionRecordFixture({
sessionId: 'pi-chat',
provider: 'pi',
accountHome: HOME('PI_CODING_AGENT_DIR', '/pi')
})
const key = agentModelCatalogFingerprintForRecord(record)
store.recordSuccess(key, record.provider, listing('command-a'), 'discovery')
const listingPending = deferred<AgentModelCatalogSuccess>()
const probe = vi.fn(() => listingPending.promise)
const service = createAgentModelCatalogService({
store,
getConversation: () => {
const held = record
return held ? agentSessionConversationView(held) : undefined
},
getRecord: () => record,
drivesRecord: () => true,
resolveAccountHome: async () => record.accountHome,
probes: { pi: probe }
})
const old = service.captureSource(record)
store.expireAgent('pi')
void service.demand({ agent: 'pi', sessionId: record.sessionId })
await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(1))
expect(service.snapshot({ agent: 'pi', sessionId: record.sessionId, savedOnly: true })).toEqual({
origin: 'unknown'
})
service.recordLiveListing(record.sessionId, listing('old-child'), old)
expect(service.snapshot({ agent: 'pi', sessionId: record.sessionId, savedOnly: true })).toEqual({
origin: 'unknown'
})
service.recordLiveListing(
record.sessionId,
listing('current-child'),
service.captureSource(record)
)
expect(
service.snapshot({ agent: 'pi', sessionId: record.sessionId, savedOnly: true })
).toMatchObject({ models: [{ id: 'current-child' }] })
service.stop()
listingPending.resolve(listing('stopped'))
})
@@ -3,9 +3,7 @@ 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'
export async function newChatCatalogKey(
deps: AgentModelCatalogServiceDeps,
@@ -42,11 +40,7 @@ async function runBounded<T>(
export async function prewarmAgentModelCatalog(
deps: AgentModelCatalogServiceDeps,
signal: AbortSignal,
read: AgentModelCatalogService['read'],
listWith: (
probe: AgentModelCatalogProbe,
home: AgentSessionAccountHome
) => () => Promise<AgentModelCatalogSuccess>
read: AgentModelCatalogService['read']
): Promise<void> {
if (signal.aborted) {
return
@@ -59,15 +53,6 @@ export async function prewarmAgentModelCatalog(
(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
if (!probe || !key || signal.aborted || !deps.store.probeDue(key.fingerprint)) {
return
}
await deps.store.refresh(key.fingerprint, agent, probe, listWith(probe, key.accountHome))
await read({ agent, waitForListing: true })
})
}
@@ -1,3 +1,4 @@
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home'
@@ -5,7 +6,10 @@ import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../../shared/agent-session-record.test-fixture'
import { agentModelCatalogFingerprint } from './agent-model-catalog-fingerprint'
import {
agentModelCatalogFingerprint,
agentModelCatalogFingerprintForRecord
} from './agent-model-catalog-fingerprint'
import { createAgentModelCatalogService } from './agent-model-catalog-service'
import {
AGENT_MODEL_CATALOG_FAILURE_TTL_MS,
@@ -24,6 +28,87 @@ const FINGERPRINT = agentModelCatalogFingerprint({
})
const SIGNED_OUT = { reason: 'notSignedIn', account: 'system' } as const
it('keeps a released claim separate while two eligible chats share their first project listing', async () => {
const released = {
...agentSessionRecordFixture(
agentSessionLeaseFixture({
sessionId: 'released',
claimStatus: 'released',
ownerProcess: null,
deathEvidence: {
kind: 'pid-absent',
detail: 'failed before spawn',
observedAt: 1000,
ownerFence: 7
}
})
),
provider: 'opencode',
accountHome: { kind: 'opencode' as const, locator: { kind: 'unmanaged' as const } },
providerHandleChain: []
}
const liveA = {
...released,
...agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: 'live-a' })),
provider: released.provider,
accountHome: released.accountHome,
providerHandleChain: []
}
const liveB = {
...liveA,
...agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: 'live-b' })),
provider: released.provider,
accountHome: released.accountHome,
providerHandleChain: []
}
const records = new Map([released, liveA, liveB].map((record) => [record.sessionId, record]))
const folder = Promise.withResolvers<string>()
const answer = Promise.withResolvers<AgentModelCatalogSuccess>()
const probe = vi.fn(() => answer.promise)
const service = createAgentModelCatalogService({
store: new AgentModelCatalogStore(),
getConversation: (id) => {
const held = records.get(id)
return held ? agentSessionConversationView(held) : undefined
},
getRecord: (id) => records.get(id),
drivesRecord: () => true,
resolveAccountHome: async () => HOME,
recordWorkspacePath: async () => folder.promise,
listsInChatFolder: new Set(['opencode']),
probes: { opencode: probe }
})
const request = (sessionId: string) => ({
agent: 'opencode',
sessionId,
inSessionWorkspace: true as const
})
try {
const first = service.demand(request(released.sessionId))
const eligible = service.demand(request(liveA.sessionId))
const joined = service.demand(request(liveB.sessionId))
expect(first).not.toBe(eligible)
expect(joined).toBe(eligible)
folder.resolve('/project')
await first
await vi.waitFor(() => expect(probe).toHaveBeenCalledOnce())
answer.resolve(listing('project-model'))
await Promise.all([eligible, joined])
expect(service.snapshot(request(liveA.sessionId))).toMatchObject({
models: [{ id: 'project-model' }]
})
expect(service.snapshot(request(liveB.sessionId))).toMatchObject({
models: [{ id: 'project-model' }]
})
await service.demand(request(released.sessionId))
expect(probe).toHaveBeenCalledOnce()
} finally {
answer.resolve(listing('project-model'))
folder.resolve('/project')
service.stop()
}
})
function listing(id: string): AgentModelCatalogSuccess {
return {
models: [{ id, label: id, isDefault: true, efforts: [] }],
@@ -100,21 +185,23 @@ describe('a saved-only catalog read', () => {
expect(probe).not.toHaveBeenCalled()
expect(resolveAccountHome).not.toHaveBeenCalled()
await service.read({ agent: 'grok' })
await service.read({ agent: 'grok', waitForListing: true })
expect(probe).toHaveBeenCalledOnce()
})
it('answers from the saved entry and starts nothing, however old the entry is', async () => {
const { store, probe, service, advance } = rig()
store.recordSuccess(FINGERPRINT, 'grok', listing('grok-4.7'), 'discovery')
await service.demand({ agent: 'grok' })
advance(AGENT_MODEL_CATALOG_FRESH_MS)
const result = await service.read({ agent: 'grok', savedOnly: true })
expect(result).toMatchObject({ origin: 'probe', models: [{ id: 'grok-4.7' }] })
expect(result).not.toHaveProperty('listingInProgress')
expect(store.pendingListing(FINGERPRINT)).toBeNull()
expect(probe).not.toHaveBeenCalled()
await service.read({ agent: 'grok' })
expect(probe).toHaveBeenCalledTimes(1)
// The same read without `savedOnly` refreshes the aged entry: the probe is what the flag skips.
await service.read({ agent: 'grok', waitForListing: true })
await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(1))
})
it('answers unknown with nothing saved, and starts no listing for it', async () => {
@@ -127,10 +214,11 @@ describe('a saved-only catalog read', () => {
it('carries a held reason without re-checking it past its TTL', async () => {
const { store, probe, service, advance } = rig()
await store
.refresh(FINGERPRINT, 'grok', probe, () =>
.refresh(FINGERPRINT, 'grok', () =>
Promise.reject(new AgentModelCatalogUnavailableError(SIGNED_OUT))
)
.catch(() => undefined)
await service.demand({ agent: 'grok' })
advance(AGENT_MODEL_CATALOG_FAILURE_TTL_MS)
expect(await service.read({ agent: 'grok', savedOnly: true })).toEqual({
origin: 'unknown',
@@ -142,6 +230,7 @@ describe('a saved-only catalog read', () => {
it('says the named default holds in every workspace only for an agent no project config moves', async () => {
const grok = rig('grok')
grok.store.recordSuccess(FINGERPRINT, 'grok', listing('grok-4.7'), 'discovery')
await grok.service.demand({ agent: 'grok' })
expect(await grok.service.read({ agent: 'grok', savedOnly: true })).toMatchObject({
listingNamesConfiguredModel: true,
defaultHoldsInEveryWorkspace: true
@@ -154,8 +243,210 @@ describe('a saved-only catalog read', () => {
wslDistro: null
})
opencode.store.recordSuccess(opencodeKey, 'opencode', listing('a/b'), 'discovery')
await opencode.service.demand({ agent: 'opencode' })
expect(await opencode.service.read({ agent: 'opencode', savedOnly: true })).not.toHaveProperty(
'defaultHoldsInEveryWorkspace'
)
})
})
it('returns a snapshot before a cold worktree selector resolves', async () => {
const folder = Promise.withResolvers<string | null>()
const account = vi.fn(async () => HOME)
const service = createAgentModelCatalogService({
store: new AgentModelCatalogStore(),
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
resolveAccountHome: account
})
const request = {
agent: 'grok',
workspace: {
selector: 'folder',
identity: () => 1,
resolve: () => folder.promise
}
}
try {
expect(await service.read(request)).toEqual({ origin: 'unknown' })
expect(account).not.toHaveBeenCalled()
void service.demand(request)
expect(await service.read(request)).toEqual({ origin: 'unknown', listingInProgress: true })
expect(await service.read({ ...request, savedOnly: true })).toEqual({ origin: 'unknown' })
expect(account).not.toHaveBeenCalled()
} finally {
service.stop()
folder.resolve(null)
}
})
it('observes a bound scope immediately while another account and default writer are unresolved', async () => {
const home = Promise.withResolvers<AgentSessionAccountHome>()
const defaults = Promise.withResolvers<boolean>()
let selected = false
const resolveAccountHome = vi.fn(() => (selected ? home.promise : Promise.resolve(HOME)))
const store = new AgentModelCatalogStore()
store.recordSuccess(FINGERPRINT, 'grok', listing('kept'), 'discovery')
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
resolveAccountHome,
workspaceMayOverrideDefaultModel: () => defaults.promise
})
try {
await service.demand({ agent: 'grok', workspacePath: '/known' })
selected = true
void service.demand({ agent: 'grok', workspacePath: '/unresolved' })
await vi.waitFor(() => expect(resolveAccountHome).toHaveBeenCalledTimes(2))
const calls = resolveAccountHome.mock.calls.length
expect(
await service.read({ agent: 'grok', workspacePath: '/known', savedOnly: true })
).toMatchObject({ models: [{ id: 'kept', isDefault: false }] })
expect(
await service.read({ agent: 'grok', workspacePath: '/unresolved', savedOnly: true })
).toEqual({ origin: 'unknown' })
expect(resolveAccountHome).toHaveBeenCalledTimes(calls)
} finally {
service.stop()
home.resolve(HOME)
defaults.resolve(false)
}
})
it.each(['success', 'failure'] as const)(
'retires pending resolution and old %s ingestion across change away and back',
async (outcome) => {
const initial = Promise.withResolvers<AgentSessionAccountHome>()
const stale = Promise.withResolvers<AgentModelCatalogSuccess>()
let resolving = true
const store = new AgentModelCatalogStore()
const probe = vi.fn(() => stale.promise)
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
resolveAccountHome: () => (resolving ? initial.promise : Promise.resolve(HOME)),
probes: { grok: probe }
})
try {
const first = service.demand({ agent: 'grok' })
store.expireLaunchScopes()
service.retireContext()
store.expireLaunchScopes()
service.retireContext()
initial.resolve(HOME)
await first
expect(probe).not.toHaveBeenCalled()
resolving = false
const old = service.demand({ agent: 'grok' })
await vi.waitFor(() => expect(probe).toHaveBeenCalledOnce())
store.expireLaunchScopes()
service.retireContext()
await store.refresh(FINGERPRINT, 'grok', () =>
Promise.reject(new Error('new account failure'))
)
if (outcome === 'success') {
stale.resolve(listing('retired'))
} else {
stale.reject(new AgentModelCatalogUnavailableError(SIGNED_OUT))
}
await old
expect(store.get(FINGERPRINT)).toBeNull()
expect(store.failureDetail(FINGERPRINT)).toBe('new account failure')
expect(service.snapshot({ agent: 'grok', savedOnly: true })).toEqual({ origin: 'unknown' })
} finally {
service.stop()
}
}
)
it('starts a new listing instead of joining a retired workspace invocation', async () => {
const stale = Promise.withResolvers<AgentModelCatalogSuccess>()
let revision = 0
const probe = vi.fn().mockReturnValueOnce(stale.promise).mockResolvedValue(listing('current'))
const store = new AgentModelCatalogStore()
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
resolveAccountHome: async () => HOME,
probes: { grok: probe }
})
const request = {
agent: 'grok',
workspace: {
selector: 'folder',
identity: () => revision,
resolve: async () => '/folder'
}
}
try {
const first = service.demand(request)
await vi.waitFor(() => expect(probe).toHaveBeenCalledOnce())
revision += 2
expect(service.snapshot({ ...request, savedOnly: true })).toEqual({ origin: 'unknown' })
await service.demand(request)
expect(probe).toHaveBeenCalledTimes(2)
stale.resolve(listing('retired'))
await first
expect(service.snapshot({ ...request, savedOnly: true })).toMatchObject({
models: [{ id: 'current' }]
})
} finally {
stale.resolve(listing('retired'))
service.stop()
}
})
it('keeps a readable retired conversation warm without listing or committing its delayed live report', async () => {
const pinned = {
...agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: 'session-1' })),
provider: 'grok',
accountHome: HOME
}
let writable: AgentSessionRecord | undefined = pinned
const store = new AgentModelCatalogStore()
const fingerprint = agentModelCatalogFingerprintForRecord(pinned, '/project')
store.recordSuccess(fingerprint, pinned.provider, listing('saved'), 'discovery')
const folder = Promise.withResolvers<string | null>()
const recordWorkspacePath = vi.fn<() => Promise<string | null>>(async () => '/project')
const probe = vi.fn(async () => listing('unexpected'))
const service = createAgentModelCatalogService({
store,
getConversation: () => agentSessionConversationView(pinned),
getRecord: () => writable,
drivesRecord: () => true,
resolveAccountHome: async () => pinned.accountHome,
recordWorkspacePath,
listsInChatFolder: new Set([pinned.provider]),
probes: { grok: probe }
})
const params = {
agent: pinned.provider,
sessionId: pinned.sessionId,
inSessionWorkspace: true as const
}
try {
expect(await service.prepareSaved(params)).toMatchObject({ models: [{ id: 'saved' }] })
recordWorkspacePath.mockImplementationOnce(() => folder.promise)
service.recordLiveListing(pinned.sessionId, { models: listing('retired-report').models })
await vi.waitFor(() => expect(recordWorkspacePath).toHaveBeenCalledTimes(2))
writable = undefined
folder.resolve('/project')
expect(await service.read({ ...params, waitForListing: true })).toMatchObject({
models: [{ id: 'saved' }]
})
expect(store.observation(fingerprint)?.models.map((row) => row.id)).toEqual(['saved'])
expect(probe).not.toHaveBeenCalled()
service.recordLiveListing(pinned.sessionId, { models: listing('unwritable-report').models })
expect(service.snapshot(params)).toMatchObject({ models: [{ id: 'saved' }] })
} finally {
folder.resolve('/project')
service.stop()
}
})
@@ -0,0 +1,189 @@
import { expect, it, vi } from 'vitest'
import { createAgentModelCatalogService } from './agent-model-catalog-service'
import { AgentModelCatalogStore, type AgentModelCatalogSuccess } from './agent-model-catalog-store'
import type { AgentModelCatalogProbe } from './agent-model-catalog-discovery'
import type { AgentModelCatalogReadParams } from './agent-model-catalog-service-contract'
it('catalog-scale retires a many-scope frontier and releases actual listing waiters before recovery', async () => {
for (const scopes of [5, 50]) {
const store = new AgentModelCatalogStore()
let active = 0
let peak = 0
let started = 0
let cancelled = 0
let identityReads = 0
const perKeyReads = Array.from({ length: scopes }, () => 0)
let pendingWaiters = 0
let peakWaiters = 0
let recover = false
const pending = store.pendingListing.bind(store)
vi.spyOn(store, 'pendingListing').mockImplementation((...args) => {
const waiting = pending(...args)
if (!waiting) {
return null
}
peakWaiters = Math.max(peakWaiters, ++pendingWaiters)
return waiting.finally(() => pendingWaiters--)
})
const probe = vi.fn<AgentModelCatalogProbe>(async (_home, options) => {
started++
if (recover) {
return {
origin: 'probe' as const,
models: [{ id: 'recovered', label: 'Recovered', isDefault: true, efforts: [] }]
}
}
peak = Math.max(peak, ++active)
return new Promise<AgentModelCatalogSuccess>((_resolve, reject) => {
const stop = () => {
active--
cancelled++
reject(new Error('owner retired'))
}
options?.signal?.addEventListener('abort', stop, { once: true })
if (options?.signal?.aborted) {
stop()
}
})
})
const home = vi.fn(async () => ({ variable: 'HOME', path: '/generated-account' }))
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
resolveAccountHome: home,
probes: { opencode: probe },
listsInChatFolder: new Set(['opencode'])
})
const params: AgentModelCatalogReadParams[] = Array.from({ length: scopes }, (_, index) => ({
agent: 'opencode',
workspace: {
selector: `workspace-${index}`,
identity: () => {
identityReads++
perKeyReads[index]++
return `scope-${index}`
},
resolve: async () => ({ path: `/generated-project/${index}`, identity: `scope-${index}` })
}
}))
const start = performance.now()
try {
const demands = params.map((request) => service.demand(request))
const joined = params.map((request) => service.demand(request))
expect(joined.every((promise, index) => promise === demands[index])).toBe(true)
await vi.waitFor(() => expect(pendingWaiters).toBe(scopes))
expect(started).toBe(2)
expect(peak).toBe(2)
expect(params.filter((request) => service.snapshot(request).listingInProgress).length).toBe(
scopes
)
const pendingIdentityReads = identityReads
expect(pendingIdentityReads).toBeGreaterThanOrEqual(scopes)
perKeyReads.fill(0)
service.snapshot(params[0])
expect(perKeyReads[0]).toBeGreaterThan(0)
expect(perKeyReads.slice(1)).toEqual(Array.from({ length: scopes - 1 }, () => 0))
expect(peakWaiters).toBe(scopes)
store.expireAgent('opencode')
service.retireContext()
await Promise.all([...demands, ...joined])
expect(active).toBe(0)
expect(cancelled).toBe(2)
expect(started).toBe(2)
expect(pendingWaiters).toBe(0)
recover = true
await service.demand(params[scopes - 1])
expect(service.snapshot(params[scopes - 1])).toMatchObject({ models: [{ id: 'recovered' }] })
expect(started).toBe(3)
expect(pendingWaiters).toBe(0)
process.stdout.write(
`CATALOG_FRONTIER ${JSON.stringify({
scopes,
callers: scopes * 2,
scopeResolutions: home.mock.calls.length - 1,
peakNativeFixtureCalls: peak,
startedBeforeRetirement: started - 1,
cancelled,
peakWaiters,
retainedWaiters: pendingWaiters,
pendingIdentityReads,
totalIdentityReads: identityReads,
recoveryCalls: 1,
elapsedMs: performance.now() - start
})}\n`
)
} finally {
service.stop()
vi.restoreAllMocks()
}
}
})
it('settles owner deadlines and joined readers without releasing ignored native attempts early', async () => {
vi.useFakeTimers()
const store = new AgentModelCatalogStore()
const attempts: ReturnType<typeof Promise.withResolvers<AgentModelCatalogSuccess>>[] = []
let active = 0
let peak = 0
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
resolveAccountHome: async () => ({ variable: 'HOME', path: '/account' }),
listsInChatFolder: new Set(['opencode']),
probes: {
opencode: async () => {
const attempt = Promise.withResolvers<AgentModelCatalogSuccess>()
attempts.push(attempt)
peak = Math.max(peak, ++active)
try {
return await attempt.promise
} finally {
active--
}
}
}
})
const params = (workspacePath: string) => ({ agent: 'opencode', workspacePath })
try {
const a = service.demand(params('/a'))
const joined = service.demand(params('/a'))
const b = service.demand(params('/b'))
expect(joined).toBe(a)
await vi.advanceTimersByTimeAsync(0)
expect(attempts).toHaveLength(2)
const read = service.read({ ...params('/a'), waitForListing: true })
await vi.advanceTimersByTimeAsync(30_000)
await Promise.all([a, joined, b, read])
expect(service.snapshot(params('/a')).listingInProgress).toBeUndefined()
expect(active).toBe(2)
const replacement = service.demand(params('/a'))
await vi.advanceTimersByTimeAsync(0)
expect(attempts).toHaveLength(2)
attempts[0]!.resolve({
origin: 'probe',
models: [{ id: 'old', label: 'Old', efforts: [], isDefault: true }]
})
await vi.advanceTimersByTimeAsync(0)
expect(attempts).toHaveLength(3)
expect(peak).toBe(2)
expect(service.snapshot(params('/a')).origin).toBe('unknown')
attempts[2]!.resolve({
origin: 'probe',
models: [{ id: 'new', label: 'New', efforts: [], isDefault: true }]
})
await vi.advanceTimersByTimeAsync(0)
await replacement
expect(service.snapshot(params('/a'))).toMatchObject({ models: [{ id: 'new' }] })
} finally {
service.stop()
for (const attempt of attempts) {
attempt.resolve({ origin: 'probe', models: [] })
}
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
}
})
@@ -1,8 +1,10 @@
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { resolveCliCommand } from '../../../shared/node-cli-command-resolution'
import { agentSessionRecordFixture } from '../agent-session-record-test-fixture'
import { createClaudeModelCatalogProbe } from '../../claude/claude-model-catalog-probe'
import { createCodexModelCatalogProbe } from '../../codex/codex-model-catalog-probe'
import {
@@ -73,6 +75,86 @@ describe('installed catalog invocation identity', () => {
)
})
describe('original operation through actual nested resolution', () => {
it.each(['overlay', 'environment', 'auth'] as const)(
'starts no stage after %s resolves beyond the original deadline',
async (held) => {
vi.useFakeTimers()
const gate = Promise.withResolvers<void>()
const entered = Promise.withResolvers<void>()
const stages: string[] = []
const stage = async (name: string) => {
stages.push(name)
if (name === held) {
entered.resolve()
await gate.promise
}
}
const runListing = vi.fn(async () => '')
const probe = createClaudeModelCatalogProbe({
resolveLaunchArgs: () => [],
resolveEnv: async () => {
await stage('overlay')
return {}
},
resolveInheritedEnv: async () => {
await stage('environment')
return { PATH: '/fixture' }
},
resolveCommand: () => {
stages.push('lookup')
return '/fixture/claude'
},
resolveAuthPolicy: async () => {
await stage('auth')
return { account: 'system' }
},
runListing
})
const record = agentSessionRecordFixture({
sessionId: 'catalog-scope-session',
provider: 'claude',
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/fixture/account' }
})
const store = new AgentModelCatalogStore()
const service = createAgentModelCatalogService({
store,
getConversation: () => {
const held = record
return held ? agentSessionConversationView(held) : undefined
},
getRecord: () => record,
drivesRecord: () => true,
resolveAccountHome: async () => record.accountHome,
recordWorkspacePath: async () => '/fixture/workspace',
probes: { claude: probe }
})
try {
const demand = service.demand({
agent: 'claude',
sessionId: record.sessionId,
deadline: Date.now() + 10
})
await entered.promise
const before = [...stages]
await vi.advanceTimersByTimeAsync(11)
await demand
gate.resolve()
await vi.advanceTimersByTimeAsync(0)
expect(stages).toEqual(before)
expect(runListing).not.toHaveBeenCalled()
expect(service.snapshot({ agent: 'claude', sessionId: record.sessionId })).toEqual({
origin: 'unknown'
})
} finally {
gate.resolve()
service.stop()
vi.useRealTimers()
}
}
)
})
describe('a picker read with the CLI missing', () => {
it('waits on at most one shell rerun per hold window, and finds a CLI installed after it', async () => {
const root = mkdtempSync(join(tmpdir(), 'orca-catalog-missing-cli-'))
@@ -1,3 +1,4 @@
import type { StructuredAgentResolutionOperation } from '../structured-agent-command-resolution'
import { homedir } from 'node:os'
import type { AgentSessionAccountHome } from '../../../shared/agent-session-record'
import { isNativeLocalLocation } from '../../../shared/execution-host'
@@ -19,7 +20,9 @@ export async function resolveAgentModelCatalogScope(
params: Parameters<AgentModelCatalogService['read']>[0],
record: AgentModelCatalogRecord | undefined,
base: string,
home: AgentSessionAccountHome | null
home: AgentSessionAccountHome | null,
current: () => boolean = () => true,
operation?: StructuredAgentResolutionOperation
): Promise<{
base: string
fingerprint: string
@@ -27,6 +30,9 @@ export async function resolveAgentModelCatalogScope(
cwd?: string
resolved?: AgentModelCatalogResolvedProbe
} | null> {
if (!current()) {
return null
}
if (!independentAgentModelCatalog(params.agent) || !home) {
return { base, fingerprint: base, exact: false }
}
@@ -40,7 +46,10 @@ export async function resolveAgentModelCatalogScope(
const resolved =
savedOnly() || !deps.store.probeDue(fingerprint)
? null
: await probe?.resolveLegacy?.(home).catch(() => null)
: await probe?.resolveLegacy?.(home, operation).catch(() => null)
if (!current()) {
return null
}
return {
base,
fingerprint,
@@ -52,19 +61,25 @@ export async function resolveAgentModelCatalogScope(
params.workspacePath === null
? null
: (params.workspacePath ?? (record ? await sessionWorkspacePath(deps, record) : homedir()))
if (!current()) {
return null
}
if (!cwd) {
return { base, fingerprint: base, exact: false }
}
const resolve = (lookup: AgentModelCatalogLookup) =>
probe?.resolve?.(home, cwd, lookup).catch(() => null)
probe?.resolve?.(home, cwd, lookup, operation).catch(() => null)
const scopeKey = (resolved: AgentModelCatalogResolvedProbe | null | undefined) =>
agentModelCatalogScopeFingerprint(base, cwd, resolved?.identity)
let resolved = await resolve(savedOnly() ? 'saved' : 'snapshot')
if (!current()) {
return null
}
// Rerun shells only for reads due to list; saved-only reads must remain passive.
if (!savedOnly() && (!resolved || deps.store.probeDue(scopeKey(resolved)))) {
resolved = await resolve('recover')
}
if (probe?.resolve && !resolved) {
if (!current() || (probe?.resolve && !resolved)) {
return null
}
return {
@@ -27,32 +27,59 @@ export type AgentModelCatalogServiceDeps = AgentModelCatalogWorkspaceDeps & {
hasChatRecords?: (agent: string) => boolean
}
export type AgentModelCatalogSource = (() => boolean) & {
bind?: (record: AgentSessionRecord) => void
release?: () => void
}
export type AgentModelCatalogService = {
snapshot: (params: AgentModelCatalogReadParams) => AgentSessionModelCatalogResult
demand: (params: AgentModelCatalogReadParams) => Promise<void>
/** Explicitly resolves saved scope without listing or refreshing the environment. */
prepareSaved: (params: AgentModelCatalogReadParams) => Promise<AgentSessionModelCatalogResult>
captureSource: (
record: Pick<AgentSessionRecord, 'provider' | 'location'>
) => AgentModelCatalogSource
retireContext: (workspaceId?: string) => void
peek: (sessionId: string, verifiedOnly?: boolean) => AgentModelCatalogEntry | null
read: (params: {
agent: string
sessionId?: string
/** null means the requested workspace is not a local directory. */
workspacePath?: string | null
inSessionWorkspace?: true
/** Await missing-catalog discovery or reprobe of expired unavailability. */
waitForListing?: boolean
/** Return saved facts only; do not start, join or recheck discovery. */
savedOnly?: boolean
verifiedOnly?: boolean
legacy?: boolean
/** Display compatibility only, never launch/admission proof. */
legacyOptionsDisplay?: true
}) => Promise<AgentSessionModelCatalogResult>
/** Validate only launch-injected rows against pinned account membership. */
retainedLiveModelIds: (
read: (params: AgentModelCatalogReadParams) => Promise<AgentSessionModelCatalogResult>
/** Saves a session's listing and newly learned menus so the next chat starts warm. */
recordLiveListing: (
sessionId: string,
listing: AgentModelCatalogLiveListing
) => Promise<string[]>
recordLiveListing: (sessionId: string, listing: AgentModelCatalogLiveListing) => void
/** Refresh missing/stale catalogs only for agents already used here. */
listing: AgentModelCatalogLiveListing,
source?: () => boolean
) => 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>
stop: () => void
/** Successful chat startup makes held unavailability due for recheck. */
providerStarted: (record: AgentSessionRecord) => void
/** A chat under this record's account proved its start: a held reason is re-checked sooner. */
providerStarted: (record: AgentSessionRecord, source?: () => boolean) => void
}
export type AgentModelCatalogReadParams = {
agent: string
/** Original explicit operation budget, shared with compatibility saved-scope continuation. */
deadline?: number
/** Internal selector resolution; never part of the wire contract. */
workspace?: {
selector: string
identity: () => unknown
resolve: () => Promise<string | null | { path: string | null; identity: unknown }>
}
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
}
@@ -48,6 +48,14 @@ function selectedHomeFingerprint(path: string): string {
})
}
function discover(
service: ReturnType<typeof createAgentModelCatalogService>,
params: Parameters<typeof service.read>[0]
) {
void service.demand(params)
return service.read(params)
}
const GROK_HOME = (path: string): { variable: 'GROK_HOME'; path: string } => ({
variable: 'GROK_HOME',
path
@@ -65,21 +73,29 @@ describe('agent model catalog service', () => {
resolveAccountHome: async () => GROK_HOME('/homes/selected'),
probes: { grok: probe }
})
expect(await service.read({ agent: 'grok', sessionId: 'session-1' })).toEqual({
expect(await discover(service, { agent: 'grok', sessionId: 'session-1' })).toEqual({
origin: 'unknown',
listingInProgress: true
})
// Coalesce reads under the record's pinned account, regardless of current selection.
await service.read({ agent: 'grok', sessionId: 'session-1' })
expect(probe).toHaveBeenCalledTimes(1)
expect(probe).toHaveBeenCalledWith(GROK_HOME('/homes/a'), { signal: expect.any(AbortSignal) })
// 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: 'grok', sessionId: 'session-1', waitForListing: true })
await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(1))
expect(probe).toHaveBeenCalledWith(
GROK_HOME('/homes/a'),
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
await vi.waitFor(async () => {
const result = await service.read({ agent: 'grok', sessionId: 'session-1' })
const result = await service.read({
agent: 'grok',
sessionId: 'session-1',
waitForListing: true
})
expect(result.origin).toBe('probe')
})
})
it('probes as for no record when this build cannot start the record as it is pinned', async () => {
it('keeps an unsupported pinned record unknown without probing another account', async () => {
const store = new AgentModelCatalogStore()
const probe = vi.fn(async (_home: AgentSessionAccountHome) => listing('gpt-a'))
// A foreign account-home variable cannot authorize a Codex probe.
@@ -94,12 +110,13 @@ describe('agent model catalog service', () => {
probes: { grok: probe }
})
await service.read({ agent: 'grok', sessionId: 'session-1' })
expect(
await service.read({ agent: 'grok', sessionId: 'session-1', waitForListing: true })
).toEqual({ origin: 'unknown' })
expect(drivesRecord).toHaveBeenCalledWith(agentSessionConversationView(pinned))
expect(probe).toHaveBeenCalledExactlyOnceWith(GROK_HOME('/homes/selected'), {
signal: expect.any(AbortSignal)
})
expect(probe).not.toHaveBeenCalled()
service.stop()
})
it('an account switch with no record reads and prewarms the NEW account, never the old entry', async () => {
@@ -115,15 +132,20 @@ describe('agent model catalog service', () => {
resolveAccountHome: async () => GROK_HOME('/homes/new'),
probes: { grok: probe }
})
expect(await service.read({ agent: 'grok' })).toEqual({
// The record-less read follows the CURRENT selection: unknown, never gpt-old.
expect(await discover(service, { agent: 'grok' })).toEqual({
origin: 'unknown',
listingInProgress: true
})
expect(probe).toHaveBeenCalledWith(GROK_HOME('/homes/new'), {
signal: expect.any(AbortSignal)
})
await vi.waitFor(() => expect(probe).toHaveBeenCalledOnce())
expect(probe).toHaveBeenCalledWith(
GROK_HOME('/homes/new'),
expect.objectContaining({
signal: expect.any(AbortSignal)
})
)
await vi.waitFor(async () => {
const result = await service.read({ agent: 'grok' })
const result = await service.read({ agent: 'grok', waitForListing: true })
expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-new')
})
expect(store.get(selectedHomeFingerprint('/homes/new'))!.models[0]!.id).toBe('gpt-new')
@@ -151,7 +173,7 @@ describe('agent model catalog service', () => {
drivesRecord: () => true,
resolveAccountHome: async () => GROK_HOME('/homes/selected')
})
const result = await service.read({ agent: 'grok' })
const result = await service.read({ agent: 'grok', waitForListing: true })
expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-mine')
})
@@ -177,7 +199,11 @@ describe('agent model catalog service', () => {
drivesRecord: () => true,
resolveAccountHome: async () => GROK_HOME('/homes/selected')
})
const result = await service.read({ agent: 'grok', sessionId: 'session-1' })
const result = await service.read({
agent: 'grok',
sessionId: 'session-1',
waitForListing: true
})
expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-session')
})
@@ -194,12 +220,15 @@ describe('agent model catalog service', () => {
resolveAccountHome: async () => GROK_HOME('/homes/a'),
probes: { grok: probe }
})
expect(await service.read({ agent: 'grok', sessionId: 'session-1' })).toEqual({
expect(await discover(service, { agent: 'grok', sessionId: 'session-1' })).toEqual({
origin: 'unknown',
listingInProgress: true
})
await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(1))
expect(await service.read({ agent: 'grok', sessionId: 'session-1' })).toEqual({
// Still a clean unknown — and the failure TTL suppresses a probe storm.
expect(
await service.read({ agent: 'grok', sessionId: 'session-1', waitForListing: true })
).toEqual({
origin: 'unknown'
})
expect(probe).toHaveBeenCalledTimes(1)
@@ -218,7 +247,9 @@ describe('agent model catalog service', () => {
},
probes: { grok: probe }
})
expect(await service.read({ agent: 'grok' })).toEqual({ origin: 'unknown' })
expect(await service.read({ agent: 'grok', waitForListing: true })).toEqual({
origin: 'unknown'
})
expect(probe).not.toHaveBeenCalled()
})
@@ -240,7 +271,7 @@ describe('agent model catalog service', () => {
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
getRecord: () => record('/homes/selected'),
drivesRecord: () => true,
resolveAccountHome: async () => GROK_HOME('/homes/selected'),
probes: { grok: probe }
@@ -252,7 +283,7 @@ describe('agent model catalog service', () => {
const pending = deferredListing()
const probe = vi.fn(() => pending.promise)
const { service } = coldService(probe)
expect(await service.read({ agent: 'grok' })).toEqual({
expect(await discover(service, { agent: 'grok' })).toEqual({
origin: 'unknown',
listingInProgress: true
})
@@ -263,153 +294,27 @@ describe('agent model catalog service', () => {
expect(probe).toHaveBeenCalledTimes(1)
})
it("answers from a chat's listing already running instead of starting a probe", async () => {
const pending = deferredListing()
const probe = vi.fn(() => new Promise<AgentModelCatalogSuccess>(() => {}))
const { store, service } = coldService(probe)
const chat = {
store,
fingerprint: selectedHomeFingerprint('/homes/selected'),
accountHomePath: '/homes/selected'
it('publishes a real live listing beside a held probe and preserves probe failure', async () => {
const pendingProbe = deferredListing()
const { store, service } = coldService(() => pendingProbe.promise)
const fingerprint = selectedHomeFingerprint('/homes/selected')
try {
await discover(service, { agent: 'grok' })
await vi.waitFor(() => expect(store.isListing(fingerprint)).toBe(true))
const terminal = store.pendingListing(fingerprint, true, true)
const waited = service.read({ agent: 'grok', waitForListing: true })
service.recordLiveListing('session-1', { models: listing('gpt-chat').models })
expect(await waited).toMatchObject({ origin: 'live-session', models: [{ id: 'gpt-chat' }] })
expect(store.isListing(fingerprint)).toBe(true)
pendingProbe.reject(new Error('probe timed out'))
await terminal
expect(store.failureDetail(fingerprint)).toBe('probe timed out')
service.recordLiveListing('session-1', { models: listing('gpt-new-chat').models })
expect(store.get(fingerprint)?.models[0]?.id).toBe('gpt-new-chat')
expect(store.hasActiveFailure(fingerprint)).toBe(true)
} finally {
service.stop()
}
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')
expect(probe).not.toHaveBeenCalled()
})
it('uses a chat listing that starts after the picker began waiting on a probe', async () => {
const pendingProbe = deferredListing()
const pendingChat = deferredListing()
const { store, service } = coldService(() => pendingProbe.promise)
expect(await service.read({ agent: 'grok' })).toEqual({
origin: 'unknown',
listingInProgress: 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, 'grok', chat, () => pendingChat.promise)
pendingChat.resolve(listing('gpt-chat'))
await chatListing
const result = await waited
expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-chat')
pendingProbe.reject(new Error('probe timed out'))
})
it('continues waiting when the probe fails before a newly started chat finishes', async () => {
const pendingProbe = deferredListing()
const pendingChat = deferredListing()
const { store, service } = coldService(() => pendingProbe.promise)
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, 'grok', chat, () => pendingChat.promise)
pendingProbe.reject(new Error('probe timed out'))
await vi.waitFor(() => expect(store.hasActiveFailure(fingerprint)).toBe(true))
let completed = false
void waited.then(() => (completed = true))
await Promise.resolve()
expect(completed).toBe(false)
pendingChat.resolve(listing('gpt-chat'))
await chatListing
const result = await waited
expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-chat')
})
it('releases when a second chat succeeds while the first chat is still listing', async () => {
const pendingProbe = deferredListing()
const firstChat = deferredListing()
const secondChat = deferredListing()
const { store, service } = coldService(() => pendingProbe.promise)
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,
'grok',
{ store, fingerprint, accountHomePath: '/homes/selected' },
() => firstChat.promise
)
pendingProbe.reject(new Error('probe timed out'))
await vi.waitFor(() => expect(store.hasActiveFailure(fingerprint)).toBe(true))
const second = store.refresh(
fingerprint,
'grok',
{ store, fingerprint, accountHomePath: '/homes/selected' },
() => secondChat.promise
)
secondChat.resolve(listing('gpt-second'))
await second
const result = await waited
expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-second')
firstChat.reject(new Error('first chat timed out'))
await first
})
it('keeps waiting for a later chat after the first chat also fails', async () => {
const pendingProbe = deferredListing()
const firstChat = deferredListing()
const secondChat = deferredListing()
const { store, service } = coldService(() => pendingProbe.promise)
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,
'grok',
{ store, fingerprint, accountHomePath: '/homes/selected' },
() => firstChat.promise
)
pendingProbe.reject(new Error('probe timed out'))
await vi.waitFor(() => expect(store.hasActiveFailure(fingerprint)).toBe(true))
const second = store.refresh(
fingerprint,
'grok',
{ store, fingerprint, accountHomePath: '/homes/selected' },
() => secondChat.promise
)
firstChat.reject(new Error('first chat timed out'))
await first
let completed = false
void waited.then(() => (completed = true))
await Promise.resolve()
expect(completed).toBe(false)
secondChat.resolve(listing('gpt-second'))
await second
const result = await waited
expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-second')
})
it('waits for a running chat even while a failed probe is inside its TTL', async () => {
const pendingProbe = deferredListing()
const pendingChat = deferredListing()
const { store, service } = coldService(() => pendingProbe.promise)
await service.read({ agent: 'grok' })
const fingerprint = selectedHomeFingerprint('/homes/selected')
const chat = { store, fingerprint, accountHomePath: '/homes/selected' }
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: 'grok', waitForListing: true })
let completed = false
void waited.then(() => (completed = true))
await Promise.resolve()
expect(completed).toBe(false)
pendingChat.resolve(listing('gpt-chat'))
await chatListing
const result = await waited
expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-chat')
})
it('answers a plain unknown when the listing fails', async () => {
@@ -425,11 +330,15 @@ describe('agent model catalog service', () => {
throw new Error('spawn failed')
})
const { store, service } = coldService(probe)
store.recordFailure(selectedHomeFingerprint('/homes/selected'), 'spawn failed')
await store.refresh(selectedHomeFingerprint('/homes/selected'), 'grok', () =>
Promise.reject(new Error('spawn failed'))
)
expect(await service.read({ agent: 'grok', waitForListing: true })).toEqual({
origin: 'unknown'
})
expect(await service.read({ agent: 'grok', waitForListing: true })).toEqual({
origin: 'unknown'
})
expect(await service.read({ agent: 'grok' })).toEqual({ origin: 'unknown' })
expect(probe).not.toHaveBeenCalled()
})
@@ -501,7 +410,11 @@ 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: 'grok', workspacePath: '/repo/wt' })
const result = await service.read({
agent: 'grok',
waitForListing: true,
workspacePath: '/repo/wt'
})
expect(defaults(result)).toEqual([false])
expect(workspaceMayOverrideDefaultModel).toHaveBeenCalledWith({
agent: 'grok',
@@ -512,20 +425,24 @@ 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: 'grok', workspacePath: '/repo/wt' }))).toEqual([
true
])
expect(
defaults(
await service.read({ agent: 'grok', waitForListing: true, 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: 'grok', workspacePath: null }))).toEqual([false])
expect(
defaults(await service.read({ agent: 'grok', waitForListing: true, 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: 'grok' }))).toEqual([true])
expect(defaults(await service.read({ agent: 'grok', waitForListing: true }))).toEqual([true])
expect(workspaceMayOverrideDefaultModel).not.toHaveBeenCalled()
})
})
@@ -581,7 +498,9 @@ describe('agent model catalog service', () => {
resolveAccountHome: async () => managed(PROFILE_B),
probes: { opencode: probe }
})
expect(await service.read({ agent: 'opencode', sessionId: 'session-1' })).toEqual({
expect(
await service.read({ agent: 'opencode', sessionId: 'session-1', waitForListing: true })
).toEqual({
origin: 'unknown'
})
expect(probe).not.toHaveBeenCalled()
@@ -589,8 +508,8 @@ describe('agent model catalog service', () => {
})
})
it.each(['cold', 'warm', 'failure', 'stop'] as const)(
'delivers queued %s settlement after the 30s snapshot to joined readers',
it.each(['cold', 'warm'] as const)(
'ends queued %s discovery within its original deadline and preserves existing observations',
async (mode) => {
vi.useFakeTimers()
const store = new AgentModelCatalogStore()
@@ -631,7 +550,9 @@ it.each(['cold', 'warm', 'failure', 'stop'] as const)(
probes: { codex: run }
})
const read = (path: string, waitForListing = false) =>
service.read({ agent: 'codex', workspacePath: path, waitForListing })
waitForListing
? service.read({ agent: 'codex', workspacePath: path, waitForListing })
: discover(service, { agent: 'codex', workspacePath: path })
const replies: unknown[] = []
let leave = () => {}
try {
@@ -641,23 +562,23 @@ it.each(['cold', 'warm', 'failure', 'stop'] as const)(
await initial
await vi.advanceTimersByTimeAsync(120_001)
}
failing = mode === 'failure'
failing = false
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(replies[0]).not.toHaveProperty('listingInProgress')
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') {
if (mode === 'warm') {
expect(replies.at(-1)).toMatchObject({ models: [{ id: '/5' }] })
} else {
expect(replies.at(-1)).toEqual({ origin: 'unknown' })
}
expect(run).toHaveBeenCalledTimes(mode === 'warm' ? 6 : 5)
second()
@@ -668,3 +589,108 @@ it.each(['cold', 'warm', 'failure', 'stop'] as const)(
}
}
)
it.each(['opencode', 'opencode2', 'omp'] as const)(
'keeps %s session-folder requests separate for explicit and historical readers',
async (agent) => {
const records = new Map(
['w1', 'w2'].map((id) => [
id,
{
...record('/same-account'),
sessionId: id,
provider: agent,
location: { ...record('/same-account').location, workspaceId: id }
}
])
)
const store = new AgentModelCatalogStore()
const probe = vi.fn(
async (_home: AgentSessionAccountHome, options?: { workspacePath?: string }) =>
listing(options?.workspacePath ?? 'account')
)
const service = createAgentModelCatalogService({
store,
getConversation: (id) => {
const held = records.get(id)
return held ? agentSessionConversationView(held) : undefined
},
getRecord: (id) => records.get(id),
drivesRecord: () => true,
resolveAccountHome: async () => GROK_HOME('/same-account'),
recordWorkspacePath: async (row) => `/${row.location.workspaceId}`,
listsInChatFolder: new Set([agent]),
probes: { [agent]: probe }
})
try {
for (const inSessionWorkspace of [true, undefined] as const) {
const params = (sessionId: string) => ({
agent,
sessionId,
...(inSessionWorkspace ? { inSessionWorkspace } : {})
})
await service.demand(params('w1'))
expect(service.snapshot(params('w1'))).toMatchObject({ models: [{ id: '/w1' }] })
if (inSessionWorkspace) {
expect(service.snapshot(params('w2'))).toEqual({ origin: 'unknown' })
} else {
expect(service.snapshot(params('w2'))).toMatchObject({ models: [{ id: '/w2' }] })
}
await service.prepareSaved(params('w2'))
await service.demand(params('w2'))
expect(service.snapshot(params('w2'))).toMatchObject({ models: [{ id: '/w2' }] })
}
} finally {
service.stop()
}
}
)
it('shares account rows while keeping workspace default provenance separate', async () => {
const store = new AgentModelCatalogStore()
const records = new Map(
['w1', 'w2'].map((id) => [
id,
{
...record('/same-account'),
sessionId: id,
provider: 'pi',
location: { ...record('/same-account').location, workspaceId: id }
}
])
)
const first = records.get('w1')!
store.recordSuccess(
agentModelCatalogFingerprintForRecord(first),
'pi',
listing('account-default'),
'discovery'
)
const service = createAgentModelCatalogService({
store,
getConversation: (id) => {
const held = records.get(id)
return held ? agentSessionConversationView(held) : undefined
},
getRecord: (id) => records.get(id),
drivesRecord: () => true,
resolveAccountHome: async () => first.accountHome,
recordWorkspacePath: async (row) => `/${row.location.workspaceId}`,
agentReadsProjectModelConfig: () => true,
workspaceMayOverrideDefaultModel: async ({ workspacePath }) => workspacePath === '/w2'
})
try {
const one = { agent: 'pi', sessionId: 'w1', inSessionWorkspace: true as const }
const two = { ...one, sessionId: 'w2' }
expect(await service.prepareSaved(one)).toMatchObject({
models: [{ id: 'account-default', isDefault: true }]
})
expect(service.snapshot(two)).toEqual({ origin: 'unknown' })
expect(await service.prepareSaved(two)).toMatchObject({
models: [{ id: 'account-default', isDefault: false }]
})
expect(service.snapshot(one)).toMatchObject({ models: [{ isDefault: true }] })
} finally {
service.stop()
}
})
@@ -1,87 +1,160 @@
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 } from '../../../shared/agent-session-record'
import { isLegacyAgentSessionAccountHome } from '../../../shared/agent-session-account-home'
import { agentModelCatalogFingerprintForRecord } from './agent-model-catalog-fingerprint'
import {
agentModelCatalogResultFromEntry,
retainedAgentModelCatalogModels
} from './agent-model-catalog-entry'
import type { AgentModelCatalogEntry } from './agent-model-catalog-store'
import { createAgentModelCatalogLiveListings } from './agent-model-catalog-live-listings'
import { scopedAgentModelCatalogLister } from './agent-model-catalog-lister'
import { prewarmAgentModelCatalog, newChatCatalogKey } from './agent-model-catalog-prewarm'
import type { AgentSessionAccountHome } from '../../../shared/agent-session-record'
import {
catalogFolder,
createAgentModelCatalogLiveListingRecorder,
sessionWorkspacePath,
type AgentModelCatalogRecord,
workspaceKeepsListedDefault
projectAgentModelCatalogDefault
} from './agent-model-catalog-workspace'
import type {
AgentModelCatalogServiceDeps,
AgentModelCatalogService
AgentModelCatalogService,
AgentModelCatalogReadParams
} 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 { onAtRestCommandContextRetired } from '../agent-session-wire/at-rest-command-context'
import {
createAgentModelCatalogSnapshot,
agentModelCatalogRequestKey,
agentModelCatalogReleasedOwner,
agentModelCatalogScopeIdentity,
retireAgentModelCatalogRequests,
agentModelCatalogMemoryBinding,
expireAgentModelCatalogBoundFailure,
agentModelCatalogSessionPeek,
type AgentModelCatalogBinding
} from './agent-model-catalog-snapshot'
import { PrioritySemaphore } from '../../../shared/priority-semaphore'
import {
AGENT_MODEL_CATALOG_PICKER_WAIT_MS,
runAgentModelCatalogOperation,
waitForAgentModelCatalogRead
} from './agent-model-catalog-listing-waiters'
/** Keep picker discovery outside session queues and key it to the launched/selected account.
* Probe unavailability persists until another probe answers, independently of live model listings. */
export function createAgentModelCatalogService(
deps: AgentModelCatalogServiceDeps
): AgentModelCatalogService {
const lifetime = new AbortController()
const admission = new PrioritySemaphore(2)
const sessionKeys = new Map<string, string>()
// Reads await configured-default recording so they reflect the reporting chat's resolution.
const recordingDefaults = new Map<string, Promise<void>>()
const listWith = agentModelCatalogProbeLister(lifetime.signal)
// Serialize folder-scoped writes across asynchronous folder lookups.
const liveWrites = new Map<string, Promise<void>>()
const sessionCatalogKey = async (record: AgentModelCatalogRecord): Promise<string> =>
agentModelCatalogFingerprintForRecord(
record,
await catalogFolder(deps, record.provider, undefined, record)
)
const saveLiveListing = createAgentModelCatalogLiveListingRecorder(deps, recordingDefaults)
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))
type Claim = Pick<AgentModelCatalogBinding, 'identity' | 'params' | 'current'> & {
controller: AbortController
promise: Promise<void>
ready: Promise<void>
}
const bindings = new Map<string, AgentModelCatalogBinding>()
const claims = new Map<string, Claim>()
const requestKey = agentModelCatalogRequestKey.bind(null, deps)
const capture = agentModelCatalogScopeIdentity.bind(null, deps)
const retireContext = (workspaceId?: string, sources = true): void => {
if (sources) {
live.retireContext(workspaceId)
}
retireAgentModelCatalogRequests(deps, bindings, claims, workspaceId)
}
const live = createAgentModelCatalogLiveListings(deps, lifetime.signal)
const unbindRetirement = onAtRestCommandContextRetired((workspaceId) =>
retireContext(workspaceId, workspaceId !== undefined)
)
const memoryBinding = (params: AgentModelCatalogReadParams) =>
agentModelCatalogMemoryBinding(deps, params, bindings, capture)
const snapshot = createAgentModelCatalogSnapshot(
deps,
memoryBinding,
(params, bound) =>
bound
? claims.has(requestKey(params))
: claims.get(requestKey(params))?.identity === capture(params),
() => lifetime.signal.aborted
)
const resolve = (params: AgentModelCatalogReadParams): Promise<void> => {
const deadline = params.deadline ?? Date.now() + AGENT_MODEL_CATALOG_PICKER_WAIT_MS
const remaining = deadline - Date.now()
if (lifetime.signal.aborted || remaining <= 0) {
return Promise.resolve()
}
const bindingKey = requestKey(params)
const key = params.savedOnly ? `${bindingKey}:saved` : bindingKey
let identity = capture(params)
const workspaceIdentity = params.workspace?.identity()
let coldWorkspace = params.workspace !== undefined && workspaceIdentity === null
const retirement = deps.store.retirementRevision(params.agent)
const previous = claims.get(key)
if (previous?.identity === identity) {
return previous.promise
}
previous?.controller.abort()
const controller = new AbortController()
const ready = Promise.withResolvers<void>()
const valid = (): boolean =>
Date.now() < deadline &&
!controller.signal.aborted &&
!lifetime.signal.aborted &&
deps.store.retirementRevision(params.agent) === retirement &&
requestKey(params) === bindingKey &&
(coldWorkspace || capture(params) === identity)
const current = (): boolean => valid() && claims.get(key)?.controller === controller
const run = async (): Promise<void> => {
if (!current()) {
return
}
void sessionCatalogKey(record)
.then((fingerprint) => {
if (deps.getRecord(record.sessionId)?.lease.runtimeFence === record.lease.runtimeFence) {
deps.store.expireFailure(fingerprint)
}
})
.catch(() => {})
},
async read(params) {
const record = params.sessionId ? deps.getConversation(params.sessionId) : undefined
const savedOnly = params.savedOnly || !!(record && !deps.getRecord(record.sessionId))
const scoped =
record && record.provider === params.agent && deps.drivesRecord(record) ? record : undefined
const workspacePath = params.inSessionWorkspace
? record
? await sessionWorkspacePath(deps, record)
: null
: params.workspacePath
if (record && !scoped) {
return
}
let workspacePath: string | null | undefined
if (params.workspace) {
const resolved = await params.workspace.resolve().catch(() => undefined)
if (!current() || resolved === undefined) {
return
}
if (resolved !== null && typeof resolved === 'object') {
if (
resolved.identity === null ||
params.workspace.identity() !== resolved.identity ||
(!coldWorkspace && workspaceIdentity !== resolved.identity)
) {
return
}
identity = capture(params)
const claim = claims.get(key)
if (claim?.controller !== controller) {
return
}
claim.identity = identity
workspacePath = resolved.path
} else {
workspacePath = resolved
}
coldWorkspace = false
} else {
workspacePath =
params.inSessionWorkspace ||
(record &&
params.workspacePath === undefined &&
!params.legacy &&
deps.agentReadsProjectModelConfig?.(params.agent) === true)
? record
? await sessionWorkspacePath(deps, record)
: null
: params.workspacePath
}
if (!current()) {
return
}
const folder = await catalogFolder(deps, params.agent, workspacePath, scoped)
if (!current()) {
return
}
let fingerprint: string
let probeHome: AgentSessionAccountHome | null
if (scoped) {
@@ -93,201 +166,139 @@ export function createAgentModelCatalogService(
: null
} else {
const key = await newChatCatalogKey(deps, params.agent, folder)
if (!key) {
return { origin: 'unknown' }
if (!current() || !key) {
return
}
fingerprint = key.fingerprint
probeHome = key.accountHome
}
const scope = await resolveAgentModelCatalogScope(
deps,
{ ...params, savedOnly },
{ ...params, workspacePath, savedOnly },
scoped,
fingerprint,
probeHome
probeHome,
current,
{ deadline, current, signal: controller.signal }
)
if (!scope) {
return { origin: 'unknown' }
if (!current() || !scope) {
return
}
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)
}
let bindingCurrent = true
const bound: AgentModelCatalogBinding = {
identity,
params,
current: () =>
bindingCurrent &&
!lifetime.signal.aborted &&
deps.store.retirementRevision(params.agent) === retirement,
workspaceId: record?.location.workspaceId,
cancel: () => {
bindingCurrent = false
controller.abort()
},
fingerprint,
base,
exact,
folder,
cwd,
workspacePath,
keepDefault:
workspacePath === undefined ||
(workspacePath === null && independentAgentModelCatalog(params.agent))
}
bindings.set(bindingKey, bound)
while (bindings.size > 256) {
const oldest = bindings.keys().next().value
if (oldest !== undefined) {
bindings.delete(oldest)
}
sessionKeys.set(scoped.sessionId, fingerprint)
}
bound.projecting = projectAgentModelCatalogDefault(
deps,
params.agent,
bound,
probeHome,
() => valid() && bindings.get(bindingKey) === bound
)
if (savedOnly) {
await bound.projecting
return
}
if (!exact && agentModelCatalogReleasedOwner(scoped)) {
return
}
const accountHomePath =
probeHome && isLegacyAgentSessionAccountHome(probeHome) ? probeHome.path : null
await Promise.all(liveWrites.values())
await recordingDefaults.get(fingerprint)
let entry = deps.store.get(fingerprint)
const probe = deps.probes?.[params.agent]
const signal = AbortSignal.any([lifetime.signal, controller.signal])
const list = scopedAgentModelCatalogLister({
legacy: params.legacy,
operation: { deadline, current, signal },
canList: () => !record || !!deps.getRecord(record.sessionId),
admission,
signal: lifetime.signal,
signal,
resolved,
probe,
probeHome,
workspacePath: cwd ?? folder
})
const answer = async (
listed: AgentModelCatalogEntry | null,
extra: { listingInProgress?: true } = {}
): Promise<AgentSessionModelCatalogResult> => {
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(
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 =
result?.listingNamesConfiguredModel === true &&
deps.agentReadsProjectModelConfig?.(params.agent) === false
return {
...(result ?? { origin: 'unknown' }),
...extra,
...(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 }
: {})
}
if (probe && probeHome && deps.store.probeDue(fingerprint)) {
void deps.store.refresh(fingerprint, params.agent, list, current, signal)
}
// Observed process exit suppresses background relaunch; explicit new chats may discover.
if (
savedOnly ||
(scoped && !deps.getRecord(scoped.sessionId)) ||
(!exact &&
scoped?.ownerObservation.claimStatus === 'released' &&
scoped.ownerObservation.deathEvidence)
) {
return answer(entry)
// Demand owns ingestion until settlement, even when its caller only needs the snapshot.
ready.resolve()
const pending = deps.store.pendingListing(fingerprint, true, true)
if (pending) {
await pending
}
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 deps.store.pendingListing(fingerprint, true, params.legacy)
return answer(deps.store.get(fingerprint))
}
const promise = runAgentModelCatalogOperation(run, remaining, controller, () => {
ready.resolve()
if (claims.get(key)?.controller === controller) {
claims.delete(key)
}
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, 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)
}
})
claims.set(key, { identity, controller, promise, ready: ready.promise, params, current })
return promise
}
return {
snapshot,
demand: (params) => (params.savedOnly ? Promise.resolve() : resolve(params)),
async prepareSaved(params) {
if (!memoryBinding(params)) {
await resolve({ ...params, savedOnly: true })
}
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)
}
const listed = await listing
entry = deps.store.get(fingerprint) ?? listed
}
return answer(entry)
return snapshot({ ...params, savedOnly: true })
},
async retainedLiveModelIds(sessionId, listing) {
const record = deps.getConversation(sessionId)
const entry =
record && deps.drivesRecord(record)
? deps.store.get(await sessionCatalogKey(record))
: undefined
return retainedAgentModelCatalogModels(entry, listing).map((model) => model.id)
captureSource: live.captureSource,
retireContext,
peek: agentModelCatalogSessionPeek(deps, memoryBinding),
providerStarted(record, source) {
expireAgentModelCatalogBoundFailure(deps, bindings, record, source)
live.providerStarted(record, source)
},
recordLiveListing(sessionId, listing) {
const record = deps.getRecord(sessionId)
if (!record || !deps.drivesRecord(record) || independentAgentModelCatalog(record.provider)) {
return
async read(params) {
if (!params.savedOnly && params.waitForListing) {
const deadline = params.deadline ?? Date.now() + AGENT_MODEL_CATALOG_PICKER_WAIT_MS
await waitForAgentModelCatalogRead({
params: { ...params, deadline },
store: deps.store,
resolve,
claim: () => claims.get(requestKey(params)),
binding: () => bindings.get(requestKey(params))
})
}
if (!deps.listsInChatFolder?.has(record.provider)) {
saveLiveListing(record, listing, agentModelCatalogFingerprintForRecord(record))
return
}
// Serialize reports so a slow folder lookup cannot overwrite a newer listing.
const writing = (liveWrites.get(sessionId) ?? Promise.resolve())
.then(async () => {
const fingerprint = await sessionCatalogKey(record)
if (deps.getRecord(sessionId)?.lease.runtimeFence === record.lease.runtimeFence) {
saveLiveListing(record, listing, fingerprint)
}
})
.catch((error: unknown) => {
console.warn('[agent-model-catalog] could not save a chat model list:', error)
})
.finally(() => {
if (liveWrites.get(sessionId) === writing) {
liveWrites.delete(sessionId)
}
})
liveWrites.set(sessionId, writing)
return snapshot(params)
},
recordLiveListing: live.recordLiveListing,
async prewarm() {
await prewarmAgentModelCatalog(deps, lifetime.signal, (params) => this.read(params), listWith)
await prewarmAgentModelCatalog(deps, lifetime.signal, (params) => this.read(params))
},
stop() {
lifetime.abort()
sessionKeys.clear()
unbindRetirement()
retireContext()
deps.store.clearPartialAttempts()
}
}
@@ -0,0 +1,280 @@
import { agentModelCatalogFingerprintForRecord } from './agent-model-catalog-fingerprint'
import { agentSessionPinnedLaunchDirectory } from '../../runtime/agent-session-record-launch-directory'
import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session-wire'
import { agentModelCatalogResultFromEntry } from './agent-model-catalog-entry'
import { independentAgentModelCatalog } from './agent-model-catalog-discovery'
import type {
AgentModelCatalogReadParams,
AgentModelCatalogServiceDeps
} from './agent-model-catalog-service-contract'
export type AgentModelCatalogBinding = {
identity: string
params: AgentModelCatalogReadParams
workspaceId?: string
current: () => boolean
cancel: () => void
fingerprint: string
base: string
exact: boolean
folder?: string
cwd?: string
workspacePath?: string | null
keepDefault: boolean
projecting?: Promise<void>
}
export function agentModelCatalogReleasedOwner(
record: AgentSessionConversationView | undefined
): boolean {
return (
record?.ownerObservation.claimStatus === 'released' && !!record.ownerObservation.deathEvidence
)
}
export function agentModelCatalogRequestKey(
deps: AgentModelCatalogServiceDeps,
params: AgentModelCatalogReadParams
): string {
const record = params.sessionId ? deps.getConversation(params.sessionId) : undefined
const inWorkspace =
record &&
!params.workspace &&
params.workspacePath === undefined &&
(params.inSessionWorkspace ||
deps.listsInChatFolder?.has(params.agent) ||
((independentAgentModelCatalog(params.agent) ||
deps.agentReadsProjectModelConfig?.(params.agent) === true) &&
!params.legacy))
return JSON.stringify({
agent: params.agent,
account: record && [
record.provider,
record.accountHome,
record.location.executionHostId,
record.location.wslDistro,
deps.drivesRecord(record)
],
released: agentModelCatalogReleasedOwner(record),
selector: params.workspace?.selector,
workspacePath: params.workspacePath,
inSessionWorkspace:
inWorkspace && record ? [record.location.workspaceId, record.launchDirectory] : undefined,
legacy: params.legacy
})
}
export function agentModelCatalogScopeIdentity(
deps: AgentModelCatalogServiceDeps,
params: AgentModelCatalogReadParams
): string {
return JSON.stringify([
deps.store.retirementRevision(params.agent),
params.workspace?.identity(),
agentModelCatalogRequestKey(deps, params)
])
}
export function agentModelCatalogMemoryBinding(
deps: AgentModelCatalogServiceDeps,
params: AgentModelCatalogReadParams,
bindings: ReadonlyMap<string, AgentModelCatalogBinding>,
capture: (params: AgentModelCatalogReadParams) => string
): AgentModelCatalogBinding | undefined {
const held = bindings.get(agentModelCatalogRequestKey(deps, params))
if (held?.current() && held.identity === capture(params)) {
return held
}
const record = params.sessionId ? deps.getConversation(params.sessionId) : undefined
if (!record || record.provider !== params.agent || !deps.drivesRecord(record)) {
return
}
// Pinned native account and cwd can reuse a scope another caller already proved.
const cwd = params.workspace
? undefined
: params.workspacePath === undefined
? agentSessionPinnedLaunchDirectory(record)
: params.workspacePath
if (cwd && record.location.executionHostId === 'local' && record.location.wslDistro === null) {
for (const bound of bindings.values()) {
if (
bound.params.agent === params.agent &&
!!bound.params.legacy === !!params.legacy &&
(bound.cwd ?? bound.folder ?? bound.workspacePath) === cwd &&
(!bound.params.sessionId ||
deps.getConversation(bound.params.sessionId)?.location.executionHostId === 'local') &&
bound.base === agentModelCatalogFingerprintForRecord(record, bound.folder) &&
bound.current() &&
bound.identity === capture(bound.params)
) {
return bound
}
}
}
if (
deps.listsInChatFolder?.has(params.agent) ||
params.workspace ||
(params.workspacePath !== null &&
(independentAgentModelCatalog(params.agent) ||
(deps.agentReadsProjectModelConfig?.(params.agent) === true && !params.legacy)))
) {
return
}
const fingerprint = agentModelCatalogFingerprintForRecord(record)
return {
identity: capture(params),
params,
current: () => true,
cancel: () => {},
fingerprint,
base: fingerprint,
exact: false,
keepDefault:
(!params.inSessionWorkspace && params.workspacePath === undefined) ||
params.legacyOptionsDisplay === true ||
deps.agentReadsProjectModelConfig?.(params.agent) === false
}
}
export function createAgentModelCatalogSnapshot(
deps: AgentModelCatalogServiceDeps,
binding: (params: AgentModelCatalogReadParams) => AgentModelCatalogBinding | undefined,
resolving: (params: AgentModelCatalogReadParams, bound: boolean) => boolean,
stopped: () => boolean
) {
return (params: AgentModelCatalogReadParams): AgentSessionModelCatalogResult => {
const bound = binding(params)
if (stopped() || !bound) {
return {
origin: 'unknown',
...(!params.savedOnly && resolving(params, false)
? { listingInProgress: true as const }
: {})
}
}
return projectAgentModelCatalogSnapshot(deps, params, bound, resolving(params, true))
}
}
export function projectAgentModelCatalogSnapshot(
deps: AgentModelCatalogServiceDeps,
params: AgentModelCatalogReadParams,
bound: AgentModelCatalogBinding,
resolving: boolean
): AgentSessionModelCatalogResult {
const listed = deps.store.observation(bound.fingerprint)
const independent = independentAgentModelCatalog(params.agent)
if (
params.verifiedOnly &&
(!listed || (independent && (!bound.exact || !deps.store.verified(listed))))
) {
return { origin: 'unknown' }
}
const display =
listed ?? (bound.exact && !params.verifiedOnly ? deps.store.observation(bound.base) : null)
const verified = bound.exact && listed !== null && deps.store.verified(listed)
const result = display
? agentModelCatalogResultFromEntry(
display,
independent &&
!params.legacyOptionsDisplay &&
(!deps.store.verified(display) || (!bound.exact && !params.legacy))
? false
: verified || bound.keepDefault,
deps.listingNamesConfiguredModel?.has(params.agent) === true,
!independent || verified
)
: null
return {
...(result ?? { origin: 'unknown' }),
...(result?.listingNamesConfiguredModel &&
deps.agentReadsProjectModelConfig?.(params.agent) === false
? { defaultHoldsInEveryWorkspace: true as const }
: {}),
...(result && (bound.folder !== undefined || bound.exact)
? { modelsDifferByWorkspace: true as const }
: {}),
...(!params.savedOnly &&
!params.verifiedOnly &&
(deps.store.isListing(bound.fingerprint) ||
(resolving && deps.store.probeDue(bound.fingerprint))) &&
(independent || !listed)
? { listingInProgress: true as const }
: {}),
...(deps.store.failure(bound.fingerprint)?.unavailable
? { unavailable: deps.store.failure(bound.fingerprint)?.unavailable }
: {})
}
}
export function retireAgentModelCatalogRequests(
deps: AgentModelCatalogServiceDeps,
bindings: Map<string, AgentModelCatalogBinding>,
claims: Map<
string,
{
identity: string
params: AgentModelCatalogReadParams
controller: AbortController
current?: () => boolean
}
>,
workspaceId?: string
): void {
for (const [key, claim] of claims) {
if (
!(
claim.current?.() ?? claim.identity === agentModelCatalogScopeIdentity(deps, claim.params)
) ||
(workspaceId !== undefined &&
claim.params.sessionId !== undefined &&
deps.getConversation(claim.params.sessionId)?.location.workspaceId === workspaceId)
) {
claim.controller.abort()
claims.delete(key)
}
}
for (const [key, bound] of bindings) {
if (
!bound.current() ||
bound.identity !== agentModelCatalogScopeIdentity(deps, bound.params) ||
(workspaceId !== undefined && bound.workspaceId === workspaceId)
) {
bound.cancel()
bindings.delete(key)
}
}
}
/** The invocation-qualified binding may differ from a live listing's account key. */
export function expireAgentModelCatalogBoundFailure(
deps: AgentModelCatalogServiceDeps,
bindings: ReadonlyMap<string, AgentModelCatalogBinding>,
record: AgentSessionRecord,
source?: () => boolean
): void {
const bound = bindings.get(
agentModelCatalogRequestKey(deps, {
agent: record.provider,
sessionId: record.sessionId,
inSessionWorkspace: true
})
)
if (bound?.current() && source?.() !== false) {
deps.store.expireFailure(bound.fingerprint)
}
}
export function agentModelCatalogSessionPeek(
deps: AgentModelCatalogServiceDeps,
binding: (params: AgentModelCatalogReadParams) => AgentModelCatalogBinding | undefined
) {
return (sessionId: string, verifiedOnly?: boolean) => {
const record = deps.getConversation(sessionId)
const bound = record && binding({ agent: record.provider, sessionId, inSessionWorkspace: true })
const entry = bound ? deps.store.observation(bound.fingerprint) : null
return entry && (!verifiedOnly || deps.store.verified(entry)) ? entry : null
}
}
@@ -13,11 +13,8 @@ import { createAgentModelCatalogFilePersistence } from './agent-model-catalog-pe
import {
AGENT_MODEL_CATALOG_FAILURE_TTL_MS,
AGENT_MODEL_CATALOG_FRESH_MS,
AGENT_MODEL_CATALOG_MAX_ENTRIES,
AGENT_MODEL_CATALOG_PICKER_WAIT_MS,
AgentModelCatalogStore,
type AgentModelCatalogProbe,
type AgentModelCatalogSessionAccess,
type AgentModelCatalogSuccess
} from './agent-model-catalog-store'
@@ -37,10 +34,6 @@ function success(...ids: string[]): AgentModelCatalogSuccess {
}
}
function liveLister(store: AgentModelCatalogStore): AgentModelCatalogSessionAccess {
return { store, fingerprint: 'fp-1', accountHomePath: '/homes/a' }
}
describe('agent model catalog store', () => {
it('serves an entry at any age and flags staleness at the refresh threshold', () => {
let at = 1_000
@@ -71,11 +64,11 @@ describe('agent model catalog store', () => {
expect(store.get('fp-1')).toBeNull()
})
it('holds a failure under its TTL without touching the last good entry', () => {
it('holds a failure under its TTL without touching the last good entry', async () => {
let at = 1_000
const store = new AgentModelCatalogStore({ now: () => at })
store.recordSuccess('fp-1', 'grok', success('gpt-a'), 'discovery')
store.recordFailure('fp-1', 'timed out')
await store.refresh('fp-1', 'grok', () => Promise.reject(new Error('timed out')))
expect(store.get('fp-1')!.models.map((model) => model.id)).toEqual(['gpt-a'])
expect(store.hasActiveFailure('fp-1')).toBe(true)
expect(store.failureDetail('fp-1')).toBe('timed out')
@@ -90,9 +83,9 @@ describe('agent model catalog store', () => {
const fetch = vi.fn(
() => new Promise<AgentModelCatalogSuccess>((resolve) => (settle = resolve))
)
const session = liveLister(store)
const first = store.refresh('fp-1', 'grok', session, fetch)
const second = store.refresh('fp-1', 'grok', session, fetch)
const first = store.refresh('fp-1', 'grok', fetch)
const second = store.refresh('fp-1', 'grok', fetch)
await Promise.resolve()
expect(fetch).toHaveBeenCalledTimes(1)
settle(success('gpt-a'))
const [entryA, entryB] = await Promise.all([first, second])
@@ -103,16 +96,13 @@ describe('agent model catalog store', () => {
it('never makes a live session wait on another lister that hangs', async () => {
const store = new AgentModelCatalogStore()
let failProbe!: (error: Error) => void
const hungProbe: AgentModelCatalogProbe = () =>
const hungProbe = () =>
new Promise<AgentModelCatalogSuccess>((_resolve, reject) => (failProbe = reject))
const probe = store.refresh('fp-1', 'grok', hungProbe, () =>
hungProbe({ variable: 'GROK_HOME', path: '/homes/a' })
)
const probe = store.refresh('fp-1', 'grok', hungProbe)
expect(store.shouldRefresh('fp-1')).toBe(false)
const live = await store.refresh('fp-1', 'grok', liveLister(store), async () =>
success('gpt-live')
)
await Promise.resolve()
const live = store.recordSuccess('fp-1', 'grok', success('gpt-live'), 'live')
expect(live!.models[0]!.id).toBe('gpt-live')
failProbe(new Error('codex app-server session exceeded 15000ms'))
@@ -121,34 +111,72 @@ describe('agent model catalog store', () => {
expect(store.get('fp-1')!.models[0]!.id).toBe('gpt-live')
})
it('answers a pending read with the first listing that succeeds, or null once all fail', async () => {
it('answers pending reads on success and releases all waiters on failure', async () => {
const store = new AgentModelCatalogStore()
expect(store.pendingListing('fp-1')).toBeNull()
let failFirst!: (error: Error) => void
let settleSecond!: (success: AgentModelCatalogSuccess) => void
void store.refresh(
'fp-1',
'grok',
liveLister(store),
() => new Promise<AgentModelCatalogSuccess>((_resolve, reject) => (failFirst = reject))
)
void store.refresh(
'fp-1',
'grok',
liveLister(store),
() => new Promise<AgentModelCatalogSuccess>((resolve) => (settleSecond = resolve))
)
const answer = Promise.withResolvers<AgentModelCatalogSuccess>()
const flight = store.refresh('fp-1', 'grok', () => answer.promise)
const pending = store.pendingListing('fp-1')
failFirst(new Error('stuck'))
settleSecond(success('gpt-second'))
answer.resolve(success('gpt-second'))
expect((await pending)!.models[0]!.id).toBe('gpt-second')
void store.refresh('fp-2', 'grok', liveLister(store), async () => {
await flight
const failed = store.refresh('fp-2', 'grok', () => {
throw new Error('no provider')
})
expect(await store.pendingListing('fp-2')).toBeNull()
const read = store.pendingListing('fp-2')
const terminal = store.pendingListing('fp-2', true, true)
expect(await Promise.all([failed, read, terminal])).toEqual([null, null, null])
expect(store.isListing('fp-2')).toBe(false)
expect(store.failureDetail('fp-2')).toBe('no provider')
})
it.each(['success', 'failure'] as const)(
'retires ignored-abort owners and fences late %s after replacement',
async (outcome) => {
const store = new AgentModelCatalogStore()
const owner = new AbortController()
const joiner = new AbortController()
const old = Promise.withResolvers<AgentModelCatalogSuccess>()
const next = Promise.withResolvers<AgentModelCatalogSuccess>()
const listing = store.refresh('fp', 'grok', () => old.promise, undefined, owner.signal)
const joined = store.refresh(
'fp',
'grok',
() => Promise.reject(new Error('must join')),
undefined,
joiner.signal
)
expect(joined).toBe(listing)
joiner.abort()
expect(store.isListing('fp')).toBe(true)
const readers = [store.pendingListing('fp'), store.pendingListing('fp', true, true)]
await Promise.resolve()
owner.abort()
expect(await Promise.all([listing, joined, ...readers])).toEqual([null, null, null, null])
const replacement = store.refresh('fp', 'grok', () => next.promise)
const waiting = store.pendingListing('fp', true, true)
let settled = false
void waiting?.then(() => {
settled = true
})
owner.abort()
if (outcome === 'success') {
old.resolve(success('old'))
} else {
old.reject(new Error('old failure'))
}
await Promise.resolve()
await Promise.resolve()
expect(store.isListing('fp')).toBe(true)
expect(settled).toBe(false)
expect(store.get('fp')).toBeNull()
expect(store.failure('fp')).toBeNull()
next.resolve(success('new'))
expect((await replacement)?.models[0]?.id).toBe('new')
expect((await waiting)?.models[0]?.id).toBe('new')
}
)
it('ends a picker wait at its deadline even while a listing remains active', async () => {
vi.useFakeTimers()
try {
@@ -157,7 +185,6 @@ describe('agent model catalog store', () => {
const listing = store.refresh(
'fp-1',
'grok',
liveLister(store),
() => new Promise<AgentModelCatalogSuccess>((resolve) => (settle = resolve))
)
const waited = store.pendingListing('fp-1')
@@ -170,112 +197,27 @@ describe('agent model catalog store', () => {
}
})
it('holds back a probe until every lister settles, then lets the account refresh again', async () => {
let at = 1_000
const store = new AgentModelCatalogStore({ now: () => at })
let settleSlow!: (success: AgentModelCatalogSuccess) => void
const slow = store.refresh(
'fp-1',
'grok',
liveLister(store),
() => new Promise<AgentModelCatalogSuccess>((resolve) => (settleSlow = resolve))
)
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)
settleSlow(success('gpt-slow'))
await slow
at += AGENT_MODEL_CATALOG_FRESH_MS
// Stale in-flight bookkeeping must not suppress later refreshes.
expect(store.shouldRefresh('fp-1')).toBe(true)
})
it('keeps the newer completed listing when an older chat finishes later', async () => {
const store = new AgentModelCatalogStore()
const save = vi.fn()
await store.attachPersistence({ load: async () => [], save, flush: async () => {} })
let settleOlder!: (success: AgentModelCatalogSuccess) => void
const older = store.refresh(
'fp-1',
'grok',
liveLister(store),
() => new Promise<AgentModelCatalogSuccess>((resolve) => (settleOlder = resolve))
)
const newer = await store.refresh('fp-1', 'grok', liveLister(store), async () =>
success('gpt-new')
)
settleOlder(success('gpt-old'))
const olderResult = await older
expect(newer!.models[0]!.id).toBe('gpt-new')
expect(olderResult!.models[0]!.id).toBe('gpt-old')
expect(store.get('fp-1')!.models[0]!.id).toBe('gpt-new')
expect(save).toHaveBeenCalledTimes(1)
})
it('keeps a direct live update ahead of a pending older probe', async () => {
const store = new AgentModelCatalogStore()
let settleProbe!: (success: AgentModelCatalogSuccess) => void
const probe: AgentModelCatalogProbe = () =>
new Promise<AgentModelCatalogSuccess>((resolve) => (settleProbe = resolve))
const pending = store.refresh('fp-1', 'grok', probe, () =>
probe({ variable: 'GROK_HOME', path: '/homes/a' })
)
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')
})
it('still writes a pending probe that resolves after a live save, keeping both', async () => {
const store = new AgentModelCatalogStore()
let settleProbe!: (success: AgentModelCatalogSuccess) => void
const probe: AgentModelCatalogProbe = () =>
new Promise<AgentModelCatalogSuccess>((resolve) => (settleProbe = resolve))
const pending = store.refresh('fp-1', 'grok', probe, () =>
probe({ variable: 'GROK_HOME', path: '/homes/a' })
)
const probe = () => new Promise<AgentModelCatalogSuccess>((resolve) => (settleProbe = resolve))
const pending = store.refresh('fp-1', 'grok', probe)
store.recordSuccess('fp-1', 'grok', success('gpt-live'), 'live')
await Promise.resolve()
settleProbe({ ...success('gpt-probe'), origin: 'probe' })
await pending
const entry = store.get('fp-1')!
expect(entry.discovered!.models[0]!.id).toBe('gpt-probe')
expect(entry.live!.models[0]!.id).toBe('gpt-live')
// Live reports cannot reset discovery freshness or its failure backoff.
store.recordFailure('fp-1', 'timed out')
await store.refresh('fp-1', 'grok', () => Promise.reject(new Error('timed out')))
store.recordSuccess('fp-1', 'grok', success('gpt-live'), 'live')
expect(store.hasActiveFailure('fp-1')).toBe(true)
})
it('keeps an older successful listing when the newer entry was evicted', async () => {
const store = new AgentModelCatalogStore()
let settleOlder!: (success: AgentModelCatalogSuccess) => void
const older = store.refresh(
'fp-1',
'grok',
liveLister(store),
() => new Promise<AgentModelCatalogSuccess>((resolve) => (settleOlder = resolve))
)
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}`, 'grok', success('other'), 'discovery')
}
expect(store.get('fp-1')).toBeNull()
const save = vi.fn()
await store.attachPersistence({ load: async () => [], save, flush: async () => {} })
settleOlder(success('gpt-old'))
expect((await older)!.models[0]!.id).toBe('gpt-old')
expect(store.get('fp-1')!.models[0]!.id).toBe('gpt-old')
expect(save).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ fingerprint: 'fp-1' })])
)
})
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', 'grok', liveLister(store), async () => {
const entry = await store.refresh('fp-1', 'grok', async () => {
throw new Error('no provider')
})
expect(entry).toBeNull()
@@ -422,7 +364,7 @@ describe('agent model catalog store', () => {
const store = new AgentModelCatalogStore()
await store.attachPersistence(createAgentModelCatalogFilePersistence(directory))
store.recordSuccess('fp-1', 'grok', success('gpt-a'), 'discovery')
store.recordFailure('fp-2', 'timed out')
await store.refresh('fp-2', 'grok', () => Promise.reject(new Error('timed out')))
await vi.waitFor(
async () => {
const persisted = await createAgentModelCatalogFilePersistence(directory).load()
@@ -1,8 +1,15 @@
import {
evictAgentModelCatalogEntries,
createAgentModelCatalogEntryFactory,
retireAgentModelCatalogObservations,
agentModelCatalogLaunchAgents,
agentModelCatalogEntryIsStale,
pruneAgentModelCatalogPartialAttempts
} from './agent-model-catalog-entry-lifetime'
import { independentAgentModelCatalog } from './agent-model-catalog-discovery'
export type { AgentModelCatalogProbe } from './agent-model-catalog-discovery'
import type { AgentModelCatalogPersistence } from './agent-model-catalog-persistence'
import {
agentModelCatalogEntryWithSuccess,
agentModelCatalogListingKey,
entryWithConfiguredDefault,
type AgentModelCatalogConfiguredChoice,
@@ -35,29 +42,29 @@ export type { AgentModelCatalogFailure }
// Persist successful catalogs only; failures/unavailability are held separately.
// Saved display data may be stale, while launch proof requires fresh discovery in this process.
export const AGENT_MODEL_CATALOG_FRESH_MS = 10 * 60_000
export { AGENT_MODEL_CATALOG_FRESH_MS } from './agent-model-catalog-entry-lifetime'
export const AGENT_MODEL_CATALOG_MAX_ENTRIES = 256
import {
refreshAgentModelCatalogListing,
type AgentModelCatalogLister,
type InFlightListings
retireAgentModelCatalogListing,
type InFlightListing
} from './agent-model-catalog-lister'
export type { AgentModelCatalogSessionAccess } from './agent-model-catalog-lister'
export class AgentModelCatalogStore {
private readonly entries = new Map<string, AgentModelCatalogEntry>()
private readonly failures: AgentModelCatalogFailures
private readonly refreshes = new Map<string, InFlightListings>()
private readonly refreshes = new Map<string, InFlightListing>()
private readonly listingWaiters = new AgentModelCatalogListingWaiters(
(fingerprint) => this.get(fingerprint),
(fingerprint) => this.refreshes.has(fingerprint)
)
private readonly latestWrittenOrder = new Map<string, number>()
// Changed launch commands/env invalidate catalogs until rediscovery.
private readonly dueEntries = new Set<string>()
private readonly partialAttempts = new Map<string, { agent: string; at: number }>()
private nextListingOrder = 0
private readonly retirementRevisions = new Map<string, number>()
private readonly retiredObservations = new Set<string>()
private persistence: AgentModelCatalogPersistence | null = null
private readonly now: () => number
@@ -94,30 +101,23 @@ export class AgentModelCatalogStore {
return entry
}
/** A known superseded invocation cannot be certified by resolving its old base key again. */
observation(fingerprint: string): AgentModelCatalogEntry | null {
return this.retiredObservations.has(fingerprint) ? null : this.get(fingerprint)
}
/** Whether any account's catalog of `agent` is saved here. */
hasEntryForAgent(agent: string): boolean {
for (const entry of this.entries.values()) {
if (entry.agent === agent) {
return true
}
}
return false
return [...this.entries.values()].some((entry) => entry.agent === agent)
}
/** Live saves must not delay account-level discovery. */
isStale(entry: AgentModelCatalogEntry): boolean {
return (
this.dueEntries.has(entry.fingerprint) ||
!entry.discovered ||
(independentAgentModelCatalog(entry.agent) && entry.discovered.complete !== true) ||
this.now() - entry.discovered.at >=
(independentAgentModelCatalog(entry.agent) ? 2 * 60_000 : AGENT_MODEL_CATALOG_FRESH_MS)
)
return agentModelCatalogEntryIsStale(entry, this.dueEntries.has(entry.fingerprint), this.now())
}
failureDetail(fingerprint: string): string | null {
return this.hasActiveFailure(fingerprint)
? (this.failures.get(fingerprint)?.detail ?? null)
: null
return this.failures.detail(fingerprint)
}
hasActiveFailure(fingerprint: string): boolean {
@@ -128,7 +128,31 @@ export class AgentModelCatalogStore {
return this.failures.get(fingerprint)
}
retirementRevision(agent: string): number {
const revision = this.retirementRevisions.get(agent) ?? 0
this.retirementRevisions.set(agent, revision)
return revision
}
private retireListings(agent: string): void {
this.retirementRevisions.set(agent, this.retirementRevision(agent) + 1)
for (const [fingerprint, flight] of this.refreshes) {
if (flight.agent === agent) {
retireAgentModelCatalogListing(this.refreshes, this.listingWaiters, fingerprint, flight)
}
}
}
expireLaunchScopes(affected: (agent: string) => boolean = () => true): void {
for (const agent of agentModelCatalogLaunchAgents(this.retirementRevisions, this.entries)) {
if (affected(agent)) {
this.expireAgent(agent)
}
}
}
expireFailures(agent: string): void {
this.retireListings(agent)
this.failures.expireAgent(agent)
}
@@ -137,17 +161,15 @@ export class AgentModelCatalogStore {
}
expireAgent(agent: string): void {
for (const entry of this.entries.values()) {
if (entry.agent === agent) {
this.dueEntries.add(entry.fingerprint)
}
}
this.retireListings(agent)
retireAgentModelCatalogObservations(
this.entries,
this.dueEntries,
this.retiredObservations,
this.partialAttempts,
agent
)
this.failures.expireAgent(agent)
for (const [key, attempt] of this.partialAttempts) {
if (attempt.agent === agent) {
this.partialAttempts.delete(key)
}
}
}
recordSuccess(
@@ -156,25 +178,16 @@ export class AgentModelCatalogStore {
success: AgentModelCatalogSuccess,
source: AgentModelCatalogSource
): AgentModelCatalogEntry | null {
const entry = this.writeSuccess(fingerprint, agent, success, source, ++this.nextListingOrder)
const entry = this.writeSuccess(fingerprint, agent, success, source)
this.listingWaiters.notify(fingerprint)
return entry
}
private entryFromSuccess(
fingerprint: string,
agent: string,
success: AgentModelCatalogSuccess,
source: AgentModelCatalogSource
): AgentModelCatalogEntry | null {
return agentModelCatalogEntryWithSuccess(
this.entries.get(fingerprint),
{ agent, fingerprint },
success,
source,
this.now()
)
}
private readonly entryFromSuccess = createAgentModelCatalogEntryFactory(
this.entries,
this.retiredObservations,
() => this.now()
)
/** Learn defaults only for existing catalogs; null clears facts the next chat no longer resolves. */
recordConfiguredDefault(
@@ -187,7 +200,7 @@ export class AgentModelCatalogStore {
return
}
this.entries.set(fingerprint, entry)
this.persistence?.save([...this.entries.values()])
this.persistence?.save(() => [...this.entries.values()])
this.listingWaiters.notify(fingerprint)
}
@@ -195,8 +208,7 @@ export class AgentModelCatalogStore {
fingerprint: string,
agent: string,
success: AgentModelCatalogSuccess,
source: AgentModelCatalogSource,
order: number
source: AgentModelCatalogSource
): AgentModelCatalogEntry | null {
const entry = this.entryFromSuccess(fingerprint, agent, success, source)
if (!entry) {
@@ -205,12 +217,10 @@ export class AgentModelCatalogStore {
}
return null
}
this.retiredObservations.delete(fingerprint)
const previous = this.entries.get(fingerprint)
this.entries.delete(fingerprint)
this.entries.set(fingerprint, entry)
if (source === 'discovery' && this.refreshes.has(fingerprint)) {
this.latestWrittenOrder.set(fingerprint, order)
}
// Live listings cannot clear discovery failures or account-unavailability verdicts.
if (
source === 'discovery' &&
@@ -235,40 +245,38 @@ export class AgentModelCatalogStore {
this.evictOverCap()
// Unchanged per-turn listings need no disk write.
if (!previous || agentModelCatalogListingKey(previous) !== agentModelCatalogListingKey(entry)) {
this.persistence?.save([...this.entries.values()])
this.persistence?.save(() => [...this.entries.values()])
}
return entry
}
recordFailure(fingerprint: string, detail: string, agent?: string): void {
this.failures.chatFailed(fingerprint, detail, agent)
}
/** Independent discovery joins by scope; other agents join by lister. Failure resolves null. */
/** One probe per canonical scope; failures resolve null. */
refresh(
fingerprint: string,
agent: string,
lister: AgentModelCatalogLister,
listModels: () => Promise<AgentModelCatalogSuccess>
listModels: () => Promise<AgentModelCatalogSuccess>,
current?: () => boolean,
signal?: AbortSignal
): Promise<AgentModelCatalogEntry | null> {
this.isListing(fingerprint)
return refreshAgentModelCatalogListing({
fingerprint,
agent,
lister,
listModels,
current,
signal,
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)
writeSuccess: (...args) => this.writeSuccess(...args)
})
}
isListing(fingerprint: string): boolean {
const flight = this.refreshes.get(fingerprint)
if (flight?.current?.() === false) {
retireAgentModelCatalogListing(this.refreshes, this.listingWaiters, fingerprint, flight)
}
return this.refreshes.has(fingerprint)
}
@@ -293,45 +301,34 @@ export class AgentModelCatalogStore {
}
shouldRefresh(fingerprint: string): boolean {
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)) {
if (this.isListing(fingerprint) || this.hasActiveFailure(fingerprint)) {
return false
}
pruneAgentModelCatalogPartialAttempts(this.partialAttempts, this.now())
const entry = this.entries.get(fingerprint)
return !entry || this.isStale(entry)
return !this.partialAttempts.has(fingerprint) && (!entry || this.isStale(entry))
}
/** Only probes can refresh expired unavailability, even beside a fresh live catalog. */
heldReasonDue(fingerprint: string): boolean {
return (
this.failures.get(fingerprint)?.unavailable !== undefined &&
!this.hasActiveFailure(fingerprint)
)
return this.failures.unavailableDue(fingerprint)
}
probeDue(fingerprint: string): boolean {
return (
this.shouldRefresh(fingerprint) ||
(!this.refreshes.has(fingerprint) && this.heldReasonDue(fingerprint))
(!this.isListing(fingerprint) && this.heldReasonDue(fingerprint))
)
}
private evictOverCap(): void {
for (const key of this.entries.keys()) {
if (this.entries.size <= AGENT_MODEL_CATALOG_MAX_ENTRIES) {
return
}
this.entries.delete(key)
this.dueEntries.delete(key)
this.partialAttempts.delete(key)
}
evictAgentModelCatalogEntries(
this.entries,
this.dueEntries,
this.partialAttempts,
AGENT_MODEL_CATALOG_MAX_ENTRIES,
this.retiredObservations
)
}
}
@@ -58,18 +58,24 @@ function rig(initialProbe: AgentModelCatalogProbe = signedOut) {
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
getRecord: () => ({
...agentSessionRecordFixture(),
provider: 'grok',
accountHome: ACCOUNT_HOME
}),
drivesRecord: () => true,
resolveAccountHome: async () => ACCOUNT_HOME,
probes: { grok: probe }
})
const chat = { store, fingerprint: FINGERPRINT, accountHomePath: HOME }
return {
store,
probe,
service,
chat,
probed: () => store.refresh(FINGERPRINT, 'grok', probe, () => probe(ACCOUNT_HOME)),
/** Lets the probe's verdict land. */
probed: async () => {
await service.prepareSaved({ agent: 'grok' })
await store.refresh(FINGERPRINT, 'grok', () => probe(ACCOUNT_HOME))
},
answerWith: (next: AgentModelCatalogProbe) => (answer = next),
pastTtl: () => (now += AGENT_MODEL_CATALOG_FAILURE_TTL_MS)
}
@@ -80,19 +86,31 @@ function unavailableOf(result: AgentSessionModelCatalogResult) {
}
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()
it('survives a real chat listing and a stale receipt; only a probe success clears it', async () => {
const { probed, answerWith, service, pastTtl } = rig()
await probed()
// Signed-out Grok can still return a live model listing.
await store.refresh(FINGERPRINT, 'grok', chat, async () => listing('gpt-live', 'live-session'))
const sessionId = agentSessionRecordFixture().sessionId
service.recordLiveListing(sessionId, { models: listing('gpt-live', 'live-session').models })
expect(await service.read({ agent: 'grok' })).toMatchObject({
origin: 'live-session',
models: [{ id: 'gpt-live' }]
})
expect(unavailableOf(await service.read({ agent: 'grok' }))).toEqual(SIGNED_OUT)
await store.refresh(FINGERPRINT, 'grok', { ...chat }, () =>
Promise.reject(new Error('401 Unauthorized'))
service.recordLiveListing(
sessionId,
{ models: listing('stale', 'live-session').models },
() => false
)
store.recordFailure(FINGERPRINT, 'grok model listing failed')
expect(await service.read({ agent: 'grok' })).toMatchObject({
origin: 'live-session',
models: [{ id: 'gpt-live' }]
})
expect(unavailableOf(await service.read({ agent: 'grok' }))).toEqual(SIGNED_OUT)
answerWith(async () => listing('gpt-probe', 'probe'))
// The later probe must be newer than the live receipt on the fixture clock.
pastTtl()
await probed()
const result = await service.read({ agent: 'grok' })
expect(result).not.toHaveProperty('unavailable')
@@ -118,7 +136,7 @@ describe('a reason the probe found', () => {
it('is expired, not dropped, by a change to that agent’s account settings', async () => {
const store = new AgentModelCatalogStore()
await store.refresh(FINGERPRINT, 'codex', signedOut, signedOut)
await store.refresh(FINGERPRINT, 'codex', signedOut)
expireAgentModelCatalogFailuresForSettings(store, { activeClaudeManagedAccountId: 'other' })
expect(store.hasActiveFailure(FINGERPRINT)).toBe(true)
expireAgentModelCatalogFailuresForSettings(store, { activeCodexManagedAccountId: 'other' })
@@ -163,18 +181,18 @@ describe('a chat that starts under the account', () => {
expect(await service.read({ agent: 'grok', waitForListing: true })).not.toHaveProperty(
'unavailable'
)
expect(probe).toHaveBeenCalledTimes(2)
await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(2))
})
})
describe('an expired failure that carries no reason', () => {
it('is dropped, so failures cannot pile up', () => {
it('is dropped, so failures cannot pile up', async () => {
const { store, pastTtl } = rig()
store.recordFailure(FINGERPRINT, 'listing timed out', 'grok')
await store.refresh(FINGERPRINT, 'grok', () => Promise.reject(new Error('listing timed out')))
pastTtl()
expect(store.hasActiveFailure(FINGERPRINT)).toBe(false)
expect(store.failure(FINGERPRINT)).toBeNull()
store.recordFailure(FINGERPRINT, 'listing timed out', 'codex')
await store.refresh(FINGERPRINT, 'codex', () => Promise.reject(new Error('listing timed out')))
expireAgentModelCatalogFailuresForSettings(store, { activeCodexManagedAccountId: 'other' })
expect(store.failure(FINGERPRINT)).toBeNull()
})
@@ -200,10 +218,10 @@ describe('a catalog read that finds a held reason', () => {
answerWith(() => signIn.promise)
const plain = await service.read({ agent: 'grok' })
expect(plain).toMatchObject({ origin: 'live-session', listingInProgress: true })
expect(plain).toMatchObject({ origin: 'live-session' })
expect(plain.unavailable).toEqual(SIGNED_OUT)
const waited = service.read({ agent: 'grok', waitForListing: true })
expect(probe).toHaveBeenCalledTimes(2)
await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(2))
signIn.resolve(listing('gpt-probe', 'probe'))
const answer = await waited
@@ -239,21 +257,31 @@ describe('a catalog read that finds a held reason', () => {
})
it('never holds a read that does not ask to wait, even on a probe that hangs', async () => {
const { probed, pastTtl, answerWith, service } = rig()
const { probe, probed, pastTtl, answerWith, service } = rig()
await probed()
pastTtl()
answerWith(() => new Promise<AgentModelCatalogSuccess>(() => {}))
const hung = deferred<AgentModelCatalogSuccess>()
answerWith(() => hung.promise)
expect(await service.read({ agent: 'grok' })).toEqual({
origin: 'unknown',
unavailable: SIGNED_OUT
})
expect(probe).toHaveBeenCalledTimes(1)
const demand = service.demand({ agent: 'grok' })
await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(2))
expect(await service.read({ agent: 'grok' })).toEqual({
origin: 'unknown',
listingInProgress: true,
unavailable: SIGNED_OUT
})
hung.resolve(listing('gpt-probe', 'probe'))
await demand
})
})
describe('a probe the host stops', () => {
it('leaves a held reason as it was', async () => {
const { store, service, probed, answerWith, pastTtl } = rig()
const { store, probe, service, probed, answerWith, pastTtl } = rig()
await probed()
pastTtl()
answerWith(
@@ -262,9 +290,14 @@ describe('a probe the host stops', () => {
options?.signal?.addEventListener('abort', () => reject(new Error('session stopped')))
)
)
await service.read({ agent: 'grok' })
const demand = service.demand({ agent: 'grok' })
await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(2))
expect(store.isListing(FINGERPRINT)).toBe(true)
const signal = probe.mock.calls[1]?.[1]?.signal
expect(signal?.aborted).toBe(false)
service.stop()
await new Promise((resolve) => setTimeout(resolve, 0))
await demand
expect(signal?.aborted).toBe(true)
expect(store.failure(FINGERPRINT)?.unavailable).toEqual(SIGNED_OUT)
expect(store.hasActiveFailure(FINGERPRINT)).toBe(false)
})
@@ -1,11 +1,13 @@
import type {
AgentModelCatalogLiveListing,
AgentModelCatalogStore
} from './agent-model-catalog-store'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
AgentSessionAccountHome,
AgentSessionRecord
} from '../../../shared/agent-session-record'
import type { AgentModelCatalogBinding } from './agent-model-catalog-snapshot'
import { independentAgentModelCatalog } from './agent-model-catalog-discovery'
import { isLegacyAgentSessionAccountHome } from '../../../shared/agent-session-account-home'
import { isNativeLocalLocation } from '../../../shared/execution-host'
import type { AgentModelCatalogConfiguredChoice } from './agent-model-catalog-entry'
import type { AgentModelCatalogStore } from './agent-model-catalog-store'
export type AgentModelCatalogRecord = Pick<
AgentSessionRecord,
@@ -79,7 +81,8 @@ export async function recordConfiguredDefault(
},
record: AgentSessionRecord,
fingerprint: string,
choice: AgentModelCatalogConfiguredChoice | null
choice: AgentModelCatalogConfiguredChoice | null,
current: () => boolean
): Promise<void> {
const accountHome = record.accountHome
if (!deps.workspaceMayOverrideDefaultModel) {
@@ -96,39 +99,33 @@ export async function recordConfiguredDefault(
) {
return
}
if (deps.getRecord(record.sessionId)?.lease.runtimeFence !== record.lease.runtimeFence) {
return
if (
current() &&
deps.getRecord(record.sessionId)?.lease.runtimeFence === record.lease.runtimeFence
) {
deps.store.recordConfiguredDefault(fingerprint, choice)
}
deps.store.recordConfiguredDefault(fingerprint, choice)
}
export function createAgentModelCatalogLiveListingRecorder(
deps: Parameters<typeof recordConfiguredDefault>[0],
recordingDefaults: Map<string, Promise<void>>
) {
return (
record: AgentSessionRecord,
listing: AgentModelCatalogLiveListing,
fingerprint: string
): void => {
const { configuredDefault, ...listed } = listing
const saved = deps.store.recordSuccess(
fingerprint,
record.provider,
{ ...listed, origin: 'live-session' },
'live'
)
if (saved && configuredDefault !== undefined) {
// Preserve report order when learning configured defaults.
const recording = (recordingDefaults.get(fingerprint) ?? Promise.resolve())
.then(() => recordConfiguredDefault(deps, record, fingerprint, configuredDefault))
.catch(() => {})
.then(() => {
if (recordingDefaults.get(fingerprint) === recording) {
recordingDefaults.delete(fingerprint)
}
})
recordingDefaults.set(fingerprint, recording)
/** Default provenance belongs to the resolved binding and the operation projecting it. */
export async function projectAgentModelCatalogDefault(
deps: AgentModelCatalogWorkspaceDeps,
agent: string,
bound: AgentModelCatalogBinding,
home: AgentSessionAccountHome | null,
current: () => boolean
): Promise<void> {
const accountHomePath = home && isLegacyAgentSessionAccountHome(home) ? home.path : null
const path =
bound.workspacePath === null && independentAgentModelCatalog(agent)
? undefined
: bound.workspacePath
try {
const keepDefault = await workspaceKeepsListedDefault(deps, agent, path, accountHomePath)
if (current()) {
bound.keepDefault = keepDefault
}
} catch {
/* A failed provenance read leaves the binding's conservative projection. */
}
}
@@ -1,10 +1,16 @@
import type { StructuredAgentSessionAtRestCommands } from './structured-agent-session-at-rest-commands'
const listeners = new Set<(workspaceId?: string) => void>()
let revision = 0
let readiness: Promise<void> | undefined
const launchSettings = new Set([
'claudeManagedAccounts',
'activeClaudeManagedAccountId',
'activeClaudeManagedAccountIdsByRuntime',
'codexManagedAccounts',
'activeCodexManagedAccountId',
'activeCodexManagedAccountIdsByRuntime',
'floatingTerminalCwd',
'agentCmdOverrides',
'agentDefaultArgs',
'agentDefaultEnv',
@@ -23,6 +29,7 @@ export function retireAtRestCommandSettings(keys: Iterable<string>): void {
}
export function retireAtRestCommandContext(workspaceId?: string): void {
revision += 1
listeners.forEach((listener) => listener(workspaceId))
}
@@ -55,3 +62,14 @@ export async function refreshAtRestCommandContext<T>(operation: () => Promise<T>
completion.resolve()
}
}
export const atRestCommandContextRevision = (): number => revision
export function onAtRestCommandContextRetired(
listener: (workspaceId?: string) => void
): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
@@ -166,12 +166,12 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
setOption: StructuredAgentSessionAdapter['setOption'] = (input) =>
this.owner(input.sessionId).setOption(input)
readAppliedInputOptions: StructuredAgentSessionAdapter['readAppliedInputOptions'] = (input) =>
this.liveOwnerOrNull(input.sessionId)?.readAppliedInputOptions?.(input) ?? null
projectOptions: StructuredAgentSessionAdapter['projectOptions'] = (sessionId, options) =>
this.liveOwnerOrNull(sessionId)?.projectOptions?.(sessionId, options)
prepareReadOptions = (input: { sessionId: string; fence: number }) =>
this.liveOwnerOrNull(input.sessionId)?.prepareReadOptions?.(input)
readOptions = (input: { sessionId: string; fence: number }) => {
const reader = this.owner(input.sessionId).readOptions
if (!reader) {
@@ -276,16 +276,19 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop &
setOption(
input: StructuredAgentSessionSetOptionInput
): Promise<void | Readonly<Record<string, string>>>
/** Applied inputs for this exact child; observations never decide message admission. */
readAppliedInputOptions?(input: {
sessionId: string
fence: number
acquisitionGeneration: string | null
next: AgentSessionExecutionOptions | undefined
}): { active: AgentSessionExecutionOptions; next: AgentSessionExecutionOptions } | null
/** Synchronous capability projection; never acquires inventory or changes saved intent. */
projectOptions?(
sessionId: string,
options: Readonly<Record<string, string>>
): StructuredAgentSessionOptionProjection | undefined
/** Fetch outside the lane; apply under the same child’s fence. */
prepareReadOptions?(input: {
sessionId: string
fence: number
}): Promise<() => StructuredAgentSessionLiveOptions> | undefined
/** Memory projection only; optional native observations belong to provider activity. */
readOptions?(input: {
sessionId: string
fence: number
@@ -1,3 +1,11 @@
import type { AttachFlowInput } from './structured-agent-session-attach-flow'
import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation'
import type { StructuredAgentSessionAttachLock } from './structured-agent-session-attach-lock'
// What attaching needs from the host, named explicitly.
//
// Passing the host itself would let this quietly grow new dependencies; an explicit context makes
// each one a deliberate addition and keeps the orchestration testable without constructing a host.
import type {
AgentSessionTurnActivity,
AgentSessionWireRefusal
@@ -44,3 +52,23 @@ export type StructuredAgentSessionAttachContext = {
options?: StructuredAgentSessionConversationOpenOptions
) => Promise<StructuredAgentSessionHostSession | null>
}
export type StructuredAgentSessionAttachOptions = {
hostLaunchDirectory?: string
/** The saved list a new chat's options are admitted against, for the folder its picker read. */
catalogModels?: AttachFlowInput['models']
recordPhase?: AgentSessionCreatePhaseRecorder
onAcquisitionFailed?: AttachFlowInput['onAcquisitionFailed']
/** The queued message a start is for; see `StructuredAgentSessionProviderChild.startedFor`. */
startedFor?: string
startupOptions?: Readonly<Record<string, string>>
/** A close, an admitted Stop or quit aborted this attach: its refusal is that abort's. */
onAborted?: () => void
/** The startup limit aborted this attach's acquire: it failed, nobody stopped it. */
onStartupExpired?: () => void
/** An agent start's: its steps run outside the session's serialize and commit under it. */
lock?: StructuredAgentSessionAttachLock
startupAttemptId?: string
/** The attach made its child the session's. */
onPublished?: () => void
}
@@ -1,9 +1,7 @@
import type { StructuredRewindRecoveryEvidence } from './structured-rewind-recovery'
import {
publishAttachedChild,
readBoundChildRewind,
type AttachCandidate,
type BoundAttach
type AttachAttempt
} from './structured-agent-session-attach-publish'
import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication'
@@ -15,7 +13,7 @@ import type {
AgentSessionMutationResult
} from '../../../shared/agent-session-wire'
import type { AgentSessionAttachParams } from './structured-agent-session-attach'
import { performAttach, type AttachFlowInput } from './structured-agent-session-attach-flow'
import { performAttach } from './structured-agent-session-attach-flow'
import { endStructuredAgentSessionReleasedChild } from './structured-agent-session-attach-failure'
import { stampFailedCreateOwnerVerdict } from './structured-agent-session-failed-create-refusal'
import {
@@ -25,13 +23,14 @@ import {
import { refuseAgentSessionMutation } from './structured-agent-session-mutation-admission'
import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement'
import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation'
import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context'
import type {
StructuredAgentSessionAttachContext,
StructuredAgentSessionAttachOptions
} from './structured-agent-session-attach-context'
export type { StructuredAgentSessionAttachOptions } from './structured-agent-session-attach-context'
import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child'
import { structuredAgentSessionAttachPermission } from './structured-agent-session-launch-permission'
import {
isStructuredAgentSessionStartupExpired,
type StructuredAgentSessionStartupAttempt
} from './structured-agent-session-startup-attempt-contract'
import { isStructuredAgentSessionStartupExpired } from './structured-agent-session-startup-attempt-contract'
import {
addAgentSessionCreatePhaseAttributes,
withAgentSessionCreatePhase,
@@ -43,24 +42,8 @@ import {
type StructuredAgentSessionAttachLock
} from './structured-agent-session-attach-lock'
export type StructuredAgentSessionAttachOptions = {
hostLaunchDirectory?: string
catalogModels?: AttachFlowInput['models']
recordPhase?: AgentSessionCreatePhaseRecorder
onAcquisitionFailed?: AttachFlowInput['onAcquisitionFailed']
startedFor?: string
startupOptions?: Readonly<Record<string, string>>
/** Preserve cancellation reason when host Stop/close/quit aborts attach. */
onAborted?: () => void
/** Startup expiry is failure, not user cancellation. */
onStartupExpired?: () => void
/** Background starts wait outside serialization and commit under it. */
lock?: StructuredAgentSessionAttachLock
startupAttemptId?: string
onPublished?: () => void
}
/** Share attach steps; background starts use a lock, client attaches hold serialization throughout. */
/** The attach an agent start runs under its own lock (`structured-agent-session-start-flight`);
* `attachStructuredAgentSession` runs the same steps inside one `serialize`, for a client. */
export function attachStructuredAgentSessionForStart(
context: StructuredAgentSessionAttachContext,
callerKey: string,
@@ -194,13 +177,17 @@ async function runAttachUnderAbort(
}
// Attempt sinks become session-owned only on successful attach; failures close their buffered writes.
const attemptSink = context.runtimeState.mintEventSink(sessionId)
const attempt: {
candidate: AttachCandidate | null
committed: boolean
startup: StructuredAgentSessionStartupAttempt | null
bound: BoundAttach | null
rewind: StructuredRewindRecoveryEvidence
} = { candidate: null, committed: false, startup: null, bound: null, rewind: null }
const attempt: AttachAttempt = {
candidate: null,
committed: false,
startup: null,
bound: null,
rewind: null,
catalogSource: context.deps.modelCatalog?.captureSource?.({
provider: params.agent,
location: params.location
})
}
const { startupAttempts } = context.runtimeState
try {
const attached = await performAttach({
@@ -236,6 +223,10 @@ async function runAttachUnderAbort(
lock,
...(options.startupAttemptId ? { startupAttemptId: options.startupAttemptId } : {}),
onStartupAttempt: (startup) => {
const record = context.deps.store.getRecord(sessionId)
if (record) {
attempt.catalogSource?.bind?.(record)
}
attempt.startup = startup
return startupAttempts.track(sessionId, startup)
},
@@ -309,6 +300,7 @@ async function runAttachUnderAbort(
return stampFailedCreateOwnerVerdict(context.deps.store, callerKey, params.envelope, attached)
} finally {
if (!attempt.committed) {
attempt.catalogSource?.release?.()
attemptSink.close()
if (attempt.startup) {
startupAttempts.abandon(sessionId, attempt.startup.attemptId)

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