mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
Open the chat model picker without walking every worktree (#38660)
* fix(native-chat): list Claude and Codex models per account, not per folder Opening the chat model picker rebuilt every worktree row to work out which folder the read was for, because Claude and Codex model lists were keyed by folder. Only the default model/effort depends on the project, so lists are now keyed by account, WSL and the resolved CLI invocation and listed from the account folder; the CLI is still resolved where the chat would launch. The selected workspace is derived directly from its selector, and its own project settings decide whether the account's default model and effort are shown. The worktree projection and its cache are removed. * test: merge duplicate node:os import * fix(native-chat): list from Orca's probe folder, keep cwd-relative CLIs in their folder, one project-settings pass - List Claude/Codex models from Orca's empty probe folder so a home folder's settings never read as another account's project settings. - A command line naming files relative to the chat's folder lists there and is keyed to it. - Read project settings once per binding for both the model and effort answers; per-model effort-only Claude settings keep the account model. - A running chat's catalog withholds the account's configured choice where its project may pick its own. - Bound the picker's workspace resolution so a stalled floating folder cannot hold it. * fix(native-chat): catch quoted ./ arguments, list prewarm from Orca's probe folder, fake-timer the shared-listing test * perf(native-chat): parse each project settings file once for both model and effort * test: catalog e2e expectations list from Orca's probe folder; phone saturation keys per folder * fix(native-chat): share a listing while any reader wants it; list custom command lines in the chat's folder; resolve a floating folder within the read's own wait - A model listing shared by several folders keeps running while any reader still waits on it, queued or running, and stops (never starts) once the last one gives up. - A Claude or Codex command line with user arguments or a relative command lists in the chat's folder; Claude reads only user settings there so project settings never narrow the account's list. - A waiting read resolves a floating folder within its own deadline; a read that doesn't wait returns at once and starts the listing when the folder resolves. * fix(native-chat): every reader of a shared model listing owns it until its own deadline; floating reads list their folder within their own budget - Readers of the same request share one lookup that lives until the last reader's deadline, so later readers neither repeat CLI/settings setup nor lose the listing when an earlier one ends or is cancelled. - A shared listing's requests carry no single reader's deadline: the provider timeout bounds them and the last reader leaving stops them. - A floating picker read that doesn't wait starts its folder's listing under its own deadline and retirement, and says the listing is in progress so the picker follows with a waiting read. * fix(native-chat): a shared model listing survives the chat that started it; unreadable workspaces list as the account - Each reader of a shared lookup fences its own request, so one chat closing never ends another chat's listing; the lookup's budget is the latest deadline among readers still waiting, and its listeners are released when it ends. - A picker for a workspace this host can't inspect (remote, WSL, ambiguous, stalled) lists as the account does from Orca's probe folder, never from wherever Orca itself runs. * refactor(native-chat): readers of one model request share an ownerless setup and each own only their claim and the listing - Same-request readers share one setup (folder, account, CLI scope and the folder's own default settings) that belongs to no reader: its budget is fixed when it starts, it ends with the service or the agent's retirement, and it is reused while it runs and while the listing it found runs. - Each reader keeps its own claim, deadline and cancellation; a binding holds scope facts only and never cancels another reader's claim or a shared listing, so one chat closing never ends another chat's listing. - A floating picker read that doesn't wait no longer asks to list the unresolved folder. * fix(native-chat): shared catalog setups end on their budget or a workspace change, readers settle on cancel, and defaults and account seeding happen once - A shared setup that runs out of its budget, or whose workspace or agent is retired, answers nothing at once and leaves the cache, so a reader with time left resolves afresh (a moved folder never reuses the old path). - Cancelling a reader, or stopping the service, ends its wait at once. - A folder's own default settings apply to its binding whenever they arrive; the account list is seeded once per shared setup, not per reader. - A reader shares a setup only with readers whose own chat agrees on whether it may list. * fix(native-chat): a settled catalog setup serves its whole listing, and a folder's default settings stay bounded and reach every reader - A setup that has answered stays reusable while the listing it found runs, past its first reader's deadline, until it is ended or its agent retired, so later readers don't redo CLI and project-settings work. - The setup's budget holds until the folder's default settings have answered too; settings that outlive it are read again while a reader still has time, and apply to the request's current binding whichever reader asked. * refactor(native-chat): the catalog service owns each model refresh within one fixed budget; pickers only wait A refresh (account, folder and CLI resolution, the folder's default settings, admission and the listing) now belongs to the catalog service with one 30s budget fixed before setup. Readers join it and wait only until their own deadline or cancellation; none extends, shortens or ends it. Expiry withdraws queued listings, stops the process and drops late answers. Defaults apply only to the refresh's own binding, so a moved folder's late settings never describe the new folder. A waiting read that used its whole budget is final. Replaces per-reader claims, shared setups and reader-counted listing demand. * fix(native-chat): retiring an agent or a chat's folder ends its catalog refresh at once, and a shared setup never rechecks the chat that started it - Retiring an agent ends a refresh still stalled in setup, so a waiting picker answers at once instead of after the refresh's budget. - A listing that runs in a chat's own folder stops, and its answer is dropped, when that folder's command context retires; listings shared across folders stay independent. - Scope resolution uses only the passivity its readers had when they asked, so another chat's record going away mid-setup no longer turns it saved-only. - A reader that stops waiting unsubscribes from the listing at once. * fix(native-chat): one workspace's retirement no longer stops a catalog listing another workspace shares A listing is keyed by the folder it lists in and the CLI invocation, so moving one workspace never makes it describe the wrong folder; stopping it there cancelled a listing another workspace on the same folder still needed. Agent retirement and shutdown still stop listings at once. * test(native-chat): a folder change keeps another workspace's shared catalog listing, and never answers the new folder with the old one's * test(native-chat): a folder agent's listing survives another workspace on its folder moving * test(native-chat): type the shared-setup harness's held stages without casts * fix(native-chat): Claude and Codex pickers show the account's default in every folder The picker no longer reads a repo's own model or effort settings for Claude and Codex: they show the account's list and default everywhere, and the CLI still applies the repo's settings at launch. OpenCode, OMP and Pi keep their project checks. The model/effort split and the peek withholding go back to main's shapes. * fix(native-chat): a Codex listing in the chat's folder never loads that folder's project config A Codex command line that may name files in the chat's folder still lists there, but now marks that folder and its parents untrusted for the listing, so a repo's own model catalog can't replace the account's. The exported project-settings check also answers no for Claude and Codex. * fix(native-chat): Codex lists models only from Orca's probe folder A Codex listing no longer starts in the chat's folder for custom or relative command lines, so a repo's own config can never change the picker's list, as other Codex hosts do. Chats still launch with the user's command line and folder. Drops the untrusted-folder override. * fix(native-chat): Codex listings never read project config above Orca's folder With no project-root markers, Codex reads project config only from the listing's own folder, so a trusted repo that contains Orca's profile can't supply the account's model list. * docs(native-chat): say what Codex listing isolation covers, and what managed config can still restore
This commit is contained in:
@@ -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<typeof runAgentModelCatalogListing>
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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<AgentModelCatalogProbe>[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<AgentModelCatalogSuccess> => {
|
||||
assertStructuredAgentResolutionCurrent(operation)
|
||||
run: async (
|
||||
signal?: AbortSignal,
|
||||
runOperation = operation
|
||||
): Promise<AgentModelCatalogSuccess> => {
|
||||
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<Record<string, string>>
|
||||
): 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
|
||||
}
|
||||
|
||||
@@ -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<Record<string, string>>
|
||||
): 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
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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<AgentModelCatalogProbe>[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<AgentModelCatalogSuccess> => {
|
||||
assertStructuredAgentResolutionCurrent(operation)
|
||||
run: async (
|
||||
signal?: AbortSignal,
|
||||
runOperation = operation
|
||||
): Promise<AgentModelCatalogSuccess> => {
|
||||
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')
|
||||
}
|
||||
|
||||
+144
@@ -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<string, string> = {
|
||||
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<AgentModelCatalogServiceDeps> = {}
|
||||
) {
|
||||
const full = ['gpt-a', 'gpt-b', 'gpt-c']
|
||||
const byFolder: Record<string, AgentModelCatalogSuccess> = {
|
||||
[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<string, PromiseWithResolvers<void>>()
|
||||
const runs: string[] = []
|
||||
const scopeGate = Promise.withResolvers<void>()
|
||||
let holdScope = false
|
||||
const resolveScope: NonNullable<AgentModelCatalogProbe['resolve']> = 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<AgentModelCatalogServiceDeps['resolveAccountHome']>(
|
||||
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<void>()
|
||||
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<ReturnType<ReturnType<typeof harness>['read']>>) =>
|
||||
result.origin === 'unknown' ? null : result.models.map((model) => model.id)
|
||||
@@ -4,7 +4,11 @@ import type { AgentModelCatalogSuccess } from './agent-model-catalog-entry'
|
||||
|
||||
export type AgentModelCatalogResolvedProbe = {
|
||||
identity: unknown
|
||||
run: (signal?: AbortSignal) => Promise<AgentModelCatalogSuccess>
|
||||
/** `operation` owns the run; it defaults to the one the invocation was resolved under. */
|
||||
run: (
|
||||
signal?: AbortSignal,
|
||||
operation?: StructuredAgentResolutionOperation
|
||||
) => Promise<AgentModelCatalogSuccess>
|
||||
}
|
||||
|
||||
/** 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<AgentModelCatalogResolvedProbe>
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/** One abort signal per key (an agent's scopes) until it retires. */
|
||||
export function createAgentModelCatalogFences<K>() {
|
||||
const fences = new Map<K, AbortController>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<AgentModelCatalogSuccess>
|
||||
|
||||
/** 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<string>
|
||||
current?: () => boolean
|
||||
current: () => boolean
|
||||
promise: Promise<AgentModelCatalogEntry | null>
|
||||
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<AgentModelCatalogSuccess>
|
||||
listModels: AgentModelCatalogListModels
|
||||
selectedModel?: string
|
||||
maxTargets: number
|
||||
targetDue: (model: string) => boolean
|
||||
targetAttempted: (model: string) => void
|
||||
current?: () => boolean
|
||||
signal?: AbortSignal
|
||||
budget?: AgentModelCatalogListingBudget
|
||||
refreshes: Map<string, InFlightListing>
|
||||
failures: AgentModelCatalogFailures
|
||||
listingWaiters: AgentModelCatalogListingWaiters
|
||||
@@ -80,28 +92,39 @@ export function refreshAgentModelCatalogListing(input: {
|
||||
source: AgentModelCatalogSource
|
||||
) => AgentModelCatalogEntry | null
|
||||
}): Promise<AgentModelCatalogEntry | null> {
|
||||
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<AgentModelCatalogEntry | null>()
|
||||
// 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<AgentModelCatalogSuccess> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AgentModelCatalogEntry | null> | 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<unknown> | undefined
|
||||
): Promise<void> {
|
||||
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<void>()
|
||||
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<void>
|
||||
claim: () => { ready: Promise<void>; promise: Promise<void> } | undefined
|
||||
refresh: AgentModelCatalogRefresh | undefined
|
||||
binding: () => AgentModelCatalogBinding | undefined
|
||||
}): Promise<void> {
|
||||
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> => {
|
||||
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<void>()
|
||||
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()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<void>
|
||||
ready: Promise<void>
|
||||
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<string, AgentModelCatalogBinding>,
|
||||
claims: Map<string, AgentModelCatalogClaim>,
|
||||
refreshes: ReturnType<typeof createAgentModelCatalogRefreshes>,
|
||||
requestKey: (params: AgentModelCatalogReadParams) => string,
|
||||
capture: (params: AgentModelCatalogReadParams) => string,
|
||||
captureRead: (params: AgentModelCatalogReadParams) => AgentModelCatalogReadOperation,
|
||||
tasks: Set<Promise<unknown>>
|
||||
capture: (params: AgentModelCatalogReadParams) => string
|
||||
) {
|
||||
return (params: AgentModelCatalogReadParams): Promise<void> => {
|
||||
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<void>()
|
||||
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<void> => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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<void>
|
||||
/** The folder's default settings reached its binding, or it ended. */
|
||||
defaults: Promise<void>
|
||||
/** Its listing ingested and the account's list seeded, or it ended. */
|
||||
done: Promise<void>
|
||||
/** 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<string, AgentModelCatalogBinding>
|
||||
requestKey: (params: AgentModelCatalogReadParams) => string
|
||||
tasks: Set<Promise<unknown>>
|
||||
}) {
|
||||
const { deps, lifetime, admission, bindings, requestKey, tasks } = context
|
||||
const refreshes = new Map<string, Held>()
|
||||
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<null>((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 = <T>(work: Promise<T | null>): Promise<T | null> =>
|
||||
Promise.race([
|
||||
work.then(
|
||||
(value) => (current() ? value : null),
|
||||
() => null
|
||||
),
|
||||
gone
|
||||
])
|
||||
const listing = Promise.withResolvers<void>()
|
||||
const defaults = Promise.withResolvers<void>()
|
||||
const targets = new Set<string>()
|
||||
let list: ((model: string | undefined) => void) | undefined
|
||||
let projecting: Promise<void> | undefined
|
||||
const run = async (): Promise<void> => {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string | null | { path: string | null; identity: unknown }>
|
||||
}
|
||||
sessionId?: string
|
||||
/** Capability observation only; never changes the chat's selected input. */
|
||||
selectedModel?: string
|
||||
|
||||
@@ -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<string, AgentModelCatalogBinding>()
|
||||
const claims = new Map<string, AgentModelCatalogClaim>()
|
||||
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<void> => {
|
||||
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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<typeof createAgentModelCatalogService>,
|
||||
params: Parameters<typeof service.read>[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<string, PromiseWithResolvers<AgentModelCatalogSuccess>>()
|
||||
const listingFor = (identity: string) => {
|
||||
const held = pending.get(identity) ?? Promise.withResolvers<AgentModelCatalogSuccess>()
|
||||
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<AgentModelCatalogSuccess>((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<typeof sharedListingHarness>, 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<AgentModelCatalogSuccess>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
failing
|
||||
? reject(new Error('offline'))
|
||||
: resolve({
|
||||
...listing(options?.workspacePath ?? 'listed'),
|
||||
complete: true,
|
||||
configured: {}
|
||||
}),
|
||||
12_000
|
||||
)
|
||||
options?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
reject(new Error('stopped'))
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
const service = createAgentModelCatalogService({
|
||||
store,
|
||||
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()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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<string, AgentSessionRecord> = {
|
||||
a: chat('a', 'ws', agent),
|
||||
b: chat('b', 'ws', agent)
|
||||
}
|
||||
const folder: {
|
||||
path: string
|
||||
gate?: PromiseWithResolvers<void>
|
||||
cli?: PromiseWithResolvers<void>
|
||||
settings?: PromiseWithResolvers<void>
|
||||
} = { path: '/ws' }
|
||||
const once = async (name: 'gate' | 'cli' | 'settings') => {
|
||||
const held = folder[name]
|
||||
folder[name] = undefined
|
||||
await held?.promise
|
||||
}
|
||||
const discovery = Promise.withResolvers<AgentModelCatalogSuccess>()
|
||||
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<void>() : 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<void>()
|
||||
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<void>()
|
||||
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<string, string> = { '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<string, PromiseWithResolvers<AgentModelCatalogSuccess>> = {
|
||||
'/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<void>()
|
||||
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<void>()
|
||||
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>()
|
||||
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>()
|
||||
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<void>()
|
||||
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<void>()
|
||||
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>()
|
||||
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<void>()
|
||||
// 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<string, string> = { 'ws-a': '/repo', 'ws-b': '/repo' }
|
||||
const lists: Record<string, PromiseWithResolvers<AgentModelCatalogSuccess>> = {
|
||||
'/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'])
|
||||
})
|
||||
@@ -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<string, AgentModelCatalogBinding>,
|
||||
claims: Map<
|
||||
string,
|
||||
{
|
||||
identity: string
|
||||
params: AgentModelCatalogReadParams
|
||||
controller: AbortController
|
||||
current?: () => boolean
|
||||
}
|
||||
>,
|
||||
workspaceId?: string
|
||||
): void {
|
||||
for (const [key, claim] of claims) {
|
||||
if (
|
||||
!(
|
||||
claim.current?.() ?? claim.identity === agentModelCatalogScopeIdentity(deps, claim.params)
|
||||
) ||
|
||||
(workspaceId !== undefined &&
|
||||
claim.params.sessionId !== undefined &&
|
||||
deps.getConversation(claim.params.sessionId)?.location.workspaceId === workspaceId)
|
||||
) {
|
||||
claim.controller.abort()
|
||||
claims.delete(key)
|
||||
}
|
||||
}
|
||||
for (const [key, bound] of bindings) {
|
||||
if (
|
||||
!bound.current() ||
|
||||
bound.identity !== agentModelCatalogScopeIdentity(deps, bound.params) ||
|
||||
(workspaceId !== undefined && bound.workspaceId === workspaceId)
|
||||
) {
|
||||
bound.cancel()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>()
|
||||
private readonly partialAttempts = new Map<string, { agent: string; at: number }>()
|
||||
private readonly retirementRevisions = new Map<string, number>()
|
||||
// Aborts when an agent's scopes are next retired.
|
||||
readonly retirements = createAgentModelCatalogFences<string>()
|
||||
private readonly retiredObservations = new Set<string>()
|
||||
private persistence: AgentModelCatalogPersistence | null = null
|
||||
readonly efforts: ReturnType<typeof createAgentModelEffortDiscovery>
|
||||
@@ -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<AgentModelCatalogSuccess>,
|
||||
current?: () => boolean,
|
||||
signal?: AbortSignal,
|
||||
listModels: AgentModelCatalogListModels,
|
||||
budget?: AgentModelCatalogListingBudget,
|
||||
selectedModel?: string
|
||||
): Promise<AgentModelCatalogEntry | null> {
|
||||
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<AgentModelCatalogEntry | null> | null {
|
||||
return this.listingWaiters.wait(fingerprint, waitForChange, terminal)
|
||||
...wait: Parameters<AgentModelCatalogListingWaiters['wait']>
|
||||
): ReturnType<AgentModelCatalogListingWaiters['wait']> {
|
||||
return this.listingWaiters.wait(...wait)
|
||||
}
|
||||
|
||||
shouldRefresh(fingerprint: string): boolean {
|
||||
|
||||
@@ -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<boolean>
|
||||
}
|
||||
|
||||
/** 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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
workspacePath: string | null | undefined,
|
||||
home: AgentSessionAccountHome | null
|
||||
): Promise<boolean | null> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -14,70 +14,6 @@ function setsAny(table: Record<string, unknown>, 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<string, unknown> | 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<string, unknown>
|
||||
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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<Record<string, ProjectModelLayers>> = {
|
||||
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<boolean> {
|
||||
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.
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+6
-8
@@ -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
|
||||
|
||||
+151
-21
@@ -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<string>()
|
||||
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<string>()
|
||||
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<string>()
|
||||
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<AgentModelCatalogSuccess>()
|
||||
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)
|
||||
|
||||
+34
-11
@@ -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
|
||||
|
||||
+7
-13
@@ -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()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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<string, Partial<WorktreeMeta>> = {}
|
||||
const scans = new Map<string, unknown>()
|
||||
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<string, unknown> = {
|
||||
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()
|
||||
}
|
||||
})
|
||||
@@ -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<RuntimeWorkspaceCatalog['projectCachedWorktrees']>
|
||||
): ReturnType<RuntimeWorkspaceCatalog['projectCachedWorktrees']>
|
||||
protected abstract buildResolvedWorktreeFromId(
|
||||
...args: Parameters<RuntimeWorkspaceSelectors['buildResolvedWorktreeFromId']>
|
||||
): ReturnType<RuntimeWorkspaceSelectors['buildResolvedWorktreeFromId']>
|
||||
|
||||
protected abstract matchWorktreeSelector(
|
||||
...args: Parameters<RuntimeWorkspaceSelectors['matchWorktreeSelector']>
|
||||
): ReturnType<RuntimeWorkspaceSelectors['matchWorktreeSelector']>
|
||||
protected abstract selectReposBySelector(
|
||||
...args: Parameters<RuntimeWorkspaceSelectors['selectReposBySelector']>
|
||||
): ReturnType<RuntimeWorkspaceSelectors['selectReposBySelector']>
|
||||
|
||||
protected abstract listRepoWorktreesForResolution(
|
||||
...args: Parameters<RuntimeWorkspaceCatalog['listRepoWorktreesForResolution']>
|
||||
@@ -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<string | null> {
|
||||
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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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<string, NodeJS.Platform>()
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<T extends Record<string, string | undefined>>(
|
||||
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[],
|
||||
|
||||
@@ -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' }]
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user