diff --git a/config/scripts/build-mobile-web-app-bundle.test.mjs b/config/scripts/build-mobile-web-app-bundle.test.mjs index 70c9eaa2296..0442c2e386b 100644 --- a/config/scripts/build-mobile-web-app-bundle.test.mjs +++ b/config/scripts/build-mobile-web-app-bundle.test.mjs @@ -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/ ) }) }) diff --git a/config/scripts/verify-mobile-web-app-bundle.mjs b/config/scripts/verify-mobile-web-app-bundle.mjs index d1c79161267..a0fb2c0f616 100644 --- a/config/scripts/verify-mobile-web-app-bundle.mjs +++ b/config/scripts/verify-mobile-web-app-bundle.mjs @@ -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) diff --git a/mobile/src/session/use-mobile-native-chat-model-discovery.test.ts b/mobile/src/session/use-mobile-native-chat-model-discovery.test.ts index c41c84815d5..af6ac67346f 100644 --- a/mobile/src/session/use-mobile-native-chat-model-discovery.test.ts +++ b/mobile/src/session/use-mobile-native-chat-model-discovery.test.ts @@ -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 () => { diff --git a/mobile/src/session/use-mobile-structured-agent-options.test.ts b/mobile/src/session/use-mobile-structured-agent-options.test.ts index f5adad3675d..e1e3542ef7b 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.test.ts @@ -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() + 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() +}) diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 0844c032932..fa4943dec91 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -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) => snapshot ) const lockSnapshot = (snapshot: ReturnType) => 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 diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 1e7786cae45..d9d2e9a0305 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -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 } diff --git a/src/main/acp/acp-dialects/opencode-dialect.ts b/src/main/acp/acp-dialects/opencode-dialect.ts index bc5d30df091..6e1a53a27aa 100644 --- a/src/main/acp/acp-dialects/opencode-dialect.ts +++ b/src/main/acp/acp-dialects/opencode-dialect.ts @@ -77,6 +77,7 @@ function normalizeToolUpdate(update: ToolCallUpdate): ToolCallUpdate { } export const OPENCODE_ACP_DIALECT: AcpDialect = { + effortChangesModel: true, normalizeToolUpdate, promptFailure } diff --git a/src/main/acp/acp-message-execution-options.ts b/src/main/acp/acp-message-execution-options.ts index 07e06db30be..9db2388621f 100644 --- a/src/main/acp/acp-message-execution-options.ts +++ b/src/main/acp/acp-message-execution-options.ts @@ -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 { diff --git a/src/main/acp/acp-model-catalog-probe.ts b/src/main/acp/acp-model-catalog-probe.ts index 4113f4c0e73..2cc5e3d09c0 100644 --- a/src/main/acp/acp-model-catalog-probe.ts +++ b/src/main/acp/acp-model-catalog-probe.ts @@ -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 => { + 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 { 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`) diff --git a/src/main/acp/acp-session-option-write.ts b/src/main/acp/acp-session-option-write.ts index 0498210ba70..2737c053311 100644 --- a/src/main/acp/acp-session-option-write.ts +++ b/src/main/acp/acp-session-option-write.ts @@ -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' diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index b0d1c42bcf9..347b21805f3 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -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 } }) } }) diff --git a/src/main/acp/acp-structured-host-configured-default.test.ts b/src/main/acp/acp-structured-host-configured-default.test.ts index ff7b6c99e9c..8fe962e0200 100644 --- a/src/main/acp/acp-structured-host-configured-default.test.ts +++ b/src/main/acp/acp-structured-host-configured-default.test.ts @@ -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' }) }) }) diff --git a/src/main/acp/acp-structured-host-live-listing.test.ts b/src/main/acp/acp-structured-host-live-listing.test.ts index 371d2c57c88..bfe356f3e06 100644 --- a/src/main/acp/acp-structured-host-live-listing.test.ts +++ b/src/main/acp/acp-structured-host-live-listing.test.ts @@ -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() + } + } +) diff --git a/src/main/acp/acp-structured-host-option-hang.test.ts b/src/main/acp/acp-structured-host-option-hang.test.ts index 7adc92fe784..088f146ae67 100644 --- a/src/main/acp/acp-structured-host-option-hang.test.ts +++ b/src/main/acp/acp-structured-host-option-hang.test.ts @@ -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) => diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index bdc0679077f..8cdaeeb91bf 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -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 export type AcpLaunchInvocationDeps = { + operation?: StructuredAgentResolutionOperation /** Reread per acquisition. */ resolveEnvironment: () => Promise> resolveLaunchEnv?: (agent: string) => Record @@ -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; envToDelete: string[] }> { + assertStructuredAgentResolutionCurrent(deps.operation) const base = await deps.resolveEnvironment() + assertStructuredAgentResolutionCurrent(deps.operation) const env: Record = spec.account.environment(accountHome, { ...base, ...deps.resolveLaunchEnv?.(spec.agent) diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts index 15bb91d572f..ed4c7603165 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -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() - /** Installed Orca policy and successful writes, distinct from native agent reports. */ + readonly appliedInput = new Map() readonly applied = new Map() - private initialChoice: Readonly> = {} - currentProof = new Set() 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> { - 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 = { ...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 } diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 422e1e2fa36..f21e4b17e49 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -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) => { + 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' } ]) }) diff --git a/src/main/antigravity/antigravity-chat-sign-in.test.ts b/src/main/antigravity/antigravity-chat-sign-in.test.ts index 9db1d9e203b..26e7c7d8cef 100644 --- a/src/main/antigravity/antigravity-chat-sign-in.test.ts +++ b/src/main/antigravity/antigravity-chat-sign-in.test.ts @@ -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 + 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>((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>>() + 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) + } + } +}) diff --git a/src/main/antigravity/antigravity-chat-sign-in.ts b/src/main/antigravity/antigravity-chat-sign-in.ts index 742fe039f67..38dcc7c7a8f 100644 --- a/src/main/antigravity/antigravity-chat-sign-in.ts +++ b/src/main/antigravity/antigravity-chat-sign-in.ts @@ -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 + launchSources(signal: AbortSignal): ReturnType /** Rejects when a chat is not proven stopped. */ stopLiveChats(): Promise connect?: ConnectAcpAgent @@ -157,7 +157,11 @@ async function signIn(current: Flow, host: AntigravityChatSignInHost): Promise 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 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) + } + } +} diff --git a/src/main/claude/claude-model-catalog-probe.ts b/src/main/claude/claude-model-catalog-probe.ts index d96e86ba543..8f7abacf2e8 100644 --- a/src/main/claude/claude-model-catalog-probe.ts +++ b/src/main/claude/claude-model-catalog-probe.ts @@ -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[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 => { + 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 >( 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) } ) } diff --git a/src/main/claude/claude-structured-applied-options.test.ts b/src/main/claude/claude-structured-applied-options.test.ts index e5c6466f07e..56aa10d940b 100644 --- a/src/main/claude/claude-structured-applied-options.test.ts +++ b/src/main/claude/claude-structured-applied-options.test.ts @@ -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 () => { diff --git a/src/main/claude/claude-structured-child-env.ts b/src/main/claude/claude-structured-child-env.ts index 7ccdf2f1d54..72c89c9d63f 100644 --- a/src/main/claude/claude-structured-child-env.ts +++ b/src/main/claude/claude-structured-child-env.ts @@ -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): Record { @@ -17,6 +24,7 @@ function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record): 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 @@ -44,15 +52,19 @@ export function resolveClaudeChildEnvSources(deps: ClaudeEnvDeps): Promise ({ ...inheritedEnv, ...overlay }), cwd: deps.resolveWorkingDirectory, signal: deps.signal - } + }, + deps.operation ) } async function readClaudeChildEnvSources(deps: ClaudeEnvDeps): Promise { + 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)({ diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 291c5fd13e2..d1aa870dec6 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -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', diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 7eb6ef0b2bf..0adde18f85f 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -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>, agent = 'claude') { diff --git a/src/main/claude/claude-structured-effort-reporting.test.ts b/src/main/claude/claude-structured-effort-reporting.test.ts index 0e95c57a460..a750925fafa 100644 --- a/src/main/claude/claude-structured-effort-reporting.test.ts +++ b/src/main/claude/claude-structured-effort-reporting.test.ts @@ -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 () => { diff --git a/src/main/claude/claude-structured-execution-recording.test.ts b/src/main/claude/claude-structured-execution-recording.test.ts index 55022b5179a..9744d1e420f 100644 --- a/src/main/claude/claude-structured-execution-recording.test.ts +++ b/src/main/claude/claude-structured-execution-recording.test.ts @@ -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() } diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 5f4ebdc29eb..d90f3a64b0c 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -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[2] ): Promise { 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 { diff --git a/src/main/claude/claude-structured-message-options.test.ts b/src/main/claude/claude-structured-message-options.test.ts index c0210c8d8f5..ff19613ff3c 100644 --- a/src/main/claude/claude-structured-message-options.test.ts +++ b/src/main/claude/claude-structured-message-options.test.ts @@ -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() diff --git a/src/main/claude/claude-structured-message-options.ts b/src/main/claude/claude-structured-message-options.ts index d07deecc4a6..793a63927fa 100644 --- a/src/main/claude/claude-structured-message-options.ts +++ b/src/main/claude/claude-structured-message-options.ts @@ -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 { 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 { - 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[0], diff --git a/src/main/claude/claude-structured-model-confirmation.test.ts b/src/main/claude/claude-structured-model-confirmation.test.ts index f856678ffc5..70ab58a2d8e 100644 --- a/src/main/claude/claude-structured-model-confirmation.test.ts +++ b/src/main/claude/claude-structured-model-confirmation.test.ts @@ -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 }) diff --git a/src/main/claude/claude-structured-option-confirmation.test.ts b/src/main/claude/claude-structured-option-confirmation.test.ts index c786d232337..6948f352822 100644 --- a/src/main/claude/claude-structured-option-confirmation.test.ts +++ b/src/main/claude/claude-structured-option-confirmation.test.ts @@ -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') diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index 1b1737906a8..46b59ea927a 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -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().mockResolvedValue(undefined) + const session = sessionFor(setModel) + const flags = vi + .fn() + .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') + }) +}) diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index 22d6ae65eba..c7d910fc8be 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -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 { + 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) } diff --git a/src/main/claude/claude-structured-result-control-overlap.test.ts b/src/main/claude/claude-structured-result-control-overlap.test.ts new file mode 100644 index 00000000000..2d42f37d60e --- /dev/null +++ b/src/main/claude/claude-structured-result-control-overlap.test.ts @@ -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() + const report = Promise.withResolvers() + 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') + } + } +) diff --git a/src/main/claude/claude-structured-retired-model.ts b/src/main/claude/claude-structured-retired-model.ts index 1113d1cd853..8fe099d4ebb 100644 --- a/src/main/claude/claude-structured-retired-model.ts +++ b/src/main/claude/claude-structured-retired-model.ts @@ -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)) } diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index d55dc403340..58a4b6c66e1 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -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'] : [] diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index d3ed1c1d00f..cd1d12e02e0 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -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 { - 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 { + 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, diff --git a/src/main/claude/structured/claude-structured-session-acquisition.ts b/src/main/claude/structured/claude-structured-session-acquisition.ts index 6a9ff2022bf..6bf007d169f 100644 --- a/src/main/claude/structured/claude-structured-session-acquisition.ts +++ b/src/main/claude/structured/claude-structured-session-acquisition.ts @@ -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 { +}: Omit< + Parameters[0], + 'previous' | 'attempt' +>): Promise { 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() }) diff --git a/src/main/claude/structured/claude-structured-session-publication.ts b/src/main/claude/structured/claude-structured-session-publication.ts index 470546315d6..0a5f1657221 100644 --- a/src/main/claude/structured/claude-structured-session-publication.ts +++ b/src/main/claude/structured/claude-structured-session-publication.ts @@ -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, + 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(() => {}) +} diff --git a/src/main/claude/structured/claude-structured-session-startup.test.ts b/src/main/claude/structured/claude-structured-session-startup.test.ts index 13394e8d174..56930bfb58c 100644 --- a/src/main/claude/structured/claude-structured-session-startup.test.ts +++ b/src/main/claude/structured/claude-structured-session-startup.test.ts @@ -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) diff --git a/src/main/claude/structured/claude-structured-session-startup.ts b/src/main/claude/structured/claude-structured-session-startup.ts index 524ddf51d26..362ad4214d5 100644 --- a/src/main/claude/structured/claude-structured-session-startup.ts +++ b/src/main/claude/structured/claude-structured-session-startup.ts @@ -236,8 +236,10 @@ async function settleClaudeStartupSettings( ): Promise { 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 diff --git a/src/main/claude/structured/claude-structured-session-state.ts b/src/main/claude/structured/claude-structured-session-state.ts index 4f306828b30..153f7c161e8 100644 --- a/src/main/claude/structured/claude-structured-session-state.ts +++ b/src/main/claude/structured/claude-structured-session-state.ts @@ -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 } @@ -351,15 +353,6 @@ export async function cancelClaudeAcquisitionAttempt( }) } -export type ClaudeSessionAcquisitionInput = { - input: SessionAdapter.StructuredAgentSessionAcquireInput - deps: ClaudeStructuredSessionAdapterDeps - sessions: Map - acquisitions: ClaudeAcquisitionRegistry - exits: Map - callbacks: ClaudeAcquireCallbacks -} - export type ClaudeAcquireCallbacks = { deliver: (attempt: ClaudeAcquisitionAttempt, sessionId: string, event: () => void) => void emit: ( diff --git a/src/main/codex/codex-model-catalog-probe.ts b/src/main/codex/codex-model-catalog-probe.ts index 05507cdb1a8..cf866382929 100644 --- a/src/main/codex/codex-model-catalog-probe.ts +++ b/src/main/codex/codex-model-catalog-probe.ts @@ -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[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 => { + 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 >( 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) } ) } diff --git a/src/main/codex/codex-model-catalog-retirement.test.ts b/src/main/codex/codex-model-catalog-retirement.test.ts new file mode 100644 index 00000000000..0cd15dadf84 --- /dev/null +++ b/src/main/codex/codex-model-catalog-retirement.test.ts @@ -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() + const release = Promise.withResolvers() + const done = Promise.withResolvers() + 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() + } + } +) diff --git a/src/main/codex/codex-structured-applied-settings.ts b/src/main/codex/codex-structured-applied-settings.ts index 53b0188df3a..8b21ced88d8 100644 --- a/src/main/codex/codex-structured-applied-settings.ts +++ b/src/main/codex/codex-structured-applied-settings.ts @@ -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 diff --git a/src/main/codex/codex-structured-catalog-entry.ts b/src/main/codex/codex-structured-catalog-entry.ts index 7e4c58a9641..cb05bdb4f5f 100644 --- a/src/main/codex/codex-structured-catalog-entry.ts +++ b/src/main/codex/codex-structured-catalog-entry.ts @@ -34,8 +34,8 @@ export function codexAcquireCatalogListing( export function codexSessionCapabilities( session: Pick & - Partial>, - model = session.options.get('model') ?? session.reportedOptions?.model + Partial>, + model = session.appliedTurnOptions?.model ): ReturnType { return structuredAgentSessionCapabilityView( codexAcquireCatalogListing(session.catalogAccess) ?? { origin: 'unknown' }, diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index f5e9915617b..da8b65e0045 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -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 /** 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 { + 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) diff --git a/src/main/codex/codex-structured-message-options.test.ts b/src/main/codex/codex-structured-message-options.test.ts index f7560d68cd4..ac747a37717 100644 --- a/src/main/codex/codex-structured-message-options.test.ts +++ b/src/main/codex/codex-structured-message-options.test.ts @@ -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) => 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) => ({ + 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) => 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) => ({ + 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) => ({ + 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' + }) }) }) diff --git a/src/main/codex/codex-structured-message-options.ts b/src/main/codex/codex-structured-message-options.ts index 9ce4b68c31a..b25b6843fa1 100644 --- a/src/main/codex/codex-structured-message-options.ts +++ b/src/main/codex/codex-structured-message-options.ts @@ -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 = {} - 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(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> +): Readonly> { + const compiled: Record = {} + 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>[0] +): ReturnType> { + 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. */ diff --git a/src/main/codex/codex-structured-model-catalog.ts b/src/main/codex/codex-structured-model-catalog.ts index b63c39b74e5..1579cd03917 100644 --- a/src/main/codex/codex-structured-model-catalog.ts +++ b/src/main/codex/codex-structured-model-catalog.ts @@ -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 { - 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 - current: { model?: string; effort?: string; serviceTier?: string } - reportedServiceTier?: string | null - reportedServiceTierKnown?: boolean - timeoutMs?: number -}): Promise { - const listing = await fetchCodexModelCatalogListing({ - connection: input.connection, - ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }) - }) - return composeCodexSessionOptionCatalog(listing, input) -} diff --git a/src/main/codex/codex-structured-option-session.test-fixture.ts b/src/main/codex/codex-structured-option-session.test-fixture.ts index c13328a85e8..8fcc48c379b 100644 --- a/src/main/codex/codex-structured-option-session.test-fixture.ts +++ b/src/main/codex/codex-structured-option-session.test-fixture.ts @@ -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 diff --git a/src/main/codex/codex-structured-service-tier-shown.test.ts b/src/main/codex/codex-structured-service-tier-shown.test.ts index 7a00cf89868..378bf661784 100644 --- a/src/main/codex/codex-structured-service-tier-shown.test.ts +++ b/src/main/codex/codex-structured-service-tier-shown.test.ts @@ -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, ageMs = 0, - child?: { tiers?: readonly string[]; reported?: string } + child?: { tiers?: readonly string[]; reported?: string }, + observeProjection?: (projected: Readonly> | 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, 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 }) diff --git a/src/main/codex/codex-structured-service-tier.test.ts b/src/main/codex/codex-structured-service-tier.test.ts index 6b26fcb8288..7e4526c444f 100644 --- a/src/main/codex/codex-structured-service-tier.test.ts +++ b/src/main/codex/codex-structured-service-tier.test.ts @@ -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', () => { diff --git a/src/main/codex/codex-structured-service-tier.ts b/src/main/codex/codex-structured-service-tier.ts index 686a73485a9..c6579f79889 100644 --- a/src/main/codex/codex-structured-service-tier.ts +++ b/src/main/codex/codex-structured-service-tier.ts @@ -106,15 +106,21 @@ export function codexOffersServiceTier( ) } -/** Fall back to standard for unoffered tiers without changing saved intent. */ +export const codexEffectiveModel = ( + session: { appliedTurnOptions?: Readonly> }, + options: Readonly> +): 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[0] + session: Parameters[0], + supplied: Readonly> = Object.fromEntries(session.options) ): Record { - 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 && diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index cef2a80aa50..3756aebee98 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -45,6 +45,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { expect(codex.connections[0].calls.map((call) => call.method)).toEqual([ 'initialize', 'thread/start', + 'config/read', 'skills/list' ]) }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 0db36b8d660..a790cf34f65 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -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 = ( + input + ) => readCodexAppliedInputOptions(this.sessions.get(input.sessionId), input) + projectOptions = (sessionId: string, options: Readonly>) => projectCodexSessionOptions(this.sessions.get(sessionId), options) - prepareReadOptions = (input: { sessionId: string; fence: number }) => - prepareLiveCodexSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) - closeSession: NonNullable = (...args) => this.teardown.close(...args) forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(sessionId) diff --git a/src/main/codex/codex-structured-session-options-catalog.test.ts b/src/main/codex/codex-structured-session-options-catalog.test.ts index e04d83e308f..c1bbcb17c26 100644 --- a/src/main/codex/codex-structured-session-options-catalog.test.ts +++ b/src/main/codex/codex-structured-session-options-catalog.test.ts @@ -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']) diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index 0a9bc1bade1..d54202eb732 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -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[0] & + Parameters[1] +) { + return composeCodexSessionOptionCatalog(await fetchCodexModelCatalogListing(input), input) +} +const readCodexStructuredSessionOptions = readCodexStructuredSessionOptionCatalog + async function primePicker(session: CodexSession): Promise { 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' }) }) }) diff --git a/src/main/codex/codex-structured-session-options.ts b/src/main/codex/codex-structured-session-options.ts index 42ed34ec6ab..c9d9a251f5f 100644 --- a/src/main/codex/codex-structured-session-options.ts +++ b/src/main/codex/codex-structured-session-options.ts @@ -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 - current: { model?: string; effort?: string; serviceTier?: string } - reportedServiceTier?: string | null - reportedServiceTierKnown?: boolean - timeoutMs?: number -}): Promise { - return readCodexStructuredSessionOptionCatalog(input) -} - function composeLiveCodexCatalog( session: CodexSession, listing: ReturnType ): CodexSessionOptionCatalog { - const model = session.options.get('model') ?? session.reportedOptions.model - const effort = session.options.get('effort') ?? session.reportedOptions.effort - const serviceTier = session.options.get('serviceTier') + 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 { - 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 { + 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> { - 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> { - 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[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 } diff --git a/src/main/codex/codex-structured-session-start.ts b/src/main/codex/codex-structured-session-start.ts index 48f78ad3dc4..b140fa47175 100644 --- a/src/main/codex/codex-structured-session-start.ts +++ b/src/main/codex/codex-structured-session-start.ts @@ -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()) { diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index b17e19727c1..078073f8ff6 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -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> appliedSettings?: CodexAppliedSettings /** Optional for adapters without a host catalog store. */ diff --git a/src/main/codex/codex-structured-thread-goal.test.ts b/src/main/codex/codex-structured-thread-goal.test.ts index 8f866a6b688..38a5ae00176 100644 --- a/src/main/codex/codex-structured-thread-goal.test.ts +++ b/src/main/codex/codex-structured-thread-goal.test.ts @@ -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'] = () => { diff --git a/src/main/codex/codex-structured-thread-goal.ts b/src/main/codex/codex-structured-thread-goal.ts index 6bac56d1294..43314be13ee 100644 --- a/src/main/codex/codex-structured-thread-goal.ts +++ b/src/main/codex/codex-structured-thread-goal.ts @@ -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 diff --git a/src/main/codex/codex-structured-turn-permission.test.ts b/src/main/codex/codex-structured-turn-permission.test.ts index 566540b393a..50a9812156c 100644 --- a/src/main/codex/codex-structured-turn-permission.test.ts +++ b/src/main/codex/codex-structured-turn-permission.test.ts @@ -38,7 +38,7 @@ it('rereads the host choice after preparation and keeps the exact configured Aut let nextTurn = 0 const configuration = Promise.withResolvers() 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({ diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 04bc0019f2f..544827eff30 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -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 | null | undefined + let requested: Readonly> = {} 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 { - 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 } } diff --git a/src/main/ipc/preflight-agent-detection.test.ts b/src/main/ipc/preflight-agent-detection.test.ts index 74342df4f03..8583a7eb671 100644 --- a/src/main/ipc/preflight-agent-detection.test.ts +++ b/src/main/ipc/preflight-agent-detection.test.ts @@ -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) => + beforeCapture?.() + ) })) vi.mock('../startup/hydrate-shell-path', () => ({ diff --git a/src/main/ipc/preflight-agent-refresh.test.ts b/src/main/ipc/preflight-agent-refresh.test.ts index fd44c133772..8196668d983 100644 --- a/src/main/ipc/preflight-agent-refresh.test.ts +++ b/src/main/ipc/preflight-agent-refresh.test.ts @@ -71,6 +71,7 @@ vi.mock('./command-path-resolver', async (importOriginal) => ({ })) vi.mock('../pty/windows-environment-path', () => ({ + cachedPersistedWindowsPathSegments: () => [], readPersistedWindowsPathSegmentsAsync: async () => [], mergePersistedWindowsPathAsync: mergePersistedWindowsPathAsyncMock, mergePersistedWindowsPath: mergePersistedWindowsPathMock diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-account-expiry.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-account-expiry.ts index 0d6ba8cb66a..3674f20d6e9 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-account-expiry.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-account-expiry.ts @@ -25,7 +25,19 @@ export function expireAgentModelCatalogFailuresForSettings( } } -type AgentLaunchSettings = Partial> +// 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 { const commands = new Map( @@ -35,9 +47,16 @@ function launchKeys(settings: AgentLaunchSettings): Map { Object.entries(settings.agentDefaultEnv ?? {}) ) const keys = new Map() - 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, + store: Pick, initial: AgentLaunchSettings ): (updates: Partial, 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) } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery-freshness.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery-freshness.test.ts index c0e57aaed5c..9d9dea5de63 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery-freshness.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery-freshness.test.ts @@ -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')) }) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts index 9e70eeb8226..9b3b45ece11 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts @@ -1,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) & { - resolveLegacy?: (home: AgentSessionAccountHome) => Promise + resolveLegacy?: ( + home: AgentSessionAccountHome, + operation?: StructuredAgentResolutionOperation + ) => Promise resolve?: ( home: AgentSessionAccountHome, cwd: string, - lookup?: AgentModelCatalogLookup + lookup?: AgentModelCatalogLookup, + operation?: StructuredAgentResolutionOperation ) => Promise } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry-lifetime.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry-lifetime.ts new file mode 100644 index 00000000000..d11f4ad9ec6 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry-lifetime.ts @@ -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, + now: number +): void { + for (const [key, attempt] of attempts) { + if (now - attempt.at >= 2 * 60_000) { + attempts.delete(key) + } + } +} + +export function evictAgentModelCatalogEntries( + entries: Map, + dueEntries: Set, + partialAttempts: Map, + cap: number, + retiredObservations: Set +): 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, + dueEntries: Set, + retiredObservations: Set, + partialAttempts: Map, + 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, + entries: ReadonlyMap +): Set { + return new Set([...revisions.keys(), ...[...entries.values()].map((entry) => entry.agent)]) +} + +export function createAgentModelCatalogEntryFactory( + entries: ReadonlyMap, + retiredObservations: ReadonlySet, + now: () => number +) { + return ( + fingerprint: string, + agent: string, + success: AgentModelCatalogSuccess, + source: AgentModelCatalogSource + ) => + agentModelCatalogEntryWithSuccess( + retiredObservations.has(fingerprint) ? undefined : entries.get(fingerprint), + { agent, fingerprint }, + success, + source, + now() + ) +} diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts index eac0b909ff6..00b40936928 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts @@ -203,13 +203,10 @@ export function agentModelCatalogEntry( } export function retainedAgentModelCatalogModels( - previous: Pick | null | undefined, + _previous: Pick | 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( diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-failures.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-failures.ts index 62bfa6f3bc7..d07477dc75b 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-failures.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-failures.ts @@ -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, { diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-independent-discovery.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-independent-discovery.test.ts index 1c15661cf01..d32a6c286dd 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-independent-discovery.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-independent-discovery.test.ts @@ -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() +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-learned-default.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-learned-default.test.ts index 422f45a32da..50b68306df8 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-learned-default.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-learned-default.test.ts @@ -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() + const releaseLearning = Promise.withResolvers() + 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') } ) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-lister.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-lister.ts index dda5308a835..07f672b1eb7 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-lister.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-lister.ts @@ -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> +export type InFlightListing = { + agent: string + current?: () => boolean + promise: Promise + 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, + 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 - refreshes: Map - entries: ReadonlyMap - latestWrittenOrder: Map + current?: () => boolean + signal?: AbortSignal + refreshes: Map 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 { - 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() + 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 => - probe(home, { signal, workspacePath }).catch((error: unknown) => { - throw signal.aborted ? new AgentModelCatalogListingStoppedError(String(error)) : error - }) -} diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-listing-waiters.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-listing-waiters.ts index 6afc1aa03ae..0af522939e4 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-listing-waiters.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-listing-waiters.ts @@ -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 + claim: () => { ready: Promise; promise: Promise } | undefined + binding: () => AgentModelCatalogBinding | undefined +}): Promise { + const { params, store } = input + const wait = async (): Promise => { + 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, + remaining: number, + controller: AbortController, + finished: () => void +): Promise { + let settled = false + const operation = Promise.resolve() + .then(run) + .catch(() => {}) + .finally(() => { + settled = true + }) + return withTimeout(operation, remaining, undefined).finally(() => { + if (!settled) { + controller.abort() + } + finished() + }) +} diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-live-listings.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-live-listings.ts new file mode 100644 index 00000000000..b1382f3d575 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-live-listings.ts @@ -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() + 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 + ): 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>() + // A running chat's saves for agents kept per folder, which wait on a folder lookup. + const liveWrites = new Map>() + /** The catalog a session's own listings belong to: its pinned account and host, and its folder + * for an agent kept per folder. */ + const sessionCatalogKey = async (record: AgentSessionRecord): Promise => + agentModelCatalogFingerprintForRecord( + record, + await catalogFolder(deps, record.provider, undefined, record) + ) + const saveLiveListing = ( + record: AgentSessionRecord, + listing: AgentModelCatalogLiveListing, + fingerprint: string, + current: () => boolean + ): Promise => { + 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) + } + } +} diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.test.ts new file mode 100644 index 00000000000..12744810878 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.test.ts @@ -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() + return { ...actual, writeFile: vi.fn(actual.writeFile) } +}) +const actual = await vi.importActual('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() + const entered = Promise.withResolvers() + 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() + 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') +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts index f1c1a4cda1e..ed44be393f6 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts @@ -19,7 +19,7 @@ const SAVE_COALESCE_MS = 500 export type AgentModelCatalogPersistence = { load: () => Promise /** 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 } @@ -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 | 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?.() diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts index 224d185b645..6897bd32e1f 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts @@ -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() 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() + const defaults = deferred() + 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() + 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')) +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.ts index 7db61cb0816..bf9f7c18ee1 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.ts @@ -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( export async function prewarmAgentModelCatalog( deps: AgentModelCatalogServiceDeps, signal: AbortSignal, - read: AgentModelCatalogService['read'], - listWith: ( - probe: AgentModelCatalogProbe, - home: AgentSessionAccountHome - ) => () => Promise + read: AgentModelCatalogService['read'] ): Promise { 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 }) }) } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts index 30958358d7b..546f368547d 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts @@ -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() + const answer = Promise.withResolvers() + 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() + 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() + const defaults = Promise.withResolvers() + 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() + const stale = Promise.withResolvers() + 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() + 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() + const recordWorkspacePath = vi.fn<() => Promise>(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() + } +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-scaling.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-scaling.test.ts new file mode 100644 index 00000000000..151e3fe0905 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-scaling.test.ts @@ -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(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((_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>[] = [] + 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() + 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() + } +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.test.ts index 698096e04cf..e7b900fe267 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.test.ts @@ -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() + const entered = Promise.withResolvers() + 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-')) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.ts index 271a0af101b..1b2a807f1e3 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.ts @@ -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[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 { diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service-contract.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service-contract.ts index c8784ff57fe..83c86733e8c 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service-contract.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service-contract.ts @@ -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 + /** Explicitly resolves saved scope without listing or refreshing the environment. */ + prepareSaved: (params: AgentModelCatalogReadParams) => Promise + captureSource: ( + record: Pick + ) => 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 - /** Validate only launch-injected rows against pinned account membership. */ - retainedLiveModelIds: ( + read: (params: AgentModelCatalogReadParams) => Promise + /** Saves a session's listing and newly learned menus so the next chat starts warm. */ + recordLiveListing: ( sessionId: string, - listing: AgentModelCatalogLiveListing - ) => Promise - 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 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 + } + 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 } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts index c26d1a4eb2c..4407813c130 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts @@ -48,6 +48,14 @@ function selectedHomeFingerprint(path: string): string { }) } +function discover( + service: ReturnType, + params: Parameters[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(() => {})) - 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() + } +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts index 147b8a00ad3..256b7518307 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts @@ -1,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() - // Reads await configured-default recording so they reflect the reporting chat's resolution. - const recordingDefaults = new Map>() - const listWith = agentModelCatalogProbeLister(lifetime.signal) - // Serialize folder-scoped writes across asynchronous folder lookups. - const liveWrites = new Map>() - const sessionCatalogKey = async (record: AgentModelCatalogRecord): Promise => - 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 & { + controller: AbortController + promise: Promise + ready: Promise + } + const bindings = new Map() + const claims = new Map() + 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 => { + 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() + 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 => { + 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 => { - 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() } } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-snapshot.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-snapshot.ts new file mode 100644 index 00000000000..bb878a9194f --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-snapshot.ts @@ -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 +} + +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, + 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, + 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, + 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 + } +} diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts index f89e118ff13..054afec937e 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts @@ -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((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((_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((_resolve, reject) => (failFirst = reject)) - ) - void store.refresh( - 'fp-1', - 'grok', - liveLister(store), - () => new Promise((resolve) => (settleSecond = resolve)) - ) + const answer = Promise.withResolvers() + 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() + const next = Promise.withResolvers() + 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((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((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((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((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((resolve) => (settleProbe = resolve)) - const pending = store.refresh('fp-1', 'grok', probe, () => - probe({ variable: 'GROK_HOME', path: '/homes/a' }) - ) + const probe = () => new Promise((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((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() diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts index ad3418b3154..3f427b4da81 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts @@ -1,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() private readonly failures: AgentModelCatalogFailures - private readonly refreshes = new Map() + private readonly refreshes = new Map() private readonly listingWaiters = new AgentModelCatalogListingWaiters( (fingerprint) => this.get(fingerprint), (fingerprint) => this.refreshes.has(fingerprint) ) - private readonly latestWrittenOrder = new Map() // Changed launch commands/env invalidate catalogs until rediscovery. private readonly dueEntries = new Set() private readonly partialAttempts = new Map() - private nextListingOrder = 0 + private readonly retirementRevisions = new Map() + private readonly retiredObservations = new Set() 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 + listModels: () => Promise, + current?: () => boolean, + signal?: AbortSignal ): Promise { + 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 + ) } } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts index 58ca707befc..ceb4e208c6a 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts @@ -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(() => {})) + const hung = deferred() + 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) }) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-workspace.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-workspace.ts index ab71dbcb5cb..fe7d535d8a6 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-workspace.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-workspace.ts @@ -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 { 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[0], - recordingDefaults: Map> -) { - 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 { + 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. */ } } diff --git a/src/main/native-chat/agent-session-wire/at-rest-command-context.ts b/src/main/native-chat/agent-session-wire/at-rest-command-context.ts index 074fb123a3f..8fb1efa22e5 100644 --- a/src/main/native-chat/agent-session-wire/at-rest-command-context.ts +++ b/src/main/native-chat/agent-session-wire/at-rest-command-context.ts @@ -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 | undefined const launchSettings = new Set([ + 'claudeManagedAccounts', 'activeClaudeManagedAccountId', 'activeClaudeManagedAccountIdsByRuntime', + 'codexManagedAccounts', + 'activeCodexManagedAccountId', + 'activeCodexManagedAccountIdsByRuntime', + 'floatingTerminalCwd', 'agentCmdOverrides', 'agentDefaultArgs', 'agentDefaultEnv', @@ -23,6 +29,7 @@ export function retireAtRestCommandSettings(keys: Iterable): void { } export function retireAtRestCommandContext(workspaceId?: string): void { + revision += 1 listeners.forEach((listener) => listener(workspaceId)) } @@ -55,3 +62,14 @@ export async function refreshAtRestCommandContext(operation: () => Promise completion.resolve() } } + +export const atRestCommandContextRevision = (): number => revision + +export function onAtRestCommandContextRetired( + listener: (workspaceId?: string) => void +): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index d1b8f8065d5..0135738d839 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -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) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index aba3d6e12d0..a1a5f0df216 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -276,16 +276,19 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & setOption( input: StructuredAgentSessionSetOptionInput ): Promise>> + /** 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> ): 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 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts index 2c8ee130ea6..600179c1418 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts @@ -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 } + +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> + /** 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 +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 1d4899d3f37..a4ab8a0cbbb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -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> - /** 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) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-publish.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-publish.ts index 80669924c26..5a02466af14 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-publish.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-publish.ts @@ -1,3 +1,7 @@ +import type { AgentModelCatalogSource } from '../agent-model-catalog/agent-model-catalog-service-contract' +// The attach's last commit: the child it spawned or kept becomes the session's, its sink the one +// the session's events go through, and its open begins. + import type { AttachedJournal } from './structured-agent-session-attach' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AttachedOwner } from './structured-agent-session-attach-flow' @@ -28,7 +32,17 @@ export type BoundAttach = { eventSink: DeferredStructuredAgentSessionEventSink } -/** Read uncertain-rewind evidence outside commits so sends do not wait behind provider reads. */ +export type AttachAttempt = { + candidate: AttachCandidate | null + committed: boolean + catalogSource?: AgentModelCatalogSource + startup: StructuredAgentSessionStartupAttempt | null + bound: BoundAttach | null + rewind: StructuredRewindRecoveryEvidence +} + +/** The provider's word on a rewind in doubt for the child this attach bound, read outside the + * commits so no send waits behind it. */ export function readBoundChildRewind( context: StructuredAgentSessionAttachContext, sessionId: string, @@ -49,12 +63,7 @@ export async function publishAttachedChild( attached: AttachedJournal, input: { bound: BoundAttach | null - attempt: { - candidate: AttachCandidate | null - committed: boolean - startup: StructuredAgentSessionStartupAttempt | null - rewind: StructuredRewindRecoveryEvidence - } + attempt: AttachAttempt attemptSink: DeferredStructuredAgentSessionEventSink fenceBefore: number | null startedFor: string | undefined @@ -77,6 +86,7 @@ export async function publishAttachedChild( generation: acquisitionGeneration, fence, phase: 'starting' as const, + catalogSource: attempt.catalogSource, heldSends: () => context.deps.adapter.heldSends?.(sessionId), exitObservation: eventSink.exitObservation, ...(startedFor === undefined ? {} : { startedFor }), @@ -126,7 +136,13 @@ export async function publishAttachedChild( await context.endExitedChild(sessionId, indexed, structuredAgentSessionEndedEventExit(exited)) } else if (attempt.startup) { if (candidate.child.phase === 'ready') { - noteStructuredAgentSessionProviderStarted(context.deps, sessionId) + noteStructuredAgentSessionProviderStarted( + context.deps, + sessionId, + () => + context.sessions.get(sessionId)?.child === candidate.child && + candidate.child.catalogSource?.() !== false + ) } else { context.openProviderChild?.(sessionId, candidate.child) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-applied-inputs.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-applied-inputs.test.ts new file mode 100644 index 00000000000..a9b7044fae4 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-applied-inputs.test.ts @@ -0,0 +1,207 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { + adapterFor, + answerWithOpenedTurn, + fakeCodex, + THREAD_ID +} from '../../codex/codex-structured-session-adapter-fixture' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' + +it('steers inherited applied inputs and sends an explicit effort or tier change on the next turn', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-codex-applied-inputs-')) + const opened = () => ({ + thread: { id: THREAD_ID }, + model: 'model-a', + reasoningEffort: 'high' + }) + const codex = fakeCodex({ + 'thread/start': opened, + 'thread/resume': opened, + 'config/read': () => ({ config: { service_tier: 'priority' } }) + }) + const openTurn = (turnId: string) => (params: Record | undefined) => { + const answer = answerWithOpenedTurn(codex, turnId)(params) + codex.connections[0].handlers.onNotification?.('item/completed', { + threadId: THREAD_ID, + turnId, + item: { + type: 'userMessage', + id: `user-${turnId}`, + clientId: params?.clientUserMessageId, + content: [] + } + }) + return answer + } + codex.routes['turn/start'] = openTurn('turn-low') + codex.routes['turn/steer'] = (params) => ({ turnId: params?.expectedTurnId }) + const adapter = adapterFor(codex, {}, [], { + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + onEvent: (event) => { + if (event.type === 'options-reported' || (event.type === 'ended' && 'cause' in event)) { + void host.handleAdapterEvent(event) + } + } + }) + const store = await openTestAgentSessionRecordStore(root) + const host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), + logger: createStructuredAgentSessionLogger(), + store, + adapter, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-1', + now: () => HOST_TEST_NOW + }) + const caller = { callerKey: 'client-1' } + const calls = (method: string) => + codex.connections[0].calls.filter((call) => call.method === method) + const eventually = (assertion: () => void | Promise) => + vi.waitFor(assertion, { timeout: 10_000 }) + async function send(options: Record) { + // Supply the already-captured boundary: saved-intent merging is owned upstream. + await store.replaceSessionOptions({ + sessionId: SESSION, + fence: 1, + options: {}, + now: HOST_TEST_NOW + }) + const body = hostTestMessage('continue') + const clientOperationId = hostTestOperationId() + expect( + await host.send(caller, { + envelope: { + sessionId: SESSION, + clientOperationId, + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body, options } + }) + }, + body, + options + }) + ).toMatchObject({ ok: true }) + return clientOperationId + } + async function finish(turnId: string) { + codex.connections[0].handlers.onNotification?.('turn/completed', { + threadId: THREAD_ID, + turn: { id: turnId, status: 'completed' } + }) + await host.flushStreamedEvents(SESSION) + } + async function expectHeld(id: string, starts: number, steers: number) { + const { loop } = host.collaboratorsForTests().conversationDelivery + await eventually(() => expect(loop.isRunning(SESSION)).toBe(false)) + const submission = (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) + expect(submission).toMatchObject({ dispatchState: 'pending' }) + expect(submission?.handedOverAt).toBeUndefined() + expect(calls('turn/start')).toHaveLength(starts) + expect(calls('turn/steer')).toHaveLength(steers) + } + try { + resetHostTestOperationIds() + expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + await send({ model: 'model-a', effort: 'low' }) + await eventually(() => expect(calls('turn/start')).toHaveLength(1)) + expect(calls('turn/start')[0].params).toMatchObject({ + model: 'model-a', + effort: 'low' + }) + expect(calls('turn/start')[0].params).not.toHaveProperty('serviceTier') + expect(await adapter.readOptions({ sessionId: SESSION, fence: 1 })).toMatchObject({ + current: { effort: 'low' } + }) + await finish('turn-low') + + codex.routes['turn/start'] = openTurn('turn-sparse') + await send({ model: 'model-a' }) + await eventually(() => expect(calls('turn/start')).toHaveLength(2)) + expect(calls('turn/start')[1].params).not.toHaveProperty('effort') + expect(calls('turn/start')[1].params).not.toHaveProperty('serviceTier') + expect(await adapter.readOptions({ sessionId: SESSION, fence: 1 })).toMatchObject({ + current: { effort: 'low' } + }) + await send({ model: 'model-a' }) + await eventually(() => expect(calls('turn/steer')).toHaveLength(1)) + expect(calls('turn/steer')[0].params).not.toHaveProperty('effort') + + const high = await send({ model: 'model-a', effort: 'high' }) + await expectHeld(high, 2, 1) + codex.routes['turn/start'] = openTurn('turn-high') + await finish('turn-sparse') + await eventually(() => expect(calls('turn/start')).toHaveLength(3)) + expect(calls('turn/start')[2].params).toMatchObject({ model: 'model-a', effort: 'high' }) + expect(calls('turn/start')[2].params).not.toHaveProperty('serviceTier') + + await send({ model: 'model-a', serviceTier: 'priority' }) + await eventually(() => expect(calls('turn/steer')).toHaveLength(2)) + const tier = await send({ model: 'model-a', serviceTier: 'flex' }) + await expectHeld(tier, 3, 2) + codex.routes['turn/start'] = openTurn('turn-flex') + await finish('turn-high') + await eventually(() => expect(calls('turn/start')).toHaveLength(4)) + expect(calls('turn/start')[3].params).toMatchObject({ + model: 'model-a', + serviceTier: 'flex' + }) + expect(calls('turn/start')[3].params).not.toHaveProperty('effort') + await send({ model: 'model-a' }) + await eventually(() => expect(calls('turn/steer')).toHaveLength(3)) + expect(calls('turn/start')).toHaveLength(4) + const personality = await send({ personality: 'friendly' }) + await expectHeld(personality, 4, 3) + codex.routes['turn/start'] = openTurn('turn-personality') + await finish('turn-flex') + await eventually(() => expect(calls('turn/start')).toHaveLength(5)) + expect(calls('turn/start')[4].params).toMatchObject({ personality: 'friendly' }) + await send({}) + await eventually(() => expect(calls('turn/steer')).toHaveLength(4)) + expect('model' in (calls('turn/steer')[3].params ?? {})).toBe(false) + expect('effort' in (calls('turn/steer')[3].params ?? {})).toBe(false) + expect('personality' in (calls('turn/steer')[3].params ?? {})).toBe(false) + expect('serviceTier' in (calls('turn/steer')[3].params ?? {})).toBe(false) + expect( + adapter.readAppliedInputOptions({ + sessionId: SESSION, + fence: 2, + acquisitionGeneration: 'generation-1', + next: {} + }) + ).toBeNull() + expect( + adapter.readAppliedInputOptions({ + sessionId: SESSION, + fence: 1, + acquisitionGeneration: 'superseded', + next: {} + }) + ).toBeNull() + } finally { + await host.flushAllStreamedEvents() + await adapter.closeAll() + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-admission.spec.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-admission.spec.ts index a674108c974..8bdc355bc79 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-admission.spec.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-admission.spec.ts @@ -48,6 +48,7 @@ it.each(CODEX_INPUT_FINALIZATION_ADMISSION_CASES)( } }) await host.flushStreamedEvents(SESSION) + await fixture.pick('auto') const measured = population > 0 ? fixture.measureInputWork('close', population) : undefined let later = '' const paused = Promise.withResolvers() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-finalization.spec.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-finalization.spec.ts index 005d34795b5..4949f688cc6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-finalization.spec.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-finalization.spec.ts @@ -18,6 +18,7 @@ import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal import * as lifetime from './structured-agent-session-host-lifetime' import { claimStructuredAgentSessionAgent } from './structured-agent-session-agent-start' import { openStructuredAgentSessionConversationJournal } from './structured-agent-session-conversation-open' +import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' export function registerCodexInputFinalization(current: typeof codexInputFinalizationFixture) { it.each([ @@ -34,7 +35,6 @@ export function registerCodexInputFinalization(current: typeof codexInputFinaliz host, adapter, codex, - config, permissionSend, pick, stop, @@ -51,14 +51,23 @@ export function registerCodexInputFinalization(current: typeof codexInputFinaliz const journal = sessions.get(SESSION)!.journal const persisted = vi.spyOn(journal, 'resolveDispatch') if (failure === 'lost-steer') { - await began(await permissionSend('running')) + await began(await permissionSend('running', { personality: 'friendly' })) } - const paused = Promise.withResolvers() - codex.routes['config/read'] = () => paused.promise - const id = await permissionSend('preserve me', { personality: 'friendly' }) - await vi.waitFor(() => - expect(codex.connections[0]?.calls.some((r) => r.method === 'config/read')).toBe(true) + const paused = Promise.withResolvers() + const preparing = Promise.withResolvers() + const dispatch = adapter.dispatch.bind(adapter) + vi.spyOn(adapter, 'dispatch').mockImplementation((input) => + dispatch({ + ...input, + beforeDispatch: async () => { + await input.beforeDispatch?.() + preparing.resolve() + await waitForPromiseWithSignal(paused.promise, input.signal) + } + }) ) + const id = await permissionSend('preserve me', { personality: 'friendly' }) + await preparing.promise await pick('auto') if (failure === 'refusal') { codex.routes['thread/start'] = codex.routes['thread/resume'] = () => { @@ -78,7 +87,7 @@ export function registerCodexInputFinalization(current: typeof codexInputFinaliz if (failure === 'post-write-stop') { codex.routes['turn/start'] = () => lost.promise } - paused.resolve(config) + paused.resolve() if (failure === 'post-write-stop') { await vi.waitFor(() => expect(starts()).toHaveLength(1)) const session = host.collaboratorsForTests().sessions.get(SESSION)! diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts index c2843976bab..4c9e8242c52 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts @@ -413,35 +413,37 @@ it.each([ const stoppedIds = Array.from({ length: population }, (_, index) => index === 0 ? 'stopped-words' : `stopped-words-${index}` ) - for (const [index, stopped] of stoppedIds.entries()) { - await journal.appendSubmission({ - clientMessageId: stopped, - payloadFingerprint: 'stopped', - body: hostTestMessage(index === 0 ? 'words that were not delivered' : stopped), - fence: 1, - source: { kind: 'user' }, - origin: 'client', - handoverRecorded: true - }) - if (index > 0) { + await host.collaboratorsForTests().serialize(SESSION, async () => { + for (const [index, stopped] of stoppedIds.entries()) { + await journal.appendSubmission({ + clientMessageId: stopped, + payloadFingerprint: 'stopped', + body: hostTestMessage(index === 0 ? 'words that were not delivered' : stopped), + fence: 1, + source: { kind: 'user' }, + origin: 'client', + handoverRecorded: true + }) + if (index > 0) { + await journal.resolveDispatch({ + clientMessageId: stopped, + state: 'pending', + fence: 1, + turnScope: { kind: 'thread' }, + carries: [stoppedIds[index - 1]!] + }) + } await journal.resolveDispatch({ clientMessageId: stopped, - state: 'pending', + state: 'rejected', fence: 1, - turnScope: { kind: 'thread' }, - carries: [stoppedIds[index - 1]!] + ...agentSessionFailureWords( + agentSessionFailureFact('cancelled', { neverReachedAgent: true }), + { surface: 'rejection' } + ) }) } - await journal.resolveDispatch({ - clientMessageId: stopped, - state: 'rejected', - fence: 1, - ...agentSessionFailureWords( - agentSessionFailureFact('cancelled', { neverReachedAgent: true }), - { surface: 'rejection' } - ) - }) - } + }) const stopped = stoppedIds[0]! const refusal = Promise.withResolvers() codex.routes['turn/steer'] = () => refusal.promise @@ -459,7 +461,11 @@ it.each([ void waiting.catch(() => {}) } await vi.waitFor(() => - expect(codex.connections[0]?.calls.some((r) => r.method === 'turn/steer')).toBe(true) + expect( + codex.connections[0]?.calls.some( + (r) => r.method === 'turn/steer' && r.params?.clientUserMessageId === id + ) + ).toBe(true) ) if (mode !== 'auto-before') { await pick(mode === 'empty' ? 'ask' : mode) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index a5ea88e83db..cda94b5d20f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -5,12 +5,11 @@ import type { StructuredAgentSessionDeliveryLoopDeps } from './structured-agent- import { beginStructuredAgentSessionInputHandover } from './structured-agent-session-input-handover' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import type { StructuredAgentSessionStartClaim } from './structured-agent-session-agent-start' import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' import { oldestQueuedSubmission, - recordStructuredAgentSessionStartFailure, + recordStructuredAgentSessionDeliveryFailure, startThatFailedWhileQueued } from './structured-agent-session-start-failure-row' import type { StructuredAgentSessionPendingHandover } from './structured-agent-session-handover' @@ -264,7 +263,13 @@ export class StructuredAgentSessionDeliveryLoop { } } if ( - waitsForCodexTurnOptions(this.deps.record(sessionId), session.journal, next, awaitedChild) + waitsForCodexTurnOptions( + this.deps.record(sessionId), + session.journal, + next, + awaitedChild, + this.deps.adapter + ) ) { return this.stop(sessionId) } @@ -286,15 +291,10 @@ export class StructuredAgentSessionDeliveryLoop { ): Promise<'continue'> { const session = this.deps.sessions.get(sessionId) if (session) { - await recordStructuredAgentSessionStartFailure( + await recordStructuredAgentSessionDeliveryFailure( { journal: session.journal, fence: this.deps.conversationFence(sessionId) }, - { - startKey: failure.startKey, - ...structuredAgentSessionStartFailure( - failure.cause, - this.deps.failureTextContext(sessionId) - ) - }, + failure, + this.deps.failureTextContext(sessionId), clientMessageId ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-goal-steering.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-goal-steering.test.ts index f8136c685a6..13917c38c1b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-goal-steering.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-goal-steering.test.ts @@ -2,6 +2,13 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import type { CodexAppServerConnection } from '../../codex/app-server/codex-app-server-connection-types' +import { + readCodexAppliedInputOptions, + resolvedCodexMessageOptions +} from '../../codex/codex-structured-message-options' +import { optionSession } from '../../codex/codex-structured-option-session.test-fixture' +import { dispatchCodexTurn } from '../../codex/codex-structured-turn-start' import { createQueuedMessageTestRig, eventually, @@ -15,13 +22,43 @@ import { } from './structured-agent-session-host-test-data' let rig: QueuedMessageTestRig +let native: ReturnType +const nativeRequest = vi.fn() + +async function createGoalRig(reports = true): Promise { + nativeRequest + .mockReset() + .mockImplementation(async (method, params) => + method === 'turn/steer' ? { turnId: params?.expectedTurnId } : {} + ) + native = optionSession(nativeRequest) + native.threadId = THREAD + const opened: Record = reports ? { model: 'gpt-ran', effort: 'high' } : {} + native.reportedOptions = opened + native.appliedTurnOptions = opened + const created = await createQueuedMessageTestRig({ + restartable: true, + ...(reports ? { reportsModel: 'gpt-ran', reportsEffort: 'high' } : {}) + }) + created.host.deps.adapter.readAppliedInputOptions = (input) => + input.sessionId === SESSION ? readCodexAppliedInputOptions(native, input) : null + created.host.deps.adapter.holdsLiveProviderProcess = (sessionId, generation) => + sessionId === SESSION && + generation === native.acquisitionGeneration && + !native.ended && + !native.connection.closed + created.dispatch.mockImplementation(async (input) => + dispatchCodexTurn( + native, + { ...input, turnOptions: await resolvedCodexMessageOptions(native, input.executionOptions) }, + undefined + ) + ) + return created +} beforeEach(async () => { - rig = await createQueuedMessageTestRig({ - restartable: true, - reportsModel: 'gpt-ran', - reportsEffort: 'high' - }) + rig = await createGoalRig() }) afterEach(() => rig.dispose()) @@ -42,6 +79,7 @@ async function startGoalTurn( change }) ).toMatchObject({ ok: true }) + native.activeTurnIds = new Set(['goal-turn']) rig.providerEvents().appendItem( { provider: 'codex', threadId: THREAD, turnId: 'goal-turn', ordinal: 99 }, { @@ -70,6 +108,11 @@ it.each<{ label: string; choices: Readonly> }>([ rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.newestTurn() ).toMatchObject({ turnId: 'goal-turn', state: 'running' }) expect(rig.cancelTurn).not.toHaveBeenCalled() + expect(nativeRequest).toHaveBeenLastCalledWith( + 'turn/steer', + expect.objectContaining({ expectedTurnId: 'goal-turn' }), + expect.anything() + ) }) it.each([ @@ -82,6 +125,7 @@ it.each([ await rig.host.collaboratorsForTests().conversationDelivery.loop.wake(SESSION) expect(rig.dispatch).toHaveBeenCalledOnce() expect((await rig.submission(steering.id))?.handedOverAt).toBeUndefined() + native.activeTurnIds = new Set() rig .providerEvents() .appendItem( @@ -96,7 +140,7 @@ it.each([ it('steers inherited choices without inventing an unreported model', async () => { await rig.dispose() - rig = await createQueuedMessageTestRig({ restartable: true }) + rig = await createGoalRig(false) await startGoalTurn() const steering = rig.send('Keep going with the current choices') expect(await steering.result).toMatchObject({ ok: true }) @@ -107,7 +151,7 @@ it('steers inherited choices without inventing an unreported model', async () => it('does not assume an explicit choice matches when the child has not reported it', async () => { await rig.dispose() - rig = await createQueuedMessageTestRig({ restartable: true }) + rig = await createGoalRig(false) await startGoalTurn() const steering = rig.send('Use a specific model', undefined, { carried: { model: 'gpt-ran' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts index 4fba81a5dc1..bd0d4e8efa6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts @@ -1,6 +1,17 @@ -import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { agentSessionRecordFixture } from '../agent-session-record-test-fixture' +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' +import type { + AgentJournalSnapshot, + AgentJournalItemBody, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { + AgentSessionRecord, + AgentSessionExecutionLocation +} from '../../../shared/agent-session-record' import { projectNativeChatTranscriptMessages } from '../../../shared/native-chat-transcript-projection' import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' import { structuredAgentSessionSendBody } from '../../../shared/structured-agent-session-send-mutation' @@ -80,6 +91,47 @@ export function hostTestDrawnRowIds( ).map(({ id }) => id) } +export function hostTestSendParams(text: string, clientOperationId = hostTestOperationId()) { + const body = hostTestMessage(text) + return { + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId, + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: HOST_TEST_SESSION, + fields: { body } + }) + }, + body + } +} + +export function hostTestCancelParams() { + return { + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: HOST_TEST_SESSION, + fields: { turnId: 'turn-none' } + }) + }, + turnId: 'turn-none' + } +} + +export async function hostTestSubmission( + host: Pick, + id: string +): Promise { + return (await host.journalSnapshot(HOST_TEST_SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) +} export const CODEX_INPUT_FINALIZATION_ADMISSION_CASES = [ ['queue-stop', 0], ['queue-close', 0], @@ -113,3 +165,47 @@ export const CODEX_INPUT_FINALIZATION_OWNER_OUTCOMES = [ 'new-ended-child', 'new-send' ] as const + +export function hostTestRestingOptionsJournal( + items: AgentJournalItemBody[] = [], + wroteBeforeOpen = false +) { + return { + threadGoal: () => null, + visitItems: (visit: (itemId: string, sequence: number, body: AgentJournalItemBody) => void) => + items.forEach((body, index) => visit(`item-${index}`, index + 1, body)), + wroteBeforeOpen: () => wroteBeforeOpen, + submissions: () => [], + contextUsage: () => null, + context: { floor: () => null } + } +} + +export function hostTestRestingOptionsRecord(sessionId = 'session-1'): AgentSessionRecord { + return agentSessionRecordFixture({ + sessionId, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/homes/a' }, + providerHandleChain: [], + location: { executionHostId: 'local', wslDistro: null }, + options: {} + }) +} + +export function hostTestOptionsReadStore(record: () => AgentSessionRecord) { + return { + getRecord: record, + getConversation: () => agentSessionConversationView(record()), + requireWritableRecord: record + } +} + +export const HOST_TEST_RESTING_OPTIONS_CREATED_LINKS = [ + { + origin: 'created', + linkId: 'link-1', + mintedAtFence: 1, + observedAt: 1, + handle: codexProviderHandle('provider-session-1') + } +] satisfies AgentSessionRecord['providerHandleChain'] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 63de9765418..f9bc74bdb88 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -1,3 +1,4 @@ +import type { AgentModelCatalogSource } from '../agent-model-catalog/agent-model-catalog-service-contract' import type { StructuredAgentSessionDeliveryLoop } from './structured-agent-session-delivery-loop' import type { OpenedStructuredAgentSessionConversation, @@ -89,7 +90,8 @@ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProvider readonly startedFor?: string /** Launch-time permission mode, memory-only. */ readonly permissionMode?: AgentChatPermissionChoice - /** Read adapter-held sends at each decision. */ + /** The launch source that may publish observations for this child. */ + readonly catalogSource?: AgentModelCatalogSource readonly heldSends?: () => ReadonlySet | undefined readonly exitObservation?: DeferredStructuredAgentSessionEventSink['exitObservation'] close?: StructuredAgentSessionChildClose diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts index 5c56f16ad21..ae2e1549c7d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts @@ -24,7 +24,7 @@ export async function structuredAgentSessionLaunchPermission( /** A synchronous admission read for providers whose modes need no model-catalog evidence. */ export function structuredAgentSessionImmediatePermission( deps: Pick, - record: AgentSessionRecord + record: Pick ): AgentChatPermissionChoice | null { const support = agentChatPermissionModes(record.provider) return support && deps.agents.definition(record.provider) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts index 7e3f94ba472..f17351cd50c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts @@ -1,5 +1,22 @@ -import { afterEach, beforeEach, expect, it } from 'vitest' -import { providerDiagnostic, withProviderDiagnostic } from '../../../shared/agent-session-failure' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk' +import { z } from 'zod' +import { sessionFor } from '../../claude/claude-structured-dispatch-test-support' +import type { ClaudeSession } from '../../claude/structured/claude-structured-session-state' +import { dispatchClaudeMessage } from '../../claude/claude-structured-message-options' +import { createClaudeUserMessageQueue } from '../../claude/claude-agent-sdk-user-message-queue' +import { ClaudeControlRequestError } from '../../claude/claude-stream-json-connection' +import { AgentJournalSubmissionSchema } from '../../../shared/agent-session-journal-submission-schema' +import { + structuredAgentSessionRecordedRejectionParts, + structuredAgentSessionAttemptFailureParts +} from '../../../shared/structured-agent-session-rejection-words' +import { agentSessionWriteNoticeEnglish } from '../../../shared/agent-session-refusal-notice' +import { + providerDiagnostic, + readWholeAgentSessionFailureFact, + withProviderDiagnostic +} from '../../../shared/agent-session-failure' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { createQueuedMessageTestRig, @@ -14,6 +31,10 @@ import { } from './structured-agent-session-host-test-data' import { AgentSessionOptionRejectedError } from './structured-agent-session-option-error' +const { agentSessionWriteNoticeText } = await vi.importActual<{ + agentSessionWriteNoticeText: typeof agentSessionWriteNoticeEnglish +}>('../../../renderer/src/components/native-chat/agent-session-write-notice-text') + let rig: QueuedMessageTestRig beforeEach(async () => { @@ -141,6 +162,146 @@ it('rejects a refused choice before prompt input and leaves the next pick saved' expect(rig.store.getRecord(SESSION)?.options?.model).toBe('model-b') }) +it('carries Claude effort validation through the SDK queue and saved desktop/phone rejection, then recovers', async () => { + await rig.dispose() + rig = await createQueuedMessageTestRig({ agent: 'claude', restartable: true }) + const queue = createClaudeUserMessageQueue() + const prompts: SDKUserMessage[] = [] + const pump = (async () => { + for await (const message of queue.messages) { + prompts.push(message) + } + })() + const session = sessionFor( + vi.fn((...args: Parameters) => { + const message = z + .strictObject({ + type: z.literal('user'), + uuid: z.templateLiteral([ + z.string(), + '-', + z.string(), + '-', + z.string(), + '-', + z.string(), + '-', + z.string() + ]), + session_id: z.string(), + parent_tool_use_id: z.null(), + message: z.strictObject({ + role: z.literal('user'), + content: z.array(z.strictObject({ type: z.literal('text'), text: z.string() })) + }) + }) + .parse(args[0]) + return queue.push(message, args[1]) + }) + ) + session.providerSessionId = THREAD + const nativeWrite = vi.fn().mockResolvedValue(undefined) + Object.assign(session.connection, { + applyFlagSettings: nativeWrite, + getSettings: () => new Promise(() => {}) + }) + rig.dispatch.mockImplementation((input) => + dispatchClaudeMessage(session, input, undefined, { warn: vi.fn(), error: vi.fn() }) + ) + const pickEffort = async (value: string) => { + const fields = { key: 'effort', value } + expect( + await rig.host.setOption(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.setOption', hostTestOperationId()), + ...fields + }) + ).toMatchObject({ ok: true }) + } + try { + await pickEffort('banana') + const invalid = rig.send('invalid effort') + expect(await invalid.result).toMatchObject({ ok: true }) + await eventually(async () => + expect((await rig.submission(invalid.id))?.dispatchState).toBe('rejected') + ) + const saved = await rig.submission(invalid.id) + if (!saved) { + throw new Error('missing rejected submission') + } + const correction = + 'Claude effort is invalid; choose low, medium, high, xhigh, or max and send again.' + expect(AgentJournalSubmissionSchema.parse(saved)).toMatchObject({ + options: { effort: 'banana' }, + rejection: { kind: 'claudeEffortInvalid' }, + reason: correction + }) + expect(saved.rejection).toEqual({ kind: 'claudeEffortInvalid' }) + expect(nativeWrite).not.toHaveBeenCalled() + expect(prompts).toEqual([]) + expect(session.dispatchWaiters).toEqual([]) + await rig.restartHostProcess() + expect(await rig.submission(invalid.id)).toEqual(saved) + expect( + agentSessionWriteNoticeText( + structuredAgentSessionRecordedRejectionParts(saved, { agentName: 'Claude' }, []) + ) + ).toBe(correction) + expect( + agentSessionWriteNoticeEnglish( + structuredAgentSessionAttemptFailureParts( + { kind: 'rejected', reason: saved.reason }, + { agentName: 'Claude', retryControl: true }, + readWholeAgentSessionFailureFact(saved.rejection) + ) + ) + ).toBe(correction) + + await pickEffort('high') + nativeWrite.mockRejectedValueOnce( + new ClaudeControlRequestError('apply_flag_settings', 'Provider declined this setting') + ) + const provider = rig.send('provider refusal', undefined, { carried: { effort: 'medium' } }) + await provider.result + await eventually(async () => + expect((await rig.submission(provider.id))?.rejection).toEqual({ + kind: 'providerRejected', + detail: { text: 'Provider declined this setting', audience: 'person' } + }) + ) + expect((await rig.submission(provider.id))?.reason).toBe( + "Claude didn't accept this message: Provider declined this setting." + ) + nativeWrite.mockRejectedValueOnce(new Error('private internal implementation detail')) + const internal = rig.send('internal refusal', undefined, { carried: { effort: 'low' } }) + await internal.result + await eventually(async () => + expect(await rig.submission(internal.id)).toMatchObject({ + rejection: { kind: 'providerRejected' }, + reason: "Claude didn't accept this message." + }) + ) + expect((await rig.submission(internal.id))?.rejection).toEqual({ kind: 'providerRejected' }) + expect(prompts).toEqual([]) + const valid = rig.send('valid effort') + expect(await valid.result).toMatchObject({ ok: true }) + await eventually(async () => + expect((await rig.submission(valid.id))?.handedOverAt).toBeDefined() + ) + expect(nativeWrite.mock.calls.map(([flags]) => flags)).toEqual([ + { effortLevel: 'medium' }, + { effortLevel: 'low' }, + { effortLevel: 'high' } + ]) + expect(prompts).toHaveLength(1) + expect(prompts[0]?.message.content).toEqual([{ type: 'text', text: 'valid effort' }]) + expect(session.appliedInputOptions.get('effort')).toBe('high') + expect(rig.store.getRecord(SESSION)?.options?.effort).toBe('high') + } finally { + queue.end() + await pump + } +}) + it('uses each send’s captured choices when two messages arrive during startup', async () => { const releaseStart = rig.holdNextStart() const first = rig.send('first', undefined, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts index 5b8f237cb47..a7fac477509 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts @@ -1,31 +1,19 @@ import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { structuredAgentSessionChildRan } from './structured-agent-session-ran-options' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' -const OPTION_ORDER = ['model', 'effort'] as const +const OPTION_ORDER = ['model', 'effort', 'serviceTier', 'personality'] as const -/** Unset message choices inherit child execution values. */ -function sameOptions( - a: AgentSessionExecutionOptions | undefined, - b: AgentSessionExecutionOptions | undefined, - ran: Readonly> | undefined -): boolean { - if ((a === undefined) !== (b === undefined)) { - return false - } - return OPTION_ORDER.every((key) => (a?.[key] ?? ran?.[key]) === (b?.[key] ?? ran?.[key])) -} - -/** Codex steer has no model/effort parameters; changed choices need a new turn. */ +/** A different Codex control needs a new turn: steer has no choice parameters. */ export function waitsForCodexTurnOptions( - record: Pick | null, - journal: Pick, + record: Pick | null, + journal: Pick, next: AgentJournalSubmission, - child: StructuredAgentSessionProviderChild + child: StructuredAgentSessionProviderChild, + adapter?: Pick ): boolean { if (record?.provider !== 'codex') { return false @@ -38,33 +26,18 @@ export function waitsForCodexTurnOptions( if (turn?.state !== 'running') { return false } - const handed = journal.submissions().filter((submission) => submission.handedOverAt !== undefined) - const opener = handed.find( - (submission) => agentJournalSubmissionKey(submission.clientMessageId) === turn.userItemId - ) - const onChild = handed.filter((submission) => submission.fence === child.fence) - // An unechoed send may own this turn; joining it first would reorder the thread. - if (!opener && onChild.some((submission) => submission.dispatchState === 'pending')) { - return true - } - const active = - opener ?? - // Goal continuations have no opener and run on this child's last turn/start choices. - onChild - // A send rejected after its turn opened still set the thread's choices. - .filter((submission) => submission.dispatchState !== 'rejected' || submission.answeredInTurn) - .reduce( - (newest, submission) => - newest && (newest.handedOverAt ?? 0) > (submission.handedOverAt ?? 0) - ? newest - : submission, - undefined - ) - const ran = structuredAgentSessionChildRan(child) - if (active) { - return !sameOptions(active.options, next.options, ran) + const applied = adapter?.readAppliedInputOptions?.({ + sessionId: record.sessionId, + fence: child.fence, + acquisitionGeneration: child.generation, + next: next.options + }) + if (!applied) { + return ( + next.options === undefined || Object.values(next.options).some((value) => value !== undefined) + ) } return OPTION_ORDER.some( - (key) => next.options?.[key] !== undefined && next.options[key] !== ran?.[key] + (key) => applied.next[key] !== undefined && applied.next[key] !== applied.active[key] ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts index 165e295bed0..39dafa9865c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts @@ -1,3 +1,5 @@ +import { AGENT_SESSION_FAILURE_COPY } from '../../../shared/agent-session-failure-copy' + /** Option validation failed before the provider session was mutated. */ export class AgentSessionOptionRejectedError extends Error { constructor( @@ -15,3 +17,9 @@ export function isAgentSessionOptionRejectedError( ): error is AgentSessionOptionRejectedError { return error instanceof Error && error.name === 'AgentSessionOptionRejectedError' } + +export class ClaudeEffortValidationError extends AgentSessionOptionRejectedError { + constructor() { + super(AGENT_SESSION_FAILURE_COPY.claudeEffortInvalid) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts index ba3b211fd23..8f3a4ab6d96 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-intent.ts @@ -7,12 +7,11 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import type { StructuredAgentRegistry } from './structured-agent-registry' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' import { - savedAgentSessionCatalogModels, + type savedAgentSessionCatalogModels, structuredAgentSessionPickReply } from './structured-agent-session-pick-reply' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { pickAgentSessionOption } from '../../runtime/agent-session-record-options' -import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' function acceptsOptionPick( agents: Pick, @@ -46,7 +45,7 @@ export function structuredAgentSessionOptionOverridesRefusal( ) } -/** Save accepted intent; model changes drop inherited effort/Fast values the new model cannot run. */ +/** Records literal picks for registered keys; the native control owns value acceptance. */ export async function recordStructuredAgentSessionOptionIntent( deps: { store: Pick< @@ -71,7 +70,6 @@ export async function recordStructuredAgentSessionOptionIntent( ) } } - const models = await savedAgentSessionCatalogModels(deps, record.provider, sessionId) let options: Readonly> if (input.key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID) { const permissionMode = input.value @@ -79,20 +77,9 @@ export async function recordStructuredAgentSessionOptionIntent( // Preserve the wire’s permission mode within options. options = { ...record.options, [input.key]: input.value } } else { - const picked = { sessionId, fence, ...input, models, now: ctx.now() } - try { - options = - (await deps.store.reviseSessionOptions(pickAgentSessionOption, picked)).options ?? {} - } catch (error) { - if (isAgentSessionRefusalError(error)) { - return { ok: false, refusal: error.refusal } - } - throw error - } + const picked = { sessionId, fence, ...input, models: undefined, now: ctx.now() } + options = (await deps.store.reviseSessionOptions(pickAgentSessionOption, picked)).options ?? {} } - const displayModels = - models ?? - (await savedAgentSessionCatalogModels(deps, record.provider, sessionId, undefined, false)) ctx.publish() return { ok: true, @@ -100,7 +87,7 @@ export async function recordStructuredAgentSessionOptionIntent( ...input, options: deps.adapter?.projectOptions?.(sessionId, options)?.options ?? - structuredAgentSessionPickReply(options, displayModels) + structuredAgentSessionPickReply(options, undefined) } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-deadline.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-deadline.test.ts new file mode 100644 index 00000000000..33c21e1baeb --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-deadline.test.ts @@ -0,0 +1,88 @@ +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' +import { it, expect, vi } from 'vitest' +import { createClaudeModelCatalogProbe } from '../../claude/claude-model-catalog-probe' +import { createAgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' +import { AgentModelCatalogStore } from '../agent-model-catalog/agent-model-catalog-store' +import { agentSessionRecordFixture } from '../agent-session-record-test-fixture' +import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { readStructuredAgentSessionOptions } from './structured-agent-session-options-read' + +const SESSION = 'session-1' +function restingJournal() { + return { + threadGoal: () => null, + visitItems: () => {}, + submissions: () => [], + contextUsage: () => null, + context: { floor: () => null } + } +} + +it('bounds legacy saved-scope preparation without changing saved Auto', async () => { + vi.useFakeTimers() + const args = Promise.withResolvers() + const resolving = vi.fn(() => args.promise) + const environment = vi.fn(async () => ({})) + const record = agentSessionRecordFixture({ + sessionId: SESSION, + provider: 'claude', + permissionMode: 'auto', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/fixture/account' } + }) + const catalog = createAgentModelCatalogService({ + store: new AgentModelCatalogStore(), + getConversation: () => { + const held = record + return held ? agentSessionConversationView(held) : undefined + }, + getRecord: () => record, + drivesRecord: () => true, + resolveAccountHome: async () => record.accountHome, + recordWorkspacePath: async () => '/fixture/workspace', + probes: { + claude: createClaudeModelCatalogProbe({ + resolveLaunchArgs: resolving, + resolveInheritedEnv: environment, + resolveCommand: () => '/fixture/claude', + resolveAuthPolicy: () => ({ account: 'system' }) + }) + } + }) + const resting = { child: null, params: { provider: 'claude' }, journal: restingJournal() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the full read only uses these context members. + const context = { + deps: { + adapter: {}, + agents: claudeAndCodexAgents(), + store: { + getConversation: () => { + const held = record + return held ? agentSessionConversationView(held) : undefined + }, + getRecord: () => record + }, + modelCatalog: catalog + }, + serialize: (_id: string, task: () => Promise) => task(), + openConversation: async () => resting, + conversation: async () => resting + } as unknown as StructuredAgentSessionMutationContext + try { + const answer = readStructuredAgentSessionOptions(context, SESSION, { + readsWithoutModel: false + }) + await vi.advanceTimersByTimeAsync(0) + expect(resolving).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(30_001) + await expect(answer).resolves.toMatchObject({ current: { permissionMode: 'auto' } }) + expect(resolving).toHaveBeenCalledOnce() + args.resolve([]) + await vi.advanceTimersByTimeAsync(0) + expect(environment).not.toHaveBeenCalled() + } finally { + args.resolve([]) + catalog.stop() + vi.useRealTimers() + } +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index b13e0628897..bf4713c53cc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -1,8 +1,19 @@ +import { claudeOpened } from '../../claude/claude-structured-startup-settled-test-support' import { agentSessionRecordFixture } from '../agent-session-record-test-fixture' -import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' +import type { ClaudeStructuredSessionEvent } from '../../claude/structured/claude-structured-session-state' +import { startCodexTurn } from '../../codex/codex-structured-turn-start' +import { readLiveCodexSessionOptions } from '../../codex/codex-structured-session-options' +import { optionSession } from '../../codex/codex-structured-option-session.test-fixture' +import { CodexAppServerRequestError } from '../../codex/app-server/codex-app-server-request-error' +import { structuredAgentSessionDrawnValues } from '../../../shared/structured-agent-session-option-snapshot' import { structuredAgentSessionCapabilityView } from './structured-agent-session-option-models' import { claudeFallbackModelOptions } from '../../claude/claude-structured-session-options' -import { acquired, fakeClaude } from '../../claude/claude-structured-session-test-support' +import { + acquired, + fakeClaude, + claudeFrame, + USER_MESSAGE +} from '../../claude/claude-structured-session-test-support' import { getAgentSessionOptionCatalog } from '../../../shared/agent-session-option-catalog' import { applyStructuredAgentSessionModelCatalog, @@ -14,11 +25,14 @@ import type { StructuredAgentSessionLiveOptions } from './structured-agent-sessi import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' import { - claudeProviderHandle, - codexProviderHandle -} from '../../../shared/agent-session-provider-handle-encoding' + hostTestRestingOptionsRecord as restingRecord, + HOST_TEST_RESTING_OPTIONS_CREATED_LINKS as created, + hostTestRestingOptionsJournal as restingJournal, + hostTestOptionsReadStore +} from './structured-agent-session-host-test-data' +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' import { createAgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' import { @@ -35,6 +49,11 @@ import { GROK_CONFIG_OPTIONS } from '../../acp/acp-structured-adapter.test-suppo import { SessionConfigOptionSchema } from '../../acp/generated/acp-protocol.generated' import { withLiveCatalogListing } from '../agent-model-catalog/agent-model-catalog-entry' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' +import { agentModelCatalogFingerprintForRecord } from '../agent-model-catalog/agent-model-catalog-fingerprint' +import { + saveStructuredAgentSessionStartReadbackListing, + type StructuredAgentSessionStartListingContext +} from './structured-agent-session-start-listing' import { recordAgentSessionRanOptions } from '../../runtime/agent-session-record-options' import { claudeAndCodexAgents, @@ -48,29 +67,6 @@ type RestingFacts = Pick< 'options' | 'provider' | 'providerHandleChain' | 'rewind' > -function restingJournal(items: AgentJournalItemBody[] = [], wroteBeforeOpen = false) { - return { - threadGoal: () => null, - visitItems: (visit: (itemId: string, sequence: number, body: AgentJournalItemBody) => void) => - items.forEach((body, index) => visit(`item-${index}`, index + 1, body)), - wroteBeforeOpen: () => wroteBeforeOpen, - submissions: () => [], - contextUsage: () => null, - context: { floor: () => null } - } -} - -function restingRecord(): AgentSessionRecord { - return agentSessionRecordFixture({ - sessionId: SESSION, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/homes/a' }, - providerHandleChain: [], - location: { executionHostId: 'local', wslDistro: null }, - options: {} - }) -} - describe('options at rest', () => { it('keeps the account model for an older client when the starting child has no folder default', async () => { const record = restingRecord() @@ -96,11 +92,7 @@ describe('options at rest', () => { deps: { adapter: {}, agents: NO_STRUCTURED_AGENTS, - store: { - getRecord: () => record, - getConversation: () => (record ? agentSessionConversationView(record) : null), - requireWritableRecord: () => record - }, + store: hostTestOptionsReadStore(() => record), modelCatalog: { read } }, serialize: (_sessionId: string, task: () => Promise) => task(), @@ -149,11 +141,7 @@ describe('options at rest', () => { }) }, agents: NO_STRUCTURED_AGENTS, - store: { - getRecord: () => record, - getConversation: () => (record ? agentSessionConversationView(record) : null), - requireWritableRecord: () => record - }, + store: hostTestOptionsReadStore(() => record), modelCatalog: { read: async () => ({ origin: 'probe', models: [listed], fetchedAt: 1 }) } }, serialize: (_sessionId: string, task: () => Promise) => task(), @@ -171,17 +159,13 @@ describe('options at rest', () => { }) it.each(['omp', 'grok', 'opencode'] as const)( - '%s keeps an unknown model and learns menus from later options', + '%s keeps an unknown model and learns menus from qualified reports', async (provider) => { const record = { ...restingRecord(), provider } const probe = vi.fn(() => new Promise(() => {})) const modelCatalog = createAgentModelCatalogService({ store: new AgentModelCatalogStore(), - getConversation: () => { - const saved = record - return saved ? agentSessionConversationView(saved) : undefined - }, - getRecord: () => record, + ...hostTestOptionsReadStore(() => record), drivesRecord: () => true, resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/homes/a' }), probes: { codex: probe } @@ -199,11 +183,7 @@ describe('options at rest', () => { deps: { adapter: { readOptions: async () => withLiveCatalogListing(reader.read()) }, agents: NO_STRUCTURED_AGENTS, - store: { - getRecord: () => record, - getConversation: () => (record ? agentSessionConversationView(record) : null), - requireWritableRecord: () => record - }, + store: hostTestOptionsReadStore(() => record), modelCatalog }, serialize: (_sessionId: string, task: () => Promise) => task(), @@ -240,6 +220,7 @@ describe('options at rest', () => { record.options = { model: 'grok-4.6', effort: 'high' } live = true await read() + modelCatalog.recordLiveListing(SESSION, { models: reader.read().models }) live = false const answer = await read() expect(answer.models).toEqual(reader.read().models) @@ -249,15 +230,6 @@ describe('options at rest', () => { }) describe('a chat at rest, read by the host', () => { - const created = [ - { - origin: 'created', - linkId: 'link-1', - mintedAtFence: 1, - observedAt: 1, - handle: codexProviderHandle('provider-session-1') - } - ] satisfies AgentSessionRecord['providerHandleChain'] const turn: AgentJournalItemBody = { kind: 'turn', turnId: 'turn-1', state: 'completed' } const fast = [{ value: 'priority', label: 'Fast' }] const flex = [...fast, { value: 'flex', label: 'Flex' }] @@ -290,11 +262,7 @@ describe('a chat at rest, read by the host', () => { deps: { adapter: liveOptions ? { readOptions: async () => liveOptions } : {}, agents: claudeAndCodexAgents(), - store: { - getRecord: () => record, - getConversation: () => (record ? agentSessionConversationView(record) : null), - requireWritableRecord: () => record - }, + store: hostTestOptionsReadStore(() => record), modelCatalog: catalog(models, verified) }, serialize: (_sessionId: string, task: () => Promise) => task(), @@ -337,6 +305,82 @@ describe('a chat at rest, read by the host', () => { expect(read.current).toMatchObject({ model: 'gpt-account', defaulted }) }) + it('preserves sparse Codex knowledge from an actual turn through host and shared desktop/phone values', async () => { + const pending = Promise.withResolvers() + const request = vi.fn(() => pending.promise) + const native = optionSession(request) + native.appliedTurnOptions = { model: 'B', effort: 'high', serviceTier: 'rapid' } + const inventory = [ + { id: 'A', label: 'A', isDefault: true, efforts: [], serviceTiers: [] }, + { + id: 'B', + label: 'B', + isDefault: false, + efforts: [], + serviceTiers: [{ value: 'rapid', label: 'Fast' }] + }, + { id: 'C', label: 'C', isDefault: false, efforts: [], serviceTiers: [] } + ] + const store = new AgentModelCatalogStore() + native.catalogAccess = { store, fingerprint: 'fixture', accountHomePath: '/fixture' } + store.recordSuccess( + 'fixture', + 'codex', + { origin: 'probe', complete: true, models: inventory }, + 'discovery' + ) + const previous = await readLiveCodexSessionOptions(native, undefined) + const sendTurn = (id: string) => + startCodexTurn(native, { + clientMessageId: id, + requestedAt: 1, + body: USER_MESSAGE, + turnOptions: { model: 'C' } + }) + const sending = sendTurn('uncertain').catch((error: unknown) => error) + await vi.waitFor(() => expect(request).toHaveBeenCalledOnce()) + expect(native.appliedTurnOptions).toEqual({ effort: 'high', serviceTier: 'rapid' }) + const assertProjection = async (models: AgentSessionModelOption[] | null) => { + const provider = await readLiveCodexSessionOptions(native, undefined) + expect(provider.current).toMatchObject({ effort: 'high', serviceTier: 'rapid' }) + expect(provider.current).not.toHaveProperty('model') + expect(provider.current).not.toHaveProperty('fastMode') + const savedOptions: Record[] = [{}, { model: 'C' }] + for (const saved of savedOptions) { + const host = await readAtRest({ options: saved }, [turnBody], true, models, true, provider) + expect(host.current.serviceTier).toBe('rapid') + const seed = getAgentSessionOptionCatalog('codex')! + let state = applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState('codex'), + seed, + models ? { origin: 'probe', models, fetchedAt: 1 } : { origin: 'unknown' }, + { newLaunch: false } + ) + state = applyStructuredAgentSessionOptions(state, seed, { + ...previous, + current: { ...previous.current, defaulted: [] } + }) + state = applyStructuredAgentSessionOptions(state, seed, host) + expect(structuredAgentSessionDrawnValues(state).serviceTier?.value).toBe('rapid') + expect(state.record.values.model?.value).toBe(host.current.model) + } + } + const turnBody = turn + await assertProjection(inventory) + native.catalogAccess = undefined + await assertProjection(null) + pending.reject(new Error('transport uncertainty')) + expect(await sending).toBeInstanceOf(Error) + await assertProjection(null) + native.appliedTurnOptions = { model: 'B', effort: 'high', serviceTier: 'rapid' } + request.mockRejectedValueOnce(new CodexAppServerRequestError('turn/start', -32602, 'refused')) + await expect(sendTurn('refused')).rejects.toThrow('refused') + expect(native.appliedTurnOptions?.model).toBe('B') + request.mockResolvedValueOnce({ turn: { id: 'accepted' } }) + await sendTurn('accepted') + expect(native.appliedTurnOptions).toEqual({ model: 'C', effort: 'high', serviceTier: 'rapid' }) + }) + it('uses seeds only for an unknown inventory, never beside known rows or a known empty list', () => { const seed = claudeFallbackModelOptions() expect( @@ -373,7 +417,7 @@ describe('a chat at rest, read by the host', () => { expect(result.models).toEqual(installed) expect(result.current).toMatchObject({ model: 'opus', effort: 'low' }) const chat = picking({ model: 'opus' }, installed, undefined, 'claude') - expect(await chat.pick('effort', 'low')).toMatchObject({ ok: false }) + expect(await chat.pick('effort', 'low')).toMatchObject({ ok: true }) expect(await chat.pick('effort', 'high')).toMatchObject({ ok: true }) const firstRun = picking({ model: 'opus' }, installed, undefined, 'claude') firstRun.ran('opus', { effort: 'low' }) @@ -513,7 +557,7 @@ describe('a chat at rest, read by the host', () => { expect(answer.current).toMatchObject({ serviceTier: runs }) const chat = picking(options, models) expect(await chat.pick('model', 'gpt-account')).toMatchObject({ - value: { options: { serviceTier: runs } } + value: { options: { serviceTier: 'flex' } } }) expect(chat.record().options?.serviceTier).toBe('flex') } @@ -539,12 +583,7 @@ describe('a chat at rest, read by the host', () => { ) { let record: AgentSessionRecord = { ...restingRecord(), options, provider } const store = { - getConversation: () => { - const saved = record - return saved ? agentSessionConversationView(saved) : undefined - }, - getRecord: () => record, - requireWritableRecord: () => record, + ...hostTestOptionsReadStore(() => record), replaceSessionOptions: vi.fn(), reviseSessionOptions: async ( revise: (held: AgentSessionRecord, args: A) => AgentSessionRecord, @@ -570,25 +609,35 @@ describe('a chat at rest, read by the host', () => { options: ran, models: rows })), - record: () => record + record: () => record, + inventory: deps.modelCatalog } } - it('admits options only under the model the chat runs, whichever write lands first', async () => { + it('keeps literal picks and rejects late evidence from another model', async () => { const chat = picking({ model: 'a', effort: 'high' }) - // New models drop unsupported inherited effort. await chat.pick('model', 'c') - expect(chat.record().options).toEqual({ model: 'c' }) - expect(await chat.pick('effort', 'high')).toMatchObject({ ok: false }) + expect(chat.record().options).toEqual({ model: 'c', effort: 'high' }) + expect(await chat.pick('effort', 'high')).toMatchObject({ ok: true }) await chat.pick('model', 'b') - // Late execution on another model cannot fill the current model’s intent. - expect(chat.ran('a', { effort: 'high' }).options).toEqual({ model: 'b' }) + // A turn that ran on another model, recorded late, fills nothing, though b offers its effort. + expect(chat.ran('a', { effort: 'high' }).options).toEqual({ model: 'b', effort: 'high' }) expect(chat.ran('b', { effort: 'high' }).options).toEqual({ model: 'b', effort: 'high' }) chat.ran('b', { effort: 'low' }, []) await chat.pick('serviceTier', 'priority') expect(chat.record().options).toEqual({ model: 'b', effort: 'high', serviceTier: 'priority' }) }) + it('saves literal and permission picks while inventory is unresolved', async () => { + const chat = picking({ model: 'a' }) + const read = vi.spyOn(chat.inventory, 'read').mockImplementation(() => new Promise(() => {})) + expect(await chat.pick('model', 'not-listed')).toMatchObject({ ok: true }) + expect(await chat.pick('effort', 'high')).toMatchObject({ ok: true }) + expect(await chat.pick('permissionMode', 'ask')).toMatchObject({ ok: true }) + expect(read).not.toHaveBeenCalled() + expect(chat.record().options).toEqual({ model: 'not-listed', effort: 'high' }) + }) + it('keeps saved literal values after the installed list omits their model', async () => { const inventory = [{ id: 'B', label: 'B', isDefault: false, efforts: [] }] const chat = picking({ model: 'opus[1m]', effort: 'low' }, inventory, undefined, 'claude') @@ -607,132 +656,175 @@ describe('a chat at rest, read by the host', () => { expect(chat.record().options).toEqual({ model: 'opus[1m]', effort: 'low' }) }) - it('keeps newer saved picks after a cold live listing waits without holding the session lane', async () => { - const listing = Promise.withResolvers() + it('fences a live read and newly received startup report to their actual child through folder awaits', async () => { + const record = agentSessionRecordFixture({ sessionId: SESSION, provider: 'opencode2' }) + const folder = Promise.withResolvers() + const lookup = vi.fn(() => folder.promise) + const store = new AgentModelCatalogStore() + const catalog = createAgentModelCatalogService({ + store, + ...hostTestOptionsReadStore(() => record), + drivesRecord: () => true, + resolveAccountHome: async () => record.accountHome, + recordWorkspacePath: lookup, + listsInChatFolder: new Set(['opencode2']) + }) const queue = new StructuredAgentSessionTaskQueue() - const record = { ...restingRecord(), options: { model: 'gpt-picked', effort: 'high' } } - const child = { fence: 7 } - const apply = vi.fn(() => ({ - models: [{ id: 'gpt-live', label: 'GPT Live', efforts: [] }], - current: { model: 'gpt-live', effort: 'high', confirmed: ['model', 'effort'] as const } - })) - const prepareReadOptions = vi.fn(async () => { - await listing.promise - return apply + const child = (generation: string, fence: number) => ({ + generation, + fence, + phase: 'ready' as const, + catalogSource: catalog.captureSource(record) }) const live = { - child, - params: { provider: 'codex' }, + child: child('old', 7), + params: { provider: record.provider }, journal: restingJournal() } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read touches only the declared context fields. - const context = { - deps: { - adapter: { prepareReadOptions }, - agents: NO_STRUCTURED_AGENTS, - store: { - getRecord: () => record, - getConversation: () => (record ? agentSessionConversationView(record) : null), - requireWritableRecord: () => record - } - }, - serialize: (sessionId: string, task: () => Promise) => - queue.serialize(sessionId, task), - openConversation: async () => live, - conversation: async () => live - } as unknown as StructuredAgentSessionMutationContext - - const reading = readStructuredAgentSessionOptions(context, SESSION, { - readsWithoutModel: false - }) - await vi.waitFor(() => - expect(prepareReadOptions).toHaveBeenCalledWith({ sessionId: SESSION, fence: 7 }) - ) - await expect(queue.serialize(SESSION, async () => 'delivery can proceed')).resolves.toBe( - 'delivery can proceed' - ) - expect(apply).not.toHaveBeenCalled() - record.options = { model: 'gpt-next', effort: 'low' } - listing.resolve() - await expect(reading).resolves.toMatchObject({ - current: { model: 'gpt-next', effort: 'low' } - }) - expect(apply).toHaveBeenCalledOnce() - }) - - it('keeps a listing failure visible to the picker', async () => { - const queue = new StructuredAgentSessionTaskQueue() - const live = { - child: { fence: 7 }, - params: { provider: 'codex' }, - journal: restingJournal() - } - const failure = new Error('model/list unavailable') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the failed read ends before touching the remaining context fields. + const sessions = new Map([[SESSION, live]]) + let model = 'old' + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the real read and startup publisher use only these context members. const context = { deps: { adapter: { - prepareReadOptions: async () => { - throw failure - } + readOptions: async () => + withLiveCatalogListing({ + models: [{ id: model, label: model, isDefault: false, efforts: [] }], + current: { model } + }) }, agents: NO_STRUCTURED_AGENTS, - store: { - getRecord: () => restingRecord(), - getConversation: () => agentSessionConversationView(restingRecord()), - requireWritableRecord: () => restingRecord() - } - }, - serialize: (sessionId: string, task: () => Promise) => - queue.serialize(sessionId, task), - openConversation: async () => live - } as unknown as StructuredAgentSessionMutationContext - - await expect( - readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) - ).rejects.toBe(failure) - await expect(queue.serialize(SESSION, async () => 'Stop can proceed')).resolves.toBe( - 'Stop can proceed' - ) - }) - - it('does not apply a listing to a replaced child', async () => { - const listing = Promise.withResolvers() - const queue = new StructuredAgentSessionTaskQueue() - const apply = vi.fn(() => ({ models: [], current: { model: 'stale' } })) - const prepareReadOptions = vi.fn(async () => { - await listing.promise - return apply - }) - const live = { - child: { fence: 7 }, - params: { provider: 'codex' }, - journal: restingJournal() - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read touches only the declared context fields. - const context = { - deps: { - adapter: { prepareReadOptions }, - agents: NO_STRUCTURED_AGENTS, - store: { - getRecord: () => restingRecord(), - getConversation: () => agentSessionConversationView(restingRecord()), - requireWritableRecord: () => restingRecord() - } + store: hostTestOptionsReadStore(() => record), + modelCatalog: catalog, + logger: { warn: vi.fn() } }, + sessions, serialize: (sessionId: string, task: () => Promise) => queue.serialize(sessionId, task), openConversation: async () => live, conversation: async () => live - } as unknown as StructuredAgentSessionMutationContext - - const reading = readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) - await vi.waitFor(() => expect(prepareReadOptions).toHaveBeenCalledOnce()) - await queue.serialize(SESSION, async () => { - live.child = { fence: 8 } - }) - listing.resolve() - expect((await reading).current).not.toHaveProperty('model') - expect(apply).not.toHaveBeenCalled() + } as unknown as StructuredAgentSessionMutationContext & + StructuredAgentSessionStartListingContext + const read = () => + readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) + try { + await read() + expect(lookup).not.toHaveBeenCalled() + const report = (generation: string, fence: number) => + saveStructuredAgentSessionStartReadbackListing( + context, + { sessionId: SESSION, acquisitionGeneration: generation, fence }, + { models: [{ id: model, label: model, isDefault: false, efforts: [] }] } + ) + await report('old', 7) + await vi.waitFor(() => expect(lookup).toHaveBeenCalled()) + await queue.serialize(SESSION, async () => { + store.expireAgent(record.provider) + live.child = child('new', 8) + model = 'current' + }) + await read() + await report('new', 8) + // The old child's first receipt after replacement cannot acquire the new child's authority. + await saveStructuredAgentSessionStartReadbackListing( + context, + { + sessionId: SESSION, + acquisitionGeneration: 'old', + fence: 7 + }, + { models: [{ id: 'late', label: 'Late', isDefault: true, efforts: [] }] } + ) + folder.resolve('/project') + const key = agentModelCatalogFingerprintForRecord(record, '/project') + await vi.waitFor(() => + expect(store.get(key)?.models.map((row) => row.id)).toEqual(['current']) + ) + const current = store.get(key) + await saveStructuredAgentSessionStartReadbackListing( + context, + { + sessionId: SESSION, + acquisitionGeneration: 'old', + fence: 7 + }, + { models: [{ id: 'late-again', label: 'Late', isDefault: true, efforts: [] }] } + ) + expect(store.get(key)).toBe(current) + } finally { + folder.resolve('/project') + catalog.stop() + } }) }) + +it('projects held Claude reports through the full reader while actual controls and Send proceed', async () => { + const events: ClaudeStructuredSessionEvent[] = [] + const native = fakeClaude() + const adapter = await acquired(native, {}, events) + await claudeOpened(adapter, SESSION) + const connection = native.connections[0]! + const pending = Promise.withResolvers() + const settings = vi.spyOn(connection, 'getSettings').mockReturnValueOnce(pending.promise) + const queue = new StructuredAgentSessionTaskQueue() + const record = { ...restingRecord(), provider: 'claude', options: {} } + const live = { child: { fence: 7 }, params: { provider: 'claude' }, journal: restingJournal() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the full reader uses only these context members; provider operations use the actual adapter. + const context = { + deps: { + adapter, + agents: claudeAndCodexAgents(), + store: hostTestOptionsReadStore(() => record) + }, + serialize: (id: string, task: () => Promise) => queue.serialize(id, task), + openConversation: async () => live, + conversation: async () => live + } as unknown as StructuredAgentSessionMutationContext + const read = () => + readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) + try { + claudeFrame(connection, { type: 'result', subtype: 'success', result: 'done', is_error: false }) + await vi.waitFor(() => expect(settings).toHaveBeenCalledOnce()) + expect((await read()).current.effort).toBe('high') + await queue.serialize(SESSION, () => + adapter.dispatch({ + sessionId: SESSION, + fence: 7, + clientMessageId: 'while-reporting', + body: USER_MESSAGE + }) + ) + expect(connection.sent.length).toBeGreaterThan(0) + await queue.serialize(SESSION, () => + adapter.setOption({ sessionId: SESSION, fence: 7, key: 'model', value: 'claude-opus-5' }) + ) + pending.resolve({ + applied: { model: 'old', effort: 'max' }, + effective: { effortLevel: 'max', fastMode: true } + }) + await Promise.resolve() + await Promise.resolve() + expect((await read()).current).toMatchObject({ model: 'claude-opus-5', effort: 'high' }) + settings.mockResolvedValueOnce({ + applied: { model: 'claude-opus-5', effort: 'low' }, + effective: { effortLevel: 'low', fastMode: true } + }) + claudeFrame(connection, { + type: 'result', + subtype: 'success', + result: 'later', + is_error: false + }) + await vi.waitFor(async () => + expect((await read()).current).toMatchObject({ effort: 'low', fastMode: true }) + ) + expect( + events.some( + (event) => event.type === 'options-reported' && event.reportedOptions.effort === 'max' + ) + ).toBe(false) + } finally { + pending.resolve(null) + await adapter.closeSession(SESSION) + } +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 2f305823882..61841163be0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -1,25 +1,22 @@ +import { AGENT_MODEL_CATALOG_PICKER_WAIT_MS } from '../agent-model-catalog/agent-model-catalog-listing-waiters' +import { independentAgentModelCatalog } from '../agent-model-catalog/agent-model-catalog-discovery' +import { + readStructuredAgentSessionOptionsAtRest, + IN_PROCESS_OPTIONS_READER, + type StructuredAgentSessionOptionsReader +} from './structured-agent-session-resting-options' +export { IN_PROCESS_OPTIONS_READER, type StructuredAgentSessionOptionsReader } import { structuredAgentSessionThreadGoalReport } from './structured-agent-session-thread-goal' // Saved choices, catalog defaults and actual execution reports remain separate facts. -import type { - AgentSessionModelCatalogResult, - AgentSessionOptionsResult -} from '../../../shared/agent-session-wire' -import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' -import { finishedListingIds } from '../../../shared/structured-agent-session-options' -import { savedEffectiveServiceTier } from '../../../shared/structured-agent-session-effective-tier' +import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentSessionLiveOptions } from './structured-agent-session-adapter' -import { retainedAgentModelCatalogModels } from '../agent-model-catalog/agent-model-catalog-entry' -import { saveStructuredAgentSessionListing } from './structured-agent-session-start-listing' -import { independentAgentModelCatalog } from '../agent-model-catalog/agent-model-catalog-discovery' -import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { structuredAgentSessionCapabilityView, - structuredAgentSessionOptionModels, type StructuredAgentSessionRestingOptions as RestingOptions } from './structured-agent-session-option-models' -import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' +import { structuredAgentSessionImmediatePermission } from './structured-agent-session-launch-permission' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { structuredAgentSessionNeverSent } from './structured-agent-session-never-sent' @@ -28,108 +25,7 @@ export { structuredAgentSessionOptionOverridesRefusal } from './structured-agent-session-option-intent' -/** Whether a client accepts answers without a model. */ -export type StructuredAgentSessionOptionsReader = { readsWithoutModel: boolean } - -/** Match same-build RPC support for model-less answers. */ -export const IN_PROCESS_OPTIONS_READER: StructuredAgentSessionOptionsReader = { - readsWithoutModel: true -} - -async function readStructuredAgentSessionOptionsAtRest( - deps: Pick, - sessionId: string, - reader: StructuredAgentSessionOptionsReader -): Promise { - const record = deps.store.getConversation(sessionId) - if (!record) { - throw new Error('agent_session_identity_required') - } - const rules = deps.agents.definition(record.provider)?.restingOptions ?? null - const readCatalog = async ( - inSessionWorkspace: boolean - ): Promise => - (await deps.modelCatalog - ?.read({ - agent: record.provider, - sessionId, - savedOnly: true, - ...(!reader.readsWithoutModel ? { legacyOptionsDisplay: true as const } : {}), - ...(inSessionWorkspace - ? { inSessionWorkspace: true as const } - : independentAgentModelCatalog(record.provider) - ? { workspacePath: null } - : {}) - }) - .catch(() => null)) ?? { origin: 'unknown' } - const saved = record.options ?? {} - const scoped = await readCatalog(true) - const folderDefault = - scoped.origin === 'unknown' ? undefined : scoped.models.find((entry) => entry.isDefault) - // Older clients requiring a model retain the account default when the folder has none. - const catalog = - saved.model === undefined && - !folderDefault && - scoped.origin !== 'unknown' && - !reader.readsWithoutModel - ? await readCatalog(false) - : scoped - const listed = catalog.origin === 'unknown' ? (rules?.fallbackModels() ?? null) : catalog.models - const models = listed ?? [] - const proof = independentAgentModelCatalog(record.provider) - ? await deps.modelCatalog - ?.read({ - agent: record.provider, - sessionId, - savedOnly: true, - verifiedOnly: true, - inSessionWorkspace: true - }) - .catch(() => null) - : catalog - const listedModelIds = proof ? finishedListingIds(proof) : undefined - const defaultModels = - independentAgentModelCatalog(record.provider) && reader.readsWithoutModel - ? proof && proof.origin !== 'unknown' - ? proof.models - : [] - : models - const fastMode = - saved.fastMode === undefined - ? null - : decodeStructuredAgentSessionOptionValue('fastMode', saved.fastMode) - // Only a real listing confirms account defaults; built-in defaults cannot override client state. - const model = - saved.model ?? - (catalog.origin === 'unknown' ? undefined : models.find((entry) => entry.isDefault)?.id) - // Prefer saved effort, then live execution or the folder’s matching model default. - const effort = - saved.effort ?? - (rules?.effortDefaultsToModel || (saved.model === undefined && folderDefault) - ? defaultModels.find((entry) => entry.id === model)?.defaultEffort - : undefined) - const defaulted = (['model', 'effort'] as const).filter( - (key) => saved[key] === undefined && Boolean(key === 'model' ? model : effort) - ) - const serviceTier = savedEffectiveServiceTier(saved, models) - return { - inventory: catalog, - fallback: catalog.origin === 'unknown' ? (listed ?? []) : [], - models: listed ? structuredAgentSessionOptionModels(listed, model, (row) => row) : [], - ...(catalog.origin !== 'unknown' && catalog.fastModeSupport - ? { fastModeSupport: catalog.fastModeSupport } - : {}), - ...(listedModelIds ? { listedModelIds } : {}), - current: { - ...(model ? { model } : {}), - ...(effort ? { effort } : {}), - ...(typeof fastMode === 'boolean' ? { fastMode } : {}), - ...(serviceTier ? { serviceTier } : {}), - defaulted - } - } -} - +/** A live child's own answer, or the answer at rest; the goal, usage and rewind either way. */ export async function readStructuredAgentSessionOptions( context: Pick< StructuredAgentSessionMutationContext, @@ -138,6 +34,9 @@ export async function readStructuredAgentSessionOptions( sessionId: string, reader: StructuredAgentSessionOptionsReader ): Promise { + reader = reader.readsWithoutModel + ? reader + : { ...reader, deadline: reader.deadline ?? Date.now() + AGENT_MODEL_CATALOG_PICKER_WAIT_MS } const { adapter, agents, store } = context.deps const started = await context.serialize(sessionId, async () => { const session = await context.openConversation(sessionId).catch((error: unknown) => { @@ -147,32 +46,18 @@ export async function readStructuredAgentSessionOptions( if (!child || child.phase === 'starting') { return { kind: 'rest' as const } } - store.requireWritableRecord(sessionId) - const prepared = adapter.prepareReadOptions?.({ sessionId, fence: child.fence }) - if (prepared) { - return { kind: 'prepared' as const, child, prepared } - } if (!adapter.readOptions) { throw new Error('structured_agent_session_options_unsupported') } return { kind: 'live' as const, + session, child, options: await adapter.readOptions({ sessionId, fence: child.fence }) } }) const live: StructuredAgentSessionLiveOptions | null = - started.kind === 'live' - ? started.options - : started.kind === 'prepared' - ? await started.prepared.then((apply) => - context.serialize(sessionId, async () => { - const child = (await context.openConversation(sessionId))?.child - store.requireWritableRecord(sessionId) - return child === started.child ? apply() : null - }) - ) - : null + started.kind === 'live' ? started.options : null let rested: RestingOptions | undefined const atRest = async (): Promise => (rested ??= await readStructuredAgentSessionOptionsAtRest(context.deps, sessionId, reader)) @@ -185,17 +70,6 @@ export async function readStructuredAgentSessionOptions( const { listedModelIds } = await atRest() options = listedModelIds ? { ...answer, listedModelIds } : answer } - if ( - catalogListing && - !independentAgentModelCatalog(store.getConversation(sessionId)?.provider ?? '') - ) { - // Check prior membership before saving: a launch-added row cannot verify itself. - const listedModelIds = context.deps.modelCatalog - ? await context.deps.modelCatalog.retainedLiveModelIds(sessionId, catalogListing) - : retainedAgentModelCatalogModels(undefined, catalogListing).map((model) => model.id) - saveStructuredAgentSessionListing(context, sessionId, catalogListing) - options = { ...answer, listedModelIds } - } } else { const { inventory: _inventory, fallback: _fallback, ...answer } = await atRest() options = answer @@ -208,9 +82,8 @@ export async function readStructuredAgentSessionOptions( const capabilities = agents.capabilities(agent) const floor = session.journal.context.floor() const contextFloor = floor ? { contextFloor: floor } : {} - // Intent and the live agent report stay separate: own never claims a mode before startup. const permissionMode = record - ? await structuredAgentSessionLaunchPermission(context.deps, record) + ? structuredAgentSessionImmediatePermission(context.deps, record) : null const resting = live ? await atRest() : null const selected = resting ?? options @@ -247,9 +120,11 @@ export async function readStructuredAgentSessionOptions( key === 'model' && !options.current.model ? displayDefault !== undefined || selected.current.defaulted?.includes(key) === true : options.current.defaulted?.includes(key) === true - const defaulted = structuredAgentSessionNeverSent(record, session.journal) - ? [] - : (['model', 'effort'] as const).filter((key) => saved[key] === undefined && defaultedBy(key)) + const defaulted = + (agent !== 'codex' || !live || options.current.model) && + structuredAgentSessionNeverSent(record, session.journal) + ? [] + : (['model', 'effort'] as const).filter((key) => saved[key] === undefined && defaultedBy(key)) const confirmed = options.current.confirmed?.filter((key) => key === 'model' || key === 'effort' || key === 'fastMode' || key === 'serviceTier' ? current[key] === options.current[key] @@ -273,12 +148,13 @@ export async function readStructuredAgentSessionOptions( : undefined ) options = { ...options, ...view, listedModelIds, current } - const projected = live - ? adapter.projectOptions?.(sessionId, { - ...saved, - ...(model ? { model } : {}) - }) - : undefined + const projected = + live && (saved.serviceTier !== undefined || saved.fastMode !== undefined) + ? adapter.projectOptions?.(sessionId, { + ...saved, + ...(model ? { model } : {}) + }) + : undefined if (projected?.options.serviceTier !== undefined) { current.serviceTier = projected.options.serviceTier } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts index e48c35de4f3..76552dbe3f3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts @@ -9,12 +9,28 @@ import { restTestSend, type RestTestRig } from './structured-agent-session-rest-test-rig' -import { hostTestOperationId } from './structured-agent-session-host-test-data' +import { + hostTestAttachParams, + hostTestOperationId, + HOST_TEST_THREAD +} from './structured-agent-session-host-test-data' import * as loginShell from '../../startup/login-shell-environment' import { createStructuredAgentEnvironmentResolvers } from '../../runtime/structured-agent-shell-environment' import { codexModelCatalogDiscovery } from '../../runtime/structured-agent-model-catalog-discovery' import { createAgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' import { AgentModelCatalogStore } from '../agent-model-catalog/agent-model-catalog-store' +import { + agentModelCatalogFingerprint, + agentModelCatalogScopeFingerprint +} from '../agent-model-catalog/agent-model-catalog-fingerprint' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { retireAtRestCommandContext } from './at-rest-command-context' +import { + applyStructuredAgentSessionOptions, + createStructuredAgentSessionOptionState +} from '../../../shared/structured-agent-session-options' +import { getAgentSessionOptionCatalog } from '../../../shared/agent-session-option-catalog' +import { structuredAgentSessionPermissionDescriptor } from '../../../shared/structured-agent-session-permission-option' describe('a chat permission mode', () => { let rig: RestTestRig @@ -53,6 +69,234 @@ describe('a chat permission mode', () => { expect((await rig.host.readOptions(SESSION)).current.permissionMode).toBe('ask') }) + it.each([true, false, undefined])( + 'keeps saved Auto independent of cold catalog support=%s and explicit preparation', + async (supportsAutoMode) => { + const home = { variable: 'CLAUDE_CONFIG_DIR', path: '/saved-auto-account' } + const key = agentModelCatalogScopeFingerprint( + agentModelCatalogFingerprint({ agent: 'claude', accountHome: home, wslDistro: null }), + '/project', + 'saved-launch' + ) + const seed = new AgentModelCatalogStore() + const saved = seed.recordSuccess( + key, + 'claude', + { + origin: 'probe', + models: [{ id: 'opus', label: 'Opus', isDefault: true, efforts: [], supportsAutoMode }] + }, + 'discovery' + )! + const store = new AgentModelCatalogStore() + const persist = vi.fn() + await store.attachPersistence({ + load: async () => (supportsAutoMode === undefined ? [] : [saved]), + save: persist, + flush: async () => {} + }) + const run = vi.fn(async () => { + throw new Error('options display must not list models') + }) + const resolve = vi.fn(async () => ({ identity: 'saved-launch', run })) + const workspace = vi.fn(async () => '/project') + const account = vi.fn(async () => home) + const catalog = createAgentModelCatalogService({ + store, + getRecord: (id) => rig.store.getRecord(id) ?? undefined, + getConversation: (id) => rig.store.getConversation(id) ?? undefined, + drivesRecord: () => true, + recordWorkspacePath: workspace, + resolveAccountHome: account, + probes: { claude: Object.assign(vi.fn(run), { resolve }) } + }) + const prepare = vi.spyOn(catalog, 'prepareSaved') + const ingest = vi.spyOn(store, 'recordSuccess') + const defaults = vi.spyOn(store, 'recordConfiguredDefault') + const acquire = rig.adapter.acquire.getMockImplementation()! + rig.adapter.acquire.mockImplementation(async (input) => { + const acquired = await acquire(input) + return { + ...acquired, + ...(acquired.link + ? { link: { ...acquired.link, handle: claudeProviderHandle(HOST_TEST_THREAD, null) } } + : {}) + } + }) + rig.adapter.dispatch.mockResolvedValue({ + state: 'accepted', + providerIdentity: { provider: 'claude', sessionId: HOST_TEST_THREAD, uuid: 'reply' } + }) + const seedCatalog = getAgentSessionOptionCatalog('claude')! + const read = async (mode: 'auto' | 'ask') => { + const answer = await rig.host.readOptions(SESSION, { readsWithoutModel: true }) + expect(answer.current.permissionMode).toBe(mode) + const state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('claude'), + seedCatalog, + answer + ) + expect(structuredAgentSessionPermissionDescriptor(state)?.kind).toMatchObject({ + type: 'select', + currentValue: mode + }) + } + try { + const attached = await rig.host.attach( + CALLER, + hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: home, + providerHandle: { kind: 'claude', sessionId: HOST_TEST_THREAD, leafUuid: null }, + options: { model: 'opus', permissionMode: 'auto' } + }) + ) + expect(attached.ok, JSON.stringify(attached)).toBe(true) + expect(rig.adapter.acquire).toHaveBeenLastCalledWith( + expect.objectContaining({ permissionMode: 'auto' }) + ) + await rig.restart({ modelCatalog: catalog }) + await read('auto') + await read('auto') + for (const work of [prepare, resolve, workspace, account, run, ingest, defaults, persist]) { + expect(work).not.toHaveBeenCalled() + } + expect(rig.adapter.acquire).toHaveBeenCalledTimes(1) + await catalog.prepareSaved({ + agent: 'claude', + sessionId: SESSION, + inSessionWorkspace: true + }) + const fence = () => rig.store.getRecord(SESSION)!.lease.runtimeFence + expect((await rig.host.send(CALLER, restTestSend('prepared start', fence()))).ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(1)) + expect(rig.adapter.acquire).toHaveBeenLastCalledWith( + expect.objectContaining({ permissionMode: 'auto' }) + ) + expect(prepare).toHaveBeenCalled() + expect(resolve).toHaveBeenCalledOnce() + prepare.mockClear() + await read('auto') + expect(prepare).not.toHaveBeenCalled() + rig.adapter.closeSession.mockClear() + expect((await rig.host.send(CALLER, restTestSend('outgrown comparison', fence()))).ok).toBe( + true + ) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + expect(prepare).not.toHaveBeenCalled() + expect(rig.adapter.acquire).toHaveBeenCalledTimes(2) + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + expect(run).not.toHaveBeenCalled() + expect(rig.store.getRecord(SESSION)?.permissionMode).toBe('auto') + } finally { + catalog.stop() + } + } + ) + + it('acquires cold saved Auto and keeps that child after an unrelated folder retires', async () => { + const home = { variable: 'CLAUDE_CONFIG_DIR', path: '/saved-auto-account' } + const key = agentModelCatalogScopeFingerprint( + agentModelCatalogFingerprint({ agent: 'claude', accountHome: home, wslDistro: null }), + '/project', + 'saved-launch' + ) + const seed = new AgentModelCatalogStore() + const saved = seed.recordSuccess( + key, + 'claude', + { + origin: 'probe', + 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 pending = Promise.withResolvers() + const run = vi.fn(() => pending.promise) + const resolve = vi.fn(async () => ({ identity: 'saved-launch', run })) + const probe = Object.assign(vi.fn(run), { resolve }) + const catalog = createAgentModelCatalogService({ + store, + getRecord: (id) => rig.store.getRecord(id) ?? undefined, + getConversation: (id) => rig.store.getConversation(id) ?? undefined, + drivesRecord: () => true, + recordWorkspacePath: async () => '/project', + resolveAccountHome: async () => home, + probes: { claude: probe } + }) + const acquire = rig.adapter.acquire.getMockImplementation()! + rig.adapter.acquire.mockImplementation(async (input) => { + const acquired = await acquire(input) + return { + ...acquired, + ...(acquired.link + ? { link: { ...acquired.link, handle: claudeProviderHandle(HOST_TEST_THREAD, null) } } + : {}) + } + }) + rig.adapter.dispatch.mockResolvedValue({ + state: 'accepted', + providerIdentity: { provider: 'claude', sessionId: HOST_TEST_THREAD, uuid: 'reply' } + }) + try { + await rig.restart({ modelCatalog: catalog }) + const attached = await rig.host.attach( + CALLER, + hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: home, + providerHandle: { kind: 'claude', sessionId: HOST_TEST_THREAD, leafUuid: null }, + options: { model: 'opus', permissionMode: 'auto' } + }) + ) + expect(attached.ok, JSON.stringify(attached)).toBe(true) + expect(rig.adapter.acquire).toHaveBeenCalledTimes(1) + expect(rig.adapter.acquire).toHaveBeenLastCalledWith( + expect.objectContaining({ permissionMode: 'auto' }) + ) + expect(resolve).not.toHaveBeenCalled() + await catalog.prepareSaved({ agent: 'claude', sessionId: SESSION, inSessionWorkspace: true }) + expect(resolve).toHaveBeenCalledWith( + home, + '/project', + 'saved', + expect.objectContaining({ + deadline: expect.any(Number), + current: expect.any(Function), + signal: expect.any(AbortSignal) + }) + ) + expect(run).not.toHaveBeenCalled() + await rig.host.send(CALLER, restTestSend('first', fence())) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(1)) + void catalog.demand({ agent: 'claude', sessionId: SESSION, inSessionWorkspace: true }) + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1)) + rig.adapter.closeSession.mockClear() + const resolutions = resolve.mock.calls.length + retireAtRestCommandContext('unrelated-folder') + expect((await rig.host.send(CALLER, restTestSend('after retirement', fence()))).ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + expect(rig.adapter.acquire).toHaveBeenCalledTimes(1) + expect(resolve).toHaveBeenCalledTimes(resolutions) + expect(rig.host.collaboratorsForTests().sessions.get(SESSION)?.child?.permissionMode).toBe( + 'auto' + ) + } finally { + catalog.stop() + } + }) + it('writes a pick to the record only, with the agent running', async () => { await foundRestTestChat(rig) const setOption = vi.spyOn(rig.host.deps.adapter, 'setOption') @@ -144,8 +388,7 @@ describe('a chat permission mode', () => { await environment.resolveBaseEnvironment() capture.mockClear() expect(await pick('ask')).toMatchObject({ ok: true }) - expect(capture).toHaveBeenCalled() - expect(capture.mock.calls.every(([options]) => options?.cachedOnly === true)).toBe(true) + expect(capture).not.toHaveBeenCalled() expect(shell).not.toHaveBeenCalled() } finally { shellReady.resolve({ PATH: '/missing/catalog-bin' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts index 3759c14b7fe..37ad3779953 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts @@ -28,6 +28,9 @@ export function indexProviderChild( session: ChildBearer, child: StructuredAgentSessionProviderChild ): void { + if (session.child !== child) { + session.child?.catalogSource?.release?.() + } session.child = child } @@ -95,6 +98,7 @@ export function recordProviderChildEnd( if (!child) { return false } + child.catalogSource?.release?.() session.lastEndedChild = { ...ended, ...(child.startedFor === undefined ? {} : { startedFor: child.startedFor }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index f1276532a58..13845699bf0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts @@ -102,6 +102,7 @@ export async function settleStructuredAgentSessionProviderStarted( error }) ) + const sourceChild = session.child context.runtimeState.startupAttempts.ready(event.sessionId, child) noteStructuredAgentSessionRuntimePermission(session.child, event.reportedOptions, true) context.publishStatus?.(event.sessionId) @@ -112,9 +113,22 @@ export async function settleStructuredAgentSessionProviderStarted( event.reportedOptions ) void context.wakeDelivery(event.sessionId) - noteStructuredAgentSessionProviderStarted(context.deps, event.sessionId) + noteStructuredAgentSessionProviderStarted( + context.deps, + event.sessionId, + () => + context.sessions.get(event.sessionId)?.child === sourceChild && + sourceChild?.catalogSource?.() !== false + ) if (event.catalogListing) { - saveStructuredAgentSessionListing(context, event.sessionId, event.catalogListing) + saveStructuredAgentSessionListing( + context, + event.sessionId, + event.catalogListing, + () => + context.sessions.get(event.sessionId)?.child === sourceChild && + sourceChild?.catalogSource?.() !== false + ) } context.publishStatus?.(event.sessionId) return undefined @@ -231,11 +245,12 @@ async function recordStartedHandle( /** Prompt catalog recheck after startup may prove installation or restored sign-in; the probe decides. */ export function noteStructuredAgentSessionProviderStarted( deps: Pick, - sessionId: string + sessionId: string, + source?: () => boolean ): void { const record = deps.store.getRecord(sessionId) if (record) { - deps.modelCatalog?.providerStarted(record) + deps.modelCatalog?.providerStarted(record, source) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts index ec7e0c3d003..9089f705f47 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts @@ -1,7 +1,10 @@ import { vi, type Mock } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { activeProviderContext } from '../../../shared/agent-session-provider-context' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' @@ -13,6 +16,7 @@ import { } from './structured-agent-session-host-test-data' export type QueuedRigProviderOptions = { + agent?: 'claude' rewind?: NonNullable recoverRewind?: NonNullable /** Resume the saved thread on later children after close/death. */ @@ -94,7 +98,10 @@ export function createQueuedRigProvider( acquisitionGeneration: 'generation-1', link: { linkId: `link-${fence}`, - handle: codexProviderHandle(thread), + handle: + options.agent === 'claude' + ? claudeProviderHandle(thread, null) + : codexProviderHandle(thread), origin: resumes ? ('resumed' as const) : ('created' as const), mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index c631f4477da..948f13702a1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -81,10 +81,17 @@ export async function createQueuedMessageTestRig( ...(options.recoveryCapsule ? { recoveryCapsule: new AgentSessionRecoveryCapsule(root) } : {}) }) let host = makeHost() - const firstAttach = hostTestAttachParams( - null, - options.location ? { location: options.location } : {} - ) + const firstAttach = hostTestAttachParams(null, { + ...(options.location ? { location: options.location } : {}), + ...(options.agent === 'claude' + ? ({ + provider: 'claude', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: THREAD, leafUuid: null }, + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/private-fixture/claude' } + } as const) + : {}) + }) expect(await host.attach(QUEUED_RIG_CALLER, firstAttach)).toMatchObject({ ok: true }) function envelope( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts index 07be40b9d31..8b20f997e33 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts @@ -8,7 +8,6 @@ import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import { structuredAgentSessionOptionOverridesRefusal } from './structured-agent-session-option-intent' -import { savedAgentSessionCatalogModels } from './structured-agent-session-pick-reply' import { recordAgentSessionRanOptions } from '../../runtime/agent-session-record-options' type RanOptionsContext = { @@ -168,14 +167,13 @@ export async function recordStructuredAgentSessionRanOptions( if (!ran.model) { return } - const models = await savedAgentSessionCatalogModels(context.deps, record.provider, sessionId) await store .reviseSessionOptions(recordAgentSessionRanOptions, { sessionId, fence: child.fence, model, options: ran, - models, + models: undefined, now: context.now() }) .catch((error: unknown) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resting-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resting-options.ts new file mode 100644 index 00000000000..d80e1a0b550 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resting-options.ts @@ -0,0 +1,145 @@ +import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session-wire' +import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' +import { finishedListingIds } from '../../../shared/structured-agent-session-options' +import { savedEffectiveServiceTier } from '../../../shared/structured-agent-session-effective-tier' +import { independentAgentModelCatalog } from '../agent-model-catalog/agent-model-catalog-discovery' +import { AGENT_MODEL_CATALOG_PICKER_WAIT_MS } from '../agent-model-catalog/agent-model-catalog-listing-waiters' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { + structuredAgentSessionOptionModels, + type StructuredAgentSessionRestingOptions as RestingOptions +} from './structured-agent-session-option-models' + +/** What the asking client takes: whether it reads an answer that names no model. */ +export type StructuredAgentSessionOptionsReader = { readsWithoutModel: boolean; deadline?: number } + +/** A same-build caller in this process reads as the RPC gate treats one: it takes no model. */ +export const IN_PROCESS_OPTIONS_READER: StructuredAgentSessionOptionsReader = { + readsWithoutModel: true +} + +export async function readStructuredAgentSessionOptionsAtRest( + deps: Pick, + sessionId: string, + reader: StructuredAgentSessionOptionsReader +): Promise { + const record = deps.store.getConversation(sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + const deadline = reader.readsWithoutModel + ? undefined + : (reader.deadline ?? Date.now() + AGENT_MODEL_CATALOG_PICKER_WAIT_MS) + const rules = deps.agents.definition(record.provider)?.restingOptions ?? null + const readCatalog = async ( + inSessionWorkspace: boolean + ): Promise => + (await deps.modelCatalog + ?.read({ + agent: record.provider, + sessionId, + savedOnly: true, + ...(!reader.readsWithoutModel ? { legacyOptionsDisplay: true as const } : {}), + ...(inSessionWorkspace + ? { inSessionWorkspace: true as const } + : independentAgentModelCatalog(record.provider) + ? { workspacePath: null } + : {}) + }) + .catch(() => null)) ?? { origin: 'unknown' } + if (!reader.readsWithoutModel) { + await deps.modelCatalog?.read({ + agent: record.provider, + sessionId, + deadline, + inSessionWorkspace: true, + waitForListing: true, + legacyOptionsDisplay: true + }) + await deps.modelCatalog?.prepareSaved?.({ + agent: record.provider, + sessionId, + deadline, + inSessionWorkspace: true, + legacyOptionsDisplay: true + }) + } + const saved = record.options ?? {} + // The chat's own folder decides its default, as its first frame did. + const scoped = await readCatalog(true) + const folderDefault = + scoped.origin === 'unknown' ? undefined : scoped.models.find((entry) => entry.isDefault) + // Where the folder names none, a client that needs a model keeps the account default it got before. + const catalog = + saved.model === undefined && + !folderDefault && + scoped.origin !== 'unknown' && + !reader.readsWithoutModel + ? (await deps.modelCatalog?.prepareSaved?.({ + agent: record.provider, + sessionId, + deadline, + legacyOptionsDisplay: true, + ...(independentAgentModelCatalog(record.provider) ? { workspacePath: null } : {}) + }), + await readCatalog(false)) + : scoped + // With no catalog for the account, the list a running child of this agent falls back to. + const listed = catalog.origin === 'unknown' ? (rules?.fallbackModels() ?? null) : catalog.models + const models = listed ?? [] + const proof = independentAgentModelCatalog(record.provider) + ? await deps.modelCatalog + ?.read({ + agent: record.provider, + sessionId, + savedOnly: true, + verifiedOnly: true, + inSessionWorkspace: true + }) + .catch(() => null) + : catalog + const listedModelIds = proof ? finishedListingIds(proof) : undefined + const defaultModels = + independentAgentModelCatalog(record.provider) && reader.readsWithoutModel + ? proof && proof.origin !== 'unknown' + ? proof.models + : [] + : models + const fastMode = + saved.fastMode === undefined + ? null + : decodeStructuredAgentSessionOptionValue('fastMode', saved.fastMode) + // An unknown model is one the client already treats as unconfirmed. Only a real listing names the + // account's default; a built-in list's default is a guess, so with none the client keeps its own. + const model = + saved.model ?? + (catalog.origin === 'unknown' ? undefined : models.find((entry) => entry.isDefault)?.id) + // The pick, else the model's default where a live child reports that, or where the chat's folder + // names that model as what it runs, as its first frame showed. + const effort = + saved.effort ?? + (rules?.effortDefaultsToModel || (saved.model === undefined && folderDefault) + ? defaultModels.find((entry) => entry.id === model)?.defaultEffort + : undefined) + const defaulted = (['model', 'effort'] as const).filter( + (key) => saved[key] === undefined && Boolean(key === 'model' ? model : effort) + ) + // The tier the chat's own model runs; the record keeps the saved one for a model that offers it. + const serviceTier = savedEffectiveServiceTier(saved, models) + return { + inventory: catalog, + fallback: catalog.origin === 'unknown' ? (listed ?? []) : [], + models: listed ? structuredAgentSessionOptionModels(listed, model, (row) => row) : [], + ...(catalog.origin !== 'unknown' && catalog.fastModeSupport + ? { fastModeSupport: catalog.fastModeSupport } + : {}), + ...(listedModelIds ? { listedModelIds } : {}), + current: { + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + ...(typeof fastMode === 'boolean' ? { fastMode } : {}), + ...(serviceTier ? { serviceTier } : {}), + defaulted + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index 1f0b7f9c43b..b607856de07 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -1,3 +1,5 @@ +import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' @@ -154,3 +156,16 @@ export function oldestQueuedSubmission( } return oldest } + +export async function recordStructuredAgentSessionDeliveryFailure( + session: Pick & { fence: number }, + failure: NonNullable>, + context: AgentSessionFailureWordsContext, + clientMessageId: string | null +): Promise { + await recordStructuredAgentSessionStartFailure( + session, + { startKey: failure.startKey, ...structuredAgentSessionStartFailure(failure.cause, context) }, + clientMessageId + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-listing.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-listing.ts index b8a5f7007cc..c15c89ff725 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-listing.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-listing.ts @@ -16,10 +16,11 @@ export type StructuredAgentSessionStartListingContext = { export function saveStructuredAgentSessionListing( context: Pick, sessionId: string, - listing: AgentModelCatalogLiveListing + listing: AgentModelCatalogLiveListing, + current: () => boolean ): void { try { - context.deps.modelCatalog?.recordLiveListing(sessionId, listing) + context.deps.modelCatalog?.recordLiveListing(sessionId, listing, current) } catch (error) { context.deps.logger.warn('saving what a provider listed failed', { scope: 'provider-catalog', @@ -41,7 +42,12 @@ export function saveStructuredAgentSessionStartReadbackListing( const child = context.sessions.get(sessionId)?.child const identity = { generation: reporter.acquisitionGeneration, fence: reporter.fence } if (child && sameProviderChild(child, identity)) { - context.deps.modelCatalog?.recordLiveListing(sessionId, listing) + context.deps.modelCatalog?.recordLiveListing( + sessionId, + listing, + () => + context.sessions.get(sessionId)?.child === child && child.catalogSource?.() !== false + ) } }) .catch((error: unknown) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host-test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host-test-support.ts index b46e9d26fbd..cb44208e05e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host-test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host-test-support.ts @@ -3,7 +3,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, vi, type Mock } from 'vitest' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -23,8 +22,9 @@ import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, hostTestAttachParams, - hostTestMessage, - hostTestOperationId, + hostTestSendParams as sendParams, + hostTestCancelParams, + hostTestSubmission, resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -101,10 +101,14 @@ async function startHost({ startupLimits, supportsGoals }: StartupHostOptions = setOption }, modelCatalog: { + 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 })), recordLiveListing, - retainedLiveModelIds: vi.fn(async () => []), prewarm: vi.fn(async () => {}), stop: vi.fn(), providerStarted @@ -167,23 +171,6 @@ export function registerStartupAttemptHostTests(): void { }) } -function sendParams(text: string, clientOperationId = hostTestOperationId()) { - const body = hostTestMessage(text) - return { - envelope: { - sessionId: SESSION, - clientOperationId, - expectedRuntimeFence: 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - }, - body - } -} - export async function accept(text: string, clientOperationId?: string): Promise { const params = sendParams(text, clientOperationId) expect(await host.send(CALLER, params)).toMatchObject({ @@ -194,9 +181,7 @@ export async function accept(text: string, clientOperationId?: string): Promise< } export async function submission(id: string): Promise { - return (await host.journalSnapshot(SESSION)).submissions.find( - (entry) => entry.clientMessageId === id - ) + return hostTestSubmission(host, id) } export async function heldBehindStart(text: string, interval = 0): Promise { @@ -209,17 +194,5 @@ export async function heldBehindStart(text: string, interval = 0): Promise { await proveOpen() await reported - await eventually(() => expect(recordLiveListing).toHaveBeenCalledWith(SESSION, listing)) + await eventually(() => + expect(recordLiveListing).toHaveBeenCalledWith(SESSION, listing, expect.any(Function)) + ) }) }) diff --git a/src/main/native-chat/structured-agent-command-resolution.test.ts b/src/main/native-chat/structured-agent-command-resolution.test.ts index d3e97fc190c..4932ea3aea1 100644 --- a/src/main/native-chat/structured-agent-command-resolution.test.ts +++ b/src/main/native-chat/structured-agent-command-resolution.test.ts @@ -1,9 +1,12 @@ +import { resolveCodexStructuredInvocation } from '../codex/codex-structured-launch-resolution' import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { resolveCodexStructuredInvocation } from '../codex/codex-structured-launch-resolution' -import { resolveStructuredAgentCommand } from './structured-agent-command-resolution' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + resolveStructuredAgentCommand, + resolveAgainAfterRefreshOnMissingProgram +} from './structured-agent-command-resolution' import { resolveCliCommand } from '../../shared/node-cli-command-resolution' const NOT_RUNNABLE = expect.objectContaining({ @@ -195,3 +198,32 @@ describe('structured agent executable resolution', () => { }) }) }) + +it('does not refresh or look up again after an expired async missing-program check', async () => { + const cwd = Promise.withResolvers() + const entered = Promise.withResolvers() + let current = true + const resolve = vi.fn(async () => ({ + command: 'absent-fixture-program', + env: { PATH: 'relative-bin' } + })) + const refresh = vi.fn(async () => {}) + const result = resolveAgainAfterRefreshOnMissingProgram( + resolve, + refresh, + { + env: (row) => row.env, + cwd: () => { + entered.resolve() + return cwd.promise + } + }, + { current: () => current } + ).catch((error: unknown) => error) + await entered.promise + current = false + cwd.resolve('/fixture') + expect(await result).toBeInstanceOf(Error) + expect(refresh).not.toHaveBeenCalled() + expect(resolve).toHaveBeenCalledOnce() +}) diff --git a/src/main/native-chat/structured-agent-command-resolution.ts b/src/main/native-chat/structured-agent-command-resolution.ts index 36820a02089..0c5016f06a8 100644 --- a/src/main/native-chat/structured-agent-command-resolution.ts +++ b/src/main/native-chat/structured-agent-command-resolution.ts @@ -16,6 +16,24 @@ import { } from './agent-session-wire/structured-agent-session-adapter' import { guestNativePathEnv, resolveGuestNativeProgram } from '../ipc/wsl-guest-windows-drive-path' +export type StructuredAgentResolutionOperation = { + deadline?: number + current?: () => boolean + signal?: AbortSignal +} + +export function assertStructuredAgentResolutionCurrent( + operation?: StructuredAgentResolutionOperation +): void { + operation?.signal?.throwIfAborted() + if ( + operation?.current?.() === false || + (operation?.deadline !== undefined && Date.now() >= operation.deadline) + ) { + throw new Error('Agent resolution operation ended') + } +} + export type StructuredAgentCommandSettings = Partial< Pick > @@ -79,7 +97,8 @@ export type ProgramSearch = { /** Bare names may resolve only at spawn through a relative PATH entry under the launch cwd. */ async function foundNothing( resolved: T, - search: ProgramSearch + search: ProgramSearch, + operation?: StructuredAgentResolutionOperation ): Promise { if (isAbsolute(resolved.command) || /[\\/]/.test(resolved.command)) { return false @@ -95,6 +114,7 @@ async function foundNothing( const from = (await waitForPromiseWithSignal(Promise.resolve(search.cwd?.()), search.signal)) ?? process.cwd() + assertStructuredAgentResolutionCurrent(operation) const found = resolveCliCommand(resolved.command, { pathEnv: relative.map((entry) => resolvePath(from, entry)).join(delimiter), homePath: env?.HOME ?? env?.USERPROFILE @@ -106,15 +126,20 @@ async function foundNothing( export async function resolveAgainAfterRefreshOnMissingProgram( resolve: () => Promise, refreshAfterMiss: ((askedAt: number) => Promise) | undefined, - search: ProgramSearch + search: ProgramSearch, + operation?: StructuredAgentResolutionOperation ): Promise { + assertStructuredAgentResolutionCurrent(operation) if (!refreshAfterMiss) { return waitForPromiseWithSignal(resolve(), search.signal) } const askedAt = Date.now() try { const resolved = await waitForPromiseWithSignal(resolve(), search.signal) - if (!(await foundNothing(resolved, search))) { + assertStructuredAgentResolutionCurrent(operation) + const missing = await foundNothing(resolved, search, operation) + assertStructuredAgentResolutionCurrent(operation) + if (!missing) { return resolved } } catch (error) { @@ -126,8 +151,10 @@ export async function resolveAgainAfterRefreshOnMissingProgram, + sanitizedUpdates: Partial, + previousSettings: GlobalSettings, + options: { notifyListeners?: boolean; originWebContentsId?: number } +): void { + if ('opencodeSessionCookie' in updates && !updates.opencodeSessionCookie) { + operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeSessionCookie) + } + if ('httpProxyUrl' in updates && !updates.httpProxyUrl) { + operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) + } + if ( + !Object.is( + previousSettings.localWindowsRuntimeDefault, + operations.state.settings.localWindowsRuntimeDefault + ) + ) { + for (const repoId of new Set(operations.state.repos.map(({ id }) => id))) { + operations.bumpLocalWorktreeScanGeneration(repoId) + } + } + operations.scheduleSave() + const previousEntries = new Map(Object.entries(previousSettings)) + const currentEntries = new Map(Object.entries(operations.state.settings)) + const changedUpdates = Object.fromEntries( + Object.keys(sanitizedUpdates) + .filter((key) => !Object.is(previousEntries.get(key), currentEntries.get(key))) + .map((key) => [key, currentEntries.get(key)]) + ) + createAgentModelCatalogSettingsExpiry(agentModelCatalogStore, previousSettings)( + changedUpdates, + operations.state.settings + ) + retireAtRestCommandSettings(Object.keys(changedUpdates)) + if (options.notifyListeners === true && Object.keys(changedUpdates).length > 0) { + operations.notifySettingsChanged(changedUpdates, options.originWebContentsId) + } +} diff --git a/src/main/persistence/applying-settings/settings-update-normalization.test.ts b/src/main/persistence/applying-settings/settings-update-normalization.test.ts index bf4e2dc88b1..e2592493fb5 100644 --- a/src/main/persistence/applying-settings/settings-update-normalization.test.ts +++ b/src/main/persistence/applying-settings/settings-update-normalization.test.ts @@ -3,6 +3,7 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' import type { PersistedState } from '../../../shared/persisted-state-types' import { MACHINE_NAME_MAX_LENGTH } from '../../../shared/machine-name' import { updateSettings, type SettingsMutationOperations } from './settings-update' +import { agentModelCatalogStore } from '../../native-chat/agent-model-catalog/agent-model-catalog-store' function settingsOperations(settings: Partial): SettingsMutationOperations { return { @@ -15,6 +16,25 @@ function settingsOperations(settings: Partial): SettingsMutation } } +it('retires only changed launch owners before notifying, including headless writes without listeners', () => { + const expire = vi.spyOn(agentModelCatalogStore, 'expireAgent') + const shell = vi.spyOn(agentModelCatalogStore, 'expireLaunchScopes') + try { + const operations = settingsOperations({ agentCmdOverrides: { pi: 'pi' } }) + updateSettings(operations, { machineName: 'host' }) + expect(expire).not.toHaveBeenCalled() + expect(shell).not.toHaveBeenCalled() + updateSettings(operations, { agentCmdOverrides: { pi: 'pi-new' } }) + expect(expire).toHaveBeenCalledExactlyOnceWith('pi') + expect(operations.notifySettingsChanged).not.toHaveBeenCalled() + updateSettings(operations, { nativeChatInheritShellEnvironment: true }) + expect(shell).toHaveBeenCalledOnce() + } finally { + expire.mockRestore() + shell.mockRestore() + } +}) + describe('updateSettings machineName', () => { it('stores the trimmed, bounded form of a written name', () => { const operations = settingsOperations({ machineName: '' }) diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts index 0d2187d2b76..5772cb15810 100644 --- a/src/main/persistence/applying-settings/settings-update.ts +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -1,7 +1,7 @@ +import { publishSettingsUpdate } from './settings-update-effects' import { legacyChatPermissionWrite } from './legacy-chat-permission-write' import { normalizeNativeChatAppearanceSettings } from '../../../shared/native-chat/native-chat-appearance-settings' import type { GlobalSettings } from '../../../shared/global-settings-types' -import { retireAtRestCommandSettings } from '../../native-chat/agent-session-wire/at-rest-command-context' import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' import { normalizeNativeChatProviderChoices } from '../../../shared/native-chat/native-chat-providers' import { resolveNestedWorkerMaxDepth } from '../../../shared/nested-worker-depth' @@ -35,10 +35,7 @@ import { normalizeSourceControlAiSettings, projectSourceControlAiToLegacyCommitMessageAi } from '../../../shared/source-control-ai' -import { - PROTECTED_SECRET_SLOT, - type ProtectedSecretPersistence -} from '../../protected-secret-persistence' +import type { ProtectedSecretPersistence } from '../../protected-secret-persistence' import { normalizeNotificationSettings } from './onboarding-normalization' import { retireLegacyInstructionsForClearedTextActionRecipes } from './source-control-settings' import { @@ -275,32 +272,6 @@ export function updateSettings( }), ...(mergedTelemetry !== undefined ? { telemetry: mergedTelemetry } : {}) } - if ('opencodeSessionCookie' in updates && !updates.opencodeSessionCookie) { - operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeSessionCookie) - } - if ('httpProxyUrl' in updates && !updates.httpProxyUrl) { - operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) - } - if ( - !Object.is( - previousSettings.localWindowsRuntimeDefault, - operations.state.settings.localWindowsRuntimeDefault - ) - ) { - for (const repoId of new Set(operations.state.repos.map(({ id }) => id))) { - operations.bumpLocalWorktreeScanGeneration(repoId) - } - } - operations.scheduleSave() - const changedUpdates = {} as Partial & Record - for (const key of Object.keys(sanitizedUpdates) as (keyof GlobalSettings)[]) { - if (!Object.is(previousSettings[key], operations.state.settings[key])) { - changedUpdates[String(key)] = operations.state.settings[key] - } - } - retireAtRestCommandSettings(Object.keys(changedUpdates)) - if (options.notifyListeners === true && Object.keys(changedUpdates).length > 0) { - operations.notifySettingsChanged(changedUpdates, options.originWebContentsId) - } + publishSettingsUpdate(operations, updates, sanitizedUpdates, previousSettings, options) return operations.state.settings } diff --git a/src/main/persistence/loading-store/durable-settings-write.spec.ts b/src/main/persistence/loading-store/durable-settings-write.spec.ts index ab48419cd7c..7730af729ec 100644 --- a/src/main/persistence/loading-store/durable-settings-write.spec.ts +++ b/src/main/persistence/loading-store/durable-settings-write.spec.ts @@ -1,10 +1,59 @@ +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { expect, it, vi } from 'vitest' +import { agentModelCatalogStore } from '../../native-chat/agent-model-catalog/agent-model-catalog-store' +import { createAgentModelCatalogService } from '../../native-chat/agent-model-catalog/agent-model-catalog-service' +import { agentSessionRecordFixture } from '../../native-chat/agent-session-record-test-fixture' import { createWorkerMaintenanceFixture, maintenanceBarrier } from './profile-state-maintenance-fixture' export function registerDurableSettingsWriteTests(): void { + it('revokes reports captured under a transient command when the durable write rolls back', async () => { + const { store, authority } = await createWorkerMaintenanceFixture() + const record = agentSessionRecordFixture({ + sessionId: 'session-settings-rollback', + provider: 'grok', + accountHome: { variable: 'GROK_HOME', path: '/settings-rollback/grok' } + }) + const catalog = createAgentModelCatalogService({ + store: agentModelCatalogStore, + getConversation: () => { + const held = record + return held ? agentSessionConversationView(held) : undefined + }, + getRecord: () => record, + drivesRecord: () => true, + resolveAccountHome: async () => record.accountHome, + probes: {} + }) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async () => { + started.resolve() + await release.promise + throw new Error('Disk full') + }) + const original = catalog.captureSource(record) + const previous = store.getSettings().agentCmdOverrides + try { + const pending = store.updateSettingsAndFlush({ agentCmdOverrides: { grok: 'grok-other' } }) + const rejected = expect(pending).rejects.toThrow('Disk full') + await started.promise + expect(original()).toBe(false) + const transient = catalog.captureSource(record) + expect(transient()).toBe(true) + release.resolve() + await rejected + expect(store.getSettings().agentCmdOverrides).toEqual(previous) + expect(transient()).toBe(false) + expect(catalog.captureSource(record)()).toBe(true) + } finally { + release.resolve() + catalog.stop() + } + }) + it('waits for the SQLite write before returning or publishing the preference', async () => { const { store, authority, readState } = await createWorkerMaintenanceFixture() const started = maintenanceBarrier() diff --git a/src/main/persistence/loading-store/profile-preferences.ts b/src/main/persistence/loading-store/profile-preferences.ts index f15b13cd7c1..6925b76c34f 100644 --- a/src/main/persistence/loading-store/profile-preferences.ts +++ b/src/main/persistence/loading-store/profile-preferences.ts @@ -1,5 +1,7 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' import { retireAtRestCommandSettings } from '../../native-chat/agent-session-wire/at-rest-command-context' +import { createAgentModelCatalogSettingsExpiry } from '../../native-chat/agent-model-catalog/agent-model-catalog-account-expiry' +import { agentModelCatalogStore } from '../../native-chat/agent-model-catalog/agent-model-catalog-store' import type { OnboardingChecklistState } from '../../../shared/onboarding-state-types' import type { PersistedState } from '../../../shared/persisted-state-types' import { getDefaultOnboardingState } from '../../../shared/onboarding-defaults' @@ -103,7 +105,7 @@ export class ProfilePreferences { rollback: () => { const currentEntries = new Map(Object.entries(runtime.state.settings)) const nextEntries = new Map(Object.entries(next)) - const restoredUpdates = Object.fromEntries( + const restoredUpdates: Record = Object.fromEntries( Object.entries(previous).filter( ([key]) => updateKeys.has(key) && Object.is(currentEntries.get(key), nextEntries.get(key)) @@ -116,8 +118,13 @@ export class ProfilePreferences { Object.is(currentEntries.get(key), nextEntries.get(key)) ) { Reflect.deleteProperty(restoredSettings, key) + restoredUpdates[key] = undefined } } + createAgentModelCatalogSettingsExpiry(agentModelCatalogStore, runtime.state.settings)( + restoredUpdates, + restoredSettings + ) runtime.state.settings = restoredSettings retireAtRestCommandSettings(updateKeys) } diff --git a/src/main/pi/rpc-idle-check.ts b/src/main/pi/rpc-idle-check.ts index 5ffe453ac84..b8dd036c8b5 100644 --- a/src/main/pi/rpc-idle-check.ts +++ b/src/main/pi/rpc-idle-check.ts @@ -2,6 +2,7 @@ import { piRpcIdle, piRpcStateSchema, type PiRpcState } from './rpc-protocol' type IdleCheckDeps = { request: () => Promise + prepareObservation?: () => (state: PiRpcState) => void current: (revision: number) => boolean settled: (state: PiRpcState) => void failed: (error: Error) => void @@ -18,6 +19,7 @@ export class PiRpcIdleCheck { if (this.ended || !this.deps.current(revision)) { return } + const observe = this.deps.prepareObservation?.() void this.deps .request() .then((value) => { @@ -25,6 +27,7 @@ export class PiRpcIdleCheck { return } const state = piRpcStateSchema.parse(value) + observe?.(state) if (piRpcIdle(state)) { this.deps.settled(state) } diff --git a/src/main/pi/rpc-launch-resolution.ts b/src/main/pi/rpc-launch-resolution.ts index c47a57d535e..e63c8f956b5 100644 --- a/src/main/pi/rpc-launch-resolution.ts +++ b/src/main/pi/rpc-launch-resolution.ts @@ -18,6 +18,8 @@ import { import { AgentCliInstallationError } from '../native-chat/agent-cli-installation-error' import { resolveAgainAfterRefreshOnMissingProgram, + assertStructuredAgentResolutionCurrent, + type StructuredAgentResolutionOperation, resolveStructuredAgentCommand, type StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' @@ -111,6 +113,7 @@ export async function samePiDirectory(left: string, right: string): Promise Promise commandSettings?: StructuredAgentCommandSettings @@ -127,11 +130,13 @@ export function resolvePiRpcInvocation(deps: { env[key] = value } } + assertStructuredAgentResolutionCurrent(deps.operation) env.PI_CODING_AGENT_DIR = deps.accountDir return { env, command: resolvePiRpcCommand(env, deps.commandSettings, deps.resolveCommand) } }, deps.refreshEnvironmentAfterMiss, - { env: ({ env }) => env, cwd: deps.resolveWorkingDirectory } + { env: ({ env }) => env, cwd: deps.resolveWorkingDirectory }, + deps.operation ) } diff --git a/src/main/pi/rpc-message-dispatch.ts b/src/main/pi/rpc-message-dispatch.ts index a09da025f9e..7d6d01dbe46 100644 --- a/src/main/pi/rpc-message-dispatch.ts +++ b/src/main/pi/rpc-message-dispatch.ts @@ -57,23 +57,12 @@ export async function dispatchPiRpcMessage( return exited() } input.onInputPreparation?.() - const model = input.executionOptions?.model ?? session.initialOptions.model if (!input.executionOptions) { input.signal?.throwIfAborted() return submitPiRpcPrompt(session, input, prompt) } for (const key of ['model', 'effort'] as const) { - const value = - input.executionOptions?.[key] ?? - (key === 'model' || model === session.initialOptions.model - ? session.initialOptions[key] - : undefined) - const applied = session.selected.get(key) - if (value === undefined && applied !== undefined && applied !== session.initialOptions[key]) { - throw new AgentSessionOptionRejectedError( - new Error(`The default ${key} for this model is unknown`) - ) - } + const value = input.executionOptions[key] if (value === undefined || session.optionProof.current.get(key) === value) { continue } diff --git a/src/main/pi/rpc-model-catalog-probe.ts b/src/main/pi/rpc-model-catalog-probe.ts index f44eb7330ba..1b1cb163d95 100644 --- a/src/main/pi/rpc-model-catalog-probe.ts +++ b/src/main/pi/rpc-model-catalog-probe.ts @@ -1,3 +1,4 @@ +import { assertStructuredAgentResolutionCurrent } from '../native-chat/structured-agent-command-resolution' import { homedir } from 'node:os' import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' import { parsePiModels } from '../../shared/commit-message-model-parsers' @@ -38,8 +39,11 @@ export function piModelCatalogFromListing(stdout: string): AgentModelCatalogSucc export function createPiModelCatalogProbe(deps: PiModelCatalogProbeDeps): AgentModelCatalogProbe { return async (accountHome, options): Promise => { + const operation = options?.operation ?? { signal: options?.signal } + assertStructuredAgentResolutionCurrent(operation) const cwd = deps.homePath ?? homedir() const { env, command } = await resolvePiRpcInvocation({ + operation, accountDir: requireLegacyAgentSessionAccountHome(accountHome).path, resolveEnvironment: deps.resolveEnvironment, commandSettings: deps.resolveCommandSettings?.(), @@ -47,7 +51,10 @@ export function createPiModelCatalogProbe(deps: PiModelCatalogProbeDeps): AgentM refreshEnvironmentAfterMiss: deps.refreshEnvironmentAfterMiss, resolveWorkingDirectory: () => cwd }) - if ((await checkPiRpcVersion({ command, cwd, env }, deps.probeVersion)).status === 'tooOld') { + assertStructuredAgentResolutionCurrent(operation) + const release = await checkPiRpcVersion({ command, cwd, env }, deps.probeVersion) + assertStructuredAgentResolutionCurrent(operation) + if (release.status === 'tooOld') { throw new Error(`pi at ${command} does not run structured chats`) } const launch = buildPiRpcLaunch({ diff --git a/src/main/pi/rpc-options.ts b/src/main/pi/rpc-options.ts index 8f91dc3b0c0..8d97f2ae2dd 100644 --- a/src/main/pi/rpc-options.ts +++ b/src/main/pi/rpc-options.ts @@ -55,12 +55,12 @@ export function parsePiModelOptionId(value: string): { provider: string; modelId } export async function readPiRpcSessionOptions( - rpc: PiRpcRequester + rpc: PiRpcRequester, + beforeStateRead?: () => void ): Promise { const models = modelsSchema.parse(await rpc.request('get_available_models')).models + beforeStateRead?.() const state = stateSchema.parse(await rpc.request('get_state')) - const currentModel = state.model - const currentId = currentModel ? piModelOptionId(currentModel.provider, currentModel.id) : '' const listed = models.map((model) => { const id = piModelOptionId(model.provider, model.id) const efforts = @@ -81,15 +81,84 @@ export async function readPiRpcSessionOptions( }) return { models: listed, - current: { - model: currentId, - ...(state.thinkingLevel ? { effort: state.thinkingLevel } : {}), - confirmed: [...(currentId ? ['model'] : []), ...(state.thinkingLevel ? ['effort'] : [])] - } + current: piRpcStateOptions(state) } } -export type PiRpcOptionProof = { current: Map; writes: Map } +export function piRpcStateOptions(state: { + model?: { provider: string; id: string } | null + thinkingLevel?: string +}): AgentSessionOptionsResult['current'] { + const model = state.model ? piModelOptionId(state.model.provider, state.model.id) : undefined + return { + model: model ?? '', + ...(state.thinkingLevel ? { effort: state.thinkingLevel } : {}), + confirmed: [...(model ? ['model'] : []), ...(state.thinkingLevel ? ['effort'] : [])] + } +} + +export type PiRpcOptionWrite = { pending: boolean } +export type PiRpcOptionProof = { + current: Map + writes: Map +} + +/** Held reports belong to one child; a report cannot cross its native write lifetime. */ +export class PiRpcOptionObservations { + private observed: AgentSessionOptionsResult = { models: [], current: {} } + private writes = new Map() + constructor( + private readonly proof: PiRpcOptionProof, + private readonly current: () => boolean + ) {} + + seed(state: Parameters[0]): void { + this.observed = { models: [], current: piRpcStateOptions(state) } + } + + prepare(): (options: AgentSessionOptionsResult) => boolean { + const writes = new Map(this.proof.writes) + return (options) => { + if (!this.current()) { + return false + } + if ( + [...this.proof.writes].some(([key, token]) => token.pending || writes.get(key) !== token) + ) { + this.observed = { ...this.observed, models: options.models } + return false + } + this.observed = options + this.writes = writes + return true + } + } + + prepareState(report: (current: AgentSessionOptionsResult['current']) => void) { + const observe = this.prepare() + return (state: Parameters[0]) => { + const current = piRpcStateOptions(state) + if (observe({ ...this.observed, current })) { + report(current) + } + } + } + + read(): AgentSessionOptionsResult { + const current = { ...this.observed.current } + const confirmed = new Set(current.confirmed) + for (const key of ['model', 'effort'] as const) { + if ( + this.proof.writes.get(key)?.pending || + this.proof.writes.get(key) !== this.writes.get(key) + ) { + current[key] = this.proof.current.get(key) + confirmed.delete(key) + } + } + return { ...this.observed, current: { ...current, confirmed: [...confirmed] } } + } +} export async function applyPiRpcSessionOption( rpc: PiRpcRequester, @@ -116,23 +185,32 @@ export async function applyPiRpcSessionOption( throw new Error(`Unsupported Pi option: ${key}`) } options.signal?.throwIfAborted() - const token = Symbol(key) + const token = { pending: true } proof?.writes.set(key, token) proof?.current.delete(key) if (key === 'model') { proof?.writes.set('effort', token) proof?.current.delete('effort') } - await waitForPromiseWithSignal( - rpc.request(command, params, { timeoutMs: options.timeoutMs }), - options.signal - ) - if (proof && proof.writes.get(key) !== token) { - throw new Error('Pi option write was superseded') + try { + await waitForPromiseWithSignal( + rpc.request(command, params, { timeoutMs: options.timeoutMs }), + options.signal + ) + if (proof && proof.writes.get(key) !== token) { + throw new Error('Pi option write was superseded') + } + selected.set(key, value) + proof?.current.set(key, value) + return Object.fromEntries(selected) + } finally { + const settled = { pending: false } + for (const [affected, write] of proof?.writes ?? []) { + if (write === token) { + proof?.writes.set(affected, settled) + } + } } - selected.set(key, value) - proof?.current.set(key, value) - return Object.fromEntries(selected) } export async function readPiRpcCommands(rpc: PiRpcRequester): Promise { diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index fb09e4d7a14..913516b51da 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -168,7 +168,7 @@ describe('Pi RPC session ownership and delivery', () => { clientMessageId: 'send-1', body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } }) - expect(result).toEqual({ state: 'admitted', ranOptions: { model: 'anthropic/model-1' } }) + expect(result).toEqual({ state: 'admitted', ranOptions: {} }) expect(h.settled).not.toHaveBeenCalled() h.connection.receive({ type: 'agent_start' }) expect(h.settled).toHaveBeenCalledWith({ @@ -222,6 +222,30 @@ describe('Pi RPC session ownership and delivery', () => { expect(h.connection.sent).toMatchObject([{ type: 'prompt', message: 'chosen' }]) }) + it('keeps sparse messages sparse and reapplies effort after a model control', async () => { + const h = await setup() + h.connection.requestOverride = (command) => + command === 'set_thinking_level' ? Promise.resolve({}) : undefined + const send = (id: string, executionOptions: Record) => + h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: id, + executionOptions, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] } + }) + await send('B', { model: 'anthropic/unlisted', effort: 'high' }) + h.connection.requests.length = 0 + await send('A', { model: 'anthropic/model-1' }) + expect(h.connection.requests.filter((key) => key.startsWith('set_'))).toEqual(['set_model']) + h.connection.requests.length = 0 + await send('empty', {}) + expect(h.connection.requests.filter((key) => key.startsWith('set_'))).toEqual([]) + await send('explicit', { effort: 'high' }) + expect(h.connection.requests).toContain('set_thinking_level') + expect(h.connection.sent).toHaveLength(4) + }) + it('sends model-only choices away from the opening model, back, and away again', async () => { const h = await setup({}, undefined, { thinkingLevel: 'medium' }) h.connection.requestOverride = (command) => @@ -245,11 +269,10 @@ describe('Pi RPC session ownership and delivery', () => { }) ).resolves.toMatchObject({ state: 'admitted', ranOptions: { model } }) } - // Back on the opening model, its opening effort is restored; elsewhere Pi keeps its own. + // Model-only sends inherit native effort on each model. expect(h.connection.requests.filter((command) => command.startsWith('set_'))).toEqual([ 'set_model', 'set_model', - 'set_thinking_level', 'set_model' ]) expect(h.connection.sent).toMatchObject([ diff --git a/src/main/pi/rpc-session-startup.test.ts b/src/main/pi/rpc-session-startup.test.ts index 6be7a232f95..fb7dd91f34c 100644 --- a/src/main/pi/rpc-session-startup.test.ts +++ b/src/main/pi/rpc-session-startup.test.ts @@ -154,3 +154,85 @@ describe('Pi host awaited open', () => { ) }) }) + +it('keeps native idle reports off current and action progress and rejects pre-control state', async () => { + const h = await setup() + await vi.waitFor(() => expect(h.adapter.readCommands(sessionId)).toBeDefined()) + const held = Promise.withResolvers() + const entered = vi.fn() + h.connection.requestOverride = (command) => { + if (command === 'get_state') { + entered() + return held.promise + } + return undefined + } + try { + const send = (id: string) => + h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: id, + executionOptions: {}, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] } + }) + await send('first') + h.connection.handlers.onRecord?.({ type: 'agent_start' }) + h.connection.handlers.onRecord?.({ type: 'agent_settled' }) + await vi.waitFor(() => expect(entered).toHaveBeenCalledOnce()) + expect((await h.adapter.readOptions({ sessionId, fence: 7 })).current.model).toBe( + 'anthropic/model-1' + ) + await h.adapter.setOption({ sessionId, fence: 7, key: 'model', value: 'anthropic/model-2' }) + expect((await h.adapter.cancelTurn({ sessionId, fence: 7 })).cancelled).toBe(true) + held.resolve({ ...state, thinkingLevel: 'high' }) + await Promise.resolve() + await Promise.resolve() + expect((await h.adapter.readOptions({ sessionId, fence: 7 })).current.model).toBe( + 'anthropic/model-2' + ) + h.connection.requestOverride = (command) => + command === 'get_state' + ? Promise.resolve({ + ...state, + model: { provider: 'anthropic', id: 'native-3' }, + thinkingLevel: 'low' + }) + : undefined + await send('later') + h.connection.handlers.onRecord?.({ type: 'agent_start' }) + h.connection.handlers.onRecord?.({ type: 'agent_settled' }) + await vi.waitFor(async () => + expect((await h.adapter.readOptions({ sessionId, fence: 7 })).current).toMatchObject({ + model: 'anthropic/native-3', + effort: 'low' + }) + ) + await send('during-control') + h.connection.handlers.onRecord?.({ type: 'agent_start' }) + const control = Promise.withResolvers() + h.connection.requestOverride = (command) => + command === 'set_model' + ? control.promise + : command === 'get_state' + ? Promise.resolve(state) + : undefined + const writing = h.adapter.setOption({ + sessionId, + fence: 7, + key: 'model', + value: 'anthropic/model-4' + }) + h.connection.handlers.onRecord?.({ type: 'agent_settled' }) + await vi.waitFor(async () => + expect((await h.adapter.readOptions({ sessionId, fence: 7 })).current.model).toBeUndefined() + ) + control.resolve({}) + await writing + const after = await h.adapter.readOptions({ sessionId, fence: 7 }) + expect(after.current.model).toBe('anthropic/model-4') + expect(after.current.confirmed).not.toContain('model') + } finally { + held.resolve(state) + } +}) diff --git a/src/main/pi/rpc-session-startup.ts b/src/main/pi/rpc-session-startup.ts index 64d1262dd35..fa79badf85c 100644 --- a/src/main/pi/rpc-session-startup.ts +++ b/src/main/pi/rpc-session-startup.ts @@ -34,7 +34,8 @@ export class PiRpcSessionStartup { /** A new Pi session starts on its config's model; a resumed or forked one keeps its own. */ private readonly resolvesConfig: boolean, private readonly setState: (state: PiRpcState) => void, - private readonly failedCause: () => Error | undefined + private readonly failedCause: () => Error | undefined, + private readonly prepareObservation: () => (options: AgentSessionOptionsResult) => boolean ) {} async run( @@ -87,14 +88,25 @@ export class PiRpcSessionStartup { return { link: claimed, reportedOptions: current } } - /** Listing/command failures after open leave optional metadata unavailable. */ - async readAfterOpen( + reportAfterOpen(generation: string, opening: AgentSessionOptionsResult['current']): void { + void this.readAfterOpen(generation, opening).catch((error: unknown) => + this.warn('Pi startup reports failed', 'pi-startup-reports', error) + ) + } + + /** After the open: the listing and the `/` commands reach the host as later reports, and a read + * that fails only leaves the chat without them. */ + private async readAfterOpen( generation: string, opening: AgentSessionOptionsResult['current'] ): Promise { - const options = await this.readListing() + let observe: ((options: AgentSessionOptionsResult) => boolean) | undefined + const options = await this.readListing(() => { + observe = this.prepareObservation() + }) if (options && !this.failedCause() && !this.connection.closed) { - // Only the opening model establishes the configured default; later switches do not. + const adopted = observe?.(options) === true + // What it started on names its config's default once; a later switch teaches nothing. const { catalogListing } = withLiveCatalogListing( options, unpickedSessionConfiguredChoice({ @@ -104,21 +116,31 @@ export class PiRpcSessionStartup { current: opening }) ) - this.deps.onLifecycle({ - type: 'options-reported', - sessionId: this.input.identity.sessionId, - fence: this.input.fence, - acquisitionGeneration: generation, - reportedOptions: options.current, - ...(catalogListing ? { catalogListing } : {}) - }) + this.reportOptions(generation, adopted ? options.current : {}, catalogListing) } await this.readCommands() } - private async readListing(): Promise { + reportOptions( + generation: string, + reportedOptions: AgentSessionOptionsResult['current'], + catalogListing?: ReturnType['catalogListing'] + ): void { + this.deps.onLifecycle({ + type: 'options-reported', + sessionId: this.input.identity.sessionId, + fence: this.input.fence, + acquisitionGeneration: generation, + reportedOptions, + ...(catalogListing ? { catalogListing } : {}) + }) + } + + private async readListing( + beforeStateRead: () => void + ): Promise { try { - return await readPiRpcSessionOptions(this.connection) + return await readPiRpcSessionOptions(this.connection, beforeStateRead) } catch (error) { this.warn('Pi options could not be read', 'pi-options', error) return undefined diff --git a/src/main/pi/rpc-session.ts b/src/main/pi/rpc-session.ts index b3c737205cc..63d2327005d 100644 --- a/src/main/pi/rpc-session.ts +++ b/src/main/pi/rpc-session.ts @@ -19,8 +19,8 @@ import type { ProviderProcessLaunch } from '../provider-process/provider-process import { JsonlRpcResponseError } from '../jsonl-rpc/peer' import { PiRpcTurns } from './rpc-turns' import { PiRpcDialogCallbacks } from './rpc-dialog-callbacks' -import { readPiRpcSessionOptions } from './rpc-options' -import { withLiveCatalogListing } from '../native-chat/agent-model-catalog/agent-model-catalog-entry' +import { PiRpcOptionObservations, type PiRpcOptionWrite } from './rpc-options' +import type { AgentSessionOptionsResult } from '../../shared/agent-session-wire' import type { PiRpcResolvedLaunch } from './rpc-launch-resolution' import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' @@ -64,10 +64,16 @@ export class PiRpcSession { readonly selected = new Map() /** Hold sends from handover through settlement. */ readonly heldSends = new Set() - readonly optionProof = { current: new Map(), writes: new Map() } + readonly optionProof = { + current: new Map(), + writes: new Map() + } + private optionObservations = new PiRpcOptionObservations( + this.optionProof, + () => !this.requestedClose && !this.failedCause && !this.connection.closed + ) readonly startup: PiRpcSessionStartup readonly history = PiHistoryCapture.forSession(this, () => this.deps) - initialOptions: Readonly> = {} openedLink: AgentSessionProviderHandleLink | null = null requestedClose = false private publishedExit = false @@ -108,6 +114,10 @@ export class PiRpcSession { generation, send: (frame) => this.connection.send(frame), request: (command, params, options) => this.connection.request(command, params, options), + prepareStateObservation: () => + this.optionObservations.prepareState((current) => + this.startup.reportOptions(generation, current) + ), settled: (id, outcome) => this.settle(id, outcome), idle: () => this.dialogs.cancelAll(), turnEnded: () => this.history.turnEnded(), @@ -152,8 +162,12 @@ export class PiRpcSession { this.connection, deps, resolvesConfig, - (state) => this.lane.apply(this.turns.context.setModel(state.model ?? undefined, Date.now())), - () => this.failedCause + (state) => { + this.lane.apply(this.turns.context.setModel(state.model ?? undefined, Date.now())) + this.optionObservations.seed(state) + }, + () => this.failedCause, + () => this.optionObservations.prepare() ) } @@ -173,28 +187,15 @@ export class PiRpcSession { async open(signal: AbortSignal): Promise { const result = await this.startup.run(this.resolvedLaunch, signal) - this.initialOptions = { - ...(result.reportedOptions.model ? { model: result.reportedOptions.model } : {}), - ...(result.reportedOptions.effort ? { effort: result.reportedOptions.effort } : {}) - } this.openedLink = result.link ?? null this.history.start(this.startup.historyAnchor) - this.optionProof.current = new Map(Object.entries(this.initialOptions)) - void this.startup - .readAfterOpen(this.generation, result.reportedOptions) - .catch((error: unknown) => - this.deps.logger.warn('Pi startup reports failed', { - scope: 'pi-startup-reports', - sessionId: this.input.identity.sessionId, - error - }) - ) + this.startup.reportAfterOpen(this.generation, result.reportedOptions) return result } - /** Current choices cannot establish the configured default; only startup can. */ - async readOptions() { - return withLiveCatalogListing(await readPiRpcSessionOptions(this.connection)) + /** A read projects this child's held observations and successful controls. */ + async readOptions(): Promise { + return this.optionObservations.read() } async close(requested = true): ReturnType { diff --git a/src/main/pi/rpc-turns.ts b/src/main/pi/rpc-turns.ts index 42f9d4ba24e..14e8b484033 100644 --- a/src/main/pi/rpc-turns.ts +++ b/src/main/pi/rpc-turns.ts @@ -12,7 +12,7 @@ import { PiRpcMessages, piRpcSkippedEventNotice } from './rpc-messages' import { PiRpcPromptDelivery, piRpcFailureFact } from './rpc-prompt-delivery' import { PiRpcIdleCheck } from './rpc-idle-check' import { piRpcRetryActivity } from './rpc-retry-activity' -import { piRpcMessageSchema, piRpcSkippedEvent } from './rpc-protocol' +import { type PiRpcState, piRpcMessageSchema, piRpcSkippedEvent } from './rpc-protocol' export type PiRpcTurnDeps = { lane: JsonlRpcTimelineLane @@ -29,6 +29,7 @@ export type PiRpcTurnDeps = { turnEnded?: () => void failed: (error: Error) => void diagnostic?: (error: unknown) => void + prepareStateObservation?: () => (state: PiRpcState) => void } /** Prompt replies can be idless and arrive after dialogs; they have no elapsed-time deadline. */ @@ -51,6 +52,7 @@ export class PiRpcTurns { this.messages = new PiRpcMessages(deps.generation, this.context) this.idleCheck = new PiRpcIdleCheck({ request: () => deps.request('get_state'), + prepareObservation: deps.prepareStateObservation, current: (revision) => !this.ended && this.settling && !this.compacting && revision === this.revision, settled: (state) => { @@ -70,8 +72,7 @@ export class PiRpcTurns { }, commandOnly: () => { this.awaitingRunTrigger = false - this.settling = true - this.probe() + this.commandCompleted() }, rejectedAfterAcceptance: (error) => { this.failure = error @@ -165,8 +166,7 @@ export class PiRpcTurns { // This can precede automatic retry or detached compaction. break case 'agent_settled': - this.settling = true - this.probe() + this.commandCompleted() return case 'compaction_start': case 'auto_compaction_start': diff --git a/src/main/preflight/workspace-agent-detection.ts b/src/main/preflight/workspace-agent-detection.ts index 9bd219b1097..73a0b14d36e 100644 --- a/src/main/preflight/workspace-agent-detection.ts +++ b/src/main/preflight/workspace-agent-detection.ts @@ -158,12 +158,11 @@ export async function refreshAgentsOnHost( } const wsl = getPreflightWslTarget(host.context) const refresh = async (): Promise => { - const result = await (options - ? refreshShellPathAndDetectAgents(host.context, options) - : refreshShellPathAndDetectAgents(host.context)) - if (!wsl) { - await captureHostShellEnvironment() - } + const detect = () => + options + ? refreshShellPathAndDetectAgents(host.context, options) + : refreshShellPathAndDetectAgents(host.context) + const result = wsl ? await detect() : await captureHostShellEnvironment(detect) agentRefreshListeners.forEach((listener) => listener()) return result } diff --git a/src/main/pty/windows-environment-path.ts b/src/main/pty/windows-environment-path.ts index c4aebb3c968..7a2ae52444a 100644 --- a/src/main/pty/windows-environment-path.ts +++ b/src/main/pty/windows-environment-path.ts @@ -304,6 +304,11 @@ export function invalidatePersistedWindowsPathCache(): void { pendingPersistedWindowsPathRefresh = undefined } +/** The last committed registry PATH; reading it never starts or waits for a refresh. */ +export function cachedPersistedWindowsPathSegments(): string[] { + return persistedWindowsPathCache ? [...persistedWindowsPathCache.segments] : [] +} + export function mergePersistedWindowsPath( env: NodeJS.ProcessEnv, options: ReadWindowsPathOptions = {} diff --git a/src/main/runtime/agent-session-record-options.ts b/src/main/runtime/agent-session-record-options.ts index fbcc973ff46..c320d2514e6 100644 --- a/src/main/runtime/agent-session-record-options.ts +++ b/src/main/runtime/agent-session-record-options.ts @@ -1,12 +1,9 @@ -import { independentAgentModelCatalog } from '../../shared/agent-model-catalog-ownership' -import { agentSessionEffortLevels } from '../../shared/agent-session-effort-levels' import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionOptionsReplacement, AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionModelOption } from '../../shared/agent-session-wire' -import { nearestAgentEffort } from './session-tabs/structured-launch-seed-listing' export function replaceAgentSessionRecordOptions( record: AgentSessionRecord, @@ -26,76 +23,6 @@ export function replaceAgentSessionRecordOptions( /** Saved catalog only; missing model evidence permits options without starting discovery. */ export type AgentSessionAdmissionModels = readonly AgentSessionModelOption[] | undefined -/** Check listed effort/Fast support under this model; Claude needs positive Fast support, and saved tiers retain effective-tier rules. */ -export function agentSessionOptionAdmitted( - provider: string, - models: AgentSessionAdmissionModels, - model: string | undefined, - key: string, - value: string -): boolean { - const row = models?.find((entry) => entry.id === model) - if (!row) { - return true - } - if (key === 'effort') { - return agentSessionEffortLevels(row)?.has(value) ?? true - } - return ( - key !== 'fastMode' || - value !== 'true' || - (provider === 'claude' ? row.supportsFastMode === true : row.supportsFastMode !== false) - ) -} - -/** Fit a remembered seed to its model's saved row: an effort the row lacks moves to the nearest listed - * level, other unlisted values drop; keys the caller named this launch keep strict admission. */ -export function seedOptionsAdmittedOnSavedListing( - provider: string, - seed: Readonly>, - models: AgentSessionAdmissionModels, - explicit: ReadonlySet -): Record | undefined { - const admitted: Record = {} - for (const [key, value] of Object.entries(seed)) { - if (explicit.has(key) || agentSessionOptionAdmitted(provider, models, seed.model, key, value)) { - admitted[key] = value - continue - } - const row = models?.find((entry) => entry.id === seed.model) - const nearest = - key === 'effort' && row - ? nearestAgentEffort( - value, - row.efforts.map((choice) => choice.value) - ) - : undefined - if (nearest) { - admitted[key] = nearest - } - } - return Object.keys(admitted).length > 0 ? admitted : undefined -} - -export function assertAgentSessionOptionsAdmitted( - provider: string, - models: AgentSessionAdmissionModels, - options: Readonly> | undefined -): void { - const [key, value] = - Object.entries(options ?? {}).find( - ([name, kept]) => !agentSessionOptionAdmitted(provider, models, options?.model, name, kept) - ) ?? [] - if (key) { - throw agentSessionRefusalError( - 'agent_session_operation_invalid', - { reason: 'optionRejected' }, - `${provider} model ${options?.model} does not offer ${key} ${value}` - ) - } -} - -/** Reconcile against the record held at write time; retain effort/Fast only if the new model supports it. */ export type AgentSessionOptionPick = { sessionId: string fence: number @@ -107,27 +34,12 @@ export type AgentSessionOptionPick = { export function pickAgentSessionOption( record: AgentSessionRecord, - { key, value, models, ...at }: AgentSessionOptionPick + { key, value, models: _models, ...at }: AgentSessionOptionPick ): AgentSessionRecord { - // A live child's own menu may offer more than its saved row (Pi's probe stops at high). - if ( - key !== 'model' && - (independentAgentModelCatalog(record.provider) || record.lease.claimStatus !== 'live') - ) { - const { model } = record.options ?? {} - assertAgentSessionOptionsAdmitted(record.provider, models, { - ...(model ? { model } : {}), - [key]: value - }) - } - const picked = { ...record.options, [key]: value } - const options = Object.fromEntries( - Object.entries(picked).filter( - ([name, kept]) => - key !== 'model' || agentSessionOptionAdmitted(record.provider, models, value, name, kept) - ) - ) - return replaceAgentSessionRecordOptions(record, { ...at, options }) + return replaceAgentSessionRecordOptions(record, { + ...at, + options: { ...record.options, [key]: value } + }) } /** Fill only missing options for the still-current model; explicit picks always win. */ @@ -142,19 +54,14 @@ export type AgentSessionRanOptions = { export function recordAgentSessionRanOptions( record: AgentSessionRecord, - { model, options, models, ...at }: AgentSessionRanOptions + { model, options, models: _models, ...at }: AgentSessionRanOptions ): AgentSessionRecord { const saved = record.options ?? {} // These run options cannot follow a concurrent model change. if (saved.model !== undefined && saved.model !== model) { return record } - const added = Object.entries({ ...options, model }).filter( - ([key, value]) => - saved[key] === undefined && - (independentAgentModelCatalog(record.provider) || - agentSessionOptionAdmitted(record.provider, models, model, key, value)) - ) + const added = Object.entries({ ...options, model }).filter(([key]) => saved[key] === undefined) return added.length === 0 ? record : replaceAgentSessionRecordOptions(record, { diff --git a/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.ts b/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.ts index 89b4e653e39..fc4f322f085 100644 --- a/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.ts @@ -1,7 +1,7 @@ /** Evaluate reservation identity/replay/CAS without I/O; commit applies the winning record and operation row in one store transaction. */ import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' -import { assertAgentSessionOptionsAdmitted } from '../../agent-session-record-options' +import type { AgentSessionAdmissionModels } from '../../agent-session-record-options' import { chatAgentsShareConversations } from '../../../../shared/structured-agent-cli-conversation-adoption' import { agentSessionOperationKey, @@ -64,7 +64,7 @@ export type AgentSessionReserveRequest = { /** Initial provider options persisted before the first process is acquired. */ options?: Readonly> /** Saved catalog rows those options are admitted against; transient, never written. */ - models?: Parameters[1] + models?: AgentSessionAdmissionModels /** Reserved tab ids are checked before spawn and claimed only when the tab publishes. */ surfaceTabId?: string /** Seed an adopted handle to resume its conversation; absent means a fresh provider conversation. */ @@ -152,7 +152,6 @@ export function validateAgentSessionReservationInput(request: AgentSessionReserv if (request.options && !isAgentSessionOptions(request.options)) { throw new Error('agent_session_options_invalid') } - assertAgentSessionOptionsAdmitted(request.provider, request.models, request.options) } export function applyAgentSessionReservation( diff --git a/src/main/runtime/orca-runtime-core.ts b/src/main/runtime/orca-runtime-core.ts index 94638f3dbc7..053519e24e1 100644 --- a/src/main/runtime/orca-runtime-core.ts +++ b/src/main/runtime/orca-runtime-core.ts @@ -26,6 +26,7 @@ export type RuntimeWorktreeScanCache = { generation: number runtimeKey: string result: RuntimeWorktreeScanResult + listing?: RuntimeWorktreeScanResult expiresAt: number adminFingerprint: string | null scannedAt: number diff --git a/src/main/runtime/orca-runtime-get-status.ts b/src/main/runtime/orca-runtime-get-status.ts index c0a617b1ef6..9c6e7876986 100644 --- a/src/main/runtime/orca-runtime-get-status.ts +++ b/src/main/runtime/orca-runtime-get-status.ts @@ -78,6 +78,8 @@ function supportsDurableTerminalPromptDelivery(): boolean { } export abstract class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId { + protected abstract pruneUnregisteredWorktreeScans(): void + private asRuntimeStatusHost(): RuntimeStatusHost { return this as unknown as RuntimeStatusHost } @@ -320,6 +322,7 @@ export abstract class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntime } protected notifyReposChanged(): void { + this.pruneUnregisteredWorktreeScans() wakeFolderRepoGitUpgradeWatch() this.notifier?.reposChanged() this.emitClientEvent({ type: 'reposChanged' }) diff --git a/src/main/runtime/orca-runtime-tests/paired-settings.spec.ts b/src/main/runtime/orca-runtime-tests/paired-settings.spec.ts index a9efdc74435..65973cbba2b 100644 --- a/src/main/runtime/orca-runtime-tests/paired-settings.spec.ts +++ b/src/main/runtime/orca-runtime-tests/paired-settings.spec.ts @@ -167,7 +167,7 @@ describe('OrcaRuntimeService', () => { expect(updateSettings).toHaveBeenCalledTimes(2) }) - it('compares retired models against the host record at mutation time', () => { + it('retires a missing future-launch default when an older client asks to prune from inventory', () => { let settings = { ...store.getSettings(), nativeChatSessionOptions: { grok: { model: 'grok-5' } } @@ -194,6 +194,10 @@ describe('OrcaRuntimeService', () => { availableModelIds: ['grok-4.5'] }) expect(settings.nativeChatSessionOptions).toEqual({ grok: {} }) + expect(updateSettings).toHaveBeenCalledExactlyOnceWith( + { nativeChatSessionOptions: { grok: {} } }, + { notifyListeners: true } + ) }) it('preserves concurrent additions after forty quick commands', () => { diff --git a/src/main/runtime/repo-worktree-row-resolution.test.ts b/src/main/runtime/repo-worktree-row-resolution.test.ts index 6e3d7fd3829..76afb18f2b5 100644 --- a/src/main/runtime/repo-worktree-row-resolution.test.ts +++ b/src/main/runtime/repo-worktree-row-resolution.test.ts @@ -1,3 +1,6 @@ +import { joinResolvedWorktreeScans } from './workspaces/resolved-worktree-scan-join' +import { getRepoHostIdentityForParts } from '../../shared/repo-host-identity' +import { WorkspaceGitIdentity } from './sidebar-inputs/workspace-git-identity' import { describe, expect, it, vi } from 'vitest' import type { ExecutionHostId } from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' @@ -439,3 +442,35 @@ describe('folder-to-Git checkout identity', () => { } ) }) + +it('stamps the scan/sidebar join while cached catalog projection stays pure across detach', () => { + const owner = repo('row-owner', '/repo', { executionHostId: 'local' }) + const deps = createDeps([owner]) + const writes = vi.spyOn(deps.store, 'setWorktreeMeta').mockImplementation(({ id }, updates) => { + const next = mergeWorktreeMetaForWrite(deps.metaById[id], updates) + deps.metaById[id] = next + return next + }) + const key = getRepoHostIdentityForParts(owner.id, 'local') + const scans = new Map([ + [key, { ok: true as const, worktrees: [gitWorktree('/repo/work')], gitRun: {} }] + ]) + joinResolvedWorktreeScans(deps.store, () => deps, scans, true) + expect(writes).not.toHaveBeenCalled() + const first = joinResolvedWorktreeScans(deps.store, () => deps, scans) + expect(writes).toHaveBeenCalledOnce() + const identity = new WorkspaceGitIdentity() + const rows = (joined: typeof first) => joined.worktrees.map((row) => ({ ...row, gitRun: {} })) + identity.observeScan(rows(first), first.scannedRepoKeys) + scans.set(key, { + ok: true, + worktrees: [{ ...gitWorktree('/repo/work'), branch: '' }], + gitRun: {} + }) + const detached = joinResolvedWorktreeScans(deps.store, () => deps, scans) + identity.observeScan(rows(detached), detached.scannedRepoKeys) + expect(identity.title(detached.worktrees[0]!)).toBe(first.worktrees[0]!.displayName) + expect(writes).toHaveBeenCalledOnce() + joinResolvedWorktreeScans(deps.store, () => deps, scans, true) + expect(writes).toHaveBeenCalledOnce() +}) diff --git a/src/main/runtime/repo-worktree-row-resolution.ts b/src/main/runtime/repo-worktree-row-resolution.ts index abb2a376946..12965cb1a64 100644 --- a/src/main/runtime/repo-worktree-row-resolution.ts +++ b/src/main/runtime/repo-worktree-row-resolution.ts @@ -1,5 +1,6 @@ import { preserveFolderUpgradeWorktreePath } from '../folder-upgrade-worktree-path' import { + composeWorktreeId, splitWorktreeId, splitWorktreeIdForFilesystem, worktreeIdentityKey @@ -130,7 +131,7 @@ export async function scanRepoWorktreeRows( } /** The join half: a repo's rows from a scan and the metadata as it is now. Synchronous. */ -export function joinRepoWorktreeRows( +export function projectRepoWorktreeRows( deps: Pick, repo: Repo, scan: RepoWorktreeRowsScan | null, @@ -159,17 +160,9 @@ export function joinRepoWorktreeRows( const expectedHostId = getRepoExecutionHostId(repo) return (scan?.worktrees ?? []).map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` - // Lineage needs durable instance identity before renderer discovery stamps it. - const existingMeta = metaById[worktreeId] - // Trust locator-keyed metadata only for its owner; host-qualified rows are exact. - const ownedExistingMeta = + const meta = readWorktreeMetaForHost(store, worktreeId, expectedHostId) ?? getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount) - const meta = ownedExistingMeta?.instanceId - ? ownedExistingMeta - : ownedExistingMeta || (!existingMeta && repoOwnerCount === 1) - ? store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: expectedHostId }), {}) - : undefined const merged = { ...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), hostId: meta?.hostId ?? expectedHostId @@ -192,6 +185,31 @@ export function joinRepoWorktreeRows( }) } +/** Resolution owns durable identity stamping; memory projection never calls this wrapper. */ +export function joinRepoWorktreeRows( + deps: Pick, + repo: Repo, + scan: RepoWorktreeRowsScan | null, + metaById: Record, + repoOwnerCount = deps.store.getRepos().filter((candidate) => candidate.id === repo.id).length +): RepoWorktreeRow[] { + let projectedMeta: Record | undefined + if (!isFolderRepo(repo)) { + const hostId = getRepoExecutionHostId(repo) + for (const git of scan?.worktrees ?? []) { + const id = composeWorktreeId(repo.id, git.path) + const owned = + readWorktreeMetaForHost(deps.store, id, hostId) ?? + getRepoOwnedWorktreeMeta(repo, id, metaById, repoOwnerCount) + if (!owned?.instanceId && (owned || (!metaById[id] && repoOwnerCount === 1))) { + projectedMeta ??= { ...metaById } + projectedMeta[id] = deps.store.setWorktreeMeta(hostWorktreeTarget({ id, hostId }), {}) + } + } + } + return projectRepoWorktreeRows(deps, repo, scan, projectedMeta ?? metaById, repoOwnerCount) +} + /** One repo's worktree rows before lineage projection. Shared by the fleet scan and scoped lookups. */ export async function resolveRepoWorktreeRows( deps: RepoWorktreeRowDeps, diff --git a/src/main/runtime/rpc/methods/antigravity-accounts.ts b/src/main/runtime/rpc/methods/antigravity-accounts.ts index c6071250b81..2d02cd9a1de 100644 --- a/src/main/runtime/rpc/methods/antigravity-accounts.ts +++ b/src/main/runtime/rpc/methods/antigravity-accounts.ts @@ -20,7 +20,8 @@ import { structuredAgentHostLaunchSources } from '../../structured-agent-launch- import { stopStructuredAgentChats } from '../../structured-agent-chat-stop' const chatSignInHost = (runtime: OrcaRuntimeService) => ({ - launchSources: () => structuredAgentHostLaunchSources('antigravity', runtime), + launchSources: (signal: AbortSignal) => + structuredAgentHostLaunchSources('antigravity', runtime, signal), stopLiveChats: () => stopStructuredAgentChats('antigravity', 'Antigravity') }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-launch-seed-options.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-launch-seed-options.test.ts index 6ae33e2ebc0..05b0c58f578 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-launch-seed-options.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-launch-seed-options.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionModelCatalogResult } from '../../../../../../shared/agent-session-wire' -import { structuredLaunchSeedOnSavedListing } from '../../../../session-tabs/structured-launch-seed-listing' const createStructuredWorkerSession = vi.fn(async (_args: Record) => ({ identity: { handle: 'structworker_1', sessionId: 'sess_1' }, @@ -101,7 +100,7 @@ describe('the create the seed options land in', () => { }) describe('a new Claude chat seeded from a remembered model the saved listing no longer names', () => { - it("starts on the listing's default, effort moved to the nearest level, as on main", async () => { + it('keeps the literal saved choice without consulting the catalog', async () => { const levels = ['low', 'high'].map((value) => ({ value, label: value })) const read = vi.fn(async (): Promise => ({ origin: 'probe', @@ -109,18 +108,31 @@ describe('a new Claude chat seeded from a remembered model the saved listing no models: [{ id: 'opus', label: 'Opus', isDefault: true, efforts: levels }] })) const host = { deps: { modelCatalog: { read } } } - const seeded = await structuredLaunchSeedOnSavedListing( - host, - 'claude', - { model: 'claude-gone', effort: 'max' }, - async () => '/repo/wt' - ) - expect(seeded).toEqual({ model: 'opus', effort: 'high' }) - expect(read).toHaveBeenCalledWith({ + const prepared = await prepareStructuredAgentSessionCreateForWorktree({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: create preparation uses only these runtime methods. + runtime: { + resolveStructuredAgentSessionCreateIntent: async () => ({ + location: { executionHostId: 'local', wslDistro: null, workspaceId: 'repo::wt' }, + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home' }, + runtimeKind: 'native', + options: { model: 'claude-gone', effort: 'max' } + }) + } as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: create preparation reads only deps.modelCatalog on this host. + ensureHost: async () => host as never, + envelope: { + sessionId: 'sess_1', + clientOperationId: 'op_1', + expectedRuntimeFence: null, + payloadFingerprint: '' + }, + worktree: 'id:repo::wt', agent: 'claude', - workspacePath: '/repo/wt', - savedOnly: true, - verifiedOnly: true + caller: { callerKey: 'renderer:test' } }) + expect(prepared.attachParams.options).toEqual({ model: 'claude-gone', effort: 'max' }) + expect(read).not.toHaveBeenCalled() }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-seed-admission.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-seed-admission.test.ts index d6f18a99bd4..0cd50ad4813 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create-seed-admission.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-seed-admission.test.ts @@ -30,10 +30,11 @@ async function prepareWithSeed( runtimeKind: 'native' as const, ...(seed ? { options: seed } : {}) } + const read = vi.fn(async () => listing(listingModels ?? [])) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the members prepare reaches. const host = { deps: { - modelCatalog: listingModels ? { read: vi.fn(async () => listing(listingModels)) } : undefined + modelCatalog: listingModels ? { read } : undefined } } as unknown as StructuredAgentSessionHost // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the members prepare reaches. @@ -50,6 +51,7 @@ async function prepareWithSeed( caller: { callerKey: 'caller-1' }, ...(options ? { options } : {}) }) + expect(read).not.toHaveBeenCalled() return prepared.attachParams.options } @@ -60,19 +62,19 @@ const piRow = (efforts: readonly string[]) => ({ efforts: efforts.map((value) => ({ value, label: value })) }) -it('moves a remembered effort the saved probe row lacks to its nearest listed level', async () => { +it('keeps a remembered effort the saved probe row lacks without refusing create', async () => { // Pi's live menu offers xhigh where its --list-models probe stops at high. const options = await prepareWithSeed({ model: 'some/model', effort: 'xhigh' }, undefined, [ piRow(['off', 'minimal', 'low', 'medium', 'high']) ]) - expect(options).toEqual({ model: 'some/model', effort: 'high' }) + expect(options).toEqual({ model: 'some/model', effort: 'xhigh' }) }) -it('drops a remembered effort its saved row no longer admits', async () => { +it('keeps a remembered effort when its saved row lists no efforts', async () => { const options = await prepareWithSeed({ model: 'some/model', effort: 'off' }, undefined, [ piRow([]) ]) - expect(options).toEqual({ model: 'some/model' }) + expect(options).toEqual({ model: 'some/model', effort: 'off' }) }) it('keeps an effort this launch named itself, even one its row does not admit', async () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index 2cadb1a8089..69394a2d429 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -21,7 +21,6 @@ import type { StructuredAgentSessionFirstMessage } from '../../../../shared/structured-agent-session-create' import { structuredAgentSessionOptionOverridesRefusal } from '../../../native-chat/agent-session-wire/structured-agent-session-options-read' -import { savedAgentSessionCatalogModels } from '../../../native-chat/agent-session-wire/structured-agent-session-pick-reply' import type { StructuredAgentSessionCreateOptions } from '../../../native-chat/agent-session-wire/structured-agent-session-create' import type { OrcaRuntimeService } from '../../orca-runtime' import { @@ -32,9 +31,7 @@ import type { StructuredAgentId } from '../../../../shared/agent-session-provide import { resolveUncommittedStructuredCreate } from './structured-agent-session-precommit-refusal' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { structuredAgentSessionClosedBeforeCreate } from './structured-agent-session-close-before-create' -import { structuredLaunchSeedOnSavedListing } from '../../session-tabs/structured-launch-seed-listing' import type { ChatTabPlacement } from '../../agent-session/agent-session-tab-layout' -import { seedOptionsAdmittedOnSavedListing } from '../../agent-session-record-options' export type PreparedStructuredAgentSessionCreate = { host: StructuredAgentSessionHost @@ -147,42 +144,12 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { ? { ...resolved.options, ...args.options } : { ...seededPermissionMode(resolved.options), ...args.options } : resolved.options - // Resolve picker cwd once so seed selection and host admission read the same catalog. - let folder: Promise | undefined - const catalogFolder = () => - (folder ??= args.runtime - .resolveStructuredAgentSessionLocalWorkspacePath(args.worktree) - .catch(() => null)) - // After fingerprinting, remap stale remembered model/effort against saved listing; never remap an explicit model. - const options = args.options?.model - ? picked - : await structuredLaunchSeedOnSavedListing(host, args.agent, picked, catalogFolder) - const catalogModels = - options && host.deps.modelCatalog - ? await savedAgentSessionCatalogModels( - host.deps, - resolved.provider, - args.envelope.sessionId, - await catalogFolder() - ) - : undefined - // Fit remembered values to the saved row instead of refusing the create (#31493). - const admittedSeed = - options && catalogModels && !args.options?.model - ? seedOptionsAdmittedOnSavedListing( - resolved.provider, - options, - catalogModels, - new Set(Object.keys(args.options ?? {})) - ) - : options return { host, - ...(catalogModels ? { catalogModels } : {}), ...(hostLaunchDirectory ? { hostLaunchDirectory } : {}), attachParams: { ...resolvedAttach, - ...(admittedSeed ? { options: admittedSeed } : {}), + ...(picked ? { options: picked } : {}), ...(args.tabId ? { surfaceTabId: args.tabId } : {}), provider: resolved.provider, agent: resolved.agent, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts index e71171d78b9..3a1aff7dc30 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts @@ -13,6 +13,10 @@ import type { AgentSessionRecord } from '../../../../shared/agent-session-record import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire' import { agentSessionRecordFixture } from '../../../../shared/agent-session-record.test-fixture' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { + agentModelCatalogFingerprintForRecord, + agentModelCatalogScopeFingerprint +} from '../../../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' import { call, clearStructuredHostStub, @@ -40,7 +44,7 @@ describe('agentSession.modelCatalog', () => { setStructuredAgentSessionHost( Object.assign(hostStub(), { deps: { - modelCatalog: { read }, + modelCatalog: { read, demand: vi.fn(async () => {}) }, store: { getRecord: () => record, getConversation: () => (record ? agentSessionConversationView(record) : null), @@ -158,11 +162,11 @@ describe('agentSession.modelCatalog', () => { STRUCTURED_CLIENT, { resolveStructuredAgentSessionLocalWorkspacePath } ) - expect(resolveStructuredAgentSessionLocalWorkspacePath).toHaveBeenCalledWith('id:wt-1') + expect(resolveStructuredAgentSessionLocalWorkspacePath).not.toHaveBeenCalled() expect(read).toHaveBeenCalledWith({ agent: 'codex', sessionId: SESSION, - workspacePath: '/repo/wt' + workspace: expect.objectContaining({ selector: 'id:wt-1', resolve: expect.any(Function) }) }) }) @@ -177,7 +181,10 @@ describe('agentSession.modelCatalog', () => { }) } ) - expect(read).toHaveBeenCalledWith({ agent: 'codex', workspacePath: null }) + expect(read).toHaveBeenCalledWith({ + agent: 'codex', + workspace: expect.objectContaining({ selector: 'id:missing', resolve: expect.any(Function) }) + }) }) it('reads as before when no worktree is named', async () => { @@ -198,6 +205,87 @@ describe('agentSession.modelCatalog', () => { await call('agentSession.modelCatalog', { agent: 'grok', savedOnly: true }, STRUCTURED_CLIENT) expect(read).toHaveBeenCalledWith({ agent: 'grok', savedOnly: true }) }) + + it('answers cold saved-only without resolution and reuses prepared memory for another equivalent chat', async () => { + const pinned = { ...agentSessionRecordFixture(), provider: 'claude' } + const key = agentModelCatalogScopeFingerprint( + agentModelCatalogFingerprintForRecord(pinned), + '/project', + 'saved-launch' + ) + const seed = new AgentModelCatalogStore() + const saved = seed.recordSuccess( + key, + 'claude', + { + origin: 'probe', + complete: true, + models: [{ id: 'saved', label: 'Saved', isDefault: false, efforts: [] }] + }, + 'discovery' + )! + const store = new AgentModelCatalogStore() + await store.attachPersistence({ + load: async () => [saved], + save: () => {}, + flush: async () => {} + }) + const folder = vi.fn(async () => '/project') + const run = vi.fn(() => new Promise(() => {})) + const resolve = vi.fn(async () => ({ identity: 'saved-launch', run })) + const catalog = createAgentModelCatalogService({ + store, + getConversation: (id) => { + const held = { ...pinned, sessionId: id } + return held ? agentSessionConversationView(held) : undefined + }, + getRecord: (id) => ({ ...pinned, sessionId: id }), + drivesRecord: () => true, + resolveAccountHome: vi.fn(() => new Promise(() => {})), + recordWorkspacePath: folder, + probes: { claude: Object.assign(run, { resolve }) } + }) + setStructuredAgentSessionHost( + Object.assign(hostStub(), { + sessionAgent: () => 'claude', + deps: { + modelCatalog: catalog, + store: { + getConversation: (id: string) => { + const held = { ...pinned, sessionId: id } + return held ? agentSessionConversationView(held) : undefined + }, + getRecord: (id: string) => ({ ...pinned, sessionId: id }) + }, + adapter: {} + } + }) + ) + try { + const cold = await call( + 'agentSession.modelCatalog', + { agent: 'claude', sessionId: 'session-cold-a', savedOnly: true }, + STRUCTURED_CLIENT + ) + expect(cold.ok, JSON.stringify(cold)).toBe(true) + expect(cold).toMatchObject({ result: { origin: 'unknown' } }) + expect(folder).not.toHaveBeenCalled() + expect(resolve).not.toHaveBeenCalled() + await catalog.prepareSaved({ agent: 'claude', sessionId: 'session-cold-a' }) + const calls = resolve.mock.calls.length + expect( + await call( + 'agentSession.modelCatalog', + { agent: 'claude', sessionId: 'session-cold-b', savedOnly: true }, + STRUCTURED_CLIENT + ) + ).toMatchObject({ ok: true, result: { models: [{ id: 'saved' }] } }) + expect(resolve).toHaveBeenCalledTimes(calls) + expect(run).not.toHaveBeenCalled() + } finally { + catalog.stop() + } + }) }) describe('agentSession.modelCatalog before anything built the host', () => { @@ -210,7 +298,7 @@ describe('agentSession.modelCatalog before anything built the host', () => { setStructuredAgentSessionHost( Object.assign(hostStub(), { deps: { - modelCatalog: { read }, + modelCatalog: { read, demand: vi.fn(async () => {}) }, store: { getRecord: () => null, getConversation: () => null }, adapter: {} } @@ -276,7 +364,7 @@ describe('agentSession.modelCatalog for a chat on a model its CLI stopped listin setStructuredAgentSessionHost( Object.assign(hostStub(), { deps: { - modelCatalog: { read }, + modelCatalog: { read, demand: vi.fn(async () => {}) }, store: { getRecord: () => record, getConversation: () => (record ? agentSessionConversationView(record) : null), diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts index 5bc30412fe7..22c50710e2e 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts @@ -84,16 +84,26 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ } // A floating chat runs in the folder it was created in, not the floating setting's current one. const launchDirectory = record ? agentSessionPinnedLaunchDirectory(record) : undefined - if (launchDirectory) { - return catalog.read({ ...params, ...scope, workspacePath: launchDirectory }) + const request = { + ...params, + ...scope, + ...(launchDirectory + ? { workspacePath: launchDirectory } + : worktree === undefined + ? {} + : { + workspace: { + selector: worktree, + identity: () => + ctx.runtime.structuredAgentSessionCatalogWorkspaceIdentity(worktree), + resolve: () => ctx.runtime.resolveStructuredAgentCatalogWorkspace(worktree) + } + }) } - if (worktree === undefined) { - return catalog.read({ ...params, ...scope }) + if (!params.savedOnly) { + void catalog.demand(request) } - const workspacePath = await ctx.runtime - .resolveStructuredAgentSessionLocalWorkspacePath(worktree) - .catch(() => null) - return catalog.read({ ...params, ...scope, workspacePath }) + return catalog.read(request) } const listing = await read() const own = record?.provider === params.agent ? record : null diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts index e5eed35cdc1..fa19d421bfe 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts @@ -124,6 +124,7 @@ async function chatAtRest(repoConfig: { agentReadsProjectModelConfig, workspaceMayOverrideDefaultModel }) + await rig.restart({ modelCatalog }) if (repoConfig.complete) { await modelCatalog.read({ agent: 'codex', @@ -132,7 +133,6 @@ async function chatAtRest(repoConfig: { waitForListing: true }) } - await rig.restart({ modelCatalog }) setStructuredAgentSessionHost(rig.host) rig.adapter.acquire.mockClear() return modelCatalog @@ -193,10 +193,7 @@ describe('options at rest in a repo with no config of its own', () => { expect(current).toMatchObject({ ok: true }) expect(current).not.toHaveProperty('result.current.model') expect(current).not.toHaveProperty('result.current.effort') - expect(await catalog.read({ agent: 'codex', savedOnly: true })).toMatchObject({ - models: [{ id: 'gpt-5.5', isDefault: false }], - listingNamesConfiguredModel: false - }) + expect(await catalog.read({ agent: 'codex', savedOnly: true })).toEqual({ origin: 'unknown' }) expect(rig.store.getRecord(SESSION)!.options).toEqual({}) }) }) diff --git a/src/main/runtime/rpc/methods/usage-credentials.test.ts b/src/main/runtime/rpc/methods/usage-credentials.test.ts index c4108aec4c5..0f54ed23be9 100644 --- a/src/main/runtime/rpc/methods/usage-credentials.test.ts +++ b/src/main/runtime/rpc/methods/usage-credentials.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../../../shared/agent-session-conversation-view' import '../unused-default-rpc-methods.test-fixture' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -11,6 +12,9 @@ import { RpcDispatcher } from '../dispatcher' import { USAGE_CREDENTIAL_METHODS } from './usage-credentials' import { API_KEY_ENVELOPE_PREFIX } from '../../../zcode/zcode-plan-api-key-store' import { RuntimeAccountController } from '../../runtime-account-controller' +import { agentModelCatalogStore } from '../../../native-chat/agent-model-catalog/agent-model-catalog-store' +import { createAgentModelCatalogService } from '../../../native-chat/agent-model-catalog/agent-model-catalog-service' +import { agentSessionRecordFixture } from '../../../native-chat/agent-session-record-test-fixture' import { hostAccountsManagedByDesktopMessage } from '../../../../shared/host-account-launch-env' const home = vi.hoisted(() => ({ dir: '' })) @@ -262,6 +266,36 @@ describe('usage credential RPC methods', () => { } ) + it('retires catalog source authority when the host restores or receives a changed launch environment', () => { + const accounts = new RuntimeAccountController() + const record = agentSessionRecordFixture({ sessionId: 'session-host-environment' }) + const catalog = createAgentModelCatalogService({ + store: agentModelCatalogStore, + getConversation: () => { + const held = record + return held ? agentSessionConversationView(held) : undefined + }, + getRecord: () => record, + drivesRecord: () => true, + resolveAccountHome: async () => record.accountHome, + probes: {} + }) + const saved = { wslDistro: 'Ubuntu', claude: {}, terminal: {} } + try { + const beforeRestore = catalog.captureSource(record) + accounts.restoreHostLaunchEnv(saved, () => {}) + expect(beforeRestore()).toBe(false) + const restored = catalog.captureSource(record) + accounts.setHostLaunchEnv(saved) + expect(restored()).toBe(true) + accounts.setHostLaunchEnv({ ...saved, claude: { CLAUDE_CONFIG_DIR: '/host/new' } }) + expect(restored()).toBe(false) + expect(catalog.captureSource(record)()).toBe(true) + } finally { + catalog.stop() + } + }) + it('refuses every call where the desktop owns this host’s accounts, storing nothing', async () => { const accounts = new RuntimeAccountController() accounts.restoreHostLaunchEnv({ wslDistro: 'Ubuntu', claude: {}, terminal: {} }, () => {}) diff --git a/src/main/runtime/runtime-account-controller.ts b/src/main/runtime/runtime-account-controller.ts index 5c431ef58c1..2ffb842f1ed 100644 --- a/src/main/runtime/runtime-account-controller.ts +++ b/src/main/runtime/runtime-account-controller.ts @@ -1,5 +1,6 @@ import type { ClaudeAccountService } from '../claude-accounts/service' import { retireAtRestCommandContext } from '../native-chat/agent-session-wire/at-rest-command-context' +import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { hasAppEnvironment } from '../../shared/app-environment' import { getManagedDataAccountService } from '../managed-data-accounts/service' import type { @@ -98,6 +99,12 @@ export class RuntimeAccountController { const previous = this.hostLaunchEnv this.hostLaunchEnv = launchEnv if (JSON.stringify(previous) !== JSON.stringify(launchEnv)) { + agentModelCatalogStore.expireLaunchScopes( + (agent) => + previous?.wslDistro !== this.hostLaunchEnv?.wslDistro || + JSON.stringify(Object.entries(hostAccountLaunchEnvFor(previous, agent)).sort()) !== + JSON.stringify(Object.entries(this.hostLaunchEnvFor(agent)).sort()) + ) retireAtRestCommandContext() } try { @@ -118,6 +125,12 @@ export class RuntimeAccountController { const previous = this.hostLaunchEnv this.hostLaunchEnv = saved if (JSON.stringify(previous) !== JSON.stringify(saved)) { + agentModelCatalogStore.expireLaunchScopes( + (agent) => + previous?.wslDistro !== this.hostLaunchEnv?.wslDistro || + JSON.stringify(Object.entries(hostAccountLaunchEnvFor(previous, agent)).sort()) !== + JSON.stringify(Object.entries(this.hostLaunchEnvFor(agent)).sort()) + ) retireAtRestCommandContext() } } diff --git a/src/main/runtime/runtime-resolved-worktree-cache.ts b/src/main/runtime/runtime-resolved-worktree-cache.ts index 90552e5a7c3..ee4dfe3d8ae 100644 --- a/src/main/runtime/runtime-resolved-worktree-cache.ts +++ b/src/main/runtime/runtime-resolved-worktree-cache.ts @@ -16,6 +16,10 @@ export class RuntimeResolvedWorktreeCache { private resolvedInFlight: ResolvedInFlight | null = null private resolvedGeneration = 0 + get generation(): number { + return this.resolvedGeneration + } + peek(): ResolvedCache | null { return this.resolved } diff --git a/src/main/runtime/runtime-worktree-filesystem.ts b/src/main/runtime/runtime-worktree-filesystem.ts index c59640577c3..b5bee5edba7 100644 --- a/src/main/runtime/runtime-worktree-filesystem.ts +++ b/src/main/runtime/runtime-worktree-filesystem.ts @@ -38,16 +38,17 @@ function getRuntimeFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: strin return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID() } -export function listRuntimeFolderWorkspaces( +function runtimeFolderWorkspaceRows( store: Pick, repo: Repo, - repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length + repoOwnerCount: number, + stamp: boolean ): Worktree[] { const rootId = getRuntimeFolderWorkspaceRootId(repo) const allMeta = store.getAllWorktreeMeta() const expectedHostId = getRepoExecutionHostId(repo) // A project a WSL move is taking still lists, without stamping identities it lacks. - const canWrite = !isWslMoveRepoBlocked(repo) + const canWrite = stamp && !isWslMoveRepoBlocked(repo) const ids = Object.keys(allMeta).filter( (worktreeId) => isRuntimeFolderWorkspaceIdForRepo(repo, worktreeId) && @@ -75,6 +76,22 @@ export function listRuntimeFolderWorkspaces( }) } +export function listRuntimeFolderWorkspaces( + store: Pick, + repo: Repo, + repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length +): Worktree[] { + return runtimeFolderWorkspaceRows(store, repo, repoOwnerCount, true) +} + +export function projectRuntimeFolderWorkspaces( + store: Pick, + repo: Repo, + repoOwnerCount: number +): Worktree[] { + return runtimeFolderWorkspaceRows(store, repo, repoOwnerCount, false) +} + export async function runtimePathExists(pathValue: string): Promise { try { await stat(pathValue) diff --git a/src/main/runtime/session-tabs/structured-launch-seed-listing.ts b/src/main/runtime/session-tabs/structured-launch-seed-listing.ts deleted file mode 100644 index 17468933b44..00000000000 --- a/src/main/runtime/session-tabs/structured-launch-seed-listing.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Seed new Claude chats from finished host listings: replace unlisted models with default/first and nearest supported effort; never replace saved/running chat choices. - -import { finishedListingIds } from '../../../shared/structured-agent-session-options' -import type { AgentModelCatalogService } from '../../native-chat/agent-model-catalog/agent-model-catalog-service' - -const EFFORT_RANK = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] - -/** `previous` if `supported` offers it, else the nearest offered level (the higher on a tie). */ -export function nearestAgentEffort( - previous: string, - supported: readonly string[] -): string | undefined { - const rank = (level: string): number => EFFORT_RANK.indexOf(level) - const distance = (level: string): number => Math.abs(rank(level) - rank(previous)) - if (supported.includes(previous)) { - return previous - } - if (rank(previous) === -1) { - return undefined - } - return supported - .filter((level) => rank(level) !== -1) - .sort((a, b) => distance(a) - distance(b) || rank(b) - rank(a))[0] -} - -/** Read saved catalogs only for Claude seeds naming a model; unreadable/unfinished catalogs preserve the seed. */ -export async function structuredLaunchSeedOnSavedListing( - host: { deps: { modelCatalog?: Pick } }, - agent: string, - seed: Readonly> | undefined, - workspacePath: () => Promise -): Promise> | undefined> { - const catalogs = agent === 'claude' && seed?.model ? host.deps.modelCatalog : undefined - if (!seed?.model || !catalogs) { - return seed - } - const catalog = await workspacePath() - .catch(() => null) - .then((path) => - catalogs.read({ agent, workspacePath: path, savedOnly: true, verifiedOnly: true }) - ) - .catch(() => null) - const listed = catalog ? finishedListingIds(catalog) : undefined - if (!catalog || catalog.origin === 'unknown' || !listed || listed.includes(seed.model)) { - return seed - } - const target = catalog.models.find((row) => row.isDefault) ?? catalog.models[0] - if (!target) { - return seed - } - const { effort: remembered, ...rest } = seed - const effort = remembered - ? nearestAgentEffort( - remembered, - target.efforts.map((choice) => choice.value) - ) - : undefined - return { ...rest, model: target.id, ...(effort ? { effort } : {}) } -} diff --git a/src/main/runtime/session-tabs/structured-session-catalog-workspace.test.ts b/src/main/runtime/session-tabs/structured-session-catalog-workspace.test.ts new file mode 100644 index 00000000000..55d0bbfe1ea --- /dev/null +++ b/src/main/runtime/session-tabs/structured-session-catalog-workspace.test.ts @@ -0,0 +1,631 @@ +import { afterEach, expect, it, vi } from 'vitest' + +// Ancestors outside this fixture's owner boundaries are inert; owner methods below are real. +vi.mock('../session-graph/session-graph-reload-lifecycle.ts', () => ({ + OrcaRuntimeWithHasTerminalsForWorktree: class {} +})) +vi.mock('../orca-runtime-read-kitty-file.ts', () => ({ OrcaRuntimeWithReadKittyFile: class {} })) +vi.mock('./headless-tab-group-moves.ts', () => ({ RuntimeHeadlessTabGroups: class {} })) +vi.mock('./structured-session-account-home.ts', () => ({ + OrcaRuntimeWithStructuredSessionAccountHome: class {} +})) + +import { RuntimeWorkspaceSelectors } from '../workspaces/workspace-selectors' +import { RuntimeWorkspaceCatalog } from '../workspaces/workspace-catalog' +import { RuntimeWorkspaceRepoScanRefresh } from '../workspaces/workspace-repo-scan-refresh' +import { OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree } from '../terminal/launch/terminal-workspace-scope' +import { OrcaRuntimeWithPersistHeadlessTerminalTitle } from '../workspaces/workspace-file-session-targets' +import { StructuredSessionWorkspaceScope } from './structured-session-workspace-scope' +import { RuntimeResolvedWorktreeCache } from '../runtime-resolved-worktree-cache' +import { STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS } from '../rpc/methods/structured-agent-session-options-read' +import { setStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' +import { createAgentModelCatalogService } from '../../native-chat/agent-model-catalog/agent-model-catalog-service' +import { + AgentModelCatalogStore, + AGENT_MODEL_CATALOG_FRESH_MS +} from '../../native-chat/agent-model-catalog/agent-model-catalog-store' +import { + agentModelCatalogFingerprint, + agentModelCatalogScopeFingerprint +} from '../../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' +import { AgentModelCatalogUnavailableError } from '../../native-chat/agent-model-catalog/agent-model-catalog-unavailable' +import { AGENT_MODEL_CATALOG_PICKER_WAIT_MS } from '../../native-chat/agent-model-catalog/agent-model-catalog-listing-waiters' + +import type { Repo } from '../../../shared/repo-types' +import type { Project } from '../../../shared/project-types' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import type { RuntimeStore } from '../runtime-store-contract' +import type { RpcContext } from '../rpc/core' +import type { StructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-host' +import type { AgentModelCatalogReadParams } from '../../native-chat/agent-model-catalog/agent-model-catalog-service-contract' +import type { RuntimeWorktreeScanCache, RuntimeWorktreeScanInFlight } from '../orca-runtime-core' +import type { RuntimeWorktreeScanResult } from '../repo-worktree-resolution-scan' +import type { ResolvedWorktree } from '../runtime-worktree-path-identity' +import type { ResolvedWorktreeScanJoin } from '../workspaces/resolved-worktree-scan-join' +import { getRuntimeFolderWorkspaceInstanceId } from '../runtime-folder-workspace' + +type ScopeFixture = { + store: RuntimeStore + resolvedWorktrees: RuntimeResolvedWorktreeCache + projectCachedWorktrees: () => ResolvedWorktreeScanJoin + worktreeScanCache: Map + worktreeScanGenerations: Map + worktreeScanInFlight: Map + resolveStructuredAgentCatalogWorkspace: StructuredSessionWorkspaceScope['resolveStructuredAgentCatalogWorkspace'] + structuredAgentSessionCatalogWorkspaceIdentity: StructuredSessionWorkspaceScope['structuredAgentSessionCatalogWorkspaceIdentity'] + resolveRuntimeFileTarget: ( + selector: string + ) => Promise<{ worktree: ResolvedWorktree; executionHostId: string }> + structuredAgentSessionLocationForTarget: (target: { + worktree: ResolvedWorktree + executionHostId: string + }) => { wslDistro: string | null; workspaceKind: string } + resolveFolderWorkspaceLaunchScope: () => Promise + resolveFolderWorkspaceSelector: () => null + refreshRepoWorktreeScan: (repo: Repo) => Promise<{ + result: RuntimeWorktreeScanResult + adminFingerprint: null + adminFingerprintProbe: null + scannedAt: number + }> + invalidateWorktreeCatalog: (repoId: string) => void + invalidateResolvedWorktreeCache: () => void + pruneUnregisteredWorktreeScans: () => void + listRepoWorktreesForResolution: (repo: Repo) => Promise +} + +const accountHome = { variable: 'HOME', path: '/private-fixture/account' } +const listed = (id = 'saved') => ({ + origin: 'probe' as const, + complete: true, + models: [{ id, label: id, isDefault: true, efforts: [] }] +}) +const cleanups: (() => void)[] = [] +afterEach(() => { + cleanups.splice(0).forEach((fn) => fn()) + setStructuredAgentSessionHost(null) + vi.restoreAllMocks() + vi.useRealTimers() +}) + +function rig(agent = 'opencode') { + const repos: Repo[] = [ + { + id: 'r1', + path: '/private-fixture/w1', + displayName: 'R1', + badgeColor: '', + addedAt: 0, + executionHostId: 'local' + }, + { + id: 'r2', + path: '/private-fixture/w2', + displayName: 'R2', + badgeColor: '', + addedAt: 0, + executionHostId: 'local' + } + ] + const metadata: Record> = { + 'r1::/private-fixture/w1': { instanceId: 'i1', hostId: 'local', displayName: 'W1' }, + 'r2::/private-fixture/w2': { instanceId: 'i2', hostId: 'local', displayName: 'W2' } + } + const writes = vi.fn((target: string | { id: string }, patch: Partial) => { + const id = typeof target === 'string' ? target : target.id + return (metadata[id] = { + ...metadata[id], + ...patch, + instanceId: metadata[id]?.instanceId ?? 'new-id' + }) + }) + const projects: Project[] = [] + const store = { + getRepos: () => repos, + getRepo: (id: string) => repos.find((repo) => repo.id === id), + getProjects: () => projects, + getSettings: () => ({}), + getFolderWorkspaces: () => [], + getAllWorktreeMeta: () => metadata, + getWorktreeMeta: (id: string) => metadata[id], + getAllWorktreeLineage: () => ({}), + getAllWorkspaceLineage: () => ({}), + removeWorktreeLineage: vi.fn(), + setWorktreeMeta: writes + } + // Compose the same owner methods without creating a service, app, journal or process. + const runtimeState = { + store, + resolvedWorktrees: new RuntimeResolvedWorktreeCache(), + worktreeScanCache: new Map(), + worktreeScanGenerations: new Map(), + worktreeScanInFlight: new Map() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: shipping prototypes are installed immediately below and the fixture supplies their observed dependency slice. + const runtime = runtimeState as unknown as ScopeFixture + for (const owner of [ + OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree, + RuntimeWorkspaceSelectors, + RuntimeWorkspaceCatalog, + RuntimeWorkspaceRepoScanRefresh, + OrcaRuntimeWithPersistHeadlessTerminalTitle, + StructuredSessionWorkspaceScope + ]) { + for (const key of Object.getOwnPropertyNames(owner.prototype)) { + if (key !== 'constructor') { + Object.defineProperty(runtime, key, Object.getOwnPropertyDescriptor(owner.prototype, key)!) + } + } + } + runtime.resolveFolderWorkspaceLaunchScope = async () => null + runtime.resolveFolderWorkspaceSelector = () => null + let heldScan: Promise | undefined + const scan = vi.fn(async (repo: Repo) => { + if (heldScan) { + await heldScan + } + return { + result: { + ok: true, + worktrees: [ + { + path: repo.path, + head: 'head', + branch: 'refs/heads/main', + isMainWorktree: true, + isBare: false + } + ] + }, + adminFingerprint: null, + adminFingerprintProbe: null, + scannedAt: Date.now() + } + }) + runtime.refreshRepoWorktreeScan = scan + const account = vi.fn(async () => accountHome) + const resolveInvocation = vi.fn(async () => ({ identity: 'launch', run: listing })) + let heldListing: Promise> | undefined + const listing = vi.fn(async () => (heldListing ? heldListing : listed('new'))) + const defaultCheck = vi.fn(async () => false) + const probe = Object.assign(listing, { resolve: resolveInvocation }) + const catalogStore = new AgentModelCatalogStore() + const base = agentModelCatalogFingerprint({ + agent, + accountHome, + wslDistro: null, + ...(agent === 'opencode' ? { folder: '/private-fixture/w2' } : {}) + }) + const fingerprint = + agent === 'claude' + ? agentModelCatalogScopeFingerprint(base, '/private-fixture/w2', 'launch') + : base + catalogStore.recordSuccess(fingerprint, agent, listed(), 'discovery') + const persist = vi.fn() + const catalog = createAgentModelCatalogService({ + store: catalogStore, + getConversation: () => undefined, + getRecord: () => undefined, + drivesRecord: () => true, + resolveAccountHome: account, + probes: { [agent]: probe }, + listsInChatFolder: new Set(['opencode']), + workspaceMayOverrideDefaultModel: defaultCheck + }) + cleanups.push(catalog.stop) + const demands: Promise[] = [] + let request: AgentModelCatalogReadParams | undefined + const exposed = { + ...catalog, + demand: (params: AgentModelCatalogReadParams) => { + request = params + const promise = catalog.demand(params) + demands.push(promise) + return promise + }, + read: (params: AgentModelCatalogReadParams) => { + request = params + return catalog.read(params) + } + } + const hostSlice = { + deps: { + store: { getConversation: () => undefined, getRecord: () => undefined }, + modelCatalog: exposed + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the RPC accesses only the installed catalog and record lookup dependencies supplied here. + const host = hostSlice as unknown as StructuredAgentSessionHost + setStructuredAgentSessionHost(host) + const method = STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS.find( + (row) => row.name === 'agentSession.modelCatalog' + )! + const contextSlice = { runtime } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this RPC reads only the composed runtime slice and has no transport dependencies. + const context = contextSlice as unknown as RpcContext + const rpc = async (params = {}) => + method.handler(method.params!.parse({ agent, worktree: 'name:W2', ...params }), context) + const current = () => rpc({ savedOnly: true }) + const counts = () => [ + scan.mock.calls.length, + writes.mock.calls.length, + account.mock.calls.length, + resolveInvocation.mock.calls.length, + listing.mock.calls.length, + defaultCheck.mock.calls.length, + persist.mock.calls.length + ] + return { + runtime, + store, + repos, + projects, + metadata, + catalog, + catalogStore, + fingerprint, + writes, + scan, + account, + resolveInvocation, + listing, + defaultCheck, + persist, + rpc, + current, + counts, + demands, + get request() { + return request + }, + setScan: (p?: Promise) => (heldScan = p), + setListing: (p: Promise>) => (heldListing = p) + } +} + +it('keeps the selected workspace through unrelated invalidation, fences genuine changes, and never advances expired resolution', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_800_000_000_000) + const h = rig() + await h.catalogStore.attachPersistence({ + load: async () => [], + save: h.persist, + flush: async () => {} + }) + delete h.metadata['r2::/private-fixture/w2'].instanceId + const scans = Promise.withResolvers() + h.setScan(scans.promise) + const initialCounts = h.counts() + expect(await h.current()).toEqual({ origin: 'unknown' }) + expect(h.counts()).toEqual(initialCounts) + const cold = await h.rpc() + expect(cold).toMatchObject({ origin: 'unknown' }) + await vi.waitFor(() => expect(h.scan).toHaveBeenCalledTimes(2)) + scans.resolve() + await Promise.all(h.demands) + const prepared = await h.current() + expect(prepared).toMatchObject({ models: [{ id: 'saved', isDefault: true }] }) + const savedCounts = h.counts() + expect(await h.current()).toEqual(prepared) + expect(h.counts()).toEqual(savedCounts) + + vi.setSystemTime(Date.now() + AGENT_MODEL_CATALOG_FRESH_MS + 1) + const pending = Promise.withResolvers>() + h.setListing(pending.promise) + await h.rpc() + await vi.waitFor(() => expect(h.listing).toHaveBeenCalledTimes(1)) + h.metadata['r1::/private-fixture/w1'].displayName = 'W1 renamed' + delete h.metadata['r1::/private-fixture/w1'].instanceId + h.runtime.invalidateWorktreeCatalog('r1') + h.catalog.retireContext('r1::/private-fixture/w1') + const nonmatchingCounts = h.counts() + expect(h.runtime.resolvedWorktrees.peek()).toBeNull() + expect(await h.current()).toMatchObject({ models: [{ id: 'saved', isDefault: true }] }) + expect(h.counts()).toEqual(nonmatchingCounts) + pending.resolve(listed('unrelated-completed')) + await Promise.all(h.demands) + expect(h.catalogStore.get(h.fingerprint)?.models[0].id).toBe('unrelated-completed') + + // Hold a new operation's existing default and listing continuations, then create ambiguity. + vi.setSystemTime(Date.now() + AGENT_MODEL_CATALOG_FRESH_MS + 1) + const stale = Promise.withResolvers>() + const defaults = Promise.withResolvers() + h.setListing(stale.promise) + h.defaultCheck.mockReturnValueOnce(defaults.promise) + await h.rpc() + await vi.waitFor(() => expect(h.listing).toHaveBeenCalledTimes(2)) + const preserved = JSON.stringify(h.catalogStore.get(h.fingerprint)) + const failure = h.catalogStore.failureDetail(h.fingerprint) + const savedWrites = h.persist.mock.calls.length + h.metadata['r1::/private-fixture/w1'].displayName = 'W2' + h.runtime.invalidateWorktreeCatalog('r1') + h.catalog.retireContext('r1::/private-fixture/w1') + expect(await h.current()).toEqual({ origin: 'unknown' }) + defaults.resolve(false) + stale.resolve(listed('must-not-ingest')) + await Promise.all(h.demands) + expect(JSON.stringify(h.catalogStore.get(h.fingerprint))).toBe(preserved) + expect(h.catalogStore.failureDetail(h.fingerprint)).toBe(failure) + expect(h.persist).toHaveBeenCalledTimes(savedWrites) + + const expired = rig('claude') + const delayed = Promise.withResolvers() + const realResolver = expired.runtime.resolveStructuredAgentCatalogWorkspace.bind(expired.runtime) + const completedScope = vi.fn(async (selector: string) => { + const resolved = await realResolver(selector) + await delayed.promise + return resolved + }) + expired.runtime.resolveStructuredAgentCatalogWorkspace = completedScope + await expired.rpc() + await vi.waitFor(() => expect(completedScope).toHaveBeenCalledOnce()) + await vi.advanceTimersByTimeAsync(AGENT_MODEL_CATALOG_PICKER_WAIT_MS + 1) + await Promise.all(expired.demands) + delayed.resolve() + await vi.runAllTimersAsync() + expect(expired.account).not.toHaveBeenCalled() + expect(expired.resolveInvocation).not.toHaveBeenCalled() + expect(expired.defaultCheck).not.toHaveBeenCalled() + expect(expired.listing).not.toHaveBeenCalled() + + // Restore one match, then independently change the actual selected host, path, and distro route. + h.metadata['r1::/private-fixture/w1'].displayName = 'W1' + for (const change of ['path', 'host', 'distro']) { + const routed = rig() + await routed.rpc() + await Promise.all(routed.demands) + vi.setSystemTime(Date.now() + AGENT_MODEL_CATALOG_FRESH_MS + 1) + const late = Promise.withResolvers>() + routed.setListing(late.promise) + await routed.rpc() + await vi.waitFor(() => expect(routed.listing).toHaveBeenCalledOnce()) + const oldEntry = JSON.stringify(routed.catalogStore.get(routed.fingerprint)) + if (change === 'path') { + const listing = routed.runtime.worktreeScanCache.get('r2\0local')?.listing + if (!listing) { + throw new Error('selected workspace was not cached') + } + listing.worktrees[0].path += '/moved' + } + if (change === 'host') { + routed.repos[1].executionHostId = 'ssh:other' + } + if (change === 'distro') { + // An explicit project preference supplies the real location owner with a distro. + const platform = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + cleanups.push(() => Object.defineProperty(process, 'platform', platform)) + routed.projects.push({ + id: 'project', + displayName: 'Project', + badgeColor: '', + sourceRepoIds: ['r2'], + createdAt: 0, + updatedAt: 0, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + } + routed.runtime.invalidateResolvedWorktreeCache() + if (change === 'distro') { + const target = await routed.runtime.resolveRuntimeFileTarget('name:W2') + expect(routed.runtime.structuredAgentSessionLocationForTarget(target).wslDistro).toBe( + 'Ubuntu' + ) + } + routed.runtime.invalidateResolvedWorktreeCache() + routed.catalog.retireContext('r2::/private-fixture/w2') + expect(await routed.current()).toEqual({ origin: 'unknown' }) + late.reject(new AgentModelCatalogUnavailableError({ reason: 'notSignedIn', account: 'system' })) + await Promise.all(routed.demands) + expect(JSON.stringify(routed.catalogStore.get(routed.fingerprint))).toBe(oldEntry) + expect(routed.catalogStore.failureDetail(routed.fingerprint)).toBeNull() + vi.restoreAllMocks() + } +}) + +it('releases removed scans and makes old owners and joiners use a re-registered repo', async () => { + const h = rig() + const pending = Promise.withResolvers() + h.setScan(pending.promise) + const first = h.runtime.listRepoWorktreesForResolution(h.repos[0]) + const joined = h.runtime.listRepoWorktreesForResolution(h.repos[0]) + const old = h.repos.shift()! + h.runtime.pruneUnregisteredWorktreeScans() + expect(h.runtime.worktreeScanInFlight.size).toBe(0) + expect(h.runtime.worktreeScanGenerations.size).toBe(0) + expect(h.runtime.worktreeScanCache.size).toBe(0) + h.repos.unshift({ ...old, path: '/replacement' }) + h.setScan() + pending.resolve() + for (const result of await Promise.all([first, joined])) { + expect(result.worktrees.map((row) => row.path)).toEqual(['/replacement']) + } + expect(h.scan).toHaveBeenCalledTimes(2) + expect(h.runtime.worktreeScanCache.get('r1\0local')?.result.worktrees[0].path).toBe( + '/replacement' + ) + h.repos.shift() + h.runtime.pruneUnregisteredWorktreeScans() + expect(h.runtime.worktreeScanCache.size).toBe(0) + expect(h.runtime.worktreeScanInFlight.size).toBe(0) +}) + +it('shares one failed SSH stored-row refresh at N and 10N callers without caching failure', async () => { + for (const callers of [5, 50]) { + const h = rig() + const repo = h.repos[0] + repo.executionHostId = 'ssh:fixture' + h.metadata['r1::/private-fixture/w1'].hostId = 'ssh:fixture' + Object.defineProperty( + h.runtime, + 'refreshRepoWorktreeScan', + Object.getOwnPropertyDescriptor( + RuntimeWorkspaceRepoScanRefresh.prototype, + 'refreshRepoWorktreeScan' + )! + ) + const refresh = vi.spyOn(h.runtime, 'refreshRepoWorktreeScan') + const results = await Promise.all( + Array.from({ length: callers }, () => h.runtime.listRepoWorktreesForResolution(repo)) + ) + expect(results).toEqual( + Array.from({ length: callers }, () => ({ + ok: false, + worktrees: [{ path: repo.path, head: '', branch: '', isMainWorktree: true, isBare: false }] + })) + ) + expect.soft(refresh, `${callers} concurrent callers`).toHaveBeenCalledTimes(1) + expect(h.runtime.worktreeScanCache.size).toBe(0) + expect(h.runtime.worktreeScanInFlight.size).toBe(0) + await h.runtime.listRepoWorktreesForResolution(repo) + expect.soft(refresh, `${callers} callers then a new request`).toHaveBeenCalledTimes(2) + } +}) + +it('catalog-scale corpus measures held RPC qualification without optional work or metadata writes', async () => { + for (const repoCount of [20, 200]) { + const h = rig() + h.repos.splice(0) + for (const key of Object.keys(h.metadata)) { + delete h.metadata[key] + } + let gitRows = 0 + for (let index = 0; index < repoCount; index++) { + const folder = index % 10 === 0 + const repo: Repo = { + id: `r${index + 1}`, + path: `/private-fixture/w${index + 1}`, + displayName: `Repository ${index}`, + kind: folder ? 'folder' : 'git', + badgeColor: '', + addedAt: 0, + executionHostId: 'local' + } + h.repos.push(repo) + const worktrees = Array.from({ length: 4 }, (_, row) => ({ + path: row === 0 ? repo.path : `${repo.path}/checkouts/feature-${row}`, + head: `commit-${index}-${row}`, + branch: `refs/heads/feature-${row}`, + isMainWorktree: row === 0, + isBare: false + })) + worktrees.forEach((row, number) => { + const id = folder + ? number === 0 + ? `${repo.id}::${repo.path}` + : getRuntimeFolderWorkspaceInstanceId(repo, `folder-${number}`) + : `${repo.id}::${row.path}` + h.metadata[id] = { + instanceId: `instance-${index}-${number}`, + hostId: 'local', + displayName: index === 1 && number === 0 ? 'W2' : `Workspace ${index}-${number}` + } + }) + if (!folder) { + gitRows += worktrees.length + h.runtime.worktreeScanCache.set(`${repo.id}\0local`, { + generation: 0, + runtimeKey: '', + result: { ok: true, worktrees }, + listing: { ok: true, worktrees }, + expiresAt: Date.now() + 60_000, + adminFingerprint: null, + scannedAt: Date.now() + }) + } + } + const models = Array.from({ length: repoCount * 4 }, (_, index) => ({ + id: `provider/model-${index}`, + label: `Provider model ${index}`, + isDefault: index === 0, + efforts: [{ value: 'high', label: 'High' }] + })) + h.catalogStore.recordSuccess( + h.fingerprint, + 'opencode', + { origin: 'probe', models }, + 'discovery' + ) + await h.catalog.demand({ + agent: 'opencode', + workspace: { + selector: 'name:W2', + identity: () => h.runtime.structuredAgentSessionCatalogWorkspaceIdentity('name:W2'), + resolve: async () => ({ + identity: h.runtime.structuredAgentSessionCatalogWorkspaceIdentity('name:W2'), + path: '/private-fixture/w2' + }) + } + }) + let repoVisits = 0 + let metadataVisits = 0 + let metadataKeys = 0 + const repos = h.repos.slice() + repos.forEach((repo, index) => { + Object.defineProperty(repos, index, { + get() { + repoVisits++ + return repo + } + }) + }) + const metadata = new Proxy(h.metadata, { + get(target, key) { + if (typeof key === 'string') { + metadataVisits++ + return target[key] + } + return undefined + }, + ownKeys(target) { + const keys = Reflect.ownKeys(target) + metadataKeys += keys.length + return keys + } + }) + vi.spyOn(h.store, 'getRepos').mockImplementation(() => repos) + vi.spyOn(h.store, 'getAllWorktreeMeta').mockImplementation(() => metadata) + const projection = vi.spyOn(h.runtime, 'projectCachedWorktrees') + const before = h.counts() + const start = performance.now() + let answer: unknown + for (let read = 0; read < 4; read++) { + answer = await h.current() + } + const elapsedMs = performance.now() - start + expect(answer).toMatchObject({ + models: expect.arrayContaining([{ ...models[0], isDefault: true }]) + }) + expect(h.counts()).toEqual(before) + expect(projection).toHaveBeenCalledTimes(4) + expect(repoVisits).toBeGreaterThanOrEqual(repoCount) + expect(repoVisits).toBeLessThanOrEqual(4 * (repoCount ** 2 + 10 * repoCount)) + expect(metadataVisits).toBeLessThanOrEqual(4 * repoCount * 4) + expect(metadataKeys).toBeGreaterThan(0) + expect(metadataKeys).toBeLessThanOrEqual(4 * (repoCount / 10) * (repoCount * 4)) + expect( + projection.mock.results.every( + (result) => result.type === 'return' && result.value.worktrees.length === repoCount * 4 + ) + ).toBe(true) + process.stdout.write( + `CATALOG_CORPUS ${JSON.stringify({ + repoCount, + folderRepos: repoCount / 10, + gitRows, + workspaceRows: repoCount * 4, + modelRows: models.length, + reads: 4, + projections: projection.mock.calls.length, + repoVisits, + metadataVisits, + metadataKeys, + responseBytes: Buffer.byteLength(JSON.stringify(answer)), + elapsedMs, + optionalWork: h.counts().map((value, index) => value - before[index]) + })}\n` + ) + h.catalog.stop() + vi.restoreAllMocks() + } +}) diff --git a/src/main/runtime/session-tabs/structured-session-create-support.ts b/src/main/runtime/session-tabs/structured-session-create-support.ts index 0e637335cf5..fccfb7f58e6 100644 --- a/src/main/runtime/session-tabs/structured-session-create-support.ts +++ b/src/main/runtime/session-tabs/structured-session-create-support.ts @@ -4,7 +4,7 @@ import { } from '../../native-chat/structured-agent-cli-source' import type { OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce as RestoreStructuredAgentSessionTabsOnceMethods } from './structured-session-tab-restoration' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' -import { OrcaRuntimeWithStructuredSessionAccountHome } from './structured-session-account-home' +import { StructuredSessionWorkspaceScope } from './structured-session-workspace-scope' import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' import { resolveCommittedStructuredAgentSessionAdoptionIntent, @@ -13,8 +13,6 @@ import { structuredAgentSessionAdoptAttach, type StructuredAgentSessionCreateIntentInput } from '../structured-agent-session-create-adoption' -import { isNativeLocalLocation, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' -import { getLocalProjectWorktreeGitOptions } from '../../project-runtime-git-options' import type { AgentSessionAttachParams } from '../../native-chat/agent-session-wire/structured-agent-session-attach' import { resolveHostStructuredAgentCreateSupport, @@ -26,16 +24,11 @@ import { structuredLaunchSeedOptions } from '../../../shared/native-chat/input/n import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from '../structured-agent-session-runtime' import { ensureStructuredAgentSessionHostUnlessRefused } from '../structured-agent-session-host-refusal' import { getProfileUserDataPath } from '../../orca-profiles/profile-storage-paths' -import { parseWslUncPath } from '../../../shared/wsl-paths' -import { - isFloatingWorkspaceId, - isFloatingWorkspaceSelector -} from '../../../shared/floating-workspace-worktree' -import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { isFloatingWorkspaceSelector } from '../../../shared/floating-workspace-worktree' import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' -export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaRuntimeWithStructuredSessionAccountHome { +export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends StructuredSessionWorkspaceScope { protected abstract restoreStructuredAgentSessionTabsOnce( ...args: Parameters< RestoreStructuredAgentSessionTabsOnceMethods['restoreStructuredAgentSessionTabsOnce'] @@ -84,42 +77,6 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte return structuredLaunchSeedOptions(this.requireStore().getSettings(), agent) } - protected async resolveStructuredAgentSessionLocation(worktreeSelector: string) { - const target = await this.resolveRuntimeFileTarget(worktreeSelector) - const repo = this.store?.getRepo(target.worktree.repoId) - const folderScope = parseWorkspaceKey(target.worktree.id) - // Floating workspaces are plain managed directories; folder describes management, not whether they contain Git. - const folderWorkspace = - folderScope?.type === 'folder' || isFloatingWorkspaceId(target.worktree.id) - // WSL routing is local-machine authority; use executionHostId so remote/runtime hosts cannot inherit it. - const isLocalHost = target.executionHostId === LOCAL_EXECUTION_HOST_ID - const configuredWslDistro = - repo && isLocalHost - ? (getLocalProjectWorktreeGitOptions(this.requireStore(), repo).wslDistro ?? null) - : null - // Folder workspaces lack repo Git options; WSL UNC paths are their durable signal to refuse native Windows Codex. - const wslDistro = - configuredWslDistro ?? - (folderWorkspace && isLocalHost - ? (parseWslUncPath(target.worktree.path)?.distro ?? null) - : null) - return { - executionHostId: target.executionHostId, - wslDistro, - workspaceId: target.worktree.id, - workspaceKind: folderWorkspace ? ('folder' as const) : ('git-worktree' as const) - } - } - - /** Where a structured chat here would run, when that is a directory on this machine. */ - async resolveStructuredAgentSessionLocalWorkspacePath(worktreeSelector: string) { - const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector) - if (!isNativeLocalLocation(location)) { - return null - } - return (await this.resolveRuntimeFileTarget(worktreeSelector)).worktree.path - } - async resolveStructuredAgentSessionCreateIntent( input: StructuredAgentSessionCreateIntentInput ): Promise { diff --git a/src/main/runtime/session-tabs/structured-session-workspace-scope.ts b/src/main/runtime/session-tabs/structured-session-workspace-scope.ts new file mode 100644 index 00000000000..c8b7a64d921 --- /dev/null +++ b/src/main/runtime/session-tabs/structured-session-workspace-scope.ts @@ -0,0 +1,157 @@ +import { OrcaRuntimeWithStructuredSessionAccountHome } from './structured-session-account-home' +import { + isNativeLocalLocation, + LOCAL_EXECUTION_HOST_ID, + getConnectionExecutionHostId, + getRepoExecutionHostId, + getRepoRoutingConnectionId +} from '../../../shared/execution-host' +import { getLocalProjectWorktreeGitOptions } from '../../project-runtime-git-options' +import { + isFloatingWorkspaceId, + isFloatingWorkspaceSelector +} from '../../../shared/floating-workspace-worktree' +import { parseWslUncPath } from '../../../shared/wsl-paths' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { splitWorktreeIdForFilesystem } from '../../../shared/worktree/id' +import { resolveWorktreeHostRouting } from '../worktree-launch-host-repo' +import { getRepoHostIdentityForParts } from '../../../shared/repo-host-identity' +import type { OrcaRuntimeWithPersistHeadlessTerminalTitle } from '../workspaces/workspace-file-session-targets' +import type { OrcaRuntimeWithTransitionGraphReloadToTerminalState } from '../session-graph/session-graph-authority' +import type { RuntimeWorkspaceCatalog } from '../workspaces/workspace-catalog' +import type { RuntimeWorkspaceSelectors } from '../workspaces/workspace-selectors' + +export abstract class StructuredSessionWorkspaceScope extends OrcaRuntimeWithStructuredSessionAccountHome { + protected abstract resolveFolderWorkspaceSelector( + ...args: Parameters< + OrcaRuntimeWithTransitionGraphReloadToTerminalState['resolveFolderWorkspaceSelector'] + > + ): ReturnType< + OrcaRuntimeWithTransitionGraphReloadToTerminalState['resolveFolderWorkspaceSelector'] + > + + protected abstract projectCachedWorktrees( + ...args: Parameters + ): ReturnType + + protected abstract matchWorktreeSelector( + ...args: Parameters + ): ReturnType + + protected async resolveStructuredAgentSessionLocation(worktreeSelector: string) { + const target = await this.resolveRuntimeFileTarget(worktreeSelector) + return this.structuredAgentSessionLocationForTarget(target) + } + + protected structuredAgentSessionLocationForTarget( + target: Awaited< + ReturnType + > + ) { + const routing = resolveWorktreeHostRouting(this.store?.getRepos() ?? [], target.worktree) + if (routing.kind === 'ambiguous') { + throw new Error('worktree_execution_host_unresolved') + } + const repo = routing.kind === 'resolved' ? routing.repo : null + const folderScope = parseWorkspaceKey(target.worktree.id) + // The floating workspace is a plain directory with no repo git options, which is exactly what + // `folder` denotes here — it describes how Orca manages the place, not whether git is in it. + const folderWorkspace = + folderScope?.type === 'folder' || isFloatingWorkspaceId(target.worktree.id) + // WSL routing describes *this* machine; no remote or runtime host may inherit + // it. Both branches key on executionHostId: the target no longer carries a + // connectionId, which used to spell remote, unresolved and local alike. + const isLocalHost = target.executionHostId === LOCAL_EXECUTION_HOST_ID + const configuredWslDistro = + repo && isLocalHost + ? (getLocalProjectWorktreeGitOptions(this.requireStore(), repo).wslDistro ?? null) + : null + // Folder workspaces have no repo Git options, so a WSL UNC path is the only + // durable signal that native Windows structured Codex cannot safely use it. + const wslDistro = + configuredWslDistro ?? + (folderWorkspace && isLocalHost + ? (parseWslUncPath(target.worktree.path)?.distro ?? null) + : null) + return { + executionHostId: target.executionHostId, + wslDistro, + workspaceId: target.worktree.id, + workspaceKind: folderWorkspace ? ('folder' as const) : ('git-worktree' as const) + } + } + + private projectStructuredAgentCatalogTarget(selector: string) { + const folder = this.resolveFolderWorkspaceSelector(selector) + if (folder) { + return { + worktree: this.folderWorkspaceToResolvedWorktree(folder), + executionHostId: getConnectionExecutionHostId( + this.resolveFolderWorkspaceConnectionId(folder) + ) + } + } + const snapshot = this.projectCachedWorktrees() + const explicitId = this.getValidatedExplicitWorktreeIdSelector(selector) + const repoId = explicitId ? splitWorktreeIdForFilesystem(explicitId)?.repoId : undefined + for (const repo of this.store?.getRepos() ?? []) { + if (repoId && repo.id !== repoId) { + continue + } + if ( + snapshot.unscannedRepoKeys.has( + getRepoHostIdentityForParts(repo.id, getRepoExecutionHostId(repo)) + ) && + !(explicitId && getRepoRoutingConnectionId(repo) && this.store?.getWorktreeMeta(explicitId)) + ) { + return null + } + } + return this.runtimeFileTargetForWorktree( + this.matchWorktreeSelector(selector, snapshot.worktrees) + ) + } + + structuredAgentSessionCatalogWorkspaceIdentity(selector: string): string | null { + if (isFloatingWorkspaceSelector(selector)) { + const settings = this.store?.getSettings() + return JSON.stringify([ + 'floating', + settings?.floatingTerminalCwd, + settings?.floatingTerminalTrustedCwds + ]) + } + try { + const target = this.projectStructuredAgentCatalogTarget(selector) + return target + ? JSON.stringify([ + target.worktree.path, + this.structuredAgentSessionLocationForTarget(target) + ]) + : null + } catch (error) { + return JSON.stringify(['unresolved', error instanceof Error ? error.message : 'unknown']) + } + } + + async resolveStructuredAgentCatalogWorkspace(selector: string) { + const target = await this.resolveRuntimeFileTarget(selector) + const location = this.structuredAgentSessionLocationForTarget(target) + const identity = this.structuredAgentSessionCatalogWorkspaceIdentity(selector) + if ( + !isFloatingWorkspaceSelector(selector) && + identity !== JSON.stringify([target.worktree.path, location]) + ) { + throw new Error('workspace_scope_changed') + } + return { identity, path: isNativeLocalLocation(location) ? target.worktree.path : null } + } + + /** Where a structured chat here would run, from the same target as its location. */ + async resolveStructuredAgentSessionLocalWorkspacePath(worktreeSelector: string) { + const target = await this.resolveRuntimeFileTarget(worktreeSelector) + return isNativeLocalLocation(this.structuredAgentSessionLocationForTarget(target)) + ? target.worktree.path + : null + } +} diff --git a/src/main/runtime/structured-agent-launch-support.test.ts b/src/main/runtime/structured-agent-launch-support.test.ts index 429ee66f334..6d5831911b2 100644 --- a/src/main/runtime/structured-agent-launch-support.test.ts +++ b/src/main/runtime/structured-agent-launch-support.test.ts @@ -5,7 +5,11 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { acpLaunchSpecFor } from '../acp/acp-launch-specs' -import { structuredAgentSupportsLaunch } from './structured-agent-launch-support' +import { + structuredAgentSupportsLaunch, + structuredAgentHostLaunchSources, + resolveHostStructuredAgentLaunchEnvironment +} from './structured-agent-launch-support' import { createStructuredAgentEnvironmentResolvers } from './structured-agent-shell-environment' import type * as LoginShellModule from '../startup/login-shell-environment' import type * as VersionProbeModule from '../agent-cli-version-probe' @@ -189,3 +193,28 @@ describe('structuredAgentSupportsLaunch', () => { } ) }) + +it('uses the same configured launch profile for a checking session as the ordinary host launch', async () => { + const configured = { + ...settings, + agentPermissionDefault: 'own' as const, + agentDefaultEnv: { goose: { GOOSE_MODE: 'manual', CATALOG_OWNER: 'configured' } } + } + const controller = new AbortController() + try { + const ordinary = await resolveHostStructuredAgentLaunchEnvironment('goose', configured) + const checking = await structuredAgentHostLaunchSources( + 'goose', + { + requireStore: () => ({ getSettings: () => configured }), + resolveStructuredAgentAccountHome: async () => ({ variable: 'HOME', path: '/fixture-home' }) + }, + controller.signal + ) + expect(checking.env).toEqual(ordinary) + expect(checking.env).toMatchObject({ GOOSE_MODE: 'manual', CATALOG_OWNER: 'configured' }) + expect(checking.commandSettings).toBe(configured) + } finally { + controller.abort() + } +}) diff --git a/src/main/runtime/structured-agent-launch-support.ts b/src/main/runtime/structured-agent-launch-support.ts index 30db49a8394..10cadcfedcb 100644 --- a/src/main/runtime/structured-agent-launch-support.ts +++ b/src/main/runtime/structured-agent-launch-support.ts @@ -2,7 +2,10 @@ import type { AgentSessionExecutionLocation } from '../../shared/agent-session-r import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' import type { GlobalSettings } from '../../shared/global-settings-types' import { isTuiAgent } from '../../shared/tui-agent-config' -import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import { + resolveTuiAgentLaunchEnv, + type AgentLaunchProfileSettings +} from '../../shared/tui-agent-launch-defaults' import { checkAgentCliVersion, launchableRelease, @@ -21,16 +24,19 @@ import { import { structuredAgentRuntimeMetadata } from './structured-agent-runtime-metadata' import { refreshHostAgentBaseEnvironmentAfterMiss, - resolveHostAgentBaseEnvironment + resolveHostAgentBaseEnvironment, + createStructuredAgentEnvironmentResolvers } from './structured-agent-shell-environment' import type { AgentSessionAccountHome } from '../../shared/agent-session-account-home' +import { nativeChatShellEnvironmentPolicy } from '../../shared/native-chat/native-chat-shell-environment' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' type LaunchEnvironmentSettings = Pick< GlobalSettings, 'agentDefaultEnv' | 'nativeChatInheritShellEnvironment' | 'nativeChatShellEnvironmentVariables' > & - Partial> + Partial> & + AgentLaunchProfileSettings /** What the check reads from the runtime asking it. */ type LaunchSettingsRuntime = { requireStore(): { getSettings(): LaunchEnvironmentSettings } } @@ -104,7 +110,8 @@ export async function structuredAgentHostLaunchSources( agent: StructuredAgentId, runtime: Pick & { resolveStructuredAgentAccountHome(agent: StructuredAgentId): Promise - } + }, + signal: AbortSignal ): Promise<{ accountHome: AgentSessionAccountHome env: Record @@ -112,9 +119,22 @@ export async function structuredAgentHostLaunchSources( dataDirectory: string }> { const settings = runtime.requireStore().getSettings() + const policy = nativeChatShellEnvironmentPolicy(settings) + const overlay = isTuiAgent(agent) ? { ...resolveTuiAgentLaunchEnv(agent, settings) } : {} + const environment = createStructuredAgentEnvironmentResolvers({ + prewarm: false, + processEnvironment: { ...process.env }, + resolveShellEnvironmentPolicy: () => policy, + resolveAgentLaunchEnv: () => overlay + }) + const release = environment.bindCatalogSources([agent]) + signal.addEventListener('abort', release, { once: true }) + if (signal.aborted) { + release() + } return { accountHome: await runtime.resolveStructuredAgentAccountHome(agent), - env: await resolveHostStructuredAgentLaunchEnvironment(agent, settings), + env: await environment.resolveAgentEnvironment(agent), commandSettings: settings, // The chat host's own state root (ensureStructuredAgentSessionHost). dataDirectory: getProfileUserDataPath() diff --git a/src/main/runtime/structured-agent-model-catalog-discovery.test.ts b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts index 20db5aec4d4..2cbc12b74c7 100644 --- a/src/main/runtime/structured-agent-model-catalog-discovery.test.ts +++ b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts @@ -43,7 +43,8 @@ function context(): StructuredAgentModelCatalogContext { environment: { resolveBaseEnvironment: unused, resolveAgentEnvironment: unused, - refreshAfterMissingProgram: unused + refreshAfterMissingProgram: unused, + bindCatalogSources: () => () => {} } } } @@ -138,7 +139,8 @@ describe('the model catalog contract on every registration', () => { environment: { resolveBaseEnvironment: env, resolveAgentEnvironment: env, - refreshAfterMissingProgram: async () => {} + refreshAfterMissingProgram: async () => {}, + bindCatalogSources: () => () => {} } }) const variables = { claude: 'CLAUDE_CONFIG_DIR', codex: 'CODEX_HOME', grok: 'GROK_HOME' } @@ -185,7 +187,8 @@ describe('the model catalog contract on every registration', () => { environment: { resolveBaseEnvironment: env, resolveAgentEnvironment: env, - refreshAfterMissingProgram: refresh + refreshAfterMissingProgram: refresh, + bindCatalogSources: () => () => {} } }) for (const [agent, variable] of Object.entries(agents)) { diff --git a/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts b/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts index 5023b2b73e0..752f4b68aec 100644 --- a/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts +++ b/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts @@ -37,7 +37,8 @@ function context(): StructuredAgentModelCatalogContext { environment: { resolveBaseEnvironment: unused, resolveAgentEnvironment: unused, - refreshAfterMissingProgram: unused + refreshAfterMissingProgram: unused, + bindCatalogSources: () => () => {} } } } diff --git a/src/main/runtime/structured-agent-model-catalog-wiring.ts b/src/main/runtime/structured-agent-model-catalog-wiring.ts index 78ef27a742e..d6262639e24 100644 --- a/src/main/runtime/structured-agent-model-catalog-wiring.ts +++ b/src/main/runtime/structured-agent-model-catalog-wiring.ts @@ -99,7 +99,8 @@ export async function modelCatalogHostDeps(input: { /** The installed host lists what a changed account or agent setting made cold; with no host yet, * its install lists everything anyway. */ export function prewarmStructuredAgentModelCatalogs(): void { - void getStructuredAgentSessionHost()?.deps.modelCatalog?.prewarm() + const catalog = getStructuredAgentSessionHost()?.deps.modelCatalog + void catalog?.prewarm() } // Re-exported so the runtime deps type can reference the resolver shape without diff --git a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts index 5399513eafc..529c2a4b82c 100644 --- a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts +++ b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts @@ -277,6 +277,7 @@ async function newChatFrames( await modelCatalog.read({ agent, waitForListing: true, workspacePath }), { newLaunch: true } ) + await modelCatalog.prepareSaved({ agent, sessionId: record.sessionId, inSessionWorkspace: true }) // Read before applying the answer: applying updates the state's option record in place. const firstPills = pills(first) const atRest = await readStructuredAgentSessionOptions(context, record.sessionId, { diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index ad074783d6d..d2207587d5c 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -78,7 +78,7 @@ function createCodexAdapter(context: StructuredAgentAdapterContext): StructuredA readModelCatalog: (sessionId, verifiedOnly) => host()?.deps.modelCatalog?.peek(sessionId, verifiedOnly) ?? null, demandModelCatalog: async (sessionId, legacy) => - host()?.deps.modelCatalog?.read({ + host()?.deps.modelCatalog?.demand({ agent: 'codex', sessionId, ...(legacy ? { legacy: true } : {}) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 7d5dbb188f8..61bc693f719 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -542,7 +542,7 @@ describe("a queued card's Send-now while a Codex turn runs", () => { }) describe('a second send made after Codex answered the first, before it opened that turn', () => { - it('waits for the opener before steering the later send, which Stop then withdraws', async () => { + it('waits for the native turn to open before steering, keeps echo acceptance separate, and withdraws on Stop', async () => { const opening = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) const followUp = await send('and check the tests') @@ -556,10 +556,14 @@ describe('a second send made after Codex answered the first, before it opened th 'running' ) await vi.waitFor(() => expect(loop.isRunning(SESSION)).toBe(false)) - expect(steers).toBe(0) + expect(steers).toBe(1) expect(verdictOf((await settled()).submissions, followUp)).toBe('pending') + expect(verdictOf((await settled()).submissions, opening)).toBe('pending') turns.echo(opening) - await vi.waitFor(() => expect(steers).toBe(1)) + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, opening)).toBe('accepted') + ) + expect(steers).toBe(1) expect(answers).toBe(1) await stop() diff --git a/src/main/runtime/structured-agent-session-host-assembly.ts b/src/main/runtime/structured-agent-session-host-assembly.ts index c0b4e8f2d00..5e8fea9b53b 100644 --- a/src/main/runtime/structured-agent-session-host-assembly.ts +++ b/src/main/runtime/structured-agent-session-host-assembly.ts @@ -120,9 +120,13 @@ export async function installOnJournal( const stopCommands = bindAtRestCommandContexts( registrations.map(({ adapter }) => adapter.atRestCommands) ) + const unbindEnvironment = envResolvers.bindCatalogSources( + registrations.map(({ definition }) => definition.agent) + ) try { setStructuredAgentSessionHost(host) } catch (error) { + unbindEnvironment() stopCommands() throw error } @@ -132,6 +136,7 @@ export async function installOnJournal( journalDatabase, waitForRecovery: lifecycle.drain, stopBackgroundWork: () => { + unbindEnvironment() stopCommands() stopVisualsSweep?.() modelCatalog?.stop() diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index a94af301b8e..e8d6cd2c16d 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -298,10 +298,13 @@ async function readKnownOptions(): Promise { expect(result).toMatchObject({ ok: true, result: { - models: [{ id: 'gpt-live', defaultEffort: 'medium' }], + models: [{ id: 'gpt-live' }], current: { model: 'gpt-live' } } }) + if (result.ok) { + expect(result.result).not.toHaveProperty('models.0.defaultEffort') + } return result }) } diff --git a/src/main/runtime/structured-agent-session-pi-startup.test.ts b/src/main/runtime/structured-agent-session-pi-startup.test.ts index ea9c7d184e9..a0b532f55ea 100644 --- a/src/main/runtime/structured-agent-session-pi-startup.test.ts +++ b/src/main/runtime/structured-agent-session-pi-startup.test.ts @@ -102,10 +102,13 @@ it('attaches Pi at spawn and holds the opening send until the provider answers i await vi.waitFor(() => expect(script.prompts()).toEqual(['hello while Pi is starting'])) await vi.waitFor(() => expect(peekOpenedAgentSessionRecordStore()?.getRecord(HOST_TEST_SESSION)?.options).toEqual({ - model: 'scripted/model-1', - effort: 'medium' + model: 'scripted/model-1' }) ) + await expect(host.readOptions(HOST_TEST_SESSION)).resolves.toMatchObject({ + current: { model: 'scripted/model-1', effort: 'medium' } + }) + expect(script.wire()).not.toContain('set_thinking_level') await stopStructuredAgentSessionRuntime() expect(script.closes()).toBe(1) }) diff --git a/src/main/runtime/structured-agent-session-runtime-contract.ts b/src/main/runtime/structured-agent-session-runtime-contract.ts new file mode 100644 index 00000000000..1f345e5817d --- /dev/null +++ b/src/main/runtime/structured-agent-session-runtime-contract.ts @@ -0,0 +1,66 @@ +import type { StructuredAgentSessionHostDeps } from '../native-chat/agent-session-wire/structured-agent-session-host' +import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' +import type { StructuredAgentSessionTransportOverrides } from './structured-agent-session-transport-overrides' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { StructuredAttentionMobileDelivery } from './structured-agent-session-mobile-attention' +import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' +import type { NativeChatShellEnvironmentPolicy } from '../../shared/native-chat/native-chat-shell-environment' +import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' +import type { RuntimeAgentAccountHomeResolver } from './structured-agent-model-catalog-wiring' +import type { ClaudeCliFlagSupport } from '../claude/claude-cli-flag-support' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' + +export type StructuredAgentSessionRuntimeDeps = StructuredAgentSessionTransportOverrides & { + /** Host state root. The record store and the journal database both hang off it. */ + stateDirectory: string + /** Execution host this runtime *is*. A record pinned elsewhere is not ours to + * probe and not ours to spawn for. */ + hostId: string + /** Key id this host's claims are minted under. */ + claimKeyId: string + resolveWorkspacePath: (workspaceId: string) => Promise + resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string + resolveClaudeCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string + /** Which version-gated flags a Claude CLI takes; absent never passes one. */ + claudeCliFlags?: ClaudeCliFlagSupport + /** Gives each chat a scratch folder and the visuals skill that teaches it, and sweeps folders + * whose chat is gone. Wired by the real hosts only, so a test runtime never loads the bundled + * skill. */ + nativeChatVisuals?: { + /** Read this host's preference when a chat starts a provider process. */ + isEnabled: () => boolean + } + /** Required, and asserted at install time — saved Arguments must never be silently omitted. */ + resolveLaunchArgs: (provider: AgentSessionRecord['provider']) => Promise | string[] + /** Required, and asserted at install time — an absent policy must not degrade to a guess. */ + resolveClaudeAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy + /** What a chat saved before its agent had modes ran with; fills that chat's mode once. */ + resolveAgentFullAccess?: (agent: string) => boolean + /** The user's per-agent environment overlay from settings, laid over every agent's base env. */ + resolveAgentLaunchEnv?: (agent: string) => Record + /** The settings a per-agent Command override is read from, for the same agents. */ + resolveAgentCommandSettings?: () => StructuredAgentCommandSettings + resolveEnvironment?: () => Promise + /** Which login-shell variables Codex and Claude children inherit; absent inherits all. */ + resolveShellEnvironmentPolicy?: () => NativeChatShellEnvironmentPolicy + /** Where every failure the runtime and its host carry on past is reported. Required, so no path + * can drop one: the desktop and headless hosts both pass the trace-file logger. */ + logger: StructuredAgentSessionLogger + /** Every structured-session status projection, for host-side reactions such as the first-work + * workspace rename that CLI agents get from their hooks. */ + onSessionStatusChanged?: StructuredAgentSessionHostDeps['onSessionStatusChanged'] + conversationNaming?: StructuredAgentSessionHostDeps['conversationNaming'] + /** See `StructuredAgentSessionHostDeps.hasOpenDispatch`. */ + hasOpenDispatch?: StructuredAgentSessionHostDeps['hasOpenDispatch'] + resolveCodexAccountKind?: (home: string) => AgentSessionAccountKind | undefined + /** Launch prep's sync for a probed home; see `CodexModelCatalogProbeDeps.prepareHome`. */ + prepareCodexCatalogProbeHome?: (homePath: string) => void + /** See `StructuredAgentSessionHostDeps.onSessionTabHidden`. */ + onSessionTabHidden?: StructuredAgentSessionHostDeps['onSessionTabHidden'] + assertNoTerminalWriter?: StructuredAgentSessionHostDeps['assertNoTerminalWriter'] + /** Host-owned phone delivery and reconciliation from the current journal projection. */ + attentionDelivery?: StructuredAttentionMobileDelivery + /** The account home a structured launch would pin right now, for catalog + * reads with no session record. Absent disables the catalog surface. */ + resolveAgentAccountHome?: RuntimeAgentAccountHomeResolver +} & Pick diff --git a/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts b/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts index 0dbc99dac07..f49554ca113 100644 --- a/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts @@ -4,6 +4,8 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' import type * as ModelCatalogWiring from './structured-agent-model-catalog-wiring' +import type * as ShellEnvironment from './structured-agent-shell-environment' +import * as registry from '../native-chat/agent-session-wire/structured-agent-session-registry' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime @@ -11,6 +13,29 @@ import { import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const catalog = vi.hoisted((): { service: AgentModelCatalogService | null } => ({ service: null })) +const environmentReleases = vi.hoisted(() => vi.fn()) + +vi.mock('./structured-agent-shell-environment', async (importOriginal) => { + const real = await importOriginal() + return { + ...real, + createStructuredAgentEnvironmentResolvers: ( + ...args: Parameters + ) => { + const environment = real.createStructuredAgentEnvironmentResolvers(...args) + return { + ...environment, + bindCatalogSources: (agents: readonly string[]) => { + const release = environment.bindCatalogSources(agents) + return () => { + release() + environmentReleases(agents) + } + } + } + } + } +}) vi.mock('./structured-agent-model-catalog-wiring', async (importOriginal) => ({ ...(await importOriginal()), @@ -20,21 +45,27 @@ vi.mock('./structured-agent-model-catalog-wiring', async (importOriginal) => ({ let root: string beforeEach(async () => { + environmentReleases.mockClear() root = await mkdtemp(join(tmpdir(), 'orca-structured-catalog-lifetime-')) }) afterEach(async () => { await stopStructuredAgentSessionRuntime().catch(() => undefined) catalog.service = null + vi.restoreAllMocks() await rm(root, { recursive: true, force: true }) }) describe('the host catalog lifetime', () => { it('prewarms once the host is installed and stops its listings at teardown', async () => { const service = { + 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(), prewarm: vi.fn(async () => {}), stop: vi.fn(), @@ -53,8 +84,29 @@ describe('the host catalog lifetime', () => { }) expect(service.prewarm).toHaveBeenCalledOnce() expect(service.stop).not.toHaveBeenCalled() + expect(environmentReleases).not.toHaveBeenCalled() await stopStructuredAgentSessionRuntime() expect(service.stop).toHaveBeenCalledOnce() + expect(environmentReleases).toHaveBeenCalledOnce() + }) + + it('releases the exact environment registration when publishing the installed host fails', async () => { + vi.spyOn(registry, 'setStructuredAgentSessionHost').mockImplementationOnce(() => { + throw new Error('installation publication failed') + }) + await expect( + ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + resolveLaunchArgs: () => [], + resolveClaudeAuthPolicy: () => ({ account: 'managed' as const }) + }) + ).rejects.toThrow('installation publication failed') + expect(environmentReleases).toHaveBeenCalledOnce() }) }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 46906ee3db7..bfa379e2d15 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -1,83 +1,29 @@ // Install lazily after Electron finalizes the profile path; runtimes without structured requests need no store. Journal-open failures install a refusal. -import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' -import type { StructuredAgentSessionTransportOverrides } from './structured-agent-session-transport-overrides' -import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker' -import type { StructuredAttentionMobileDelivery } from './structured-agent-session-mobile-attention' import { structuredAgentSessionTeardownTrigger, tearDownRuntime, type InstalledRuntime } from './agent-session/structured-agent-session-runtime-teardown' -import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' -import type { - StructuredAgentSessionHost, - StructuredAgentSessionHostDeps -} from '../native-chat/agent-session-wire/structured-agent-session-host' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { stopAgentSessionAttachments } from './structured-agent-session-attachment-wiring' import { openAgentSessionRecordStoreOnce, releaseAgentSessionRecordStore } from './agent-session-record-store-slot' -import type { NativeChatShellEnvironmentPolicy } from '../../shared/native-chat/native-chat-shell-environment' -import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { neverThrowingStructuredAgentSessionLogger, type StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' -import type { RuntimeAgentAccountHomeResolver } from './structured-agent-model-catalog-wiring' -import type { ClaudeCliFlagSupport } from '../claude/claude-cli-flag-support' import { migrateLegacyNativeChatVisualsFolders } from '../native-chat/native-chat-scratch-folder-migration' export { hasPersistedStructuredAgentSessionStore } from './agent-session-record-store-slot' -export type StructuredAgentSessionRuntimeDeps = StructuredAgentSessionTransportOverrides & { - stateDirectory: string - /** Only probe or spawn records pinned to this execution host. */ - hostId: string - claimKeyId: string - resolveWorkspacePath: (workspaceId: string) => Promise - resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string - resolveClaudeCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string - /** Which version-gated flags a Claude CLI takes; absent never passes one. */ - claudeCliFlags?: ClaudeCliFlagSupport - /** Create per-chat visuals/scratch folders and sweep retired chats; only real hosts load the bundled skill. */ - nativeChatVisuals?: { - isEnabled: () => boolean - } - /** Required at installation so saved Arguments cannot be silently omitted. */ - resolveLaunchArgs: (provider: AgentSessionRecord['provider']) => Promise | string[] - /** Required at installation so authentication policy is never guessed. */ - resolveClaudeAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy - /** Backfill permission mode once for chats saved before modes existed. */ - resolveAgentFullAccess?: (agent: string) => boolean - resolveAgentLaunchEnv?: (agent: string) => Record - resolveAgentCommandSettings?: () => StructuredAgentCommandSettings - resolveEnvironment?: () => Promise - /** Which login-shell variables Codex and Claude children inherit; absent inherits all. */ - resolveShellEnvironmentPolicy?: () => NativeChatShellEnvironmentPolicy - /** Required so carried-on failures reach the desktop/headless trace logger. */ - logger: StructuredAgentSessionLogger - /** Host reactions to status, including the first-work workspace rename CLI hooks provide. */ - onSessionStatusChanged?: StructuredAgentSessionHostDeps['onSessionStatusChanged'] - conversationNaming?: StructuredAgentSessionHostDeps['conversationNaming'] - /** See `StructuredAgentSessionHostDeps.hasOpenDispatch`. */ - hasOpenDispatch?: StructuredAgentSessionHostDeps['hasOpenDispatch'] - resolveCodexAccountKind?: (home: string) => AgentSessionAccountKind | undefined - /** Launch prep's sync for a probed home; see `CodexModelCatalogProbeDeps.prepareHome`. */ - prepareCodexCatalogProbeHome?: (homePath: string) => void - /** See `StructuredAgentSessionHostDeps.onSessionTabHidden`. */ - onSessionTabHidden?: StructuredAgentSessionHostDeps['onSessionTabHidden'] - /** See `StructuredAgentSessionHostDeps.assertNoTerminalWriter`. */ - assertNoTerminalWriter?: StructuredAgentSessionHostDeps['assertNoTerminalWriter'] - /** Host-owned phone delivery and reconciliation from the current journal projection. */ - attentionDelivery?: StructuredAttentionMobileDelivery - /** Resolve the account home launch would pin for catalog reads without a session; absent disables catalogs. */ - resolveAgentAccountHome?: RuntimeAgentAccountHomeResolver -} & Pick +export type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime-contract' +import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime-contract' let installing: Promise | null = null diff --git a/src/main/runtime/structured-agent-shell-environment-catalog.test.ts b/src/main/runtime/structured-agent-shell-environment-catalog.test.ts new file mode 100644 index 00000000000..40068d65937 --- /dev/null +++ b/src/main/runtime/structured-agent-shell-environment-catalog.test.ts @@ -0,0 +1,400 @@ +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' +import { afterEach, expect, it, vi } from 'vitest' +import { createAgentModelCatalogSettingsExpiry } from '../native-chat/agent-model-catalog/agent-model-catalog-account-expiry' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { agentModelCatalogFingerprintForRecord } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' +import { createAgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' +import { + captureHostShellEnvironment, + createStructuredAgentEnvironmentResolvers, + structuredAgentEnvironmentIdentity +} from './structured-agent-shell-environment' + +const shell = vi.hoisted( + (): { + current: { PATH: string; IGNORED: string } + next: { PATH: string; IGNORED: string } + saved: string[] + nextSaved: string[] + } => ({ + current: { PATH: '/a', IGNORED: 'a' }, + next: { PATH: '/a', IGNORED: 'a' }, + saved: [], + nextSaved: [] + }) +) +vi.mock('../startup/login-shell-environment', () => ({ + resolveLoginShellEnvironment: async (options?: { startedAfter?: number }) => { + if (options?.startedAfter !== undefined) { + shell.current = { ...shell.next } + } + return { ...shell.current } + } +})) +vi.mock('../pty/windows-environment-path', () => ({ + cachedPersistedWindowsPathSegments: () => shell.saved, + readPersistedWindowsPathSegmentsAsync: async () => (shell.saved = [...shell.nextSaved]), + mergePersistedWindowsPathAsync: async () => {} +})) + +const cleanups: (() => void)[] = [] +afterEach(() => { + cleanups.splice(0).forEach((stop) => stop()) + vi.restoreAllMocks() + shell.current = { PATH: '/a', IGNORED: 'a' } + shell.next = { ...shell.current } + shell.saved = [] + shell.nextSaved = [] +}) + +function rig() { + const record = { + ...agentSessionRecordFixture(), + provider: 'grok', + accountHome: { variable: 'GROK_HOME', path: '/catalog-refresh-fixture' } + } + const save = vi.fn() + const defaults = vi.fn(async () => false) + const service = createAgentModelCatalogService({ + store: agentModelCatalogStore, + getConversation: (id) => { + const held = id === record.sessionId ? record : undefined + return held ? agentSessionConversationView(held) : undefined + }, + getRecord: (id) => (id === record.sessionId ? record : undefined), + drivesRecord: () => true, + resolveAccountHome: async () => record.accountHome, + recordWorkspacePath: async () => '/workspace', + workspaceMayOverrideDefaultModel: defaults + }) + const environment = createStructuredAgentEnvironmentResolvers({ + resolveShellEnvironmentPolicy: () => ({ inheritAll: false, names: [] }) + }) + cleanups.push(service.stop, environment.bindCatalogSources(['grok'])) + const listing = (id: string) => ({ + models: [{ id, label: id, isDefault: true, efforts: [] }], + configuredDefault: { modelId: id, effort: 'high' } + }) + return { + record, + service, + save, + defaults, + listing, + fingerprint: agentModelCatalogFingerprintForRecord(record) + } +} + +it('retires a producer on effective Refresh before new publication and fences late defaults and new old reports', async () => { + const h = rig() + await agentModelCatalogStore.attachPersistence({ + load: async () => [], + save: h.save, + flush: async () => {} + }) + const sourceA = h.service.captureSource(h.record) + cleanups.push(() => sourceA.release?.()) + const heldDefault = Promise.withResolvers() + h.defaults.mockReturnValueOnce(heldDefault.promise) + h.service.recordLiveListing(h.record.sessionId, h.listing('A'), sourceA) + await vi.waitFor(() => expect(h.defaults).toHaveBeenCalledOnce()) + await captureHostShellEnvironment(async () => { + shell.next = { PATH: '/b', IGNORED: 'a' } + }) + expect(sourceA()).toBe(false) + const sourceB = h.service.captureSource(h.record) + cleanups.push(() => sourceB.release?.()) + h.service.recordLiveListing(h.record.sessionId, h.listing('B'), sourceB) + await vi.waitFor(() => expect(agentModelCatalogStore.get(h.fingerprint)?.models[0]?.id).toBe('B')) + const configured = vi.spyOn(agentModelCatalogStore, 'recordConfiguredDefault') + heldDefault.resolve(false) + await vi.waitFor(() => + expect(agentModelCatalogStore.get(h.fingerprint)?.configured?.modelId).toBe('B') + ) + expect(configured.mock.calls.map((call) => call[1])).toEqual([{ modelId: 'B', effort: 'high' }]) + configured.mockClear() + const before = JSON.stringify(agentModelCatalogStore.get(h.fingerprint)) + const writes = h.save.mock.calls.length + const ingested = vi.spyOn(agentModelCatalogStore, 'recordSuccess') + await agentModelCatalogStore.refresh(h.fingerprint, 'grok', () => + Promise.reject(new Error('current failure')) + ) + h.service.recordLiveListing(h.record.sessionId, h.listing('late A'), sourceA) + h.service.providerStarted(h.record, sourceA) + await vi.waitFor(() => expect(h.defaults).toHaveBeenCalledTimes(2)) + await Promise.resolve() + expect(ingested).not.toHaveBeenCalled() + expect(configured).not.toHaveBeenCalled() + expect(JSON.stringify(agentModelCatalogStore.get(h.fingerprint))).toBe(before) + expect(agentModelCatalogStore.failureDetail(h.fingerprint)).toBe('current failure') + expect(h.save).toHaveBeenCalledTimes(writes) +}) + +it('preserves ignored inputs and other agent overlays, and releases only the exact registration', async () => { + const h = rig() + const source = h.service.captureSource(h.record) + cleanups.push(() => source.release?.()) + let overlay = '/one' + const other = createStructuredAgentEnvironmentResolvers({ + resolveShellEnvironmentPolicy: () => ({ inheritAll: true, names: [] }), + resolveAgentLaunchEnv: () => ({ PATH: overlay }) + }) + const leaveOld = other.bindCatalogSources(['pi']) + const leaveNew = other.bindCatalogSources(['pi']) + leaveOld() + cleanups.push(leaveNew) + const piRevision = agentModelCatalogStore.retirementRevision('pi') + await captureHostShellEnvironment(async () => { + overlay = '/two' + shell.next = { PATH: '/a', IGNORED: 'b' } + }) + expect(source()).toBe(true) + expect(agentModelCatalogStore.retirementRevision('pi')).toBe(piRevision + 1) + leaveNew() + await captureHostShellEnvironment(async () => { + overlay = '/three' + }) + expect(agentModelCatalogStore.retirementRevision('pi')).toBe(piRevision + 1) + const injected = createStructuredAgentEnvironmentResolvers({ + resolveEnvironment: async () => ({ PATH: '/fixed' }) + }) + cleanups.push(injected.bindCatalogSources(['grok'])) + expect(await injected.resolveAgentEnvironment('grok')).toEqual({ PATH: '/fixed' }) +}) + +it('keeps more than 256 live producer leases and releases a replaced source independently', () => { + const service = createAgentModelCatalogService({ + store: agentModelCatalogStore, + getConversation: () => undefined, + getRecord: () => undefined, + drivesRecord: () => true, + resolveAccountHome: async () => ({ variable: 'HOME', path: '/account' }), + listsInChatFolder: new Set(['opencode']) + }) + cleanups.push(service.stop) + const record = agentSessionRecordFixture() + const origin = { provider: 'opencode', location: record.location } + const first = service.captureSource(origin) + const replacement = service.captureSource(origin) + const others = Array.from({ length: 257 }, (_, index) => + service.captureSource({ + ...origin, + location: { ...origin.location, workspaceId: `other-workspace-${index}` } + }) + ) + expect(first()).toBe(true) + first.release?.() + expect(first()).toBe(false) + expect(replacement()).toBe(true) + service.retireContext(record.location.workspaceId) + expect(replacement()).toBe(false) + expect(others.every((source) => source())).toBe(true) + replacement.release?.() + others.forEach((source) => source.release?.()) +}) + +it('compares Windows effective key spelling and ignores a shadow Path value', () => { + expect( + structuredAgentEnvironmentIdentity({ Path: 'a', PATH: 'shadow', token: 'x' }, 'win32') + ).toBe(structuredAgentEnvironmentIdentity({ PATH: 'a', TOKEN: 'x' }, 'win32')) + expect(structuredAgentEnvironmentIdentity({ PATH: 'b', TOKEN: 'x' }, 'win32')).not.toBe( + structuredAgentEnvironmentIdentity({ PATH: 'a', TOKEN: 'x' }, 'win32') + ) +}) + +it('freezes persisted Windows PATH before the detection callback refreshes its cache', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + cleanups.push(() => Object.defineProperty(process, 'platform', platform)) + shell.current = { PATH: 'C:\\shell', IGNORED: 'a' } + shell.next = { ...shell.current } + shell.saved = ['C:\\before'] + shell.nextSaved = [...shell.saved] + const h = rig() + const source = h.service.captureSource(h.record) + cleanups.push(() => source.release?.()) + await captureHostShellEnvironment(async () => { + shell.saved = ['C:\\after'] + shell.nextSaved = [...shell.saved] + }) + expect(source()).toBe(false) +}) + +it('catalog-scale settles retired publication chains and bounds Refresh registrations by live owners', async () => { + for (const owners of [5, 50]) { + const records = new Map( + Array.from({ length: owners }, (_, index) => { + const record = { + ...agentSessionRecordFixture(), + sessionId: `scale-${owners}-${index}`, + provider: 'grok', + accountHome: { variable: 'GROK_HOME', path: `/generated-account/${owners}` }, + providerHandleChain: [], + location: { + executionHostId: 'local' as const, + wslDistro: null, + workspaceId: `scale-workspace-${owners}-${index}`, + workspaceKind: 'folder' as const + } + } + return [record.sessionId, record] as const + }) + ) + const folder = Promise.withResolvers() + let held = true + let pendingFolders = 0 + let peakFolders = 0 + let folderCalls = 0 + let envReads = 0 + let overlay = 'before' + let saves = 0 + let serializedBytes = 0 + let serializedModels = 0 + let firstSnapshotModels = 0 + let pendingSnapshot: + | (() => readonly NonNullable>[]) + | undefined + await agentModelCatalogStore.attachPersistence({ + load: async () => [], + save: (snapshot) => { + saves++ + pendingSnapshot = snapshot + }, + flush: async () => { + const entries = pendingSnapshot?.() + pendingSnapshot = undefined + if (!entries) { + return + } + serializedBytes += Buffer.byteLength(JSON.stringify(entries)) + const snapshotModels = entries.reduce((count, entry) => count + entry.models.length, 0) + firstSnapshotModels = snapshotModels + serializedModels += snapshotModels + } + }) + const service = createAgentModelCatalogService({ + store: agentModelCatalogStore, + getConversation: (id) => { + const held = records.get(id) + return held ? agentSessionConversationView(held) : undefined + }, + getRecord: (id) => records.get(id), + drivesRecord: () => true, + resolveAccountHome: async () => ({ + variable: 'GROK_HOME', + path: `/generated-account/${owners}` + }), + listsInChatFolder: new Set(['grok']), + recordWorkspacePath: async (record) => { + folderCalls++ + peakFolders = Math.max(peakFolders, ++pendingFolders) + try { + if (held) { + await folder.promise + } + return `/generated-workspace/${record.sessionId}` + } finally { + pendingFolders-- + } + }, + workspaceMayOverrideDefaultModel: async () => false + }) + const sources = [...records.values()].map((record) => service.captureSource(record)) + const registrations = [...records.values()].map(() => + createStructuredAgentEnvironmentResolvers({ + prewarm: false, + processEnvironment: {}, + resolveShellEnvironmentPolicy: () => ({ inheritAll: true, names: [] }), + resolveAgentLaunchEnv: () => { + envReads++ + return { CATALOG_SOURCE: overlay } + } + }).bindCatalogSources(['grok']) + ) + const models = Array.from({ length: 20 }, (_, index) => ({ + id: `model-${index}`, + label: `Model ${index}`, + isDefault: index === 0, + efforts: [{ value: 'high', label: 'High' }] + })) + const rows = [...records.values()] + const success = vi.spyOn(agentModelCatalogStore, 'recordSuccess') + const start = performance.now() + try { + rows.forEach((record, index) => { + service.recordLiveListing(record.sessionId, { models }, sources[index]) + service.recordLiveListing(record.sessionId, { models }, sources[index]) + }) + await vi.waitFor(() => expect(pendingFolders).toBe(owners)) + const refreshStart = agentModelCatalogStore.retirementRevision('grok') + await captureHostShellEnvironment(async () => { + overlay = 'after' + }) + expect(envReads).toBe(owners * 2) + expect(sources.every((source) => !source())).toBe(true) + sources.forEach((source) => source.release?.()) + registrations.forEach((release) => release()) + held = false + folder.resolve() + await vi.waitFor(() => expect(folderCalls).toBe(owners * 2)) + await Promise.resolve() + expect(success).not.toHaveBeenCalled() + expect(pendingFolders).toBe(0) + const revisionAfterRefresh = agentModelCatalogStore.retirementRevision('grok') + const expireSettings = createAgentModelCatalogSettingsExpiry(agentModelCatalogStore, {}) + expireSettings( + { agentDefaultArgs: { grok: '--fixture-setting' } }, + { agentDefaultArgs: { grok: '--fixture-setting' } } + ) + const revisionAfterSettings = agentModelCatalogStore.retirementRevision('grok') + expect(revisionAfterSettings - revisionAfterRefresh).toBe(1) + await captureHostShellEnvironment(async () => { + overlay = 'released' + }) + expect(envReads).toBe(owners * 2) + expect(agentModelCatalogStore.retirementRevision('grok')).toBe(revisionAfterSettings) + const nextSources = rows.map((record) => service.captureSource(record)) + rows.forEach((record, index) => { + service.recordLiveListing(record.sessionId, { models }, nextSources[index]) + service.recordLiveListing(record.sessionId, { models }, nextSources[index]) + }) + await vi.waitFor(() => expect(success).toHaveBeenCalledTimes(owners * 2)) + await vi.waitFor(() => expect(pendingFolders).toBe(0)) + expect(saves).toBe(owners) + expect(serializedModels).toBe(0) + await agentModelCatalogStore.flushPersistence() + expect(serializedModels).toBeGreaterThanOrEqual(owners * models.length) + expect(serializedModels).toBe(firstSnapshotModels) + nextSources.forEach((source) => source.release?.()) + expect(nextSources.every((source) => !source())).toBe(true) + process.stdout.write( + `CATALOG_PUBLICATION ${JSON.stringify({ + owners, + modelRows: models.length, + retiredEvents: owners * 2, + successorEvents: owners * 2, + envReads, + refreshRetirements: revisionAfterRefresh - refreshStart, + settingsRetirements: revisionAfterSettings - revisionAfterRefresh, + folderCalls, + peakFolders, + pendingFolders, + publicationCalls: success.mock.calls.length, + saves, + serializedModels, + firstSnapshotModels, + serializedBytes, + elapsedMs: performance.now() - start + })}\n` + ) + } finally { + held = false + folder.resolve() + sources.forEach((source) => source.release?.()) + registrations.forEach((release) => release()) + service.stop() + success.mockRestore() + } + } +}) diff --git a/src/main/runtime/structured-agent-shell-environment.ts b/src/main/runtime/structured-agent-shell-environment.ts index 0cdbcbd4ea0..265c87fa681 100644 --- a/src/main/runtime/structured-agent-shell-environment.ts +++ b/src/main/runtime/structured-agent-shell-environment.ts @@ -1,9 +1,12 @@ +import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' import { refreshAtRestCommandContext } from '../native-chat/agent-session-wire/at-rest-command-context' import { + cachedPersistedWindowsPathSegments, mergePersistedWindowsPathAsync, readPersistedWindowsPathSegmentsAsync } from '../pty/windows-environment-path' +import { mergeWindowsPathSegments, resolvePathEnvKey } from '../pty/windows-path-segment-merge' import type { GlobalSettings } from '../../shared/global-settings-types' import { nativeChatShellEnvironmentPolicy, @@ -90,11 +93,20 @@ export async function hostShellEnvironment(cachedOnly = false): Promise { - await refreshAtRestCommandContext(captureHostShellEnvironment) + await refreshAtRestCommandContext(() => captureHostShellEnvironment()) } -export async function captureHostShellEnvironment(): Promise { - const startedAfter = Date.now() - await Promise.all([ - resolveLoginShellEnvironment({ startedAfter }), - readPersistedWindowsPathSegmentsAsync({ forceRefresh: true, startedAfter }) - ]).catch(() => {}) +type EnvironmentProjection = (shellEnv: NodeJS.ProcessEnv) => Map +const servingEnvironmentCaptures = new Set<() => EnvironmentProjection>() + +export function captureHostShellEnvironment(): Promise +export function captureHostShellEnvironment(beforeCapture: () => Promise): Promise +export async function captureHostShellEnvironment( + beforeCapture?: () => Promise +): Promise { + const cachedShell = resolveLoginShellEnvironment({ cachedOnly: true }) + const cachedPath = cachedPersistedWindowsPathSegments() + const comparisons = [...servingEnvironmentCaptures].map((capture) => ({ + capture, + project: capture() + })) + const before = { ...(await cachedShell) } + if (process.platform === 'win32') { + mergeWindowsPathSegments(before, cachedPath, process.platform, process.env) + } + const previous = comparisons.map(({ project }) => project(before)) + try { + return await beforeCapture?.() + } finally { + const startedAfter = Date.now() + await Promise.all([ + resolveLoginShellEnvironment({ startedAfter }), + readPersistedWindowsPathSegmentsAsync({ forceRefresh: true, startedAfter }) + ]).catch(() => {}) + const after = { ...(await resolveLoginShellEnvironment({ cachedOnly: true })) } + if (process.platform === 'win32') { + mergeWindowsPathSegments( + after, + cachedPersistedWindowsPathSegments(), + process.platform, + process.env + ) + } + comparisons.forEach(({ capture }, index) => { + if (!servingEnvironmentCaptures.has(capture)) { + return + } + const next = capture()(after) + agentModelCatalogStore.expireLaunchScopes( + (agent) => previous[index].has(agent) && previous[index].get(agent) !== next.get(agent) + ) + }) + } } /** The base env every local agent launch on this host starts from, read from current settings. */ @@ -134,6 +186,8 @@ export async function resolveHostAgentBaseEnvironment( } export type StructuredAgentEnvironmentSources = { + prewarm?: boolean + processEnvironment?: NodeJS.ProcessEnv /** Replaces the host's shell env, read once when the resolvers are made; never reread. */ resolveEnvironment?: () => Promise resolveShellEnvironmentPolicy?: () => NativeChatShellEnvironmentPolicy @@ -150,22 +204,51 @@ export function createStructuredAgentEnvironmentResolvers( /** The base with the user's overlay for `agent` laid over it. */ resolveAgentEnvironment: (agent: string) => Promise> refreshAfterMissingProgram: (askedAt: number) => Promise + bindCatalogSources: (agents: readonly string[]) => () => void } { // Start capture with the runtime so the first chat rarely waits for the shell profile. const injected = sources.resolveEnvironment?.() const shellEnvironment = injected ? () => injected : hostShellEnvironment - if (!injected) { + if (!injected && sources.prewarm !== false) { void hostShellEnvironment().catch(() => {}) } const resolveBase = async (cachedOnly = false): Promise> => structuredAgentBaseEnvironment({ shellEnv: await shellEnvironment(cachedOnly), - policy: sources.resolveShellEnvironmentPolicy?.() ?? nativeChatShellEnvironmentPolicy(null) + policy: sources.resolveShellEnvironmentPolicy?.() ?? nativeChatShellEnvironmentPolicy(null), + processEnv: sources.processEnvironment }) return { refreshAfterMissingProgram: injected ? async () => {} : refreshHostAgentBaseEnvironmentAfterMiss, + bindCatalogSources: (agents) => { + if (injected) { + return () => {} + } + const capture = (): EnvironmentProjection => { + const selectedPolicy = + sources.resolveShellEnvironmentPolicy?.() ?? nativeChatShellEnvironmentPolicy(null) + const policy = { ...selectedPolicy, names: [...selectedPolicy.names] } + const processEnv = { ...(sources.processEnvironment ?? process.env) } + const overlays = new Map( + agents.map((agent) => [agent, { ...sources.resolveAgentLaunchEnv?.(agent) }]) + ) + return (shellEnv) => { + const base = structuredAgentBaseEnvironment({ shellEnv, policy, processEnv }) + return new Map( + agents.map((agent) => { + const effective = { ...base, ...overlays.get(agent) } + return [agent, structuredAgentEnvironmentIdentity(effective)] + }) + ) + } + } + servingEnvironmentCaptures.add(capture) + return () => { + servingEnvironmentCaptures.delete(capture) + } + }, resolveBaseEnvironment: resolveBase, resolveAgentEnvironment: async (agent) => ({ ...(await resolveBase()), @@ -173,3 +256,25 @@ export function createStructuredAgentEnvironmentResolvers( }) } } + +/** Compare the environment block the launcher reads, including Windows' first matching key. */ +export function structuredAgentEnvironmentIdentity( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform = process.platform +): string { + const effective = new Map() + const pathKey = resolvePathEnvKey(env, platform) + for (const [key, value] of Object.entries(env)) { + if (value === undefined) { + continue + } + const name = platform === 'win32' ? key.toUpperCase() : key + if (platform === 'win32' && name === 'PATH' && key !== pathKey) { + continue + } + if (!effective.has(name)) { + effective.set(name, value) + } + } + return JSON.stringify([...effective].sort(([a], [b]) => a.localeCompare(b))) +} diff --git a/src/main/runtime/structured-codex-child-work-runtime.test.ts b/src/main/runtime/structured-codex-child-work-runtime.test.ts index 534ae958d0a..0dde84c24e8 100644 --- a/src/main/runtime/structured-codex-child-work-runtime.test.ts +++ b/src/main/runtime/structured-codex-child-work-runtime.test.ts @@ -195,6 +195,9 @@ describe('Codex background-only resume through hook shutdown', () => { }) params.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}` expect(await host.attach({ callerKey: 'restart-test' }, params)).toMatchObject({ ok: true }) + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') + ) const notify = runtime.codex.notify notify('turn/started', { threadId: RESTART_THREAD, turn: { id: 'lead-turn' } }) if (kind === 'agent') { diff --git a/src/main/runtime/workspaces/resolved-worktree-scan-join.ts b/src/main/runtime/workspaces/resolved-worktree-scan-join.ts index aa546998928..8da0ce49a64 100644 --- a/src/main/runtime/workspaces/resolved-worktree-scan-join.ts +++ b/src/main/runtime/workspaces/resolved-worktree-scan-join.ts @@ -16,6 +16,7 @@ import { getAgentLaunchPlatformForRepo } from '../runtime-agent-launch-resolutio import type { ResolvedWorktreeSnapshot } from '../runtime-resolved-worktree-cache' import type { RuntimeStore } from '../runtime-store-contract' import { + projectRepoWorktreeRows, joinRepoWorktreeRows, scanRepoWorktreeRows, type RepoWorktreeRowDeps, @@ -81,7 +82,8 @@ export async function scanResolvedWorktreeRepos( export function joinResolvedWorktreeScans( store: RuntimeStore | null, rowDeps: () => RepoWorktreeRowDeps, - scans: ResolvedWorktreeRepoScans + scans: ResolvedWorktreeRepoScans, + projectOnly = false ): ResolvedWorktreeScanJoin { if (!store) { return { @@ -108,7 +110,12 @@ export function joinResolvedWorktreeScans( if (!scan && !isFolderRepo(repo)) { unscannedRepoKeys.add(key) } - const rows = joinRepoWorktreeRows(deps, repo, scan, metaById) + const rows = (projectOnly ? projectRepoWorktreeRows : joinRepoWorktreeRows)( + deps, + repo, + scan, + metaById + ) if (scan?.ok) { scannedRepoKeys.add(key) for (const row of rows) { diff --git a/src/main/runtime/workspaces/workspace-catalog.ts b/src/main/runtime/workspaces/workspace-catalog.ts index c1e98991d02..5d1c747c4a2 100644 --- a/src/main/runtime/workspaces/workspace-catalog.ts +++ b/src/main/runtime/workspaces/workspace-catalog.ts @@ -23,7 +23,10 @@ import { type ResolvedWorktreeScanJoin } from './resolved-worktree-scan-join' import { projectResolvedWorktreeLineage } from '../../../shared/resolved-worktree-lineage' -import { listRuntimeFolderWorkspaces } from '../runtime-worktree-filesystem' +import { + listRuntimeFolderWorkspaces, + projectRuntimeFolderWorkspaces +} from '../runtime-worktree-filesystem' import type { ExecutionHostId } from '../../../shared/execution-host' import type { Repo } from '../../../shared/repo-types' import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' @@ -37,6 +40,7 @@ import { withUnregisteredRemovalCheckouts } from '../../worktree-removal-listing import { withoutInFlightWorktreeCreates } from '../../worktree-create-in-flight' import { preserveFolderUpgradeWorktreePath } from '../../folder-upgrade-worktree-path' import type { RuntimeWorktreeScanCache } from '../orca-runtime-core' +import { getRepoHostIdentityForParts } from '../../../shared/repo-host-identity' import { resolveWorktreeScanCacheTtlMs } from '../runtime-worktree-scan-cache' export abstract class RuntimeWorkspaceCatalog extends OrcaRuntimeWithReadKittyFile { @@ -128,8 +132,57 @@ export abstract class RuntimeWorkspaceCatalog extends OrcaRuntimeWithReadKittyFi ) } + protected pruneUnregisteredWorktreeScans(): void { + const registered = new Set( + (this.store?.getRepos() ?? []).map((repo) => `${repo.id}\0${getRepoExecutionHostId(repo)}`) + ) + for (const cache of [ + this.worktreeScanCache, + this.worktreeScanGenerations, + this.worktreeScanInFlight + ]) { + for (const key of cache.keys()) { + if (!registered.has(key)) { + cache.delete(key) + } + } + } + } + + /** The fleet listing from cached scans and the store as it is now, synchronously. */ protected joinResolvedWorktreeScans(scans: ResolvedWorktreeRepoScans): ResolvedWorktreeScanJoin { - return joinResolvedWorktreeScans(this.store, this.repoWorktreeRowDeps.bind(this), scans) + return joinResolvedWorktreeScans( + this.store, + () => ({ + ...this.repoWorktreeRowDeps(), + listFolderWorkspaces: (repo, count) => + projectRuntimeFolderWorkspaces(this.requireStore(), repo, count) + }), + scans + ) + } + + protected projectCachedWorktrees(): ResolvedWorktreeScanJoin { + const scans: ResolvedWorktreeRepoScans = new Map() + for (const repo of this.store?.getRepos() ?? []) { + const cached = this.worktreeScanCache.get(`${repo.id}\0${getRepoExecutionHostId(repo)}`) + if (cached) { + scans.set(getRepoHostIdentityForParts(repo.id, getRepoExecutionHostId(repo)), { + ...(cached.listing ?? cached.result), + gitRun: cached + }) + } + } + return joinResolvedWorktreeScans( + this.store, + () => ({ + ...this.repoWorktreeRowDeps(), + listFolderWorkspaces: (repo, count) => + projectRuntimeFolderWorkspaces(this.requireStore(), repo, count) + }), + scans, + true + ) } protected repoWorktreeRowDeps(): RepoWorktreeRowDeps { @@ -161,15 +214,21 @@ export abstract class RuntimeWorkspaceCatalog extends OrcaRuntimeWithReadKittyFi const cached = await this.listRepoWorktreesForResolution(repo, projectRuntimeByRepoId) // Paired detectedList must retain folder-upgrade locators or folder ids lose metadata and terminals (#30150). const scan = { ...cached, worktrees: preserveFolderUpgradeWorktreePath(repo, cached.worktrees) } - return scan.ok && getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID - ? { - ok: true, - worktrees: await withUnregisteredRemovalCheckouts( - repo.id, - withoutInFlightWorktreeCreates(repo.id, scan.worktrees) - ) - } - : scan + const listing = + scan.ok && getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID + ? { + ok: true, + worktrees: await withUnregisteredRemovalCheckouts( + repo.id, + withoutInFlightWorktreeCreates(repo.id, scan.worktrees) + ) + } + : scan + const cachedEntry = this.worktreeScanCache.get(`${repo.id}\0${getRepoExecutionHostId(repo)}`) + if (cachedEntry?.result === cached) { + cachedEntry.listing = listing + } + return listing } protected async listRepoWorktreesForResolution( @@ -192,6 +251,18 @@ export abstract class RuntimeWorkspaceCatalog extends OrcaRuntimeWithReadKittyFi : 'local:default' const now = Date.now() const scanScopeKey = `${repo.id}\0${getRepoExecutionHostId(repo)}` + const retryCurrent = (): Promise => { + const current = this.store + ?.getRepos() + .find( + (candidate) => + candidate.id === repo.id && + getRepoExecutionHostId(candidate) === getRepoExecutionHostId(repo) + ) + return current + ? this.listRepoWorktreesForResolution(current) + : Promise.resolve({ ok: false, worktrees: [] }) + } const generation = this.worktreeScanGenerations.get(scanScopeKey) ?? 0 const cached = this.worktreeScanCache.get(scanScopeKey) if ( @@ -203,45 +274,39 @@ export abstract class RuntimeWorkspaceCatalog extends OrcaRuntimeWithReadKittyFi } const inFlight = this.worktreeScanInFlight.get(scanScopeKey) if (inFlight?.generation === generation && inFlight.runtimeKey === runtimeKey) { - const refresh = await inFlight.promise - if (generation !== (this.worktreeScanGenerations.get(scanScopeKey) ?? 0)) { - return this.listRepoWorktreesForResolution(repo, projectRuntimeByRepoId) - } - return refresh.result + return (await inFlight.promise).result } const reusableCached = cached?.generation === generation && cached.runtimeKey === runtimeKey ? cached : null const promise = this.refreshRepoWorktreeScan(repo, projectRuntime, reusableCached) - this.worktreeScanInFlight.set(scanScopeKey, { generation, runtimeKey, promise }) - try { - const refresh = await promise - if (generation !== (this.worktreeScanGenerations.get(scanScopeKey) ?? 0)) { - return this.listRepoWorktreesForResolution(repo, projectRuntimeByRepoId) - } - if ( - (refresh.result.ok || !sshConnectionId) && - this.worktreeScanInFlight.get(scanScopeKey)?.promise === promise - ) { - const entry: RuntimeWorktreeScanCache = { - generation, - runtimeKey, - result: refresh.result, - expiresAt: Date.now() + resolveWorktreeScanCacheTtlMs(repo), - adminFingerprint: refresh.adminFingerprint, - scannedAt: refresh.scannedAt + .then(async (refresh) => { + if (this.worktreeScanInFlight.get(scanScopeKey)?.promise !== promise) { + return { ...refresh, result: await retryCurrent() } } - this.worktreeScanCache.set(scanScopeKey, entry) - void refresh.adminFingerprintProbe?.then((fingerprint) => { - if (this.worktreeScanCache.get(scanScopeKey) === entry) { - entry.adminFingerprint = fingerprint + if (refresh.result.ok || !sshConnectionId) { + const entry: RuntimeWorktreeScanCache = { + generation, + runtimeKey, + result: refresh.result, + expiresAt: Date.now() + resolveWorktreeScanCacheTtlMs(repo), + adminFingerprint: refresh.adminFingerprint, + scannedAt: refresh.scannedAt } - }) - } - return refresh.result - } finally { - if (this.worktreeScanInFlight.get(scanScopeKey)?.promise === promise) { - this.worktreeScanInFlight.delete(scanScopeKey) - } - } + this.worktreeScanCache.set(scanScopeKey, entry) + void refresh.adminFingerprintProbe?.then((fingerprint) => { + if (this.worktreeScanCache.get(scanScopeKey) === entry) { + entry.adminFingerprint = fingerprint + } + }) + } + return refresh + }) + .finally(() => { + if (this.worktreeScanInFlight.get(scanScopeKey)?.promise === promise) { + this.worktreeScanInFlight.delete(scanScopeKey) + } + }) + this.worktreeScanInFlight.set(scanScopeKey, { generation, runtimeKey, promise }) + return (await promise).result } } diff --git a/src/main/runtime/workspaces/workspace-file-session-targets.ts b/src/main/runtime/workspaces/workspace-file-session-targets.ts index e6d72504844..8c6553b04c0 100644 --- a/src/main/runtime/workspaces/workspace-file-session-targets.ts +++ b/src/main/runtime/workspaces/workspace-file-session-targets.ts @@ -297,8 +297,16 @@ export abstract class OrcaRuntimeWithPersistHeadlessTerminalTitle extends Runtim } } + return this.runtimeFileTargetForWorktree( + await this.resolveGitWorktreeSelector(worktreeSelector) + ) + } + + protected runtimeFileTargetForWorktree(worktree: ResolvedWorktree): { + worktree: ResolvedWorktree + executionHostId: ExecutionHostId + } { const store = this.requireStore() - const worktree = await this.resolveGitWorktreeSelector(worktreeSelector) const routing = resolveWorktreeHostRouting(store.getRepos(), worktree) if (routing.kind === 'ambiguous') { throw new Error('worktree_execution_host_unresolved') diff --git a/src/main/runtime/workspaces/workspace-repo-scan-refresh.ts b/src/main/runtime/workspaces/workspace-repo-scan-refresh.ts index 03b2c624dc1..a3de3f18a57 100644 --- a/src/main/runtime/workspaces/workspace-repo-scan-refresh.ts +++ b/src/main/runtime/workspaces/workspace-repo-scan-refresh.ts @@ -203,9 +203,16 @@ export abstract class RuntimeWorkspaceRepoScanRefresh extends RuntimeWorkspaceCa } } } + const registered = new Set( + (this.store?.getRepos() ?? []).map((repo) => `${repo.id}\0${getRepoExecutionHostId(repo)}`) + ) for (const key of scopeKeys) { - this.worktreeScanGenerations.set(key, (this.worktreeScanGenerations.get(key) ?? 0) + 1) - this.worktreeScanCache.delete(key) + if (registered.has(key)) { + this.worktreeScanGenerations.set(key, (this.worktreeScanGenerations.get(key) ?? 0) + 1) + } else { + this.worktreeScanGenerations.delete(key) + this.worktreeScanCache.delete(key) + } this.worktreeScanInFlight.delete(key) } } @@ -223,7 +230,6 @@ export abstract class RuntimeWorkspaceRepoScanRefresh extends RuntimeWorkspaceCa ) for (const key of affectedScopeKeys) { this.worktreeScanGenerations.set(key, (this.worktreeScanGenerations.get(key) ?? 0) + 1) - this.worktreeScanCache.delete(key) this.worktreeScanInFlight.delete(key) } if (affectedScopeKeys.size > 0) { diff --git a/src/main/runtime/workspaces/workspace-selectors.ts b/src/main/runtime/workspaces/workspace-selectors.ts index 912da331a6c..cf464a839f3 100644 --- a/src/main/runtime/workspaces/workspace-selectors.ts +++ b/src/main/runtime/workspaces/workspace-selectors.ts @@ -53,6 +53,14 @@ export abstract class RuntimeWorkspaceSelectors extends OrcaRuntimeWithResolveBr } } const worktrees = await this.listResolvedWorktrees() + return this.matchWorktreeSelector(selector, worktrees, explicitWorktreeId) + } + + protected matchWorktreeSelector( + selector: string, + worktrees: ResolvedWorktree[], + explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(selector) + ): ResolvedWorktree { let candidates: ResolvedWorktree[] if (selector === 'active') { diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index d5b1b839e54..f67a90ac090 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -15,8 +15,6 @@ import { agentHookServer } from '../agent-hooks/server' import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' import { browserManager } from '../browser/browser-manager' import { prewarmStructuredAgentModelCatalogs } from '../runtime/structured-agent-model-catalog-wiring' -import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import { createAgentModelCatalogSettingsExpiry } from '../native-chat/agent-model-catalog/agent-model-catalog-account-expiry' import { mainProcessState as state } from './main-process-state' // Settings that change which account or binary a new chat's agent runs under. @@ -66,12 +64,7 @@ export function initializeMainProcessAccountServices(): void { void startCodexStateDbBackfillRecoveryInBackground(getOrcaManagedCodexHomePath()) // Defer shared-home migration; compatibility launches re-arm non-destructive scans for new rollouts (#4444, #8612, #12480). state.codexSessionMigration.scheduleInitialRun() - const expireModelCatalogs = createAgentModelCatalogSettingsExpiry( - agentModelCatalogStore, - store.getSettings() - ) - store.onSettingsChanged((updates, settings) => { - expireModelCatalogs(updates, settings) + store.onSettingsChanged((updates) => { // An account switch would otherwise leave the next chat's picker on a cold catalog. if (MODEL_CATALOG_ACCOUNT_SETTINGS.some((key) => key in updates)) { prewarmStructuredAgentModelCatalogs() diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index add6c73711d..a25374b478d 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -189,6 +189,11 @@ const PIECES: Record + translate( + 'components.native-chat.failureWords.claudeEffortInvalid', + COPY.claudeEffortInvalid + ), providerRejected: (values) => translate( 'components.native-chat.failureWords.providerRejected', diff --git a/src/renderer/src/components/native-chat/native-chat-pty-retired-model.test.ts b/src/renderer/src/components/native-chat/native-chat-pty-retired-model.test.ts index 0679be977c5..ae8ebf8742e 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-retired-model.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-retired-model.test.ts @@ -1,7 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { mergeDiscoveredAuthoritativeModels } from '../../../../shared/agent-session-option-catalog' import { GROK_SESSION_OPTION_CATALOG } from '../../../../shared/agent-session-option-catalog-grok' -import { updateNativeChatSessionOptionDefaults } from '../../../../shared/native-chat/input/native-chat-session-option-defaults' +import { + applyNativeChatSessionOptionSettingsMutation, + updateNativeChatSessionOptionDefaults +} from '../../../../shared/native-chat/input/native-chat-session-option-defaults' import type { PersistedNativeChatSessionOptions } from '../../../../shared/native-chat/input/native-chat-session-options' import { clearNativeChatSessionOptionCacheForTests, @@ -19,11 +22,15 @@ const discoveredGrok5 = (): ReturnType): { +function createGrokSurface( + args?: Partial, + initial: PersistedNativeChatSessionOptions = {} +): { surface: NativeChatPtySessionOptionsSurface persisted: () => PersistedNativeChatSessionOptions + retireFutureDefault: () => void } { - let persisted: PersistedNativeChatSessionOptions = {} + let persisted = initial const surface = createNativeChatPtySessionOptions({ agent: 'grok', scopeKey: 'pty-1', @@ -42,39 +49,56 @@ function createGrokSurface(args?: Partial }, ...args })! - return { surface, persisted: () => persisted } + return { + surface, + persisted: () => persisted, + retireFutureDefault: () => { + persisted = + applyNativeChatSessionOptionSettingsMutation(persisted, { + type: 'clear-model-if-missing', + agent: 'grok', + availableModelIds: ['grok-5'] + }) ?? persisted + } + } } -/** Untrack retired models too: later option writes otherwise reinject and persist the fatal -m flag. */ -describe('retired grok model untracking', () => { +describe('current model retention beside future launch defaults', () => { beforeEach(() => clearNativeChatSessionOptionCacheForTests()) - it('untracks a picked model an authoritative discovery dropped', async () => { + it('retains a picked model without adopting it again as a future launch default', async () => { seedNativeChatAppliedSessionOptions('pty-1', 'grok', { model: 'grok-4.5' }) - const { surface, persisted } = createGrokSurface() + const { surface, persisted, retireFutureDefault } = createGrokSurface(undefined, { + grok: { model: 'grok-4.5', valuesByModel: {} } + }) surface.replaceModels(discoveredGrok5()) + retireFutureDefault() + expect(persisted().grok?.model).toBeUndefined() + // Discovery does not replace the current session selection. expect(surface.getSnapshot()[0].kind).toMatchObject({ - currentValue: 'grok-5', - choices: [expect.objectContaining({ value: 'grok-5' })] + currentValue: 'grok-4.5', + choices: expect.arrayContaining([expect.objectContaining({ value: 'grok-4.5' })]) }) - expect(readNativeChatSessionOptionCache('pty-1')?.model).toBeUndefined() + expect(readNativeChatSessionOptionCache('pty-1')?.model?.value).toBe('grok-4.5') + // A later effort write must not adopt the omitted model as a launch default. await surface.setOption('effort', 'low') - expect(persisted().grok?.model).toBe('grok-5') + expect(persisted().grok?.model).toBeUndefined() }) - it('untracks a cached retired model at surface creation', () => { - // Discovery can finish before mounting, leaving a cached model already proven retired. + it('retains a cached model omitted before surface creation', () => { + // The probe can settle before this pane mounts; the cache handoff then restores a + // tracked id the authoritative list already dropped. seedNativeChatAppliedSessionOptions('pty-1', 'grok', { model: 'grok-4.5' }) const { surface } = createGrokSurface({ initialModels: discoveredGrok5() }) expect(surface.getSnapshot()[0].kind).toMatchObject({ - currentValue: 'grok-5', - choices: [expect.objectContaining({ value: 'grok-5' })] + currentValue: 'grok-4.5', + choices: expect.arrayContaining([expect.objectContaining({ value: 'grok-4.5' })]) }) - expect(readNativeChatSessionOptionCache('pty-1')?.model).toBeUndefined() + expect(readNativeChatSessionOptionCache('pty-1')?.model?.value).toBe('grok-4.5') }) it('never persists a typed model the authoritative list lacks', () => { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-options-contract.ts b/src/renderer/src/components/native-chat/structured-agent-session-options-contract.ts new file mode 100644 index 00000000000..99b2ff1313b --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-options-contract.ts @@ -0,0 +1,21 @@ +import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisional-launch' +import type { AgentType } from '../../../../shared/agent-status-types' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' + +export type StructuredAgentSessionOptionsArgs = { + agent: AgentType + sessionId: string + target: RuntimeClientTarget + transportEnabled: boolean + isVisible: boolean + providerVisible: boolean + providerStarting?: boolean + providerRunning?: boolean + hostOptionStamp?: string + fence: number | null + turnId: string | null + unloadedTurnRevisions: number | undefined + mutate: StructuredAgentSessionMutate + launch?: StructuredAgentSessionLaunchView +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx index be15602ed84..3ea5f4eced2 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx @@ -350,3 +350,49 @@ describe('a new chat picker’s first frame', () => { local.unmount() }) }) + +it('refuses typed literals while the current fence awaits options, then accepts with an empty catalog', async () => { + const pending = deferred() + let reads = 0 + mocks.call.mockImplementation((_target: unknown, method: string) => + method === 'agentSession.options' + ? ++reads === 1 + ? Promise.resolve({ models: [], current: {} }) + : pending.promise + : Promise.resolve({ origin: 'unknown' }) + ) + const mutate = vi.fn().mockResolvedValue({ + status: 'accepted', + sameFence: true, + value: { key: 'model', value: 'literal', options: { model: 'literal' } } + }) + const view = renderHook( + ({ fence }) => + useStructuredAgentSessionOptions({ + agent: 'codex', + sessionId: 'locked-chat', + target: LOCAL_TARGET, + transportEnabled: true, + isVisible: true, + providerVisible: true, + fence, + turnId: null, + unloadedTurnRevisions: undefined, + mutate + }), + { initialProps: { fence: 1 } } + ) + await waitFor(() => expect(reads).toBe(1)) + view.rerender({ fence: 2 }) + await waitFor(() => expect(reads).toBe(2)) + await act(async () => + expect(await view.result.current.setStructuredOption('model', 'literal')).toBe(false) + ) + expect(mutate).not.toHaveBeenCalled() + await act(async () => pending.resolve({ models: [], current: {} })) + await act(async () => + expect(await view.result.current.setStructuredOption('model', 'literal')).toBe(true) + ) + expect(mutate).toHaveBeenCalledTimes(1) + view.unmount() +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx index eb08964f227..7e253662b62 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx @@ -318,12 +318,7 @@ describe('useStructuredAgentSessionOptions', () => { accepted = await result.current.setStructuredOption('model', 'gpt-5.6-luna') }) expect(accepted).toBe(true) - expect(mocks.hold).toHaveBeenCalledWith( - 'session-1', - 'model', - 'gpt-5.6-luna', - expect.anything() - ) + expect(mocks.hold).toHaveBeenCalledWith('session-1', 'model', 'gpt-5.6-luna') rerender({ ...PROVISIONAL, launchSeedOptions: SEED, heldOptions: { model: 'gpt-5.6-luna' } }) expect(currentValue(result.current.optionSnapshot, 'model')).toBe('gpt-5.6-luna') expect(descriptor(result.current.optionSnapshot, 'model')?.valueSource).toBe('dispatched') @@ -629,7 +624,7 @@ describe('useStructuredAgentSessionOptions', () => { await act(async () => { await result.current.setStructuredOption('model', 'gpt-hosted') }) - expect(mocks.hold).toHaveBeenCalledWith('session-1', 'model', 'gpt-hosted', expect.anything()) + expect(mocks.hold).toHaveBeenCalledWith('session-1', 'model', 'gpt-hosted') unmount() }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index 5bf4dd5ed67..6e8685a03e3 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -1,7 +1,4 @@ -import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' -import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisional-launch' -import type { AgentType } from '../../../../shared/agent-status-types' -import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import type { StructuredAgentSessionOptionsArgs } from './structured-agent-session-options-contract' import { toast } from 'sonner' import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import type { AgentSessionOptionResult } from '../../../../shared/agent-session-wire' @@ -33,22 +30,7 @@ import { type StructuredLaunchOptionOutcome } from '@/lib/structured-agent-session-launch-options' -export type StructuredAgentSessionOptionsArgs = { - agent: AgentType - sessionId: string - target: RuntimeClientTarget - transportEnabled: boolean - isVisible: boolean - providerVisible: boolean - providerStarting?: boolean - providerRunning?: boolean - hostOptionStamp?: string - fence: number | null - turnId: string | null - unloadedTurnRevisions: number | undefined - mutate: StructuredAgentSessionMutate - launch?: StructuredAgentSessionLaunchView -} +export type { StructuredAgentSessionOptionsArgs } from './structured-agent-session-options-contract' const NO_HELD_OPTIONS: Readonly> = {} @@ -213,12 +195,14 @@ export function useStructuredAgentSessionOptions(args: StructuredAgentSessionOpt }, [launchSeedOptions, optionStateRef, optionView, rememberOptionPicks] ) + const interactionReadyRef = useRef(acceptsPicks) const lockSnapshotRef = useRef( (snapshot: ReturnType) => snapshot ) const lockSnapshot = (snapshot: ReturnType) => !acceptsPicks ? lockedStructuredAgentSessionOptionSnapshot(snapshot) : snapshot useLayoutEffect(() => { + interactionReadyRef.current = acceptsPicks lockSnapshotRef.current = lockSnapshot }) const optionSnapshot = lockSnapshot( @@ -242,14 +226,15 @@ export function useStructuredAgentSessionOptions(args: StructuredAgentSessionOpt }, id, value, - lockSnapshotRef.current(structuredAgentSessionOptionSnapshot(view)) + lockSnapshotRef.current(structuredAgentSessionOptionSnapshot(view)), + interactionReadyRef.current ) ) { return false } if (!transportEnabled || fence === null) { // No fence yet: the launch holds it and applies it before anything else is sent. - const applied = holdStructuredAgentSessionLaunchOption(sessionId, id, encoded, view) + const applied = holdStructuredAgentSessionLaunchOption(sessionId, id, encoded) void applied?.then(settleLaunchOptionPick) return applied !== null } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index ba8b1530ac8..3ec57f37790 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -726,6 +726,7 @@ "providerExitedRejection": "{{agent}} stopped before this message was sent.", "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.", "providerRateLimited": "{{agent}} is rate-limited and retrying.", + "claudeEffortInvalid": "Claude effort is invalid; choose low, medium, high, xhigh, or max and send again.", "providerRejected": "{{agent}} didn't accept this message.", "providerRejectedQuoted": "{{agent}} didn't accept this message: {{detail}}.", "providerRetryLastError": "Last error: {{detail}}.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ecfc0b5c1d8..29fe40432bc 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17833,6 +17833,7 @@ "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.", "providerExitedRejection": "{{agent}} stopped before this message was sent.", "providerExitedAnswer": "{{agent}} has stopped, so your answer was not sent. Send a message to continue.", + "claudeEffortInvalid": "Claude effort is invalid; choose low, medium, high, xhigh, or max and send again.", "providerRejected": "{{agent}} didn't accept this message.", "providerRejectedQuoted": "{{agent}} didn't accept this message: {{detail}}.", "attachmentEmpty": "An image on this message is empty, so the message was not sent.", diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts index 1da79737f1c..b8340a8f113 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts @@ -93,11 +93,6 @@ import { subscribeStructuredAgentLaunchStatus } from './agent-session/launch/structured-agent-session-launch-registry' import { markStructuredAgentSessionLaunchPublished } from './agent-session/launch/structured-agent-session-launch-publication' -import { CODEX_SESSION_OPTION_CATALOG } from '../../../shared/agent-session-option-catalog-claude-codex' -import { - applyStructuredAgentSessionModelCatalog, - createStructuredAgentSessionOptionState -} from '../../../shared/structured-agent-session-options' const WORKTREE_ID = 'wt-1' const SESSION_ID = 'session-1' @@ -262,38 +257,31 @@ describe('picks made while a chat launches', () => { expect(mutations().map((mutation) => mutation.value)).toEqual(['gpt-picked', 'gpt-repicked']) }) - it('drops a held Fast the picked model does not offer: neither sent nor remembered', async () => { + it('sends held Fast with the picked model even when its catalog omits that tier', async () => { const created = deferred<{ sessionId: string; fence: number }>() mocks.launch.mockReturnValue(created.promise) const launch = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-tier' }) - const picker = applyStructuredAgentSessionModelCatalog( - createStructuredAgentSessionOptionState('codex', CODEX_SESSION_OPTION_CATALOG), - CODEX_SESSION_OPTION_CATALOG, - { - origin: 'probe', - fetchedAt: 1, - models: [ - { - id: 'gpt-5.5', - label: 'GPT-5.5', - isDefault: true, - efforts: [], - serviceTiers: [{ value: 'priority', label: 'Fast' }] - }, - { id: 'gpt-nofast', label: 'No Fast', isDefault: false, efforts: [] } - ] - }, - { newLaunch: true } - ) const tierPick = holdStructuredAgentSessionLaunchOption(SESSION_ID, 'serviceTier', 'priority') - holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-nofast', picker) - await expect(tierPick).resolves.toEqual({ kind: 'superseded' }) + holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-nofast') + expect(getStructuredAgentSessionLaunchSelection(SESSION_ID)?.held).toEqual({ + model: 'gpt-nofast', + serviceTier: 'priority' + }) created.resolve({ sessionId: SESSION_ID, fence: 3 }) await settle() setOptionReplies[0]!.resolve(ACCEPTED) + await settle() + setOptionReplies[1]!.resolve({ ok: true, value: { options: { serviceTier: 'priority' } } }) await launch.launchResult - expect(mutations().map(({ key, value }) => [key, value])).toEqual([['model', 'gpt-nofast']]) + await expect(tierPick).resolves.toEqual({ + kind: 'accepted', + options: { serviceTier: 'priority' } + }) + expect(mutations().map(({ key, value }) => [key, value])).toEqual([ + ['model', 'gpt-nofast'], + ['serviceTier', 'priority'] + ]) }) it('reports a refused pick to its picker and publishes all the same', async () => { diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index 0323f0c98bf..efe8291b881 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -32,10 +32,6 @@ import { } from '../../../shared/agent-session-write-failure' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import type { StructuredAgentSessionLaunchIntent } from './launch-structured-agent-session' -import { - structuredAgentSessionModelOffers, - type StructuredAgentSessionOptionState -} from '../../../shared/structured-agent-session-options' /** The options a launch starts with, replaced whole so readers can compare by identity. */ export type StructuredLaunchSelection = { @@ -82,9 +78,7 @@ function repliesFor(state: StructuredLaunchState): Map { export function holdStructuredAgentSessionLaunchOption( sessionId: string, id: string, - encoded: string, - /** The picker's list, under which a model pick keeps only the held values the model offers. */ - picker?: Pick + encoded: string ): Promise | null { const state = getStructuredLaunchStateBySessionId(sessionId) if (!state || state.cancelled || state.callers.outcome === 'published') { @@ -97,9 +91,7 @@ export function holdStructuredAgentSessionLaunchOption( const held: Record = {} // Each held value is the chat's own: a pick supersedes an earlier pick of the same option. for (const [key, value] of Object.entries(state.selection.held)) { - const offered = - id !== 'model' || !picker || structuredAgentSessionModelOffers(picker, encoded, key, value) - if (key !== id && offered) { + if (key !== id) { held[key] = value } else { replies.get(key)?.({ kind: 'superseded' }) diff --git a/src/renderer/src/runtime/host-model-catalog-snapshots.test.ts b/src/renderer/src/runtime/host-model-catalog-snapshots.test.ts index 9ba02e905fa..1e777058731 100644 --- a/src/renderer/src/runtime/host-model-catalog-snapshots.test.ts +++ b/src/renderer/src/runtime/host-model-catalog-snapshots.test.ts @@ -94,6 +94,33 @@ describe('host model catalog snapshots', () => { }) ).toBeUndefined() }) + + it('retires a workspace answer and its derived account fallback, retaining independent answers', () => { + const worldwide = { ...list(true), defaultHoldsInEveryWorkspace: true as const } + const scoped = { ...list(false), modelsDifferByWorkspace: true as const } + const other = { ...NEW_CHAT, worktree: 'wt-2' } + recordHostModelCatalogSnapshot(PAIRED, 'grok', 'wt-1', worldwide) + recordHostModelCatalogSnapshot(LOCAL, 'omp', 'wt-1', scoped) + for (const retired of [{ origin: 'unknown' } as const, { ...worldwide, models: [] }]) { + recordHostModelCatalogSnapshot(LOCAL, 'grok', 'wt-2', scoped) + recordHostModelCatalogSnapshot(LOCAL, 'grok', 'wt-1', worldwide) + expect(readHostModelCatalogSnapshot(LOCAL, 'grok', NEW_CHAT)).toBe(worldwide) + recordHostModelCatalogSnapshot(LOCAL, 'grok', 'wt-1', retired) + expect(readHostModelCatalogSnapshot(LOCAL, 'grok', NEW_CHAT)).toBeUndefined() + expect(readHostModelCatalogSnapshot(LOCAL, 'grok', other)).toBe(scoped) + expect(readHostModelCatalogSnapshot(PAIRED, 'grok', NEW_CHAT)).toBe(worldwide) + expect(readHostModelCatalogSnapshot(LOCAL, 'omp', NEW_CHAT)).toBe(scoped) + } + const newer = { ...worldwide, fetchedAt: 2 } + recordHostModelCatalogSnapshot(LOCAL, 'grok', 'wt-1', worldwide) + recordHostModelCatalogSnapshot(LOCAL, 'grok', 'wt-2', newer) + recordHostModelCatalogSnapshot(LOCAL, 'grok', 'wt-1', { origin: 'unknown' }) + expect(readHostModelCatalogSnapshot(LOCAL, 'grok', NEW_CHAT)).toBe(newer) + recordHostModelCatalogSnapshot(LOCAL, 'grok', null, worldwide) + recordHostModelCatalogSnapshot(LOCAL, 'grok', 'wt-1', scoped) + recordHostModelCatalogSnapshot(LOCAL, 'grok', 'wt-1', { origin: 'unknown' }) + expect(readHostModelCatalogSnapshot(LOCAL, 'grok', NEW_CHAT)).toBe(worldwide) + }) }) const SAVED_ONLY = 'agent-session.model-catalog.saved-only.v1' diff --git a/src/renderer/src/runtime/host-model-catalog-snapshots.ts b/src/renderer/src/runtime/host-model-catalog-snapshots.ts index 38e91ed887b..d2fb2400d97 100644 --- a/src/renderer/src/runtime/host-model-catalog-snapshots.ts +++ b/src/renderer/src/runtime/host-model-catalog-snapshots.ts @@ -68,8 +68,16 @@ export function recordHostModelCatalogSnapshot( ): void { const key = snapshotKey(structuredAgentSessionHostKey(target), agent) if (catalog.origin === 'unknown' || catalog.models.length === 0) { - // The account a new chat pins has no list: an older answer belongs to another account. - snapshots.delete(key) + if (worktree === null) { + snapshots.delete(key) + } else { + const entry = snapshots.get(key) + const retired = entry?.byWorktree.get(worktree) + if (entry && retired && entry.account === retired) { + entry.account = null + } + entry?.byWorktree.delete(worktree) + } return } const entry = snapshots.get(key) ?? { account: null, byWorktree: new Map() } diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index b137508a9a5..5e0ee73e760 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -96,6 +96,8 @@ export const AGENT_SESSION_FAILURE_COPY = { providerExitedRejection: '{{agent}} stopped before this message was sent.', providerExitedAnswer: '{{agent}} has stopped, so your answer was not sent. Send a message to continue.', + claudeEffortInvalid: + 'Claude effort is invalid; choose low, medium, high, xhigh, or max and send again.', providerRejected: "{{agent}} didn't accept this message.", providerRejectedQuoted: "{{agent}} didn't accept this message: {{detail}}.", attachmentEmpty: 'An image on this message is empty, so the message was not sent.', diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 7dc94b5979c..3655224b06c 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -192,6 +192,7 @@ const FAILURE_SENTENCES = { agent(say, context) ), restartFailed: couldNot('couldNotRestart'), + claudeEffortInvalid: (_context, _fact, _surface, say) => say('claudeEffortInvalid'), providerRejected: (context, fact, _surface, say) => quotingPersonDetail( say, diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index 3e83486b879..9185f0890bc 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -49,6 +49,7 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'providerExited', 'restartFailed', 'providerRejected', + 'claudeEffortInvalid', 'attachmentInvalid', 'attachmentUnreadable', 'emptyMessage', diff --git a/src/shared/native-chat-pty-session-options.ts b/src/shared/native-chat-pty-session-options.ts index 0fd2297d570..5127c204486 100644 --- a/src/shared/native-chat-pty-session-options.ts +++ b/src/shared/native-chat-pty-session-options.ts @@ -81,21 +81,6 @@ export function createNativeChatPtySessionOptions( if (args.reportedValues && applyNativeChatReportedSessionOptions(record, args.reportedValues)) { writeNativeChatSessionOptionCache(args.scopeKey, record) } - /** Untrack probe-retired ids so reinjection and later option writes cannot restore fatal launch flags. */ - const untrackRetiredModel = (): boolean => { - if (!catalog.discoveredModelsAreAuthoritative || !modelsAreDiscovered) { - return false - } - const trackedId = typeof record.model?.value === 'string' ? record.model.value : null - if (!trackedId || models.some((model) => model.id === trackedId)) { - return false - } - clearNativeChatSessionModel(record) - return true - } - if (untrackRetiredModel()) { - writeNativeChatSessionOptionCache(args.scopeKey, record) - } const activeModels = (): CatalogModel[] => withTrackedNativeChatModel(catalog, models, record) let snapshot = buildNativeChatSessionOptionSnapshot({ catalog, @@ -209,7 +194,6 @@ export function createNativeChatPtySessionOptions( replaceModels: (nextModels) => { models = [...nextModels] modelsAreDiscovered = true - untrackRetiredModel() publish() }, contextWindowTokens: (modelId) => diff --git a/src/shared/native-chat/input/native-chat-session-option-defaults.ts b/src/shared/native-chat/input/native-chat-session-option-defaults.ts index 2e51f5f474f..da61a799ba7 100644 --- a/src/shared/native-chat/input/native-chat-session-option-defaults.ts +++ b/src/shared/native-chat/input/native-chat-session-option-defaults.ts @@ -143,8 +143,7 @@ export function applyNativeChatSessionOptionPicks(args: { return persisted } -/** Applies one host-owned delta to the latest record. Returning null means the - * authoritative model list found nothing to retire. */ +/** Applies a host-owned delta; null means no future launch default needs retirement. */ export function applyNativeChatSessionOptionSettingsMutation( persisted: PersistedNativeChatSessionOptions | null | undefined, mutation: NativeChatSessionOptionSettingsMutation diff --git a/src/shared/structured-agent-session-composer.ts b/src/shared/structured-agent-session-composer.ts index bc86b19ed45..9443872c763 100644 --- a/src/shared/structured-agent-session-composer.ts +++ b/src/shared/structured-agent-session-composer.ts @@ -240,13 +240,6 @@ export async function dispatchStructuredAgentSessionComposerCommand( return unavailable(command.name) } const descriptor = controller.snapshot.find((entry) => entry.id === command.name) - if (!descriptor || descriptor.kind.type !== 'select') { - return { - handled: true, - accepted: true, - error: `${command.name === 'model' ? 'Models' : 'Reasoning effort'} are unavailable for this chat session.` - } - } if (!command.argument) { const opened = await controller.invokeAction(command.name) return { @@ -256,20 +249,18 @@ export async function dispatchStructuredAgentSessionComposerCommand( } } const normalized = command.argument.toLowerCase() - const choice = descriptor.kind.choices.find( - (entry) => entry.value.toLowerCase() === normalized || entry.label.toLowerCase() === normalized - ) - if (!choice) { - return { - handled: true, - accepted: false, - error: `${command.argument} is not an available ${command.name} for this chat session.` - } - } - const applied = await controller.setOption(command.name, choice.value) + const choice = + descriptor?.kind.type === 'select' + ? descriptor.kind.choices.find( + (entry) => + entry.value.toLowerCase() === normalized || entry.label.toLowerCase() === normalized + ) + : undefined + const value = choice?.value ?? command.argument + const applied = await controller.setOption(command.name, value) return { handled: true, accepted: applied, - error: applied ? null : `Could not apply ${command.name} ${choice.label}.` + error: applied ? null : `Could not apply ${command.name} ${choice?.label ?? value}.` } } diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 4c9fdebad1b..467be66d9c1 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -86,6 +86,7 @@ const KIND_CATEGORY = { conversationOpenElsewhere: 'startFailed', restartFailed: 'startFailed', providerRejected: 'content', + claudeEffortInvalid: 'content', attachmentInvalid: 'content', attachmentUnreadable: 'content', emptyMessage: 'content', @@ -123,6 +124,7 @@ const KIND_VERDICT = { conversationOpenElsewhere: 'failure', restartFailed: 'failure', providerRejected: 'failure', + claudeEffortInvalid: 'failure', attachmentInvalid: 'failure', attachmentUnreadable: 'failure', emptyMessage: 'failure', diff --git a/src/shared/structured-agent-session-first-frame-picker.spec.ts b/src/shared/structured-agent-session-first-frame-picker.spec.ts index f303ca143ea..5a9b218c13a 100644 --- a/src/shared/structured-agent-session-first-frame-picker.spec.ts +++ b/src/shared/structured-agent-session-first-frame-picker.spec.ts @@ -64,7 +64,7 @@ describe('the first frame of every agent’s picker', () => { settable: false, kind: { choices: [] } }) - expect(canSetStructuredAgentSessionOption(state, 'model', 'anything')).toBe(false) + expect(canSetStructuredAgentSessionOption(state, 'model', 'anything')).toBe(true) } else { // Built-in lists are presentation, never tracked picks. expect(state.record.values.model).toBeUndefined() diff --git a/src/shared/structured-agent-session-option-snapshot.ts b/src/shared/structured-agent-session-option-snapshot.ts index 808d8c763f4..d7b6b109f73 100644 --- a/src/shared/structured-agent-session-option-snapshot.ts +++ b/src/shared/structured-agent-session-option-snapshot.ts @@ -88,7 +88,13 @@ function valuesUnderControls( ): Readonly> { const tier = values.serviceTier?.value const control = controls.find((option) => option.id === 'serviceTier') - if (typeof tier !== 'string' || !control || (olderHost && tier === 'flex')) { + if ( + typeof tier !== 'string' || + !control || + values.serviceTier?.source === 'applied' || + values.model?.source === 'default' || + (olderHost && tier === 'flex') + ) { return values } const effective = effectiveServiceTier({ @@ -189,8 +195,8 @@ export function structuredAgentSessionOptionSnapshot( ): SessionOptionDescriptor[] { const permission = structuredAgentSessionPermissionDescriptor(state) const options = modelOptionSnapshot(state).map((option) => - (state.listingInProgress && ['effort', 'serviceTier', 'fastMode'].includes(option.id)) || - (state.fastModeRestricted && option.id === 'fastMode') + (state.fastModeRestricted && option.id === 'fastMode') || + (state.listingInProgress && ['effort', 'serviceTier', 'fastMode'].includes(option.id)) ? { ...option, settable: false } : option ) @@ -213,16 +219,28 @@ export function canSetStructuredAgentSessionOption( state: StructuredAgentSessionOptionState, id: string, value: SessionOptionValue, - snapshot = structuredAgentSessionOptionSnapshot(state) + snapshot = structuredAgentSessionOptionSnapshot(state), + interactionReady = true ): boolean { - const descriptor = snapshot.find((entry) => entry.id === id) - return Boolean( - state.catalog && - descriptor?.settable && - state.pendingId !== id && - ((typeof value === 'string' && - descriptor?.kind.type === 'select' && - descriptor.kind.choices.some((choice) => choice.value === value)) || - (typeof value === 'boolean' && descriptor?.kind.type === 'boolean')) + if ( + !interactionReady || + state.pendingId === id || + (id === 'fastMode' && state.fastModeRestricted) + ) { + return false + } + if (id === 'permissionMode') { + const descriptor = snapshot.find((entry) => entry.id === id) + return ( + descriptor?.settable === true && + descriptor.kind.type === 'select' && + descriptor.kind.choices.some((choice) => choice.value === value) + ) + } + return ( + (['model', 'effort', 'serviceTier', 'personality'].includes(id) && + typeof value === 'string' && + value.length > 0) || + (id === 'fastMode' && typeof value === 'boolean') ) } diff --git a/src/shared/structured-agent-session-option-state.ts b/src/shared/structured-agent-session-option-state.ts index bce4a8dcb62..113f4f589d8 100644 --- a/src/shared/structured-agent-session-option-state.ts +++ b/src/shared/structured-agent-session-option-state.ts @@ -55,13 +55,14 @@ export function structuredAgentSessionOwnValue( export function replaceStructuredAgentSessionAnsweredValues( record: StructuredAgentSessionOptionRecord, answered: Readonly>>, - sourceFor: (id: string) => TrackedNativeChatSessionOption['source'] + sourceFor: (id: string) => TrackedNativeChatSessionOption['source'], + replaceUnknownModel = false ): void { for (const id of ANSWERED_VALUE_IDS) { const value = answered[id] if (value !== undefined) { record.values[id] = { value, source: sourceFor(id) } - } else if (id !== 'model') { + } else if (id !== 'model' || replaceUnknownModel) { delete record.values[id] } } diff --git a/src/shared/structured-agent-session-options.test.ts b/src/shared/structured-agent-session-options.test.ts index e9dd24bb814..f62c715020e 100644 --- a/src/shared/structured-agent-session-options.test.ts +++ b/src/shared/structured-agent-session-options.test.ts @@ -301,7 +301,25 @@ describe('structured agent session options', () => { structuredAgentSessionOptionSnapshot(fast).find(({ id }) => id === 'serviceTier') ).toMatchObject({ settable: true, kind: { currentValue: 'priority' } }) const standard = commitStructuredAgentSessionOption(fast, 'serviceTier', 'default') - // With no model, nothing says which other speed the chat can take. - expect(canSetStructuredAgentSessionOption(standard, 'serviceTier', 'priority')).toBe(false) + // Inventory does not authorize a literal pick. + expect(canSetStructuredAgentSessionOption(standard, 'serviceTier', 'priority')).toBe(true) }) }) + +it('separates typed literal readiness from an empty or refreshing catalog', () => { + const state = createStructuredAgentSessionOptionState('codex') + const snapshot = structuredAgentSessionOptionSnapshot(state) + expect(canSetStructuredAgentSessionOption(state, 'model', 'unlisted', snapshot, false)).toBe( + false + ) + expect(canSetStructuredAgentSessionOption(state, 'model', 'unlisted', snapshot, true)).toBe(true) + expect( + canSetStructuredAgentSessionOption( + { ...state, listingInProgress: true }, + 'effort', + 'high', + snapshot, + true + ) + ).toBe(true) +}) diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index a5ee0ac40a6..16872c781db 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -11,10 +11,7 @@ import { } from './native-chat/input/native-chat-session-option-defaults' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from './agent-chat-permission-mode' import { usesInstalledAgentModelCatalog } from './agent-model-catalog-ownership' -import { - structuredAgentSessionDescribedModel, - structuredAgentSessionShownModel -} from './structured-agent-session-option-snapshot' +import { structuredAgentSessionShownModel } from './structured-agent-session-option-snapshot' import type { SessionOptionValue } from './native-chat/input/native-chat-session-options' import type { AgentSessionModelCatalogResult, @@ -181,7 +178,10 @@ export function applyStructuredAgentSessionOptions( ? 'default' : current.confirmed?.includes(id) ? 'reported' - : 'dispatched' + : id === 'serviceTier' && current.defaulted !== undefined + ? 'applied' + : 'dispatched', + state.record.agent === 'codex' && current.defaulted !== undefined ) return { ...state, @@ -233,24 +233,6 @@ export function commitStructuredAgentSessionOption( return { ...state, pendingId: null } } -/** Before launch no host can reconcile held picks; keep only values offered by the model’s controls. - * An undescribed model preserves all values because no listing can disprove them. */ -export function structuredAgentSessionModelOffers( - state: Pick, - modelId: string, - id: string, - value: SessionOptionValue -): boolean { - const model = structuredAgentSessionDescribedModel(state, modelId) - if (id === 'model' || id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID || !model) { - return true - } - const option = model.options.find((entry) => entry.id === id) - return option?.kind.type === 'select' - ? option.kind.choices.some((choice) => choice.value === value) - : option !== undefined -} - export function commitStructuredAgentSessionOptionValues( state: StructuredAgentSessionOptionState, values: Readonly> diff --git a/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts index ee9e4e60263..b2507d5e895 100644 --- a/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts +++ b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts @@ -26,6 +26,8 @@ vi.mock('@/lib/structured-agent-session-launch-options', () => ({ getStructuredAgentSessionLaunchSelection: () => null })) +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../src/main/ipc/desktop-renderer-runtime-capabilities' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' import { agentSessionConversationView } from '../../src/shared/agent-session-conversation-view' import { agentSessionRecordFixture } from '../../src/main/native-chat/agent-session-record-test-fixture' import type { SessionOptionDescriptor } from '../../src/shared/native-chat/input/native-chat-session-options' @@ -92,10 +94,12 @@ let release: () => void beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'orca-host-auto-mode-')) mocks.rpc.mockReset() + setLocalRuntimeCapabilitiesForTests(DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES) resetHostModelCatalogSnapshotsForTests() }) afterEach(() => { + setLocalRuntimeCapabilitiesForTests(null) rmSync(dir, { recursive: true, force: true }) }) @@ -162,17 +166,27 @@ async function hostCatalog(listingLands: Promise) { } as unknown as StructuredAgentSessionMutationContext // As the host's RPC methods answer them: the catalog for the chat's folder, and its options. mocks.rpc.mockImplementation( - (_target: unknown, method: string, params: { waitForListing?: boolean }) => - method === 'agentSession.modelCatalog' - ? modelCatalog.read({ - agent: 'claude', - sessionId: SESSION, - workspacePath: join(dir, 'project'), - ...(params.waitForListing ? { waitForListing: true } : {}) - }) - : method === 'agentSession.options' - ? readStructuredAgentSessionOptions(context, SESSION, IN_PROCESS_OPTIONS_READER) - : new Promise(() => {}) + ( + _target: unknown, + method: string, + params: { waitForListing?: boolean; savedOnly?: boolean } + ) => { + if (method === 'agentSession.modelCatalog') { + const request = { + agent: 'claude', + sessionId: SESSION, + workspacePath: join(dir, 'project'), + ...params + } + if (!params.savedOnly) { + void modelCatalog.demand(request) + } + return modelCatalog.read(request) + } + return method === 'agentSession.options' + ? readStructuredAgentSessionOptions(context, SESSION, IN_PROCESS_OPTIONS_READER) + : new Promise(() => {}) + } ) return { persistence } } diff --git a/tests/e2e/cross-version-wire/host-model-catalog-released-phone.unit.test.ts b/tests/e2e/cross-version-wire/host-model-catalog-released-phone.unit.test.ts index b1711fe3a05..d20eb62eff7 100644 --- a/tests/e2e/cross-version-wire/host-model-catalog-released-phone.unit.test.ts +++ b/tests/e2e/cross-version-wire/host-model-catalog-released-phone.unit.test.ts @@ -85,7 +85,7 @@ it.each(['success', 'budget'] as const)( } }), async () => { - await wait(strict ? 14_000 : mode === 'success' ? 14_000 : 60_000) + await wait(strict ? 14_000 : mode === 'success' ? 8_000 : 60_000) if (strict) { throw new AgentModelCatalogProbeError('enterprise policy', 'exit', false, { exitCode: 1, @@ -188,10 +188,12 @@ it.each(['success', 'budget'] as const)( ) try { await Promise.all( - ['/1', '/2', '/3', '/4', '/5'].map((workspacePath) => - service.read({ agent: 'claude', workspacePath }) - ) + ['/1', '/2', '/3', '/4', '/5'].map((workspacePath) => { + void service.demand({ agent: 'claude', workspacePath }) + return service.read({ agent: 'claude', workspacePath }) + }) ) + await vi.advanceTimersByTimeAsync(0) expect(modern).toHaveBeenCalledTimes(2) useMobileHostModelCatalogUpgrade({ agent: 'claude', diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index 12ed907425d..eef82ab2ff3 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -126,7 +126,7 @@ export function installableHost( ...hostCalls, // The catalog read checks the session's record for a floating chat's own folder; none here. deps: { - modelCatalog: { read: hostCalls.modelCatalog }, + modelCatalog: { read: hostCalls.modelCatalog, demand: vi.fn(async () => {}) }, store: { getRecord: () => null, getConversation: () => null, getOperationRow: () => null } }, restartResume: { diff --git a/tests/e2e/structured-permission-report-publication.unit.test.ts b/tests/e2e/structured-permission-report-publication.unit.test.ts index c3ea61a46bc..ce5946dc4ee 100644 --- a/tests/e2e/structured-permission-report-publication.unit.test.ts +++ b/tests/e2e/structured-permission-report-publication.unit.test.ts @@ -4,6 +4,7 @@ import { THREAD_ID as CODEX_THREAD_ID } from '../../src/main/codex/codex-structured-session-adapter-fixture' import { structuredAgentRuntimeRegistration } from '../../src/main/runtime/structured-agent-runtime-registrations' +import { createStructuredAgentEnvironmentResolvers } from '../../src/main/runtime/structured-agent-shell-environment' import { act, renderHook, waitFor, cleanup } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import type { SDKStatusMessage } from '@anthropic-ai/claude-agent-sdk' @@ -486,11 +487,9 @@ it('publishes accepted Codex fresh policy to desktop and phone without stale sta }, store, journalDatabase: host.deps.journalDatabase, - environment: { - resolveBaseEnvironment: async () => ({}), - resolveAgentEnvironment: async () => ({}), - refreshAfterMissingProgram: async () => {} - }, + environment: createStructuredAgentEnvironmentResolvers({ + resolveEnvironment: async () => ({}) + }), host: () => host, followUps: { onDispatchSettledLate: () => {} }, deliverLifecycle: (event) => { @@ -528,7 +527,8 @@ it('publishes accepted Codex fresh policy to desktop and phone without stale sta expect(await desktop.result.current.setStructuredOption('permissionMode', 'ask')).toBe(true) }) const saved = structuredClone(store.getRecord(sessionId)) - const staleRead = (await adapter.prepareReadOptions!({ sessionId, fence }))! + const readCurrentOptions = adapter.readOptions!.bind(adapter, { sessionId, fence }) + expect((await readCurrentOptions()).current.reportedPermissionMode).toBe('auto') sending = adapter.dispatch({ sessionId, clientMessageId: 'same-child-ask', @@ -555,7 +555,7 @@ it('publishes accepted Codex fresh policy to desktop and phone without stale sta await Promise.all(settlements) }) await waitFor(() => expect(descriptions()).toEqual(['Ask for approval', 'Ask for approval'])) - expect(staleRead().current.reportedPermissionMode).toBe('ask') + expect((await readCurrentOptions()).current.reportedPermissionMode).toBe('ask') expect(store.getRecord(sessionId)).toEqual(saved) expect((await host.readOptions(sessionId)).current).toMatchObject({ permissionMode: 'ask',