mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)({
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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'] = () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+8
-5
@@ -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, {
|
||||
|
||||
+110
@@ -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()
|
||||
})
|
||||
|
||||
+37
-9
@@ -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 })
|
||||
})
|
||||
}
|
||||
|
||||
+296
-5
@@ -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
|
||||
}
|
||||
|
||||
+26
-34
@@ -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
Reference in New Issue
Block a user