diff --git a/src/main/claude/claude-model-catalog-probe.test.ts b/src/main/claude/claude-model-catalog-probe.test.ts index eccf3fcc892..8cb9dac7e30 100644 --- a/src/main/claude/claude-model-catalog-probe.test.ts +++ b/src/main/claude/claude-model-catalog-probe.test.ts @@ -5,6 +5,9 @@ import { describe, expect, it } from 'vitest' import { PROVIDER_STDIN_END_GRACE_MS } from '../provider-process/provider-process-supervisor' import { createClaudeModelCatalogProbe } from './claude-model-catalog-probe' import { resolveStructuredAgentCommand } from '../native-chat/structured-agent-command-resolution' +import type { runAgentModelCatalogListing } from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner' + +type ListingCall = Parameters const AUTH_POLICY = { account: 'system' } as const @@ -30,7 +33,68 @@ function probeDeps(command = `"${process.execPath}"`): { } } +const LISTED = `${JSON.stringify({ + type: 'control_response', + response: { + subtype: 'success', + request_id: 'orca-model-discovery', + response: { + models: [ + { + value: 'sonnet', + displayName: 'Sonnet', + supportsEffort: true, + supportedEffortLevels: ['low'] + } + ] + } + } +})}\n` + describe('claude model catalog probe', () => { + it.each([ + [[], [], ['/orca-probe', '/orca-probe'], 'user,project,local'], + [['node_modules/claude/cli.js'], [], ['/chat-a', '/chat-b'], 'user'], + [['--env-file=work env', '--', 'claude'], [], ['/chat-a', '/chat-b'], 'user'], + [[], ['--settings', 'settings.json'], ['/chat-a', '/chat-b'], 'user'] + ])( + 'lists prefix %j with arguments %j from %j, reading %s settings', + async (prefixArgs, launchArgs, listedIn, sources) => { + // A user command line may name files in the chat's folder, so it runs there, but reads only + // user settings there so the project's own settings never narrow the account's list. + const calls: ListingCall[] = [] + const probe = createClaudeModelCatalogProbe({ + ...probeDeps(), + resolveCommand: () => ({ command: process.execPath, prefixArgs }), + resolveLaunchArgs: async () => launchArgs, + runListing: async (...call) => { + calls.push(call) + return LISTED + } + }) + const identities: unknown[] = [] + for (const folder of ['/chat-a', '/chat-b']) { + const resolved = await probe.resolve?.( + HOME('/homes/a'), + '/orca-probe', + 'snapshot', + undefined, + folder + ) + identities.push(resolved?.identity) + await resolved?.run() + } + expect(calls.map(([launch]) => launch.cwd)).toEqual(listedIn) + const args = calls[0]?.[0].args ?? [] + expect(args.slice(0, prefixArgs.length)).toEqual(prefixArgs) + expect(args.slice(args.length - launchArgs.length)).toEqual(launchArgs) + expect(args[args.indexOf('--setting-sources') + 1]).toBe(sources) + expect(JSON.stringify(identities[0]) === JSON.stringify(identities[1])).toBe( + listedIn[0] === listedIn[1] + ) + } + ) + it.runIf(process.platform !== 'win32')( 'lists through a supervised one-shot that answers after its input ends', async () => { diff --git a/src/main/claude/claude-model-catalog-probe.ts b/src/main/claude/claude-model-catalog-probe.ts index 15794c6b824..e58d16209ea 100644 --- a/src/main/claude/claude-model-catalog-probe.ts +++ b/src/main/claude/claude-model-catalog-probe.ts @@ -14,6 +14,7 @@ import { listedModels, record } from './structured/launch/claude-structured-model-catalog' +import { claudeCatalogSignedOut } from './claude-model-catalog-signed-out' import { claudeFastModeSupport, wireClaudeModels @@ -23,6 +24,10 @@ import { readClaudeControlResponses } from './claude-control-responses' import { claudeStructuredLaunchArgs } from './structured/launch/claude-structured-launch-args' import { AgentModelCatalogUnavailableError } from '../native-chat/agent-model-catalog/agent-model-catalog-unavailable' import { claudeConfigDirEnvPatch } from './claude-config-dir-pin' +import { + envWithPathRelativeTo, + catalogListingDirectory +} from '../../shared/node-cli-command-resolution' import { CLAUDE_DEFAULT_SETTING_SOURCES, resolveClaudeStructuredInvocation, @@ -114,7 +119,8 @@ export function createClaudeModelCatalogProbe( accountHome: Parameters[0], cwd?: string, lookup: AgentModelCatalogLookup = 'recover', - operation?: StructuredAgentResolutionOperation + operation?: StructuredAgentResolutionOperation, + launchDirectory = cwd ) => { assertStructuredAgentResolutionCurrent(operation) const configuredArgs = claudeStructuredLaunchArgs( @@ -127,18 +133,17 @@ export function createClaudeModelCatalogProbe( ), ...configuredArgs.additionalDirectories.flatMap((directory) => ['--add-dir', directory]) ] - const argsFor = (strictMcp: boolean) => [ + const argsFor = (strictMcp: boolean, sources: readonly string[]) => [ ...buildClaudeModelListArgs(strictMcp, userArgs), - ...(cwd ? ['--setting-sources', CLAUDE_DEFAULT_SETTING_SOURCES.join(',')] : []), + ...(cwd ? ['--setting-sources', sources.join(',')] : []), ...userArgs ] - const args = argsFor(true) const launchDeps = { ...deps, operation, ...(lookup === 'saved' ? { resolveInheritedEnv: deps.resolveSavedEnvironment } : {}), ...(lookup === 'recover' ? {} : { refreshEnvironmentAfterMiss: undefined }), - ...(cwd ? { resolveWorkingDirectory: () => cwd } : {}) + ...(launchDirectory ? { resolveWorkingDirectory: () => launchDirectory } : {}) } const sources = await resolveClaudeChildEnvSources(launchDeps) assertStructuredAgentResolutionCurrent(operation) @@ -153,32 +158,46 @@ export function createClaudeModelCatalogProbe( sources ) assertStructuredAgentResolutionCurrent(operation) + // A command line that may name files relative to the chat's folder runs there, reading only + // user settings so the project's settings never narrow the account's list. + const listIn = catalogListingDirectory( + command, + [...prefixArgs, ...userArgs], + cwd, + launchDirectory + ) + const settingSources = listIn === cwd ? CLAUDE_DEFAULT_SETTING_SOURCES : ['user'] + const args = argsFor(true, settingSources) return { identity: { command, prefixArgs, args, - cwd, + cwd: listIn, overlay: Object.entries(sources.overlay ?? {}).sort() }, - run: async (signal?: AbortSignal): Promise => { - assertStructuredAgentResolutionCurrent(operation) + run: async ( + signal?: AbortSignal, + runOperation = operation + ): Promise => { + assertStructuredAgentResolutionCurrent(runOperation) signal?.throwIfAborted() const outcome = await runClaudeBackgroundAttempts({ agentId: 'claude', run: (strictMcp) => { - assertStructuredAgentResolutionCurrent(operation) + assertStructuredAgentResolutionCurrent(runOperation) signal?.throwIfAborted() return (deps.runListing ?? runAgentModelCatalogListing)( { command, - args: [...prefixArgs, ...argsFor(strictMcp)], - ...(cwd ? { cwd } : {}), + args: [...prefixArgs, ...argsFor(strictMcp, settingSources)], + ...(listIn ? { cwd: listIn } : {}), stdin: `${CLAUDE_MODEL_LIST_STDIN}${JSON.stringify({ type: 'control_request', request_id: 'orca-model-settings', request: { subtype: 'get_settings' } })}\n${JSON.stringify({ type: 'control_request', request_id: 'orca-signin-discovery', request: { subtype: 'initialize' } })}\n` }, { site: 'claude-model-catalog-probe', - inheritedEnv: env, + // The listing runs from the account folder; relative PATH entries still mean the chat's. + inheritedEnv: launchDirectory ? envWithPathRelativeTo(env, launchDirectory) : env, signal, partialOutput: { timeoutMs: 2_000, @@ -255,34 +274,3 @@ export function createClaudeModelCatalogProbe( { resolve, resolveLegacy: (home, operation) => resolve(home, undefined, 'recover', operation) } ) } - -function claudeCatalogSignedOut( - initialization: unknown, - settings: unknown, - env: Readonly> -): boolean { - const account = record(record(initialization)?.account) - const effective = record(record(settings)?.effective) - if ( - !account || - !effective || - account.tokenSource !== 'none' || - (account.apiProvider !== undefined && account.apiProvider !== 'firstParty') || - (account.apiKeySource && account.apiKeySource !== 'none') || - account.email || - account.subscriptionType - ) { - return false - } - const configuredEnv = record(effective.env) - const endpoint = configuredEnv?.ANTHROPIC_BASE_URL ?? env.ANTHROPIC_BASE_URL - if (typeof endpoint === 'string' && endpoint.trim()) { - try { - const url = new URL(endpoint) - return url.protocol === 'https:' && url.hostname === 'api.anthropic.com' - } catch { - return false - } - } - return true -} diff --git a/src/main/claude/claude-model-catalog-signed-out.ts b/src/main/claude/claude-model-catalog-signed-out.ts new file mode 100644 index 00000000000..5d9cbabbccd --- /dev/null +++ b/src/main/claude/claude-model-catalog-signed-out.ts @@ -0,0 +1,33 @@ +import { record } from './structured/launch/claude-structured-model-catalog' + +/** A first-party CLI with no credential and no other provider or endpoint is signed out. */ +export function claudeCatalogSignedOut( + initialization: unknown, + settings: unknown, + env: Readonly> +): boolean { + const account = record(record(initialization)?.account) + const effective = record(record(settings)?.effective) + if ( + !account || + !effective || + account.tokenSource !== 'none' || + (account.apiProvider !== undefined && account.apiProvider !== 'firstParty') || + (account.apiKeySource && account.apiKeySource !== 'none') || + account.email || + account.subscriptionType + ) { + return false + } + const configuredEnv = record(effective.env) + const endpoint = configuredEnv?.ANTHROPIC_BASE_URL ?? env.ANTHROPIC_BASE_URL + if (typeof endpoint === 'string' && endpoint.trim()) { + try { + const url = new URL(endpoint) + return url.protocol === 'https:' && url.hostname === 'api.anthropic.com' + } catch { + return false + } + } + return true +} diff --git a/src/main/codex/structured/catalog/codex-model-catalog-probe.test.ts b/src/main/codex/structured/catalog/codex-model-catalog-probe.test.ts index ff86655a317..d3fb98dccf4 100644 --- a/src/main/codex/structured/catalog/codex-model-catalog-probe.test.ts +++ b/src/main/codex/structured/catalog/codex-model-catalog-probe.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { createCodexModelCatalogProbe } from './codex-model-catalog-probe' -import { runCodexAppServerSession } from '../../app-server/codex-app-server-session' +import { + runCodexAppServerSession, + type CodexAppServerInvocation +} from '../../app-server/codex-app-server-session' import { resolveStructuredAgentCommand } from '../../../native-chat/structured-agent-command-resolution' const MODEL_ROW = { @@ -12,6 +15,64 @@ const MODEL_ROW = { } describe('codex model catalog probe', () => { + it.each([ + [[]], + [['node_modules/@openai/codex/bin/codex.js']], + [['--env-file=work env', '--', 'codex']], + [['-c', 'model_instructions_file="./prompt.md"']] + ])( + "lists a command line with arguments %j from Orca's folder for every chat", + async (prefixArgs) => { + // The chat's repo never picks the list, so no listing runs in a chat's folder. + const invocations: CodexAppServerInvocation[] = [] + const configReads: unknown[] = [] + const probe = createCodexModelCatalogProbe({ + resolveEnvironment: async () => ({ PATH: '/bin' }), + resolveCommand: () => ({ command: '/bin/codex', prefixArgs }), + runSession: async (invocation, body) => { + invocations.push(invocation) + return body({ + request: async (method: string, params: unknown) => { + if (method === 'config/read') { + configReads.push(params) + return { config: {} } + } + return { data: [MODEL_ROW], nextCursor: null } + }, + notify: () => {} + }) + } + }) + const identities: unknown[] = [] + for (const folder of ['/chat-a', '/chat-b']) { + const resolved = await probe.resolve?.( + { variable: 'CODEX_HOME', path: '/homes/a' }, + '/orca-probe', + 'snapshot', + undefined, + folder + ) + identities.push(resolved?.identity) + await resolved?.run() + } + expect(invocations.map((invocation) => invocation.cwd)).toEqual([ + '/orca-probe', + '/orca-probe' + ]) + expect(invocations[0]?.args.slice(0, prefixArgs.length + 2)).toEqual([ + ...prefixArgs, + '-c', + 'project_root_markers=[]' + ]) + expect(identities[0]).toMatchObject({ cwd: '/orca-probe' }) + expect(identities[1]).toEqual(identities[0]) + // The account's configured default is read for Orca's folder, never the project's. + expect(configReads).toEqual( + expect.arrayContaining([expect.objectContaining({ cwd: '/orca-probe' })]) + ) + } + ) + it('keeps the listing when config/read never answers', async () => { const server = String.raw` const readline = require('node:readline') diff --git a/src/main/codex/structured/catalog/codex-model-catalog-probe.ts b/src/main/codex/structured/catalog/codex-model-catalog-probe.ts index 5724bf8cb2d..dc72b22ea32 100644 --- a/src/main/codex/structured/catalog/codex-model-catalog-probe.ts +++ b/src/main/codex/structured/catalog/codex-model-catalog-probe.ts @@ -3,6 +3,7 @@ import { type StructuredAgentResolutionOperation } from '../../../native-chat/structured-agent-command-resolution' import { codexStructuredLaunchArgs } from '../launch/codex-structured-launch-args' +import { envWithPathRelativeTo } from '../../../../shared/node-cli-command-resolution' import type { AgentSessionAccountKind } from '../../../../shared/agent-session/agent-session-availability' import { AgentModelCatalogUnavailableError } from '../../../native-chat/agent-model-catalog/agent-model-catalog-unavailable' import { isMissingProviderExecutable } from '../../../provider-process/provider-executable-missing' @@ -51,7 +52,8 @@ export function createCodexModelCatalogProbe( accountHome: Parameters[0], cwd?: string, lookup: AgentModelCatalogLookup = 'recover', - operation?: StructuredAgentResolutionOperation + operation?: StructuredAgentResolutionOperation, + launchDirectory = cwd ) => { assertStructuredAgentResolutionCurrent(operation) const accountHomePath = requireLegacyAgentSessionAccountHome(accountHome).path @@ -63,20 +65,35 @@ export function createCodexModelCatalogProbe( operation, resolveEnvironment: async () => ({ ...(await readEnvironment?.()), ...overlay }), ...(lookup === 'recover' ? {} : { refreshEnvironmentAfterMiss: undefined }), - ...(cwd ? { resolveWorkingDirectory: () => cwd } : {}) + ...(launchDirectory ? { resolveWorkingDirectory: () => launchDirectory } : {}) }) assertStructuredAgentResolutionCurrent(operation) + // Every listing runs in `cwd` with the user's command line, as other Codex hosts list from their + // own folder, so the chat's repo never picks the list; relative paths in user arguments are not + // resolved against the chat's folder. No root markers keep Codex from reading a repo above + // Orca's folder too, unless an administrator's managed config sets its own. const args = [ ...prefixArgs, ...(cwd ? codexStructuredLaunchArgs((await deps.resolveLaunchArgs?.()) ?? []) : []), + '-c', + 'project_root_markers=[]', ...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS ] assertStructuredAgentResolutionCurrent(operation) - const env = { ...definedEnv(environment), CODEX_HOME: accountHomePath } + // The listing runs from the account folder; relative PATH entries still mean the chat's folder. + const env = { + ...(launchDirectory + ? envWithPathRelativeTo(definedEnv(environment), launchDirectory) + : definedEnv(environment)), + CODEX_HOME: accountHomePath + } return { identity: { command, args, cwd, overlay: Object.entries(overlay).sort() }, - run: async (signal?: AbortSignal): Promise => { - assertStructuredAgentResolutionCurrent(operation) + run: async ( + signal?: AbortSignal, + runOperation = operation + ): Promise => { + assertStructuredAgentResolutionCurrent(runOperation) signal?.throwIfAborted() deps.prepareHome?.(accountHomePath) const run = deps.runSession ?? runCodexAppServerSession @@ -91,16 +108,20 @@ export function createCodexModelCatalogProbe( ...(signal ? { signal } : {}) }, async (rpc) => { - assertStructuredAgentResolutionCurrent(operation) + assertStructuredAgentResolutionCurrent(runOperation) // Overlap model and account reads to avoid serial probe latency. - const listed = fetchCodexModelCatalogListing({ connection: rpc, cwd, operation }) + const listed = fetchCodexModelCatalogListing({ + connection: rpc, + cwd, + operation: runOperation + }) void listed.catch(() => {}) - assertStructuredAgentResolutionCurrent(operation) + assertStructuredAgentResolutionCurrent(runOperation) 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) + assertStructuredAgentResolutionCurrent(runOperation) if ( typeof response !== 'object' || response === null || @@ -116,7 +137,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) + assertStructuredAgentResolutionCurrent(runOperation) if (!inHand?.models.length) { throw new AgentModelCatalogUnavailableError(signedOut) } @@ -128,7 +149,7 @@ export function createCodexModelCatalogProbe( } throw error }) - assertStructuredAgentResolutionCurrent(operation) + assertStructuredAgentResolutionCurrent(runOperation) if (listing.models.length === 0) { throw new Error('codex app-server listed no models') } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-account-list-test-support.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-account-list-test-support.ts new file mode 100644 index 00000000000..f65c9794786 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-account-list-test-support.ts @@ -0,0 +1,144 @@ +import { resolveHiddenProbeCwd } from '../../rate-limits/hidden-probe-cwd' +import { onTestFinished, vi } from 'vitest' +import { agentSessionConversationView } from '../../../shared/agent-session/lifecycle/agent-session-conversation-view' +import type { AgentSessionRecord } from '../../../shared/agent-session/lifecycle/agent-session-record' +import { agentSessionRecordFixture } from '../agent-session-record-test-fixture' +import { + agentModelCatalogFingerprint, + agentModelCatalogScopeFingerprint +} from './agent-model-catalog-fingerprint' +import type { AgentModelCatalogServiceDeps } from './agent-model-catalog-service-contract' +import { createAgentModelCatalogService } from './agent-model-catalog-service' +import { + AgentModelCatalogStore, + type AgentModelCatalogProbe, + type AgentModelCatalogSuccess +} from './agent-model-catalog-store' + +export const HOME = { variable: 'CODEX_HOME', path: '/account' } +export const FOLDERS: Record = { + a: '/project-a', + b: '/project-b', + e: '/project-effort', + r: '/restricted' +} + +export const listing = (ids: string[], model?: string): AgentModelCatalogSuccess => ({ + origin: 'probe', + complete: true, + models: ids.map((id) => ({ id, label: id, isDefault: id === ids[0], efforts: [] })), + configured: model ? { model } : {} +}) + +export function record(sessionId: string, agent: 'claude' | 'codex' = 'codex'): AgentSessionRecord { + return agentSessionRecordFixture({ + sessionId, + provider: agent, + accountHome: HOME, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: sessionId, + workspaceKind: 'folder' + } + }) +} + +export function harness( + agent: 'claude' | 'codex' = 'codex', + overrides: Partial = {} +) { + const full = ['gpt-a', 'gpt-b', 'gpt-c'] + const byFolder: Record = { + [resolveHiddenProbeCwd()]: listing(full), + '/project-a': listing(full, 'gpt-b'), + '/project-b': listing(full, 'gpt-c'), + '/restricted': listing(['local-only'], 'local-only') + } + const gates = new Map>() + const runs: string[] = [] + const scopeGate = Promise.withResolvers() + let holdScope = false + const resolveScope: NonNullable = async ( + _home, + cwd, + _lookup, + _operation, + launchDirectory + ) => { + if (holdScope) { + await scopeGate.promise + } + // A project-local install resolves another CLI; every other folder shares the account's. + const local = launchDirectory === '/restricted' + return { + identity: { command: local ? `./node_modules/.bin/${agent}` : agent, cwd }, + run: async () => { + const listed = local ? '/restricted' : cwd + runs.push(listed) + await gates.get(listed)?.promise + return byFolder[listed] + } + } + } + const resolve = vi.fn(resolveScope) + // Folders a listing ran in without a resolved CLI (undefined: wherever Orca itself runs). + const unresolvedRuns: (string | undefined)[] = [] + const probe: AgentModelCatalogProbe = Object.assign( + async (_home: unknown, options?: { workspacePath?: string }) => { + unresolvedRuns.push(options?.workspacePath) + return byFolder[resolveHiddenProbeCwd()] + }, + { resolve } + ) + const store = overrides.store ?? new AgentModelCatalogStore({ now: () => 1000 }) + const resolveAccountHome = vi.fn( + async () => HOME + ) + const probes = { [agent]: probe } + const service = createAgentModelCatalogService({ + store, + getConversation: (id) => agentSessionConversationView(record(id, agent)), + getRecord: (id) => record(id, agent), + drivesRecord: () => true, + resolveAccountHome, + recordWorkspacePath: async (saved) => FOLDERS[saved.sessionId] ?? null, + workspaceMayOverrideDefaultModel: async () => false, + probes, + ...overrides + }) + const hold = (folder: string): (() => void) => { + const gate = Promise.withResolvers() + gates.set(folder, gate) + return () => gate.resolve() + } + const read = (sessionId: string, extra = {}) => service.read({ agent, sessionId, ...extra }) + onTestFinished(() => service.stop()) + return { + service, + probes, + store, + runs, + unresolvedRuns, + hold, + read, + full, + byFolder, + canonicalFingerprint: agentModelCatalogScopeFingerprint( + agentModelCatalogFingerprint({ agent, accountHome: HOME, wslDistro: null }), + resolveHiddenProbeCwd(), + { command: agent, cwd: resolveHiddenProbeCwd() } + ), + resolve, + resolveScope, + resolveAccountHome, + activity: () => [resolveAccountHome.mock.calls.length, resolve.mock.calls.length, runs.length], + holdScope: () => { + holdScope = true + return () => scopeGate.resolve() + } + } +} + +export const ids = (result: Awaited['read']>>) => + result.origin === 'unknown' ? null : result.models.map((model) => model.id) 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 eaf393e8f72..311f1d95f1b 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 @@ -4,7 +4,11 @@ import type { AgentModelCatalogSuccess } from './agent-model-catalog-entry' export type AgentModelCatalogResolvedProbe = { identity: unknown - run: (signal?: AbortSignal) => Promise + /** `operation` owns the run; it defaults to the one the invocation was resolved under. */ + run: ( + signal?: AbortSignal, + operation?: StructuredAgentResolutionOperation + ) => Promise } /** Recover reruns shell lookup after a miss; snapshot and saved never do. */ @@ -28,7 +32,9 @@ export type AgentModelCatalogProbe = (( home: AgentSessionAccountHome, cwd: string, lookup?: AgentModelCatalogLookup, - operation?: StructuredAgentResolutionOperation + operation?: StructuredAgentResolutionOperation, + /** Where a chat would launch, for resolving the executable; the listing still runs in `cwd`. */ + launchDirectory?: string ) => Promise } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fences.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fences.ts new file mode 100644 index 00000000000..e020851bf2a --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fences.ts @@ -0,0 +1,20 @@ +/** One abort signal per key (an agent's scopes) until it retires. */ +export function createAgentModelCatalogFences() { + const fences = new Map() + return { + signal(key: K): AbortSignal { + const fence = fences.get(key) ?? new AbortController() + fences.set(key, fence) + return fence.signal + }, + /** Retires one key, or every key without one. */ + retire(key?: K): void { + for (const [held, fence] of fences) { + if (key === undefined || held === key) { + fence.abort() + fences.delete(held) + } + } + } + } +} 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 a19a75f7304..c6f1e3196cd 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 @@ -25,10 +25,23 @@ export type AgentModelCatalogSessionAccess = { accountHomePath: string } +/** `owner` is the listing's own operation: its fixed budget, never a reader's. */ +export type AgentModelCatalogListModels = ( + selectedModels: readonly string[] | undefined, + owner: StructuredAgentResolutionOperation +) => Promise + +/** The refresh that starts a listing gives it its budget; joining never extends or shortens it. */ +export type AgentModelCatalogListingBudget = { + /** Expiry withdraws it from admission, stops its process and drops its late answer. */ + deadline?: number + signal?: AbortSignal +} + export type InFlightListing = { agent: string targets: Set - current?: () => boolean + current: () => boolean promise: Promise finish: (entry: AgentModelCatalogEntry | null) => void cleanup: () => void @@ -63,13 +76,12 @@ export function retireAgentModelCatalogListing( export function refreshAgentModelCatalogListing(input: { fingerprint: string agent: string - listModels: (selectedModels?: readonly string[]) => Promise + listModels: AgentModelCatalogListModels selectedModel?: string maxTargets: number targetDue: (model: string) => boolean targetAttempted: (model: string) => void - current?: () => boolean - signal?: AbortSignal + budget?: AgentModelCatalogListingBudget refreshes: Map failures: AgentModelCatalogFailures listingWaiters: AgentModelCatalogListingWaiters @@ -80,28 +92,39 @@ export function refreshAgentModelCatalogListing(input: { source: AgentModelCatalogSource ) => AgentModelCatalogEntry | null }): Promise { - const { fingerprint, agent, listModels, signal } = input + const { fingerprint, agent, listModels } = input + const { deadline, signal } = input.budget ?? {} const existing = input.refreshes.get(fingerprint) if (existing) { demandAgentModelEfforts(existing, input.selectedModel, input.maxTargets) return existing.promise } const result = Promise.withResolvers() + // Ending the flight (its budget, the account or CLI retired, shutdown) stops its process too. + const stopped = new AbortController() + const expiry = + deadline === undefined + ? undefined + : setTimeout(() => retire(), Math.max(0, deadline - Date.now())) const flight: InFlightListing = { agent, targets: new Set(input.selectedModel ? [input.selectedModel] : []), - current: input.current, + current: () => !signal?.aborted && (deadline === undefined || Date.now() < deadline), promise: result.promise, finish: result.resolve, - cleanup: () => signal?.removeEventListener('abort', retire) + cleanup: () => { + clearTimeout(expiry) + signal?.removeEventListener('abort', retire) + stopped.abort() + } } const retire = (): void => retireAgentModelCatalogListing(input.refreshes, input.listingWaiters, fingerprint, flight) - const current = (): boolean => - input.refreshes.get(fingerprint) === flight && !signal?.aborted && flight.current?.() !== false + const current = (): boolean => input.refreshes.get(fingerprint) === flight && flight.current() + const owner: StructuredAgentResolutionOperation = { current, signal: stopped.signal, deadline } input.refreshes.set(fingerprint, flight) signal?.addEventListener('abort', retire, { once: true }) - if (signal?.aborted) { + if (!flight.current()) { retire() } const span = startSpan('agentModelCatalog.discovery', { attributes: { agent, lister: 'probe' } }) @@ -114,7 +137,7 @@ export function refreshAgentModelCatalogListing(input: { let targets = input.selectedModel ? [input.selectedModel] : undefined let entry: AgentModelCatalogEntry | null = null do { - const success = await listModels(targets) + const success = await listModels(targets, owner) if (!current()) { return } @@ -149,28 +172,31 @@ export function refreshAgentModelCatalogListing(input: { export function scopedAgentModelCatalogLister(input: { legacy?: boolean - operation?: StructuredAgentResolutionOperation - canList: () => boolean admission: PrioritySemaphore signal: AbortSignal resolved: AgentModelCatalogResolvedProbe | undefined probe: AgentModelCatalogProbe | undefined probeHome: AgentSessionAccountHome | null workspacePath: string | undefined -}): (selectedModels?: readonly string[]) => Promise { - const { legacy, admission, signal, resolved, probe, probeHome, workspacePath } = input - return async (selectedModels) => { +}): AgentModelCatalogListModels { + const { legacy, admission, resolved, probe, probeHome, workspacePath } = input + return async (selectedModels, operation) => { + const signal = operation.signal + ? AbortSignal.any([input.signal, operation.signal]) + : input.signal + // Waiting for a slot spends the listing's own budget; expiry withdraws it from the queue. const release = legacy ? () => {} : await admission.acquire(0, signal) const deadline = new AbortController() // Share the retry budget and await bounded provider teardown before settling. const timer = legacy ? setTimeout(() => deadline.abort(), 30_000) : undefined const attemptSignal = AbortSignal.any([signal, deadline.signal]) try { - if (!input.canList()) { - throw new AgentModelCatalogListingStoppedError('catalog scope unavailable') + // A slot that frees as the budget ends never starts the queued listing. + if (operation.current?.() === false) { + throw new AgentModelCatalogListingStoppedError('listing budget ended') } if (resolved) { - return await resolved.run(attemptSignal) + return await resolved.run(attemptSignal, operation) } if (!probe || !probeHome) { throw new AgentModelCatalogListingStoppedError('catalog scope unavailable') @@ -179,12 +205,13 @@ export function scopedAgentModelCatalogLister(input: { signal: attemptSignal, workspacePath, ...(selectedModels ? { selectedModels } : {}), - operation: input.operation + operation }) } catch (error) { throw signal.aborted ? new AgentModelCatalogListingStoppedError(String(error)) : error } finally { clearTimeout(timer) + // The slot is held until the provider's teardown finishes. release() } } 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 7aca61122ab..c88765eacf8 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 @@ -7,6 +7,7 @@ import { } from '../../../shared/agent-session/options/agent-model-catalog-ownership' import { withTimeout } from '../../../shared/promise-timeout-fallback' import type { AgentModelCatalogEntry } from './agent-model-catalog-entry' +import type { AgentModelCatalogRefresh } from './agent-model-catalog-refresh' export const AGENT_MODEL_CATALOG_PICKER_WAIT_MS = 30_000 @@ -19,12 +20,14 @@ export class AgentModelCatalogListingWaiters { private readonly listing: (fingerprint: string) => boolean ) {} + /** `signal` ends the wait (and its subscription) when its reader leaves. */ wait( fingerprint: string, waitForChange = false, - terminal = false + terminal = false, + signal?: AbortSignal ): Promise | null { - if (!this.listing(fingerprint)) { + if (!this.listing(fingerprint) || signal?.aborted) { return null } const previous = this.read(fingerprint) @@ -37,6 +40,7 @@ export class AgentModelCatalogListingWaiters { } settled = true clearTimeout(deadline) + signal?.removeEventListener('abort', leave) waiters.delete(check) if (waiters.size === 0) { this.waiters.delete(fingerprint) @@ -55,6 +59,8 @@ export class AgentModelCatalogListingWaiters { const deadline = terminal ? undefined : setTimeout(() => finish(this.read(fingerprint)), AGENT_MODEL_CATALOG_PICKER_WAIT_MS) + const leave = (): void => finish(this.read(fingerprint)) + signal?.addEventListener('abort', leave, { once: true }) waiters.add(check) this.waiters.set(fingerprint, waiters) check() @@ -68,23 +74,41 @@ export class AgentModelCatalogListingWaiters { } } -/** Compatibility discovery waits share the deadline captured before any scope resolution. */ +/** A reader waits on shared work only until its own deadline or cancellation; it ends nothing. */ +export function waitWithinAgentModelCatalogRead( + reader: { deadline?: number; signal: AbortSignal }, + work: Promise | undefined +): Promise { + const remaining = reader.deadline === undefined ? undefined : reader.deadline - Date.now() + if (!work || reader.signal.aborted || (remaining !== undefined && remaining <= 0)) { + return Promise.resolve() + } + const cancelled = Promise.withResolvers() + const cancel = (): void => cancelled.resolve() + reader.signal.addEventListener('abort', cancel, { once: true }) + const waited = Promise.race([work.then(noop, noop), cancelled.promise]) + return (remaining === undefined ? waited : withTimeout(waited, remaining, undefined)).finally( + () => reader.signal.removeEventListener('abort', cancel) + ) +} + +const noop = (): void => {} + +/** A waiting read follows the refresh it joined, then the listing its scope names. */ export async function waitForAgentModelCatalogRead(input: { - params: AgentModelCatalogReadParams & { deadline: number } + params: AgentModelCatalogReadParams + reader: { deadline: number; signal: AbortSignal } store: AgentModelCatalogStore - resolve: (params: AgentModelCatalogReadParams) => Promise - claim: () => { ready: Promise; promise: Promise } | undefined + refresh: AgentModelCatalogRefresh | undefined binding: () => AgentModelCatalogBinding | undefined }): Promise { - const { params, store } = input + const { params, store, refresh } = input + // Once the reader stops waiting, nothing it left behind subscribes to or holds a listing wait. + const left = new AbortController() 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 - } + await refresh?.listing + await (refresh?.defaults ?? input.binding()?.projecting) + const bound = left.signal.aborted ? undefined : input.binding() if ( bound && (!store.get(bound.fingerprint) || @@ -95,17 +119,16 @@ export async function waitForAgentModelCatalogRead(input: { await store.pendingListing( bound.fingerprint, true, - params.legacy || usesModelSpecificAcpEfforts(params.agent) + params.legacy || usesModelSpecificAcpEfforts(params.agent), + left.signal ) } if (bound && !store.isListing(bound.fingerprint)) { - await input.claim()?.promise + await refresh?.done } } - const remaining = params.deadline - Date.now() - if (remaining > 0) { - await withTimeout(wait(), remaining, undefined) - } + await waitWithinAgentModelCatalogRead(input.reader, wait()) + left.abort() } /** The operation owns its original budget even while a resolver cannot be cancelled. */ @@ -122,10 +145,17 @@ export function runAgentModelCatalogOperation( .finally(() => { settled = true }) - return withTimeout(operation, remaining, undefined).finally(() => { - if (!settled) { - controller.abort() + // Cancelling the operation ends its wait at once, whatever its resolver is still doing. + const cancelled = Promise.withResolvers() + const cancel = (): void => cancelled.resolve() + controller.signal.addEventListener('abort', cancel, { once: true }) + return withTimeout(Promise.race([operation, cancelled.promise]), remaining, undefined).finally( + () => { + controller.signal.removeEventListener('abort', cancel) + if (!settled) { + controller.abort() + } + finished() } - finished() - }) + ) } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-lookup.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-lookup.ts index 45a99289fbe..c887e78f97a 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-lookup.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-lookup.ts @@ -1,281 +1,73 @@ -import { seedAccountList } from './agent-model-catalog-account-list' -import type { PrioritySemaphore } from '../../../shared/priority-semaphore' import type { AgentModelCatalogReadParams, AgentModelCatalogServiceDeps } from './agent-model-catalog-service-contract' import type { AgentModelCatalogReadOperation } from './agent-model-catalog-operation' import { agentModelCatalogReadRecords } from './agent-model-catalog-read-scope' -import { recordCatalogKey } from './agent-model-catalog-fingerprint' -import { scopedAgentModelCatalogLister } from './agent-model-catalog-lister' -import { newChatCatalogKey } from './agent-model-catalog-prewarm' -import { - catalogFolder, - sessionWorkspacePath, - projectAgentModelCatalogDefault -} from './agent-model-catalog-workspace' -import { resolveAgentModelCatalogScope } from './agent-model-catalog-scope' -import { - independentAgentModelCatalog, - usesModelSpecificAcpEfforts -} from '../../../shared/agent-session/options/agent-model-catalog-ownership' -import { - agentModelCatalogReleasedOwner, - bindAgentModelCatalogScope, - type AgentModelCatalogBinding -} from './agent-model-catalog-snapshot' -import { - AGENT_MODEL_CATALOG_PICKER_WAIT_MS, - runAgentModelCatalogOperation -} from './agent-model-catalog-listing-waiters' - -export type AgentModelCatalogClaim = Pick< - AgentModelCatalogBinding, - 'identity' | 'params' | 'current' -> & { - controller: AbortController - promise: Promise - ready: Promise - demandModel: (model: string | undefined) => void -} +import { usesModelSpecificAcpEfforts } from '../../../shared/agent-session/options/agent-model-catalog-ownership' +import type { + AgentModelCatalogLaunch, + AgentModelCatalogRefresh, + createAgentModelCatalogRefreshes +} from './agent-model-catalog-refresh' +/** Picks the refresh a read's request needs; readers of one request share it, whatever chat. */ export function createAgentModelCatalogLookup( deps: AgentModelCatalogServiceDeps, - lifetime: AbortController, - admission: PrioritySemaphore, - bindings: Map, - claims: Map, + refreshes: ReturnType, requestKey: (params: AgentModelCatalogReadParams) => string, - capture: (params: AgentModelCatalogReadParams) => string, - captureRead: (params: AgentModelCatalogReadParams) => AgentModelCatalogReadOperation, - tasks: Set> + capture: (params: AgentModelCatalogReadParams) => string ) { - return (params: AgentModelCatalogReadParams): Promise => { - const operation = params.operation ?? captureRead(params) - const deadline = operation.deadline ?? Date.now() + AGENT_MODEL_CATALOG_PICKER_WAIT_MS - const remaining = deadline - Date.now() - if (!operation.current() || remaining <= 0) { - return Promise.resolve() + const launchFor = ( + params: AgentModelCatalogReadParams + ): { key: string; launch: AgentModelCatalogLaunch } | undefined => { + // This reader's own chat decides whether it may list; only readers that agree share one. + const records = agentModelCatalogReadRecords(deps, params) + if (!records) { + return undefined } const bindingKey = requestKey(params) - const key = params.savedOnly ? `${bindingKey}:saved` : bindingKey - let identity = capture(params) - const workspaceIdentity = operation.workspaceIdentity - let coldWorkspace = params.workspace !== undefined && workspaceIdentity === null - const retirement = operation.retirement - const previous = claims.get(key) - const selectedModel = usesModelSpecificAcpEfforts(params.agent) - ? (params.selectedModel ?? - (params.sessionId ? deps.getConversation(params.sessionId)?.options?.model : undefined)) - : undefined - if (previous?.identity === identity) { - previous.demandModel(selectedModel) - return previous.promise - } - previous?.controller.abort() - const controller = operation.controller - let target = selectedModel - let targetFingerprint: string | undefined - const demandModel = (model: string | undefined) => { - target = model - if (targetFingerprint) { - deps.store.efforts.demand(targetFingerprint, model) - } - } - const ready = Promise.withResolvers() - const valid = (): boolean => - Date.now() < deadline && - !controller.signal.aborted && - !lifetime.signal.aborted && - operation.current() && - 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 - } - const records = agentModelCatalogReadRecords(deps, params) - if (!records) { - return - } - const { record, scoped } = records - const savedOnly = params.savedOnly || records.savedOnly - 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 - } - const account = scoped - ? recordCatalogKey(scoped, folder) - : await newChatCatalogKey(deps, params.agent, folder) - if (!current() || !account) { - return - } - const { fingerprint: base, accountHome: probeHome } = account - 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, - base, - exact: false, - folder, - workspacePath, - keepDefault: - workspacePath === undefined || - (workspacePath === null && independentAgentModelCatalog(params.agent)) - } - // Prewarm proves the selected account before command/folder resolution can finish. - if ( - independentAgentModelCatalog(params.agent) && - !scoped && - workspacePath === undefined && - !params.legacy && - !savedOnly - ) { - bindAgentModelCatalogScope(bindings, bindingKey, bound, requestKey, true) - } - const scope = await resolveAgentModelCatalogScope( - deps, - { ...params, workspacePath, savedOnly }, - scoped, - base, - probeHome, - current, - { deadline, current, signal: AbortSignal.any([controller.signal, operation.signal]) } - ) - if (!current() || !scope) { - return - } - const { fingerprint, exact, resolved, cwd } = scope - targetFingerprint = fingerprint - Object.assign(bound, { fingerprint, exact, cwd }) - bindAgentModelCatalogScope(bindings, bindingKey, bound, requestKey) - 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 probe = deps.probes?.[params.agent] - const signal = AbortSignal.any([lifetime.signal, controller.signal, operation.signal]) - const list = scopedAgentModelCatalogLister({ + const identity = capture(params) + const passive = !!params.savedOnly || records.savedOnly + return { + key: JSON.stringify([bindingKey, identity, passive]), + launch: { + agent: params.agent, + records, + workspacePath: params.workspacePath, + inSessionWorkspace: params.inSessionWorkspace, legacy: params.legacy, - operation: { deadline, current, signal }, - canList: () => !record || !!deps.getRecord(record.sessionId), - admission, - signal, - resolved, - probe, - probeHome, - workspacePath: cwd ?? folder - }) - if ( - probe && - probeHome && - (deps.store.probeDue(fingerprint) || - deps.store.efforts.due(fingerprint, target) || - deps.store.isListing(fingerprint)) - ) { - void deps.store.refresh( - fingerprint, - params.agent, - (selected) => { - const task = list(selected) - tasks.add(task) - void task.finally(() => tasks.delete(task)).catch(() => {}) - return task - }, - current, - signal, - target - ) - } - // 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 (current()) { - seedAccountList(deps, scope, params.agent) + passive, + bindingKey, + identity } } - const promise = runAgentModelCatalogOperation(run, remaining, controller, () => { - ready.resolve() - if (claims.get(key)?.controller === controller) { - claims.delete(key) + } + return { + /** Starts or joins the refresh this read needs, while the read itself is still current. */ + request( + params: AgentModelCatalogReadParams, + operation: AgentModelCatalogReadOperation + ): AgentModelCatalogRefresh | undefined { + const found = operation.current() ? launchFor(params) : undefined + if (!found) { + return undefined } - }) - tasks.add(promise) - void promise.finally(() => tasks.delete(promise)).catch(() => {}) - claims.set(key, { - identity, - controller, - promise, - ready: ready.promise, - params, - current, - demandModel - }) - return promise + const refresh = refreshes.join(found.key, found.launch) + refresh.want( + usesModelSpecificAcpEfforts(params.agent) + ? (params.selectedModel ?? + (params.sessionId + ? deps.getConversation(params.sessionId)?.options?.model + : undefined)) + : undefined + ) + return refresh + }, + /** Whether a refresh this read would join is still running. */ + pending(params: AgentModelCatalogReadParams): boolean { + const found = launchFor(params) + return !!found && refreshes.pending(found.key) + } } } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-operation.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-operation.ts index 9199ecbad11..0e8d5646f5b 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-operation.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-operation.ts @@ -11,7 +11,6 @@ import { export type AgentModelCatalogReadOperation = { deadline?: number retirement: number - workspaceIdentity?: unknown signal: AbortSignal current: () => boolean controller: AbortController @@ -27,7 +26,6 @@ export function captureAgentModelCatalogRead( params.deadline ?? (!params.savedOnly ? Date.now() + AGENT_MODEL_CATALOG_PICKER_WAIT_MS : undefined) const retirement = deps.store.retirementRevision(params.agent) - const workspaceIdentity = params.workspace?.identity() const original = { ...params, records: undefined, operation: undefined } const identity = agentModelCatalogRequestKey(deps, original) const controller = new AbortController() @@ -35,14 +33,12 @@ export function captureAgentModelCatalogRead( return { deadline, retirement, - workspaceIdentity, signal, controller, current: () => !signal.aborted && (deadline === undefined || Date.now() < deadline) && deps.store.retirementRevision(params.agent) === retirement && - (workspaceIdentity === null || params.workspace?.identity() === workspaceIdentity) && agentModelCatalogRequestKey(deps, original) === identity } } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-project-defaults.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-project-defaults.test.ts new file mode 100644 index 00000000000..f5dd6208862 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-project-defaults.test.ts @@ -0,0 +1,53 @@ +import { resolveHiddenProbeCwd } from '../../rate-limits/hidden-probe-cwd' +import { describe, expect, it } from 'vitest' +import { applyCodexConfiguredLaunchDefaults } from '../../codex/structured/launch/codex-configured-launch-defaults' +import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session/agent-session-wire' +import { harness } from './agent-model-catalog-account-list-test-support' + +for (const agent of ['claude', 'codex'] as const) { + describe(`${agent} project defaults on the account listing`, () => { + it('shows the account’s configured model and effort in every folder, whatever its own settings pick', async () => { + // Every folder's settings may pick both; the picker still follows the account. + const h = harness(agent, { workspaceMayOverrideDefaultModel: async () => true }) + const efforts = ['low', 'medium', 'high'].map((value) => ({ value, label: value })) + const raw = [ + { + id: h.full[0], + label: 'Model', + isDefault: true, + efforts, + defaultEffort: 'medium', + declaredDefaultEffort: 'medium' + } + ] + h.byFolder[resolveHiddenProbeCwd()] = { + origin: 'probe', + complete: true, + configured: { model: h.full[0], effort: 'high' }, + // Codex folds configured defaults into rows before the catalog service sees them. + models: applyCodexConfiguredLaunchDefaults(raw, { model: h.full[0], effort: 'high' }) + } + await h.service.read({ agent, waitForListing: true }) + const row = (result: AgentSessionModelCatalogResult) => + result.origin === 'unknown' ? null : result.models[0] + for (const id of ['a', 'e']) { + expect(row(await h.read(id, { waitForListing: true }))).toMatchObject({ + isDefault: true, + defaultEffort: 'high' + }) + expect(h.service.peek(id)?.configured).toMatchObject({ modelId: h.full[0] }) + } + expect(h.runs).toEqual([resolveHiddenProbeCwd()]) + }) + + it('lists a workspace this host cannot inspect as the account does, never where Orca runs', async () => { + const h = harness(agent) + const result = await h.service.read({ agent, workspacePath: null, waitForListing: true }) + expect(result.origin === 'unknown' ? null : result.models).toEqual( + h.full.map((id) => expect.objectContaining({ id })) + ) + expect(h.runs).toEqual([resolveHiddenProbeCwd()]) + expect(h.unresolvedRuns).toEqual([]) + }) + }) +} diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-refresh.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-refresh.ts new file mode 100644 index 00000000000..4f8d39458ad --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-refresh.ts @@ -0,0 +1,300 @@ +import type { PrioritySemaphore } from '../../../shared/priority-semaphore' +import { independentAgentModelCatalog } from '../../../shared/agent-session/options/agent-model-catalog-ownership' +import type { + AgentModelCatalogReadParams, + AgentModelCatalogServiceDeps +} from './agent-model-catalog-service-contract' +import type { AgentModelCatalogReadRecords } from './agent-model-catalog-running-account' +import { recordCatalogKey } from './agent-model-catalog-fingerprint' +import { newChatCatalogKey } from './agent-model-catalog-prewarm' +import { + agentModelCatalogDefaultFollowsAccount, + agentModelCatalogListedDefault, + catalogFolder, + sessionWorkspacePath +} from './agent-model-catalog-workspace' +import { resolveAgentModelCatalogScope } from './agent-model-catalog-scope' +import { scopedAgentModelCatalogLister } from './agent-model-catalog-lister' +import { seedAccountList } from './agent-model-catalog-account-list' +import { + agentModelCatalogReleasedOwner, + bindAgentModelCatalogScope, + type AgentModelCatalogBinding +} from './agent-model-catalog-snapshot' +import { AGENT_MODEL_CATALOG_PICKER_WAIT_MS } from './agent-model-catalog-listing-waiters' + +/** One budget for a whole refresh: setup, the folder's settings, admission and the listing. */ +const AGENT_MODEL_CATALOG_REFRESH_MS = AGENT_MODEL_CATALOG_PICKER_WAIT_MS + +/** What a refresh resolves, captured when it starts; no reader's session, deadline or callback. */ +export type AgentModelCatalogLaunch = { + agent: string + records: AgentModelCatalogReadRecords + workspacePath?: string | null + inSessionWorkspace?: true + legacy?: boolean + /** Resolves saved scope only and lists nothing (a saved-only read, or a deleted chat's). */ + passive: boolean + bindingKey: string + identity: string +} + +export type AgentModelCatalogRefresh = { + current: () => boolean + /** Its scope is bound and the listing it found started (or none was due), or it ended. */ + listing: Promise + /** The folder's default settings reached its binding, or it ended. */ + defaults: Promise + /** Its listing ingested and the account's list seeded, or it ended. */ + done: Promise + /** Also lists efforts for a reader's selected model. */ + want: (model: string | undefined) => void +} + +type Held = AgentModelCatalogRefresh & { workspaceId?: string; end: () => void } + +/** Equivalent requests share one refresh that belongs to no reader. It ends when it finishes, at + * its fixed budget, on its agent's or workspace's retirement, or with the service; readers only + * wait on it, each until its own deadline or cancellation, and never extend or end it. */ +export function createAgentModelCatalogRefreshes(context: { + deps: AgentModelCatalogServiceDeps + lifetime: AbortSignal + admission: PrioritySemaphore + bindings: Map + requestKey: (params: AgentModelCatalogReadParams) => string + tasks: Set> +}) { + const { deps, lifetime, admission, bindings, requestKey, tasks } = context + const refreshes = new Map() + const start = (key: string, launch: AgentModelCatalogLaunch): Held => { + const { agent } = launch + const retirement = deps.store.retirementRevision(agent) + const deadline = Date.now() + AGENT_MODEL_CATALOG_REFRESH_MS + const ended = new AbortController() + // Retiring its agent ends it at once, like its budget, its workspace or shutdown. + const signal = AbortSignal.any([lifetime, ended.signal, deps.store.retirements.signal(agent)]) + const current = (): boolean => + !signal.aborted && + Date.now() < deadline && + deps.store.retirementRevision(agent) === retirement + const timer = setTimeout(() => ended.abort(), AGENT_MODEL_CATALOG_REFRESH_MS) + timer.unref?.() + const gone = new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(null), { once: true }) + }) + // Every stage settles as nothing the moment the refresh ends, whatever I/O is still running. + const bounded = (work: Promise): Promise => + Promise.race([ + work.then( + (value) => (current() ? value : null), + () => null + ), + gone + ]) + const listing = Promise.withResolvers() + const defaults = Promise.withResolvers() + const targets = new Set() + let list: ((model: string | undefined) => void) | undefined + let projecting: Promise | undefined + const run = async (): Promise => { + const facts = await bounded(resolveAccount(deps, launch, current)) + if (!facts) { + return + } + const { record, scoped, workspacePath, folder, base, probeHome } = facts + const bound: AgentModelCatalogBinding = { + identity: launch.identity, + agent, + legacy: launch.legacy, + local: !record || record.location.executionHostId === 'local', + workspaceId: record?.location.workspaceId, + current: () => !lifetime.aborted && deps.store.retirementRevision(agent) === retirement, + fingerprint: base, + base, + exact: false, + folder, + workspacePath, + // A workspace that was named but cannot be inspected here may pick its own model. + keepDefault: workspacePath === undefined || agentModelCatalogDefaultFollowsAccount(agent) + } + // Prewarm proves the selected account before command/folder resolution can finish. + if ( + independentAgentModelCatalog(agent) && + !scoped && + workspacePath === undefined && + !launch.legacy && + !launch.passive + ) { + bindAgentModelCatalogScope(bindings, launch.bindingKey, bound, requestKey, true) + } + // The folder's own settings, read alongside discovery; they only ever describe this binding. + bound.projecting = bounded( + agentModelCatalogListedDefault(deps, agent, workspacePath, probeHome) + ).then((keepDefault) => { + if (keepDefault !== null) { + bound.keepDefault = keepDefault + } + }) + projecting = bound.projecting + void projecting.finally(defaults.resolve) + const scope = await bounded( + resolveAgentModelCatalogScope( + deps, + { agent, workspacePath, savedOnly: launch.passive, legacy: launch.legacy }, + scoped, + base, + probeHome, + current, + { deadline, current, signal } + ) + ) + if (!scope) { + return + } + const { fingerprint, exact, resolved, cwd } = scope + Object.assign(bound, { fingerprint, exact, cwd }) + bindAgentModelCatalogScope(bindings, launch.bindingKey, bound, requestKey) + const probe = deps.probes?.[agent] + if ( + !launch.passive && + (exact || !agentModelCatalogReleasedOwner(scoped)) && + probe && + probeHome + ) { + const lister = scopedAgentModelCatalogLister({ + legacy: launch.legacy, + admission, + signal: lifetime, + resolved, + probe, + probeHome, + workspacePath: cwd ?? folder + }) + list = (model) => { + if ( + current() && + (deps.store.probeDue(fingerprint) || + deps.store.efforts.due(fingerprint, model) || + deps.store.isListing(fingerprint)) + ) { + // A listing another refresh started keeps that refresh's budget. + void deps.store.refresh( + fingerprint, + agent, + (selected, owner) => { + const task = lister(selected, owner) + tasks.add(task) + void task.finally(() => tasks.delete(task)).catch(() => {}) + return task + }, + { deadline, signal: lifetime }, + model + ) + } + } + for (const model of targets.size > 0 ? [...targets] : [undefined]) { + list(model) + } + listing.resolve() + await bounded( + deps.store.pendingListing(fingerprint, true, true, signal) ?? Promise.resolve(null) + ) + } + listing.resolve() + if (launch.passive) { + await bound.projecting + } else if (current()) { + seedAccountList(deps, scope, agent) + } + } + const end = (): void => ended.abort() + const held: Held = { + current: () => current() && refreshes.get(key) === held, + listing: listing.promise, + defaults: defaults.promise, + done: Promise.resolve(), + want: (model) => { + if (model && !targets.has(model)) { + targets.add(model) + list?.(model) + } + }, + workspaceId: launch.records.record?.location.workspaceId, + end + } + refreshes.set(key, held) + held.done = run() + .catch(() => {}) + .finally(() => listing.resolve()) + // The folder's settings may still arrive, inside the same budget, after the listing ends. + const settled = held.done + .then(() => projecting) + .finally(() => { + clearTimeout(timer) + ended.abort() + defaults.resolve() + if (refreshes.get(key) === held) { + refreshes.delete(key) + } + }) + tasks.add(settled) + void settled.finally(() => tasks.delete(settled)) + return held + } + return { + /** Joins the running refresh for this request, or starts one; joining changes nothing. */ + join(key: string, launch: AgentModelCatalogLaunch): AgentModelCatalogRefresh { + const running = refreshes.get(key) + return running?.current() ? running : start(key, launch) + }, + pending: (key: string): boolean => refreshes.get(key)?.current() === true, + /** A workspace's folder or command context changed: its refreshes (all, without one) end. */ + retire(workspaceId?: string): void { + for (const held of refreshes.values()) { + if (workspaceId === undefined || held.workspaceId === workspaceId) { + held.end() + } + } + } + } +} + +async function resolveAccount( + deps: AgentModelCatalogServiceDeps, + launch: AgentModelCatalogLaunch, + current: () => boolean +) { + const { agent, records } = launch + const { record, scoped } = records + const workspacePath = + launch.inSessionWorkspace || + (record && + launch.workspacePath === undefined && + !launch.legacy && + (independentAgentModelCatalog(agent) || deps.agentReadsProjectModelConfig?.(agent) === true)) + ? record + ? await sessionWorkspacePath(deps, record) + : null + : launch.workspacePath + if (!current()) { + return null + } + const folder = await catalogFolder(deps, agent, workspacePath, scoped) + if (!current()) { + return null + } + const account = scoped + ? recordCatalogKey(scoped, folder) + : await newChatCatalogKey(deps, agent, folder) + if (!current() || !account) { + return null + } + return { + record, + scoped, + workspacePath, + folder, + base: account.fingerprint, + probeHome: account.accountHome + } +} 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 dd40b22d050..7c2791bdbe5 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,5 +1,5 @@ import type { StructuredAgentResolutionOperation } from '../structured-agent-command-resolution' -import { homedir } from 'node:os' +import { resolveHiddenProbeCwd } from '../../rate-limits/hidden-probe-cwd' import type { AgentSessionAccountHome } from '../../../shared/agent-session/lifecycle/agent-session-account-home' import { isNativeLocalLocation } from '../../../shared/execution-host' import type { @@ -41,7 +41,8 @@ export async function resolveAgentModelCatalogScope( return { base, fingerprint: base, exact: false } } const probe = deps.probes?.[params.agent] - const savedOnly = () => params.savedOnly || !!(record && !deps.getRecord(record.sessionId)) + // Fixed when its readers asked: no chat closing later turns a shared refresh passive. + const savedOnly = () => !!params.savedOnly if (params.legacy) { const fingerprint = agentModelCatalogScopeFingerprint(base, 'legacy-account') const resolved = @@ -58,20 +59,32 @@ export async function resolveAgentModelCatalogScope( ...(resolved ? { resolved } : {}) } } + // The list belongs to the account and the CLI a chat here would launch, not to this folder: + // resolve the executable where the chat runs, but list from an empty Orca-owned folder so the + // listing reads only account settings (a home folder can hold another account's settings as + // project settings) and every workspace sharing that invocation shares one listing. + let listIn: string + try { + listIn = resolveHiddenProbeCwd() + } catch { + return { base, fingerprint: base, exact: false } + } + // A workspace this host can't inspect lists as the account does (its default stays unknown), + // never from wherever Orca itself runs. const cwd = params.workspacePath === null - ? null - : (params.workspacePath ?? (record ? await sessionWorkspacePath(deps, record) : homedir())) + ? listIn + : (params.workspacePath ?? (record ? await sessionWorkspacePath(deps, record) : listIn)) if (!current()) { return null } if (!cwd) { - return { base, fingerprint: base, exact: false } + return { base, fingerprint: base, exact: false, cwd: listIn } } const resolve = (lookup: AgentModelCatalogLookup) => - probe?.resolve?.(home, cwd, lookup, operation).catch(() => null) + probe?.resolve?.(home, listIn, lookup, operation, cwd).catch(() => null) const scopeKey = (resolved: AgentModelCatalogResolvedProbe | null | undefined) => - agentModelCatalogScopeFingerprint(base, cwd, resolved?.identity) + agentModelCatalogScopeFingerprint(base, listIn, resolved?.identity) let resolved = await resolve(savedOnly() ? 'saved' : 'snapshot') if (!current()) { return null @@ -89,7 +102,7 @@ export async function resolveAgentModelCatalogScope( exact: true, // The account's own listing (prewarm names no folder or chat) seeds what unlisted folders show. accountListing: !record && params.workspacePath === undefined && !params.legacy, - cwd, + cwd: listIn, ...(resolved ? { resolved } : {}) } } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service-contract.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service-contract.ts index 360fe48e289..a790ee48ed5 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 @@ -66,12 +66,6 @@ export type AgentModelCatalogReadParams = { operation?: AgentModelCatalogReadOperation /** 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 /** Capability observation only; never changes the chat's selected input. */ selectedModel?: string 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 45997e1ad34..35b005e03a7 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 @@ -3,12 +3,11 @@ import { agentSessionConversationView } from '../../../shared/agent-session/life import type { AgentSessionRecord } from '../../../shared/agent-session/lifecycle/agent-session-record' import { readAccountList } from './agent-model-catalog-account-list' import { captureAgentModelCatalogRead } from './agent-model-catalog-operation' -import { - createAgentModelCatalogLookup, - type AgentModelCatalogClaim -} from './agent-model-catalog-lookup' +import { createAgentModelCatalogLookup } from './agent-model-catalog-lookup' +import { createAgentModelCatalogRefreshes } from './agent-model-catalog-refresh' import { createAgentModelCatalogLiveListings } from './agent-model-catalog-live-listings' import { prewarmAgentModelCatalog } from './agent-model-catalog-prewarm' +import { agentModelCatalogSessionPeek } from './agent-model-catalog-session-peek' import type { AgentModelCatalogServiceDeps, @@ -21,15 +20,15 @@ import { createAgentModelCatalogSnapshot, agentModelCatalogRequestKey, agentModelCatalogScopeIdentity, - retireAgentModelCatalogRequests, + retireAgentModelCatalogBindings, agentModelCatalogMemoryBinding, - agentModelCatalogSessionPeek, type AgentModelCatalogBinding } from './agent-model-catalog-snapshot' import { PrioritySemaphore } from '../../../shared/priority-semaphore' import { AGENT_MODEL_CATALOG_PICKER_WAIT_MS, - waitForAgentModelCatalogRead + waitForAgentModelCatalogRead, + waitWithinAgentModelCatalogRead } from './agent-model-catalog-listing-waiters' export function createAgentModelCatalogService( @@ -40,14 +39,23 @@ export function createAgentModelCatalogService( const admission = new PrioritySemaphore(2) const bindings = new Map() - const claims = new Map() const requestKey = agentModelCatalogRequestKey.bind(null, deps) const capture = agentModelCatalogScopeIdentity.bind(null, deps) + const refreshes = createAgentModelCatalogRefreshes({ + deps, + lifetime: lifetime.signal, + admission, + bindings, + requestKey, + tasks + }) + const lookup = createAgentModelCatalogLookup(deps, refreshes, requestKey, capture) const retireContext = (workspaceId?: string, sources = true): void => { if (sources) { live.retireContext(workspaceId) } - retireAgentModelCatalogRequests(deps, bindings, claims, workspaceId) + retireAgentModelCatalogBindings(bindings, workspaceId) + refreshes.retire(workspaceId) } const live = createAgentModelCatalogLiveListings(deps, lifetime.signal) const unbindRetirement = onAtRestCommandContextRetired((workspaceId) => @@ -55,36 +63,32 @@ export function createAgentModelCatalogService( ) const memoryBinding = (params: AgentModelCatalogReadParams) => agentModelCatalogMemoryBinding(deps, params, bindings, capture) + // In progress while a refresh this read would join still runs. const snapshot = createAgentModelCatalogSnapshot( deps, memoryBinding, - (params, bound) => - bound - ? claims.has(requestKey(params)) - : claims.get(requestKey(params))?.identity === capture(params), + (params) => lookup.pending(params), () => lifetime.signal.aborted ) const captureRead = (params: AgentModelCatalogReadParams) => captureAgentModelCatalogRead(deps, lifetime.signal, params) - const resolve = createAgentModelCatalogLookup( - deps, - lifetime, - admission, - bindings, - claims, - requestKey, - capture, - captureRead, - tasks - ) + // The read asks for the shared refresh and waits on it only within its own budget. + const demand = async (params: AgentModelCatalogReadParams): Promise => { + const operation = params.operation ?? captureRead(params) + await waitWithinAgentModelCatalogRead(operation, lookup.request(params, operation)?.done) + } return { captureRead, snapshot, - demand: (params) => (params.savedOnly ? Promise.resolve() : resolve(params)), + demand: async (params) => { + if (!params.savedOnly) { + await demand(params) + } + }, async prepareSaved(params) { if (!memoryBinding(params)) { - await resolve({ ...params, savedOnly: true }) + await demand({ ...params, savedOnly: true }) } return snapshot({ ...params, savedOnly: true }) }, @@ -96,20 +100,25 @@ export function createAgentModelCatalogService( live.providerStarted(record, source) }, async read(params) { - if (!params.savedOnly && params.waitForListing) { - const deadline = params.deadline ?? Date.now() + AGENT_MODEL_CATALOG_PICKER_WAIT_MS + const waits = !params.savedOnly && params.waitForListing + const deadline = params.deadline ?? Date.now() + AGENT_MODEL_CATALOG_PICKER_WAIT_MS + if (waits) { + const operation = params.operation ?? captureRead({ ...params, deadline }) await waitForAgentModelCatalogRead({ - params: { ...params, deadline }, + params, + reader: { deadline, signal: operation.signal }, store: deps.store, - resolve, - claim: () => claims.get(requestKey(params)), + refresh: lookup.request(params, operation), binding: () => bindings.get(requestKey(params)) }) } const result = snapshot(params) + // A waiting read that used its whole budget is final; work still running doesn't reopen it. + const { listingInProgress: _, ...settled } = result + const answer = waits && Date.now() >= deadline ? settled : result return lifetime.signal.aborted - ? result - : readAccountList(deps, params, result, () => memoryBinding({ agent: params.agent })?.base) + ? answer + : readAccountList(deps, params, answer, () => memoryBinding({ agent: params.agent })?.base) }, recordLiveListing: live.recordLiveListing, async prewarm() { diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-session-peek.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-session-peek.ts new file mode 100644 index 00000000000..a8f982bbb41 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-session-peek.ts @@ -0,0 +1,25 @@ +import { onLiveAccount } from './agent-model-catalog-running-account' +import type { AgentModelCatalogBinding } from './agent-model-catalog-snapshot' +import type { + AgentModelCatalogReadParams, + AgentModelCatalogServiceDeps +} from './agent-model-catalog-service-contract' + +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, + records: { record, scoped: onLiveAccount(deps, record), savedOnly: false } + }) + 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-shared-listing.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-shared-listing.test.ts new file mode 100644 index 00000000000..27890f46d0a --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-shared-listing.test.ts @@ -0,0 +1,444 @@ +import { expect, it, onTestFinished, vi } from 'vitest' +import { createAgentModelCatalogService } from './agent-model-catalog-service' +import { + assertStructuredAgentResolutionCurrent, + type StructuredAgentResolutionOperation +} from '../structured-agent-command-resolution' +import { AgentModelCatalogStore, type AgentModelCatalogSuccess } from './agent-model-catalog-store' +import type { AgentSessionAccountHome } from '../../../shared/agent-session/lifecycle/agent-session-account-home' +import { + joinHostModelListingWait, + isHostModelListingWaitInFlight +} from '../../../shared/host-model-listing-waits' + +function listing(id: string): AgentModelCatalogSuccess { + return { + models: [{ id, label: id, isDefault: true, efforts: [] }], + origin: 'probe' + } +} + +function discover( + service: ReturnType, + params: Parameters[0] +) { + void service.demand(params) + return service.read(params) +} + +function sharedListingHarness() { + // Folders under /busy and /own each resolve their own CLI; every other folder shares one listing. + const pending = new Map>() + const listingFor = (identity: string) => { + const held = pending.get(identity) ?? Promise.withResolvers() + pending.set(identity, held) + return held + } + const discovery = listingFor('codex') + const runs: { + identity: string + signal?: AbortSignal + current?: () => boolean + deadline?: number + }[] = [] + const run = vi.fn( + (identity: string, signal?: AbortSignal, operation?: StructuredAgentResolutionOperation) => { + runs.push({ identity, signal, current: operation?.current, deadline: operation?.deadline }) + return new Promise((resolve, reject) => { + void listingFor(identity).promise.then(resolve) + // Busy CLIs are slow to stop: they hold their slot until they finish. + if (!identity.startsWith('/busy')) { + signal?.addEventListener('abort', () => reject(new Error('stopped')), { once: true }) + } + }).then((success) => { + // The production probes check their owning operation before publishing. + assertStructuredAgentResolutionCurrent(operation) + return success + }) + } + ) + const store = new AgentModelCatalogStore() + const service = createAgentModelCatalogService({ + store, + getConversation: () => undefined, + getRecord: () => undefined, + drivesRecord: () => true, + resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/account' }), + probes: { + codex: Object.assign(vi.fn(), { + resolve: async ( + _home: unknown, + _cwd: string, + _lookup: unknown, + operation?: StructuredAgentResolutionOperation, + folder?: string + ) => { + const identity = + folder?.startsWith('/busy') || folder?.startsWith('/own') ? folder : 'codex' + return { + identity, + run: (signal?: AbortSignal, owner = operation) => run(identity, signal, owner) + } + } + }) + } + }) + onTestFinished(() => { + for (const held of pending.values()) { + held.resolve(listing('late')) + } + return service.stop() + }) + return { discovery, listingFor, runs, run, store, service } +} + +it('finishes a shared listing for a folder that joined before the first reader gave up', async () => { + vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) + const h = sharedListingHarness() + const started = Date.now() + void h.service.demand({ agent: 'codex', workspacePath: '/a', deadline: Date.now() + 1_000 }) + await vi.waitFor(() => expect(h.run).toHaveBeenCalledOnce()) + const b = h.service.read({ + agent: 'codex', + workspacePath: '/b', + waitForListing: true, + deadline: Date.now() + 60_000 + }) + await vi.advanceTimersByTimeAsync(2_000) + // The first reader is gone, but the listing it started is still running for the folder that joined. + expect(h.runs[0]?.signal?.aborted).toBe(false) + // Its requests carry the refresh's own budget, not the first reader's deadline. + expect(h.runs[0]?.deadline).toBe(started + 30_000) + h.discovery.resolve({ ...listing('shared'), complete: true, configured: {} }) + await vi.advanceTimersByTimeAsync(0) + expect(await b).toMatchObject({ models: [{ id: 'shared' }] }) + expect(h.run).toHaveBeenCalledOnce() +}) + +it.each(['retired', 'stopped'] as const)( + 'stops a shared listing and drops its late answer when the account is %s', + async (how) => { + const h = sharedListingHarness() + void h.service.demand({ agent: 'codex', workspacePath: '/a' }) + await vi.waitFor(() => expect(h.run).toHaveBeenCalledOnce()) + const write = vi.spyOn(h.store, 'recordSuccess') + if (how === 'retired') { + h.store.expireAgent('codex') + } else { + await h.service.stop() + } + expect(h.runs[0]?.signal?.aborted).toBe(true) + expect(h.runs[0]?.current?.()).toBe(false) + h.discovery.resolve(listing('late')) + await expect(h.run.mock.results[0]?.value).rejects.toThrow() + expect(write).not.toHaveBeenCalled() + } +) + +it('never starts a queued shared listing once its budget has run out, nor a replacement', async () => { + vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) + const h = sharedListingHarness() + // Two other CLIs hold both discovery slots past every budget below. + for (const busy of ['/busy-1', '/busy-2']) { + void h.service.demand({ agent: 'codex', workspacePath: busy }) + } + await vi.waitFor(() => expect(h.run).toHaveBeenCalledTimes(2)) + void h.service.demand({ agent: 'codex', workspacePath: '/a', deadline: Date.now() + 1_000 }) + void h.service.demand({ agent: 'codex', workspacePath: '/b', deadline: Date.now() + 2_000 }) + await vi.advanceTimersByTimeAsync(31_000) + h.listingFor('/busy-1').resolve(listing('busy')) + h.listingFor('/busy-2').resolve(listing('busy')) + await vi.advanceTimersByTimeAsync(60_000) + expect(h.runs.map((entry) => entry.identity)).toEqual(['/busy-1', '/busy-2']) +}) + +it.each([5, 50])( + 'runs two of %i folders with their own CLIs at a time, starts none after its budget, and leaves nothing behind', + async (folders) => { + vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) + const h = sharedListingHarness() + for (let index = 0; index < folders; index++) { + void h.service.demand({ agent: 'codex', workspacePath: `/own-${index}` }) + } + await vi.advanceTimersByTimeAsync(0) + expect(h.run).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(30_000) + expect(h.runs.every((entry) => entry.signal?.aborted)).toBe(true) + // No reader asked again, so nothing replaces the expired work. + await vi.advanceTimersByTimeAsync(60_000) + expect(h.run).toHaveBeenCalledTimes(2) + for (let index = 0; index < folders; index++) { + expect( + h.service.snapshot({ agent: 'codex', workspacePath: `/own-${index}` }) + ).not.toHaveProperty('listingInProgress') + } + } +) + +it("never answers a newly picked folder with the previous folder's late listing", async () => { + const h = sharedListingHarness() + const old = h.service.read({ agent: 'codex', workspacePath: '/own-old', waitForListing: true }) + await vi.waitFor(() => expect(h.run).toHaveBeenCalledOnce()) + const picked = h.service.read({ agent: 'codex', workspacePath: '/own-new', waitForListing: true }) + await vi.waitFor(() => expect(h.run).toHaveBeenCalledTimes(2)) + h.listingFor('/own-old').resolve({ ...listing('old'), complete: true, configured: {} }) + expect(await old).toMatchObject({ models: [{ id: 'old' }] }) + expect(h.service.snapshot({ agent: 'codex', workspacePath: '/own-new' }).origin).toBe('unknown') + h.listingFor('/own-new').resolve({ ...listing('new'), complete: true, configured: {} }) + expect(await picked).toMatchObject({ models: [{ id: 'new' }] }) + // A reader still asking for the old folder keeps its answer. + expect(h.service.snapshot({ agent: 'codex', workspacePath: '/own-old' })).toMatchObject({ + models: [{ id: 'old' }] + }) +}) + +it('keeps a queued shared listing for a folder that still wants it after the first gave up', async () => { + vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) + const h = sharedListingHarness() + for (const busy of ['/busy-1', '/busy-2']) { + void h.service.demand({ agent: 'codex', workspacePath: busy }) + } + await vi.waitFor(() => expect(h.run).toHaveBeenCalledTimes(2)) + void h.service.demand({ agent: 'codex', workspacePath: '/a', deadline: Date.now() + 1_000 }) + const b = h.service.read({ + agent: 'codex', + workspacePath: '/b', + waitForListing: true, + deadline: Date.now() + 60_000 + }) + await vi.advanceTimersByTimeAsync(2_000) + h.listingFor('/busy-1').resolve(listing('busy')) + await vi.advanceTimersByTimeAsync(0) + expect(h.runs.map((entry) => entry.identity)).toEqual(['/busy-1', '/busy-2', 'codex']) + h.discovery.resolve({ ...listing('shared'), complete: true, configured: {} }) + await vi.advanceTimersByTimeAsync(0) + expect(await b).toMatchObject({ models: [{ id: 'shared' }] }) +}) + +it('keeps a shared listing for an earlier-ending reader of the same folder when the first is cancelled', async () => { + vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) + const h = sharedListingHarness() + const first = h.service.captureRead({ + agent: 'codex', + workspacePath: '/a', + deadline: Date.now() + 60_000 + }) + void h.service.demand({ agent: 'codex', workspacePath: '/a', operation: first }) + await vi.waitFor(() => expect(h.run).toHaveBeenCalledOnce()) + const second = h.service.read({ + agent: 'codex', + workspacePath: '/a', + waitForListing: true, + deadline: Date.now() + 30_000 + }) + await vi.advanceTimersByTimeAsync(0) + first.controller.abort() + await vi.advanceTimersByTimeAsync(1_000) + expect(h.runs[0]?.signal?.aborted).toBe(false) + h.discovery.resolve({ ...listing('shared'), complete: true, configured: {} }) + await vi.advanceTimersByTimeAsync(0) + expect(await second).toMatchObject({ models: [{ id: 'shared' }] }) + expect(h.run).toHaveBeenCalledOnce() +}) + +it('keeps a later reader of the same folder waiting for its own deadline, not the first one', async () => { + vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) + const h = sharedListingHarness() + void h.service.demand({ agent: 'codex', workspacePath: '/a', deadline: Date.now() + 1_000 }) + await vi.waitFor(() => expect(h.run).toHaveBeenCalledOnce()) + const later = h.service.read({ + agent: 'codex', + workspacePath: '/a', + waitForListing: true, + deadline: Date.now() + 60_000 + }) + await vi.advanceTimersByTimeAsync(2_000) + h.discovery.resolve({ ...listing('shared'), complete: true, configured: {} }) + await vi.advanceTimersByTimeAsync(0) + expect(await later).toMatchObject({ models: [{ id: 'shared' }] }) + expect(h.run).toHaveBeenCalledOnce() +}) + +function readOperation(h: ReturnType, deadline: number) { + const controller = new AbortController() + let current = true + return { + operation: { + deadline, + retirement: h.store.retirementRevision('codex'), + signal: controller.signal, + controller, + current: () => current && !controller.signal.aborted && Date.now() < deadline + }, + // As when its chat closes: its own request no longer matches, though nothing aborts it. + leave: () => { + current = false + } + } +} + +it("keeps a shared listing for a second chat when the first chat's own request stops matching", async () => { + vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) + const h = sharedListingHarness() + const first = readOperation(h, Date.now() + 60_000) + void h.service.demand({ agent: 'codex', workspacePath: '/a', operation: first.operation }) + await vi.waitFor(() => expect(h.run).toHaveBeenCalledOnce()) + const second = h.service.read({ + agent: 'codex', + workspacePath: '/a', + waitForListing: true, + deadline: Date.now() + 30_000 + }) + await vi.advanceTimersByTimeAsync(0) + first.leave() + await vi.advanceTimersByTimeAsync(1_000) + h.discovery.resolve({ ...listing('shared'), complete: true, configured: {} }) + await vi.advanceTimersByTimeAsync(0) + expect(await second).toMatchObject({ models: [{ id: 'shared' }] }) + expect(h.run).toHaveBeenCalledOnce() +}) + +it('settles each reader at its own deadline or cancellation, and stops the listing only at its budget', async () => { + vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) + const h = sharedListingHarness() + let settled = false + void h.service + .demand({ agent: 'codex', workspacePath: '/a', deadline: Date.now() + 1_000 }) + .then(() => { + settled = true + }) + await vi.waitFor(() => expect(h.run).toHaveBeenCalledOnce()) + const longer = readOperation(h, Date.now() + 30_000) + void h.service.demand({ agent: 'codex', workspacePath: '/a', operation: longer.operation }) + longer.operation.controller.abort() + await vi.advanceTimersByTimeAsync(1_500) + expect(settled).toBe(true) + // Nobody waits any more, but the listing belongs to its refresh, not to them. + expect(h.runs[0]?.signal?.aborted).toBe(false) + await vi.advanceTimersByTimeAsync(28_500) + expect(h.runs[0]?.signal?.aborted).toBe(true) + h.discovery.resolve({ ...listing('late'), complete: true, configured: {} }) + await vi.advanceTimersByTimeAsync(0) + // Its late answer is dropped. + expect(h.service.snapshot({ agent: 'codex', workspacePath: '/a' }).origin).toBe('unknown') +}) + +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() + let failing = false + const run = vi.fn( + ( + _home: AgentSessionAccountHome, + options?: { signal?: AbortSignal; workspacePath?: string } + ) => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => + failing + ? reject(new Error('offline')) + : resolve({ + ...listing(options?.workspacePath ?? 'listed'), + complete: true, + configured: {} + }), + 12_000 + ) + options?.signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer) + reject(new Error('stopped')) + }, + { once: true } + ) + }) + ) + const service = createAgentModelCatalogService({ + store, + getConversation: () => undefined, + getRecord: () => undefined, + drivesRecord: () => true, + resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/account' }), + // Each folder resolves its own CLI (a project-local install), so each lists separately. + probes: { + codex: Object.assign(run, { + resolve: async ( + home: AgentSessionAccountHome, + _cwd: string, + _lookup?: unknown, + _operation?: unknown, + launchDirectory?: string + ) => ({ + identity: launchDirectory, + run: (signal?: AbortSignal) => run(home, { signal, workspacePath: launchDirectory }) + }) + }) + } + }) + const read = (path: string, waitForListing = false) => + waitForListing + ? service.read({ agent: 'codex', workspacePath: path, waitForListing }) + : discover(service, { agent: 'codex', workspacePath: path }) + const replies: unknown[] = [] + let leave = () => {} + try { + if (mode === 'warm') { + const initial = read('/5', true) + await vi.advanceTimersByTimeAsync(12_000) + await initial + await vi.advanceTimersByTimeAsync(120_001) + } + failing = 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]).not.toHaveProperty('listingInProgress') + expect(follow).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(7_000) + expect(replies.at(-1)).not.toHaveProperty('listingInProgress') + expect(isHostModelListingWaitInFlight('queued', owner)).toBe(false) + 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() + } finally { + leave() + service.stop() + vi.useRealTimers() + } + } +) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-shared-setup.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-shared-setup.test.ts new file mode 100644 index 00000000000..cdc4c1bca9a --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-shared-setup.test.ts @@ -0,0 +1,514 @@ +import { expect, it, onTestFinished, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session/lifecycle/agent-session-record' +import { agentSessionConversationView } from '../../../shared/agent-session/lifecycle/agent-session-conversation-view' +import { agentSessionRecordFixture } from '../agent-session-record-test-fixture' +import { createAgentModelCatalogService } from './agent-model-catalog-service' +import { AgentModelCatalogStore, type AgentModelCatalogSuccess } from './agent-model-catalog-store' + +const HOME = { variable: 'CODEX_HOME', path: '/account' } +// Its picker follows a project's own settings, unlike Claude's and Codex's. +const agent = 'pi' + +function chat(sessionId: string, workspaceId = 'ws', provider = 'codex'): AgentSessionRecord { + return agentSessionRecordFixture({ + sessionId, + provider, + accountHome: HOME, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId, + workspaceKind: 'folder' + } + }) +} + +/** As when its tab closes: the owner is released with evidence that its process ended. */ +function released(record: AgentSessionRecord): AgentSessionRecord { + return { + ...record, + lease: { + ...record.lease, + claimStatus: 'released', + deathEvidence: { + kind: 'kill-delivered', + detail: 'closed', + observedAt: 2_000, + ownerFence: record.lease.runtimeFence, + lastProvenAliveAt: 1_000, + runtimeEnd: 'quit' + } + } + } +} + +const listing = (id: string): AgentModelCatalogSuccess => ({ + origin: 'probe', + complete: true, + configured: {}, + models: [{ id, label: id, isDefault: true, efforts: [] }] +}) + +// Chats a and b share one workspace, so they share one request. The next folder lookup, CLI +// resolution or project-settings check can each be held once. Claude and Codex never check a +// project's settings, so settings stages run as Pi. +function harness(agent = 'codex') { + const records: Record = { + a: chat('a', 'ws', agent), + b: chat('b', 'ws', agent) + } + const folder: { + path: string + gate?: PromiseWithResolvers + cli?: PromiseWithResolvers + settings?: PromiseWithResolvers + } = { path: '/ws' } + const once = async (name: 'gate' | 'cli' | 'settings') => { + const held = folder[name] + folder[name] = undefined + await held?.promise + } + const discovery = Promise.withResolvers() + const run = vi.fn((signal?: AbortSignal) => { + signal?.addEventListener('abort', () => discovery.reject(new Error('stopped')), { once: true }) + return discovery.promise + }) + const resolve = vi.fn( + async (_home: unknown, _cwd: string, _lookup: unknown, _operation: unknown, at?: string) => { + await once('cli') + return { identity: `codex in ${at}`, run } + } + ) + const recordWorkspacePath = vi.fn(async (_row: { location: { workspaceId: string } }) => { + const path = folder.path + await once('gate') + return path + }) + const workspaceMayOverrideDefaultModel = vi.fn(async (_input: { workspacePath: string }) => { + await once('settings') + return false + }) + const store = new AgentModelCatalogStore() + const service = createAgentModelCatalogService({ + store, + getConversation: (id) => (records[id] ? agentSessionConversationView(records[id]) : undefined), + getRecord: (id) => records[id], + drivesRecord: () => true, + resolveAccountHome: async () => HOME, + recordWorkspacePath, + agentReadsProjectModelConfig: () => true, + workspaceMayOverrideDefaultModel, + // Claude and Codex list through their resolved CLI; other agents through the probe itself. + probes: { + [agent]: Object.assign( + vi.fn((_home: unknown, options?: { signal?: AbortSignal }) => run(options?.signal)), + { resolve: agent === 'codex' ? resolve : undefined } + ) + } + }) + onTestFinished(() => { + discovery.resolve(listing('late')) + return service.stop() + }) + return { + records, + folder, + discovery, + run, + resolve, + recordWorkspacePath, + workspaceMayOverrideDefaultModel, + store, + service + } +} + +it.each(['listing', 'setup'] as const)( + "keeps chat b's listing when chat a closes during its %s and an unrelated folder changes", + async (stage) => { + const h = harness() + const gate = stage === 'setup' ? Promise.withResolvers() : undefined + h.folder.gate = gate + void h.service.demand({ agent: 'codex', sessionId: 'a', deadline: Date.now() + 60_000 }) + const b = h.service.read({ + agent: 'codex', + sessionId: 'b', + waitForListing: true, + deadline: Date.now() + 30_000 + }) + if (stage === 'listing') { + await vi.waitFor(() => expect(h.run).toHaveBeenCalledOnce()) + } + h.records.a = released(h.records.a) + h.service.retireContext('another-folder') + gate?.resolve() + await vi.waitFor(() => expect(h.run).toHaveBeenCalledOnce()) + h.discovery.resolve(listing('shared')) + expect(await b).toMatchObject({ models: [{ id: 'shared' }] }) + expect(h.run).toHaveBeenCalledOnce() + } +) + +it.each( + (['gate', 'cli', 'settings'] as const).flatMap((stage) => + (['agent', 'workspace'] as const).map((retired) => [stage, retired] as const) + ) +)( + 'settles a waiting reader at once when its %s stage is stalled and its %s retires', + async (stage, retired) => { + vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) + // Only agents whose picker follows a project's settings check them. + const listed = stage === 'settings' ? agent : 'codex' + const h = harness(listed) + const stalled = Promise.withResolvers() + h.folder[stage] = stalled + let settled = false + void h.service + .read({ agent: listed, sessionId: 'b', waitForListing: true, deadline: Date.now() + 30_000 }) + .then(() => { + settled = true + }) + await vi.advanceTimersByTimeAsync(0) + if (retired === 'agent') { + h.store.expireAgent(listed) + } else { + h.service.retireContext('ws') + } + await vi.advanceTimersByTimeAsync(0) + expect(settled).toBe(true) + stalled.resolve() + h.discovery.resolve(listing('retired')) + await vi.advanceTimersByTimeAsync(0) + expect(h.service.snapshot({ agent: listed, sessionId: 'b' })).not.toHaveProperty('models') + } +) + +it("still lists for chat b when chat a's record goes away while their shared setup runs", async () => { + const h = harness() + // The CLI is found only once the login shell is read again. + h.resolve.mockImplementation(async (_home, _cwd, lookup, _operation, at) => { + if (lookup !== 'recover') { + throw new Error('not on the saved PATH') + } + return { identity: `codex in ${at}`, run: h.run } + }) + const gate = Promise.withResolvers() + h.folder.gate = gate + void h.service.demand({ agent: 'codex', sessionId: 'a' }) + const b = h.service.read({ agent: 'codex', sessionId: 'b', waitForListing: true }) + delete h.records.a + gate.resolve() + await vi.waitFor(() => expect(h.run).toHaveBeenCalledOnce()) + h.discovery.resolve(listing('shared')) + expect(await b).toMatchObject({ models: [{ id: 'shared' }] }) +}) + +it('keeps one listing for a workspace on the same folder when another workspace there moves', async () => { + const h = harness() + h.records.a = chat('a', 'ws-a') + h.records.b = chat('b', 'ws-b') + const paths: Record = { 'ws-a': '/repo', 'ws-b': '/repo' } + h.recordWorkspacePath.mockImplementation(async (row) => paths[row.location.workspaceId]!) + // A command line that runs in the chat's folder lists there, once per folder. + const lists: Record> = { + '/repo': Promise.withResolvers(), + '/other': Promise.withResolvers() + } + const runs: string[] = [] + h.resolve.mockImplementation(async (_home, _cwd, _lookup, _operation, at) => ({ + identity: `codex in ${at}`, + run: vi.fn(() => { + runs.push(at!) + return lists[at!]!.promise + }) + })) + void h.service.demand({ agent: 'codex', sessionId: 'a' }) + await vi.waitFor(() => expect(runs).toEqual(['/repo'])) + const b = h.service.read({ agent: 'codex', sessionId: 'b', waitForListing: true }) + paths['ws-a'] = '/other' + h.service.retireContext('ws-a') + const a = h.service.read({ agent: 'codex', sessionId: 'a', waitForListing: true }) + await vi.waitFor(() => expect(runs).toEqual(['/repo', '/other'])) + lists['/repo']!.resolve(listing('repo')) + expect(await b).toMatchObject({ models: [{ id: 'repo' }] }) + lists['/other']!.resolve(listing('other')) + expect(await a).toMatchObject({ models: [{ id: 'other' }] }) +}) + +it('ends a shorter reader at its own deadline, and a stalled refresh lists nothing after its budget', async () => { + vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) + const h = harness() + const gate = Promise.withResolvers() + h.folder.gate = gate + const longer = h.service.captureRead({ + agent: 'codex', + sessionId: 'a', + deadline: Date.now() + 30_000 + }) + void h.service.demand({ agent: 'codex', sessionId: 'a', operation: longer }) + let settled = false + void h.service + .demand({ agent: 'codex', sessionId: 'b', deadline: Date.now() + 1_000 }) + .then(() => { + settled = true + }) + longer.controller.abort() + await vi.advanceTimersByTimeAsync(1_000) + expect(settled).toBe(true) + // Its readers are gone, but the refresh runs on its own budget. + expect(h.service.snapshot({ agent: 'codex', sessionId: 'b' })).toHaveProperty('listingInProgress') + await vi.advanceTimersByTimeAsync(29_000) + expect(h.service.snapshot({ agent: 'codex', sessionId: 'b' })).not.toHaveProperty( + 'listingInProgress' + ) + gate.resolve() + await vi.advanceTimersByTimeAsync(0) + expect(h.run).not.toHaveBeenCalled() +}) + +it.each([10, 100])( + 'resolves setup and project settings once for %i readers of one request, before and after it settles', + async (readers) => { + const h = harness() + const gate = Promise.withResolvers() + h.folder.gate = gate + const reads = Array.from({ length: readers }, () => + h.service.demand({ agent: 'codex', sessionId: 'b', deadline: Date.now() + 30_000 }) + ) + gate.resolve() + await vi.waitFor(() => expect(h.run).toHaveBeenCalledOnce()) + // More readers arrive while the listing that setup found is still running. + reads.push( + ...Array.from({ length: readers }, () => + h.service.demand({ agent: 'codex', sessionId: 'b', deadline: Date.now() + 30_000 }) + ) + ) + h.discovery.resolve(listing('shared')) + await Promise.all(reads) + // One request needs one folder lookup, one CLI resolution and one project-settings check. + expect(h.recordWorkspacePath).toHaveBeenCalledOnce() + // A due listing resolves the CLI twice (a snapshot, then a fresh lookup), once per setup. + expect(h.resolve.mock.calls.length).toBeLessThanOrEqual(2) + expect(h.workspaceMayOverrideDefaultModel.mock.calls.length).toBeLessThanOrEqual(1) + expect(h.run).toHaveBeenCalledOnce() + } +) + +it('holds readers of a stalled refresh only to their own deadlines, and starts no other until asked', async () => { + vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) + const h = harness() + h.folder.gate = Promise.withResolvers() + void h.service.demand({ agent: 'codex', sessionId: 'a', deadline: Date.now() + 1_000 }) + const b = h.service.read({ + agent: 'codex', + sessionId: 'b', + waitForListing: true, + deadline: Date.now() + 30_000 + }) + await vi.advanceTimersByTimeAsync(30_000) + // b's deadline ended its wait; a reader leaving never restarts the work. + expect(await b).toEqual({ origin: 'unknown' }) + expect(h.recordWorkspacePath).toHaveBeenCalledOnce() + const c = h.service.read({ + agent: 'codex', + sessionId: 'b', + waitForListing: true, + deadline: Date.now() + 30_000 + }) + await vi.waitFor(() => expect(h.run).toHaveBeenCalledOnce()) + expect(h.recordWorkspacePath).toHaveBeenCalledTimes(2) + h.discovery.resolve(listing('fresh')) + await vi.advanceTimersByTimeAsync(0) + expect(await c).toMatchObject({ models: [{ id: 'fresh' }] }) +}) + +it("resolves a moved folder afresh instead of reusing the old folder's setup", async () => { + const h = harness() + h.folder.cli = Promise.withResolvers() + void h.service.demand({ agent: 'codex', sessionId: 'b' }) + await vi.waitFor(() => expect(h.resolve).toHaveBeenCalledOnce()) + h.folder.path = '/new' + h.service.retireContext('ws') + void h.service.demand({ agent: 'codex', sessionId: 'b' }) + await vi.waitFor(() => expect(h.run).toHaveBeenCalledOnce()) + expect(h.resolve.mock.calls.at(-1)?.[4]).toBe('/new') +}) + +it.each(['cancelled', 'stopped'] as const)( + 'settles a reader at once when it is %s while its setup is stalled', + async (how) => { + vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) + const h = harness() + h.folder.gate = Promise.withResolvers() + const reader = h.service.captureRead({ + agent: 'codex', + sessionId: 'b', + deadline: Date.now() + 30_000 + }) + let settled = false + void h.service.demand({ agent: 'codex', sessionId: 'b', operation: reader }).then(() => { + settled = true + }) + await vi.advanceTimersByTimeAsync(0) + if (how === 'cancelled') { + reader.controller.abort() + } else { + void h.service.stop() + } + await vi.advanceTimersByTimeAsync(0) + expect(settled).toBe(true) + } +) + +it("keeps the folder's defaults when they arrive after a shorter last reader left", async () => { + vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) + const h = harness(agent) + h.folder.settings = Promise.withResolvers() + const settings = h.folder.settings + const a = h.service.read({ + agent, + sessionId: 'a', + waitForListing: true, + deadline: Date.now() + 30_000 + }) + void h.service.demand({ agent, sessionId: 'b', deadline: Date.now() + 1_000 }) + await vi.waitFor(() => expect(h.run).toHaveBeenCalledOnce()) + await vi.advanceTimersByTimeAsync(2_000) + settings.resolve() + h.discovery.resolve(listing('listed')) + await vi.advanceTimersByTimeAsync(0) + // The project picks nothing, so the account's default stands. + expect(await a).toMatchObject({ models: [{ id: 'listed', isDefault: true }] }) +}) + +it("reuses a settled setup after its starting reader's deadline while its listing still runs", async () => { + vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) + const h = harness(agent) + void h.service.demand({ agent, sessionId: 'a', deadline: Date.now() + 1_000 }) + void h.service.demand({ agent, sessionId: 'b', deadline: Date.now() + 30_000 }) + await vi.waitFor(() => expect(h.run).toHaveBeenCalledOnce()) + await vi.advanceTimersByTimeAsync(2_000) + void h.service.demand({ agent, sessionId: 'b', deadline: Date.now() + 30_000 }) + await vi.advanceTimersByTimeAsync(0) + expect(h.recordWorkspacePath).toHaveBeenCalledOnce() + expect(h.workspaceMayOverrideDefaultModel).toHaveBeenCalledOnce() +}) + +it("keeps the listing when the folder's settings stall, naming no default, within the reader's budget", async () => { + vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) + const h = harness(agent) + h.folder.settings = Promise.withResolvers() + void h.service.demand({ agent, sessionId: 'a', deadline: Date.now() + 1_000 }) + let answer: unknown + void h.service + .read({ agent, sessionId: 'b', waitForListing: true, deadline: Date.now() + 30_000 }) + .then((result) => { + answer = result + }) + await vi.waitFor(() => expect(h.run).toHaveBeenCalledOnce()) + h.discovery.resolve(listing('listed')) + await vi.advanceTimersByTimeAsync(30_000) + // Whether the project picks its own model stays unknown, so the account's default isn't named. + expect(answer).toMatchObject({ models: [{ id: 'listed', isDefault: false }] }) + expect(answer).not.toHaveProperty('listingInProgress') +}) + +it("never lets a moved folder's late settings describe the folder it moved to", async () => { + vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) + const h = harness(agent) + const stalled = Promise.withResolvers() + // The old folder's settings pick their own model; the new folder's pick nothing. + h.workspaceMayOverrideDefaultModel.mockImplementation(async (input) => { + const old = input.workspacePath === '/ws' + if (old) { + await stalled.promise + } + return old + }) + void h.service.demand({ agent, sessionId: 'b' }) + await vi.waitFor(() => expect(h.run).toHaveBeenCalledOnce()) + h.folder.path = '/new' + h.service.retireContext('ws') + const moved = h.service.read({ agent, sessionId: 'b', waitForListing: true }) + h.discovery.resolve(listing('listed')) + expect(await moved).toMatchObject({ models: [{ id: 'listed', isDefault: true }] }) + stalled.resolve() + await vi.advanceTimersByTimeAsync(0) + expect(h.service.snapshot({ agent, sessionId: 'b' })).toMatchObject({ + models: [{ id: 'listed', isDefault: true }] + }) +}) + +it('keeps one folder-agent listing for a workspace on the same folder when another workspace there moves', async () => { + const records = new Map( + ['a', 'b'].map((id) => [id, { ...chat(id, `ws-${id}`), provider: 'opencode' }]) + ) + const paths: Record = { 'ws-a': '/repo', 'ws-b': '/repo' } + const lists: Record> = { + '/repo': Promise.withResolvers(), + '/other': Promise.withResolvers() + } + const probe = vi.fn((_home: unknown, options?: { workspacePath?: string }) => { + return lists[options!.workspacePath!]!.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 (row) => paths[row.location.workspaceId]!, + listsInChatFolder: new Set(['opencode']), + probes: { opencode: probe } + }) + onTestFinished(() => { + lists['/repo']!.resolve(listing('late')) + lists['/other']!.resolve(listing('late')) + return service.stop() + }) + const params = (sessionId: string) => ({ + agent: 'opencode', + sessionId, + inSessionWorkspace: true as const, + waitForListing: true + }) + void service.demand(params('a')) + await vi.waitFor(() => expect(probe).toHaveBeenCalledOnce()) + const b = service.read(params('b')) + paths['ws-a'] = '/other' + service.retireContext('ws-a') + const a = service.read(params('a')) + await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(2)) + lists['/repo']!.resolve(listing('repo')) + expect(await b).toMatchObject({ models: [{ id: 'repo' }] }) + lists['/other']!.resolve(listing('other')) + expect(await a).toMatchObject({ models: [{ id: 'other' }] }) + expect(probe.mock.calls.map((call) => call[1]?.workspacePath)).toEqual(['/repo', '/other']) +}) 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 index 4cdc6706efb..b737ff65017 100644 --- 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 @@ -2,12 +2,12 @@ import { agentModelCatalogReadRecords, agentModelCatalogScopedRecord } from './agent-model-catalog-read-scope' -import { onLiveAccount } from './agent-model-catalog-running-account' import { agentModelCatalogFingerprintForRecord } from './agent-model-catalog-fingerprint' import { agentSessionPinnedLaunchDirectory } from '../../runtime/agent-session/records/agent-session-record-launch-directory' import type { AgentSessionConversationView } from '../../../shared/agent-session/lifecycle/agent-session-conversation-view' import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session/agent-session-wire' import { agentModelCatalogResultFromEntry } from './agent-model-catalog-entry' +import { agentModelCatalogDefaultFollowsAccount } from './agent-model-catalog-workspace' import { independentAgentModelCatalog, usesModelSpecificAcpEfforts @@ -17,12 +17,15 @@ import type { AgentModelCatalogServiceDeps } from './agent-model-catalog-service-contract' +/** A request's resolved scope: lookup and projection data that owns no work. */ export type AgentModelCatalogBinding = { identity: string - params: AgentModelCatalogReadParams + agent: string + legacy?: boolean + /** Whether the chat it was resolved for runs on this machine (or it had none). */ + local: boolean workspaceId?: string current: () => boolean - cancel: () => void fingerprint: string base: string exact: boolean @@ -76,7 +79,6 @@ export function agentModelCatalogRequestKey( const record = agentModelCatalogScopedRecord(deps, params) const inWorkspace = record && - !params.workspace && params.workspacePath === undefined && (params.inSessionWorkspace || deps.listsInChatFolder?.has(params.agent) || @@ -93,7 +95,6 @@ export function agentModelCatalogRequestKey( deps.drivesRecord(record) ], released: agentModelCatalogReleasedOwner(record), - selector: params.workspace?.selector, workspacePath: params.workspacePath, inSessionWorkspace: inWorkspace && record ? [record.location.workspaceId, record.launchDirectory] : undefined, @@ -107,7 +108,6 @@ export function agentModelCatalogScopeIdentity( ): string { return JSON.stringify([ deps.store.retirementRevision(params.agent), - params.workspace?.identity(), agentModelCatalogRequestKey(deps, params) ]) } @@ -130,22 +130,19 @@ export function agentModelCatalogMemoryBinding( return } // Pinned native account and cwd can reuse a scope another caller already proved. - const cwd = params.workspace - ? undefined - : params.workspacePath === undefined + const cwd = + 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.agent === params.agent && + !!bound.legacy === !!params.legacy && + (bound.workspacePath ?? bound.folder ?? bound.cwd) === cwd && + bound.local && bound.base === agentModelCatalogFingerprintForRecord(record, bound.folder) && - bound.current() && - bound.identity === capture(bound.params) + bound.current() ) { return bound } @@ -153,7 +150,6 @@ export function agentModelCatalogMemoryBinding( } if ( deps.listsInChatFolder?.has(params.agent) || - params.workspace || (params.workspacePath !== null && (independentAgentModelCatalog(params.agent) || (deps.agentReadsProjectModelConfig?.(params.agent) === true && !params.legacy))) @@ -163,13 +159,15 @@ export function agentModelCatalogMemoryBinding( const fingerprint = agentModelCatalogFingerprintForRecord(record) return { identity: capture(params), - params, + agent: params.agent, + legacy: params.legacy, + local: true, current: () => true, - cancel: () => {}, fingerprint, base: fingerprint, exact: false, keepDefault: + agentModelCatalogDefaultFollowsAccount(params.agent) || (!params.inSessionWorkspace && params.workspacePath === undefined) || params.legacyOptionsDisplay === true || deps.agentReadsProjectModelConfig?.(params.agent) === false @@ -251,60 +249,14 @@ export function projectAgentModelCatalogSnapshot( } } -export function retireAgentModelCatalogRequests( - deps: AgentModelCatalogServiceDeps, +/** Drops resolved scopes its agent retired, and (named) a workspace's; no work ends with them. */ +export function retireAgentModelCatalogBindings( 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() + if (!bound.current() || (workspaceId !== undefined && bound.workspaceId === workspaceId)) { bindings.delete(key) } } } - -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, - records: { record, scoped: onLiveAccount(deps, record), savedOnly: false } - }) - 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.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts index dd67303959b..753084a7cf9 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,4 +1,5 @@ import { createAgentModelEffortDiscovery } from './agent-model-catalog-effort-discovery' +import { createAgentModelCatalogFences } from './agent-model-catalog-fences' import { evictAgentModelCatalogEntries, createAgentModelCatalogEntryFactory, @@ -49,6 +50,8 @@ export const AGENT_MODEL_CATALOG_MAX_ENTRIES = 256 import { refreshAgentModelCatalogListing, retireAgentModelCatalogListing, + type AgentModelCatalogListModels, + type AgentModelCatalogListingBudget, type InFlightListing } from './agent-model-catalog-lister' export type { AgentModelCatalogSessionAccess } from './agent-model-catalog-lister' @@ -65,6 +68,8 @@ export class AgentModelCatalogStore { private readonly dueEntries = new Set() private readonly partialAttempts = new Map() private readonly retirementRevisions = new Map() + // Aborts when an agent's scopes are next retired. + readonly retirements = createAgentModelCatalogFences() private readonly retiredObservations = new Set() private persistence: AgentModelCatalogPersistence | null = null readonly efforts: ReturnType @@ -146,6 +151,7 @@ export class AgentModelCatalogStore { private retireListings(agent: string): void { this.retirementRevisions.set(agent, this.retirementRevision(agent) + 1) + this.retirements.retire(agent) for (const [fingerprint, flight] of this.refreshes) { if (flight.agent === agent) { retireAgentModelCatalogListing(this.refreshes, this.listingWaiters, fingerprint, flight) @@ -256,13 +262,12 @@ export class AgentModelCatalogStore { return entry } - /** One probe per canonical scope; failures resolve null. */ + /** One probe per canonical scope; failures resolve null. Its starter's budget bounds it. */ refresh( fingerprint: string, agent: string, - listModels: (selectedModels?: readonly string[]) => Promise, - current?: () => boolean, - signal?: AbortSignal, + listModels: AgentModelCatalogListModels, + budget?: AgentModelCatalogListingBudget, selectedModel?: string ): Promise { this.isListing(fingerprint) @@ -274,8 +279,7 @@ export class AgentModelCatalogStore { maxTargets: AGENT_MODEL_CATALOG_MAX_ENTRIES, targetDue: (model) => this.efforts.due(fingerprint, model), targetAttempted: (model) => this.efforts.attempted(fingerprint, agent, model), - current, - signal, + budget, refreshes: this.refreshes, failures: this.failures, listingWaiters: this.listingWaiters, @@ -285,7 +289,7 @@ export class AgentModelCatalogStore { isListing(fingerprint: string): boolean { const flight = this.refreshes.get(fingerprint) - if (flight?.current?.() === false) { + if (flight && !flight.current()) { retireAgentModelCatalogListing(this.refreshes, this.listingWaiters, fingerprint, flight) } return this.refreshes.has(fingerprint) @@ -304,11 +308,9 @@ export class AgentModelCatalogStore { } pendingListing( - fingerprint: string, - waitForChange = false, - terminal = false - ): Promise | null { - return this.listingWaiters.wait(fingerprint, waitForChange, terminal) + ...wait: Parameters + ): ReturnType { + return this.listingWaiters.wait(...wait) } shouldRefresh(fingerprint: string): boolean { 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 82ffe1aae95..545e9cea331 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,5 +1,4 @@ import type { AgentSessionRecord } from '../../../shared/agent-session/lifecycle/agent-session-record' -import type { AgentModelCatalogBinding } from './agent-model-catalog-snapshot' import { independentAgentModelCatalog } from '../../../shared/agent-session/options/agent-model-catalog-ownership' import { type AgentSessionAccountHome, @@ -30,13 +29,19 @@ export type AgentModelCatalogWorkspaceDeps = { }) => Promise } +/** Claude and Codex pickers show the account's default everywhere, as the references do; the CLI + * still applies a project's own settings at launch and the chat then reports what it ran. */ +export function agentModelCatalogDefaultFollowsAccount(agent: string): boolean { + return independentAgentModelCatalog(agent) +} + export async function workspaceKeepsListedDefault( deps: AgentModelCatalogWorkspaceDeps, agent: string, workspacePath: string | null | undefined, accountHomePath: string | null ): Promise { - if (workspacePath === undefined) { + if (workspacePath === undefined || agentModelCatalogDefaultFollowsAccount(agent)) { return true } if (workspacePath === null || !deps.workspaceMayOverrideDefaultModel) { @@ -89,19 +94,21 @@ export async function recordConfiguredDefault( source?: AgentModelCatalogListing | null ): Promise { const accountHome = record.accountHome - if (!deps.workspaceMayOverrideDefaultModel) { - return - } - const workspacePath = await sessionWorkspacePath(deps, record) - if ( - !workspacePath || - (await deps.workspaceMayOverrideDefaultModel({ - agent: record.provider, - workspacePath, - accountHomePath: isLegacyAgentSessionAccountHome(accountHome) ? accountHome.path : null - })) - ) { - return + if (!agentModelCatalogDefaultFollowsAccount(record.provider)) { + if (!deps.workspaceMayOverrideDefaultModel) { + return + } + const workspacePath = await sessionWorkspacePath(deps, record) + if ( + !workspacePath || + (await deps.workspaceMayOverrideDefaultModel({ + agent: record.provider, + workspacePath, + accountHomePath: isLegacyAgentSessionAccountHome(accountHome) ? accountHome.path : null + })) + ) { + return + } } if ( current() && @@ -111,25 +118,14 @@ export async function recordConfiguredDefault( } } -/** Default provenance belongs to the resolved binding and the operation projecting it. */ -export async function projectAgentModelCatalogDefault( +/** The workspace's own settings, checked once per refresh for every reader sharing it; null + * when the read failed, which leaves each binding's conservative projection. */ +export function agentModelCatalogListedDefault( deps: AgentModelCatalogWorkspaceDeps, agent: string, - bound: AgentModelCatalogBinding, - home: AgentSessionAccountHome | null, - current: () => boolean -): Promise { + workspacePath: string | null | undefined, + home: AgentSessionAccountHome | null +): 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. */ - } + return workspaceKeepsListedDefault(deps, agent, workspacePath, accountHomePath).catch(() => null) } diff --git a/src/main/native-chat/agent-model-catalog/agent-project-model-config-keys.ts b/src/main/native-chat/agent-model-catalog/agent-project-model-config-keys.ts index 801b0cf0c2f..dbcf8fae5f4 100644 --- a/src/main/native-chat/agent-model-catalog/agent-project-model-config-keys.ts +++ b/src/main/native-chat/agent-model-catalog/agent-project-model-config-keys.ts @@ -14,70 +14,6 @@ function setsAny(table: Record, keys: readonly string[]): boole return keys.some((key) => table[key] !== undefined) } -// Claude agent selection can apply its model to the main thread. -const CLAUDE_MODEL_SETTINGS = [ - 'model', - 'agent', - 'effortLevel', - 'maxEffortLevel', - 'modelSettings', - 'availableModels', - 'enforceAvailableModels', - 'modelOverrides' -] as const - -function isClaudeModelEnvKey(key: string): boolean { - return ( - key === 'ANTHROPIC_MODEL' || - key === 'CLAUDE_CODE_EFFORT_LEVEL' || - /^ANTHROPIC_DEFAULT_([A-Z]+_)?MODEL$/.test(key) || - /^CLAUDE_CODE_USE_(BEDROCK|VERTEX|FOUNDRY|ANTHROPIC_AWS|ANTHROPIC_GOOGLE_CLOUD|MANTLE|GATEWAY)$/.test( - key - ) - ) -} - -/** Treat malformed settings as possible model overrides. */ -export function claudeSettingsMayPickModel(text: string): boolean { - let settings: Record | null - try { - settings = record(JSON.parse(text)) - } catch { - return true - } - if (!settings) { - return true - } - if (setsAny(settings, CLAUDE_MODEL_SETTINGS)) { - return true - } - const env = record(settings.env) - return env !== null && Object.keys(env).some(isClaudeModelEnvKey) -} - -const CODEX_MODEL_KEYS = ['model', 'model_reasoning_effort', 'model_provider'] as const - -/** Treat malformed TOML and profile-based picks as possible model overrides. */ -export function codexConfigMayPickModel(text: string): boolean { - let config: Record - try { - config = parseToml(text) - } catch { - return true - } - if (setsAny(config, CODEX_MODEL_KEYS) || config.profile !== undefined) { - return true - } - const profiles = record(config.profiles) - return ( - profiles !== null && - Object.values(profiles).some((profile) => { - const table = record(profile) - return table === null || setsAny(table, CODEX_MODEL_KEYS) - }) - ) -} - const OPENCODE_MODEL_KEYS = [ 'model', 'default_agent', diff --git a/src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts b/src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts index 24d522a3d9e..95705b06bb8 100644 --- a/src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts @@ -33,33 +33,31 @@ describe('workspaceMayOverrideDefaultModel', () => { const folder = join(repo, 'packages', 'app') write(join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n') mkdirSync(folder, { recursive: true }) - write(join(root, '.codex', 'config.toml'), 'model = "above-the-repo"\n') - expect(await mayOverride('codex', folder)).toBe(false) - write(join(repo, '.codex', 'config.toml'), 'model = "gpt-project"\n') - expect(await mayOverride('codex', folder)).toBe(true) + write(join(root, '.pi', 'settings.json'), '{"defaultModel":"above-the-repo"}') + expect(await mayOverride('pi', folder)).toBe(false) + write(join(repo, '.pi', 'settings.json'), '{"defaultModel":"project"}') + expect(await mayOverride('pi', folder)).toBe(true) }) it('reads only the folder itself when no repository contains it', async () => { const folder = join(root, 'loose') mkdirSync(folder, { recursive: true }) - write(join(root, '.codex', 'config.toml'), 'model = "parent"\n') - expect(await mayOverride('codex', folder)).toBe(false) + write(join(root, '.pi', 'settings.json'), '{"defaultModel":"parent"}') + expect(await mayOverride('pi', folder)).toBe(false) }) - it('skips the .codex directory that is the account home itself', async () => { + it('skips the .pi directory that is the account home itself', async () => { const worktree = join(root, 'home-repo') write(join(worktree, '.git'), 'gitdir: /elsewhere') - write(join(worktree, '.codex', 'config.toml'), 'model = "user"\n') - expect(await mayOverride('codex', worktree, join(worktree, '.codex'))).toBe(false) + write(join(worktree, '.pi', 'settings.json'), '{"defaultModel":"user"}') + expect(await mayOverride('pi', worktree, join(worktree, '.pi'))).toBe(false) }) it('counts unreadable project config as an override', async () => { const worktree = join(root, 'broken') write(join(worktree, '.git'), 'gitdir: /elsewhere') - write(join(worktree, '.claude', 'settings.json'), '{ "model": ') - write(join(worktree, '.codex', 'config.toml'), 'model = ') - expect(await mayOverride('claude', worktree)).toBe(true) - expect(await mayOverride('codex', worktree)).toBe(true) + write(join(worktree, '.pi', 'settings.json'), '{ "defaultModel": ') + expect(await mayOverride('pi', worktree)).toBe(true) }) it('reads OpenCode config above the repository root too, as OpenCode 2.x does', async () => { diff --git a/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts b/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts index bf3b01e9c2b..74da4683683 100644 --- a/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts +++ b/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts @@ -1,9 +1,8 @@ import type { Dirent } from 'node:fs' import { readdir, readFile, stat } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' +import { independentAgentModelCatalog } from '../../../shared/agent-session/options/agent-model-catalog-ownership' import { - claudeSettingsMayPickModel, - codexConfigMayPickModel, ompSettingsMayPickModel, openCodeAgentFileMayPickModel, openCodeConfigMayPickModel, @@ -78,13 +77,6 @@ const OPENCODE_PROJECT_MODEL_LAYERS: ProjectModelLayers = { } const PROJECT_MODEL_LAYERS: Readonly> = { - codex: { files: files(['.codex/config.toml'], codexConfigMayPickModel) }, - claude: { - files: files( - ['.claude/settings.json', '.claude/settings.local.json'], - claudeSettingsMayPickModel - ) - }, opencode: OPENCODE_PROJECT_MODEL_LAYERS, opencode2: OPENCODE_PROJECT_MODEL_LAYERS, // OMP also merges other agents' project settings. @@ -203,7 +195,8 @@ export async function workspaceMayOverrideDefaultModel(input: { workspacePath: string accountHomePath: string | null }): Promise { - if (!agentReadsProjectModelConfig(input.agent)) { + // Claude and Codex pickers follow the account wherever the chat runs. + if (!agentReadsProjectModelConfig(input.agent) || independentAgentModelCatalog(input.agent)) { return false } // Unknown project layers may override defaults anywhere. diff --git a/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-catalog-mutation.test-support.ts b/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-catalog-mutation.test-support.ts index 7b1d1b4d879..8270f6b0385 100644 --- a/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-catalog-mutation.test-support.ts +++ b/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-catalog-mutation.test-support.ts @@ -1,3 +1,4 @@ +import { resolveHiddenProbeCwd } from '../../../../rate-limits/hidden-probe-cwd' import { rm } from 'node:fs/promises' import { join } from 'node:path' import { expect, vi } from 'vitest' @@ -126,7 +127,7 @@ export async function createCatalogMutationFixture( rig.host.deps.launchAccountHome = async () => home const fingerprint = agentModelCatalogScopeFingerprint( agentModelCatalogFingerprint({ agent, accountHome: home, wslDistro: null }), - rig.root + resolveHiddenProbeCwd() ) agentModelCatalogStore.recordSuccess( fingerprint, diff --git a/src/main/native-chat/agent-session-wire/options/structured-agent-session-resting-options.ts b/src/main/native-chat/agent-session-wire/options/structured-agent-session-resting-options.ts index 9c1913242ae..d484a8acfa8 100644 --- a/src/main/native-chat/agent-session-wire/options/structured-agent-session-resting-options.ts +++ b/src/main/native-chat/agent-session-wire/options/structured-agent-session-resting-options.ts @@ -43,11 +43,7 @@ export async function readStructuredAgentSessionOptionsAtRest( sessionId, savedOnly: true, ...(!reader.readsWithoutModel ? { legacyOptionsDisplay: true as const } : {}), - ...(inSessionWorkspace - ? { inSessionWorkspace: true as const } - : independentAgentModelCatalog(record.provider) - ? { workspacePath: null } - : {}) + ...(inSessionWorkspace ? { inSessionWorkspace: true as const } : {}) }) .catch(() => null)) ?? { origin: 'unknown' } if (!reader.readsWithoutModel) { @@ -75,18 +71,20 @@ export async function readStructuredAgentSessionOptionsAtRest( 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. + // Claude and Codex already read the account's listing here; naming none means the project's + // settings pick the model, so the account's default would name the wrong one. const catalog = saved.model === undefined && !folderDefault && scoped.origin !== 'unknown' && - !reader.readsWithoutModel + !reader.readsWithoutModel && + !independentAgentModelCatalog(record.provider) ? (await deps.modelCatalog?.prepareSaved?.({ ...scope, agent: record.provider, sessionId, deadline, - legacyOptionsDisplay: true, - ...(independentAgentModelCatalog(record.provider) ? { workspacePath: null } : {}) + legacyOptionsDisplay: true }), await readCatalog(false)) : scoped diff --git a/src/main/runtime/rpc/methods/structured-sessions/catalog/structured-agent-session-options-read.test.ts b/src/main/runtime/rpc/methods/structured-sessions/catalog/structured-agent-session-options-read.test.ts index 72ac140f4cc..db8593f561c 100644 --- a/src/main/runtime/rpc/methods/structured-sessions/catalog/structured-agent-session-options-read.test.ts +++ b/src/main/runtime/rpc/methods/structured-sessions/catalog/structured-agent-session-options-read.test.ts @@ -1,3 +1,4 @@ +import { resolveHiddenProbeCwd } from '../../../../../rate-limits/hidden-probe-cwd' import { agentSessionConversationView } from '../../../../../../shared/agent-session/lifecycle/agent-session-conversation-view' import '../../../unused-default-rpc-methods.test-fixture' // The catalog read behind the picker: which directory a named worktree runs in on this host. @@ -6,7 +7,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setStructuredAgentSessionHost } from '../../../../../native-chat/agent-session-wire/host/structured-agent-session-registry' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../../../ipc/desktop-renderer-runtime-capabilities' import { createAgentModelCatalogService } from '../../../../../native-chat/agent-model-catalog/agent-model-catalog-service' -import { AgentModelCatalogStore } from '../../../../../native-chat/agent-model-catalog/agent-model-catalog-store' +import { + AgentModelCatalogStore, + type AgentModelCatalogSuccess +} from '../../../../../native-chat/agent-model-catalog/agent-model-catalog-store' import { createCodexModelCatalogProbe } from '../../../../../codex/structured/catalog/codex-model-catalog-probe' import type { runCodexAppServerSession } from '../../../../../codex/app-server/codex-app-server-session' import type { AgentSessionRecord } from '../../../../../../shared/agent-session/lifecycle/agent-session-record' @@ -16,6 +20,7 @@ import { agentSessionRecordFixture } from '../../../../../../shared/agent-session/lifecycle/agent-session-record.test-fixture' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../../shared/constants' +import { AGENT_MODEL_CATALOG_PICKER_WAIT_MS } from '../../../../../native-chat/agent-model-catalog/agent-model-catalog-listing-waiters' import { agentModelCatalogFingerprintForRecord, agentModelCatalogScopeFingerprint @@ -55,15 +60,21 @@ function catalogHost() { describe('agentSession.modelCatalog', () => { const read = vi.fn(async () => ({ origin: 'unknown' as const })) + const demand = vi.fn(async () => {}) + let readCurrent = true + const captureRead = vi.fn(() => ({ current: () => readCurrent })) let record: AgentSessionRecord | null = null beforeEach(() => { read.mockClear() + demand.mockClear() + captureRead.mockClear() + readCurrent = true record = null setStructuredAgentSessionHost( Object.assign(catalogHost(), { deps: { - modelCatalog: { read, demand: vi.fn(async () => {}) }, + modelCatalog: { read, demand, captureRead }, store: { getRecord: () => record, getConversation: () => (record ? agentSessionConversationView(record) : null), @@ -75,7 +86,7 @@ describe('agentSession.modelCatalog', () => { ) }) - it('lists the local desktop catalog under its pinned account, folder and launch arguments', async () => { + it("lists the local desktop catalog under its pinned account and launch arguments, from Orca's folder rather than the chat's", async () => { record = { ...agentSessionRecordFixture(), provider: 'codex', @@ -87,7 +98,7 @@ describe('agentSession.modelCatalog', () => { listed(invocation) expect(invocation).toMatchObject({ command: '/installed/codex', - cwd: '/repo/work', + cwd: resolveHiddenProbeCwd(), env: { CODEX_HOME: '/account/work' } }) expect(invocation.args).toEqual(expect.arrayContaining(['-c', 'model_provider="work"'])) @@ -158,14 +169,14 @@ describe('agentSession.modelCatalog', () => { launchDirectory: '/home/me/floating-a' } // The floating setting has since moved to another folder. - const resolveStructuredAgentSessionLocalWorkspacePath = vi.fn(async () => '/home/me/floating-b') + const resolveStructuredAgentCatalogWorkspacePath = vi.fn(async () => '/home/me/floating-b') await call( 'agentSession.modelCatalog', { agent: 'codex', sessionId: SESSION, worktree: `id:${FLOATING_TERMINAL_WORKTREE_ID}` }, STRUCTURED_CLIENT, - { resolveStructuredAgentSessionLocalWorkspacePath } + { resolveStructuredAgentCatalogWorkspacePath } ) - expect(resolveStructuredAgentSessionLocalWorkspacePath).not.toHaveBeenCalled() + expect(resolveStructuredAgentCatalogWorkspacePath).not.toHaveBeenCalled() expect(read).toHaveBeenCalledWith({ agent: 'codex', sessionId: SESSION, @@ -174,18 +185,18 @@ describe('agentSession.modelCatalog', () => { }) it('reads the catalog for the directory the named worktree runs in on this host', async () => { - const resolveStructuredAgentSessionLocalWorkspacePath = vi.fn(async () => '/repo/wt') + const resolveStructuredAgentCatalogWorkspacePath = vi.fn(async () => '/repo/wt') await call( 'agentSession.modelCatalog', { agent: 'codex', sessionId: SESSION, worktree: 'id:wt-1' }, STRUCTURED_CLIENT, - { resolveStructuredAgentSessionLocalWorkspacePath } + { resolveStructuredAgentCatalogWorkspacePath } ) - expect(resolveStructuredAgentSessionLocalWorkspacePath).not.toHaveBeenCalled() + expect(resolveStructuredAgentCatalogWorkspacePath).toHaveBeenCalledExactlyOnceWith('id:wt-1') expect(read).toHaveBeenCalledWith({ agent: 'codex', sessionId: SESSION, - workspace: expect.objectContaining({ selector: 'id:wt-1', resolve: expect.any(Function) }) + workspacePath: '/repo/wt' }) }) @@ -194,16 +205,135 @@ describe('agentSession.modelCatalog', () => { 'agentSession.modelCatalog', { agent: 'codex', worktree: 'id:missing' }, STRUCTURED_CLIENT, - { - resolveStructuredAgentSessionLocalWorkspacePath: vi.fn(async () => { - throw new Error('selector_not_found') - }) - } + { resolveStructuredAgentCatalogWorkspacePath: vi.fn(async () => null) } ) - expect(read).toHaveBeenCalledWith({ - agent: 'codex', - workspace: expect.objectContaining({ selector: 'id:missing', resolve: expect.any(Function) }) + expect(read).toHaveBeenCalledWith({ agent: 'codex', workspacePath: null }) + }) + + it('answers at once for a floating workspace whose folder is slow, and lists it once resolved', async () => { + // Nothing advances a timer here: a read that does not wait never waits on the filesystem. + const folder = Promise.withResolvers() + const resolver = { resolveStructuredAgentCatalogWorkspacePath: () => folder.promise } + const floating = `id:${FLOATING_TERMINAL_WORKTREE_ID}` + const first = await call( + 'agentSession.modelCatalog', + { agent: 'codex', worktree: floating }, + STRUCTURED_CLIENT, + resolver + ) + // The picker follows with a waiting read, which answers for the resolved folder. + expect(first).toMatchObject({ result: { listingInProgress: true } }) + await call( + 'agentSession.modelCatalog', + { agent: 'codex', worktree: floating, savedOnly: true }, + STRUCTURED_CLIENT, + resolver + ) + expect(read.mock.calls).toEqual([ + [{ agent: 'codex', workspacePath: null }], + [{ agent: 'codex', workspacePath: null, savedOnly: true }] + ]) + expect(demand).not.toHaveBeenCalledWith(expect.objectContaining({ workspacePath: '/float' })) + folder.resolve('/float') + // The listing it starts keeps the read's own deadline and retirement. + await vi.waitFor(() => + expect(demand).toHaveBeenCalledWith({ + agent: 'codex', + workspacePath: '/float', + deadline: expect.any(Number), + operation: captureRead.mock.results[0]?.value + }) + ) + // The passive saved-only read never starts a listing. + expect(demand).not.toHaveBeenCalledWith(expect.objectContaining({ savedOnly: true })) + }) + + it('starts no listing for a floating folder that resolves after its read was retired', async () => { + const folder = Promise.withResolvers() + await call( + 'agentSession.modelCatalog', + { agent: 'codex', worktree: `id:${FLOATING_TERMINAL_WORKTREE_ID}` }, + STRUCTURED_CLIENT, + { resolveStructuredAgentCatalogWorkspacePath: () => folder.promise } + ) + // The account or CLI changed while the folder was resolving. + readCurrent = false + folder.resolve('/float') + await new Promise((settled) => setTimeout(settled, 0)) + expect(demand).not.toHaveBeenCalledWith(expect.objectContaining({ workspacePath: '/float' })) + }) + + it("waits for a floating folder within the read's own budget, which the catalog keeps", async () => { + vi.useFakeTimers() + try { + const folder = Promise.withResolvers() + const reply = call( + 'agentSession.modelCatalog', + { agent: 'codex', worktree: `id:${FLOATING_TERMINAL_WORKTREE_ID}`, waitForListing: true }, + STRUCTURED_CLIENT, + { resolveStructuredAgentCatalogWorkspacePath: () => folder.promise } + ) + await vi.advanceTimersByTimeAsync(10_000) + folder.resolve('/float') + await reply + expect(read).toHaveBeenCalledWith({ + agent: 'codex', + waitForListing: true, + workspacePath: '/float', + deadline: Date.now() + AGENT_MODEL_CATALOG_PICKER_WAIT_MS - 10_000 + }) + } finally { + vi.useRealTimers() + } + }) + + it("keeps a moved floating workspace's list apart from the old folder's late answer", async () => { + const late = Promise.withResolvers() + const listed = (id: string): AgentModelCatalogSuccess => ({ + origin: 'probe', + complete: true, + models: [{ id, label: id, isDefault: false, efforts: [] }] }) + const probe = vi.fn(async (_home: unknown, options?: { workspacePath?: string }) => + options?.workspacePath === '/a' ? late.promise : listed('b-model') + ) + const catalog = createAgentModelCatalogService({ + store: new AgentModelCatalogStore(), + getConversation: () => undefined, + getRecord: () => undefined, + drivesRecord: () => true, + resolveAccountHome: async () => ({ variable: 'HOME', path: '/account' }), + listsInChatFolder: new Set(['opencode']), + probes: { opencode: probe } + }) + let folder = '/a' + const runtime = { + structuredAgentModelCatalog: async () => catalog, + resolveStructuredAgentCatalogWorkspacePath: async () => folder + } + const open = (waitForListing?: true) => + call( + 'agentSession.modelCatalog', + { agent: 'opencode', worktree: `id:${FLOATING_TERMINAL_WORKTREE_ID}`, waitForListing }, + STRUCTURED_CLIENT, + runtime + ) + try { + await open() + await vi.waitFor(() => expect(probe).toHaveBeenCalledOnce()) + folder = '/b' + expect(await open(true)).toMatchObject({ ok: true, result: { models: [{ id: 'b-model' }] } }) + late.resolve(listed('a-model')) + await vi.waitFor(async () => + expect(await catalog.read({ agent: 'opencode', workspacePath: '/a' })).toMatchObject({ + models: [{ id: 'a-model' }] + }) + ) + expect(await open(true)).toMatchObject({ ok: true, result: { models: [{ id: 'b-model' }] } }) + } finally { + late.resolve(listed('a-model')) + catalog.stop() + } }) it('reads as before when no worktree is named', async () => { @@ -229,7 +359,7 @@ describe('agentSession.modelCatalog', () => { const pinned = { ...agentSessionRecordFixture(), provider: 'claude' } const key = agentModelCatalogScopeFingerprint( agentModelCatalogFingerprintForRecord(pinned), - '/project', + resolveHiddenProbeCwd(), 'saved-launch' ) const seed = new AgentModelCatalogStore() @@ -412,7 +542,7 @@ describe('agentSession.modelCatalog for a chat on a model its CLI stopped listin }) // A new chat's read, which seeds the next new chat's list, and the account's name only the CLI's. const newChat = { ...forChat, worktree: 'id:wt-1' } - const runtime = { resolveStructuredAgentSessionLocalWorkspacePath: async () => '/repo' } + const runtime = { resolveStructuredAgentCatalogWorkspacePath: async () => '/repo' } for (const reply of [ await call('agentSession.modelCatalog', newChat, STRUCTURED_CLIENT, runtime), await call('agentSession.modelCatalog', { agent: 'claude' }, STRUCTURED_CLIENT) diff --git a/src/main/runtime/rpc/methods/structured-sessions/catalog/structured-agent-session-options-read.ts b/src/main/runtime/rpc/methods/structured-sessions/catalog/structured-agent-session-options-read.ts index 3b34c4675ff..797d930cf09 100644 --- a/src/main/runtime/rpc/methods/structured-sessions/catalog/structured-agent-session-options-read.ts +++ b/src/main/runtime/rpc/methods/structured-sessions/catalog/structured-agent-session-options-read.ts @@ -3,6 +3,9 @@ import { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from '../../../../../ // Older hosts return method_not_found (mobile may see forbidden); clients retain static seeds. import { defineMethod } from '../../../core' +import { withTimeout } from '../../../../../../shared/promise-timeout-fallback' +import { isFloatingWorkspaceSelector } from '../../../../../../shared/floating-workspace-worktree' +import { AGENT_MODEL_CATALOG_PICKER_WAIT_MS } from '../../../../../native-chat/agent-model-catalog/agent-model-catalog-listing-waiters' import { requireInstalledStructuredHost, requireStructuredAgentAudience @@ -99,29 +102,49 @@ 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 + const waits = !params.savedOnly && params.waitForListing === true + const deadline = Date.now() + AGENT_MODEL_CATALOG_PICKER_WAIT_MS + // Only a floating folder touches the filesystem; a read that doesn't wait never waits on it. + const resolving = + launchDirectory || worktree === undefined + ? undefined + : ctx.runtime.resolveStructuredAgentCatalogWorkspacePath(worktree) + const later = !waits && isFloatingWorkspaceSelector(worktree) const request = { ...params, ...scope, + ...(waits && resolving ? { deadline } : {}), ...(launchDirectory ? { workspacePath: launchDirectory } - : worktree === undefined + : !resolving ? {} : { - workspace: { - selector: worktree, - identity: () => - ctx.runtime.structuredAgentSessionCatalogWorkspaceIdentity(worktree), - resolve: () => ctx.runtime.resolveStructuredAgentCatalogWorkspace(worktree) - } + // Past the read's own budget, a stalled mount reads as a workspace this host + // can't inspect: no default and no exact listing. + workspacePath: later + ? null + : await withTimeout(resolving, Math.max(0, deadline - Date.now()), null) }) } - if (host) { - return host.readModelCatalog(request) + const follows = later && resolving && !params.savedOnly + if (follows) { + // The listing for the folder it resolves to starts once it does, inside this read's own + // budget and retirement; the answer says so, so the picker follows with a waiting read. + const operation = catalog.captureRead({ ...request, deadline }) + void withTimeout(resolving, Math.max(0, deadline - Date.now()), null).then( + (workspacePath) => { + if (workspacePath && operation.current()) { + void catalog.demand({ ...request, workspacePath, deadline, operation }) + } + } + ) } - if (!params.savedOnly) { + // A floating read lists the folder it resolves to (above), never the unresolved one. + if (!host && !params.savedOnly && !follows) { void catalog.demand(request) } - return catalog.read(request) + const result = host ? await host.readModelCatalog(request) : await catalog.read(request) + return follows ? { ...result, listingInProgress: true } : result } const listing = await read() const own = record?.provider === params.agent ? record : null diff --git a/src/main/runtime/rpc/methods/structured-sessions/catalog/structured-agent-session-options-workspace-scope.test.ts b/src/main/runtime/rpc/methods/structured-sessions/catalog/structured-agent-session-options-workspace-scope.test.ts index e1366fcae44..5a9b6623a74 100644 --- a/src/main/runtime/rpc/methods/structured-sessions/catalog/structured-agent-session-options-workspace-scope.test.ts +++ b/src/main/runtime/rpc/methods/structured-sessions/catalog/structured-agent-session-options-workspace-scope.test.ts @@ -113,10 +113,11 @@ async function chatAtRest(repoConfig: { ...(repoConfig.complete ? { probes: { + // The listing runs from the account's folder and reports the account's config only. codex: async () => ({ ...listing, complete: true, - configured: { model: 'gpt-5.5', effort: repoConfig.configured ? 'high' : 'medium' } + configured: { model: 'gpt-5.5', effort: 'medium' } }) } } @@ -232,22 +233,15 @@ describe('options at rest in a repo with no config of its own', () => { }) }) -describe('completed discovery in the exact project scope', () => { +describe('completed account discovery in a repo that sets its own effort', () => { it.each([OLDER_CLIENT, CURRENT_CLIENT])( - 'shows what the project actually runs to $clientId', + "shows the account's model and effort to $clientId, as the CLI's own picker does", async (client) => { await chatAtRest({ configured: true, complete: true }) const reply = await readOptions(client) - expect(reply).toMatchObject({ - ok: true, - result: { - current: { model: 'gpt-5.5' }, - models: expect.arrayContaining([ - expect.objectContaining({ id: 'gpt-5.5', declaredDefaultEffort: 'high' }) - ]) - } - }) - expect(reply).not.toHaveProperty('result.current.effort') + expect(reply).toMatchObject({ ok: true, result: { current: { model: 'gpt-5.5' } } }) + expect(reply).toHaveProperty('result.models.0.id', 'gpt-5.5') + expect(reply).toHaveProperty('result.models.0.declaredDefaultEffort', 'medium') expect(rig.adapter.acquire).not.toHaveBeenCalled() } ) 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..4226ac31d70 --- /dev/null +++ b/src/main/runtime/session-tabs/structured-session-catalog-workspace.test.ts @@ -0,0 +1,183 @@ +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('../files/paths/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/catalog/workspace-catalog' +import { OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree } from '../terminal/launch/terminal-workspace-scope' +import { RuntimeWorkspaceFileTargets } from '../workspaces/workspace-file-session-targets' +import { StructuredSessionWorkspaceScope } from './structured-session-workspace-scope' +import { RuntimeResolvedWorktreeCache } from '../workspaces/catalog/runtime-resolved-worktree-cache' +import { STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS } from '../rpc/methods/structured-sessions/catalog/structured-agent-session-options-read' +import { setStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/host/structured-agent-session-registry' +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 type { AgentModelCatalogReadParams } from '../../native-chat/agent-model-catalog/agent-model-catalog-service-contract' +import type { Repo } from '../../../shared/repo-types' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import type { RpcContext } from '../rpc/core' + +const repo = (id: string, path: string): Repo => ({ + id, + path, + displayName: id, + badgeColor: '', + addedAt: 0, + executionHostId: 'local' +}) + +afterEach(() => { + setStructuredAgentSessionHost(null) + vi.restoreAllMocks() +}) + +/** A fleet of `worktrees` worktrees across a fixed set of repos, with warm scan caches, so any + * fleet-wide projection or selector match would have every row to walk. */ +function rig(worktrees: number) { + const repos = [repo('r1', '/fleet/r1'), repo('r2', '/fleet/r2'), repo('r3', '/fleet/r3')] + const metadata: Record> = {} + const scans = new Map() + for (const owner of repos) { + const rows = Array.from({ length: worktrees / repos.length }, (_, index) => ({ + path: index === 0 ? owner.path : `${owner.path}-wt-${index}`, + head: 'head', + branch: `refs/heads/b${index}`, + isMainWorktree: index === 0, + isBare: false + })) + for (const row of rows) { + metadata[`${owner.id}::${row.path}`] = { instanceId: row.path, hostId: 'local' } + } + scans.set(`${owner.id}\0local`, { result: { ok: true, worktrees: rows }, scannedAt: 0 }) + } + const store = { + getRepos: vi.fn(() => repos), + getRepo: (id: string) => repos.find((candidate) => candidate.id === id), + getSettings: () => ({}), + getFolderWorkspaces: () => [], + getProjectGroups: () => [], + getAllWorktreeMeta: vi.fn(() => metadata), + getWorktreeMeta: vi.fn((id: string) => metadata[id]), + getAllWorktreeLineage: () => ({}), + getAllWorkspaceLineage: () => ({}) + } + // Compose the shipping owner methods without creating a service, app, journal or process. + const runtime: Record = { + store, + resolvedWorktrees: new RuntimeResolvedWorktreeCache(), + worktreeScanCache: scans, + worktreeScanGenerations: new Map(), + worktreeScanInFlight: new Map() + } + for (const owner of [ + OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree, + RuntimeWorkspaceSelectors, + RuntimeWorkspaceCatalog, + RuntimeWorkspaceFileTargets, + StructuredSessionWorkspaceScope + ]) { + for (const key of Object.getOwnPropertyNames(owner.prototype)) { + if (key !== 'constructor') { + Object.defineProperty(runtime, key, Object.getOwnPropertyDescriptor(owner.prototype, key)!) + } + } + } + runtime.requireStore = () => store + runtime.resolveFolderWorkspaceSelector = () => null + // Every fleet-wide path the old identity walked; the picker must reach none of them. + const fleet = { + listResolvedWorktrees: vi.fn(), + matchWorktreeSelector: vi.fn(), + joinResolvedWorktreeScans: vi.fn() + } + Object.assign(runtime, fleet) + const listing = vi.fn(async () => ({ + origin: 'probe' as const, + complete: true, + models: [{ id: 'm', label: 'm', isDefault: true, efforts: [] }] + })) + const catalog = createAgentModelCatalogService({ + store: new AgentModelCatalogStore(), + getConversation: () => undefined, + getRecord: () => undefined, + drivesRecord: () => true, + resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/account' }), + probes: { + codex: Object.assign(listing, { + resolve: async () => ({ identity: 'codex', run: listing }) + }) + } + }) + const requests: AgentModelCatalogReadParams[] = [] + runtime.structuredAgentModelCatalog = async () => ({ + ...catalog, + read: (params: AgentModelCatalogReadParams) => { + requests.push(params) + return catalog.read(params) + } + }) + const method = STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS.find( + (row) => row.name === 'agentSession.modelCatalog' + )! + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this RPC reads only the composed runtime slice and has no transport dependencies. + const context = { runtime } as unknown as RpcContext + const open = (worktree: string) => + method.handler( + method.params!.parse({ agent: 'codex', worktree, waitForListing: true }), + context + ) + const work = () => [store.getRepos.mock.calls.length, store.getWorktreeMeta.mock.calls.length] + return { catalog, store, fleet, requests, open, work } +} + +it.each([ + ['id:r2::/fleet/r2-wt-1', '/fleet/r2-wt-1'], + ['r2::/fleet/r2-wt-1', '/fleet/r2-wt-1'], + ['path:/fleet/r2', '/fleet/r2'] +])( + 'opening the picker for %s reads only that workspace, at N and 10N worktrees', + async (selector, path) => { + // One picker open needs one folder; nothing it does may depend on how many worktrees exist. + const runs: number[][] = [] + for (const worktrees of [30, 300]) { + const h = rig(worktrees) + try { + expect(await h.open(selector)).toMatchObject({ origin: 'probe', models: [{ id: 'm' }] }) + expect(h.requests.at(-1)?.workspacePath).toBe(path) + expect(h.store.getAllWorktreeMeta).not.toHaveBeenCalled() + expect(h.fleet.listResolvedWorktrees).not.toHaveBeenCalled() + expect(h.fleet.matchWorktreeSelector).not.toHaveBeenCalled() + expect(h.fleet.joinResolvedWorktreeScans).not.toHaveBeenCalled() + runs.push(h.work()) + } finally { + h.catalog.stop() + } + } + expect(runs[1]).toEqual(runs[0]) + } +) + +it('names no workspace for a path two hosts share, without walking worktrees', async () => { + const h = rig(30) + try { + h.store.getRepos.mockReturnValue([ + ...h.store.getRepos(), + { ...repo('r2', '/fleet/r2'), executionHostId: 'ssh:remote' } + ]) + await h.open('path:/fleet/r2') + expect(h.requests.at(-1)?.workspacePath).toBeNull() + expect(h.fleet.matchWorktreeSelector).not.toHaveBeenCalled() + } finally { + h.catalog.stop() + } +}) diff --git a/src/main/runtime/session-tabs/structured-session-workspace-scope.ts b/src/main/runtime/session-tabs/structured-session-workspace-scope.ts index 6de5f7395a0..f529d4a586d 100644 --- a/src/main/runtime/session-tabs/structured-session-workspace-scope.ts +++ b/src/main/runtime/session-tabs/structured-session-workspace-scope.ts @@ -5,8 +5,7 @@ import { isNativeLocalLocation, LOCAL_EXECUTION_HOST_ID, getConnectionExecutionHostId, - getRepoExecutionHostId, - getRepoRoutingConnectionId + getRepoExecutionHostId } from '../../../shared/execution-host' import { getLocalProjectWorktreeGitOptions } from '../../workspaces/execution-host/project-runtime-git-options' import { @@ -15,9 +14,8 @@ import { } from '../../../shared/floating-workspace-worktree' import { parseWslUncPath } from '../../../shared/wsl-paths' import { parseWorkspaceKey } from '../../../shared/workspace-scope' -import { splitWorktreeIdForFilesystem } from '../../../shared/worktree/id' +import { composeWorktreeId, splitWorktreeIdForFilesystem } from '../../../shared/worktree/id' import { resolveWorktreeHostRouting } from '../workspaces/catalog/worktree-launch-host-repo' -import { getRepoHostIdentityForParts } from '../../../shared/repo-host-identity' import type { RuntimeWorkspaceFileTargets } from '../workspaces/workspace-file-session-targets' import type { OrcaRuntimeWithTransitionGraphReloadToTerminalState } from '../session-graph/session-graph-authority' import type { RuntimeWorkspaceCatalog } from '../workspaces/catalog/workspace-catalog' @@ -34,13 +32,13 @@ export abstract class StructuredSessionWorkspaceScope extends OrcaRuntimeWithStr OrcaRuntimeWithTransitionGraphReloadToTerminalState['resolveFolderWorkspaceSelector'] > - protected abstract projectCachedWorktrees( - ...args: Parameters - ): ReturnType + protected abstract buildResolvedWorktreeFromId( + ...args: Parameters + ): ReturnType - protected abstract matchWorktreeSelector( - ...args: Parameters - ): ReturnType + protected abstract selectReposBySelector( + ...args: Parameters + ): ReturnType protected abstract listRepoWorktreesForResolution( ...args: Parameters @@ -104,7 +102,9 @@ export abstract class StructuredSessionWorkspaceScope extends OrcaRuntimeWithStr } } - private projectStructuredAgentCatalogTarget(selector: string) { + /** The one workspace a catalog read names, derived from its selector and the repo rows without + * listing worktrees: a model picker's cost must not grow with the fleet. */ + private structuredAgentCatalogTarget(selector: string) { const folder = this.resolveFolderWorkspaceSelector(selector) if (folder) { return { @@ -114,62 +114,36 @@ export abstract class StructuredSessionWorkspaceScope extends OrcaRuntimeWithStr ) } } - 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)) - ) { + let worktreeId = this.getValidatedExplicitWorktreeIdSelector(selector) + if (selector.startsWith('path:')) { + const repos = this.selectReposBySelector(selector) + // The same path on two hosts names no one workspace. + if (new Set(repos.map(getRepoExecutionHostId)).size !== 1) { return null } + worktreeId = composeWorktreeId(repos[0].id, repos[0].path) + } else if (!worktreeId && splitWorktreeIdForFilesystem(selector)) { + worktreeId = selector } - return this.runtimeFileTargetForWorktree( - this.matchWorktreeSelector(selector, snapshot.worktrees) - ) + const worktree = worktreeId ? this.buildResolvedWorktreeFromId(worktreeId) : null + return worktree ? this.runtimeFileTargetForWorktree(worktree) : null } - structuredAgentSessionCatalogWorkspaceIdentity(selector: string): string | null { - if (isFloatingWorkspaceSelector(selector)) { - const settings = this.store?.getSettings() - return JSON.stringify([ - 'floating', - settings?.floatingTerminalCwd, - settings?.floatingTerminalTrustedCwds - ]) - } + /** The folder a model catalog read may inspect for project settings; null when the selected + * workspace is not a native local directory or names no single workspace. */ + async resolveStructuredAgentCatalogWorkspacePath(selector: string): Promise { try { - const target = this.projectStructuredAgentCatalogTarget(selector) - return target - ? JSON.stringify([ - target.worktree.path, - this.structuredAgentSessionLocationForTarget(target) - ]) + const target = isFloatingWorkspaceSelector(selector) + ? await this.resolveRuntimeFileTarget(selector) + : this.structuredAgentCatalogTarget(selector) + return target && isNativeLocalLocation(this.structuredAgentSessionLocationForTarget(target)) + ? target.worktree.path : null - } catch (error) { - return JSON.stringify(['unresolved', error instanceof Error ? error.message : 'unknown']) + } catch { + return null } } - 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) diff --git a/src/main/runtime/workspaces/catalog/repo-worktree-row-resolution.test.ts b/src/main/runtime/workspaces/catalog/repo-worktree-row-resolution.test.ts index 3a1c78fd649..813bd662ee9 100644 --- a/src/main/runtime/workspaces/catalog/repo-worktree-row-resolution.test.ts +++ b/src/main/runtime/workspaces/catalog/repo-worktree-row-resolution.test.ts @@ -1,7 +1,4 @@ -import { - joinResolvedWorktreeScans, - projectResolvedWorktreeScans -} from './resolved-worktree-scan-join' +import { joinResolvedWorktreeScans } from './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' @@ -446,7 +443,7 @@ describe('folder-to-Git checkout identity', () => { ) }) -it('stamps the scan/sidebar join while cached catalog projection stays pure across detach', () => { +it('stamps the scan/sidebar join once and keeps the title across detach', () => { const owner = repo('row-owner', '/repo', { executionHostId: 'local' }) const deps = createDeps([owner]) const writes = vi.spyOn(deps.store, 'setWorktreeMeta').mockImplementation(({ id }, updates) => { @@ -458,8 +455,6 @@ it('stamps the scan/sidebar join while cached catalog projection stays pure acro const scans = new Map([ [key, { ok: true as const, worktrees: [gitWorktree('/repo/work')], gitRun: {} }] ]) - projectResolvedWorktreeScans(deps.store, () => deps, scans) - expect(writes).not.toHaveBeenCalled() const first = joinResolvedWorktreeScans(deps.store, () => deps, scans) expect(writes).toHaveBeenCalledOnce() const identity = new WorkspaceGitIdentity() @@ -474,6 +469,4 @@ it('stamps the scan/sidebar join while cached catalog projection stays pure acro identity.observeScan(rows(detached), detached.scannedRepoKeys) expect(identity.title(detached.worktrees[0]!)).toBe(first.worktrees[0]!.displayName) expect(writes).toHaveBeenCalledOnce() - projectResolvedWorktreeScans(deps.store, () => deps, scans) - expect(writes).toHaveBeenCalledOnce() }) diff --git a/src/main/runtime/workspaces/catalog/resolved-worktree-scan-join.ts b/src/main/runtime/workspaces/catalog/resolved-worktree-scan-join.ts index 1bd035baff0..14165cd974f 100644 --- a/src/main/runtime/workspaces/catalog/resolved-worktree-scan-join.ts +++ b/src/main/runtime/workspaces/catalog/resolved-worktree-scan-join.ts @@ -16,7 +16,6 @@ import { getAgentLaunchPlatformForRepo } from '../../terminal/launch/runtime-age import type { ResolvedWorktreeSnapshot } from './runtime-resolved-worktree-cache' import type { RuntimeStore } from '../../runtime-store-contract' import { - projectRepoWorktreeRows, joinRepoWorktreeRows, scanRepoWorktreeRows, type RepoWorktreeRowDeps, @@ -90,23 +89,13 @@ export function joinResolvedWorktreeScans( const platformByRepoId = store ? platformsByRepoId(repos, resolveLocalProjectRuntimesForRepos(store, repos)) : new Map() - return { ...joinRows(store, rowDeps, scans, joinRepoWorktreeRows), platformByRepoId } -} - -/** The same rows from memory only: no identity stamping and no launch platforms. */ -export function projectResolvedWorktreeScans( - store: RuntimeStore | null, - rowDeps: () => RepoWorktreeRowDeps, - scans: ResolvedWorktreeRepoScans -): ResolvedWorktreeRowsJoin { - return joinRows(store, rowDeps, scans, projectRepoWorktreeRows) + return { ...joinRows(store, rowDeps, scans), platformByRepoId } } function joinRows( store: RuntimeStore | null, rowDeps: () => RepoWorktreeRowDeps, - scans: ResolvedWorktreeRepoScans, - rowsForRepo: typeof joinRepoWorktreeRows + scans: ResolvedWorktreeRepoScans ): ResolvedWorktreeRowsJoin { if (!store) { return { @@ -127,7 +116,7 @@ function joinRows( if (!scan && !isFolderRepo(repo)) { unscannedRepoKeys.add(key) } - const rows = rowsForRepo(deps, repo, scan, metaById) + const rows = joinRepoWorktreeRows(deps, repo, scan, metaById) if (scan?.ok) { scannedRepoKeys.add(key) for (const row of rows) { diff --git a/src/main/runtime/workspaces/catalog/runtime-resolved-worktree-cache.ts b/src/main/runtime/workspaces/catalog/runtime-resolved-worktree-cache.ts index 4f86b3d13be..ee4dfe3d8ae 100644 --- a/src/main/runtime/workspaces/catalog/runtime-resolved-worktree-cache.ts +++ b/src/main/runtime/workspaces/catalog/runtime-resolved-worktree-cache.ts @@ -1,7 +1,4 @@ import type { ResolvedWorktree } from './runtime-worktree-path-identity' -import type { RuntimeStore } from '../../runtime-store-contract' -import type { ResolvedWorktreeRowsJoin } from './resolved-worktree-scan-join' -import { WORKTREE_METADATA_DOMAINS } from '../../../persistence/loading-store/worktree-identity-metadata' export type ResolvedWorktreeSnapshot = { worktrees: ResolvedWorktree[] @@ -9,8 +6,6 @@ export type ResolvedWorktreeSnapshot = { } type ResolvedCache = ResolvedWorktreeSnapshot & { expiresAt: number; inventoryRevision: number } -type CachedScanProjection = { inputs: readonly unknown[]; rows: ResolvedWorktreeRowsJoin } -type StoreWrites = { store: RuntimeStore; revision: number } type ResolvedInFlight = { generation: number inventoryRevision: number @@ -20,8 +15,6 @@ export class RuntimeResolvedWorktreeCache { private resolved: ResolvedCache | null = null private resolvedInFlight: ResolvedInFlight | null = null private resolvedGeneration = 0 - private projection: CachedScanProjection | null = null - private storeWrites: StoreWrites | null = null get generation(): number { return this.resolvedGeneration @@ -71,54 +64,8 @@ export class RuntimeResolvedWorktreeCache { } } - /** Rows projected from cached scans, reused until a store write, an invalidation, or a changed scan entry; stores that cannot report writes recompute every read. */ - projectCachedScans( - store: RuntimeStore | null, - scanEntries: readonly unknown[], - project: () => ResolvedWorktreeRowsJoin - ): ResolvedWorktreeRowsJoin { - const revision = this.storeWriteRevision(store) - if (revision === null) { - return project() - } - // Captured before projecting: a metadata migration the read itself saves must miss next time. - const inputs = [revision, ...scanEntries] - const held = this.projection - if ( - held && - held.inputs.length === inputs.length && - held.inputs.every((input, index) => input === inputs[index]) - ) { - return held.rows - } - const generation = this.resolvedGeneration - const rows = project() - if (generation === this.resolvedGeneration) { - this.projection = { inputs, rows } - } - return rows - } - - private storeWriteRevision(store: RuntimeStore | null): number | null { - if (!store?.onStateWritten) { - return null - } - if (this.storeWrites?.store !== store) { - const writes: StoreWrites = { store, revision: 0 } - this.storeWrites = writes - // Rows read metadata plus repos, lineage and folder workspaces, whose saves name no domain. - store.onStateWritten((domains) => { - if (!domains || WORKTREE_METADATA_DOMAINS.some((domain) => domains.includes(domain))) { - writes.revision += 1 - } - }) - } - return this.storeWrites.revision - } - invalidateResolved(): void { this.resolvedGeneration += 1 this.resolved = null - this.projection = null } } diff --git a/src/main/runtime/workspaces/catalog/workspace-catalog.ts b/src/main/runtime/workspaces/catalog/workspace-catalog.ts index 57298cfabac..1beb000f3e4 100644 --- a/src/main/runtime/workspaces/catalog/workspace-catalog.ts +++ b/src/main/runtime/workspaces/catalog/workspace-catalog.ts @@ -15,10 +15,8 @@ import { import { joinResolvedWorktreeScans, platformsByRepoId, - projectResolvedWorktreeScans, scanResolvedWorktreeRepos, type ResolvedWorktreeRepoScans, - type ResolvedWorktreeRowsJoin, type ResolvedWorktreeScanJoin } from './resolved-worktree-scan-join' import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage' @@ -35,7 +33,6 @@ import { withUnregisteredRemovalCheckouts } from '../../../workspaces/removal/wo import { withoutInFlightWorktreeCreates } from '../../../workspaces/creation/worktree-create-in-flight' import { preserveFolderUpgradeWorktreePath } from '../../../workspaces/catalog/folder-upgrade-worktree-path' import type { RuntimeWorktreeScanCache } from './runtime-worktree-scan-state' -import { getRepoHostIdentity } from '../../../../shared/repo-host-identity' import * as ScanCache from './runtime-worktree-scan-cache' const RESOLVED_WORKTREE_CACHE_TTL_MS = 1000 @@ -158,38 +155,6 @@ export abstract class RuntimeWorkspaceCatalog extends OrcaRuntimeWithReadKittyFi ) } - /** The fleet from cached scans for synchronous selector matching, reprojected only when its inputs change. */ - protected projectCachedWorktrees(): ResolvedWorktreeRowsJoin { - const cachedScans = (this.store?.getRepos() ?? []).map((repo) => { - const entry = this.worktreeScanCache.get(`${repo.id}\0${getRepoExecutionHostId(repo)}`) - return { repo, entry, scan: entry && (entry.listing ?? entry.result) } - }) - return this.resolvedWorktrees.projectCachedScans( - this.store, - cachedScans.flatMap(({ repo, entry, scan }) => [getRepoHostIdentity(repo), entry, scan]), - () => { - const scans: ResolvedWorktreeRepoScans = new Map() - for (const { repo, entry, scan } of cachedScans) { - if (entry && scan) { - scans.set(getRepoHostIdentity(repo), { - ...scan, - gitRun: entry - }) - } - } - return projectResolvedWorktreeScans( - this.store, - () => ({ - ...this.repoWorktreeRowDeps(), - listFolderWorkspaces: (repo, count) => - projectRuntimeFolderWorkspaces(this.requireStore(), repo, count) - }), - scans - ) - } - ) - } - protected repoWorktreeRowDeps(): RepoWorktreeRowDeps { const store = this.requireStore() return { diff --git a/src/shared/node-cli-command-resolution.ts b/src/shared/node-cli-command-resolution.ts index ba2021fe596..2db487885fd 100644 --- a/src/shared/node-cli-command-resolution.ts +++ b/src/shared/node-cli-command-resolution.ts @@ -44,6 +44,38 @@ export function pathEnvRelativeTo(pathEnv: string | undefined, cwd: string): str .join(delimiter) } +/** Whether a command line may name files relative to where it runs: a relative command, or any + * user-configured argument (no rule can tell `settings.json` or `--env-file=work env` from a + * word), so it may mean something else from another working directory. */ +function invocationDependsOnCwd(command: string, userArgs: readonly string[]): boolean { + return userArgs.length > 0 || (!isAbsolute(command) && /[\\/]/.test(command)) +} + +/** Where a catalog listing runs: `listIn`, unless the command line may mean something else + * outside the chat's `launchDirectory`. */ +export function catalogListingDirectory( + command: string, + userArgs: readonly string[], + listIn: string | undefined, + launchDirectory: string | undefined +): string | undefined { + return listIn && + launchDirectory && + launchDirectory !== listIn && + invocationDependsOnCwd(command, userArgs) + ? launchDirectory + : listIn +} + +/** The environment for a child that runs elsewhere but must find what it would from `cwd`. */ +export function envWithPathRelativeTo>( + env: T, + cwd: string +): T { + const key = env.PATH !== undefined ? 'PATH' : env.Path !== undefined ? 'Path' : null + return key ? { ...env, [key]: pathEnvRelativeTo(env[key], cwd) } : env +} + function* firstRunnable( platform: NodeJS.Platform, directories: string[], diff --git a/tests/e2e/agent-model-catalog-renderer-cache.unit.test.ts b/tests/e2e/agent-model-catalog-renderer-cache.unit.test.ts index b78d7d5affe..3a648858c86 100644 --- a/tests/e2e/agent-model-catalog-renderer-cache.unit.test.ts +++ b/tests/e2e/agent-model-catalog-renderer-cache.unit.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { homedir } from 'node:os' +import { resolveHiddenProbeCwd } from '../../src/main/rate-limits/hidden-probe-cwd' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ call: vi.fn(), cli: vi.fn() })) @@ -152,7 +152,9 @@ describe('account catalog to renderer cache', () => { expect(publish).not.toHaveBeenCalled() scope.resolve() await refresh - expect(probe.resolve.mock.calls.every(([, cwd]) => cwd === homedir())).toBe(true) + expect(probe.resolve.mock.calls.every(([, cwd]) => cwd === resolveHiddenProbeCwd())).toBe( + true + ) const exact = service.snapshot({ agent, verifiedOnly: true }) expect(exact).toMatchObject({ models: [{ id: 'account-model', isDefault: true, declaredDefaultEffort: 'high' }] 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 14db4a9e5bc..211c4cd2a7f 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 @@ -114,9 +114,16 @@ it.each(['success', 'budget'] as const)( }) ) const home = { variable: 'CLAUDE_CONFIG_DIR', path: '/qa-account' } + // A command line relative to each folder lists per folder, so five folders saturate admission. const scoped = Object.assign(probe, { - resolve: async (_home: unknown, cwd: string) => ({ - identity: { command: 'qa-modern', cwd }, + resolve: async ( + _home: unknown, + _cwd: string, + _lookup: unknown, + _operation: unknown, + launchDirectory?: string + ) => ({ + identity: { command: 'qa-modern', cwd: launchDirectory }, run: modern }) })