diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 6d81a92daf20..5b7ba8aa7338 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -3,6 +3,8 @@ import { SQLITE_DATABASE_RUNTIME_INCLUDE } from './vitest-sqlite-database-runtim // Real SQLite fixtures keep Node publication and close semantics. export const SQLITE_RUNTIME_INCLUDE = [ ...SQLITE_DATABASE_RUNTIME_INCLUDE, + 'src/main/acp/acp-structured-host-live-listing.test.ts', + 'src/main/acp/acp-structured-host-configured-default.test.ts', 'src/main/acp/acp-structured-omp-recordings.test.ts', 'src/main/acp/acp-text-stream-identity.test.ts', 'src/main/acp/acp-text-message-ownership.test.ts', diff --git a/mobile/src/session/mobile-created-structured-sessions.ts b/mobile/src/session/mobile-created-structured-sessions.ts new file mode 100644 index 000000000000..fc1792aefa11 --- /dev/null +++ b/mobile/src/session/mobile-created-structured-sessions.ts @@ -0,0 +1,30 @@ +// Structured chats this phone created and that have not yet answered live, as the desktop latches +// its own launches: only a new chat runs the account's configured model, so only its first frame may +// name it. + +const CREATED_LIMIT = 32 + +const created = new Map() + +export function rememberMobileCreatedStructuredSession(sessionId: string, worktree: string): void { + created.delete(sessionId) + created.set(sessionId, { worktree }) + for (const oldest of created.keys()) { + if (created.size <= CREATED_LIMIT) { + break + } + created.delete(oldest) + } +} + +/** The chat's workspace selector when this phone created it, else undefined. */ +export function mobileCreatedStructuredSession( + sessionId: string +): { worktree: string } | undefined { + return created.get(sessionId) +} + +/** The chat answered live, so a reopened view reads its options rather than the listed default. */ +export function forgetMobileCreatedStructuredSession(sessionId: string): void { + created.delete(sessionId) +} diff --git a/mobile/src/session/mobile-existing-agent-launch.test.ts b/mobile/src/session/mobile-existing-agent-launch.test.ts index 0e5d289ad511..d39f42224052 100644 --- a/mobile/src/session/mobile-existing-agent-launch.test.ts +++ b/mobile/src/session/mobile-existing-agent-launch.test.ts @@ -13,6 +13,7 @@ import { supportsMobileExistingAgentLaunch, AGENT_LAUNCH_TAB_CLOSED_MESSAGE } from './mobile-existing-agent-launch' +import { mobileCreatedStructuredSession } from './mobile-created-structured-sessions' // A connected client whose only behaviour is the scripted `sendRequest`. function requestPortRpcClient(sendRequest: RpcClient['sendRequest']): RpcClient { @@ -114,6 +115,10 @@ describe('reserveMobileAgentLaunch', () => { paneKey: `${reservation.pane.tabId}:${reservation.pane.leafId}`, sessionId: reservation.sessionId }) + // The chat it reserved is one this phone created, so its picker names the listed default. + expect(mobileCreatedStructuredSession(reservation.sessionId!)).toMatchObject({ + worktree: expect.stringMatching(/^id:/) + }) }) it('asks for the new tab after the one the user is on', async () => { diff --git a/mobile/src/session/mobile-existing-agent-launch.ts b/mobile/src/session/mobile-existing-agent-launch.ts index 3b08bbc54327..a3784fe5e3b4 100644 --- a/mobile/src/session/mobile-existing-agent-launch.ts +++ b/mobile/src/session/mobile-existing-agent-launch.ts @@ -28,6 +28,7 @@ import { structuredSessionOperationId, structuredSessionRandomUuid } from './structured-session-operation-id' +import { rememberMobileCreatedStructuredSession } from './mobile-created-structured-sessions' // Why: the host waits up to 60s for a terminal agent to become ready before pasting the prompt, // so a prompted launch must outlive that wait or the phone reports a launch that is still running. @@ -122,6 +123,9 @@ export async function launchAgentInExistingWorkspace(args: { } : {}) }) + if (args.reservation?.sessionId) { + rememberMobileCreatedStructuredSession(args.reservation.sessionId, `id:${args.worktreeId}`) + } let sent try { sent = await sendReplayingAmbiguousDelivery( diff --git a/mobile/src/session/mobile-structured-agent-session-launch.test.ts b/mobile/src/session/mobile-structured-agent-session-launch.test.ts index c5cd130f660e..e9291c562e71 100644 --- a/mobile/src/session/mobile-structured-agent-session-launch.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-launch.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { createMobileStructuredAgentSession } from './mobile-structured-agent-session-launch' +import { mobileCreatedStructuredSession } from './mobile-created-structured-sessions' function clientReturning( ...responses: unknown[] @@ -115,6 +116,10 @@ describe('mobile structured agent-session launch', () => { expect(params.agent).toBe('claude') expect(params.envelope.sessionId).toMatch(/^claude_[A-Za-z0-9_]{8,128}$/) expect(params.envelope.payloadFingerprint).toMatch(/^[0-9a-f]{64}$/) + // Its picker treats it as a new chat, as the desktop does one its own view launched. + expect(mobileCreatedStructuredSession(params.envelope.sessionId)).toEqual({ + worktree: 'id:workspace-1' + }) }) it('names the refusing agent in the failure copy rather than always saying Codex', async () => { diff --git a/mobile/src/session/mobile-structured-agent-session-launch.ts b/mobile/src/session/mobile-structured-agent-session-launch.ts index fb9af8256171..c22e2558086f 100644 --- a/mobile/src/session/mobile-structured-agent-session-launch.ts +++ b/mobile/src/session/mobile-structured-agent-session-launch.ts @@ -17,6 +17,7 @@ import { structuredAgentSupportProbe } from './mobile-session-launch-operations' import { structuredSessionRandomUuid } from './structured-session-operation-id' +import { rememberMobileCreatedStructuredSession } from './mobile-created-structured-sessions' type StructuredCreateSupport = { supported?: boolean @@ -122,6 +123,7 @@ export async function createMobileStructuredAgentSession( } const params = createParamsFor(agent, worktree) + rememberMobileCreatedStructuredSession(params.envelope.sessionId, worktree) let response try { response = await structuredAgentSessionCreate.request(client, params, { diff --git a/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts b/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts new file mode 100644 index 000000000000..e4581c8f311c --- /dev/null +++ b/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts @@ -0,0 +1,113 @@ +import { useEffect, type MutableRefObject } from 'react' +import type { AgentSessionModelCatalogResult } from '../../../src/shared/agent-session-wire' +import type { AgentSessionOptionCatalog } from '../../../src/shared/agent-session-option-catalog' +import type { NativeChatSessionOptionRecord } from '../../../src/shared/native-chat-session-option-state' +import { + applyStructuredAgentSessionModelCatalog, + settleStructuredAgentSessionBuiltinCatalog, + type StructuredAgentSessionOptionState +} from '../../../src/shared/structured-agent-session-options' +import type { RpcClient } from '../transport/rpc-client' +import { callAgentSession } from './mobile-structured-agent-session-rpc' + +// The host answers a waiting read when its first listing lands, within its own 30s picker wait. +const LISTING_WAIT_TIMEOUT_MS = 45_000 + +/** + * The desktop's host-catalog upgrade on the phone: the picker lists the account's models from the + * host store while the session's own options read may still wait on its attach. Until the host + * answers it shows the quiet placeholder; it never waits on a listing: a first listing still + * running lands in place. An older host refuses the method, and the built-in list stands, naming + * nothing, until the options read lands. + */ +export function useMobileHostModelCatalogUpgrade(args: { + agent: string | null + client: RpcClient | null + sessionId: string | null + enabled: boolean + /** This phone created the chat, so it launches the listed default; a reopened one may not. */ + newLaunch: boolean + /** Where a new chat runs: the host names no default its config could replace. */ + worktree?: string + fence: number | null + optionCatalog: AgentSessionOptionCatalog | null + activeOptionRecordRef: MutableRefObject + updateOptionState: ( + update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState + ) => void +}): void { + const { + activeOptionRecordRef, + agent, + client, + enabled, + fence, + newLaunch, + optionCatalog, + sessionId, + updateOptionState, + worktree + } = args + useEffect(() => { + if (!client || !sessionId || !enabled || !agent || !optionCatalog) { + return + } + let stale = false + const params = { agent, sessionId, ...(newLaunch && worktree ? { worktree } : {}) } + const read = (waitForListing: boolean): Promise => + waitForListing + ? callAgentSession( + client, + 'agentSession.modelCatalog', + { ...params, waitForListing }, + LISTING_WAIT_TIMEOUT_MS + ) + : callAgentSession(client, 'agentSession.modelCatalog', params) + // A pick made meanwhile stays on the record; a listing only replaces the list it is picked from. + const apply = (catalog: AgentSessionModelCatalogResult): void => { + if (stale) { + return + } + updateOptionState((current) => + current.record === activeOptionRecordRef.current + ? applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, { newLaunch }) + : current + ) + } + // A refused or failed read is no list: the built-in one becomes usable, naming nothing. + const settleBuiltin = (): void => { + if (!stale) { + updateOptionState((current) => + current.record === activeOptionRecordRef.current + ? settleStructuredAgentSessionBuiltinCatalog(current) + : current + ) + } + } + void read(false) + .then((catalog) => { + if (catalog.origin === 'unknown' && catalog.listingInProgress === true && !stale) { + // Usable on the built-in list while the listing runs; it lands in place. + apply(catalog) + return read(true).then(apply) + } + apply(catalog) + return undefined + }) + .catch(settleBuiltin) + return () => { + stale = true + } + }, [ + activeOptionRecordRef, + agent, + client, + enabled, + fence, + newLaunch, + optionCatalog, + sessionId, + updateOptionState, + worktree + ]) +} diff --git a/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts b/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts new file mode 100644 index 000000000000..0779304fb026 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts @@ -0,0 +1,102 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionModelCatalogResult } from '../../../src/shared/agent-session-wire' +import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options' +import type { RpcClient } from '../transport/rpc-client' +import type { StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc' +import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' + +const HOST_LIST: AgentSessionModelCatalogResult = { + origin: 'probe', + models: [{ id: 'gpt-host', label: 'GPT Host', isDefault: true, efforts: [] }], + fetchedAt: 1_000 +} + +/** Catalog reads take each scripted answer in turn; option reads never answer. */ +function client(catalog: () => Promise): RpcClient { + return { + sendRequest: async (method: string) => + method === 'agentSession.modelCatalog' + ? { id: 'rpc-1', ok: true as const, result: await catalog(), _meta: { runtimeId: 'r' } } + : new Promise(() => {}), + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } +} + +function modelChoices(snapshot: readonly SessionOptionDescriptor[]): string[] { + const model = snapshot.find((entry) => entry.id === 'model') + return model?.kind.type === 'select' ? model.kind.choices.map((choice) => choice.value) : [] +} + +type Props = { rpc: RpcClient; fence: number } + +async function mount(initial: Props) { + const frames: string[][] = [] + const mutate: StructuredAgentSessionMutate = vi.fn() + function Probe(props: Props): null { + const options = useMobileStructuredAgentOptions({ + agent: 'codex', + client: props.rpc, + sessionId: 'session-1', + enabled: true, + fence: props.fence, + mutate + }) + frames.push(modelChoices(options.optionSnapshot)) + return null + } + let renderer: ReactTestRenderer | null = null + await act(async () => { + renderer = create(createElement(Probe, initial)) + }) + return { + frames, + update: (next: Props) => + act(async () => { + renderer?.update(createElement(Probe, next)) + }), + unmount: () => act(async () => renderer?.unmount()) + } +} + +describe('the phone picker’s first frame', () => { + it('shows the quiet placeholder, never the built-in list, until the host answers', async () => { + let answer!: (value: AgentSessionModelCatalogResult) => void + const rpc = client(() => new Promise((resolve) => (answer = resolve))) + const harness = await mount({ rpc, fence: 1 }) + expect(harness.frames.every((choices) => choices.length === 0)).toBe(true) + await act(async () => answer(HOST_LIST)) + expect(harness.frames.at(-1)).toEqual(['gpt-host']) + // Only the host's list was ever offered. + expect(harness.frames.filter((choices) => choices.length > 0)).toEqual( + harness.frames.filter((choices) => choices.length > 0).map(() => ['gpt-host']) + ) + await harness.unmount() + }) + + it('keeps the host list through a new fence instead of falling back to the placeholder', async () => { + let reads = 0 + const rpc = client(() => (reads++ === 0 ? Promise.resolve(HOST_LIST) : new Promise(() => {}))) + const harness = await mount({ rpc, fence: 1 }) + expect(harness.frames.at(-1)).toEqual(['gpt-host']) + const before = harness.frames.length + await harness.update({ rpc, fence: 2 }) + expect(harness.frames.slice(before).every((choices) => choices[0] === 'gpt-host')).toBe(true) + await harness.unmount() + }) + + it('makes the built-in list usable when the host refuses the read', async () => { + const rpc = client(() => Promise.reject(new Error('method_not_found'))) + const harness = await mount({ rpc, fence: 1 }) + expect(harness.frames.at(-1)?.length).toBeGreaterThan(0) + await harness.unmount() + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-options.test.ts b/mobile/src/session/use-mobile-structured-agent-options.test.ts index 5658cc018393..15698925bba6 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.test.ts @@ -2,6 +2,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { describe, expect, it, vi } from 'vitest' import type { + AgentSessionModelCatalogResult, AgentSessionOptionResult, AgentSessionOptionsResult } from '../../../src/shared/agent-session-wire' @@ -12,6 +13,7 @@ import type { StructuredAgentSessionMutationResult } from './mobile-structured-agent-session-rpc' import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' +import { rememberMobileCreatedStructuredSession } from './mobile-created-structured-sessions' const OPTIONS: AgentSessionOptionsResult = { models: [ @@ -69,11 +71,25 @@ type SentRequest = { method: string; params: unknown } /** `reads` yields what each successive `agentSession.options` call resolves to, so a test can * make the post-write refresh disagree with the first read. */ -function optionsClient(reads: () => Promise) { +function optionsClient( + reads: () => Promise, + catalog?: (params: unknown) => Promise +) { const sent: SentRequest[] = [] const client: RpcClient = { sendRequest: async (method: string, params?: unknown) => { sent.push({ method, params }) + if (method === 'agentSession.modelCatalog') { + // Without a scripted catalog, the host predates the method. + return catalog + ? rpcSuccess(await catalog(params)) + : { + id: 'rpc-1', + ok: false as const, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + } + } return rpcSuccess(method === 'agentSession.options' ? await reads() : {}) }, subscribe: () => () => {}, @@ -396,3 +412,211 @@ describe('useMobileStructuredAgentOptions pending guard', () => { await harness.unmount() }) }) + +describe('useMobileStructuredAgentOptions for every agent', () => { + it('reads and picks for an agent with no built-in list, as the desktop does', async () => { + const client = optionsClient(queuedReads(OPTIONS)) + const { calls, mutate } = recordingMutate(async () => + accepted({ key: 'model', value: 'gpt-fast', options: { model: 'gpt-fast' } }, true) + ) + const harness = await mountOptions({ ...BASE, agent: 'grok', client: client.client, mutate }) + + expect(client.optionReads()).toBe(1) + expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe('gpt-live') + await act(async () => { + await harness.current().setStructuredOption('model', 'gpt-fast') + }) + await settle() + expect(calls).toEqual([ + { method: 'agentSession.setOption', fields: { key: 'model', value: 'gpt-fast' } } + ]) + expect(client.methods('settings.mutateNativeChatSessionOptions')[0]?.params).toMatchObject({ + agent: 'grok' + }) + await harness.unmount() + }) +}) + +describe('useMobileStructuredAgentOptions host catalog', () => { + const HOST_CATALOG: AgentSessionModelCatalogResult = { + origin: 'probe', + models: [ + { + id: 'grok-4', + label: 'Grok 4', + isDefault: true, + defaultEffort: 'high', + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + } + ], + fetchedAt: 1, + listingNamesConfiguredModel: true + } + + it('lists the host’s models while the session’s own options read is still waiting', async () => { + const pending = deferred() + const client = optionsClient( + () => pending.promise, + async () => HOST_CATALOG + ) + const { mutate } = recordingMutate(async () => ({ status: 'unknown' })) + const harness = await mountOptions({ ...BASE, agent: 'grok', client: client.client, mutate }) + + expect(client.methods('agentSession.modelCatalog')[0]?.params).toEqual({ + agent: 'grok', + sessionId: 'session-1' + }) + const model = descriptorFor(harness.current().optionSnapshot, 'model') + expect(model?.kind.type === 'select' && model.kind.choices.map((c) => c.value)).toEqual([ + 'grok-4' + ]) + // An existing chat may run a model picked in it, so the listing's default is not named. + expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBeUndefined() + + pending.resolve({ ...OPTIONS, current: { model: 'gpt-live', confirmed: ['model'] } }) + await settle() + expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe('gpt-live') + await harness.unmount() + }) + + it.each(['grok', 'codex'])( + 'names the configured model and its effort for a %s chat this phone created', + async (agent) => { + const sessionId = `${agent}-created-here` + rememberMobileCreatedStructuredSession(sessionId, 'id:wt-1') + const client = optionsClient( + () => deferred().promise, + async () => HOST_CATALOG + ) + const { mutate } = recordingMutate(async () => ({ status: 'unknown' })) + const harness = await mountOptions({ + ...BASE, + agent, + sessionId, + client: client.client, + mutate + }) + + // As the desktop asks for a chat it launched: the host checks that workspace's config. + expect(client.methods('agentSession.modelCatalog')[0]?.params).toEqual({ + agent, + sessionId, + worktree: 'id:wt-1' + }) + expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe('grok-4') + expect(currentValueOf(harness.current().optionSnapshot, 'effort')).toBe('high') + await harness.unmount() + } + ) + + it('does not name the listed default when a chat this phone created is reopened', async () => { + const sessionId = 'grok-reopened' + rememberMobileCreatedStructuredSession(sessionId, 'id:wt-1') + const live = { ...OPTIONS, current: { model: 'gpt-fast', confirmed: ['model'] } } + const first = optionsClient( + () => Promise.resolve(live), + async () => HOST_CATALOG + ) + const { mutate } = recordingMutate(async () => ({ status: 'unknown' })) + const opened = await mountOptions({ + ...BASE, + agent: 'grok', + sessionId, + client: first.client, + mutate + }) + await settle() + await opened.unmount() + + const pending = deferred() + const second = optionsClient( + () => pending.promise, + async () => HOST_CATALOG + ) + const reopened = await mountOptions({ + ...BASE, + agent: 'grok', + sessionId, + client: second.client, + mutate + }) + await settle() + + expect(second.methods('agentSession.modelCatalog')[0]?.params).toEqual({ + agent: 'grok', + sessionId + }) + expect(currentValueOf(reopened.current().optionSnapshot, 'model')).toBeUndefined() + pending.resolve(live) + await settle() + expect(currentValueOf(reopened.current().optionSnapshot, 'model')).toBe('gpt-fast') + await reopened.unmount() + }) + + it('waits once for the host’s first listing of the account', async () => { + const catalog = vi.fn(async (params: unknown): Promise => + typeof params === 'object' && params !== null && 'waitForListing' in params + ? HOST_CATALOG + : { origin: 'unknown', listingInProgress: true } + ) + const client = optionsClient(() => deferred().promise, catalog) + const { mutate } = recordingMutate(async () => ({ status: 'unknown' })) + const harness = await mountOptions({ ...BASE, agent: 'grok', client: client.client, mutate }) + await settle() + + expect(catalog).toHaveBeenCalledTimes(2) + const model = descriptorFor(harness.current().optionSnapshot, 'model') + expect(model?.kind.type === 'select' && model.kind.choices.map((c) => c.value)).toEqual([ + 'grok-4' + ]) + await harness.unmount() + }) + + it('keeps a cold Codex picker usable on the built-in list and lands the listing in place', async () => { + const listed = deferred() + const catalog = vi.fn(async (params: unknown): Promise => + typeof params === 'object' && params !== null && 'waitForListing' in params + ? listed.promise + : { origin: 'unknown', listingInProgress: true } + ) + const client = optionsClient(() => deferred().promise, catalog) + const { calls, mutate } = recordingMutate(async () => ({ status: 'unknown' })) + const harness = await mountOptions({ ...BASE, client: client.client, mutate }) + await settle() + + const seeded = descriptorFor(harness.current().optionSnapshot, 'model') + expect(seeded?.settable).toBe(true) + const seedChoices = seeded?.kind.type === 'select' ? seeded.kind.choices : [] + expect(seedChoices.length).toBeGreaterThan(0) + await act(async () => { + await harness.current().setStructuredOption('model', seedChoices[0]!.value) + }) + expect(calls).toHaveLength(1) + + // A pick made meanwhile does not drop the listing that lands afterwards, and stays picked. + listed.resolve(HOST_CATALOG) + await settle() + const model = descriptorFor(harness.current().optionSnapshot, 'model') + expect(model?.kind.type === 'select' && model.kind.choices.map((c) => c.value)).toEqual([ + 'grok-4', + seedChoices[0]!.value + ]) + expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe(seedChoices[0]!.value) + await harness.unmount() + }) + + it('keeps the provider-default pill when an older host has no catalog method', async () => { + const client = optionsClient(() => deferred().promise) + const { mutate } = recordingMutate(async () => ({ status: 'unknown' })) + const harness = await mountOptions({ ...BASE, agent: 'grok', client: client.client, mutate }) + + expect(descriptorFor(harness.current().optionSnapshot, 'model')).toMatchObject({ + settable: false, + valueSource: 'unknown' + }) + await harness.unmount() + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 54d14fd2a8ae..c186ade0cdb1 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { AgentSessionConversationCommand } from '../../../src/shared/agent-session-conversation-command' -import { getAgentSessionOptionCatalog } from '../../../src/shared/agent-session-option-catalog' +import { structuredAgentSessionSeedCatalog } from '../../../src/shared/structured-agent-session-seed-catalog' import type { AgentSessionOptionResult, AgentSessionOptionsResult @@ -26,6 +26,11 @@ import { type StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc' import { persistMobileStructuredOptionPicks } from './mobile-native-chat-session-option-persistence' +import { useMobileHostModelCatalogUpgrade } from './use-mobile-host-model-catalog-upgrade' +import { + forgetMobileCreatedStructuredSession, + mobileCreatedStructuredSession +} from './mobile-created-structured-sessions' import { encodeStructuredAgentSessionOptionValue } from '../../../src/shared/structured-agent-session-option-codec' type StructuredOptionsController = { @@ -47,8 +52,13 @@ export function useMobileStructuredAgentOptions(args: { mutate: StructuredAgentSessionMutate }): StructuredOptionsController { const { agent, client, enabled, fence, mutate, sessionId } = args + // Every agent's seed, as on the desktop: a built-in list or the provider-default pill. + const optionCatalog = useMemo( + () => (agent ? structuredAgentSessionSeedCatalog(agent) : null), + [agent] + ) const [optionState, setOptionState] = useState(() => - createStructuredAgentSessionOptionState(agent ?? 'codex') + createStructuredAgentSessionOptionState(agent ?? 'codex', optionCatalog) ) const optionStateRef = useRef(optionState) const activeOptionRecordRef = useRef(optionState.record) @@ -70,19 +80,43 @@ export function useMobileStructuredAgentOptions(args: { sessionId: string commands: readonly AgentSessionConversationCommand[] } | null>(null) - const optionCatalog = useMemo( - () => (agent === 'claude' || agent === 'codex' ? getAgentSessionOptionCatalog(agent) : null), - [agent] - ) + const optionIdentityRef = useRef(`${agent}:${sessionId}`) useEffect(() => { - const next = createStructuredAgentSessionOptionState(agent ?? 'codex') + const identity = `${agent}:${sessionId}` + const sameSession = optionIdentityRef.current === identity + optionIdentityRef.current = identity + const previous = optionStateRef.current + const seeded = createStructuredAgentSessionOptionState(agent ?? 'codex', optionCatalog) + // A host answer is the account's, not the fence's: keep it rather than fall back to the placeholder. + const next = + sameSession && (previous.catalogSource === 'host' || previous.catalogSource === 'builtin') + ? { ...seeded, catalog: previous.catalog, catalogSource: previous.catalogSource } + : seeded optionMutationGeneration.current += 1 pendingOptionRef.current = null optionStateRef.current = next activeOptionRecordRef.current = next.record setOptionState(next) - }, [agent, enabled, fence, sessionId]) + }, [agent, enabled, fence, optionCatalog, sessionId]) + + // A chat this phone created runs the listed default, as a desktop chat its own view launched does. + const createdHere = useMemo( + () => (sessionId ? mobileCreatedStructuredSession(sessionId) : undefined), + [sessionId] + ) + useMobileHostModelCatalogUpgrade({ + agent, + client, + sessionId, + enabled, + fence, + newLaunch: createdHere !== undefined, + ...(createdHere ? { worktree: createdHere.worktree } : {}), + optionCatalog, + activeOptionRecordRef, + updateOptionState + }) useEffect(() => { if (!client || !sessionId || !enabled || !optionCatalog) { @@ -92,6 +126,8 @@ export function useMobileStructuredAgentOptions(args: { const readGeneration = optionMutationGeneration.current void callAgentSession(client, 'agentSession.options', { sessionId }) .then((result) => { + // This view keeps its latch; a later one may run a model picked here. + forgetMobileCreatedStructuredSession(sessionId) if (!stale && optionMutationGeneration.current === readGeneration) { setConversationSupport({ sessionId, commands: result.conversationCommands ?? [] }) updateOptionState((current) => @@ -152,7 +188,7 @@ export function useMobileStructuredAgentOptions(args: { // Only an accepted pick: an `unknown` outcome commits optimistically to the // visible record, and remembering one the provider refused would seed a // launch the user never chose. - if (agent === 'claude' || agent === 'codex') { + if (agent) { void persistMobileStructuredOptionPicks({ client, agent, diff --git a/mobile/src/session/use-mobile-structured-agent-session.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.test.tsx index db9657a4dd8b..098c73e2c175 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session.test.tsx @@ -7,6 +7,7 @@ import type { AgentJournalResolution } from '../../../src/shared/agent-session-journal-types' import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { formatQuestionFreeTextAnswer } from './mobile-native-chat-question' @@ -22,6 +23,11 @@ const asyncStorage = vi.hoisted(() => ({ vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) +function listedModelIds(snapshot: readonly SessionOptionDescriptor[]): string[] { + const model = snapshot.find((descriptor) => descriptor.id === 'model') + return model?.kind.type === 'select' ? model.kind.choices.map((choice) => choice.value) : [] +} + function ok(result: unknown) { return { ok: true, result, _meta: { runtimeId: 'runtime-1' } } } @@ -412,7 +418,8 @@ describe('useMobileStructuredAgentSession', () => { await vi.waitFor(() => expect(hook.permission).not.toBeNull()) await vi.waitFor(() => expect(hook.question).not.toBeNull()) - await vi.waitFor(() => expect(hook.optionSnapshot.length).toBeGreaterThan(0)) + // The seed paints first; the session's own list is what a pick is checked against. + await vi.waitFor(() => expect(listedModelIds(hook.optionSnapshot)).toContain('gpt-slow')) expect(hook.permission).toMatchObject({ title: 'Allow Bash?', @@ -706,7 +713,7 @@ describe('useMobileStructuredAgentSession', () => { }) await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) act(() => listener?.(snapshotEvent(3))) - await vi.waitFor(() => expect(hook!.optionSnapshot.length).toBeGreaterThan(0)) + await vi.waitFor(() => expect(listedModelIds(hook!.optionSnapshot)).toContain('gpt-slow')) sendRequest.mockImplementation(async (method, params) => { if (method === 'agentSession.setOption') { throw markRpcDeliveryUnknown(new Error('Connection closed')) diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 195a8beadf84..47fba1f0f86d 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -7,7 +7,8 @@ import type { } from '../../../shared/native-chat-types' import type { ProviderTimelineRequestBody } from '../../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpAgentError } from '../acp-errors' -import type { ToolCallUpdate } from '../generated/acp-protocol.generated' +import type { AgentSessionOptionChoice } from '../../../shared/agent-session-wire' +import type { ModelInfo, ToolCallUpdate } from '../generated/acp-protocol.generated' export type AcpRequestPresentation = { body: ProviderTimelineRequestBody @@ -80,6 +81,8 @@ export type AcpDialect = { subagentSessionEnd?(method: string, params: unknown): 'completed' | 'stopped' | undefined notification?(method: string, params: unknown, at: number): AcpDialectNotification | undefined contextWindow?(models: unknown): number | undefined + /** A model's own effort menu and default, as the agent advertises them per model. */ + modelEfforts?(model: ModelInfo): { efforts: AgentSessionOptionChoice[]; defaultEffort?: string } request?(method: string, params: unknown): AcpRequestPresentation | undefined /** Requests answered at once instead of shown to the person. */ settleRequest?(method: string, params: unknown): AcpRequestSettlement | undefined diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts index 150ff066266b..569bca94ebf4 100644 --- a/src/main/acp/acp-dialects/grok-dialect.ts +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -3,6 +3,7 @@ import { acpNotificationEnvelopeSchema } from '../acp-context-usage' import type { AcpDialect, AcpDialectNotification } from './acp-dialect' import { grokRequest, grokSettleRequest } from './grok-requests' import { grokBackgroundTaskNotification, grokToolBackgroundTasks } from './grok-background-tasks' +import { grokModelEfforts } from './grok-model-catalog' import { grokSubagentNotification, grokToolSubagents } from './grok-subagents' import { grokSubagentStop } from './grok-subagent-stop' @@ -205,6 +206,7 @@ export const GROK_ACP_DIALECT: AcpDialect = { subagentSessionEnd, notification, contextWindow, + modelEfforts: grokModelEfforts, promptErrorDetail: (error) => promptErrorDataSchema.safeParse(error.data).data?.message, failedTurnText: (stopReason) => stopReason === 'rate_limit' diff --git a/src/main/acp/acp-dialects/grok-model-catalog.ts b/src/main/acp/acp-dialects/grok-model-catalog.ts new file mode 100644 index 000000000000..7f1fbce3a144 --- /dev/null +++ b/src/main/acp/acp-dialects/grok-model-catalog.ts @@ -0,0 +1,125 @@ +// Grok's own model facts: the per-model effort menu it writes in each model's `_meta`, and the +// models it lists during `initialize` (or serves from `x.ai/models/list`) with no session. + +import { z } from 'zod' +import type { + AgentSessionModelOption, + AgentSessionOptionChoice +} from '../../../shared/agent-session-wire' +import { + SessionModelStateSchema, + type InitializeResponse, + type ModelInfo, + type SessionModelState +} from '../generated/acp-protocol.generated' + +const EFFORT_LABELS: Readonly> = { + none: 'None', + minimal: 'Minimal', + low: 'Low', + medium: 'Medium', + high: 'High', + xhigh: 'X-High', + max: 'Max' +} +// The menu Grok offers a model that supports effort but names none of its own. +const BUILT_IN_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const + +const effortEntrySchema = z.union([ + z.string().min(1), + z.looseObject({ + value: z.string().min(1), + id: z.string().min(1).optional(), + label: z.string().min(1).optional(), + description: z.string().optional(), + default: z.boolean().optional() + }) +]) +const modelMetaSchema = z.looseObject({ + supportsReasoningEffort: z.boolean().optional(), + reasoningEfforts: z.array(z.unknown()).optional() +}) +const modelsListReplySchema = z.looseObject({ + result: SessionModelStateSchema.nullish(), + error: z.unknown().optional() +}) + +function capitalized(id: string): string { + return id.charAt(0).toUpperCase() + id.slice(1) +} + +/** One model's effort menu as Grok advertises it. Choice values are Grok's option ids — what its + * effort config option takes — not the canonical sampling value behind them. */ +export function grokModelEfforts(model: Pick): { + efforts: AgentSessionOptionChoice[] + defaultEffort?: string +} { + const meta = modelMetaSchema.safeParse(model._meta ?? {}) + if (!meta.success || meta.data.supportsReasoningEffort !== true) { + return { efforts: [] } + } + const efforts: AgentSessionOptionChoice[] = [] + let defaultEffort: string | undefined + for (const raw of meta.data.reasoningEfforts ?? []) { + const entry = effortEntrySchema.safeParse(raw) + if (!entry.success) { + continue + } + if (typeof entry.data === 'string') { + efforts.push({ value: entry.data, label: EFFORT_LABELS[entry.data] ?? entry.data }) + continue + } + const id = entry.data.id ?? entry.data.value + const label = + entry.data.label ?? + (entry.data.id ? capitalized(id) : (EFFORT_LABELS[entry.data.value] ?? entry.data.value)) + efforts.push({ + value: id, + label, + ...(entry.data.description ? { description: entry.data.description } : {}) + }) + if (entry.data.default === true) { + defaultEffort ??= id + } + } + if (efforts.length === 0) { + return { + efforts: BUILT_IN_EFFORTS.map((value) => ({ value, label: EFFORT_LABELS[value] ?? value })) + } + } + return { efforts, ...(defaultEffort ? { defaultEffort } : {}) } +} + +/** Names no default: a session's own model can differ from the `currentModelId` computed without + * one (Grok resolves its default from remote config too), so a no-pick chat reports it instead. */ +export function grokModelCatalogFromState(state: SessionModelState): AgentSessionModelOption[] { + return state.availableModels.map((model) => ({ + id: model.modelId, + label: model.name, + ...(model.description ? { description: model.description } : {}), + isDefault: false, + ...grokModelEfforts(model) + })) +} + +/** The models `initialize` computed, else `x.ai/models/list`; neither creates a session. */ +export async function readGrokModelCatalog( + initialized: InitializeResponse, + connection: { requestSessionFreeExtension(method: string, params: unknown): Promise } +): Promise { + const fromInitialize = SessionModelStateSchema.safeParse(initialized._meta?.modelState) + if (fromInitialize.success && fromInitialize.data.availableModels.length > 0) { + return grokModelCatalogFromState(fromInitialize.data) + } + const reply = modelsListReplySchema.safeParse( + await connection.requestSessionFreeExtension('x.ai/models/list', {}) + ) + if ( + !reply.success || + (reply.data.error !== undefined && reply.data.error !== null) || + !reply.data.result + ) { + throw new Error('grok did not list its models') + } + return grokModelCatalogFromState(reply.data.result) +} diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index 31d03a5e48ee..2cceeb216d64 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -18,6 +18,13 @@ import { openCodeStoredUserMessagesReader } from '../opencode/opencode-acp-store import type { AcpStoredUserMessagesReader } from './acp-recovery-history' import { isStableCliVersionFrom, isStableCliVersionOnLine } from '../agent-cli-version-probe' import type { TuiAgent } from '../../shared/tui-agent' +import type { AgentSessionModelOption } from '../../shared/agent-session-wire' +import type { InitializeResponse } from './generated/acp-protocol.generated' +import { readGrokModelCatalog } from './acp-dialects/grok-model-catalog' +import { + OPENCODE_MODEL_LISTING_ARGS, + parseOpenCodeModelListing +} from '../opencode/opencode-model-catalog-listing' import { agentSessionSignInFor } from '../../shared/agent-session-sign-in' import { loadGrokVisualsSkill, @@ -26,6 +33,28 @@ import { type AcpVisualsSkillLoader } from './acp-visuals-skill' +/** How an agent lists its models without a session: from its `initialize` answer (plus extension + * requests), from a listing command, or not at all. Never `authenticate` or `session/new`. */ +export type AcpModelDiscovery = + | { + kind: 'initialize' + read( + initialized: InitializeResponse, + connection: { + requestSessionFreeExtension(method: string, params: unknown): Promise + } + ): Promise + /** The listing marks the model the account is configured to run as its default. */ + listingNamesConfiguredModel: boolean + } + | { + kind: 'command' + args: readonly string[] + parse(stdout: string): AgentSessionModelOption[] + listingNamesConfiguredModel: boolean + } + | { kind: 'unavailable'; reason: string } + export type AcpLaunchSpec = { /** The Orca agent id, which names the agent's records, its catalog label and its settings. */ agent: TuiAgent @@ -58,6 +87,7 @@ export type AcpLaunchSpec = { imagePrompts?: true /** The agent's own store of a session's user messages, read for restart recovery only. */ readStoredUserMessages?: AcpStoredUserMessagesReader + modelDiscovery: AcpModelDiscovery /** How a launch loads the inline-visuals skill; absent, the agent's chats have no visuals. */ visualsSkill?: AcpVisualsSkillLoader } @@ -84,6 +114,13 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = { : undefined, account: directoryAccountBinding('GROK_HOME', (homePath) => join(homePath, '.grok')), installDirectories: ({ env }) => (env.GROK_HOME ? [join(env.GROK_HOME, 'bin')] : []), + // Grok lists its models in `initialize`, before and without any session. Its `currentModelId` + // there can differ from what a session runs, so a chat started with no pick names the default. + modelDiscovery: { + kind: 'initialize', + read: readGrokModelCatalog, + listingNamesConfiguredModel: false + }, visualsSkill: loadGrokVisualsSkill } @@ -113,6 +150,14 @@ const OPENCODE_LAUNCH_SPEC: AcpLaunchSpec = { OPENCODE_ACP_RELEASE_LINES.some((line) => isStableCliVersionOnLine(version, line)), imagePrompts: true, readStoredUserMessages: openCodeStoredUserMessagesReader(), + // Its `initialize` names no models and `session/new` stores a session; the listing does neither. + modelDiscovery: { + kind: 'command', + args: OPENCODE_MODEL_LISTING_ARGS, + parse: parseOpenCodeModelListing, + // The listing marks no default; a chat started with no pick names it. + listingNamesConfiguredModel: false + }, visualsSkill: loadOpenCodeVisualsSkill } @@ -133,6 +178,12 @@ const OMP_LAUNCH_SPEC: AcpLaunchSpec = { installDirectories: () => [], // Stable releases from 17.0.5, the release verified to serve `omp acp`. supportsVersion: (version) => isStableCliVersionFrom(version, '17.0.5'), + // `omp models --json` lists every model without the `enabledModels` filter a chat applies, so it + // could offer models a chat refuses; its chats' own listings fill the catalog instead. + modelDiscovery: { + kind: 'unavailable', + reason: 'omp has no session-free listing that matches what its chats offer' + }, visualsSkill: loadOmpVisualsSkill } diff --git a/src/main/acp/acp-model-catalog-probe.test.ts b/src/main/acp/acp-model-catalog-probe.test.ts new file mode 100644 index 000000000000..a166a143a337 --- /dev/null +++ b/src/main/acp/acp-model-catalog-probe.test.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs' +import { + createAcpModelCatalogProbe, + type AcpModelCatalogProbeDeps +} from './acp-model-catalog-probe' +import { AcpSessionRuntime } from './acp-session-runtime' +import { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-account-home' + +const GROK = acpLaunchSpecFor('grok')! +// The user's own OpenCode environment, read without the app's managed-account service. +const OPENCODE: AcpLaunchSpec = { + ...acpLaunchSpecFor('opencode')!, + account: openCodeAcpAccountBinding(() => ({ + list: () => ({ activeAccountId: null, accounts: [] }), + restoreOriginalEnvironment: () => {}, + environmentForAccount: () => ({}) + })) +} +const GROK_HOME = { variable: 'GROK_HOME', path: '/homes/grok-a' } + +const MODEL_STATE = { + currentModelId: 'grok-build', + availableModels: [ + { + modelId: 'grok-build', + name: 'Grok Build', + _meta: { + supportsReasoningEffort: true, + // The session's own pick, which must not become a catalog default. + reasoningEffort: 'low', + reasoningEfforts: [ + { value: 'low', id: 'fast', label: 'Fast' }, + { value: 'high', id: 'deep', default: true } + ] + } + }, + { modelId: 'grok-mini', name: 'Grok Mini', _meta: { supportsReasoningEffort: true } }, + { modelId: 'grok-chat', name: 'Grok Chat', _meta: {} } + ] +} + +const agents: AcpScriptedAgent[] = [] +afterEach(() => { + for (const agent of agents.splice(0)) { + agent.close() + } +}) + +function scriptedGrok(initializeMeta: Record): { + agent: AcpScriptedAgent + launches: ProviderProcessLaunch[] + closed: () => number + deps: AcpModelCatalogProbeDeps +} { + const agent = new AcpScriptedAgent() + agents.push(agent) + agent.on('initialize', (frame) => + agent.reply(frame, { protocolVersion: 1, agentCapabilities: {}, _meta: initializeMeta }) + ) + agent.on('x.ai/models/list', (frame) => agent.reply(frame, { result: MODEL_STATE })) + const launches: ProviderProcessLaunch[] = [] + let closes = 0 + return { + agent, + launches, + closed: () => closes, + deps: { + resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }), + resolveLaunchEnv: () => ({ XAI_API_KEY: 'from-settings' }), + resolveCommand: () => '/opt/grok/bin/grok', + homePath: '/home/user', + connect: (launch) => { + launches.push(launch) + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin) + return { + initialize: () => runtime.initialize(), + requestSessionFreeExtension: (method, params) => + runtime.requestSessionFreeExtension(method, params), + close: async () => { + closes++ + runtime.close() + } + } + } + } + } +} + +describe('ACP model catalog probes', () => { + it('lists Grok from initialize alone, under its pinned account, and closes the child', async () => { + const { agent, deps, launches, closed } = scriptedGrok({ modelState: MODEL_STATE }) + const success = await createAcpModelCatalogProbe(GROK, deps)(GROK_HOME) + expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize']) + expect(agent.frames[0]!.params).toMatchObject({ + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false } + }) + expect(launches).toEqual([ + expect.objectContaining({ + command: '/opt/grok/bin/grok', + args: ['agent', 'stdio'], + env: expect.objectContaining({ GROK_HOME: '/homes/grok-a', XAI_API_KEY: 'from-settings' }) + }) + ]) + expect(closed()).toBe(1) + expect(success.models).toEqual([ + { + id: 'grok-build', + label: 'Grok Build', + // `initialize`'s currentModelId is not what a session runs, so it names no default. + isDefault: false, + // Grok's option ids, which its effort config option takes, and its own default only. + efforts: [ + { value: 'fast', label: 'Fast' }, + { value: 'deep', label: 'Deep' } + ], + defaultEffort: 'deep' + }, + { + id: 'grok-mini', + label: 'Grok Mini', + isDefault: false, + efforts: ['minimal', 'low', 'medium', 'high', 'xhigh'].map((value) => ({ + value, + label: expect.any(String) + })) + }, + { id: 'grok-chat', label: 'Grok Chat', isDefault: false, efforts: [] } + ]) + }) + + it('falls back to x.ai/models/list and never authenticates or opens a session', async () => { + const { agent, deps } = scriptedGrok({}) + const success = await createAcpModelCatalogProbe(GROK, deps)(GROK_HOME) + expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize', 'x.ai/models/list']) + expect(success.models.map((model) => model.id)).toEqual([ + 'grok-build', + 'grok-mini', + 'grok-chat' + ]) + }) + + it('fails, closing the child, when Grok lists nothing', async () => { + const { agent, deps, closed } = scriptedGrok({}) + agent.on('x.ai/models/list', (frame) => agent.reply(frame, { error: 'not signed in' })) + await expect(createAcpModelCatalogProbe(GROK, deps)(GROK_HOME)).rejects.toThrow(/did not list/) + expect(closed()).toBe(1) + expect(agent.frames.some((frame) => frame.method === 'authenticate')).toBe(false) + }) + + it('lists OpenCode with `models --verbose` under its scrubbed launch env, with no ACP call', async () => { + const runListing = vi.fn(async () => 'opencode/big-pickle\n{\n "name": "Big Pickle"\n}\n') + const connect = vi.fn() + const probe = createAcpModelCatalogProbe(OPENCODE, { + resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }), + resolveCommand: () => '/opt/opencode/bin/opencode', + probeVersion: async () => true, + homePath: '/home/user', + runListing, + connect + }) + const success = await probe({ kind: 'opencode', locator: { kind: 'unmanaged' } }) + expect(connect).not.toHaveBeenCalled() + expect(runListing).toHaveBeenCalledWith( + expect.objectContaining({ + command: '/opt/opencode/bin/opencode', + args: ['models', '--verbose'], + env: expect.objectContaining({ OPENCODE_CLIENT: 'acp' }) + }), + expect.objectContaining({ site: 'opencode-model-catalog-probe' }) + ) + expect(success.models).toEqual([ + { id: 'opencode/big-pickle', label: 'opencode/Big Pickle', isDefault: false, efforts: [] } + ]) + }) + + it('lists nothing for a release that runs no structured chat', async () => { + const runListing = vi.fn() + const probe = createAcpModelCatalogProbe(OPENCODE, { + resolveEnvironment: async () => ({ PATH: '/usr/bin' }), + resolveCommand: () => '/opt/opencode/bin/opencode', + probeVersion: async () => false, + runListing + }) + await expect(probe({ kind: 'opencode', locator: { kind: 'unmanaged' } })).rejects.toThrow( + /does not run structured chats/ + ) + expect(runListing).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/acp/acp-model-catalog-probe.ts b/src/main/acp/acp-model-catalog-probe.ts new file mode 100644 index 000000000000..24875317882e --- /dev/null +++ b/src/main/acp/acp-model-catalog-probe.ts @@ -0,0 +1,88 @@ +import { homedir } from 'node:os' +import { probeAgentCliVersion } from '../agent-cli-version-probe' +import type { + AgentModelCatalogProbe, + AgentModelCatalogSuccess +} from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { + runAgentModelCatalogListing, + runAgentModelCatalogSession +} from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import type { AgentSessionModelOption } from '../../shared/agent-session-wire' +import { createAcpAgentConnection } from './acp-agent-connection' +import type { AcpLaunchSpec } from './acp-launch-specs' +import { + acpLaunchVersionSupported, + resolveAcpLaunchInvocation, + type AcpLaunchInvocationDeps +} from './acp-structured-launch-resolution' +import type { InitializeResponse } from './generated/acp-protocol.generated' + +/** The part of an ACP connection a session-free listing may use: no session method is reachable. */ +export type AcpModelCatalogConnection = { + initialize(): Promise + requestSessionFreeExtension(method: string, params: unknown): Promise + close(): Promise +} + +export type AcpModelCatalogProbeDeps = AcpLaunchInvocationDeps & { + /** Test seams; production spawns through the shared supervised runner and ACP connection. */ + probeVersion?: typeof probeAgentCliVersion + connect?: (launch: ProviderProcessLaunch) => AcpModelCatalogConnection + runListing?: typeof runAgentModelCatalogListing +} + +/** + * Lists an ACP agent's models without a session, under the binary, account and scrubbed + * environment its chats launch with. A release that runs no structured chat lists nothing. + */ +export function createAcpModelCatalogProbe( + spec: AcpLaunchSpec, + deps: AcpModelCatalogProbeDeps +): AgentModelCatalogProbe { + return async (accountHome, options): Promise => { + const discovery = spec.modelDiscovery + // Registered as unavailable instead (`acpModelCatalogDiscovery`); never built for one. + if (discovery.kind === 'unavailable') { + throw new Error(discovery.reason) + } + const { command, env, envToDelete } = await resolveAcpLaunchInvocation(spec, accountHome, deps) + // A listing reads no workspace: the catalog is the account's, whichever chat asks. + const cwd = deps.homePath ?? homedir() + if ( + !(await acpLaunchVersionSupported( + spec, + { command, cwd, env }, + deps.probeVersion ?? probeAgentCliVersion + )) + ) { + throw new Error(`${spec.agent} at ${command} does not run structured chats`) + } + let models: AgentSessionModelOption[] + if (discovery.kind === 'command') { + const stdout = await (deps.runListing ?? runAgentModelCatalogListing)( + { command, args: [...discovery.args], cwd, env, envToDelete }, + { site: `${spec.agent}-model-catalog-probe`, signal: options?.signal } + ) + models = discovery.parse(stdout) + } else { + const launch = { + command, + args: spec.args({ fullAccess: false, pluginDir: null }), + cwd, + env, + envToDelete + } + models = await runAgentModelCatalogSession( + () => (deps.connect ?? createAcpAgentConnection)(launch), + async (connection) => discovery.read(await connection.initialize(), connection), + { label: spec.agent, signal: options?.signal } + ) + } + if (models.length === 0) { + throw new Error(`${spec.agent} listed no models`) + } + return { models, fastModeTierByModel: new Map(), origin: 'probe' } + } +} diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 5d7db97fd0b4..5f362d0fe7db 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -132,6 +132,16 @@ export class AcpSessionRuntime { return this.call('authenticate', { methodId }, AuthenticateResponseSchema) } + /** A vendor extension method, answered as the agent sent it. Core methods have typed calls here, + * so a session-free caller can never create or touch a session through this. */ + async requestSessionFreeExtension(method: string, params: unknown): Promise { + if (!method.includes('/') || method.startsWith('session/')) { + throw new Error(`Not an ACP extension method: ${method}`) + } + await this.initialize() + return this.peer.request(method, params, { timeoutMs: null }) + } + start(options: AcpSessionStartOptions): Promise { if (this.started) { return Promise.resolve(this.started) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 2fef70bef875..3f8e9555e9f8 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -94,7 +94,7 @@ export async function acquireAcpStructuredSession(input: { lane: null, reattaching: false } - const options = new AcpStructuredOptions() + const options = new AcpStructuredOptions(launch.spec.dialect) // Only Orca's own prompt may ask the person, as with permissions: a turn the agent began itself // has nobody waiting on it. What a settled request carries still shows in that turn. const prompts = new AcpStructuredPrompts( @@ -262,7 +262,7 @@ export async function acquireAcpStructuredSession(input: { liveLane = makeLane(started.sessionId) } subagentStopSupported = await probeAcpChildStop(connection, spec.dialect) - options.adoptSession(started.response) + options.adoptSession(started.response, started.kind === 'new' ? 'new' : 'loaded') liveLane.apply(liveLane.translator.contextModels(started.response.models, now())) const restoreSkipped = await restoreAcpSessionOptions(connection, options, acquire.options) const process = await identity.read(pid) diff --git a/src/main/acp/acp-structured-host-configured-default.test.ts b/src/main/acp/acp-structured-host-configured-default.test.ts new file mode 100644 index 000000000000..eeb5ccc46646 --- /dev/null +++ b/src/main/acp/acp-structured-host-configured-default.test.ts @@ -0,0 +1,153 @@ +// Grok's session-free listing can name a model (`initialize`'s currentModelId) other than the one a +// session runs. The user's case: the listing said Grok 4.7, every chat ran Grok 4.6. Through the +// real Grok probe, adapter and host, a new chat's first frame names only what a chat ran. + +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentSessionModelCatalogResult } from '../../shared/agent-session-wire' +import { structuredAgentSessionSeedCatalog } from '../../shared/structured-agent-session-seed-catalog' +import { + applyStructuredAgentSessionModelCatalog, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../shared/structured-agent-session-options' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +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 { + agentReadsProjectModelConfig, + workspaceMayOverrideDefaultModel +} from '../native-chat/agent-model-catalog/agent-project-model-override' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { createAcpModelCatalogProbe } from './acp-model-catalog-probe' +import { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import { AcpSessionRuntime } from './acp-session-runtime' +import { GROK, GROK_CONFIG_OPTIONS } from './acp-structured-adapter.test-support' +import { CALLER } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { attachParams, launch, openHostRig } from './acp-structured-host.test-support' + +const GROK_HOME = { variable: 'GROK_HOME', path: '/grok' } +const EFFORT_META = { + supportsReasoningEffort: true, + reasoningEfforts: [ + { id: 'high', value: 'high', default: true }, + { id: 'low', value: 'low' } + ] +} + +const agents: AcpScriptedAgent[] = [] +afterEach(async () => { + for (const agent of agents.splice(0)) { + agent.close() + } + await closeProviderTimelineRigs() +}) + +/** Grok's `initialize` with no session: it computes 4.7 as its current model. */ +function grokProbe() { + return createAcpModelCatalogProbe(GROK, { + resolveEnvironment: async () => ({ PATH: '/usr/bin' }), + resolveCommand: () => '/opt/grok/bin/grok', + homePath: '/home/user', + connect: () => { + const agent = new AcpScriptedAgent() + agents.push(agent) + agent.on('initialize', (frame) => + agent.reply(frame, { + protocolVersion: 1, + agentCapabilities: {}, + _meta: { + modelState: { + currentModelId: 'grok-4.7', + availableModels: [ + { modelId: 'grok-4.7', name: 'Grok 4.7', _meta: EFFORT_META }, + { modelId: 'grok-4.6', name: 'Grok 4.6', _meta: EFFORT_META } + ] + } + } + }) + ) + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin) + return { + initialize: () => runtime.initialize(), + requestSessionFreeExtension: (method, params) => + runtime.requestSessionFreeExtension(method, params), + close: async () => runtime.close() + } + } + }) +} + +/** What a new Grok chat's composer paints first from a host answer. */ +function firstFrame(answer: AgentSessionModelCatalogResult) { + const seed = structuredAgentSessionSeedCatalog('grok') + const state = applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState('grok', seed), + seed, + answer, + { newLaunch: true } + ) + const snapshot = structuredAgentSessionOptionSnapshot(state) + const select = (id: string) => { + const entry = snapshot.find((descriptor) => descriptor.id === id) + return entry?.kind.type === 'select' ? (entry.kind.currentValue ?? null) : null + } + return { model: select('model'), effort: select('effort') } +} + +/** A Grok chat opened with no model pick, whose own session runs `runs`. */ +async function openGrokHost(runs: string) { + const records: { store: AgentSessionRecordStore | null } = { store: null } + const discovery = GROK.modelDiscovery + const catalog = createAgentModelCatalogService({ + store: new AgentModelCatalogStore(), + getRecord: (sessionId) => records.store?.getRecord(sessionId) ?? undefined, + drivesRecord: () => true, + resolveAccountHome: async () => GROK_HOME, + recordWorkspacePath: async () => '/workspace/project', + agentReadsProjectModelConfig, + workspaceMayOverrideDefaultModel, + probes: { grok: grokProbe() }, + // As the launch spec says, not by hand. + listingNamesConfiguredModel: new Set( + discovery.kind !== 'unavailable' && discovery.listingNamesConfiguredModel ? ['grok'] : [] + ) + }) + const opened = GROK_CONFIG_OPTIONS.map((option) => + option.id === 'model' ? { ...option, currentValue: runs } : option + ) + const rig = await openHostRig({ + modelCatalog: catalog, + script: (agent) => + agent.on('session/new', (frame) => + agent.reply(frame, { sessionId: 'acp-session-1', configOptions: opened }) + ), + deps: { resolveLaunch: launch(() => false) } + }) + records.store = rig.store + expect(await rig.host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + return { ...rig, catalog } +} + +describe('Grok’s default comes from what a chat with no pick runs', () => { + it('names no model before any chat ran, though the listing computed one', async () => { + const { catalog } = await openGrokHost('grok-4.6') + const answer = await catalog.read({ agent: 'grok', waitForListing: true }) + expect(answer).toMatchObject({ origin: 'probe', listingNamesConfiguredModel: false }) + expect(firstFrame(answer)).toEqual({ model: null, effort: null }) + }) + + it('names 4.6, the model a chat with no pick ran, never the listing’s 4.7', async () => { + const { catalog, host } = await openGrokHost('grok-4.6') + await catalog.read({ agent: 'grok', waitForListing: true }) + expect((await host.readOptions(SESSION)).current).toMatchObject({ model: 'grok-4.6' }) + + // The next new chat, in any workspace: Grok reads no project config for its model. + const answer = await catalog.read({ agent: 'grok', workspacePath: '/elsewhere' }) + expect(answer).toMatchObject({ + listingNamesConfiguredModel: true, + defaultHoldsInEveryWorkspace: true + }) + expect(firstFrame(answer)).toEqual({ model: 'grok-4.6', effort: 'high' }) + }) +}) diff --git a/src/main/acp/acp-structured-host-live-listing.test.ts b/src/main/acp/acp-structured-host-live-listing.test.ts new file mode 100644 index 000000000000..9e2512981d39 --- /dev/null +++ b/src/main/acp/acp-structured-host-live-listing.test.ts @@ -0,0 +1,34 @@ +// A running Grok chat's own model list, read through the real host, reaches the account's saved +// catalog: the next chat opens warm even when a session-free listing failed. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { AgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' +import { openAttachedHostRig } from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +describe('a live Grok listing', () => { + it('is saved through the host catalog when the chat reads its options', async () => { + const modelCatalog: AgentModelCatalogService = { + read: vi.fn(async () => ({ origin: 'unknown' as const })), + recordLiveListing: vi.fn(), + prewarm: vi.fn(async () => {}), + stop: vi.fn(), + providerStarted: vi.fn() + } + const { host } = await openAttachedHostRig({}, undefined, modelCatalog) + + await host.readOptions(SESSION) + + expect(modelCatalog.recordLiveListing).toHaveBeenCalledTimes(1) + const [sessionId, listing] = vi.mocked(modelCatalog.recordLiveListing).mock.calls[0]! + expect(sessionId).toBe(SESSION) + expect(listing.models.map((model) => model.id)).toEqual(['grok-4.7', 'grok-4.6']) + // The session's picks are no one's default. + expect(listing.models.every((model) => !model.isDefault)).toBe(true) + }) +}) diff --git a/src/main/acp/acp-structured-host.test-support.ts b/src/main/acp/acp-structured-host.test-support.ts index 6dbbb75c171d..e9ebec7d8855 100644 --- a/src/main/acp/acp-structured-host.test-support.ts +++ b/src/main/acp/acp-structured-host.test-support.ts @@ -38,6 +38,7 @@ import { } from './acp-structured-agent-definitions' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' +import type { AgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-ownership' export const hello: AgentJournalMessageItem = { @@ -84,6 +85,7 @@ export async function openHostRig( script?: (agent: AcpScriptedAgent) => void initialize?: Record deps?: Partial + modelCatalog?: AgentModelCatalogService statusSink?: StructuredAgentSessionStatusSink } = {} ) { @@ -118,7 +120,8 @@ export async function openHostRig( claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => HOST_TEST_NOW, - statusSink: options.statusSink + statusSink: options.statusSink, + ...(options.modelCatalog ? { modelCatalog: options.modelCatalog } : {}) }) hosted.host = host replaceHostTestState({ store, host }) @@ -185,12 +188,14 @@ export const framesOf = (child: FakeAcpChild, method: string) => /** A new Grok chat the host attached; Grok loads its session on a later start, counting each. */ export async function openAttachedHostRig( deps: Partial = {}, - statusSink?: StructuredAgentSessionStatusSink + statusSink?: StructuredAgentSessionStatusSink, + modelCatalog?: AgentModelCatalogService ) { const count = { loads: 0 } let resumed = false const rig = await openHostRig({ statusSink, + ...(modelCatalog ? { modelCatalog } : {}), initialize: RESUMES, script: (agent) => agent.on('session/load', (frame) => { diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index fb0211c624e5..240dddfff3c8 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -14,6 +14,7 @@ import { type AgentSessionProviderHandleChain } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { AgentSessionAccountHome } from '../../shared/agent-session-account-home' import type { resolveCliCommand } from '../../shared/node-cli-command-resolution' import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { @@ -140,6 +141,37 @@ export async function acpLaunchVersionSupported( return (await probeAcpLaunchVersion(spec, launch, false, probe)).supported } +export type AcpLaunchInvocationDeps = Pick< + AcpStructuredLaunchResolverDeps, + | 'resolveEnvironment' + | 'resolveLaunchEnv' + | 'resolveCommandSettings' + | 'resolveCommand' + | 'homePath' + | 'inheritedEnv' +> + +/** The binary and environment any child of this agent runs under for `accountHome`: a session's + * launch and a catalog probe both resolve here, so neither can reach another account or install. */ +export async function resolveAcpLaunchInvocation( + spec: AcpLaunchSpec, + accountHome: AgentSessionAccountHome, + deps: AcpLaunchInvocationDeps +): Promise<{ command: string; env: Record; envToDelete: string[] }> { + const base = await deps.resolveEnvironment() + const env: Record = spec.account.environment(accountHome, { + ...base, + ...deps.resolveLaunchEnv?.(spec.agent) + }) + const envToDelete = spec.scrubEnvironment?.(env, deps.inheritedEnv ?? process.env) ?? [] + Object.assign(env, spec.env) + const command = resolveAcpLaunchCommand(spec, env, { + ...deps, + ...(deps.resolveCommandSettings ? { commandSettings: deps.resolveCommandSettings() } : {}) + }) + return { command, env, envToDelete } +} + export function createAcpStructuredLaunchResolver( spec: AcpLaunchSpec, deps: AcpStructuredLaunchResolverDeps @@ -160,17 +192,7 @@ export function createAcpStructuredLaunchResolver( `${spec.agent} structured sessions run on this runtime's host, not ${location.executionHostId}` ) } - const base = await deps.resolveEnvironment() - const env: Record = spec.account.environment(accountHome, { - ...base, - ...deps.resolveLaunchEnv?.(spec.agent) - }) - const envToDelete = spec.scrubEnvironment?.(env, deps.inheritedEnv ?? process.env) ?? [] - Object.assign(env, spec.env) - const command = resolveAcpLaunchCommand(spec, env, { - ...deps, - ...(deps.resolveCommandSettings ? { commandSettings: deps.resolveCommandSettings() } : {}) - }) + const { env, envToDelete, command } = await resolveAcpLaunchInvocation(spec, accountHome, deps) const cwd = await deps.resolveWorkspacePath(location.workspaceId) const visuals = spec.visualsSkill ? ((await deps.prepareVisuals?.(identity.sessionId)) ?? null) diff --git a/src/main/acp/acp-structured-options.test.ts b/src/main/acp/acp-structured-options.test.ts new file mode 100644 index 000000000000..b348d0fdba92 --- /dev/null +++ b/src/main/acp/acp-structured-options.test.ts @@ -0,0 +1,174 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { AcpStructuredOptions, restoreAcpSessionOptions } from './acp-structured-options' +import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' + +const configOptions = [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select' as const, + currentValue: 'model-b', + options: [ + { value: 'model-a', name: 'Model A' }, + { value: 'model-b', name: 'Model B' } + ] + }, + { + id: 'effort', + name: 'Effort', + category: 'thought_level', + type: 'select' as const, + currentValue: 'high', + options: [ + { value: 'low', name: 'Low' }, + { value: 'high', name: 'High' } + ] + } +] + +describe('ACP session options', () => { + it('keeps the session’s picks out of the catalog facts', () => { + const options = new AcpStructuredOptions() + options.adoptSession({ configOptions }) + const { models, current } = options.read() + // The effort menu belongs to the model running now; nothing is anyone's default. + expect(models).toEqual([ + { id: 'model-a', label: 'Model A', isDefault: false, efforts: [] }, + { + id: 'model-b', + label: 'Model B', + isDefault: false, + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + } + ]) + expect(current).toEqual({ model: 'model-b', effort: 'high', confirmed: ['model', 'effort'] }) + }) + + it('reads each model’s own menu where the agent advertises one', () => { + const options = new AcpStructuredOptions(GROK_ACP_DIALECT) + options.adoptSession({ + configOptions, + models: { + currentModelId: 'model-b', + availableModels: [ + { + modelId: 'model-a', + name: 'Model A', + _meta: { + supportsReasoningEffort: true, + reasoningEfforts: [{ value: 'medium', default: true }] + } + }, + // The session's own effort written into the running model's meta is not its default. + { + modelId: 'model-b', + name: 'Model B', + _meta: { + supportsReasoningEffort: true, + reasoningEffort: 'high', + reasoningEfforts: ['low', 'high'] + } + } + ] + } + }) + expect(options.read().models).toEqual([ + { + id: 'model-a', + label: 'Model A', + isDefault: false, + efforts: [{ value: 'medium', label: 'Medium' }], + defaultEffort: 'medium' + }, + { + id: 'model-b', + label: 'Model B', + isDefault: false, + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + } + ]) + expect(options.reported()).toEqual({ model: 'model-b', effort: 'high' }) + }) + + it('keeps the session’s effort menu for the running model when its advertised one is empty', () => { + const options = new AcpStructuredOptions(GROK_ACP_DIALECT) + options.adoptSession({ + configOptions, + models: { + currentModelId: 'model-b', + availableModels: [ + { modelId: 'model-a', name: 'Model A' }, + { modelId: 'model-b', name: 'Model B', _meta: { supportsReasoningEffort: false } } + ] + } + }) + const [modelA, modelB] = options.read().models + expect(modelA?.efforts).toEqual([]) + expect(modelB?.efforts).toEqual([ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ]) + expect(modelB?.defaultEffort).toBeUndefined() + }) +}) + +describe('what a new session with no pick says about the configured default', () => { + const connection = { + setConfigOption: async () => ({ configOptions }), + setModel: async () => ({}) + } + + it('is the model and effort the agent chose for itself', () => { + const options = new AcpStructuredOptions() + options.adoptSession({ configOptions }, 'new') + expect(options.configuredDefault()).toEqual({ modelId: 'model-b', effort: 'high' }) + }) + + it('says nothing of a loaded session, which keeps the model it ran', () => { + const options = new AcpStructuredOptions() + options.adoptSession({ configOptions }) + expect(options.configuredDefault()).toBeUndefined() + }) + + it('says nothing once the chat holds a model pick, even one the session already runs', async () => { + const options = new AcpStructuredOptions() + options.adoptSession({ configOptions }, 'new') + await restoreAcpSessionOptions(connection, options, { model: 'model-b' }) + expect(options.configuredDefault()).toBeUndefined() + }) + + it('keeps the model but not an effort the chat picked', async () => { + const options = new AcpStructuredOptions() + options.adoptSession({ configOptions }, 'new') + await restoreAcpSessionOptions(connection, options, { effort: 'low' }) + expect(options.configuredDefault()).toEqual({ modelId: 'model-b' }) + }) + + it('says nothing when the agent reports no model, so no empty default is saved', () => { + const noModel = JSON.parse( + readFileSync(new URL('./fixtures/omp-v17-windows-new-no-model.json', import.meta.url), 'utf8') + ) + const options = new AcpStructuredOptions() + options.adoptSession(noModel, 'new') + expect(options.configuredDefault()).toBeUndefined() + options.adoptSession({ models: { currentModelId: '', availableModels: [] } }, 'new') + expect(options.configuredDefault()).toBeUndefined() + }) + + it('retires a saved default when the session runs a model it does not list', () => { + const options = new AcpStructuredOptions() + options.adoptSession( + { configOptions: [{ ...configOptions[0]!, currentValue: 'model-z' }] }, + 'new' + ) + expect(options.configuredDefault()).toBeNull() + }) +}) diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts index 250541a7e217..0ee9d5d8b5ef 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -11,6 +11,8 @@ import type { import { isAcpStructuredOptionKey } from './acp-structured-agent-definitions' import { AcpRpcError } from './acp-errors' import type { AcpStructuredConnection } from './acp-structured-connection' +import type { AcpDialect } from './acp-dialects/acp-dialect' +import type { AgentModelCatalogConfiguredChoice } from '../native-chat/agent-model-catalog/agent-model-catalog-entry' import { SessionConfigSelectGroupSchema, SessionConfigSelectOptionSchema, @@ -48,17 +50,31 @@ function isSelect(option: SessionConfigOption): option is SelectOption { } export class AcpStructuredOptions { + constructor(private readonly dialect: Pick = {}) {} + private configOptions: SessionConfigOption[] = [] private models: SessionModelState | null = null private commands: AgentSessionSlashCommand[] | undefined + // A loaded session keeps the model it ran; only a new one resolves the agent's own config. + private resolvesConfig = false + private readonly picked = new Set() /** The session state a new, load or resume response reported. */ - adoptSession(response: { - configOptions?: SessionConfigOption[] | null - models?: SessionModelState | null - }): void { + adoptSession( + response: { + configOptions?: SessionConfigOption[] | null + models?: SessionModelState | null + }, + origin: 'new' | 'loaded' = 'loaded' + ): void { this.configOptions = response.configOptions ?? [] this.models = response.models ?? null + this.resolvesConfig = origin === 'new' + } + + /** Orca sent `key` to the session, or the chat's saved options hold a value for it. */ + notePick(key: string): void { + this.picked.add(key) } adoptConfigOptions(configOptions: SessionConfigOption[] | null | undefined): void { @@ -102,10 +118,15 @@ export class AcpStructuredOptions { return null } + /** + * The agent's models and what this session runs. A model's effort menu and default are catalog + * facts: the dialect's per-model menu when the agent advertises one, else the session's effort + * option for the model it runs now only. The session's current values are never a default. + */ read(): Pick { const modelOption = this.select('model') const effortOption = this.select('thought_level') - const efforts: AgentSessionOptionChoice[] = effortOption + const sessionEfforts: AgentSessionOptionChoice[] = effortOption ? selectChoices(effortOption).map((choice) => ({ value: choice.value, label: choice.name, @@ -124,12 +145,17 @@ export class AcpStructuredOptions { label: model.name, ...(model.description ? { description: model.description } : {}) })) ?? []) - const models: AgentSessionModelOption[] = listed.map((model) => ({ - ...model, - isDefault: model.id === currentModel, - efforts, - ...(effortOption ? { defaultEffort: effortOption.currentValue } : {}) - })) + const models: AgentSessionModelOption[] = listed.map((model) => { + const info = this.models?.availableModels.find((entry) => entry.modelId === model.id) + const advertised = info ? this.dialect.modelEfforts?.(info) : undefined + const sessionMenu = { efforts: model.id === currentModel ? sessionEfforts : [] } + return { + ...model, + isDefault: false, + // An empty advertised menu is no menu: the running model keeps the one its session offers. + ...(advertised && advertised.efforts.length > 0 ? advertised : sessionMenu) + } + }) const confirmed = [...(currentModel ? ['model'] : []), ...(effortOption ? ['effort'] : [])] return { models, @@ -141,6 +167,27 @@ export class AcpStructuredOptions { } } + /** With no model sent since a new session began, what it runs is the agent's own config + * resolution: that scope's configured default. Null when that names no listed model; undefined + * when this session can't say. */ + configuredDefault(): AgentModelCatalogConfiguredChoice | null | undefined { + if (!this.resolvesConfig || this.picked.has('model')) { + return undefined + } + const { models, current } = this.read() + if (!current.model) { + return undefined + } + if (!models.some((model) => model.id === current.model)) { + return null + } + return { + modelId: current.model, + // An effort this session picked is its own and says nothing of the config's. + ...(this.picked.has('effort') ? {} : { effort: current.effort ?? null }) + } + } + /** The values the agent reports now, as the record's options would name them. */ reported(): Readonly> { const { current } = this.read() @@ -207,8 +254,11 @@ export async function restoreAcpSessionOptions( ): Promise { const skipped: string[] = [] for (const [key, value] of Object.entries(saved ?? {})) { - const reported = options.reported() - if (!isAcpStructuredOptionKey(key) || reported[key] === value) { + if (!isAcpStructuredOptionKey(key)) { + continue + } + options.notePick(key) + if (options.reported()[key] === value) { continue } const write = options.write(key, value) diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index b69b24cc4b2b..a352c924e2cc 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -43,6 +43,7 @@ import { } from './acp-structured-session-adapter-deps' import { writeAcpSessionOption } from './acp-structured-options' import { readAcpRecoveryHistory } from './acp-recovery-history' +import { withLiveCatalogListing } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { stopAcpChildren, acpChildStopCapabilities } from './acp-structured-child-stop' export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapter { @@ -212,6 +213,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte if (!write) { throw new Error(`${session.spec.agent} offers no session option named ${input.key}`) } + session.options.notePick(input.key) // Bounded, and abandoned by a close or Stop: the session's queue waits on it. await writeAcpSessionOption(session.connection, session.options, write, { agent: session.spec.agent, @@ -221,8 +223,10 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte return session.options.reported() } - readOptions = async (input: { sessionId: string; fence: number }) => - this.live(input.sessionId).options.read() + readOptions = async (input: { sessionId: string; fence: number }) => { + const { options } = this.live(input.sessionId) + return withLiveCatalogListing(options.read(), options.configuredDefault()) + } readOptionRestoreFailures = (sessionId: string): readonly string[] => this.sessions.get(sessionId)?.restoreSkipped ?? [] diff --git a/src/main/claude/claude-model-catalog-probe.test.ts b/src/main/claude/claude-model-catalog-probe.test.ts index b03ce52f3b81..e6a337a70a65 100644 --- a/src/main/claude/claude-model-catalog-probe.test.ts +++ b/src/main/claude/claude-model-catalog-probe.test.ts @@ -1,5 +1,3 @@ -import { createMockDiscoveryChild } from '../text-generation/commit-message-text-generation-test-harness' -import { CLAUDE_MODEL_LIST_STDIN } from '../../shared/claude-model-list-probe' import { AgentModelCatalogUnavailableError } from '../native-chat/agent-model-catalog/agent-model-catalog-unavailable' import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' @@ -8,18 +6,25 @@ import { describe, expect, it, vi } from 'vitest' import { PROVIDER_STDIN_END_GRACE_MS } from '../provider-process/provider-process-supervisor' import { createClaudeModelCatalogProbe } from './claude-model-catalog-probe' import { resolveClaudeStructuredInvocation } from './claude-structured-launch-resolution' -import type { discoverModelsLocal } from '../text-generation/commit-message-model-discovery' import { resolveStructuredAgentCommand } from '../native-chat/structured-agent-command-resolution' -import type { - SpawnedSourceControlAgentProcess, - SpawnSourceControlAgent -} from '../text-generation/source-control-text-generation-types' +import { + AgentModelCatalogProbeError, + type runAgentModelCatalogListing +} from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner' +import { + CLAUDE_MODEL_LIST_ARGS, + CLAUDE_MODEL_LIST_STDIN +} from '../../shared/claude-model-list-probe' -type DiscoverInput = Parameters[0] -type DiscoverResult = Awaited> +type ListingCall = Parameters const AUTH_POLICY = { stripAuthEnv: false } as const +const HOME = (path: string): { variable: string; path: string } => ({ + variable: 'CLAUDE_CONFIG_DIR', + path +}) + function probeDeps(command = `"${process.execPath}"`): { resolveCommand: () => string resolveEnv: () => Record @@ -37,113 +42,99 @@ function probeDeps(command = `"${process.execPath}"`): { } } -function listedResult(): DiscoverResult { - return { - success: true, - capability: { - id: 'claude', - label: 'Claude', - modelSource: 'dynamic', - defaultModelId: 'sonnet', - models: [] - }, - models: [ - { - id: 'sonnet', - label: 'Sonnet', - isDefault: true, - thinkingLevels: [{ id: 'low', label: 'Low' }], - defaultThinkingLevel: 'low' - } - ], - defaultModelId: 'sonnet', - catalogOrigin: 'probe' +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([`"${process.execPath}"`, ''])( - 'lists with the session executable for Command %j', + 'lists with the session executable and env for Command %j', async (command) => { const deps = probeDeps(command) - const captured: DiscoverInput[] = [] - const spawnAgent = vi.fn( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake discover below never reads the returned child. - () => ({}) as SpawnedSourceControlAgentProcess - ) + const calls: ListingCall[] = [] const probe = createClaudeModelCatalogProbe({ ...deps, - spawnAgent, - discover: async (input) => { - captured.push(input) - // Drive the probe's spawn wrapper once, the way the real listing would. - input.spawnAgent({ - binary: 'claude', - args: ['--list'], - env: input.env, - stdinMode: 'ignore', - useCwdForNative: false - }) - return listedResult() + runListing: async (...call) => { + calls.push(call) + return LISTED } }) - const success = await probe('/homes/account-a') + const success = await probe(HOME('/homes/account-a')) expect(success.origin).toBe('probe') expect(success.models).toEqual([ { id: 'sonnet', label: 'Sonnet', - isDefault: true, - // The spec's `defaultThinkingLevel` is not what Claude runs; naming it would label the effort. + isDefault: false, + // The parser's thinking default is not what Claude runs; naming it would label the effort. efforts: [{ value: 'low', label: 'Low' }] } ]) - // The probe's env is exactly the session launch's resolved env for the - // same deps, plus the account pin — and the spawn runs the resolved - // binary, never the bare spec name. + // The probe's env is exactly the session launch's resolved env for the same deps, plus the + // account pin, and the listing runs the resolved binary, never the bare spec name. const invocation = await resolveClaudeStructuredInvocation(deps, (env) => ({ ...env, CLAUDE_CONFIG_DIR: '/homes/account-a' })) - expect(captured).toHaveLength(1) - expect(captured[0]!.env).toEqual(invocation.env) - expect(captured[0]!.agentCommandOverride).toBeUndefined() - expect(spawnAgent).toHaveBeenCalledTimes(1) - expect(spawnAgent.mock.calls[0]![0].binary).toBe(invocation.command) + expect(calls).toHaveLength(1) + const [launch, options] = calls[0]! + expect(launch).toEqual({ + command: invocation.command, + args: [...CLAUDE_MODEL_LIST_ARGS], + stdin: CLAUDE_MODEL_LIST_STDIN + }) + expect(options.inheritedEnv).toEqual(invocation.env) } ) it('pins no CLAUDE_CONFIG_DIR for the CLI default home, exactly as a session spawn does', async () => { - const envs: DiscoverInput['env'][] = [] + const envs: (NodeJS.ProcessEnv | undefined)[] = [] const probe = createClaudeModelCatalogProbe({ ...probeDeps(), - discover: async (input) => { - envs.push(input.env) - return listedResult() + runListing: async (_launch, options) => { + envs.push(options.inheritedEnv) + return LISTED } }) - await probe(join(homedir(), '.claude')) - await probe('/homes/account-a') + await probe(HOME(join(homedir(), '.claude'))) + await probe(HOME('/homes/account-a')) // An explicit default would move the CLI off its default Keychain item (claude.ai OAuth). expect(envs[0]).not.toHaveProperty('CLAUDE_CONFIG_DIR') expect(envs[1]).toMatchObject({ CLAUDE_CONFIG_DIR: '/homes/account-a' }) }) - it('refuses a static-fallback answer rather than reporting it as a catalog', async () => { + it('refuses an answer that names no model, as an older CLI gives, rather than store it', async () => { const probe = createClaudeModelCatalogProbe({ ...probeDeps(), - discover: async () => ({ ...listedResult(), catalogOrigin: 'spec' }) + runListing: async () => + `${JSON.stringify({ type: 'control_response', response: { subtype: 'error' } })}\n` }) - await expect(probe('/homes/a')).rejects.toThrow(/listed no models/) + await expect(probe(HOME('/homes/a'))).rejects.toThrow(/listed no models/) }) it.each(['npx claude', 'claude --verbose', '/missing/claude', './claude'])( 'lists nothing, and never probes the stock CLI, for Command %s', async (command) => { - const discover = vi.fn() - const probe = createClaudeModelCatalogProbe({ ...probeDeps(command), discover }) - await expect(probe('/homes/a')).rejects.toMatchObject({ reason: 'agentCommandNotRunnable' }) - expect(discover).not.toHaveBeenCalled() + const runListing = vi.fn() + const probe = createClaudeModelCatalogProbe({ ...probeDeps(command), runListing }) + await expect(probe(HOME('/homes/a'))).rejects.toMatchObject({ + reason: 'agentCommandNotRunnable' + }) + expect(runListing).not.toHaveBeenCalled() } ) @@ -176,7 +167,7 @@ process.stdin.on('end', () => setTimeout(() => { resolveInheritedEnv: async () => ({ PATH: process.env.PATH ?? '', HOME: folder }) }) - await expect(probe(join(folder, 'account'))).resolves.toMatchObject({ + await expect(probe(HOME(join(folder, 'account')))).resolves.toMatchObject({ origin: 'probe', models: [{ id: 'sonnet', label: 'Sonnet' }] }) @@ -190,73 +181,46 @@ process.stdin.on('end', () => setTimeout(() => { }) describe('Claude catalog availability', () => { - it('does not turn a generic discovery failure into unavailable', async () => { + it('does not turn a generic listing failure into unavailable', async () => { const probe = createClaudeModelCatalogProbe({ ...probeDeps(), - discover: async () => ({ success: false, error: 'timeout' }) + runListing: async () => { + throw new AgentModelCatalogProbeError('claude did not list models', 'timeout') + } }) - await expect(probe('/homes/a')).rejects.not.toBeInstanceOf(AgentModelCatalogUnavailableError) + await expect(probe(HOME('/homes/a'))).rejects.not.toBeInstanceOf( + AgentModelCatalogUnavailableError + ) }) -}) -describe('Claude catalog fake-child contract', () => { // Claude has no pre-send sign-in verdict: its real start refusal is the only one. - it.each([false, true])( - 'lists in one process with no sign-in verdict, managed=%s', - async (managed) => { - const child = createMockDiscoveryChild() - const spawnAgent = vi.fn(() => { - queueMicrotask(() => { - child.stdout.emit( - 'data', - Buffer.from( - `${JSON.stringify({ - type: 'control_response', - response: { - request_id: 'orca-model-discovery', - subtype: 'success', - response: { models: [{ value: 'sonnet', displayName: 'Sonnet' }] } - } - })}\n` - ) - ) - child.emit('close', 0) - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: discovery reads only the fake child's EventEmitter streams, pid, kill and stdin.end. - return child as unknown as SpawnedSourceControlAgentProcess - }) + it.each([false, true])('lists with no sign-in verdict, managed=%s', async (managed) => { + const runListing = vi.fn(async () => LISTED) + const probe = createClaudeModelCatalogProbe({ + ...probeDeps(), + resolveAuthPolicy: () => ({ stripAuthEnv: managed }), + runListing + }) + const result = await probe(HOME('/homes/a')) + expect(result).toMatchObject({ models: [{ id: 'sonnet' }] }) + expect(result).not.toHaveProperty('unavailable') + expect(runListing).toHaveBeenCalledTimes(1) + }) + + it('says the CLI is missing when the resolved executable is not there', async () => { + const folder = mkdtempSync(join(tmpdir(), 'orca-claude-missing-')) + try { + // Never created: the real runner's spawn reports ENOENT for this exact path. + const missing = join(folder, 'claude with spaces', 'claude') const probe = createClaudeModelCatalogProbe({ ...probeDeps(), - resolveEnv: () => ({}), - resolveAuthPolicy: () => ({ stripAuthEnv: managed }), - spawnAgent + resolveCommand: () => missing + }) + await expect(probe(HOME('/homes/a'))).rejects.toMatchObject({ + unavailable: { reason: 'cliMissing' } }) - const result = await probe('/homes/a') - expect(result).toMatchObject({ models: [{ id: 'sonnet' }] }) - expect(result).not.toHaveProperty('unavailable') - expect(spawnAgent).toHaveBeenCalledTimes(1) - expect(child.stdin.end).toHaveBeenCalledWith(CLAUDE_MODEL_LIST_STDIN) + } finally { + rmSync(folder, { recursive: true, force: true }) } - ) - it('preserves the resolved executable missing error from a child', async () => { - const child = createMockDiscoveryChild() - const probe = createClaudeModelCatalogProbe({ - ...probeDeps(), - resolveCommand: () => '/resolved/claude with spaces/claude', - spawnAgent: () => { - queueMicrotask(() => - child.emit( - 'error', - Object.assign(new Error('missing'), { - code: 'ENOENT', - path: '/resolved/claude with spaces/claude' - }) - ) - ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: discovery reads only the fake child's EventEmitter streams, pid, kill and stdin.end. - return child as unknown as SpawnedSourceControlAgentProcess - } - }) - await expect(probe('/homes/a')).rejects.toMatchObject({ unavailable: { reason: 'cliMissing' } }) }) }) diff --git a/src/main/claude/claude-model-catalog-probe.ts b/src/main/claude/claude-model-catalog-probe.ts index 7a316bd0f40c..58ef1050a93e 100644 --- a/src/main/claude/claude-model-catalog-probe.ts +++ b/src/main/claude/claude-model-catalog-probe.ts @@ -1,7 +1,10 @@ +import { + CLAUDE_MODEL_LIST_ARGS, + CLAUDE_MODEL_LIST_STDIN +} from '../../shared/claude-model-list-probe' +import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' +import { parseClaudeModels } from '../../shared/commit-message-model-parsers' import { AgentModelCatalogUnavailableError } from '../native-chat/agent-model-catalog/agent-model-catalog-unavailable' -import { discoverModelsLocal } from '../text-generation/commit-message-model-discovery' -import { commandBackslashMode } from '../text-generation/commit-message-text-generation' -import { spawnSourceControlAgent } from '../text-generation/source-control-agent-launch' import { claudeConfigDirEnvPatch } from './claude-config-dir-pin' import { resolveClaudeStructuredInvocation, @@ -11,60 +14,58 @@ import type { AgentModelCatalogProbe, AgentModelCatalogSuccess } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { + AgentModelCatalogProbeError, + runAgentModelCatalogListing +} from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner' export type ClaudeModelCatalogProbeDeps = Pick< ClaudeStructuredLaunchResolverDeps, 'resolveCommand' | 'resolveEnv' | 'resolveInheritedEnv' | 'resolveAuthPolicy' > & { authSwitchSettleTimeoutMs?: number - /** Test seams; production runs the one-shot listing child. */ - discover?: typeof discoverModelsLocal - spawnAgent?: typeof spawnSourceControlAgent + /** Test seam; production runs the shared supervised listing. */ + runListing?: typeof runAgentModelCatalogListing } /** - * Lists models without a live session, through the existing one-shot CLI - * listing — but under the SAME binary and environment the structured session - * launch resolves, so a probe can never list under a different install or - * shell env than the sessions it stands in for. + * Lists models without a live session: one `list_models` control request, under the SAME binary + * and environment the structured session launch resolves, so a probe can never list under a + * different install or shell env than the sessions it stands in for. */ export function createClaudeModelCatalogProbe( deps: ClaudeModelCatalogProbeDeps ): AgentModelCatalogProbe { - return async (accountHomePath: string): Promise => { + return async (accountHome, options): Promise => { + const accountHomePath = requireLegacyAgentSessionAccountHome(accountHome).path // Same pin rule as the session spawn: naming the CLI's default dir would move // it off the default Keychain item and list under another identity. const { command, env } = await resolveClaudeStructuredInvocation(deps, (base) => ({ ...base, ...claudeConfigDirEnvPatch(accountHomePath, { env: base }) })) - const result = await (deps.discover ?? discoverModelsLocal)({ - agentId: 'claude', - binary: command, - env, - options: {}, - backslash: commandBackslashMode({ kind: 'local', cwd: '' }), - // The resolved absolute command replaces the plan's bare binary directly: - // routing it through the command-override template would re-tokenize a - // path that may contain spaces. - spawnAgent: (input) => - (deps.spawnAgent ?? spawnSourceControlAgent)({ ...input, binary: command }) + const stdout = await (deps.runListing ?? runAgentModelCatalogListing)( + { command, args: [...CLAUDE_MODEL_LIST_ARGS], stdin: CLAUDE_MODEL_LIST_STDIN }, + { site: 'claude-model-catalog-probe', inheritedEnv: env, signal: options?.signal } + ).catch((error: unknown) => { + // Claude has no pre-send sign-in verdict; a missing CLI is the one this probe can find. + if (error instanceof AgentModelCatalogProbeError && error.executableMissing) { + throw new AgentModelCatalogUnavailableError({ reason: 'cliMissing' }) + } + throw error }) - if (!result.success && result.unavailable) { - throw new AgentModelCatalogUnavailableError(result.unavailable) - } - // The spec's static fallback must never pass as a listing: Claude's real - // list replaces the seed, so only a probe-origin answer is a catalog. - if (!result.success || result.catalogOrigin !== 'probe' || result.models.length === 0) { - throw new Error(result.success ? 'claude listed no models' : result.error) + // A CLI that predates the request answers a control error: no models, so no catalog. + const listed = parseClaudeModels(stdout) + if (listed.length === 0) { + throw new Error('claude listed no models') } return { - models: result.models.map((model) => ({ + models: listed.map((model) => ({ id: model.id, label: model.label, ...(model.description ? { description: model.description } : {}), isDefault: model.isDefault === true, - // No defaultEffort: the listing's thinking default is the commit-message generator's + // No defaultEffort: the parser's thinking default is the commit-message generator's // choice, not the effort Claude runs; the store keeps the one a live child reported. efforts: (model.thinkingLevels ?? []).map((level) => ({ value: level.id, diff --git a/src/main/claude/claude-structured-acquire-catalog.ts b/src/main/claude/claude-structured-acquire-catalog.ts deleted file mode 100644 index e4144a22c014..000000000000 --- a/src/main/claude/claude-structured-acquire-catalog.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' -import type { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import { CLAUDE_STRUCTURED_AGENT } from './claude-structured-agent-definition' - -/** A Claude session's catalog, keyed by the config directory it launched under. */ -export function claudeAcquireCatalogAccess( - store: AgentModelCatalogStore | undefined, - claudeConfigDir: string | null -) { - return agentModelCatalogSessionAccess(store, CLAUDE_STRUCTURED_AGENT, claudeConfigDir) -} diff --git a/src/main/claude/claude-structured-catalog-listing.ts b/src/main/claude/claude-structured-catalog-listing.ts new file mode 100644 index 000000000000..1b26cb4d9996 --- /dev/null +++ b/src/main/claude/claude-structured-catalog-listing.ts @@ -0,0 +1,108 @@ +// What a Claude child's listing says about its account, apart from this session's own state. + +import type { + AgentSessionFastModeSupport, + AgentSessionOptionsResult +} from '../../shared/agent-session-wire' +import type { + AgentModelCatalogConfiguredChoice, + AgentModelCatalogLiveListing +} from '../native-chat/agent-model-catalog/agent-model-catalog-entry' +import type { ListedModel } from './claude-structured-model-catalog' +import type { ClaudeSession } from './claude-structured-session-state' + +const TRANSIENT_FAST_MODE_REASONS = new Set(['network_error', 'unknown', 'pending']) +const NON_BLOCKING_FAST_MODE_REASONS = new Set(['preference', 'sdk_opt_in_required']) + +export function claudeFastModeSupport( + models: readonly ListedModel[], + disabledReason: string | undefined +): AgentSessionFastModeSupport | undefined { + if (disabledReason && TRANSIENT_FAST_MODE_REASONS.has(disabledReason)) { + return undefined + } + if (disabledReason && !NON_BLOCKING_FAST_MODE_REASONS.has(disabledReason)) { + return { supported: false, reason: disabledReason } + } + if (!models.some((model) => model.supportsFastMode === true)) { + return models.length > 0 && models.every((model) => model.supportsFastMode === false) + ? { supported: false, reason: 'model-not-supported' } + : undefined + } + return { supported: true } +} + +export type WireClaudeModel = AgentSessionOptionsResult['models'][number] + +function wireClaudeModel(entry: ListedModel): WireClaudeModel { + return { + id: entry.id, + label: entry.label, + ...(entry.description ? { description: entry.description } : {}), + isDefault: entry.isDefault, + efforts: entry.efforts, + ...(entry.supportsFastMode !== undefined ? { supportsFastMode: entry.supportsFastMode } : {}) + } +} + +export function wireClaudeModels(models: readonly ListedModel[]): WireClaudeModel[] { + return models.map(wireClaudeModel) +} + +/** The account-level facts of a provider listing, for the host to save: this session's disabled + * reason and its unlisted current model stay out, so another surface never inherits session state + * as a catalog. A child launched with `--model X` lists X itself (Claude Code 2.1.280 adds a row + * named by the raw id, "Custom model"), whether or not X exists; native rows carry a display name + * of their own, so a row named by its id is the launch talking, not the account. */ +export function claudeCatalogListing( + session: ClaudeSession, + discovered: ListedModel[] +): AgentModelCatalogLiveListing | undefined { + if (discovered.length === 0) { + return undefined + } + const launched = session.launchedModel + const launchOnly = + launched !== null && discovered.some((row) => row.id === launched && row.label === row.id) + const support = claudeFastModeSupport(discovered, undefined) + const configured = configuredClaudeDefault(session, discovered) + return { + // No default effort here: the CLI's effort is a config fact, saved with the configured model. + models: wireClaudeModels(discovered), + ...(support ? { fastModeSupport: support } : {}), + ...(launchOnly ? { launchOnlyModelId: launched } : {}), + ...(configured !== undefined ? { configuredDefault: configured } : {}) + } +} + +/** With no model sent at launch or since, the model and effort the CLI says it applies are its own + * resolution of env over settings over its default: the configured default for this session's + * config scope. Null when that resolution names no listed model; undefined when the session + * picked its own model or never read its settings back. */ +function configuredClaudeDefault( + session: ClaudeSession, + discovered: ListedModel[] +): AgentModelCatalogConfiguredChoice | null | undefined { + const applied = session.appliedOptions + if (session.launchedModel !== null || session.options.has('model') || !applied) { + return undefined + } + const row = applied.model + ? discovered.find( + (entry) => entry.id === applied.model || entry.resolvedModel === applied.model + ) + : undefined + if (!row) { + return null + } + const runs = row.resolvedModel ?? row.id + const sameModelIds = discovered + .filter((entry) => entry !== row && (entry.resolvedModel ?? entry.id) === runs) + .map((entry) => entry.id) + return { + modelId: row.id, + ...(sameModelIds.length > 0 ? { sameModelIds } : {}), + // An effort this session picked is its own and says nothing of the config's. + ...(session.options.has('effort') ? {} : { effort: applied.effort ?? null }) + } +} diff --git a/src/main/claude/claude-structured-configured-default.test.ts b/src/main/claude/claude-structured-configured-default.test.ts new file mode 100644 index 000000000000..bf57896023d0 --- /dev/null +++ b/src/main/claude/claude-structured-configured-default.test.ts @@ -0,0 +1,233 @@ +// A new Claude chat names its model and effort on the first frame: a chat launched with nothing +// picked reports the model the CLI's own config resolution applies, and the host keeps that as the +// account's configured default when the chat's workspace has no Claude settings of its own. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { getAgentSessionOptionCatalog } from '../../shared/agent-session-option-catalog' +import { + applyStructuredAgentSessionModelCatalog, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../shared/structured-agent-session-options' +import { createAgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' +import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { agentModelCatalogFingerprintForRecord } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' +import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' +import { + PROVIDER_SESSION_ID, + fakeClaude, + identityFor, + recordingJournalSink, + claudeStartupSettled +} from './claude-structured-session-test-support' + +const SESSION = 'session-1' +const ACCOUNT_HOME = '/accounts/claude' +const EFFORTS = ['low', 'medium', 'high'] + +const CATALOG = [ + { + value: 'default', + resolvedModel: 'claude-opus-5-5', + displayName: 'Default (recommended)', + supportsEffort: true, + supportedEffortLevels: EFFORTS + }, + { + value: 'opus', + resolvedModel: 'claude-opus-5-5', + displayName: 'Opus', + supportsEffort: true, + supportedEffortLevels: EFFORTS + }, + { + value: 'sonnet', + resolvedModel: 'claude-sonnet-5', + displayName: 'Sonnet', + supportsEffort: true, + supportedEffortLevels: EFFORTS + } +] + +/** The user's settings pick Sonnet over the listing's recommended Opus; the CLI applies Sonnet. */ +async function liveListing( + options?: Record, + applied: string | null = 'claude-sonnet-5' +) { + const claude = fakeClaude({ + initProof: 'session-start', + initModel: applied ?? 'claude-opus-5-5', + initModels: CATALOG, + settings: { + effective: {}, + sources: [], + applied: { model: applied, effort: applied ? 'high' : null, advisor: null, ultracode: false } + }, + routes: { list_models: () => CATALOG } + }) + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: options?.model ? { model: options.model } : {}, + cwd: '/work/repo', + claudeConfigDir: ACCOUNT_HOME, + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500, + persistHandle: async () => {} + }) + await adapter.acquire({ + identity: identityFor(SESSION), + fence: 7, + spawnToken: 'spawn-9', + events: recordingJournalSink(), + ...(options ? { options } : {}) + }) + await claudeStartupSettled(adapter, SESSION) + return (await adapter.readOptions({ sessionId: SESSION, fence: 7 })).catalogListing +} + +function record(): AgentSessionRecord { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the catalog reads only these fields. + return { + sessionId: SESSION, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }, + location: { executionHostId: 'local', wslDistro: null, workspaceId: 'ws-1' }, + launchDirectory: '/work/repo' + } as unknown as AgentSessionRecord +} + +function service(store: AgentModelCatalogStore, projectSettings: boolean) { + const launched = record() + return createAgentModelCatalogService({ + store, + getRecord: (sessionId) => (sessionId === SESSION ? launched : undefined), + drivesRecord: () => true, + resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }), + recordWorkspacePath: async (row) => row.launchDirectory ?? null, + workspaceMayOverrideDefaultModel: async ({ workspacePath }) => + projectSettings && workspacePath === '/work/repo' + }) +} + +/** What a new chat's first frame shows from the host's answer alone. */ +async function firstFrame(store: AgentModelCatalogStore, workspacePath: string) { + const answer = await service(store, false).read({ agent: 'claude', workspacePath }) + const seed = getAgentSessionOptionCatalog('claude')! + const state = applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState('claude', seed), + seed, + answer, + { newLaunch: true } + ) + const snapshot = structuredAgentSessionOptionSnapshot(state) + const current = (id: string) => { + const kind = snapshot.find((row) => row.id === id)?.kind + return kind && 'currentValue' in kind ? kind.currentValue : undefined + } + return { answer, model: current('model'), effort: current('effort') } +} + +describe('Claude configured default', () => { + it('names the model and effort the CLI resolves for a chat with nothing picked', async () => { + const store = new AgentModelCatalogStore() + const listing = await liveListing() + // Sonnet, as the user's settings pick it — not the listing's recommended Opus. + expect(listing?.configuredDefault).toEqual({ modelId: 'sonnet', effort: 'high' }) + // The effort travels with the configured model, never as a live row's own default. + expect(listing?.models.some((model) => model.defaultEffort !== undefined)).toBe(false) + service(store, false).recordLiveListing(SESSION, listing!) + + await vi.waitFor(async () => + expect((await firstFrame(store, '/work/other')).answer).toMatchObject({ + listingNamesConfiguredModel: true + }) + ) + const frame = await firstFrame(store, '/work/other') + expect(frame.model).toBe('sonnet') + expect(frame.effort).toBe('high') + }) + + it('keeps nothing from a chat whose workspace has Claude settings of its own', async () => { + const store = new AgentModelCatalogStore() + service(store, true).recordLiveListing(SESSION, (await liveListing())!) + await new Promise((resolve) => setTimeout(resolve, 0)) + + const frame = await firstFrame(store, '/work/other') + expect(frame.answer).toMatchObject({ listingNamesConfiguredModel: false }) + expect(frame.model).toBeUndefined() + // The project's effort stays out of the account's catalog too. + const entry = store.get(agentModelCatalogFingerprintForRecord(record())) + expect(entry?.configured).toBeNull() + expect(entry?.models.some((model) => model.defaultEffort !== undefined)).toBe(false) + }) + + it('forgets a saved default once the config resolves to no listed model, for good', async () => { + const store = new AgentModelCatalogStore() + const fingerprint = agentModelCatalogFingerprintForRecord(record()) + service(store, false).recordLiveListing(SESSION, (await liveListing())!) + await vi.waitFor(() => expect(store.get(fingerprint)?.configured?.modelId).toBe('sonnet')) + + // The user's settings now pick a model the account no longer lists. + const unlisted = await liveListing(undefined, 'claude-retired-1') + expect(unlisted?.configuredDefault).toBeNull() + service(store, false).recordLiveListing(SESSION, unlisted!) + await vi.waitFor(() => expect(store.get(fingerprint)?.configured).toBeNull()) + + // A later background listing keeps it forgotten. + store.recordSuccess( + fingerprint, + 'claude', + { + models: CATALOG.map((row) => ({ + id: row.value, + label: row.displayName, + isDefault: row.value === 'default', + efforts: EFFORTS.map((value) => ({ value, label: value })) + })), + fastModeTierByModel: new Map(), + origin: 'probe' + }, + 'discovery' + ) + const frame = await firstFrame(store, '/work/other') + expect(store.get(fingerprint)?.configured).toBeNull() + expect(frame.answer).toMatchObject({ listingNamesConfiguredModel: false }) + expect(frame.model).toBeUndefined() + }) + + it('forgets a saved default when the config resolves to no model at all', async () => { + const listing = await liveListing(undefined, null) + expect(listing?.configuredDefault).toBeNull() + }) + + it('learns nothing from a chat launched with a model pick', async () => { + const listing = await liveListing({ model: 'sonnet' }) + expect(listing).toBeDefined() + expect(listing?.configuredDefault).toBeUndefined() + }) + + it('names nothing in a workspace whose own Claude settings could pick another model', async () => { + const store = new AgentModelCatalogStore() + service(store, false).recordLiveListing(SESSION, (await liveListing())!) + await vi.waitFor(() => + expect(store.get(agentModelCatalogFingerprintForRecord(record()))?.configured).not.toBeNull() + ) + + const answer = await createAgentModelCatalogService({ + store, + getRecord: () => undefined, + drivesRecord: () => true, + resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }), + workspaceMayOverrideDefaultModel: async () => true + }).read({ agent: 'claude', workspacePath: '/work/repo' }) + expect(answer).toMatchObject({ listingNamesConfiguredModel: false }) + }) +}) diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index e0342094db65..73b871ae2c9c 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -1,5 +1,5 @@ -// A Claude chat at rest shows the effort its next start will run: a live child teaches the host -// catalog what the CLI runs for each model when no effort is sent, and the resting read answers it. +// A Claude chat at rest shows the effort its next start will run: a live child with nothing picked +// teaches the host catalog the configured model and effort, and the resting read answers it. import { describe, expect, it } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' @@ -111,8 +111,7 @@ async function startChild( openConnection: settingsWithNoOverride(cli.runs, cli.lists).openConnection, readProcessStartTime: async () => 1_700_000_000_000, now: () => 1_700_000_000_500, - persistHandle: async () => {}, - modelCatalog: store + persistHandle: async () => {} }) await adapter.acquire({ identity: identityFor(SESSION), @@ -122,6 +121,13 @@ async function startChild( ...(options ? { options } : {}) }) await claudeStartupSettled(adapter, SESSION) + // As the host does with every live options read: what the child listed becomes the account's. + const { catalogListing } = await adapter.readOptions({ sessionId: SESSION, fence: 7 }) + if (catalogListing) { + catalogService(store, restingRecord({})).recordLiveListing(SESSION, catalogListing) + // The configured default lands after the workspace check. + await new Promise((resolve) => setTimeout(resolve, 0)) + } return adapter } @@ -140,14 +146,21 @@ function catalogDefault(store: AgentModelCatalogStore, model: string): string | return entry?.models.find((row) => row.id === model)?.defaultEffort } -/** The options a client reads for the chat once its child is gone. */ -function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) { - const modelCatalog = createAgentModelCatalogService({ +function catalogService(store: AgentModelCatalogStore, record: AgentSessionRecord) { + return createAgentModelCatalogService({ store, getRecord: () => record, drivesRecord: () => true, - resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }) + resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }), + // The chat ran in a workspace with no Claude settings of its own: its config is the account's. + recordWorkspacePath: async () => '/work/repo', + workspaceMayOverrideDefaultModel: async () => false }) +} + +/** The options a client reads for the chat once its child is gone. */ +function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) { + const modelCatalog = catalogService(store, record) const resting = { child: null, params: { provider: 'claude' }, @@ -262,10 +275,15 @@ describe('Claude effort default at rest', () => { ], fastModeTierByModel: new Map() } - store.recordSuccess(agentModelCatalogFingerprintForRecord(record), 'codex', { - ...listing, - origin: 'live-session' - }) + store.recordSuccess( + agentModelCatalogFingerprintForRecord(record), + 'codex', + { + ...listing, + origin: 'live-session' + }, + 'discovery' + ) const resting = await readAtRest(store, record) const live = composeCodexSessionOptionCatalog(listing, { @@ -340,22 +358,27 @@ describe('Claude effort default at rest', () => { location: { wslDistro: null }, options: { model: 'gpt-5.5' } } as unknown as AgentSessionRecord - store.recordSuccess(agentModelCatalogFingerprintForRecord(record), 'codex', { - models: [ - { - id: 'gpt-5.5', - label: 'GPT-5.5', - isDefault: true, - defaultEffort: 'medium', - efforts: [ - { value: 'medium', label: 'Medium' }, - { value: 'high', label: 'High' } - ] - } - ], - fastModeTierByModel: new Map(), - origin: 'live-session' - }) + store.recordSuccess( + agentModelCatalogFingerprintForRecord(record), + 'codex', + { + models: [ + { + id: 'gpt-5.5', + label: 'GPT-5.5', + isDefault: true, + defaultEffort: 'medium', + efforts: [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + } + ], + fastModeTierByModel: new Map(), + origin: 'live-session' + }, + 'discovery' + ) const result = await readAtRest(store, record) diff --git a/src/main/claude/claude-structured-retired-model.test.ts b/src/main/claude/claude-structured-retired-model.test.ts index 86397cc59f61..a2ede54c961d 100644 --- a/src/main/claude/claude-structured-retired-model.test.ts +++ b/src/main/claude/claude-structured-retired-model.test.ts @@ -1,12 +1,9 @@ import { describe, expect, it } from 'vitest' -import { claudeAcquireCatalogAccess } from './claude-structured-acquire-catalog' -import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' import { - claudeCatalogRowsOfAccount, - retireClaudeLaunchedModel -} from './claude-structured-retired-model' + AgentModelCatalogStore, + type AgentModelCatalogLiveListing +} from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { retireClaudeLaunchedModel } from './claude-structured-retired-model' import { adapterFor, claudeFrame, @@ -18,15 +15,6 @@ import { recordingJournalSink } from './claude-structured-session-test-support' -const ACCOUNT_HOME = '/accounts/claude' - -function catalogAccess(store: AgentModelCatalogStore) { - const access = claudeAcquireCatalogAccess(store, ACCOUNT_HOME) - if (!access) { - throw new Error('a store and an account home always give access') - } - return access -} const RETIRED = 'claude-retired-1' /** The rows Claude Code 2.1.280 lists for a child launched with `--model claude-bogus-9`: its own * native rows, then one for the launched id, named by that id. */ @@ -79,76 +67,64 @@ describe("the CLI's word that a launched model does not exist", () => { }) }) -describe("the account catalog a child's listing writes through", () => { - const rows = [ - { id: 'opus', label: 'Opus' }, - { id: RETIRED, label: RETIRED } - ] +describe("the account catalog facts a child's listing hands the host", () => { + async function listingOfChildLaunchedWith(model: string) { + const claude = fakeClaude({ + initModels: [NATIVE_OPUS, LAUNCHED_ROW], + routes: { list_models: () => [NATIVE_OPUS, LAUNCHED_ROW] } + }) + const adapter = adapterFor(claude) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: recordingJournalSink(), + options: { model } + }) + await claudeStartupSettled(adapter, 'session-1') + const { catalogListing } = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + await adapter.closeAll() + return catalogListing + } - function access(cached: string[] | null) { + function savedIds(cached: string[] | null, listing: AgentModelCatalogLiveListing) { const store = new AgentModelCatalogStore() - const handle = catalogAccess(store) if (cached) { - store.recordSuccess(handle.fingerprint, 'claude', { - models: cached.map((id) => ({ id, label: id, isDefault: false, efforts: [] })), - fastModeTierByModel: new Map(), - origin: 'probe' - }) + store.recordSuccess( + 'fp', + 'claude', + { + models: cached.map((id) => ({ id, label: id, isDefault: false, efforts: [] })), + fastModeTierByModel: new Map(), + origin: 'probe' + }, + 'discovery' + ) } - return handle + store.recordSuccess( + 'fp', + 'claude', + { ...listing, fastModeTierByModel: new Map(), origin: 'live-session' }, + 'live' + ) + return store.get('fp')?.models.map((model) => model.id) } - it('leaves out the row the launch added for its own model', () => { - expect(claudeCatalogRowsOfAccount(access(null), rows, RETIRED)).toEqual([rows[0]]) + it('marks the row the launch added for its own model, which the account catalog leaves out', async () => { + const listing = await listingOfChildLaunchedWith(RETIRED) + expect(listing?.launchOnlyModelId).toBe(RETIRED) + expect(savedIds(null, listing!)).toEqual(['opus']) }) - it('keeps that row when the account already listed the model', () => { - expect(claudeCatalogRowsOfAccount(access([RETIRED]), rows, RETIRED)).toEqual(rows) + it('keeps that row when the account already listed the model', async () => { + const listing = await listingOfChildLaunchedWith(RETIRED) + expect(savedIds([RETIRED], listing!)).toEqual(['opus', RETIRED]) }) - it('keeps a launched native model, which carries its own display name', () => { - expect(claudeCatalogRowsOfAccount(access(null), rows, 'opus')).toEqual(rows) - }) - - it('keeps every row for a launch that named no model', () => { - expect(claudeCatalogRowsOfAccount(access(null), rows, null)).toEqual(rows) - }) - - it('never writes a launched-only model into the account catalog at start', async () => { - const store = new AgentModelCatalogStore() - const handle = catalogAccess(store) - const claude = fakeClaude({ initModels: [NATIVE_OPUS, LAUNCHED_ROW] }) - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = new ClaudeStructuredSessionAdapter({ - resolveLaunch: async () => ({ - pathToClaudeCodeExecutable: 'claude', - options: {}, - cwd: '/work/repo', - claudeConfigDir: ACCOUNT_HOME, - providerSessionId: PROVIDER_SESSION_ID, - resumeLeafUuid: null, - resumesTranscript: true, - continuesChain: true - }), - onEvent: (event) => events.push(event), - openConnection: claude.openConnection, - readProcessStartTime: async () => 1_700_000_000_000, - now: () => 1_700_000_000_500, - persistHandle: async () => {}, - modelCatalog: store - }) - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - events: recordingJournalSink(), - options: { model: RETIRED } - }) - await claudeStartupSettled(adapter, 'session-1') - - expect(events.some((event) => event.type === 'started')).toBe(true) - expect(store.get(handle.fingerprint)?.models.map((model) => model.id)).toEqual(['opus']) - await adapter.closeAll() + it('marks nothing for a launched native model, which carries its own display name', async () => { + const listing = await listingOfChildLaunchedWith('opus') + expect(listing?.launchOnlyModelId).toBeUndefined() + expect(savedIds(null, listing!)).toEqual(['opus', RETIRED]) }) }) diff --git a/src/main/claude/claude-structured-retired-model.ts b/src/main/claude/claude-structured-retired-model.ts index 5c25ad461811..b6433d7a060a 100644 --- a/src/main/claude/claude-structured-retired-model.ts +++ b/src/main/claude/claude-structured-retired-model.ts @@ -2,7 +2,6 @@ // retired fails the turn. The CLI says so itself: a synthetic reply whose `error` is // `model_not_found` (Claude Code 2.1.280). That reply is the evidence the saved model cannot run. -import type { AgentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import type { ClaudeSession, ClaudeStructuredSessionAdapterDeps @@ -71,20 +70,3 @@ export function claudeRetiredOptions( ? { retiredOptions: { model: session.launchedModel } } : {} } - -/** The rows of a listing the account's catalog keeps. A child launched with `--model X` lists X - * itself (Claude Code 2.1.280 adds a row named by the raw id, "Custom model"), whether or not X - * exists: that row is the launch talking, not the account, unless the account already listed X. - * Native rows carry a display name of their own, so a row named by its id is the tell. */ -export function claudeCatalogRowsOfAccount( - access: AgentModelCatalogSessionAccess, - rows: readonly T[], - launchedModel: string | null -): T[] { - const listedBefore = access.store - .get(access.fingerprint) - ?.models.some((model) => model.id === launchedModel) - return rows.filter( - (row) => listedBefore === true || row.id !== launchedModel || row.label !== row.id - ) -} diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 2290ba53e965..83fc04ba2fb5 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -41,7 +41,6 @@ import { readClaudeTranscriptEntryUuid } from './claude-transcript-entry-uuid' import { persistClaudeTurnResumePoint } from './claude-structured-resume-point' import { withAgentSessionCreatePhase } from '../observability/agent-session-instrumentation' import { resolveClaudeAcquisitionLaunch } from './claude-structured-acquisition-launch' -import { claudeAcquireCatalogAccess } from './claude-structured-acquire-catalog' import { bindClaudeConnectionJournalControls, createClaudeSessionJournalTranslator @@ -260,10 +259,6 @@ export async function acquireClaudeSession({ const session = publication.session liveSession = session adoptClaudeStructuredSpawnOptions(session, launch.savedOptions) - const catalogAccess = claudeAcquireCatalogAccess(deps.modelCatalog, launch.claudeConfigDir) - if (catalogAccess) { - session.catalogAccess = catalogAccess - } acquisitions.deleteIfCurrent(sessionId, attempt) await withAgentSessionCreatePhase('publish', input.recordPhase, async () => { sessions.set(sessionId, session) diff --git a/src/main/claude/claude-structured-session-adapter-turns.test.ts b/src/main/claude/claude-structured-session-adapter-turns.test.ts index 3d0512d8b7cc..413d2db4fb67 100644 --- a/src/main/claude/claude-structured-session-adapter-turns.test.ts +++ b/src/main/claude/claude-structured-session-adapter-turns.test.ts @@ -239,7 +239,13 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { fence: 7 }) - await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + const { catalogListing, ...options } = await adapter.readOptions({ + sessionId: 'session-1', + fence: 7 + }) + // The listing the host saves for the account: the CLI's rows, none of this session's state. + expect(catalogListing?.models.map((model) => model.id)).toEqual(['opus', 'sonnet']) + expect(options).toEqual({ models: [ { id: 'opus', diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index 1718a10e4478..37a98944d3cb 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -1,8 +1,4 @@ -import type { - AgentSessionFastModeState, - AgentSessionFastModeSupport, - AgentSessionOptionsResult -} from '../../shared/agent-session-wire' +import type { AgentSessionFastModeState } from '../../shared/agent-session-wire' import { currentModelId, listedModels, @@ -14,7 +10,13 @@ import { } from './claude-structured-model-catalog' import type { ClaudeSession } from './claude-structured-session-state' import { structuredAgentSessionOptionModels } from '../native-chat/agent-session-wire/structured-agent-session-option-models' -import { claudeCatalogRowsOfAccount } from './claude-structured-retired-model' +import { + claudeCatalogListing, + claudeFastModeSupport, + wireClaudeModels, + type WireClaudeModel +} from './claude-structured-catalog-listing' +import type { StructuredAgentSessionLiveOptions } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' /** @@ -172,27 +174,6 @@ export function claudeModelFastModeSupport( } } -const TRANSIENT_FAST_MODE_REASONS = new Set(['network_error', 'unknown', 'pending']) -const NON_BLOCKING_FAST_MODE_REASONS = new Set(['preference', 'sdk_opt_in_required']) - -function claudeFastModeSupport( - models: readonly ListedModel[], - disabledReason: string | undefined -): AgentSessionFastModeSupport | undefined { - if (disabledReason && TRANSIENT_FAST_MODE_REASONS.has(disabledReason)) { - return undefined - } - if (disabledReason && !NON_BLOCKING_FAST_MODE_REASONS.has(disabledReason)) { - return { supported: false, reason: disabledReason } - } - if (!models.some((model) => model.supportsFastMode === true)) { - return models.length > 0 && models.every((model) => model.supportsFastMode === false) - ? { supported: false, reason: 'model-not-supported' } - : undefined - } - return { supported: true } -} - function listedModelFastModeSupport( models: readonly ListedModel[], modelId: string @@ -225,66 +206,16 @@ export function claudeCatalogAdmitsModel(models: readonly ListedModel[], modelId ) } -type WireClaudeModel = AgentSessionOptionsResult['models'][number] - -function wireClaudeModel(entry: ListedModel): WireClaudeModel { - return { - id: entry.id, - label: entry.label, - ...(entry.description ? { description: entry.description } : {}), - isDefault: entry.isDefault, - efforts: entry.efforts, - ...(entry.supportsFastMode !== undefined ? { supportsFastMode: entry.supportsFastMode } : {}) - } -} - -function wireClaudeModels(models: readonly ListedModel[]): WireClaudeModel[] { - return models.map(wireClaudeModel) -} - /** The built-in models a running child lists when the CLI gives it none; a chat at rest with no * catalog lists the same. */ export function claudeFallbackModelOptions(): WireClaudeModel[] { return wireClaudeModels(seedModels()) } -/** The listing, with what the CLI runs when no effort is sent on each model the child applies — - * a default only a running child knows, and only while this session has no effort pick. */ -function catalogClaudeModels(session: ClaudeSession, discovered: ListedModel[]): WireClaudeModel[] { - const applied = session.options.has('effort') ? undefined : session.appliedOptions - return discovered.map((listed) => { - const model = wireClaudeModel(listed) - const effort = applied?.effort - const runsApplied = - applied?.model !== undefined && - (listed.id === applied.model || listed.resolvedModel === applied.model) - return effort && runsApplied && model.efforts.some((choice) => choice.value === effort) - ? { ...model, defaultEffort: effort } - : model - }) -} - -/** Write a provider-listed catalog through to the host store. Account-level - * facts only: this session's disabled reason and its unlisted current model - * stay out, so another surface never inherits session state as a catalog. */ -function writeClaudeCatalogThrough(session: ClaudeSession, discovered: ListedModel[]): void { - if (discovered.length === 0 || !session.catalogAccess) { - return - } - const rows = claudeCatalogRowsOfAccount(session.catalogAccess, discovered, session.launchedModel) - const support = claudeFastModeSupport(rows, undefined) - session.catalogAccess.store.recordSuccess(session.catalogAccess.fingerprint, 'claude', { - models: catalogClaudeModels(session, rows), - ...(support ? { fastModeSupport: support } : {}), - fastModeTierByModel: new Map(), - origin: 'live-session' - }) -} - export async function readClaudeStructuredSessionOptions( session: ClaudeSession, timeoutMs: number | undefined -): Promise { +): Promise { const readMutationSequence = session.optionMutationSequence // Before startup both requests would wait on initialize; answer from the saved options. const [catalog, settings] = @@ -332,9 +263,9 @@ export function claudeStructuredSessionOptionsFrom( session: ClaudeSession, catalog: unknown[] | null, readMutationSequence = session.optionMutationSequence -): AgentSessionOptionsResult { +): StructuredAgentSessionLiveOptions { const discovered = listedModels(catalog ? { models: catalog } : null) - writeClaudeCatalogThrough(session, discovered) + const catalogListing = claudeCatalogListing(session, discovered) const listed = discovered.length > 0 ? discovered : seedModels() const current = readClaudeCurrentModel(session) const model = currentModelId(listed, current.id) @@ -384,6 +315,7 @@ export function claudeStructuredSessionOptionsFrom( ...(fastMode !== undefined ? { fastMode } : {}), ...(session.fastModeState ? { fastModeState: session.fastModeState } : {}), ...(confirmed.length > 0 ? { confirmed } : {}) - } + }, + ...(catalogListing ? { catalogListing } : {}) } } diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index 0c01bcd51f0f..7eece9a4ed6d 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -120,6 +120,8 @@ describe('Claude structured session publishes before the CLI answers initialize' // What the child was launched with, carried so the host never asks the CLI again. reportedOptions: expect.objectContaining({ model: 'opus' }), restoreSkippedOptions: [], + // The account listing initialize answered, for the host's catalog. + catalogListing: { models: [expect.objectContaining({ id: 'claude-sonnet' })] }, // No host revision handed in: nothing has moved it. optionRevision: 0 }) diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index a40d22c25c00..024746715d7d 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -46,7 +46,11 @@ import { export type StructuredAgentSessionStartedOptions = Pick< StructuredAgentSessionStartedEvent, - 'reportedOptions' | 'restoreSkippedOptions' | 'retiredOptions' | 'optionRevision' + | 'reportedOptions' + | 'restoreSkippedOptions' + | 'retiredOptions' + | 'optionRevision' + | 'catalogListing' > /** A lifecycle event the start reports, before the session's identity is stamped on it. */ @@ -170,19 +174,25 @@ function applyClaudeSettingsFacts( session.events?.publish() } -/** What the start persists as the session's options. The applied effort is display-only: saved, - * it would pin an effort nobody chose on every reopen, past a later settings change. */ +/** What the start persists as the session's options, and the account listing it read for the + * host's catalog. The applied effort is display-only: saved, it would pin an effort nobody chose + * on every reopen, past a later settings change. */ function claudeStartedReportedOptions( session: ClaudeSession, catalog: unknown[], readMutationSequence = session.optionMutationSequence -): StructuredAgentSessionStartedOptions['reportedOptions'] { - const { current } = claudeStructuredSessionOptionsFrom(session, catalog, readMutationSequence) +): Pick { + const { current, catalogListing } = claudeStructuredSessionOptionsFrom( + session, + catalog, + readMutationSequence + ) + const listing = catalogListing ? { catalogListing } : {} if (session.options.has('effort') || session.reportedOptions.effort !== undefined) { - return current + return { reportedOptions: current, ...listing } } const { effort: _displayOnly, ...persisted } = current - return persisted + return { reportedOptions: persisted, ...listing } } /** Applies the initialize answer and reports `started`, so the host hands the child what it holds; @@ -229,7 +239,7 @@ export async function settleClaudeSessionStartup(input: { // `list_models` is answered from this same initialize result, so nothing is re-read. A // saved Fast the launch left out is decided once settings are read, so this read settles // none of it (an older sequence never rewrites an option). - reportedOptions: claudeStartedReportedOptions( + ...claudeStartedReportedOptions( session, readClaudeModels(initialized.initialization), session.fastModeAtStart ? session.optionMutationSequence - 1 : undefined @@ -288,7 +298,9 @@ async function settleClaudeStartupSettings( const startFastMode = writtenSinceRead ? null : admitClaudeStartFastMode(session, facts) input.report({ type: 'options-reported', - reportedOptions: claudeStartedReportedOptions(session, readClaudeModels(facts.initialization)), + // `started` already carried this listing to the host. + reportedOptions: claudeStartedReportedOptions(session, readClaudeModels(facts.initialization)) + .reportedOptions, restoreSkippedOptions: [...session.restoreSkippedOptions], ...claudeRetiredOptions(session), optionRevision diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 61ad3a645511..b1ccc81b9d5f 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -20,10 +20,6 @@ import type { ClaudeJournalTranslator } from './claude-journal-translator-contra import type { ClaudePendingPrompt, ClaudePromptRegistry } from './claude-structured-prompt-replies' import { cancelProcessAcquisition } from '../../shared/child-process/cancel-process-acquisition' import { randomUUID } from 'node:crypto' -import type { - AgentModelCatalogSessionAccess, - AgentModelCatalogStore -} from '../native-chat/agent-model-catalog/agent-model-catalog-store' import type { AgentSessionFastModeState } from '../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import type { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' @@ -133,8 +129,6 @@ export type ClaudeStructuredSessionAdapterDeps = { leafUuid: string fence: number }) => Promise - /** Host model catalog; sessions write their listings through. */ - modelCatalog?: AgentModelCatalogStore } export type ClaudeDispatchWaiter = { @@ -191,8 +185,6 @@ export type ClaudeSession = { launchedModel: string | null /** The launch left the saved Fast out, so the start applies it once the settings are read. */ fastModeAtStart: boolean - /** Absent when the adapter runs without a host catalog store (tests). */ - catalogAccess?: AgentModelCatalogSessionAccess /** CLI-advertised protocol capabilities from init; gates interrupt-receipt handling. */ capabilities: readonly string[] backgroundTasks: ClaudeBackgroundTaskTracker diff --git a/src/main/codex-accounts/runtime-home-catalog-account-key.test.ts b/src/main/codex-accounts/runtime-home-catalog-account-key.test.ts new file mode 100644 index 000000000000..ac6998ad71dc --- /dev/null +++ b/src/main/codex-accounts/runtime-home-catalog-account-key.test.ts @@ -0,0 +1,129 @@ +// A new Codex chat's picker reads the catalog under the account key the record-less resolver +// answers; the chat it launches saves under the key launch preparation pins. Both run for real +// here, wired as the runtime service wires them, so a drift between the two fails this test. + +import type * as NodeOs from 'node:os' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionAccountHome } from '../../shared/agent-session-account-home' +import { createSettings } from './runtime-home-settings-test-fixtures' +import { + createCodexAccountRecord, + createCodexAuthJson, + createManagedAuth, + createStore, + getRuntimeCodexHomePath, + getSystemCodexHomePath, + setupRuntimeHomeTest, + teardownRuntimeHomeTest, + testState +} from './runtime-home-service-test-harness' + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.userDataDir + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +// Hook install and session bridges are launch chores outside the key; keep them off disk. +vi.mock('../codex/hook-service', () => ({ + codexHookService: { prepareRuntimeHomeForLaunch: async () => ({ state: 'installed' }) } +})) +vi.mock('../codex/codex-hook-reconcile', () => ({ reconcileCodexHooksForLaunch: async () => {} })) +vi.mock('../codex/codex-session-bridge', () => ({ + startSystemCodexSessionBridgeInBackground: async () => {} +})) +vi.mock('../codex/codex-account-session-bridge', () => ({ + startCodexAccountSessionBridgeInBackground: async () => {} +})) + +/** The production wiring: the host's one runtime home service behind both resolvers. */ +async function wireCodexAccountKey(settings: ReturnType) { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const { mainProcessState } = await import('../startup/main-process-state') + const { codexStructuredLaunchHomeResolvers } = await import('../startup/codex-launch-preparation') + const { structuredAgentRuntimeRegistration } = + await import('../runtime/structured-agent-runtime-registrations') + const { agentModelCatalogFingerprint } = + await import('../native-chat/agent-model-catalog/agent-model-catalog-fingerprint') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the harness store implements the settings read/update surface the service uses. + const runtimeHome = new CodexRuntimeHomeService(createStore(settings) as never) + mainProcessState.codexRuntimeHome = runtimeHome + const codex = structuredAgentRuntimeRegistration('codex')! + const services = { + getClaudeConfigDirectory: () => null, + // The resolvers main-process-runtime-service hands the runtime. + prepareCodexLaunchHome: codexStructuredLaunchHomeResolvers.prepareCodexStructuredLaunch, + readCodexLaunchHome: codexStructuredLaunchHomeResolvers.resolveCodexStructuredLaunchHome, + workspaceTrustSettings: () => settings + } + const resolve = (purpose: 'launch' | 'read') => + codex.resolveAccountHome( + { launchEnv: {}, location: null, purpose, workspacePath: null }, + services + ) + const key = (accountHome: AgentSessionAccountHome) => + agentModelCatalogFingerprint({ agent: 'codex', accountHome, wslDistro: null }) + return { resolve, key, release: () => (mainProcessState.codexRuntimeHome = null) } +} + +describe('Codex catalog account key: picker read vs launch', () => { + let release: (() => void) | null = null + + beforeEach(() => { + setupRuntimeHomeTest() + }) + + afterEach(() => { + release?.() + release = null + teardownRuntimeHomeTest() + }) + + async function expectOneKey(settings: ReturnType, expectedPath: string) { + const wired = await wireCodexAccountKey(settings) + release = wired.release + const before = await wired.resolve('read') + const launched = await wired.resolve('launch') + // Launch preparation syncs homes; the next picker read must still land on the same key. + const after = await wired.resolve('read') + + expect(launched).toMatchObject({ variable: 'CODEX_HOME', path: expectedPath }) + expect(wired.key(before)).toBe(wired.key(launched)) + expect(wired.key(after)).toBe(wired.key(launched)) + } + + it('system account on the real ~/.codex', async () => { + await expectOneKey(createSettings({ realHomeRoutable: true }), getSystemCodexHomePath()) + }) + + it("system account on Orca's shared runtime home", async () => { + await expectOneKey(createSettings({ realHomeRoutable: false }), getRuntimeCodexHomePath()) + }) + + it('a managed account in its own home', async () => { + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('user@example.com', 'acct-1', 'refresh-1') + ) + await expectOneKey( + createSettings({ + realHomeRoutable: true, + codexManagedAccounts: [ + createCodexAccountRecord('account-1', 'user@example.com', 'acct-1', managedHomePath) + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }), + managedHomePath + ) + }) +}) diff --git a/src/main/codex/codex-app-server-session.ts b/src/main/codex/codex-app-server-session.ts index 122d95c16f54..c63873c197f0 100644 --- a/src/main/codex/codex-app-server-session.ts +++ b/src/main/codex/codex-app-server-session.ts @@ -40,6 +40,8 @@ export type CodexAppServerInvocation = { envToDelete?: readonly string[] /** Whole-session deadline. The codex child is stopped when it lapses. */ timeoutMs: number + /** Stops the session and its child early, as the deadline would. */ + signal?: AbortSignal } /** Codex-side absence of the requested app-server RPC surface (old CLI without @@ -194,18 +196,30 @@ export async function runCodexAppServerSession( const deadlinePromise = new Promise((_resolve, reject) => { rejectDeadline = reject }) - const deadline = setTimeout(() => { + function endSession(error: Error): void { timedOut = true - const error = new CodexAppServerTimeoutError( - `codex app-server session exceeded ${invocation.timeoutMs}ms (${invocation.command})` - ) // A supervised child is stopped by the finally below, which this rejection runs at once. if (!managed.supervised) { killCodexAppServerProcessTree(child) } failPending(error) rejectDeadline(error) - }, invocation.timeoutMs) + } + const deadline = setTimeout( + () => + endSession( + new CodexAppServerTimeoutError( + `codex app-server session exceeded ${invocation.timeoutMs}ms (${invocation.command})` + ) + ), + invocation.timeoutMs + ) + const onAbort = (): void => endSession(new Error('codex app-server session stopped')) + if (invocation.signal?.aborted) { + onAbort() + } else { + invocation.signal?.addEventListener('abort', onAbort, { once: true }) + } function sendLine(payload: Record): void { child.stdin.write(`${JSON.stringify(payload)}\n`) @@ -333,5 +347,6 @@ export async function runCodexAppServerSession( // Ends stdin, gives a supervisor its full stop time (a direct child 1.5 s), then the tree. await managed.close() clearTimeout(deadline) + invocation.signal?.removeEventListener('abort', onAbort) } } diff --git a/src/main/codex/codex-model-catalog-probe.test.ts b/src/main/codex/codex-model-catalog-probe.test.ts index 58bb8fd27d24..0e69991f75bb 100644 --- a/src/main/codex/codex-model-catalog-probe.test.ts +++ b/src/main/codex/codex-model-catalog-probe.test.ts @@ -47,7 +47,7 @@ describe('codex model catalog probe', () => { }) } }) - const success = await probe('/homes/account-a') + const success = await probe({ variable: 'CODEX_HOME', path: '/homes/account-a' }) expect(success.origin).toBe('probe') expect(success.models.map((model) => model.id)).toEqual(['gpt-live']) // The session launch resolves the exact same invocation for the same deps. @@ -97,7 +97,7 @@ describe('codex model catalog probe', () => { body ) }) - const success = await probe('/homes/a') + const success = await probe({ variable: 'CODEX_HOME', path: '/homes/a' }) expect(success.models.map((model) => ({ id: model.id, isDefault: model.isDefault }))).toEqual([ { id: 'gpt-live', isDefault: true } ]) @@ -110,7 +110,9 @@ describe('codex model catalog probe', () => { runSession: async (_invocation, body) => body({ request: async () => ({ data: [], nextCursor: null }), notify: () => {} }) }) - await expect(probe('/homes/a')).rejects.toThrow(/listed no models/) + await expect(probe({ variable: 'CODEX_HOME', path: '/homes/a' })).rejects.toThrow( + /listed no models/ + ) }) it.each(['npx codex', 'codex --profile work', '/missing/codex', './codex'])( @@ -127,7 +129,9 @@ describe('codex model catalog probe', () => { ), runSession }) - await expect(probe('/homes/a')).rejects.toMatchObject({ reason: 'agentCommandNotRunnable' }) + await expect(probe({ variable: 'CODEX_HOME', path: '/homes/a' })).rejects.toMatchObject({ + reason: 'agentCommandNotRunnable' + }) expect(runSession).not.toHaveBeenCalled() } ) @@ -168,7 +172,9 @@ describe('Codex catalog availability', () => { new Error('unsupported method'), new Error('timeout') ])('unknown or authenticated account does not block: %j', async (account) => { - expect((await withAccount(account)('/homes/a')).models).toHaveLength(1) + expect( + (await withAccount(account)({ variable: 'CODEX_HOME', path: '/homes/a' })).models + ).toHaveLength(1) }) it.each([ ['/homes/a', 'managed'], @@ -177,7 +183,12 @@ describe('Codex catalog availability', () => { 'reports explicit signed-out account for %s beside its list', async (home, account) => { // The verdict can be wrong while a chat works, so the picker keeps the list it got. - expect(await withAccount({ account: null, requiresOpenaiAuth: true })(home)).toMatchObject({ + expect( + await withAccount({ account: null, requiresOpenaiAuth: true })({ + variable: 'CODEX_HOME', + path: home + }) + ).toMatchObject({ models: [{ id: 'gpt-live' }], unavailable: { reason: 'notSignedIn', account } }) @@ -196,7 +207,9 @@ describe('Codex catalog availability', () => { notify: () => {} }) }) - expect((await probe('/custom/home')).unavailable).toEqual({ reason: 'notSignedIn' }) + expect((await probe({ variable: 'CODEX_HOME', path: '/custom/home' })).unavailable).toEqual({ + reason: 'notSignedIn' + }) }) it('lists models while the account check is still answering', async () => { let answerAccount!: (value: unknown) => void @@ -216,7 +229,7 @@ describe('Codex catalog availability', () => { notify: () => {} }) }) - const pending = probe('/homes/a') + const pending = probe({ variable: 'CODEX_HOME', path: '/homes/a' }) await vi.waitFor(() => expect(methods).toContain('model/list')) expect(methods).toContain('account/read') answerAccount({ account: { type: 'chatgpt' }, requiresOpenaiAuth: true }) @@ -237,7 +250,7 @@ describe('Codex catalog availability', () => { notify: () => {} }) }) - await expect(probe('/homes/a')).rejects.toMatchObject({ + await expect(probe({ variable: 'CODEX_HOME', path: '/homes/a' })).rejects.toMatchObject({ unavailable: { reason: 'notSignedIn' } }) }) @@ -249,7 +262,9 @@ describe('Codex catalog availability', () => { throw Object.assign(new Error('spawn failed'), { code, path: 'codex' }) } }) - const failure = await probe('/homes/a').catch((error: unknown) => error) + const failure = await probe({ variable: 'CODEX_HOME', path: '/homes/a' }).catch( + (error: unknown) => error + ) expect(failure instanceof AgentModelCatalogUnavailableError).toBe(code === 'ENOENT') }) }) @@ -272,7 +287,7 @@ describe('Codex catalog probe reads the home a launch would', () => { }) } }) - expect((await probe('/homes/mirror')).models).toHaveLength(1) + expect((await probe({ variable: 'CODEX_HOME', path: '/homes/mirror' })).models).toHaveLength(1) expect(order).toEqual(['prepare /homes/mirror', 'spawn']) }) }) diff --git a/src/main/codex/codex-model-catalog-probe.ts b/src/main/codex/codex-model-catalog-probe.ts index 6f065d184f90..5dc42007a93a 100644 --- a/src/main/codex/codex-model-catalog-probe.ts +++ b/src/main/codex/codex-model-catalog-probe.ts @@ -8,15 +8,13 @@ import { resolveCodexStructuredInvocation, type CodexStructuredLaunchResolverDeps } from './codex-structured-launch-resolution' +import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' +import { AGENT_MODEL_CATALOG_PROBE_TIMEOUT_MS } from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner' import type { AgentModelCatalogProbe, AgentModelCatalogSuccess } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -// Why 15s: the whole probe session is stopped at the deadline (on POSIX its supervisor then gets -// up to PROVIDER_SUPERVISOR_MAX_STOP_MS), and a cold `model/list` may pay one /models fetch. -const CODEX_MODEL_CATALOG_PROBE_TIMEOUT_MS = 15_000 - export type CodexModelCatalogProbeDeps = Pick< CodexStructuredLaunchResolverDeps, 'resolveCommand' | 'resolveEnvironment' @@ -39,15 +37,17 @@ function definedEnv(env: NodeJS.ProcessEnv | undefined): Record } /** - * Lists models without a live session: one short-lived read-only app-server - * under the given account home, spawned through the SAME invocation resolver - * a structured session launch uses — a probe that resolved a different binary - * or env could list models the user's sessions cannot see, under their key. + * Lists models without a live session: one short-lived read-only app-server under the given + * account home and the shared probe budget, spawned through the SAME invocation resolver a + * structured session launch uses — a probe that resolved a different binary or env could list + * models the user's sessions cannot see, under their key. Its supervised session runner is the + * one every Codex app-server RPC consumer shares. */ export function createCodexModelCatalogProbe( deps: CodexModelCatalogProbeDeps ): AgentModelCatalogProbe { - return async (accountHomePath: string): Promise => { + return async (accountHome, options): Promise => { + const accountHomePath = requireLegacyAgentSessionAccountHome(accountHome).path const { command, environment } = await resolveCodexStructuredInvocation(deps) deps.prepareHome?.(accountHomePath) const run = deps.runSession ?? runCodexAppServerSession @@ -57,7 +57,8 @@ export function createCodexModelCatalogProbe( args: [...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS], cliPath: command, env: { ...definedEnv(environment), CODEX_HOME: accountHomePath }, - timeoutMs: CODEX_MODEL_CATALOG_PROBE_TIMEOUT_MS + timeoutMs: AGENT_MODEL_CATALOG_PROBE_TIMEOUT_MS, + ...(options?.signal ? { signal: options.signal } : {}) }, async (rpc) => { // Both at once, so the account check adds no latency. diff --git a/src/main/codex/codex-short-lived-app-server-spawn.test.ts b/src/main/codex/codex-short-lived-app-server-spawn.test.ts index fdd3733225d4..c69f8c6f5abe 100644 --- a/src/main/codex/codex-short-lived-app-server-spawn.test.ts +++ b/src/main/codex/codex-short-lived-app-server-spawn.test.ts @@ -56,7 +56,7 @@ describe('short-lived Codex app-server invocations on Windows', () => { }) } }) - await probe('C:\\homes\\a') + await probe({ variable: 'CODEX_HOME', path: 'C:\\homes\\a' }) expect(invocations[0]?.command).toBe(NPM_CODEX_SHIM) expect(invocations[0]?.args).toEqual([...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS]) }) diff --git a/src/main/codex/codex-structured-background-catalog.ts b/src/main/codex/codex-structured-background-catalog.ts index ed4eda7aa11d..5e6dc2f62c32 100644 --- a/src/main/codex/codex-structured-background-catalog.ts +++ b/src/main/codex/codex-structured-background-catalog.ts @@ -72,11 +72,17 @@ async function refresh(input: BackgroundCatalogInput): Promise { return } if (fingerprint && store) { - store.recordSuccess(fingerprint, 'codex', { - models: listing.models, - fastModeTierByModel: listing.fastModeTierByModel, - origin: 'live-session' - }) + // `model/list` is Codex's account-level listing, the one its probe also runs. + store.recordSuccess( + fingerprint, + 'codex', + { + models: listing.models, + fastModeTierByModel: listing.fastModeTierByModel, + origin: 'live-session' + }, + 'discovery' + ) } applyListing(session, listing) } catch (error) { diff --git a/src/main/codex/codex-structured-fast-mode.test.ts b/src/main/codex/codex-structured-fast-mode.test.ts index 4182014d14af..4480df25050f 100644 --- a/src/main/codex/codex-structured-fast-mode.test.ts +++ b/src/main/codex/codex-structured-fast-mode.test.ts @@ -78,11 +78,16 @@ describe('Codex structured Fast mode without send-path catalog waits', () => { CODEX_STRUCTURED_AGENT, '/codex/home' )! - modelCatalog.recordSuccess(access.fingerprint, 'codex', { - models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], - fastModeTierByModel: new Map([['gpt-live', 'priority-live-v2']]), - origin: 'live-session' - }) + modelCatalog.recordSuccess( + access.fingerprint, + 'codex', + { + models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], + fastModeTierByModel: new Map([['gpt-live', 'priority-live-v2']]), + origin: 'live-session' + }, + 'discovery' + ) const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog }) await acquire(adapter) await send(adapter, 'first') @@ -343,11 +348,16 @@ describe('Codex structured Fast mode without send-path catalog waits', () => { await vi.waitFor(() => expect(codex.connections[0].calls.some((call) => call.method === 'model/list')).toBe(true) ) - modelCatalog.recordSuccess(access.fingerprint, 'codex', { - models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], - fastModeTierByModel: new Map([['gpt-live', 'priority-probe']]), - origin: 'probe' - }) + modelCatalog.recordSuccess( + access.fingerprint, + 'codex', + { + models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], + fastModeTierByModel: new Map([['gpt-live', 'priority-probe']]), + origin: 'probe' + }, + 'discovery' + ) pending.reject(new Error('own listing failed')) await vi.waitFor(() => expect( @@ -370,20 +380,31 @@ describe('Codex structured Fast mode without send-path catalog waits', () => { '/codex/home' )! const medium = [{ value: 'medium', label: 'Medium' }] - modelCatalog.recordSuccess(access.fingerprint, 'codex', { - models: [ - { - id: 'gpt-live', - label: 'Live', - isDefault: true, - efforts: medium, - supportsFastMode: false - }, - { id: 'gpt-next', label: 'Next', isDefault: false, efforts: medium, supportsFastMode: true } - ], - fastModeTierByModel: new Map([['gpt-next', 'priority-next']]), - origin: 'live-session' - }) + modelCatalog.recordSuccess( + access.fingerprint, + 'codex', + { + models: [ + { + id: 'gpt-live', + label: 'Live', + isDefault: true, + efforts: medium, + supportsFastMode: false + }, + { + id: 'gpt-next', + label: 'Next', + isDefault: false, + efforts: medium, + supportsFastMode: true + } + ], + fastModeTierByModel: new Map([['gpt-next', 'priority-next']]), + origin: 'live-session' + }, + 'discovery' + ) const adapter = adapterFor(codex, { codexHome: '/codex/home', resumeThreadId: THREAD_ID }, [], { modelCatalog }) diff --git a/src/main/codex/codex-structured-session-options-catalog.test.ts b/src/main/codex/codex-structured-session-options-catalog.test.ts index f5b29ac51ba6..b9ee653b18cc 100644 --- a/src/main/codex/codex-structured-session-options-catalog.test.ts +++ b/src/main/codex/codex-structured-session-options-catalog.test.ts @@ -11,6 +11,7 @@ import { fetchCodexModelCatalogListing } from './codex-structured-model-catalog' import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexSession } from './codex-structured-session-state' import { + AGENT_MODEL_CATALOG_FAILURE_TTL_MS, AGENT_MODEL_CATALOG_FRESH_MS, AgentModelCatalogStore, type AgentModelCatalogProbe @@ -71,17 +72,22 @@ function storeSession( } function seedEntry(store: AgentModelCatalogStore, ...ids: string[]): void { - store.recordSuccess(FINGERPRINT, 'codex', { - models: ids.map((id, index) => ({ - id, - label: id.toUpperCase(), - isDefault: index === 0, - efforts: [{ value: 'high', label: 'High' }], - defaultEffort: 'high' - })), - fastModeTierByModel: new Map(), - origin: 'live-session' - }) + store.recordSuccess( + FINGERPRINT, + 'codex', + { + models: ids.map((id, index) => ({ + id, + label: id.toUpperCase(), + isDefault: index === 0, + efforts: [{ value: 'high', label: 'High' }], + defaultEffort: 'high' + })), + fastModeTierByModel: new Map(), + origin: 'live-session' + }, + 'discovery' + ) } describe('Codex session options through the host catalog store', () => { @@ -90,7 +96,9 @@ describe('Codex session options through the host catalog store', () => { const signedOut: AgentModelCatalogProbe = async () => { throw new AgentModelCatalogUnavailableError({ reason: 'notSignedIn', account: 'system' }) } - await store.refresh(FINGERPRINT, 'codex', signedOut, () => signedOut('/homes/a')) + await store.refresh(FINGERPRINT, 'codex', signedOut, () => + signedOut({ variable: 'CODEX_HOME', path: '/homes/a' }) + ) const request = vi.fn(async () => listAnswer('gpt-live')) const result = await readLiveCodexSessionOptions(storeSession(request, store), undefined) expect(result.models.map((model) => model.id)).toEqual(['gpt-live']) @@ -100,6 +108,27 @@ describe('Codex session options through the host catalog store', () => { }) }) + it('never re-lists for an aged signed-out verdict beside a fresh entry', async () => { + let at = 1_000 + const store = new AgentModelCatalogStore({ now: () => at }) + seedEntry(store, 'gpt-live') + const signedOut: AgentModelCatalogProbe = async () => { + throw new AgentModelCatalogUnavailableError({ reason: 'notSignedIn', account: 'system' }) + } + await store.refresh(FINGERPRINT, 'codex', signedOut, () => + signedOut({ variable: 'CODEX_HOME', path: '/homes/a' }) + ) + at += AGENT_MODEL_CATALOG_FAILURE_TTL_MS + const request = vi.fn(async () => listAnswer('gpt-live')) + const session = storeSession(request, store) + // Only a probe re-derives the verdict, so the chat's own listing would repeat on every read. + await readLiveCodexSessionOptions(session, undefined) + await readLiveCodexSessionOptions(session, undefined) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(modelListCalls(request)).toBe(0) + expect(store.probeDue(FINGERPRINT)).toBe(true) + }) + it('lists once at the first read and serves every later read from the store', async () => { const store = new AgentModelCatalogStore() const request = vi.fn(async () => listAnswer('gpt-live', 'gpt-next')) @@ -124,7 +153,9 @@ describe('Codex session options through the host catalog store', () => { const store = new AgentModelCatalogStore() // Opening the chat's picker kicked the host probe for this account; its Codex never answers. const hungProbe: AgentModelCatalogProbe = () => new Promise(() => {}) - void store.refresh(FINGERPRINT, 'codex', hungProbe, () => hungProbe('/homes/a')) + void store.refresh(FINGERPRINT, 'codex', hungProbe, () => + hungProbe({ variable: 'CODEX_HOME', path: '/homes/a' }) + ) const request = vi.fn(async () => listAnswer('gpt-live')) const session = storeSession(request, store) // The acquire-time restore read: joining the probe would fail the chat at the probe's deadline. diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-account-expiry.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-account-expiry.ts index a80d509d0f70..487d8cb854c8 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-account-expiry.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-account-expiry.ts @@ -26,3 +26,59 @@ export function expireAgentModelCatalogFailuresForSettings( store.expireFailures(agent) } } + +// The settings that pick the binary and environment an agent's probe and chats launch with. +type AgentLaunchSettings = Partial> + +function launchKeys(settings: AgentLaunchSettings): Map { + const commands = new Map( + Object.entries(settings.agentCmdOverrides ?? {}) + ) + const envs = new Map | undefined>( + Object.entries(settings.agentDefaultEnv ?? {}) + ) + const keys = new Map() + for (const agent of new Set([...commands.keys(), ...envs.keys()])) { + const env = Object.entries(envs.get(agent) ?? {}).sort(([a], [b]) => a.localeCompare(b)) + keys.set(agent, JSON.stringify([commands.get(agent) ?? null, env])) + } + return keys +} + +/** Agents whose command or launch env differs between the two settings. */ +export function agentsWithChangedLaunchSettings( + previous: AgentLaunchSettings, + next: AgentLaunchSettings +): string[] { + const before = launchKeys(previous) + const after = launchKeys(next) + return [...new Set([...before.keys(), ...after.keys()])].filter( + (agent) => before.get(agent) !== after.get(agent) + ) +} + +/** A settings listener that expires the catalogs a change made stale: an account change expires + * the agent's held reasons; a command or env change also marks its catalogs due for a listing. */ +export function createAgentModelCatalogSettingsExpiry( + store: Pick, + initial: AgentLaunchSettings +): (updates: Partial, settings: AgentLaunchSettings) => void { + let launch: AgentLaunchSettings = { + agentCmdOverrides: initial.agentCmdOverrides, + agentDefaultEnv: initial.agentDefaultEnv + } + return (updates, settings) => { + expireAgentModelCatalogFailuresForSettings(store, updates) + if (!('agentCmdOverrides' in updates) && !('agentDefaultEnv' in updates)) { + return + } + const next = { + agentCmdOverrides: settings.agentCmdOverrides, + agentDefaultEnv: settings.agentDefaultEnv + } + for (const agent of agentsWithChangedLaunchSettings(launch, next)) { + store.expireAgent(agent) + } + launch = next + } +} diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery-freshness.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery-freshness.test.ts new file mode 100644 index 000000000000..e47c22100487 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery-freshness.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' +import { agentModelCatalogFingerprintForRecord } from './agent-model-catalog-fingerprint' +import { createAgentModelCatalogService } from './agent-model-catalog-service' +import { + AGENT_MODEL_CATALOG_FRESH_MS, + AgentModelCatalogStore, + type AgentModelCatalogSuccess +} from './agent-model-catalog-store' + +// A live session lists every turn; the account-level discovery is the only source of the configured +// default. These pin that the first can never starve, outrun or erase the second. + +const HOME: AgentSessionAccountHome = { variable: 'GROK_HOME', path: '/homes/grok' } + +const RECORD = { + sessionId: 'grok-session', + provider: 'grok', + accountHome: HOME, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'ws-1', + workspaceKind: 'git-worktree' + } +} as const satisfies Pick + +const EFFORTS = [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } +] + +function discovery(defaultModel: string): AgentModelCatalogSuccess { + return { + models: ['grok-4', 'grok-3-mini'].map((id) => ({ + id, + label: id, + isDefault: id === defaultModel, + efforts: EFFORTS, + ...(id === defaultModel ? { defaultEffort: 'high' } : {}) + })), + fastModeTierByModel: new Map(), + origin: 'probe' + } +} + +/** What a running Grok chat reports: no default, and the model it switched to. */ +const LIVE: AgentSessionModelOption[] = [ + { id: 'grok-4', label: 'grok-4', isDefault: false, efforts: EFFORTS }, + { id: 'grok-3-mini', label: 'grok-3-mini', isDefault: false, efforts: EFFORTS }, + { id: 'grok-code', label: 'grok-code', isDefault: false, efforts: [] } +] + +function harness(probe: () => Promise) { + let now = 1_000 + const store = new AgentModelCatalogStore({ now: () => now }) + const probes = vi.fn(probe) + const catalog = createAgentModelCatalogService({ + store, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only provider, accountHome and location. + getRecord: () => RECORD as unknown as AgentSessionRecord, + drivesRecord: () => true, + resolveAccountHome: async () => HOME, + probes: { grok: probes }, + listingNamesConfiguredModel: new Set(['grok']) + }) + const read = async () => { + const result = await catalog.read({ agent: 'grok', waitForListing: true }) + return result.origin === 'unknown' ? null : result + } + const defaultOf = async () => (await read())?.models.find((model) => model.isDefault)?.id + return { + store, + catalog, + probes, + read, + defaultOf, + advance: (ms: number) => (now += ms), + saveLive: () => catalog.recordLiveListing(RECORD.sessionId, { models: LIVE }) + } +} + +describe('a live listing beside the account-level discovery', () => { + it('merges a discovery that resolves after a live save instead of dropping it', async () => { + let settle!: (success: AgentModelCatalogSuccess) => void + const run = harness(() => new Promise((resolve) => (settle = resolve))) + // The first read starts the account's discovery; a chat lists while it is still running. + expect(await run.catalog.read({ agent: 'grok' })).toEqual({ + origin: 'unknown', + listingInProgress: true + }) + run.saveLive() + settle(discovery('grok-4')) + await vi.waitFor(() => + expect(run.store.get(agentModelCatalogFingerprintForRecord(RECORD))?.discovered).not.toBe( + null + ) + ) + + const read = await run.read() + expect(read?.listingNamesConfiguredModel).toBe(true) + expect(await run.defaultOf()).toBe('grok-4') + // The session's newer membership stays, with the default effort discovery named. + expect(read?.models.map((model) => model.id)).toEqual(['grok-4', 'grok-3-mini', 'grok-code']) + expect(read?.models[0]?.defaultEffort).toBe('high') + }) + + it('names the default on a cold account whose first listing came from a live chat', async () => { + const run = harness(async () => discovery('grok-4')) + run.saveLive() + // A live listing alone names no default and does not count as fresh, so a read lists the account. + await run.catalog.read({ agent: 'grok' }) + expect(run.probes).toHaveBeenCalledTimes(1) + await vi.waitFor(async () => expect(await run.defaultOf()).toBe('grok-4')) + }) + + it('still refreshes the account listing after ten minutes of live saves', async () => { + let configured = 'grok-4' + const run = harness(async () => discovery(configured)) + expect(await run.defaultOf()).toBe('grok-4') + expect(run.probes).toHaveBeenCalledTimes(1) + + // The user changes the account's default while chats keep listing every minute. + configured = 'grok-3-mini' + for (let minute = 0; minute < 11; minute++) { + run.advance(60_000) + run.saveLive() + } + expect(run.store.shouldRefresh(agentModelCatalogFingerprintForRecord(RECORD))).toBe(true) + await run.catalog.read({ agent: 'grok' }) + expect(run.probes).toHaveBeenCalledTimes(2) + await vi.waitFor(async () => expect(await run.defaultOf()).toBe('grok-3-mini')) + }) + + it('does not refresh while the discovery is younger than its freshness window', async () => { + const run = harness(async () => discovery('grok-4')) + await run.read() + run.advance(AGENT_MODEL_CATALOG_FRESH_MS - 1) + run.saveLive() + await run.read() + expect(run.probes).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts new file mode 100644 index 000000000000..a2c2ff6b8e42 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts @@ -0,0 +1,17 @@ +import type { AgentModelCatalogProbe } from './agent-model-catalog-store' + +/** + * What every agent registration must say about its models: a probe that lists them without + * starting a session, or that it has none. Refresh, keying, persistence and publication are the + * catalog service's, never the agent's. An agent with no probe shows the provider-default + * placeholder until a live session reports. + */ +export type AgentModelCatalogDiscovery = + | { + kind: 'probe' + probe: AgentModelCatalogProbe + /** The listing marks the model the account is configured to run as its default, so a new + * chat launches the listed default. False where the agent's own settings may pick another. */ + listingNamesConfiguredModel: boolean + } + | { kind: 'unavailable'; reason: string } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts new file mode 100644 index 000000000000..3e31cc41400d --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts @@ -0,0 +1,253 @@ +import type { AgentSessionUnavailable } from '../../../shared/agent-session-availability' +import type { + AgentSessionFastModeSupport, + AgentSessionModelOption +} from '../../../shared/agent-session-wire' + +/** `discovery`: an account-level listing that names the configured default and owns freshness. + * `live`: what a running session listed — which models exist and their efforts, nothing more. */ +export type AgentModelCatalogSource = 'discovery' | 'live' + +/** One successful listing, as any lister hands it to the store. */ +export type AgentModelCatalogSuccess = { + models: AgentSessionModelOption[] + fastModeSupport?: AgentSessionFastModeSupport + fastModeTierByModel: ReadonlyMap + origin: 'live-session' | 'probe' + /** A row only this session's launch added (its own `--model`): kept only once the account's + * catalog already lists that model. */ + launchOnlyModelId?: string + /** A probe that listed models but also found no chat can start (a signed-out Codex): both kept. */ + unavailable?: AgentSessionUnavailable +} + +/** One listing as the store keeps it. */ +export type AgentModelCatalogListing = { + models: AgentSessionModelOption[] + fastModeSupport?: AgentSessionFastModeSupport + /** Provider-advertised Fast tier per model id. */ + fastModeTierByModel: Record + origin: 'live-session' | 'probe' + at: number +} + +/** What a running session listed, as its adapter hands it to the host. Kept here, apart from the + * store, so the session wire types don't pull the store's Node-only persistence into clients. */ +export type AgentModelCatalogLiveListing = { + models: AgentSessionModelOption[] + fastModeSupport?: AgentSessionFastModeSupport + /** A row only this session's launch added (its own `--model`): kept only once the account's + * catalog already lists that model. */ + launchOnlyModelId?: string + /** What the agent's own config resolution picked for a session launched with no model pick: + * the configured default for that session's config scope. Null when that resolution names no + * listed model, so a saved default is stale; absent when this session can't say. */ + configuredDefault?: AgentModelCatalogConfiguredChoice | null +} + +/** A configured model as one session resolved it. `effort` is null when the config sends none + * and absent when the session picked its own, which says nothing about the config. */ +export type AgentModelCatalogConfiguredChoice = { + modelId: string + /** Other listed rows that run the same model, and so the same effort. */ + sameModelIds?: string[] + effort?: string | null +} + +/** The account's configured default as an agent's CLI resolved it for a chat with no model pick, + * in a workspace with no config of its own; for agents whose listing names none. */ +export type AgentModelCatalogConfiguredDefault = { + modelId: string + sameModelIds?: string[] + effort?: string + at: number +} + +/** A live options answer whose model rows are the account's listing as the child reported it, + * with what its no-pick launch resolved when the session can say. */ +export function withLiveCatalogListing< + T extends Pick +>( + options: T, + configuredDefault?: AgentModelCatalogConfiguredChoice | null +): T & { catalogListing: AgentModelCatalogLiveListing } { + return { + ...options, + catalogListing: { + models: options.models, + ...(options.fastModeSupport ? { fastModeSupport: options.fastModeSupport } : {}), + ...(configuredDefault !== undefined ? { configuredDefault } : {}) + } + } +} + +export type AgentModelCatalogEntry = { + agent: string + fingerprint: string + discovered: AgentModelCatalogListing | null + live: AgentModelCatalogListing | null + configured: AgentModelCatalogConfiguredDefault | null + // The merged view every reader uses, derived from the two listings above. + models: AgentSessionModelOption[] + fastModeSupport?: AgentSessionFastModeSupport + fastModeTierByModel: Record + origin: 'live-session' | 'probe' + fetchedAt: number +} + +/** Which models exist and their menus follow the newer listing; the default model and effort are + * the CLI-resolved configured default's, else discovery's, else what a live child reported while + * its model offers it. */ +function mergedModels( + discovered: AgentModelCatalogListing | null, + live: AgentModelCatalogListing | null, + configured: AgentModelCatalogConfiguredDefault | null +): AgentSessionModelOption[] { + const liveIsNewer = live !== null && (discovered === null || live.at >= discovered.at) + const newer = liveIsNewer ? live : discovered + const older = liveIsNewer ? discovered : live + return (newer?.models ?? []).map((model) => { + const listed = discovered?.models.find((entry) => entry.id === model.id) + const reported = live?.models.find((entry) => entry.id === model.id) + const efforts = + model.efforts.length > 0 + ? model.efforts + : (older?.models.find((entry) => entry.id === model.id)?.efforts ?? []) + const runsConfigured = + configured?.modelId === model.id || configured?.sameModelIds?.includes(model.id) === true + const configuredEffort = runsConfigured ? configured?.effort : undefined + const defaultEffort = [configuredEffort, listed?.defaultEffort, reported?.defaultEffort].find( + (effort) => effort !== undefined && efforts.some((choice) => choice.value === effort) + ) + const { defaultEffort: _own, ...rest } = model + return { + ...rest, + // A session names no default; without any discovery its own flags are all there is. + isDefault: configured + ? model.id === configured.modelId + : discovered + ? listed?.isDefault === true + : model.isDefault, + efforts, + ...(defaultEffort ? { defaultEffort } : {}) + } + }) +} + +export function agentModelCatalogEntry( + agent: string, + fingerprint: string, + discovered: AgentModelCatalogListing | null, + live: AgentModelCatalogListing | null, + configured: AgentModelCatalogConfiguredDefault | null +): AgentModelCatalogEntry | null { + const newer = live && (!discovered || live.at >= discovered.at) ? live : discovered + if (!newer) { + return null + } + const older = newer === live ? discovered : live + const fastModeSupport = newer.fastModeSupport ?? older?.fastModeSupport + return { + agent, + fingerprint, + discovered, + live, + configured, + models: mergedModels(discovered, live, configured), + ...(fastModeSupport ? { fastModeSupport } : {}), + fastModeTierByModel: { + ...live?.fastModeTierByModel, + ...discovered?.fastModeTierByModel + }, + origin: newer.origin, + fetchedAt: newer.at + } +} + +/** The entry `success` makes of `previous`, replacing only the listing of its source. */ +export function agentModelCatalogEntryWithSuccess( + previous: AgentModelCatalogEntry | undefined, + identity: { agent: string; fingerprint: string }, + success: AgentModelCatalogSuccess, + source: AgentModelCatalogSource, + at: number +): AgentModelCatalogEntry | null { + const launchOnly = success.launchOnlyModelId + const models = + launchOnly !== undefined && !previous?.models.some((model) => model.id === launchOnly) + ? success.models.filter((model) => model.id !== launchOnly) + : success.models + if (models.length === 0) { + // An empty list identifies no model; it is doubt, not a catalog. + return null + } + const listing: AgentModelCatalogListing = { + models: models.map((model) => ({ ...model })), + ...(success.fastModeSupport ? { fastModeSupport: success.fastModeSupport } : {}), + fastModeTierByModel: Object.fromEntries(success.fastModeTierByModel.entries()), + origin: success.origin, + at + } + const { agent, fingerprint } = identity + const configured = previous?.configured ?? null + return source === 'discovery' + ? agentModelCatalogEntry(agent, fingerprint, listing, previous?.live ?? null, configured) + : agentModelCatalogEntry(agent, fingerprint, previous?.discovered ?? null, listing, configured) +} + +/** A choice as the store keeps it: one that says nothing of effort keeps the saved effort of the + * same model, since only the session's own pick hid it. */ +function configuredDefaultFacts( + choice: AgentModelCatalogConfiguredChoice | null, + previous: AgentModelCatalogConfiguredDefault | null = null +): Omit | null { + if (!choice) { + return null + } + const effort = + choice.effort === undefined + ? previous?.modelId === choice.modelId + ? previous.effort + : undefined + : (choice.effort ?? undefined) + return { + modelId: choice.modelId, + ...(choice.sameModelIds?.length ? { sameModelIds: [...choice.sameModelIds] } : {}), + ...(effort ? { effort } : {}) + } +} + +/** `entry` with `choice` as its configured default (null forgets it); null when nothing changes. */ +export function entryWithConfiguredDefault( + entry: AgentModelCatalogEntry, + choice: AgentModelCatalogConfiguredChoice | null, + at: number +): AgentModelCatalogEntry | null { + const next = configuredDefaultFacts(choice, entry.configured) + if (JSON.stringify(next) === JSON.stringify(configuredDefaultFacts(entry.configured))) { + return null + } + return agentModelCatalogEntry( + entry.agent, + entry.fingerprint, + entry.discovered, + entry.live, + next && { ...next, at } + ) +} + +/** What a saved entry says, without its clocks: an unchanged key needs no write to disk. */ +export function agentModelCatalogListingKey(entry: AgentModelCatalogEntry): string { + const facts = (listing: AgentModelCatalogListing | null): unknown => + listing && [ + listing.origin, + listing.models, + listing.fastModeSupport ?? null, + listing.fastModeTierByModel + ] + return JSON.stringify([ + facts(entry.discovered), + facts(entry.live), + configuredDefaultFacts(entry.configured) + ]) +} diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-failures.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-failures.ts index 7af34934ca83..7da3c68d6800 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-failures.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-failures.ts @@ -7,6 +7,9 @@ import { AgentModelCatalogUnavailableError } from './agent-model-catalog-unavail export const AGENT_MODEL_CATALOG_FAILURE_TTL_MS = 30_000 +/** A probe the host stopped (it is going away): says nothing about the account, so it is not held. */ +export class AgentModelCatalogListingStoppedError extends Error {} + export type AgentModelCatalogFailure = { /** Set by the store's own refreshes; the fingerprint is a hash, so expiry by agent reads it. */ agent?: string diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-learned-default.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-learned-default.test.ts new file mode 100644 index 000000000000..f957737983e4 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-learned-default.test.ts @@ -0,0 +1,168 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentSessionAccountHome, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import { AcpStructuredOptions } from '../../acp/acp-structured-options' +import { agentModelCatalogFingerprintForRecord } from './agent-model-catalog-fingerprint' +import { createAgentModelCatalogService } from './agent-model-catalog-service' +import { AgentModelCatalogStore, withLiveCatalogListing } from './agent-model-catalog-store' +import { + agentReadsProjectModelConfig, + workspaceMayOverrideDefaultModel +} from './agent-project-model-override' + +// OpenCode and OMP list no default. A chat started with no pick in a workspace whose own config +// picks nothing runs the account's default, so the host names it for the next new chat there. + +let root: string +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-learned-default-')) +}) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +const HOMES: Record = { + opencode: { kind: 'opencode', locator: { kind: 'unmanaged' } }, + omp: { variable: 'PI_CODING_AGENT_DIR', path: '/homes/omp' } +} + +const MODELS = [ + { value: 'github-copilot/gpt-6', name: 'GPT-6' }, + { value: 'github-copilot/claude-fable-5', name: 'Claude Fable 5' } +] + +function workspace(name: string, files: Record = {}): string { + const dir = join(root, name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, '.git'), 'gitdir: /elsewhere') + for (const [file, text] of Object.entries(files)) { + mkdirSync(join(dir, file, '..'), { recursive: true }) + writeFileSync(join(dir, file), text) + } + return dir +} + +function record(agent: string, workspacePath: string): AgentSessionRecord { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the catalog service reads only these fields. + return { + sessionId: `${agent}-1`, + provider: agent, + accountHome: HOMES[agent]!, + launchDirectory: workspacePath, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'ws-1', + workspaceKind: 'git-worktree' + } + } as AgentSessionRecord +} + +/** A host whose saved list names no default, and one chat started with no pick in `workspacePath` + * that reports what it runs. */ +async function afterNoPickChat(agent: string, workspacePath: string, picked = false) { + const chat = record(agent, workspacePath) + const store = new AgentModelCatalogStore() + store.recordSuccess( + agentModelCatalogFingerprintForRecord(chat), + agent, + { + models: MODELS.map((model) => ({ + id: model.value, + label: model.name, + isDefault: false, + efforts: [] + })), + fastModeTierByModel: new Map(), + origin: 'probe' + }, + 'discovery' + ) + const catalog = createAgentModelCatalogService({ + store, + getRecord: (sessionId) => (sessionId === chat.sessionId ? chat : undefined), + drivesRecord: () => true, + resolveAccountHome: async () => HOMES[agent]!, + recordWorkspacePath: async (row) => row.launchDirectory ?? null, + agentReadsProjectModelConfig, + workspaceMayOverrideDefaultModel + }) + const options = new AcpStructuredOptions() + options.adoptSession( + { + configOptions: [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'github-copilot/claude-fable-5', + options: MODELS + } + ] + }, + 'new' + ) + if (picked) { + options.notePick('model') + } + catalog.recordLiveListing( + chat.sessionId, + withLiveCatalogListing(options.read(), options.configuredDefault()).catalogListing + ) + return catalog +} + +function named( + answer: Awaited['read']>> +) { + return answer.origin === 'unknown' || answer.listingNamesConfiguredModel !== true + ? null + : (answer.models.find((model) => model.isDefault)?.id ?? null) +} + +describe('a default learned from a chat with no pick', () => { + it.each(['opencode', 'omp'])( + '%s: the next new chat in that workspace names the model the first one ran', + async (agent) => { + const clean = workspace('clean') + const catalog = await afterNoPickChat(agent, clean) + const answer = await catalog.read({ agent, workspacePath: clean }) + expect(named(answer)).toBe('github-copilot/claude-fable-5') + // Its own project config may still pick the model, so it is not every workspace's. + expect(answer).not.toHaveProperty('defaultHoldsInEveryWorkspace') + expect(named(await catalog.read({ agent, workspacePath: workspace('other') }))).toBe( + 'github-copilot/claude-fable-5' + ) + } + ) + + it.each([ + ['opencode', 'opencode.json', '{"model":"github-copilot/claude-fable-5"}'], + ['omp', '.omp/config.yml', 'modelRoles:\n default: github-copilot/claude-fable-5\n'] + ])( + '%s: a chat whose workspace config picks the model teaches nothing', + async (agent, file, text) => { + const configured = workspace('configured', { [file]: text }) + const catalog = await afterNoPickChat(agent, configured) + expect(named(await catalog.read({ agent, workspacePath: workspace('clean') }))).toBeNull() + } + ) + + it('a new chat in a workspace whose config picks the model names none', async () => { + const catalog = await afterNoPickChat('opencode', workspace('clean')) + const configured = workspace('configured', { 'opencode.json': '{"model":"x/y"}' }) + expect(named(await catalog.read({ agent: 'opencode', workspacePath: configured }))).toBeNull() + }) + + it('a chat that picked its model teaches nothing', async () => { + const clean = workspace('clean') + const catalog = await afterNoPickChat('opencode', clean, true) + expect(named(await catalog.read({ agent: 'opencode', workspacePath: clean }))).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-listing-waiters.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-listing-waiters.ts new file mode 100644 index 000000000000..cbbca251a5f7 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-listing-waiters.ts @@ -0,0 +1,55 @@ +import type { AgentModelCatalogEntry } from './agent-model-catalog-entry' + +export const AGENT_MODEL_CATALOG_PICKER_WAIT_MS = 30_000 + +/** Pickers following a fingerprint's listing work: each settles once a catalog lands, all work + * ends, or its fixed deadline expires. */ +export class AgentModelCatalogListingWaiters { + private readonly waiters = new Map void>>() + + constructor( + private readonly read: (fingerprint: string) => AgentModelCatalogEntry | null, + private readonly listing: (fingerprint: string) => boolean + ) {} + + wait(fingerprint: string): Promise | null { + if (!this.listing(fingerprint)) { + return null + } + return new Promise((resolve) => { + const waiters = this.waiters.get(fingerprint) ?? new Set<() => void>() + let settled = false + const finish = (entry: AgentModelCatalogEntry | null): void => { + if (settled) { + return + } + settled = true + clearTimeout(deadline) + waiters.delete(check) + if (waiters.size === 0) { + this.waiters.delete(fingerprint) + } + resolve(entry) + } + const check = (): void => { + const entry = this.read(fingerprint) + if (entry || !this.listing(fingerprint)) { + finish(entry) + } + } + const deadline = setTimeout( + () => finish(this.read(fingerprint)), + AGENT_MODEL_CATALOG_PICKER_WAIT_MS + ) + waiters.add(check) + this.waiters.set(fingerprint, waiters) + check() + }) + } + + notify(fingerprint: string): void { + for (const check of this.waiters.get(fingerprint) ?? []) { + check() + } + } +} diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts index d47e9efbec56..ca8a90a0f049 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts @@ -4,10 +4,14 @@ import type { AgentSessionModelOption, AgentSessionOptionChoice } from '../../../shared/agent-session-wire' -import type { AgentModelCatalogEntry } from './agent-model-catalog-store' +import { + agentModelCatalogEntry, + type AgentModelCatalogEntry, + type AgentModelCatalogListing +} from './agent-model-catalog-entry' import { isStructuredAgentId } from '../../../shared/agent-session-provider-handle-encoding' -const SCHEMA_VERSION = 1 +const SCHEMA_VERSION = 2 const SAVE_COALESCE_MS = 500 export type AgentModelCatalogPersistence = { @@ -62,16 +66,12 @@ function parseModel(value: unknown): AgentSessionModelOption | null { } } -/** Checked reconstruction rather than trust: a field a future schema drops or - * reshapes loads as "no entry", never as a corrupt catalog. */ -function parseEntry(value: unknown): AgentModelCatalogEntry | null { +function parseListing(value: unknown): AgentModelCatalogListing | null { const row = asRecord(value) if ( !row || - !isStructuredAgentId(row.agent) || - typeof row.fingerprint !== 'string' || (row.origin !== 'live-session' && row.origin !== 'probe') || - typeof row.fetchedAt !== 'number' || + typeof row.at !== 'number' || !Array.isArray(row.models) || row.models.length === 0 ) { @@ -86,11 +86,14 @@ function parseEntry(value: unknown): AgentModelCatalogEntry | null { const supported = support?.supported const supportReason = text(support?.reason) return { - agent: row.agent, - fingerprint: row.fingerprint, models: models.filter((model): model is AgentSessionModelOption => model !== null), ...(typeof supported === 'boolean' - ? { fastModeSupport: { supported, ...(supportReason ? { reason: supportReason } : {}) } } + ? { + fastModeSupport: { + supported, + ...(supportReason ? { reason: supportReason } : {}) + } + } : {}), fastModeTierByModel: Object.fromEntries( Object.entries(tiers ?? {}).filter( @@ -98,7 +101,47 @@ function parseEntry(value: unknown): AgentModelCatalogEntry | null { ) ), origin: row.origin, - fetchedAt: row.fetchedAt + at: row.at + } +} + +/** Checked reconstruction rather than trust: a field a future schema drops or + * reshapes loads as "no entry", never as a corrupt catalog. */ +function parseEntry(value: unknown): AgentModelCatalogEntry | null { + const row = asRecord(value) + if (!row || !isStructuredAgentId(row.agent) || typeof row.fingerprint !== 'string') { + return null + } + const configured = asRecord(row.configured) + const configuredModelId = text(configured?.modelId) + const configuredEffort = text(configured?.effort) + const sameModelIds = Array.isArray(configured?.sameModelIds) + ? configured.sameModelIds.filter((id): id is string => typeof id === 'string') + : [] + return agentModelCatalogEntry( + row.agent, + row.fingerprint, + parseListing(row.discovered), + parseListing(row.live), + configuredModelId && typeof configured?.at === 'number' + ? { + modelId: configuredModelId, + ...(sameModelIds.length > 0 ? { sameModelIds } : {}), + ...(configuredEffort ? { effort: configuredEffort } : {}), + at: configured.at + } + : null + ) +} + +/** Only the two listings and the configured default are written; the merged view is derived again on load. */ +function persistedEntry(entry: AgentModelCatalogEntry): unknown { + return { + agent: entry.agent, + fingerprint: entry.fingerprint, + discovered: entry.discovered, + live: entry.live, + ...(entry.configured ? { configured: entry.configured } : {}) } } @@ -126,7 +169,14 @@ export function createAgentModelCatalogFilePersistence( try { await mkdir(dirname(filePath), { recursive: true }) const tmpPath = `${filePath}.tmp` - await writeFile(tmpPath, JSON.stringify({ version: SCHEMA_VERSION, entries }), 'utf8') + await writeFile( + tmpPath, + JSON.stringify({ + version: SCHEMA_VERSION, + entries: entries.map(persistedEntry) + }), + 'utf8' + ) await rename(tmpPath, filePath) } catch { // Bookkeeping only; the in-memory store stays authoritative this run. diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts new file mode 100644 index 000000000000..5c5c9bf1a05b --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts @@ -0,0 +1,303 @@ +import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' +import { describe, expect, it, vi } from 'vitest' + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + agentModelCatalogFingerprintForRecord, + agentModelCatalogSessionAccess +} from './agent-model-catalog-fingerprint' +import { createAgentModelCatalogService } from './agent-model-catalog-service' +import { + AGENT_MODEL_CATALOG_FRESH_MS, + AgentModelCatalogStore, + type AgentModelCatalogSuccess +} from './agent-model-catalog-store' + +const spans = vi.hoisted(() => { + const started: { name: string; attributes: Record; outcome: string }[] = [] + return { started } +}) + +vi.mock('../../observability/tracer', () => ({ + startSpan: (name: string, options?: { attributes?: Record }) => { + const span = { name, attributes: { ...options?.attributes }, outcome: 'open' } + spans.started.push(span) + return { + setAttribute: (key: string, value: unknown) => { + span.attributes[key] = value + }, + end: () => { + span.outcome = 'ok' + }, + fail: () => { + span.outcome = 'failed' + } + } + } +})) +function listing(id: string): AgentModelCatalogSuccess { + return { + models: [{ id, label: id, isDefault: true, efforts: [] }], + fastModeTierByModel: new Map(), + origin: 'probe' + } +} + +const HOME = (variable: string, path: string): AgentSessionAccountHome => ({ variable, path }) + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((accept) => { + resolve = accept + }) + return { promise, resolve } +} + +describe('model catalog prewarm', () => { + it('lists every agent once for the account a new chat would pin, and joins a read meanwhile', async () => { + let now = 0 + const store = new AgentModelCatalogStore({ now: () => now }) + const codexListed = deferred() + const codex = vi.fn(async (_home: AgentSessionAccountHome) => codexListed.promise) + const grok = vi.fn(async (_home: AgentSessionAccountHome) => listing('grok-4')) + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + drivesRecord: () => true, + hasChatRecords: () => true, + resolveAccountHome: async (agent) => + agent === 'codex' ? HOME('CODEX_HOME', '/homes/a') : HOME('GROK_HOME', '/grok'), + probes: { codex, grok } + }) + + const prewarmed = service.prewarm() + await vi.waitFor(() => expect(codex).toHaveBeenCalledTimes(1)) + // A picker opening while the prewarm lists joins it instead of spawning a second listing. + expect(await service.read({ agent: 'codex' })).toEqual({ + origin: 'unknown', + listingInProgress: true + }) + codexListed.resolve(listing('gpt-a')) + await prewarmed + + expect(codex).toHaveBeenCalledTimes(1) + expect(codex).toHaveBeenCalledWith(HOME('CODEX_HOME', '/homes/a'), { + signal: expect.any(AbortSignal) + }) + expect(grok).toHaveBeenCalledTimes(1) + expect((await service.read({ agent: 'codex' })).origin).toBe('probe') + expect((await service.read({ agent: 'grok' })).origin).toBe('probe') + + // Fresh: a second prewarm lists nothing; once old, it lists again. + await service.prewarm() + expect(codex).toHaveBeenCalledTimes(1) + now += AGENT_MODEL_CATALOG_FRESH_MS + await service.prewarm() + expect(codex).toHaveBeenCalledTimes(2) + expect(grok).toHaveBeenCalledTimes(2) + }) + + it('lists the new account after a switch and leaves the old account’s catalog alone', async () => { + const store = new AgentModelCatalogStore() + let selected = '/homes/a' + const probe = vi.fn(async (home: AgentSessionAccountHome) => + listing('variable' in home && home.path === '/homes/a' ? 'gpt-a' : 'gpt-b') + ) + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + drivesRecord: () => true, + hasChatRecords: () => true, + resolveAccountHome: async () => HOME('CODEX_HOME', selected), + probes: { codex: probe } + }) + await service.prewarm() + selected = '/homes/b' + await service.prewarm() + + expect(probe.mock.calls.map(([home]) => home)).toEqual([ + HOME('CODEX_HOME', '/homes/a'), + HOME('CODEX_HOME', '/homes/b') + ]) + const read = await service.read({ agent: 'codex' }) + expect(read.origin !== 'unknown' && read.models.map((model) => model.id)).toEqual(['gpt-b']) + }) + + it('respects the failure back-off and skips an agent whose account cannot be resolved', async () => { + const store = new AgentModelCatalogStore() + const codex = vi.fn( + async (_home: AgentSessionAccountHome): Promise => { + throw new Error('spawn codex ENOENT') + } + ) + const claude = vi.fn(async (_home: AgentSessionAccountHome) => listing('opus')) + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + drivesRecord: () => true, + hasChatRecords: () => true, + resolveAccountHome: async (agent) => { + if (agent === 'claude') { + throw new Error('account home unreadable') + } + return HOME('CODEX_HOME', '/homes/a') + }, + probes: { codex, claude } + }) + spans.started.length = 0 + await service.prewarm() + await service.prewarm() + expect(codex).toHaveBeenCalledTimes(1) + expect(claude).not.toHaveBeenCalled() + // Each listing leaves its agent and outcome in the trace log; the span carries its duration. + expect(spans.started).toEqual([ + { + name: 'agentModelCatalog.discovery', + attributes: { agent: 'codex', lister: 'probe' }, + outcome: 'failed' + } + ]) + }) + + it('lists only agents used here: a saved catalog or a chat of it', async () => { + const store = new AgentModelCatalogStore() + const probeFor = (agent: string) => + vi.fn(async (_home: AgentSessionAccountHome) => listing(`${agent}-model`)) + const probes = { codex: probeFor('codex'), claude: probeFor('claude'), pi: probeFor('pi') } + // Claude has a catalog saved under another account; Codex has a chat; Pi was never used. + store.recordSuccess('claude-other-account', 'claude', listing('opus'), 'discovery') + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + drivesRecord: () => true, + resolveAccountHome: async (agent) => HOME('HOME', `/homes/${agent}`), + hasChatRecords: (agent) => agent === 'codex', + probes + }) + await service.prewarm() + + expect(probes.codex).toHaveBeenCalledTimes(1) + expect(probes.claude).toHaveBeenCalledTimes(1) + expect(probes.pi).not.toHaveBeenCalled() + // Its first chat still lists it on demand. + expect(await service.read({ agent: 'pi' })).toEqual({ + origin: 'unknown', + listingInProgress: true + }) + expect(probes.pi).toHaveBeenCalledTimes(1) + }) + + it('on stop, starts no queued agent, stops the running listings and lists nothing more', async () => { + const store = new AgentModelCatalogStore() + const started: string[] = [] + const signals: AbortSignal[] = [] + const probeFor = + (agent: string) => (_home: AgentSessionAccountHome, options?: { signal?: AbortSignal }) => { + started.push(agent) + const signal = options?.signal + if (signal) { + signals.push(signal) + } + return new Promise((_resolve, reject) => + signal?.addEventListener('abort', () => reject(new Error('stopped'))) + ) + } + const agents = ['codex', 'claude', 'grok', 'opencode'] + const hasChatRecords = vi.fn(() => true) + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + drivesRecord: () => true, + resolveAccountHome: async (agent) => HOME('HOME', `/homes/${agent}`), + hasChatRecords, + probes: Object.fromEntries(agents.map((agent) => [agent, probeFor(agent)])) + }) + const prewarmed = service.prewarm() + await vi.waitFor(() => expect(started).toHaveLength(2)) + + service.stop() + await prewarmed + expect(started).toEqual(['codex', 'claude']) + expect(signals.every((signal) => signal.aborted)).toBe(true) + + // A read or another prewarm after stop spawns nothing. + expect(await service.read({ agent: 'grok' })).toEqual({ origin: 'unknown' }) + hasChatRecords.mockClear() + await service.prewarm() + expect(started).toEqual(['codex', 'claude']) + // Not even the chat records are read. + expect(hasChatRecords).not.toHaveBeenCalled() + }) + + it('runs at most two listings at once', async () => { + const store = new AgentModelCatalogStore() + let running = 0 + let peak = 0 + const gates = new Map void>() + const probeFor = (agent: string) => async (): Promise => { + running += 1 + peak = Math.max(peak, running) + await new Promise((resolve) => gates.set(agent, resolve)) + running -= 1 + return listing(`${agent}-model`) + } + const agents = ['codex', 'claude', 'grok', 'opencode', 'pi'] + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + drivesRecord: () => true, + hasChatRecords: () => true, + resolveAccountHome: async (agent) => HOME('HOME', `/homes/${agent}`), + probes: Object.fromEntries(agents.map((agent) => [agent, probeFor(agent)])) + }) + const prewarmed = service.prewarm() + for (const agent of agents) { + await vi.waitFor(() => expect(gates.has(agent)).toBe(true)) + gates.get(agent)?.() + } + await prewarmed + expect(peak).toBe(2) + }) +}) + +describe('one account, one catalog key', () => { + function record(path: string): AgentSessionRecord { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the key reads only provider, accountHome and location. + return { + sessionId: 'session-1', + provider: 'codex', + accountHome: HOME('CODEX_HOME', path), + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'ws-1', + workspaceKind: 'git-worktree' + } + } as AgentSessionRecord + } + + // The read and launch resolvers agreeing on the home is runtime-home-catalog-account-key.test.ts. + it('a session’s saves through the host and through its adapter’s handle land under its record’s key', async () => { + const store = new AgentModelCatalogStore() + const managedHome = '/orca/codex-accounts/acct-1/home' + const launched = record(managedHome) + const service = createAgentModelCatalogService({ + store, + getRecord: () => launched, + drivesRecord: () => true, + resolveAccountHome: async () => HOME('CODEX_HOME', managedHome) + }) + // What the chat saved, through the host and through the Codex adapter's own handle… + service.recordLiveListing('session-1', { models: listing('gpt-live').models }) + const adapterAccess = agentModelCatalogSessionAccess( + store, + { agent: 'codex', accountHomeVariable: 'CODEX_HOME' }, + managedHome + ) + expect(adapterAccess?.fingerprint).toBe(agentModelCatalogFingerprintForRecord(launched)) + + // …is what the next new chat reads for the same home: no cold start for this account. + const read = await service.read({ agent: 'codex' }) + expect(read.origin !== 'unknown' && read.models.map((model) => model.id)).toEqual(['gpt-live']) + }) +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.test.ts new file mode 100644 index 000000000000..744c13dafa66 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.test.ts @@ -0,0 +1,167 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AgentModelCatalogProbeError, + runAgentModelCatalogListing, + runAgentModelCatalogSession +} from './agent-model-catalog-probe-runner' + +// Every child here is this test's own Node, never an agent CLI. +const folders: string[] = [] +afterEach(() => { + for (const folder of folders.splice(0)) { + rmSync(folder, { recursive: true, force: true }) + } +}) + +function node(script: string): { command: string; args: string[] } { + return { command: process.execPath, args: ['-e', script] } +} + +function pidFile(): string { + const folder = mkdtempSync(join(tmpdir(), 'orca-catalog-probe-')) + folders.push(folder) + return join(folder, 'pid') +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +const NODE_ENV = { ELECTRON_RUN_AS_NODE: '1' } + +describe('the shared catalog probe runner', () => { + it('answers a listing’s stdout once it exits 0, with its request on stdin', async () => { + const stdout = await runAgentModelCatalogListing( + { + ...node( + "let i='';process.stdin.on('data',c=>i+=c);process.stdin.on('end',()=>process.stdout.write('got:'+i))" + ), + env: NODE_ENV, + stdin: 'list' + }, + { site: 'test-listing' } + ) + expect(stdout).toBe('got:list') + }) + + it('rejects a listing that exits non-zero, with its stderr', async () => { + await expect( + runAgentModelCatalogListing( + { ...node("process.stderr.write('not signed in');process.exit(3)"), env: NODE_ENV }, + { site: 'test-listing' } + ) + ).rejects.toMatchObject({ reason: 'exit', message: expect.stringContaining('not signed in') }) + }) + + it('stops a listing that outlives its budget before rejecting', async () => { + const file = pidFile() + const run = runAgentModelCatalogListing( + { + ...node( + `require('fs').writeFileSync(${JSON.stringify(file)}, String(process.pid));setInterval(()=>{},1000)` + ), + env: NODE_ENV + }, + { site: 'test-listing', timeoutMs: 1_000 } + ) + await expect(run).rejects.toMatchObject({ reason: 'timeout' }) + const pid = Number(readFileSync(file, 'utf8')) + await vi.waitFor(() => expect(isAlive(pid)).toBe(false), { timeout: 5_000 }) + }, 20_000) + + it('stops a running listing, and starts none, once its host stops it', async () => { + const file = pidFile() + const stop = new AbortController() + const run = runAgentModelCatalogListing( + { + ...node( + `require('fs').writeFileSync(${JSON.stringify(file)}, String(process.pid));setInterval(()=>{},1000)` + ), + env: NODE_ENV + }, + { site: 'test-listing', signal: stop.signal } + ) + await vi.waitFor(() => expect(readFileSync(file, 'utf8')).not.toBe(''), { timeout: 5_000 }) + stop.abort() + await expect(run).rejects.toMatchObject({ reason: 'stopped' }) + const pid = Number(readFileSync(file, 'utf8')) + await vi.waitFor(() => expect(isAlive(pid)).toBe(false), { timeout: 5_000 }) + + const never = pidFile() + await expect( + runAgentModelCatalogListing( + { ...node(`require('fs').writeFileSync(${JSON.stringify(never)}, 'ran')`), env: NODE_ENV }, + { site: 'test-listing', signal: stop.signal } + ) + ).rejects.toMatchObject({ reason: 'stopped' }) + expect(() => readFileSync(never, 'utf8')).toThrow() + }, 20_000) + + it('stops a listing whose output overflows the cap', async () => { + const file = pidFile() + const run = runAgentModelCatalogListing( + { + ...node( + `require('fs').writeFileSync(${JSON.stringify(file)}, String(process.pid));setInterval(()=>process.stdout.write('x'.repeat(4096)),5)` + ), + env: NODE_ENV + }, + { site: 'test-listing', maxOutputBytes: 64 * 1024 } + ) + await expect(run).rejects.toMatchObject({ reason: 'output-overflow' }) + const pid = Number(readFileSync(file, 'utf8')) + await vi.waitFor(() => expect(isAlive(pid)).toBe(false), { timeout: 5_000 }) + }, 20_000) + + it('rejects a command that cannot start, saying its executable is missing', async () => { + const failure = await runAgentModelCatalogListing( + { command: join(tmpdir(), 'orca-no-such-agent-binary'), args: [] }, + { site: 'test-listing' } + ).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(AgentModelCatalogProbeError) + expect(failure).toMatchObject({ reason: 'spawn', executableMissing: true }) + }) + + it('closes a probe connection on success, failure and timeout alike', async () => { + const closes: string[] = [] + const open = (name: string) => () => ({ + close: async () => { + closes.push(name) + } + }) + await expect( + runAgentModelCatalogSession(open('ok'), async () => 1, { label: 'a' }) + ).resolves.toBe(1) + await expect( + runAgentModelCatalogSession( + open('crash'), + async () => { + throw new Error('agent exited') + }, + { label: 'a' } + ) + ).rejects.toThrow('agent exited') + await expect( + runAgentModelCatalogSession(open('hang'), () => new Promise(() => {}), { + label: 'a', + timeoutMs: 20 + }) + ).rejects.toMatchObject({ reason: 'timeout' }) + const stop = new AbortController() + const stopped = runAgentModelCatalogSession(open('stopped'), () => new Promise(() => {}), { + label: 'a', + signal: stop.signal + }) + stop.abort() + await expect(stopped).rejects.toMatchObject({ reason: 'stopped' }) + expect(closes).toEqual(['ok', 'crash', 'hang', 'stopped']) + }) +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.ts new file mode 100644 index 000000000000..8adf15a13653 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.ts @@ -0,0 +1,194 @@ +// The one way a catalog probe runs an agent CLI: a disposable child under the provider supervisor, +// one budget for the whole listing, bounded output, and a teardown awaited on every path. A probe +// only names what to run and how to read its answer. + +import { createOutputSink } from '../../../shared/child-process/bounded-output-sink' +import type { spawnProcess } from '../../../shared/child-process/run-process' +import { spawnManagedProviderProcess } from '../../provider-process/managed-provider-process' +import type { ProviderProcessClosePolicy } from '../../provider-process/provider-process-close' +import type { ProviderProcessLaunch } from '../../provider-process/provider-process-launch' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../../provider-process/provider-process-supervisor' +import { + providerStderrForDisplay, + supervisedProviderSpawnFailure +} from '../../provider-process/provider-spawn-failure-report' + +// Why 15s: a cold listing may pay one network fetch; past that, the picker keeps what it has. +export const AGENT_MODEL_CATALOG_PROBE_TIMEOUT_MS = 15_000 +export const AGENT_MODEL_CATALOG_PROBE_MAX_OUTPUT_BYTES = 8 * 1024 * 1024 +const STDERR_DETAIL_MAX_CHARS = 400 +const UNSUPERVISED_GRACEFUL_EXIT_MS = 1_500 +const FORCED_EXIT_MS = 1_000 + +export type AgentModelCatalogProbeFailure = + | 'timeout' + | 'output-overflow' + | 'exit' + | 'spawn' + | 'stopped' + +export class AgentModelCatalogProbeError extends Error { + constructor( + message: string, + readonly reason: AgentModelCatalogProbeFailure, + /** The listing's own executable was not found, as the spawn or its supervisor reported. */ + readonly executableMissing = false + ) { + super(message) + this.name = 'AgentModelCatalogProbeError' + } +} + +/** Nothing waits on a probe past its answer, so a close stops it at once rather than draining. */ +function probeClosePolicy(supervised: boolean): ProviderProcessClosePolicy { + return { + gracefulExitMs: supervised ? PROVIDER_SUPERVISOR_MAX_STOP_MS : UNSUPERVISED_GRACEFUL_EXIT_MS, + forcedExitMs: FORCED_EXIT_MS, + signalSupervisorOnClose: true + } +} + +export type AgentModelCatalogListingLaunch = ProviderProcessLaunch & { + /** Written then closed; a listing that reads no request gets an immediately closed stdin. */ + stdin?: string +} + +export type AgentModelCatalogRunnerOptions = { + site: string + timeoutMs?: number + maxOutputBytes?: number + /** Stops the listing and its child, as the deadline would. */ + signal?: AbortSignal + /** Test seams. */ + spawnImpl?: typeof spawnProcess + platform?: NodeJS.Platform + inheritedEnv?: NodeJS.ProcessEnv +} + +/** Runs a listing command to its exit and answers its stdout; any exit but 0, an overflow or the + * deadline rejects. The child is stopped and awaited before this settles either way. */ +export async function runAgentModelCatalogListing( + launch: AgentModelCatalogListingLaunch, + options: AgentModelCatalogRunnerOptions +): Promise { + const timeoutMs = options.timeoutMs ?? AGENT_MODEL_CATALOG_PROBE_TIMEOUT_MS + throwIfStopped(options.signal, launch.command) + const managed = spawnManagedProviderProcess(launch, { + site: options.site, + // A listing's stdin end is the end of its request, not a stop. + lifetime: 'one-shot', + policy: probeClosePolicy, + ...(options.spawnImpl ? { spawnImpl: options.spawnImpl } : {}), + ...(options.platform ? { platform: options.platform } : {}), + ...(options.inheritedEnv ? { inheritedEnv: options.inheritedEnv } : {}) + }) + const { child } = managed + const output = createOutputSink( + options.maxOutputBytes ?? AGENT_MODEL_CATALOG_PROBE_MAX_OUTPUT_BYTES + ) + const stderrDetail = (): string => + providerStderrForDisplay(managed.stderrTail()).trim().slice(0, STDERR_DETAIL_MAX_CHARS) + let timer: ReturnType | undefined + let onAbort = (): void => {} + try { + return await new Promise((resolve, reject) => { + const fail = (message: string, reason: AgentModelCatalogProbeFailure): void => + reject( + new AgentModelCatalogProbeError( + message, + reason, + reason === 'spawn' && managed.executableMissing + ) + ) + onAbort = () => fail(`${launch.command} listing stopped`, 'stopped') + options.signal?.addEventListener('abort', onAbort, { once: true }) + timer = setTimeout( + () => fail(`${launch.command} did not list models within ${timeoutMs}ms`, 'timeout'), + timeoutMs + ) + child.stdout.on('data', (chunk: Buffer | string) => { + output.write(chunk) + if (output.truncated()) { + fail(`${launch.command} listed more than the model catalog reads`, 'output-overflow') + } + }) + child.stdout.on('error', () => {}) + child.stdin.on('error', () => {}) + child.on('error', (error) => { + if (child.pid === undefined) { + fail(`${launch.command} could not start: ${error.message}`, 'spawn') + } + }) + // `close`, not `exit`: only then has every byte of stdout arrived. + child.on('close', (code: number | null) => { + const spawnFailure = managed.supervised + ? supervisedProviderSpawnFailure(code, managed.stderrTail()) + : null + if (spawnFailure) { + fail(`${launch.command} could not start: ${spawnFailure.error.message}`, 'spawn') + } else if (code === 0) { + resolve(output.text()) + } else { + const detail = stderrDetail() + fail( + `${launch.command} exited ${code ?? 'by signal'}${detail ? `: ${detail}` : ''}`, + 'exit' + ) + } + }) + try { + child.stdin.end(launch.stdin ?? '') + } catch { + // A child that is already gone reports through `close`. + } + }) + } finally { + clearTimeout(timer) + options.signal?.removeEventListener('abort', onAbort) + await managed.close() + } +} + +function throwIfStopped(signal: AbortSignal | undefined, label: string): void { + if (signal?.aborted) { + throw new AgentModelCatalogProbeError(`${label} listing stopped`, 'stopped') + } +} + +/** A probe that holds a protocol connection rather than reading one command's output. */ +export type AgentModelCatalogProbeConnection = { close(): Promise } + +/** Opens a disposable connection, runs `body` under the one probe budget, and always closes it. */ +export async function runAgentModelCatalogSession( + open: () => C, + body: (connection: C) => Promise, + options: { label: string; timeoutMs?: number; signal?: AbortSignal } +): Promise { + const timeoutMs = options.timeoutMs ?? AGENT_MODEL_CATALOG_PROBE_TIMEOUT_MS + throwIfStopped(options.signal, options.label) + const connection = open() + let timer: ReturnType | undefined + let onAbort = (): void => {} + const deadline = new Promise((_resolve, reject) => { + onAbort = () => + reject(new AgentModelCatalogProbeError(`${options.label} listing stopped`, 'stopped')) + options.signal?.addEventListener('abort', onAbort, { once: true }) + timer = setTimeout( + () => + reject( + new AgentModelCatalogProbeError( + `${options.label} did not list models within ${timeoutMs}ms`, + 'timeout' + ) + ), + timeoutMs + ) + }) + try { + return await Promise.race([body(connection), deadline]) + } finally { + clearTimeout(timer) + options.signal?.removeEventListener('abort', onAbort) + await connection.close().catch(() => undefined) + } +} diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts new file mode 100644 index 000000000000..2262edfaf85f --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' +import { agentModelCatalogFingerprint } from './agent-model-catalog-fingerprint' +import { createAgentModelCatalogService } from './agent-model-catalog-service' +import { + AGENT_MODEL_CATALOG_FAILURE_TTL_MS, + AGENT_MODEL_CATALOG_FRESH_MS, + AgentModelCatalogStore, + type AgentModelCatalogSuccess +} from './agent-model-catalog-store' +import { AgentModelCatalogUnavailableError } from './agent-model-catalog-unavailable' +import { agentReadsProjectModelConfig } from './agent-project-model-override' + +// A client preloads every agent's saved list through `savedOnly`: the answer comes from what the +// host saved, and no listing, probe or re-check of a held reason starts. + +const HOME = { variable: 'GROK_HOME', path: '/homes/grok' } as const +const FINGERPRINT = agentModelCatalogFingerprint({ + agent: 'grok', + accountHome: HOME, + wslDistro: null +}) +const SIGNED_OUT = { reason: 'notSignedIn', account: 'system' } as const + +function listing(id: string): AgentModelCatalogSuccess { + return { + models: [{ id, label: id, isDefault: true, efforts: [] }], + fastModeTierByModel: new Map(), + origin: 'probe' + } +} + +function rig(agent = 'grok') { + let now = 1_000 + const store = new AgentModelCatalogStore({ now: () => now }) + const probe = vi.fn( + (_home: AgentSessionAccountHome) => new Promise(() => {}) + ) + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + drivesRecord: () => true, + resolveAccountHome: async () => HOME, + probes: { [agent]: probe }, + listingNamesConfiguredModel: new Set(['grok']), + agentReadsProjectModelConfig + }) + return { store, probe, service, advance: (ms: number) => (now += ms) } +} + +describe('a saved-only catalog read', () => { + it('answers from the saved entry and starts nothing, however old the entry is', async () => { + const { store, probe, service, advance } = rig() + store.recordSuccess(FINGERPRINT, 'grok', listing('grok-4.7'), 'discovery') + advance(AGENT_MODEL_CATALOG_FRESH_MS) + const result = await service.read({ agent: 'grok', savedOnly: true }) + expect(result).toMatchObject({ origin: 'probe', models: [{ id: 'grok-4.7' }] }) + expect(result).not.toHaveProperty('listingInProgress') + expect(store.pendingListing(FINGERPRINT)).toBeNull() + expect(probe).not.toHaveBeenCalled() + // The same read without `savedOnly` refreshes the aged entry: the probe is what the flag skips. + await service.read({ agent: 'grok' }) + expect(probe).toHaveBeenCalledTimes(1) + }) + + it('answers unknown with nothing saved, and starts no listing for it', async () => { + const { store, probe, service } = rig() + expect(await service.read({ agent: 'grok', savedOnly: true })).toEqual({ origin: 'unknown' }) + expect(store.pendingListing(FINGERPRINT)).toBeNull() + expect(probe).not.toHaveBeenCalled() + }) + + it('carries a held reason without re-checking it past its TTL', async () => { + const { store, probe, service, advance } = rig() + await store + .refresh(FINGERPRINT, 'grok', probe, () => + Promise.reject(new AgentModelCatalogUnavailableError(SIGNED_OUT)) + ) + .catch(() => undefined) + advance(AGENT_MODEL_CATALOG_FAILURE_TTL_MS) + expect(await service.read({ agent: 'grok', savedOnly: true })).toEqual({ + origin: 'unknown', + unavailable: SIGNED_OUT + }) + expect(probe).not.toHaveBeenCalled() + }) + + it('says the named default holds in every workspace only for an agent no project config moves', async () => { + const grok = rig('grok') + grok.store.recordSuccess(FINGERPRINT, 'grok', listing('grok-4.7'), 'discovery') + expect(await grok.service.read({ agent: 'grok', savedOnly: true })).toMatchObject({ + listingNamesConfiguredModel: true, + defaultHoldsInEveryWorkspace: true + }) + // OpenCode's listing names no default, and a project's own config may pick its model. + const opencode = rig('opencode') + const opencodeKey = agentModelCatalogFingerprint({ + agent: 'opencode', + accountHome: HOME, + wslDistro: null + }) + opencode.store.recordSuccess(opencodeKey, 'opencode', listing('a/b'), 'discovery') + expect(await opencode.service.read({ agent: 'opencode', savedOnly: true })).not.toHaveProperty( + 'defaultHoldsInEveryWorkspace' + ) + }) +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts index 14b3ce613ebb..16092382a6fd 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts @@ -1,3 +1,4 @@ +import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { @@ -51,7 +52,7 @@ const CODEX_HOME = (path: string): { variable: 'CODEX_HOME'; path: string } => ( describe('agent model catalog service', () => { it('answers unknown and kicks one probe for a session whose key has never listed', async () => { const store = new AgentModelCatalogStore() - const probe = vi.fn(async (_home: string) => listing('gpt-a')) + const probe = vi.fn(async (_home: AgentSessionAccountHome) => listing('gpt-a')) const service = createAgentModelCatalogService({ store, getRecord: () => record('/homes/a'), @@ -67,7 +68,7 @@ describe('agent model catalog service', () => { // record-scoped read probes the RECORD's pinned home, not the selection. await service.read({ agent: 'codex', sessionId: 'session-1' }) expect(probe).toHaveBeenCalledTimes(1) - expect(probe).toHaveBeenCalledWith('/homes/a') + expect(probe).toHaveBeenCalledWith(CODEX_HOME('/homes/a'), { signal: expect.any(AbortSignal) }) await vi.waitFor(async () => { const result = await service.read({ agent: 'codex', sessionId: 'session-1' }) expect(result.origin).toBe('probe') @@ -76,7 +77,7 @@ describe('agent model catalog service', () => { it('probes as for no record when this build cannot start the record as it is pinned', async () => { const store = new AgentModelCatalogStore() - const probe = vi.fn(async (_home: string) => listing('gpt-a')) + const probe = vi.fn(async (_home: AgentSessionAccountHome) => listing('gpt-a')) // A Codex record pinning Claude's variable: its path is not a Codex home to probe under. const pinned = { ...record('/x'), accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/x' } } const drivesRecord = vi.fn(() => false) @@ -91,14 +92,16 @@ describe('agent model catalog service', () => { await service.read({ agent: 'codex', sessionId: 'session-1' }) expect(drivesRecord).toHaveBeenCalledWith(pinned) - expect(probe).toHaveBeenCalledExactlyOnceWith('/homes/selected') + expect(probe).toHaveBeenCalledExactlyOnceWith(CODEX_HOME('/homes/selected'), { + signal: expect.any(AbortSignal) + }) }) it('an account switch with no record reads and prewarms the NEW account, never the old entry', async () => { const store = new AgentModelCatalogStore() // The old account listed under its own fingerprint before the switch. const oldFingerprint = selectedHomeFingerprint('/homes/old') - store.recordSuccess(oldFingerprint, 'codex', listing('gpt-old')) + store.recordSuccess(oldFingerprint, 'codex', listing('gpt-old'), 'discovery') const probe = vi.fn(async () => listing('gpt-new')) const service = createAgentModelCatalogService({ store, @@ -112,7 +115,9 @@ describe('agent model catalog service', () => { origin: 'unknown', listingInProgress: true }) - expect(probe).toHaveBeenCalledWith('/homes/new') + expect(probe).toHaveBeenCalledWith(CODEX_HOME('/homes/new'), { + signal: expect.any(AbortSignal) + }) await vi.waitFor(async () => { const result = await service.read({ agent: 'codex' }) expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-new') @@ -124,8 +129,18 @@ describe('agent model catalog service', () => { it('a record-less read serves the selected account entry when it exists', async () => { const store = new AgentModelCatalogStore() - store.recordSuccess(selectedHomeFingerprint('/homes/selected'), 'codex', listing('gpt-mine')) - store.recordSuccess(selectedHomeFingerprint('/homes/other'), 'codex', listing('gpt-other')) + store.recordSuccess( + selectedHomeFingerprint('/homes/selected'), + 'codex', + listing('gpt-mine'), + 'discovery' + ) + store.recordSuccess( + selectedHomeFingerprint('/homes/other'), + 'codex', + listing('gpt-other'), + 'discovery' + ) const service = createAgentModelCatalogService({ store, getRecord: () => undefined, @@ -142,12 +157,14 @@ describe('agent model catalog service', () => { store.recordSuccess( agentModelCatalogFingerprintForRecord(sessionRecord), 'codex', - listing('gpt-session') + listing('gpt-session'), + 'discovery' ) store.recordSuccess( selectedHomeFingerprint('/homes/selected'), 'codex', - listing('gpt-selected') + listing('gpt-selected'), + 'discovery' ) const service = createAgentModelCatalogService({ store, @@ -210,7 +227,9 @@ describe('agent model catalog service', () => { return { promise, resolve, reject } } - function coldService(probe: (home: string) => Promise) { + function coldService( + probe: (home: AgentSessionAccountHome) => Promise + ) { const store = new AgentModelCatalogStore() const service = createAgentModelCatalogService({ store, @@ -423,7 +442,12 @@ describe('agent model catalog service', () => { it('serves an aged entry at once and refreshes it behind the answer', async () => { let now = 0 const store = new AgentModelCatalogStore({ now: () => now }) - store.recordSuccess(selectedHomeFingerprint('/homes/selected'), 'codex', listing('gpt-old')) + store.recordSuccess( + selectedHomeFingerprint('/homes/selected'), + 'codex', + listing('gpt-old'), + 'discovery' + ) now = AGENT_MODEL_CATALOG_FRESH_MS const probe = vi.fn(() => new Promise(() => {})) const service = createAgentModelCatalogService({ @@ -442,7 +466,12 @@ describe('agent model catalog service', () => { describe('a read for the workspace a new chat runs in', () => { function serviceWith(mayOverride: boolean) { const store = new AgentModelCatalogStore() - store.recordSuccess(selectedHomeFingerprint('/homes/selected'), 'codex', listing('gpt-user')) + store.recordSuccess( + selectedHomeFingerprint('/homes/selected'), + 'codex', + listing('gpt-user'), + 'discovery' + ) const workspaceMayOverrideDefaultModel = vi.fn(async () => mayOverride) const service = createAgentModelCatalogService({ store, @@ -490,4 +519,59 @@ describe('agent model catalog service', () => { expect(workspaceMayOverrideDefaultModel).not.toHaveBeenCalled() }) }) + + describe('an agent whose account is not one directory', () => { + const managed = (id: string): AgentSessionAccountHome => ({ + kind: 'opencode', + locator: { kind: 'managed', managedProfileId: id } + }) + const PROFILE_A = '0b0c6f5e-4f51-4c4a-9d3e-1a2b3c4d5e6f' + const PROFILE_B = '9f8e7d6c-5b4a-4c3d-8e2f-1a0b9c8d7e6f' + const openCodeRecord = ( + home: AgentSessionAccountHome, + wslDistro: string | null = null + ): AgentSessionRecord => ({ + ...record('/unused'), + provider: 'opencode', + accountHome: home, + location: { ...record('/unused').location, wslDistro } + }) + + it('probes under the account the chat pinned, and keeps each account’s listing apart', async () => { + const store = new AgentModelCatalogStore() + const probe = vi.fn(async (home: AgentSessionAccountHome) => listing(JSON.stringify(home))) + const service = createAgentModelCatalogService({ + store, + getRecord: () => openCodeRecord(managed(PROFILE_A)), + drivesRecord: () => true, + resolveAccountHome: async () => managed(PROFILE_B), + probes: { opencode: probe } + }) + await service.read({ agent: 'opencode', sessionId: 'session-1', waitForListing: true }) + await service.read({ agent: 'opencode', waitForListing: true }) + expect(probe.mock.calls.map(([home]) => home)).toEqual([ + managed(PROFILE_A), + managed(PROFILE_B) + ]) + const pinned = await service.read({ agent: 'opencode', sessionId: 'session-1' }) + const selected = await service.read({ agent: 'opencode' }) + expect(pinned.origin !== 'unknown' && pinned.models[0]!.id).toContain(PROFILE_A) + expect(selected.origin !== 'unknown' && selected.models[0]!.id).toContain(PROFILE_B) + }) + + it('starts no host-side probe for a chat pinned inside WSL', async () => { + const probe = vi.fn(async () => listing('never')) + const service = createAgentModelCatalogService({ + store: new AgentModelCatalogStore(), + getRecord: () => openCodeRecord(managed(PROFILE_A), 'Ubuntu'), + drivesRecord: () => true, + resolveAccountHome: async () => managed(PROFILE_B), + probes: { opencode: probe } + }) + expect(await service.read({ agent: 'opencode', sessionId: 'session-1' })).toEqual({ + origin: 'unknown' + }) + expect(probe).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts index ca2dd6c77a20..92dff5766e7b 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts @@ -8,11 +8,15 @@ import { agentModelCatalogFingerprint, agentModelCatalogFingerprintForRecord } from './agent-model-catalog-fingerprint' +import type { AgentModelCatalogConfiguredChoice } from './agent-model-catalog-entry' import type { AgentModelCatalogEntry, + AgentModelCatalogLiveListing, AgentModelCatalogProbe, - AgentModelCatalogStore + AgentModelCatalogStore, + AgentModelCatalogSuccess } from './agent-model-catalog-store' +import { AgentModelCatalogListingStoppedError } from './agent-model-catalog-failures' export type AgentModelCatalogServiceDeps = { store: AgentModelCatalogStore @@ -26,12 +30,21 @@ export type AgentModelCatalogServiceDeps = { resolveAccountHome: (agent: string) => Promise /** Session-less listers, one per agent that has one on this host. */ probes?: Readonly>> - /** Whether the workspace's own config could pick a model other than the listed default. */ + /** Agents whose listing marks the model the account is configured to run as its default. */ + listingNamesConfiguredModel?: ReadonlySet + /** Where a session's provider runs, for deciding whether its own config scope is the account's. */ + recordWorkspacePath?: (record: AgentSessionRecord) => Promise + /** False for an agent whose model no project config can pick; its default holds everywhere. */ + agentReadsProjectModelConfig?: (agent: string) => boolean + /** Whether the workspace's own config could pick a model other than the listed default; + * `accountHomePath` is the agent's own home folder, which is account config, not a layer. */ workspaceMayOverrideDefaultModel?: (input: { agent: string workspacePath: string - accountHomePath: string + accountHomePath: string | null }) => Promise + /** Whether this host keeps any chat of the agent; with a saved catalog, what marks it in use. */ + hasChatRecords?: (agent: string) => boolean } export type AgentModelCatalogService = { @@ -43,17 +56,33 @@ export type AgentModelCatalogService = { /** With no entry yet, answer from the listing this read starts or joins instead of `unknown`; * with a held reason past its TTL, from the probe re-checking it. */ waitForListing?: boolean + /** Answer only from the saved entry and held reason; start, join or re-check no listing. */ + savedOnly?: boolean }) => Promise + /** Saves what a running session listed as its account's catalog, so the next chat starts warm. */ + recordLiveListing: (sessionId: string, listing: AgentModelCatalogLiveListing) => void + /** Lists, in the background, every agent the user has used here (a saved catalog or a chat) + * whose catalog for the account a new chat would pin is missing or old, so a picker never meets + * a cold catalog. Resolves once those listings settle. */ + prewarm: () => Promise + /** The host is going away: start no listing, and stop the ones running. */ + stop: () => void /** A chat under this record's account proved its start: a held reason is re-checked sooner. */ providerStarted: ( record: Pick ) => void } +// At most this many agents list at once: each listing spawns that agent's CLI. +const PREWARM_CONCURRENCY = 2 + function resultFromEntry( entry: AgentModelCatalogEntry, - namesDefault: boolean -): AgentSessionModelCatalogResult { + namesDefault: boolean, + listingNamesConfiguredModel: boolean +): Exclude { + // A CLI-resolved default names the configured model even where the listing names none. + const namesConfigured = listingNamesConfiguredModel || entry.configured !== null return { origin: entry.origin, // Without a default the picker names nothing until the chat reports its model. @@ -61,7 +90,8 @@ function resultFromEntry( namesDefault ? { ...model } : { ...model, isDefault: false } ), ...(entry.fastModeSupport ? { fastModeSupport: entry.fastModeSupport } : {}), - fetchedAt: entry.fetchedAt + fetchedAt: entry.fetchedAt, + listingNamesConfiguredModel: namesDefault && namesConfigured } } @@ -75,7 +105,7 @@ async function workspaceKeepsListedDefault( if (workspacePath === undefined) { return true } - if (workspacePath === null || !accountHomePath || !deps.workspaceMayOverrideDefaultModel) { + if (workspacePath === null || !deps.workspaceMayOverrideDefaultModel) { return false } try { @@ -85,6 +115,69 @@ async function workspaceKeepsListedDefault( } } +/** The catalog a new chat of `agent` would read: the account a launch would pin right now. */ +async function newChatCatalogKey( + deps: AgentModelCatalogServiceDeps, + agent: string +): Promise<{ fingerprint: string; accountHome: AgentSessionAccountHome } | null> { + let accountHome: AgentSessionAccountHome + try { + accountHome = await deps.resolveAccountHome(agent) + } catch { + return null + } + return { + fingerprint: agentModelCatalogFingerprint({ agent, accountHome, wslDistro: null }), + accountHome + } +} + +/** A session launched with no model pick resolved its config scope's default model and effort; + * when that scope is the account's (a native workspace with no config of its own), they are the + * account's default, and a resolution naming no listed model retires the saved one. */ +async function recordConfiguredDefault( + deps: AgentModelCatalogServiceDeps, + record: AgentSessionRecord, + fingerprint: string, + choice: AgentModelCatalogConfiguredChoice | null +): Promise { + const accountHome = record.accountHome + if ( + record.location.wslDistro !== null || + !deps.recordWorkspacePath || + !deps.workspaceMayOverrideDefaultModel + ) { + return + } + const workspacePath = await deps.recordWorkspacePath(record) + if ( + !workspacePath || + (await deps.workspaceMayOverrideDefaultModel({ + agent: record.provider, + workspacePath, + accountHomePath: isLegacyAgentSessionAccountHome(accountHome) ? accountHome.path : null + })) + ) { + return + } + deps.store.recordConfiguredDefault(fingerprint, choice) +} + +async function runBounded( + items: readonly T[], + limit: number, + stopped: AbortSignal, + run: (item: T) => Promise +): Promise { + const queue = [...items] + const worker = async (): Promise => { + for (let item = queue.shift(); item !== undefined && !stopped.aborted; item = queue.shift()) { + await run(item).catch(() => {}) + } + } + await Promise.all(Array.from({ length: Math.min(limit, queue.length) }, worker)) +} + /** * Serves the host catalog to pickers, never through a session's serialize * queue. A session record names its own catalog (the account home pinned at @@ -100,6 +193,19 @@ async function workspaceKeepsListedDefault( export function createAgentModelCatalogService( deps: AgentModelCatalogServiceDeps ): AgentModelCatalogService { + // One per host: stopping it ends every listing this service started. + const lifetime = new AbortController() + // A chat's configured default still being checked against its workspace, per catalog: a read + // answers after it, so the chat that reported it is followed by an answer naming it. + const recordingDefaults = new Map>() + const listWith = + (probe: AgentModelCatalogProbe, home: AgentSessionAccountHome) => + (): Promise => + probe(home, { signal: lifetime.signal }).catch((error: unknown) => { + throw lifetime.signal.aborted + ? new AgentModelCatalogListingStoppedError(String(error)) + : error + }) return { providerStarted(record) { deps.store.expireFailure(agentModelCatalogFingerprintForRecord(record)) @@ -109,62 +215,61 @@ export function createAgentModelCatalogService( const scoped = record && record.provider === params.agent && deps.drivesRecord(record) ? record : undefined let fingerprint: string - let accountHomePath: string | null + // The account a probe lists under; null where this host cannot spawn one natively. + let probeHome: AgentSessionAccountHome | null if (scoped) { fingerprint = agentModelCatalogFingerprintForRecord(scoped) // Probes spawn natively; a WSL-pinned record has no host-side lister. - accountHomePath = - scoped.location.wslDistro === null && isLegacyAgentSessionAccountHome(scoped.accountHome) - ? scoped.accountHome.path - : null + probeHome = scoped.location.wslDistro === null ? scoped.accountHome : null } else { - let resolved: AgentSessionAccountHome - try { - resolved = await deps.resolveAccountHome(params.agent) - } catch { + const key = await newChatCatalogKey(deps, params.agent) + if (!key) { return { origin: 'unknown' } } - fingerprint = agentModelCatalogFingerprint({ - agent: params.agent, - accountHome: resolved, - wslDistro: null - }) - accountHomePath = isLegacyAgentSessionAccountHome(resolved) ? resolved.path : null + fingerprint = key.fingerprint + probeHome = key.accountHome } + const accountHomePath = + probeHome && isLegacyAgentSessionAccountHome(probeHome) ? probeHome.path : null + await recordingDefaults.get(fingerprint) let entry = deps.store.get(fingerprint) const probe = deps.probes?.[params.agent] - const home = accountHomePath + const home = probeHome // Every answer carries the reason the probe last found, read when the answer is made. const answer = async ( listed: AgentModelCatalogEntry | null, extra: { listingInProgress?: true } = {} ): Promise => { const unavailable = deps.store.failure(fingerprint)?.unavailable + const result = listed + ? resultFromEntry( + listed, + await workspaceKeepsListedDefault( + deps, + params.agent, + params.workspacePath, + accountHomePath + ), + deps.listingNamesConfiguredModel?.has(params.agent) === true + ) + : null + const holdsEverywhere = + result?.listingNamesConfiguredModel === true && + deps.agentReadsProjectModelConfig?.(params.agent) === false return { - ...(listed - ? resultFromEntry( - listed, - await workspaceKeepsListedDefault( - deps, - params.agent, - params.workspacePath, - accountHomePath - ) - ) - : { origin: 'unknown' }), + ...(result ?? { origin: 'unknown' }), + ...(holdsEverywhere ? { defaultHoldsInEveryWorkspace: true as const } : {}), ...extra, ...(unavailable ? { unavailable } : {}) } } + if (params.savedOnly) { + return answer(entry) + } // Past its TTL, only the probe re-derives a held reason. The reason is served meanwhile; // only a read that asks waits for the probe's answer. - if ( - probe && - home && - deps.store.failure(fingerprint)?.unavailable && - !deps.store.hasActiveFailure(fingerprint) - ) { - const probing = deps.store.refresh(fingerprint, params.agent, probe, () => probe(home)) + if (probe && home && !lifetime.signal.aborted && deps.store.heldReasonDue(fingerprint)) { + const probing = deps.store.refresh(fingerprint, params.agent, probe, listWith(probe, home)) if (!params.waitForListing) { return answer(entry, { listingInProgress: true }) } @@ -173,11 +278,11 @@ export function createAgentModelCatalogService( } // Without an entry, answer from any running listing instead of starting a second one. let listing = !entry && home ? deps.store.pendingListing(fingerprint) : null - if (probe && home) { + if (probe && home && !lifetime.signal.aborted) { if (entry && deps.store.shouldRefresh(fingerprint)) { - void deps.store.refresh(fingerprint, params.agent, probe, () => probe(home)) + void deps.store.refresh(fingerprint, params.agent, probe, listWith(probe, home)) } else if (!entry && !listing && !deps.store.hasActiveFailure(fingerprint)) { - void deps.store.refresh(fingerprint, params.agent, probe, () => probe(home)) + void deps.store.refresh(fingerprint, params.agent, probe, listWith(probe, home)) listing = deps.store.pendingListing(fingerprint) } } @@ -192,6 +297,55 @@ export function createAgentModelCatalogService( entry = deps.store.get(fingerprint) ?? listed } return answer(entry) + }, + recordLiveListing(sessionId, listing) { + const record = deps.getRecord(sessionId) + if (!record || !deps.drivesRecord(record)) { + return + } + // The record's pinned account and host: the account this child listed under. + const fingerprint = agentModelCatalogFingerprintForRecord(record) + const { configuredDefault, ...listed } = listing + const saved = deps.store.recordSuccess( + fingerprint, + record.provider, + { ...listed, fastModeTierByModel: new Map(), origin: 'live-session' }, + 'live' + ) + if (saved && configuredDefault !== undefined) { + // In report order, so the latest chat's resolution is the one kept. + const recording = (recordingDefaults.get(fingerprint) ?? Promise.resolve()) + .then(() => recordConfiguredDefault(deps, record, fingerprint, configuredDefault)) + .catch(() => {}) + .then(() => { + if (recordingDefaults.get(fingerprint) === recording) { + recordingDefaults.delete(fingerprint) + } + }) + recordingDefaults.set(fingerprint, recording) + } + }, + async prewarm() { + if (lifetime.signal.aborted) { + return + } + const probes = deps.probes ?? {} + // An agent never used here waits for its first chat, which shows the quiet placeholder. + const used = Object.keys(probes).filter( + (agent) => deps.store.hasEntryForAgent(agent) || deps.hasChatRecords?.(agent) === true + ) + await runBounded(used, PREWARM_CONCURRENCY, lifetime.signal, async (agent) => { + const probe = probes[agent] + const key = probe ? await newChatCatalogKey(deps, agent) : null + // A fresh entry, a listing already running, or a recent failure each mean nothing to do. + if (!probe || !key || lifetime.signal.aborted || !deps.store.probeDue(key.fingerprint)) { + return + } + await deps.store.refresh(key.fingerprint, agent, probe, listWith(probe, key.accountHome)) + }) + }, + stop() { + lifetime.abort() } } } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts index 09bc3f977d92..33b60f0cd6f0 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts @@ -47,7 +47,7 @@ describe('agent model catalog store', () => { it('serves an entry at any age and flags staleness at the refresh threshold', () => { let at = 1_000 const store = new AgentModelCatalogStore({ now: () => at }) - store.recordSuccess('fp-1', 'codex', success('gpt-a')) + store.recordSuccess('fp-1', 'codex', success('gpt-a'), 'discovery') const entry = store.get('fp-1')! expect(entry.models.map((model) => model.id)).toEqual(['gpt-a']) expect(store.isStale(entry)).toBe(false) @@ -60,11 +60,16 @@ describe('agent model catalog store', () => { it('never memoizes an empty list as a catalog', () => { const store = new AgentModelCatalogStore() expect( - store.recordSuccess('fp-1', 'codex', { - models: [], - fastModeTierByModel: new Map(), - origin: 'live-session' - }) + store.recordSuccess( + 'fp-1', + 'codex', + { + models: [], + fastModeTierByModel: new Map(), + origin: 'live-session' + }, + 'discovery' + ) ).toBeNull() expect(store.get('fp-1')).toBeNull() }) @@ -72,7 +77,7 @@ describe('agent model catalog store', () => { it('holds a failure under its TTL without touching the last good entry', () => { let at = 1_000 const store = new AgentModelCatalogStore({ now: () => at }) - store.recordSuccess('fp-1', 'codex', success('gpt-a')) + store.recordSuccess('fp-1', 'codex', success('gpt-a'), 'discovery') store.recordFailure('fp-1', 'timed out') expect(store.get('fp-1')!.models.map((model) => model.id)).toEqual(['gpt-a']) expect(store.hasActiveFailure('fp-1')).toBe(true) @@ -103,7 +108,9 @@ describe('agent model catalog store', () => { let failProbe!: (error: Error) => void const hungProbe: AgentModelCatalogProbe = () => new Promise((_resolve, reject) => (failProbe = reject)) - const probe = store.refresh('fp-1', 'codex', hungProbe, () => hungProbe('/homes/a')) + const probe = store.refresh('fp-1', 'codex', hungProbe, () => + hungProbe({ variable: 'CODEX_HOME', path: '/homes/a' }) + ) expect(store.shouldRefresh('fp-1')).toBe(false) const live = await store.refresh('fp-1', 'codex', liveLister(store), async () => @@ -216,13 +223,35 @@ describe('agent model catalog store', () => { let settleProbe!: (success: AgentModelCatalogSuccess) => void const probe: AgentModelCatalogProbe = () => new Promise((resolve) => (settleProbe = resolve)) - const pending = store.refresh('fp-1', 'codex', probe, () => probe('/homes/a')) - store.recordSuccess('fp-1', 'codex', success('gpt-live')) + const pending = store.refresh('fp-1', 'codex', probe, () => + probe({ variable: 'CODEX_HOME', path: '/homes/a' }) + ) + store.recordSuccess('fp-1', 'codex', success('gpt-live'), 'discovery') settleProbe({ ...success('gpt-probe'), origin: 'probe' }) expect((await pending)!.models[0]!.id).toBe('gpt-probe') expect(store.get('fp-1')!.models[0]!.id).toBe('gpt-live') }) + it('still writes a pending probe that resolves after a live save, keeping both', async () => { + const store = new AgentModelCatalogStore() + let settleProbe!: (success: AgentModelCatalogSuccess) => void + const probe: AgentModelCatalogProbe = () => + new Promise((resolve) => (settleProbe = resolve)) + const pending = store.refresh('fp-1', 'codex', probe, () => + probe({ variable: 'CODEX_HOME', path: '/homes/a' }) + ) + store.recordSuccess('fp-1', 'codex', success('gpt-live'), 'live') + settleProbe({ ...success('gpt-probe'), origin: 'probe' }) + await pending + const entry = store.get('fp-1')! + expect(entry.discovered!.models[0]!.id).toBe('gpt-probe') + expect(entry.live!.models[0]!.id).toBe('gpt-live') + // A live save neither postpones the next discovery nor clears its failure back-off. + store.recordFailure('fp-1', 'timed out') + store.recordSuccess('fp-1', 'codex', success('gpt-live'), 'live') + expect(store.hasActiveFailure('fp-1')).toBe(true) + }) + it('keeps an older successful listing when the newer entry was evicted', async () => { const store = new AgentModelCatalogStore() let settleOlder!: (success: AgentModelCatalogSuccess) => void @@ -234,7 +263,7 @@ describe('agent model catalog store', () => { ) await store.refresh('fp-1', 'codex', liveLister(store), async () => success('gpt-new')) for (let index = 0; index < AGENT_MODEL_CATALOG_MAX_ENTRIES; index++) { - store.recordSuccess(`other-${index}`, 'codex', success('other')) + store.recordSuccess(`other-${index}`, 'codex', success('other'), 'discovery') } expect(store.get('fp-1')).toBeNull() const save = vi.fn() @@ -274,7 +303,7 @@ describe('agent model catalog store', () => { }) expect(fingerprintA).not.toBe(fingerprintB) const store = new AgentModelCatalogStore() - store.recordSuccess(fingerprintA, 'codex', success('gpt-a')) + store.recordSuccess(fingerprintA, 'codex', success('gpt-a'), 'discovery') expect(store.get(fingerprintB)).toBeNull() }) @@ -304,12 +333,12 @@ describe('agent model catalog store', () => { const store = new AgentModelCatalogStore({ now: () => at }) const save = vi.fn() void store.attachPersistence({ load: async () => [], save, flush: async () => {} }) - store.recordSuccess('fp', 'claude', success('opus')) + store.recordSuccess('fp', 'claude', success('opus'), 'discovery') at += AGENT_MODEL_CATALOG_FRESH_MS - store.recordSuccess('fp', 'claude', success('opus')) + store.recordSuccess('fp', 'claude', success('opus'), 'discovery') expect(save).toHaveBeenCalledTimes(1) expect(store.shouldRefresh('fp')).toBe(false) - store.recordSuccess('fp', 'claude', success('opus', 'sonnet')) + store.recordSuccess('fp', 'claude', success('opus', 'sonnet'), 'discovery') expect(save).toHaveBeenCalledTimes(2) }) @@ -334,9 +363,9 @@ describe('agent model catalog store', () => { fastModeTierByModel: new Map(), origin: 'live-session' }) - store.recordSuccess('fp', 'claude', listing('medium')) + store.recordSuccess('fp', 'claude', listing('medium'), 'live') // A session-less probe never names Claude's default. - store.recordSuccess('fp', 'claude', { ...listing(), origin: 'probe' }) + store.recordSuccess('fp', 'claude', { ...listing(), origin: 'probe' }, 'discovery') expect(store.get('fp')!.models[0]!.defaultEffort).toBe('medium') await store.flushPersistence() const restarted = new AgentModelCatalogStore() @@ -344,20 +373,63 @@ describe('agent model catalog store', () => { expect(restarted.get('fp')!.models[0]!.defaultEffort).toBe('medium') // A newer report replaces it; a model that stops offering it drops it. - store.recordSuccess('fp', 'claude', listing('high')) + store.recordSuccess('fp', 'claude', listing('high'), 'live') expect(store.get('fp')!.models[0]!.defaultEffort).toBe('high') - store.recordSuccess('fp', 'claude', { - ...listing(), - models: [{ id: 'opus', label: 'Opus', isDefault: true, efforts: [efforts[0]!] }] - }) + store.recordSuccess( + 'fp', + 'claude', + { + ...listing(), + models: [{ id: 'opus', label: 'Opus', isDefault: true, efforts: [efforts[0]!] }] + }, + 'live' + ) expect(store.get('fp')!.models[0]).not.toHaveProperty('defaultEffort') }) + it('keeps a CLI-resolved configured default through later listings and a restart', async () => { + const directory = mkdtempSync(join(tmpdir(), 'agent-model-catalog-')) + const store = new AgentModelCatalogStore() + await store.attachPersistence(createAgentModelCatalogFilePersistence(directory)) + // Never on its own: there is no catalog to name a default in yet. + store.recordConfiguredDefault('fp', { modelId: 'sonnet' }) + expect(store.get('fp')).toBeNull() + + store.recordSuccess('fp', 'claude', success('opus', 'sonnet'), 'live') + store.recordConfiguredDefault('fp', { modelId: 'sonnet', effort: 'high' }) + store.recordSuccess( + 'fp', + 'claude', + { ...success('opus', 'sonnet'), origin: 'probe' }, + 'discovery' + ) + const named = (entry: ReturnType) => + entry?.models.filter((model) => model.isDefault).map((model) => model.id) + expect(named(store.get('fp'))).toEqual(['sonnet']) + + await store.flushPersistence() + const restarted = new AgentModelCatalogStore() + await restarted.attachPersistence(createAgentModelCatalogFilePersistence(directory)) + expect(restarted.get('fp')?.configured).toMatchObject({ + modelId: 'sonnet', + effort: 'high' + }) + expect(named(restarted.get('fp'))).toEqual(['sonnet']) + const sonnet = restarted.get('fp')?.models.find((model) => model.id === 'sonnet') + expect(sonnet?.defaultEffort).toBe('high') + + restarted.recordConfiguredDefault('fp', null) + expect(restarted.get('fp')?.configured).toBeNull() + expect(restarted.get('fp')?.models.find((model) => model.id === 'sonnet')).not.toHaveProperty( + 'defaultEffort' + ) + }) + it('persists successes only and hydrates them across a restart', async () => { const directory = mkdtempSync(join(tmpdir(), 'agent-model-catalog-')) const store = new AgentModelCatalogStore() await store.attachPersistence(createAgentModelCatalogFilePersistence(directory)) - store.recordSuccess('fp-1', 'codex', success('gpt-a')) + store.recordSuccess('fp-1', 'codex', success('gpt-a'), 'discovery') store.recordFailure('fp-2', 'timed out') await vi.waitFor( async () => { @@ -380,7 +452,7 @@ describe('agent model catalog store', () => { const directory = mkdtempSync(join(tmpdir(), 'agent-model-catalog-')) const store = new AgentModelCatalogStore() await store.attachPersistence(createAgentModelCatalogFilePersistence(directory)) - store.recordSuccess('fp-1', 'codex', success('gpt-a')) + store.recordSuccess('fp-1', 'codex', success('gpt-a'), 'discovery') await store.flushPersistence() const persisted = await createAgentModelCatalogFilePersistence(directory).load() expect(persisted.map((entry) => entry.fingerprint)).toEqual(['fp-1']) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts index 89d434ed11eb..6a2a6e58192b 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts @@ -1,50 +1,59 @@ -import type { AgentSessionUnavailable } from '../../../shared/agent-session-availability' -import type { - AgentSessionFastModeSupport, - AgentSessionModelOption -} from '../../../shared/agent-session-wire' +import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' import type { AgentModelCatalogPersistence } from './agent-model-catalog-persistence' +import { startSpan } from '../../observability/tracer' +import { + agentModelCatalogEntryWithSuccess, + agentModelCatalogListingKey, + entryWithConfiguredDefault, + type AgentModelCatalogConfiguredChoice, + type AgentModelCatalogEntry, + type AgentModelCatalogSource, + type AgentModelCatalogSuccess +} from './agent-model-catalog-entry' + +export type { + AgentModelCatalogEntry, + AgentModelCatalogListing, + AgentModelCatalogLiveListing, + AgentModelCatalogSource, + AgentModelCatalogSuccess +} from './agent-model-catalog-entry' +export { withLiveCatalogListing } from './agent-model-catalog-entry' import { AGENT_MODEL_CATALOG_FAILURE_TTL_MS, AgentModelCatalogFailures, + AgentModelCatalogListingStoppedError, type AgentModelCatalogFailure } from './agent-model-catalog-failures' +import { + AGENT_MODEL_CATALOG_PICKER_WAIT_MS, + AgentModelCatalogListingWaiters +} from './agent-model-catalog-listing-waiters' -export { AGENT_MODEL_CATALOG_FAILURE_TTL_MS, type AgentModelCatalogFailure } +export { AGENT_MODEL_CATALOG_FAILURE_TTL_MS, AGENT_MODEL_CATALOG_PICKER_WAIT_MS } +export type { AgentModelCatalogFailure } // The execution host's one model catalog per (agent, launch fingerprint): // served immediately at any age, refreshed in the background when old, and // written through by every successful listing a live session already performs. +// Each entry keeps two listings: the last account-level discovery (the only +// source of the configured default and default efforts, and the only clock a +// refresh follows) and the last live session's listing (which models exist and +// their efforts). Readers see the two merged; neither write erases the other. // Success-only: a failure, timeout or empty list is never stored as a catalog // and never persisted — it is held separately under a short TTL so a burst of // picker opens does not hammer a dead binary, then dies on its own (see // `AgentModelCatalogFailures`, which also holds why no chat can start under the account). export const AGENT_MODEL_CATALOG_FRESH_MS = 10 * 60_000 -export const AGENT_MODEL_CATALOG_PICKER_WAIT_MS = 30_000 export const AGENT_MODEL_CATALOG_MAX_ENTRIES = 256 -export type AgentModelCatalogEntry = { - agent: string - fingerprint: string - models: AgentSessionModelOption[] - fastModeSupport?: AgentSessionFastModeSupport - /** Provider-advertised Fast tier per model id; derived from the same listing. */ - fastModeTierByModel: Record - origin: 'live-session' | 'probe' - fetchedAt: number -} - -export type AgentModelCatalogSuccess = { - models: AgentSessionModelOption[] - fastModeSupport?: AgentSessionFastModeSupport - fastModeTierByModel: ReadonlyMap - origin: 'live-session' | 'probe' - /** A probe that listed models but also found no chat can start (a signed-out Codex): both kept. */ - unavailable?: AgentSessionUnavailable -} - -export type AgentModelCatalogProbe = (accountHomePath: string) => Promise +/** Lists an agent's models without a session, under the account a launch would pin. `signal` + * stops the listing and its child once the host that asked is going away. */ +export type AgentModelCatalogProbe = ( + accountHome: AgentSessionAccountHome, + options?: { signal?: AbortSignal } +) => Promise /** Who lists, by identity: a live session's per-spawn handle, or the session-less probe. */ export type AgentModelCatalogLister = AgentModelCatalogSessionAccess | AgentModelCatalogProbe @@ -60,41 +69,17 @@ export type AgentModelCatalogSessionAccess = { accountHomePath: string } -function tierRecord(tiers: ReadonlyMap): Record { - return Object.fromEntries(tiers.entries()) -} - -/** A listing that names no default effort for a model keeps the one a live child reported for it, - * while that model still offers it: Claude's listing never names one, only a running child does. */ -function withKnownDefaultEfforts( - models: readonly AgentSessionModelOption[], - previous: AgentModelCatalogEntry | undefined -): AgentSessionModelOption[] { - return models.map((model) => { - const known = previous?.models.find((entry) => entry.id === model.id)?.defaultEffort - return model.defaultEffort === undefined && - known !== undefined && - model.efforts.some((choice) => choice.value === known) - ? { ...model, defaultEffort: known } - : { ...model } - }) -} - -function listingKey(entry: AgentModelCatalogEntry): string { - return JSON.stringify([ - entry.origin, - entry.models, - entry.fastModeSupport ?? null, - entry.fastModeTierByModel - ]) -} - export class AgentModelCatalogStore { private readonly entries = new Map() private readonly failures: AgentModelCatalogFailures private readonly refreshes = new Map() - private readonly listingWaiters = new Map void>>() + private readonly listingWaiters = new AgentModelCatalogListingWaiters( + (fingerprint) => this.get(fingerprint), + (fingerprint) => this.refreshes.has(fingerprint) + ) private readonly latestWrittenOrder = new Map() + // Catalogs listed under a command or env the agent no longer launches with; cleared by a discovery. + private readonly dueEntries = new Set() private nextListingOrder = 0 private persistence: AgentModelCatalogPersistence | null = null private readonly now: () => number @@ -130,8 +115,23 @@ export class AgentModelCatalogStore { return entry } + /** Whether any account's catalog of `agent` is saved here. */ + hasEntryForAgent(agent: string): boolean { + for (const entry of this.entries.values()) { + if (entry.agent === agent) { + return true + } + } + return false + } + + /** Only a discovery ages: live saves never postpone the next account-level listing. */ isStale(entry: AgentModelCatalogEntry): boolean { - return this.now() - entry.fetchedAt >= AGENT_MODEL_CATALOG_FRESH_MS + return ( + this.dueEntries.has(entry.fingerprint) || + !entry.discovered || + this.now() - entry.discovered.at >= AGENT_MODEL_CATALOG_FRESH_MS + ) } failureDetail(fingerprint: string): string | null { @@ -156,57 +156,85 @@ export class AgentModelCatalogStore { this.failures.expire(fingerprint) } + /** The agent's command or launch env changed: each of its catalogs and held reasons is due. */ + expireAgent(agent: string): void { + for (const entry of this.entries.values()) { + if (entry.agent === agent) { + this.dueEntries.add(entry.fingerprint) + } + } + this.failures.expireAgent(agent) + } + + /** The one ingestion step every listing goes through, whoever listed it. */ recordSuccess( fingerprint: string, agent: string, - success: AgentModelCatalogSuccess + success: AgentModelCatalogSuccess, + source: AgentModelCatalogSource ): AgentModelCatalogEntry | null { - const entry = this.writeSuccess(fingerprint, agent, success, ++this.nextListingOrder) - this.notifyListingWaiters(fingerprint) + const entry = this.writeSuccess(fingerprint, agent, success, source, ++this.nextListingOrder) + this.listingWaiters.notify(fingerprint) return entry } private entryFromSuccess( fingerprint: string, agent: string, - success: AgentModelCatalogSuccess + success: AgentModelCatalogSuccess, + source: AgentModelCatalogSource ): AgentModelCatalogEntry | null { - if (success.models.length === 0) { - // An empty list identifies no model; it is doubt, not a catalog. - return null - } + return agentModelCatalogEntryWithSuccess( + this.entries.get(fingerprint), + { agent, fingerprint }, + success, + source, + this.now() + ) + } + + /** Records which model and effort the account's own config resolves to, for an agent whose + * listing names none; null forgets a default that config no longer resolves to. Superseded by + * the next chat that resolves it; never creates an entry on its own. */ + recordConfiguredDefault( + fingerprint: string, + choice: AgentModelCatalogConfiguredChoice | null + ): void { const previous = this.entries.get(fingerprint) - return { - agent, - fingerprint, - models: withKnownDefaultEfforts(success.models, previous), - ...(success.fastModeSupport ? { fastModeSupport: success.fastModeSupport } : {}), - fastModeTierByModel: tierRecord(success.fastModeTierByModel), - origin: success.origin, - fetchedAt: this.now() + const entry = previous && entryWithConfiguredDefault(previous, choice, this.now()) + if (!entry) { + return } + this.entries.set(fingerprint, entry) + this.persistence?.save([...this.entries.values()]) + this.listingWaiters.notify(fingerprint) } private writeSuccess( fingerprint: string, agent: string, success: AgentModelCatalogSuccess, + source: AgentModelCatalogSource, order: number ): AgentModelCatalogEntry | null { - const entry = this.entryFromSuccess(fingerprint, agent, success) + const entry = this.entryFromSuccess(fingerprint, agent, success, source) if (!entry) { return null } const previous = this.entries.get(fingerprint) this.entries.delete(fingerprint) this.entries.set(fingerprint, entry) - if (this.refreshes.has(fingerprint)) { + if (source === 'discovery' && this.refreshes.has(fingerprint)) { this.latestWrittenOrder.set(fingerprint, order) } - this.failures.listed(fingerprint, agent, success.origin, success.unavailable) + // A live listing is not the account's answer: it neither clears a failure nor a probe's verdict. + if (source === 'discovery') { + this.dueEntries.delete(fingerprint) + this.failures.listed(fingerprint, agent, success.origin, success.unavailable) + } this.evictOverCap() // Live sessions re-list every turn; an unchanged listing only refreshes the in-memory age. - if (!previous || listingKey(previous) !== listingKey(entry)) { + if (!previous || agentModelCatalogListingKey(previous) !== agentModelCatalogListingKey(entry)) { this.persistence?.save([...this.entries.values()]) } return entry @@ -216,7 +244,7 @@ export class AgentModelCatalogStore { this.failures.chatFailed(fingerprint, detail, agent) } - /** Joins an in-flight refresh by the same lister rather than starting a second. Never + /** A discovery listing. Joins an in-flight refresh by the same lister rather than starting a second. Never * joins another lister's: a probe or another chat's Codex that hangs must not decide * whether this chat starts. Resolves with the entry on success, null on failure. */ refresh( @@ -236,28 +264,37 @@ export class AgentModelCatalogStore { this.refreshes.delete(fingerprint) this.latestWrittenOrder.delete(fingerprint) } - this.notifyListingWaiters(fingerprint) + this.listingWaiters.notify(fingerprint) } const order = ++this.nextListingOrder + // Agent, outcome and duration only: a slow listing shows in the trace log without its content. + const span = startSpan('agentModelCatalog.discovery', { + attributes: { agent, lister: typeof lister === 'function' ? 'probe' : 'session' } + }) const run = listModels().then( (success) => { - // An older session still receives its own result, but cannot replace a newer catalog. + span.setAttribute('models', success.models.length) + span.end() + // An older lister still receives its own result, but cannot replace a newer discovery. const superseded = (this.latestWrittenOrder.get(fingerprint) ?? 0) > order && this.entries.has(fingerprint) if (superseded && success.origin === 'probe') { this.failures.listed(fingerprint, agent, success.origin, success.unavailable) } const entry = superseded - ? this.entryFromSuccess(fingerprint, agent, success) - : this.writeSuccess(fingerprint, agent, success, order) + ? this.entryFromSuccess(fingerprint, agent, success, 'discovery') + : this.writeSuccess(fingerprint, agent, success, 'discovery', order) settle() return entry }, (error: unknown) => { + span.fail(error instanceof Error ? error : String(error)) settle() // Probes are functions; a live session lists through its access object. if (typeof lister === 'function') { - this.failures.probeFailed(fingerprint, agent, error) + if (!(error instanceof AgentModelCatalogListingStoppedError)) { + this.failures.probeFailed(fingerprint, agent, error) + } } else { this.recordFailure( fingerprint, @@ -276,44 +313,7 @@ export class AgentModelCatalogStore { /** A picker follows the current account work until a catalog lands, all work ends, * or its fixed deadline expires. */ pendingListing(fingerprint: string): Promise | null { - if (!this.refreshes.has(fingerprint)) { - return null - } - return new Promise((resolve) => { - const waiters = this.listingWaiters.get(fingerprint) ?? new Set<() => void>() - let settled = false - const finish = (entry: AgentModelCatalogEntry | null): void => { - if (settled) { - return - } - settled = true - clearTimeout(deadline) - waiters.delete(check) - if (waiters.size === 0) { - this.listingWaiters.delete(fingerprint) - } - resolve(entry) - } - const check = (): void => { - const entry = this.get(fingerprint) - if (entry || !this.refreshes.has(fingerprint)) { - finish(entry) - } - } - const deadline = setTimeout( - () => finish(this.get(fingerprint)), - AGENT_MODEL_CATALOG_PICKER_WAIT_MS - ) - waiters.add(check) - this.listingWaiters.set(fingerprint, waiters) - check() - }) - } - - private notifyListingWaiters(fingerprint: string): void { - for (const check of this.listingWaiters.get(fingerprint) ?? []) { - check() - } + return this.listingWaiters.wait(fingerprint) } /** True when a read should kick a background refresh: nothing known or the @@ -326,12 +326,30 @@ export class AgentModelCatalogStore { return !entry || this.isStale(entry) } + /** A probe's reason past its TTL. Only a probe re-derives it, however fresh the catalog beside + * it, so only probe paths ask; a chat's own listing would never clear it. */ + heldReasonDue(fingerprint: string): boolean { + return ( + this.failures.get(fingerprint)?.unavailable !== undefined && + !this.hasActiveFailure(fingerprint) + ) + } + + /** Whether a session-less probe should list now: the refresh rule, or a held reason due. */ + probeDue(fingerprint: string): boolean { + return ( + this.shouldRefresh(fingerprint) || + (!this.refreshes.has(fingerprint) && this.heldReasonDue(fingerprint)) + ) + } + private evictOverCap(): void { for (const key of this.entries.keys()) { if (this.entries.size <= AGENT_MODEL_CATALOG_MAX_ENTRIES) { return } this.entries.delete(key) + this.dueEntries.delete(key) } } } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts index 87868ef82f05..c822ccc14c6d 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session-wire' import { agentModelCatalogFingerprint } from './agent-model-catalog-fingerprint' import { createAgentModelCatalogService } from './agent-model-catalog-service' -import { expireAgentModelCatalogFailuresForSettings } from './agent-model-catalog-account-expiry' +import { + agentsWithChangedLaunchSettings, + createAgentModelCatalogSettingsExpiry, + expireAgentModelCatalogFailuresForSettings +} from './agent-model-catalog-account-expiry' import { AGENT_MODEL_CATALOG_FAILURE_TTL_MS, AgentModelCatalogStore, @@ -11,11 +15,13 @@ import { } from './agent-model-catalog-store' import { AgentModelCatalogUnavailableError } from './agent-model-catalog-unavailable' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' // Why a chat cannot start, as the probe found it: the reason rides every catalog answer until a // later probe answers again, and a chat's own listing never replaces it. const HOME = '/homes/selected' +const ACCOUNT_HOME = { variable: 'CODEX_HOME', path: HOME } as const const FINGERPRINT = agentModelCatalogFingerprint({ agent: 'codex', accountHomeVariable: 'CODEX_HOME', @@ -49,12 +55,14 @@ function rig(initialProbe: AgentModelCatalogProbe = signedOut) { let now = 1_000 let answer = initialProbe const store = new AgentModelCatalogStore({ now: () => now }) - const probe = vi.fn((home: string) => answer(home)) + const probe = vi.fn((home: AgentSessionAccountHome, options?: { signal?: AbortSignal }) => + answer(home, options) + ) const service = createAgentModelCatalogService({ store, getRecord: () => undefined, drivesRecord: () => true, - resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: HOME }), + resolveAccountHome: async () => ACCOUNT_HOME, probes: { codex: probe } }) const chat = { store, fingerprint: FINGERPRINT, accountHomePath: HOME } @@ -64,7 +72,7 @@ function rig(initialProbe: AgentModelCatalogProbe = signedOut) { service, chat, /** Lets the probe's verdict land. */ - probed: () => store.refresh(FINGERPRINT, 'codex', probe, () => probe(HOME)), + probed: () => store.refresh(FINGERPRINT, 'codex', probe, () => probe(ACCOUNT_HOME)), answerWith: (next: AgentModelCatalogProbe) => (answer = next), pastTtl: () => (now += AGENT_MODEL_CATALOG_FAILURE_TTL_MS) } @@ -188,7 +196,7 @@ describe('a catalog read that finds a held reason', () => { it('past the TTL, serves it at once and starts one probe; a waiting read gets its answer', async () => { const { store, probe, probed, pastTtl, answerWith, service } = rig() await probed() - store.recordSuccess(FINGERPRINT, 'codex', listing('gpt-live', 'live-session')) + store.recordSuccess(FINGERPRINT, 'codex', listing('gpt-live', 'live-session'), 'live') pastTtl() const signIn = deferred() answerWith(() => signIn.promise) @@ -241,3 +249,85 @@ describe('a catalog read that finds a held reason', () => { }) }) }) + +describe('a probe the host stops', () => { + it('leaves a held reason as it was', async () => { + const { store, service, probed, answerWith, pastTtl } = rig() + await probed() + pastTtl() + answerWith( + (_home, options) => + new Promise((_resolve, reject) => + options?.signal?.addEventListener('abort', () => reject(new Error('session stopped'))) + ) + ) + await service.read({ agent: 'codex' }) + service.stop() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(store.failure(FINGERPRINT)?.unavailable).toEqual(SIGNED_OUT) + expect(store.hasActiveFailure(FINGERPRINT)).toBe(false) + }) +}) + +describe('a prewarm probe', () => { + it('sets and clears the reason as a read’s probe does, re-checking it however fresh the catalog', async () => { + const { store, probe, service, answerWith, pastTtl } = rig(async () => ({ + ...listing('gpt-gateway', 'probe'), + unavailable: SIGNED_OUT + })) + // A saved catalog marks the agent as used here. + store.recordSuccess(FINGERPRINT, 'codex', listing('gpt-live', 'live-session'), 'live') + await service.prewarm() + expect(store.failure(FINGERPRINT)?.unavailable).toEqual(SIGNED_OUT) + answerWith(async () => listing('gpt-probe', 'probe')) + await service.prewarm() + expect(probe).toHaveBeenCalledTimes(1) + pastTtl() + expect(store.isStale(store.get(FINGERPRINT)!)).toBe(false) + await service.prewarm() + expect(probe).toHaveBeenCalledTimes(2) + expect(store.failure(FINGERPRINT)).toBeNull() + }) +}) + +describe("an agent's command or environment change", () => { + it('re-lists its fresh catalog and re-checks a held "not installed" at the next prewarm', async () => { + const { store, probe, service, probed, answerWith } = rig(() => + Promise.reject(new AgentModelCatalogUnavailableError({ reason: 'cliMissing' })) + ) + store.recordSuccess(FINGERPRINT, 'codex', listing('gpt-saved', 'probe'), 'discovery') + await probed() + expect(store.failure(FINGERPRINT)?.unavailable).toEqual({ reason: 'cliMissing' }) + await service.prewarm() + expect(probe).toHaveBeenCalledTimes(1) + const expire = createAgentModelCatalogSettingsExpiry(store, { agentCmdOverrides: {} }) + const fixed = { agentCmdOverrides: { codex: '/opt/codex/bin/codex' } } + expire(fixed, fixed) + answerWith(async () => listing('gpt-new', 'probe')) + await service.prewarm() + expect(probe).toHaveBeenCalledTimes(2) + expect(store.failure(FINGERPRINT)).toBeNull() + expect(store.get(FINGERPRINT)!.models.map((model) => model.id)).toEqual(['gpt-new']) + // The new listing is the fresh one: nothing is due until the next change. + await service.prewarm() + expect(probe).toHaveBeenCalledTimes(2) + }) + + it('touches only the agents whose command or env changed', () => { + expect( + agentsWithChangedLaunchSettings( + { + agentCmdOverrides: { codex: 'codex', claude: 'claude' }, + agentDefaultEnv: { claude: { A: '1', B: '2' } } + }, + { + agentCmdOverrides: { codex: 'codex', claude: 'claude' }, + agentDefaultEnv: { claude: { B: '2', A: '1' }, pi: { KEY: 'x' } } + } + ) + ).toEqual(['pi']) + expect(agentsWithChangedLaunchSettings({ agentCmdOverrides: { codex: 'codex' } }, {})).toEqual([ + 'codex' + ]) + }) +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-project-model-config-keys.ts b/src/main/native-chat/agent-model-catalog/agent-project-model-config-keys.ts new file mode 100644 index 000000000000..fce96f166aa9 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-project-model-config-keys.ts @@ -0,0 +1,172 @@ +import { parse as parseJsonc, type ParseError } from 'jsonc-parser' +import { parse as parseToml } from 'smol-toml' +import { parse as parseYaml } from 'yaml' + +// Which keys in a project's own agent config can move a new chat off the account's model or +// effort. A file that sets none (permissions, hooks, MCP servers) leaves the account default. + +function record(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value)) + : null +} + +function setsAny(table: Record, keys: readonly string[]): boolean { + return keys.some((key) => table[key] !== undefined) +} + +// Claude settings keys that pick the model, its effort or effort cap, or which models Default may +// resolve to; `agent` applies that agent's model to the main thread. +const CLAUDE_MODEL_SETTINGS = [ + 'model', + 'agent', + 'effortLevel', + 'maxEffortLevel', + 'modelSettings', + 'availableModels', + 'enforceAvailableModels', + 'modelOverrides' +] as const + +/** The model, alias-target and effort env vars Claude Code reads, plus the provider switches + * that change which model ids exist at all. */ +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 + ) + ) +} + +/** True when a Claude settings file's text could pick a model or effort; unreadable JSON counts. */ +export function claudeSettingsMayPickModel(text: string): boolean { + let settings: Record | null + try { + settings = record(JSON.parse(text)) + } catch { + return true + } + if (!settings) { + return true + } + if (setsAny(settings, CLAUDE_MODEL_SETTINGS)) { + return true + } + const env = record(settings.env) + return env !== null && Object.keys(env).some(isClaudeModelEnvKey) +} + +const CODEX_MODEL_KEYS = ['model', 'model_reasoning_effort', 'model_provider'] as const + +/** True when a Codex `config.toml`'s text could pick a model or effort, directly or through a + * profile; unparseable TOML counts. */ +export function codexConfigMayPickModel(text: string): boolean { + let config: Record + try { + config = parseToml(text) + } catch { + return true + } + if (setsAny(config, CODEX_MODEL_KEYS) || config.profile !== undefined) { + return true + } + const profiles = record(config.profiles) + return ( + profiles !== null && + Object.values(profiles).some((profile) => { + const table = record(profile) + return table === null || setsAny(table, CODEX_MODEL_KEYS) + }) + ) +} + +// OpenCode config keys (1.x and 2.x schemas) that pick the chat's model or effort, or the providers +// a model-less config picks its default from. +const OPENCODE_MODEL_KEYS = [ + 'model', + 'default_agent', + 'provider', + 'providers', + 'enabled_providers', + 'disabled_providers' +] as const +// Tables of agents, each of which may carry its own model or variant (effort), or turn one off. +const OPENCODE_AGENT_TABLES = ['agent', 'agents', 'mode'] as const +const OPENCODE_AGENT_MODEL_KEYS = ['model', 'variant', 'disable'] as const + +/** True when an `opencode.json(c)`'s text could pick a model or effort; invalid JSONC counts. */ +export function openCodeConfigMayPickModel(text: string): boolean { + const errors: ParseError[] = [] + const config = record(parseJsonc(text, errors, { allowTrailingComma: true })) + if (errors.length > 0 || !config) { + return true + } + if (setsAny(config, OPENCODE_MODEL_KEYS)) { + return true + } + return OPENCODE_AGENT_TABLES.some((key) => { + if (config[key] === undefined) { + return false + } + const agents = record(config[key]) + return ( + agents === null || + Object.values(agents).some((agent) => { + const table = record(agent) + return table === null || setsAny(table, OPENCODE_AGENT_MODEL_KEYS) + }) + ) + }) +} + +/** True when an OpenCode agent file's frontmatter could pick a model or effort; unreadable counts. */ +export function openCodeAgentFileMayPickModel(text: string): boolean { + const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text) + if (!match) { + return false + } + try { + const frontmatter = record(parseYaml(match[1] ?? '')) + return frontmatter !== null && setsAny(frontmatter, OPENCODE_AGENT_MODEL_KEYS) + } catch { + return true + } +} + +// OMP settings that pick the model (its role models, the models and providers it may choose +// from) or its thinking level, plus the older Pi names for the same. +const OMP_MODEL_SETTINGS = [ + 'modelRoles', + 'enabledModels', + 'disabledProviders', + 'modelProviderOrder', + 'modelTags', + 'providers', + 'defaultThinkingLevel', + 'defaultModel', + 'defaultProvider' +] as const + +function setsOmpModelSetting(settings: Record): boolean { + return Object.keys(settings).some((key) => + OMP_MODEL_SETTINGS.some((setting) => key === setting || key.startsWith(`${setting}.`)) + ) +} + +/** True when a settings file OMP merges into its project settings could pick a model or + * thinking level; one it can't parse counts. `format` is the file's syntax. */ +export function ompSettingsMayPickModel(text: string, format: 'json' | 'yaml' | 'toml'): boolean { + let settings: Record | null + try { + const parsed: unknown = + format === 'json' ? JSON.parse(text) : format === 'yaml' ? parseYaml(text) : parseToml(text) + // An empty YAML file is no settings. + settings = parsed === null && format === 'yaml' ? {} : record(parsed) + } catch { + return true + } + return settings === null || setsOmpModelSetting(settings) +} diff --git a/src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts b/src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts index 0e78922ae7f3..75bddb13249b 100644 --- a/src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts @@ -12,9 +12,9 @@ function write(path: string, content = ''): void { } function mayOverride( - agent: 'claude' | 'codex', + agent: string, workspacePath: string, - accountHomePath = '/homes/a' + accountHomePath: string | null = '/homes/a' ) { return workspaceMayOverrideDefaultModel({ agent, workspacePath, accountHomePath }) } @@ -61,7 +61,148 @@ describe('workspaceMayOverrideDefaultModel', () => { expect(await mayOverride('codex', worktree, join(worktree, '.codex'))).toBe(false) }) - it('never vouches for a Claude default, whose user settings can pick the model', async () => { - expect(await mayOverride('claude', join(root, 'anywhere'))).toBe(true) + it('finds a Claude project settings file, shared or local, but not the account home', async () => { + const worktree = join(root, 'claude-repo') + write(join(worktree, '.git'), 'gitdir: /elsewhere') + expect(await mayOverride('claude', worktree)).toBe(false) + // The user's own `.claude` is account config, which the CLI's resolution already covers. + write(join(worktree, '.claude', 'settings.json'), '{"model":"sonnet"}') + expect(await mayOverride('claude', worktree, join(worktree, '.claude'))).toBe(false) + expect(await mayOverride('claude', worktree)).toBe(true) + rmSync(join(worktree, '.claude', 'settings.json')) + write(join(worktree, '.claude', 'settings.local.json'), '{"effortLevel":"low"}') + expect(await mayOverride('claude', worktree)).toBe(true) + }) + + it('ignores Claude project settings that pick no model or effort', async () => { + const worktree = join(root, 'claude-permissions') + write(join(worktree, '.git'), 'gitdir: /elsewhere') + // A committed permissions/hooks file, and the local file "always allow" writes. + write( + join(worktree, '.claude', 'settings.json'), + JSON.stringify({ permissions: { allow: ['Bash(pnpm test)'] }, hooks: {}, env: { CI: '1' } }) + ) + write( + join(worktree, '.claude', 'settings.local.json'), + JSON.stringify({ permissions: { allow: ['WebFetch'] } }) + ) + expect(await mayOverride('claude', worktree)).toBe(false) + }) + + it.each([ + ['a model', { model: 'opus' }], + ['a per-model effort', { modelSettings: { 'claude-opus-5-5': { effortLevel: 'high' } } }], + ['the model env var', { env: { ANTHROPIC_MODEL: 'claude-sonnet-5' } }], + ['an alias target', { env: { ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-5' } }], + ['the effort env var', { env: { CLAUDE_CODE_EFFORT_LEVEL: 'low' } }], + ['another provider', { env: { CLAUDE_CODE_USE_BEDROCK: '1' } }], + ['an effort cap', { maxEffortLevel: 'medium' }], + ['a main-thread agent', { agent: 'reviewer' }], + ['Anthropic on AWS', { env: { CLAUDE_CODE_USE_ANTHROPIC_AWS: '1' } }], + ['Anthropic on Google Cloud', { env: { CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD: '1' } }] + ])('counts Claude project settings that set %s', async (_name, settings) => { + const worktree = join(root, 'claude-model') + write(join(worktree, '.git'), 'gitdir: /elsewhere') + write(join(worktree, '.claude', 'settings.json'), JSON.stringify(settings)) + expect(await mayOverride('claude', worktree)).toBe(true) + }) + + 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) + }) + + it('ignores Codex project config that picks no model, effort or profile', async () => { + const worktree = join(root, 'codex-mcp') + write(join(worktree, '.git'), 'gitdir: /elsewhere') + write(join(worktree, '.codex', 'config.toml'), '[mcp_servers.docs]\ncommand = "docs-mcp"\n') + expect(await mayOverride('codex', worktree)).toBe(false) + write( + join(worktree, '.codex', 'config.toml'), + '[profiles.deep]\nmodel_reasoning_effort = "xhigh"\n' + ) + expect(await mayOverride('codex', worktree)).toBe(true) + write(join(worktree, '.codex', 'config.toml'), 'profile = "deep"\n') + expect(await mayOverride('codex', worktree)).toBe(true) + }) + + it('never vouches for an agent whose project config it does not know', async () => { + expect(await mayOverride('pi', join(root, 'anywhere'))).toBe(true) + }) + + it('ignores OpenCode project config that picks no model, effort or provider', async () => { + const worktree = join(root, 'oc-plain') + write(join(worktree, '.git'), 'gitdir: /elsewhere') + expect(await mayOverride('opencode', worktree, null)).toBe(false) + // JSONC with comments, MCP servers, permissions and a subagent prompt. + write( + join(worktree, 'opencode.jsonc'), + '{\n // team MCP\n "mcp": { "docs": { "type": "local" } },\n "permission": { "edit": "ask" },\n "agent": { "review": { "prompt": "be brief" } },\n}\n' + ) + write(join(worktree, '.opencode', 'agent', 'review.md'), '---\ndescription: Reviews\n---\nBody') + expect(await mayOverride('opencode', worktree, null)).toBe(false) + }) + + it.each([ + ['a model', 'opencode.json', { model: 'anthropic/claude-sonnet-5' }], + ['a default agent', 'opencode.json', { default_agent: 'plan' }], + ['an agent model', '.opencode/opencode.json', { agent: { build: { model: 'x/y' } } }], + ['a 2.x agent variant', 'opencode.jsonc', { agents: { build: { variant: 'high' } } }], + ['a mode model', 'opencode.json', { mode: { build: { model: 'x/y' } } }], + ['its providers', 'opencode.json', { enabled_providers: ['openai'] }], + ['a 2.x provider table', 'opencode.json', { providers: { openai: {} } }] + ])('counts OpenCode project config that sets %s', async (_name, file, config) => { + const worktree = join(root, 'oc-model') + write(join(worktree, '.git'), 'gitdir: /elsewhere') + write(join(worktree, file), JSON.stringify(config)) + expect(await mayOverride('opencode', worktree, null)).toBe(true) + }) + + it('counts an OpenCode agent file whose frontmatter picks a model, and invalid JSONC', async () => { + const worktree = join(root, 'oc-agent-file') + write(join(worktree, '.git'), 'gitdir: /elsewhere') + write(join(worktree, '.opencode', 'agents', 'nested', 'build.md'), '---\nmodel: x/y\n---\n') + expect(await mayOverride('opencode', worktree, null)).toBe(true) + rmSync(join(worktree, '.opencode'), { recursive: true }) + write(join(worktree, 'opencode.json'), '{ "model": ') + expect(await mayOverride('opencode', worktree, null)).toBe(true) + }) + + it('reads OpenCode config above the repository root too, as OpenCode 2.x does', async () => { + const repo = join(root, 'oc-above', 'repo') + write(join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n') + write(join(root, 'oc-above', 'opencode.json'), '{"model":"x/y"}') + expect(await mayOverride('opencode', repo, null)).toBe(true) + }) + + it.each([ + ['.omp/config.yml', 'modelRoles:\n default: openai/gpt-6\n'], + ['.omp/settings.json', '{"defaultThinkingLevel":"low"}'], + ['.omp/settings.json', '{"enabledModels":["openai/*"]}'], + ['.claude/settings.json', '{"modelRoles":{"default":"x/y"}}'], + ['opencode.json', '{"modelProviderOrder":["openai"]}'], + ['.codex/config.toml', 'defaultThinkingLevel = "low"\n'], + ['.omp/config.yml', 'modelRoles: [\n'] + ])( + 'counts OMP project settings in %s that pick a model or thinking level', + async (file, text) => { + const worktree = join(root, 'omp-model') + write(join(worktree, '.git'), 'gitdir: /elsewhere') + write(join(worktree, file), text) + expect(await mayOverride('omp', worktree, join(root, 'omp-home'))).toBe(true) + } + ) + + it('ignores OMP project settings that pick no model', async () => { + const worktree = join(root, 'omp-plain') + write(join(worktree, '.git'), 'gitdir: /elsewhere') + write(join(worktree, '.omp', 'config.yml'), 'theme.dark: nord\n') + write(join(worktree, '.claude', 'settings.json'), '{"model":"opus"}') + write(join(worktree, 'opencode.json'), '{"mcp":{}}') + expect(await mayOverride('omp', worktree, join(root, 'omp-home'))).toBe(false) }) }) diff --git a/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts b/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts index b9fefa7e19d0..92023c038519 100644 --- a/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts +++ b/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts @@ -1,9 +1,17 @@ -import { stat } from 'node:fs/promises' +import type { Dirent } from 'node:fs' +import { readdir, readFile, stat } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' +import { + claudeSettingsMayPickModel, + codexConfigMayPickModel, + ompSettingsMayPickModel, + openCodeAgentFileMayPickModel, + openCodeConfigMayPickModel +} from './agent-project-model-config-keys' // A listing's default is the account's, but a chat runs in a workspace whose own -// config can pick another model. These checks only look for such config; they -// never read what it picks, so a hit means "name no default", not a model. +// config can pick another model. These checks only ask whether such config sets a +// model or effort key; they never use what it picks, so a hit means "name no default". async function exists(path: string): Promise { try { @@ -43,27 +51,165 @@ async function projectConfigDirectories(cwd: string): Promise { } } -/** Codex skips the `.codex` that is its own home; any other one with a config is a layer. */ -function directoryMayOverride(dir: string, accountHomePath: string): Promise { - const layer = join(dir, '.codex') - return layer === resolve(accountHomePath) - ? Promise.resolve(false) - : exists(join(layer, 'config.toml')) +type ProjectModelFile = { + path: string + mayPickModel: (text: string) => boolean +} + +type ProjectModelLayers = { + /** Files, relative to each directory of the chain, that the agent reads a model from. */ + files: readonly ProjectModelFile[] + /** Folders of `.md` agent definitions whose frontmatter may pick the model. */ + agentFolders?: readonly { path: string; recursive: boolean }[] + /** The agent reads every ancestor of the workspace, not only up to its project root. */ + wholeAncestry?: boolean +} + +const files = ( + paths: readonly string[], + mayPickModel: (text: string) => boolean +): ProjectModelFile[] => paths.map((path) => ({ path, mayPickModel })) + +// The project files each agent's CLI reads a model from. +const PROJECT_MODEL_LAYERS: Readonly> = { + codex: { files: files(['.codex/config.toml'], codexConfigMayPickModel) }, + claude: { + files: files( + ['.claude/settings.json', '.claude/settings.local.json'], + claudeSettingsMayPickModel + ) + }, + // OpenCode 2.x discovers `opencode.json(c)` and `.opencode/` in every ancestor of the workspace. + opencode: { + wholeAncestry: true, + files: files( + ['opencode.json', 'opencode.jsonc', '.opencode/opencode.json', '.opencode/opencode.jsonc'], + openCodeConfigMayPickModel + ), + agentFolders: [ + { path: '.opencode/agent', recursive: true }, + { path: '.opencode/agents', recursive: true }, + { path: '.opencode/mode', recursive: false }, + { path: '.opencode/modes', recursive: false } + ] + }, + // OMP merges its own `.omp` files and these other agents' project settings into its settings. + omp: { + files: [ + ...files( + [ + '.omp/settings.json', + '.claude/settings.json', + '.cursor/settings.json', + '.gemini/settings.json', + 'opencode.json' + ], + (text) => ompSettingsMayPickModel(text, 'json') + ), + ...files(['.omp/config.yml', '.omp/config.yaml'], (text) => + ompSettingsMayPickModel(text, 'yaml') + ), + ...files(['.codex/config.toml'], (text) => ompSettingsMayPickModel(text, 'toml')) + ] + } +} + +function isMissing(error: unknown): boolean { + const code = error instanceof Error && 'code' in error ? error.code : null + return code === 'ENOENT' || code === 'ENOTDIR' +} + +/** A missing file is no layer; one that exists but can't be read might pick anything. */ +async function fileMayPickModel( + path: string, + mayPickModel: (text: string) => boolean +): Promise { + let text: string + try { + text = await readFile(path, 'utf8') + } catch (error) { + return !isMissing(error) + } + return mayPickModel(text) +} + +// More agent files than this are not read one by one; they may pick anything. +const MAX_AGENT_FILES = 200 + +async function agentFolderMayPickModel(folder: string, recursive: boolean): Promise { + let entries: Dirent[] + try { + entries = await readdir(folder, { withFileTypes: true, recursive }) + } catch (error) { + return !isMissing(error) + } + const markdown = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.md')) + if (markdown.length > MAX_AGENT_FILES) { + return true + } + const found = await Promise.all( + markdown.map((entry) => + fileMayPickModel(join(entry.parentPath, entry.name), openCodeAgentFileMayPickModel) + ) + ) + return found.some(Boolean) +} + +/** A folder that is the agent's own home is account config, not a layer; any other file or agent + * folder setting a model or effort is one. */ +async function directoryMayOverride( + dir: string, + layers: ProjectModelLayers, + accountHomePath: string | null +): Promise { + const home = accountHomePath === null ? null : resolve(accountHomePath) + const found = await Promise.all([ + ...layers.files + .filter((file) => dirname(join(dir, file.path)) !== home) + .map((file) => fileMayPickModel(join(dir, file.path), file.mayPickModel)), + ...(layers.agentFolders ?? []).map((folder) => + agentFolderMayPickModel(join(dir, folder.path), folder.recursive) + ) + ]) + return found.some(Boolean) +} + +/** Every directory from the workspace up to the filesystem root. */ +function ancestry(cwd: string): string[] { + const chain: string[] = [] + for (let dir = resolve(cwd); ; dir = dirname(dir)) { + chain.push(dir) + if (dirname(dir) === dir) { + return chain + } + } +} + +/** Whether the agent's CLI may read its model from a project's own config. Grok reads its default + * model from user, managed and env config only. */ +export function agentReadsProjectModelConfig(agent: string): boolean { + return agent !== 'grok' } /** True when a new chat in `workspacePath` could run a model other than the listed default. */ export async function workspaceMayOverrideDefaultModel(input: { agent: string workspacePath: string - accountHomePath: string + accountHomePath: string | null }): Promise { - // Claude's user settings or env can pick another model wherever it runs; only Codex's listed default is its configured one. - if (input.agent !== 'codex') { + if (!agentReadsProjectModelConfig(input.agent)) { + return false + } + // An agent whose project layers are not known here may pick another model anywhere. + const layers = PROJECT_MODEL_LAYERS[input.agent] + if (!layers) { return true } - const dirs = await projectConfigDirectories(input.workspacePath) + const dirs = layers.wholeAncestry + ? ancestry(input.workspacePath) + : await projectConfigDirectories(input.workspacePath) const results = await Promise.all( - dirs.map((dir) => directoryMayOverride(dir, input.accountHomePath)) + dirs.map((dir) => directoryMayOverride(dir, layers, input.accountHomePath)) ) return results.some(Boolean) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 9aa03a27ab07..98aab1b6ba28 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -52,6 +52,14 @@ import type { AgentSessionPromptResponse } from '../../../shared/agent-session-q import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' +import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' + +/** A live options read, plus what the child listed for its account's saved catalog. The host + * saves that listing and strips it before answering; an adapter whose listing reaches the + * catalog another way omits it. */ +export type StructuredAgentSessionLiveOptions = AgentSessionOptionsResult & { + catalogListing?: AgentModelCatalogLiveListing +} export class AgentSessionAcquisitionRefusal extends Error { readonly code = 'agent_session_operation_invalid' @@ -232,6 +240,8 @@ export type StructuredAgentSessionStartedEvent = { restoreSkippedOptions: readonly string[] /** Values the child showed it cannot run: a report naming the same value is not persisted. */ retiredOptions?: Readonly> + /** What the child listed at startup, saved as its account's catalog even if no view reads it. */ + catalogListing?: AgentModelCatalogLiveListing /** The attempt's `optionRevision()` as the read behind this report began: a pick the host took * since makes it out of date. An earlier report never does, whenever the host took it. */ optionRevision: number @@ -416,7 +426,10 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & sessionId: string fence: number }): Promise<() => AgentSessionOptionsResult> | undefined - readOptions?(input: { sessionId: string; fence: number }): Promise + readOptions?(input: { + sessionId: string + fence: number + }): Promise /** Effective options already known after acquisition, without discovering picker choices. */ readAcquisitionOptions?(input: { sessionId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 6b2bdaba9596..9b370959be09 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -16,6 +16,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentDefinition } from './structured-agent-definition' import type { StructuredAgentRegistry } from './structured-agent-registry' +import type { StructuredAgentSessionLiveOptions } from './structured-agent-session-adapter' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { structuredAgentSessionOptionModels } from './structured-agent-session-option-models' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -130,7 +131,7 @@ export async function readStructuredAgentSessionOptions( options: await adapter.readOptions({ sessionId, fence: child.fence }) } }) - const live = + const live: StructuredAgentSessionLiveOptions | null = started.kind === 'live' ? started.options : started.kind === 'prepared' @@ -141,7 +142,17 @@ export async function readStructuredAgentSessionOptions( }) ) : null - const options = live ?? (await readStructuredAgentSessionOptionsAtRest(context.deps, sessionId)) + let options: Omit + if (live) { + const { catalogListing, ...answer } = live + if (catalogListing) { + // What a running child listed is its account's catalog too, so the next chat opens warm. + context.deps.modelCatalog?.recordLiveListing(sessionId, catalogListing) + } + options = answer + } else { + options = await readStructuredAgentSessionOptionsAtRest(context.deps, sessionId) + } // Re-acquired after the reads above: the handle they saw may have closed and reopened since. const session = await context.conversation(sessionId) const phase = store.getRecord(sessionId)?.rewind?.phase diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts index 5c59dbf0b3f8..7543580e4304 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts @@ -54,7 +54,13 @@ function startHost(phase: 'ready' | 'starting'): void { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - modelCatalog: { read: vi.fn(), providerStarted }, + modelCatalog: { + read: vi.fn(), + recordLiveListing: vi.fn(), + prewarm: vi.fn(async () => {}), + stop: vi.fn(), + providerStarted + }, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index 734f262f7b57..786c7360e46e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -6,6 +6,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-store' import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { @@ -36,12 +37,14 @@ let host: StructuredAgentSessionHost let adapter: ClaudeStructuredSessionAdapter let lifecycle: Promise[] let statuses: AgentSessionStatusEvent[] +let savedListings: { sessionId: string; listing: AgentModelCatalogLiveListing }[] beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-provider-started-')) resetHostTestOperationIds() lifecycle = [] statuses = [] + savedListings = [] // A CLI whose own default is not the catalog's: startup reports it through get_settings, // since system/init arrives only with the first command. const claude = fakeClaude({ @@ -86,6 +89,13 @@ beforeEach(async () => { journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', + modelCatalog: { + read: async () => ({ origin: 'unknown' }), + recordLiveListing: (sessionId, listing) => savedListings.push({ sessionId, listing }), + prewarm: async () => {}, + stop: () => {}, + providerStarted: () => {} + }, now: () => NOW }) host.subscribeStatus({ id: 'status-1', emit: (event) => statuses.push(event) }) @@ -163,4 +173,20 @@ describe('a publish-first Claude create whose init is slow', () => { expect(store.getRecord(SESSION)?.options?.model).toBe('opus') expect(lastPhase()).toBe('ready') }) + it('saves the account listing the child read at startup, though no view asks for options', async () => { + await host.attach(CALLER, claudeParams()) + expect(savedListings).toEqual([]) + + await claudeStartupSettled(adapter, SESSION) + await Promise.all(lifecycle) + + expect(savedListings).toEqual([ + { + sessionId: SESSION, + listing: expect.objectContaining({ + models: [expect.objectContaining({ id: 'claude-sonnet' })] + }) + } + ]) + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index 7aa18579ade9..c25df2408bc3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts @@ -66,6 +66,9 @@ export async function settleStructuredAgentSessionProviderStarted( context.runtimeState.startupAttempts.ready(event.sessionId, child) const delivered = context.wakeDelivery(event.sessionId) noteStructuredAgentSessionProviderStarted(context.deps, event.sessionId) + if (event.catalogListing) { + context.deps.modelCatalog?.recordLiveListing(event.sessionId, event.catalogListing) + } context.publishStatus?.(event.sessionId) return { delivered } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index 28be32c15759..4c8c2ab2e6c8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -70,7 +70,6 @@ export async function withdrawQueuedMessagesForOperation( }) } - /** Draft actions run like any mutation: admitted on the session's lane, the * conversation opened for the write. */ function mutateQueued( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts index 7efd39d0072a..a99d7f7010ac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts @@ -90,7 +90,13 @@ async function startHost(): Promise { setOption, ...(readAcquisitionOptions ? { readAcquisitionOptions } : {}) }, - modelCatalog: { read: vi.fn(), providerStarted }, + modelCatalog: { + read: vi.fn(), + recordLiveListing: vi.fn(), + prewarm: vi.fn(async () => {}), + stop: vi.fn(), + providerStarted + }, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, diff --git a/src/main/opencode/opencode-model-catalog-listing.test.ts b/src/main/opencode/opencode-model-catalog-listing.test.ts new file mode 100644 index 000000000000..999fcadf94a9 --- /dev/null +++ b/src/main/opencode/opencode-model-catalog-listing.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { parseOpenCodeModelListing } from './opencode-model-catalog-listing' + +// What `opencode models --verbose` prints: each id, then `JSON.stringify(model, null, 2)`. +function listing(models: Record): string { + return Object.entries(models) + .map(([id, model]) => `${id}\n${JSON.stringify(model, null, 2)}\n`) + .join('') +} + +describe('OpenCode verbose model listing', () => { + it('reads every model and its variants through multiline metadata', () => { + const stdout = listing({ + 'anthropic/claude-sonnet': { + id: 'claude-sonnet', + providerID: 'anthropic', + name: 'Claude Sonnet', + cost: { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } }, + options: { nested: { '}': '{' } }, + variants: { high: { thinking: { budget: 16000 } }, max: { thinking: { budget: 32000 } } } + }, + 'opencode/grok-code': { + id: 'grok-code', + name: 'Grok Code', + variants: { default: {}, fast: {} } + }, + 'openai/gpt-plain': { id: 'gpt-plain', name: 'GPT Plain' } + }) + expect(parseOpenCodeModelListing(stdout)).toEqual([ + { + id: 'anthropic/claude-sonnet', + label: 'anthropic/Claude Sonnet', + isDefault: false, + efforts: [ + { value: 'high', label: 'High' }, + { value: 'max', label: 'Max' }, + { value: 'default', label: 'Default' } + ], + // No `default` variant: a new session runs the first one, as OpenCode's own ACP does. + defaultEffort: 'high' + }, + { + id: 'opencode/grok-code', + label: 'opencode/Grok Code', + isDefault: false, + efforts: [ + { value: 'default', label: 'Default' }, + { value: 'fast', label: 'Fast' } + ], + defaultEffort: 'default' + }, + { id: 'openai/gpt-plain', label: 'openai/GPT Plain', isDefault: false, efforts: [] } + ]) + }) + + it('reads the plain listing, CRLF endings and an empty object, and skips stray lines', () => { + const stdout = [ + 'Models cache refreshed', + 'openai/gpt-a', + 'openai/gpt-b', + '{}', + 'openai/gpt-c', + '{', + ' "name": "GPT C"', + '}', + '' + ].join('\r\n') + expect(parseOpenCodeModelListing(stdout).map((model) => [model.id, model.label])).toEqual([ + ['openai/gpt-a', 'openai/gpt-a'], + ['openai/gpt-b', 'openai/gpt-b'], + ['openai/gpt-c', 'openai/GPT C'] + ]) + }) + + it('keeps a model whose metadata does not parse, with no effort menu', () => { + const stdout = 'openai/gpt-broken\n{\n "name": \n}\nopenai/gpt-next\n' + expect(parseOpenCodeModelListing(stdout)).toEqual([ + { id: 'openai/gpt-broken', label: 'openai/gpt-broken', isDefault: false, efforts: [] }, + { id: 'openai/gpt-next', label: 'openai/gpt-next', isDefault: false, efforts: [] } + ]) + }) +}) diff --git a/src/main/opencode/opencode-model-catalog-listing.ts b/src/main/opencode/opencode-model-catalog-listing.ts new file mode 100644 index 000000000000..7f667cf3a498 --- /dev/null +++ b/src/main/opencode/opencode-model-catalog-listing.ts @@ -0,0 +1,88 @@ +// OpenCode's session-free model listing: `opencode models --verbose` prints each `provider/model` id +// on its own line, followed by that model's metadata as indented JSON closed by a bare `}` line. + +import { z } from 'zod' +import type { AgentSessionModelOption } from '../../shared/agent-session-wire' +import { assertJsonTextStructureWithinLimits } from '../../shared/json-text-structure-limit' + +export const OPENCODE_MODEL_LISTING_ARGS = ['models', '--verbose'] as const + +// OpenCode's effort sentinel for "no variant override", always offered beside a model's variants. +const DEFAULT_VARIANT = 'default' +const MODEL_JSON_LIMITS = { structuralTokens: 64 * 1024, nestingDepth: 32 } as const + +const modelMetaSchema = z.looseObject({ + name: z.string().min(1).optional(), + variants: z.record(z.string(), z.unknown()).optional() +}) + +/** Effort labels as OpenCode's own effort option spells them: `high-thinking` → `High Thinking`. */ +function variantLabel(variant: string): string { + return variant + .split(/[_-]/) + .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part)) + .join(' ') +} + +function modelOption(id: string, metadata: unknown): AgentSessionModelOption { + const meta = modelMetaSchema.safeParse(metadata) + const name = meta.success ? meta.data.name : undefined + const variants = meta.success ? Object.keys(meta.data.variants ?? {}) : [] + const providerId = id.slice(0, id.indexOf('/')) + return { + id, + label: name ? `${providerId}/${name}` : id, + // The listing names the account's models, not which one its config picks. + isDefault: false, + ...(variants.length > 0 + ? { + efforts: [...new Set([...variants, DEFAULT_VARIANT])].map((value) => ({ + value, + label: variantLabel(value) + })), + // What a new OpenCode session runs: its `default` variant, else the first it lists. + defaultEffort: variants.includes(DEFAULT_VARIANT) ? DEFAULT_VARIANT : variants[0] + } + : { efforts: [] }) + } +} + +function isModelIdLine(line: string): boolean { + const slash = line.indexOf('/') + return slash > 0 && slash < line.length - 1 && !/\s/.test(line) && !line.startsWith('{') +} + +/** Every listed model; one whose metadata does not parse keeps its id with no effort menu. */ +export function parseOpenCodeModelListing(stdout: string): AgentSessionModelOption[] { + const lines = stdout.split(/\r?\n/) + const models = new Map() + // A pretty-printed object closes on an unindented `}`; an empty one is `{}` on one line. + const closesObject = (line: string, first: boolean): boolean => + line.trimEnd() === '}' || (first && line.trimEnd() === '{}') + for (let index = 0; index < lines.length; index++) { + const id = (lines[index] ?? '').trim() + if (!isModelIdLine(id)) { + continue + } + let metadata: unknown + if (lines[index + 1]?.startsWith('{')) { + const start = index + 1 + let end = start + while (end < lines.length && !closesObject(lines[end] ?? '', end === start)) { + end++ + } + const text = lines.slice(start, end + 1).join('\n') + index = end + try { + assertJsonTextStructureWithinLimits(text, MODEL_JSON_LIMITS) + metadata = JSON.parse(text) + } catch { + metadata = undefined + } + } + if (!models.has(id)) { + models.set(id, modelOption(id, metadata)) + } + } + return [...models.values()] +} diff --git a/src/main/pi/rpc-model-catalog-probe.test.ts b/src/main/pi/rpc-model-catalog-probe.test.ts new file mode 100644 index 000000000000..d775c3520c24 --- /dev/null +++ b/src/main/pi/rpc-model-catalog-probe.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { createPiModelCatalogProbe, piModelCatalogFromListing } from './rpc-model-catalog-probe' + +const TABLE = [ + 'provider model context max-out thinking images', + 'anthropic claude-sonnet-4 200K 64K yes yes', + 'openai gpt-4o-mini 128K 16K no yes' +].join('\n') + +describe('Pi model catalog', () => { + it('reuses the listing identity, with the levels every reasoning model keeps and no default', () => { + expect(piModelCatalogFromListing(TABLE)).toEqual([ + { + id: 'anthropic/claude-sonnet-4', + label: expect.any(String), + isDefault: false, + efforts: ['off', 'minimal', 'low', 'medium', 'high'].map((level) => ({ + value: level, + label: level + })) + }, + { id: 'openai/gpt-4o-mini', label: expect.any(String), isDefault: false, efforts: [] } + ]) + }) + + it('lists under the pinned account with the chat launch env, through the shared runner', async () => { + const runListing = vi.fn(async () => TABLE) + const probe = createPiModelCatalogProbe({ + resolveEnvironment: async () => ({ + PATH: '/usr/bin', + HOME: '/home/user', + ORCA_PANE_KEY: 'p' + }), + resolveCommand: () => '/opt/pi/bin/pi', + probeVersion: async () => true, + homePath: '/home/user', + runListing + }) + const success = await probe({ variable: 'PI_CODING_AGENT_DIR', path: '/homes/pi-a' }) + expect(success.models).toHaveLength(2) + expect(runListing).toHaveBeenCalledWith( + expect.objectContaining({ + command: '/opt/pi/bin/pi', + args: ['--list-models'], + env: expect.objectContaining({ PI_CODING_AGENT_DIR: '/homes/pi-a' }) + }), + { site: 'pi-model-catalog-probe' } + ) + }) + + it('lists nothing from a Pi that runs no structured chat', async () => { + const runListing = vi.fn() + const probe = createPiModelCatalogProbe({ + resolveEnvironment: async () => ({ PATH: '/usr/bin' }), + resolveCommand: () => '/opt/pi/bin/pi', + probeVersion: async () => false, + runListing + }) + await expect(probe({ variable: 'PI_CODING_AGENT_DIR', path: '/homes/pi-a' })).rejects.toThrow( + /does not run structured chats/ + ) + expect(runListing).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/pi/rpc-model-catalog-probe.ts b/src/main/pi/rpc-model-catalog-probe.ts new file mode 100644 index 000000000000..b9c0f526ae8b --- /dev/null +++ b/src/main/pi/rpc-model-catalog-probe.ts @@ -0,0 +1,66 @@ +import { homedir } from 'node:os' +import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' +import { parsePiModels } from '../../shared/commit-message-model-parsers' +import type { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import type { probeAgentCliVersion } from '../agent-cli-version-probe' +import type { + AgentModelCatalogProbe, + AgentModelCatalogSuccess +} from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { runAgentModelCatalogListing } from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner' +import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' +import { buildPiRpcLaunch } from './rpc-launch' +import { piRpcVersionSupported, resolvePiRpcCommand } from './rpc-launch-resolution' +import { PI_UNMAPPED_THINKING_LEVELS } from './rpc-options' + +export type PiModelCatalogProbeDeps = { + resolveEnvironment: () => Promise + resolveCommandSettings?: () => StructuredAgentCommandSettings + resolveCommand?: typeof resolveCliCommand + /** Test seams; production runs the shared supervised listing. */ + probeVersion?: typeof probeAgentCliVersion + runListing?: typeof runAgentModelCatalogListing + homePath?: string +} + +/** Pi's table names each model and whether it reasons; its live thinking menu is model-specific, + * so a reasoning model lists the levels every map keeps and names no default. */ +export function piModelCatalogFromListing(stdout: string): AgentModelCatalogSuccess['models'] { + return parsePiModels(stdout).map((model) => ({ + id: model.id, + label: model.label, + isDefault: false, + efforts: model.thinkingLevels + ? PI_UNMAPPED_THINKING_LEVELS.map((level) => ({ value: level, label: level })) + : [] + })) +} + +/** Lists Pi's models with `pi --list-models`, under the binary and environment its chats run. */ +export function createPiModelCatalogProbe(deps: PiModelCatalogProbeDeps): AgentModelCatalogProbe { + return async (accountHome, options): Promise => { + const env: Record = {} + for (const [key, value] of Object.entries(await deps.resolveEnvironment())) { + if (value !== undefined) { + env[key] = value + } + } + env.PI_CODING_AGENT_DIR = requireLegacyAgentSessionAccountHome(accountHome).path + const command = resolvePiRpcCommand(env, deps.resolveCommandSettings?.(), deps.resolveCommand) + const cwd = deps.homePath ?? homedir() + if (!(await piRpcVersionSupported({ command, cwd, env }, deps.probeVersion))) { + throw new Error(`pi at ${command} does not run structured chats`) + } + // The chat launch's own env rules; only the arguments differ. + const launch = buildPiRpcLaunch({ command, cwd, env, fullAccess: true }) + const stdout = await (deps.runListing ?? runAgentModelCatalogListing)( + { ...launch, args: ['--list-models'] }, + { site: 'pi-model-catalog-probe', signal: options?.signal } + ) + const models = piModelCatalogFromListing(stdout) + if (models.length === 0) { + throw new Error('pi listed no models') + } + return { models, fastModeTierByModel: new Map(), origin: 'probe' } + } +} diff --git a/src/main/pi/rpc-options.ts b/src/main/pi/rpc-options.ts index 9b59bec398ad..4f75e064c6e1 100644 --- a/src/main/pi/rpc-options.ts +++ b/src/main/pi/rpc-options.ts @@ -33,6 +33,10 @@ const commandsSchema = z.object({ }) const THINKING_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const +/** The levels a reasoning model offers unless its thinking map removes one; the rest need a map entry. */ +export const PI_UNMAPPED_THINKING_LEVELS = THINKING_LEVELS.filter( + (level) => level !== 'xhigh' && level !== 'max' +) export function piModelOptionId(provider: string, modelId: string): string { if (!provider || !modelId || provider.includes('/')) { diff --git a/src/main/pi/rpc-runtime-registration.ts b/src/main/pi/rpc-runtime-registration.ts index fca0a4b411f0..ac78e88301a5 100644 --- a/src/main/pi/rpc-runtime-registration.ts +++ b/src/main/pi/rpc-runtime-registration.ts @@ -5,6 +5,7 @@ import { isAgentSessionPreSpawnError } from '../native-chat/agent-session-wire/s import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' import type { StructuredAgentAdapterContext, + StructuredAgentModelCatalogContext, StructuredAgentRuntimeAdapter, StructuredAgentRuntimeRegistration } from '../runtime/structured-agent-runtime-registrations' @@ -15,6 +16,18 @@ import { resolvePiRpcCommand } from './rpc-launch-resolution' import { PiRpcSessionAdapter } from './rpc-session-adapter' +import { createPiModelCatalogProbe } from './rpc-model-catalog-probe' + +/** The environment every Pi child starts from: a chat's launch and a catalog listing alike. */ +function piEnvironment({ + deps, + environment +}: StructuredAgentModelCatalogContext): () => Promise { + return async () => ({ + ...(await environment.resolveBaseEnvironment()), + ...deps.resolveAgentLaunchEnv?.('pi') + }) +} function createPiRpcAdapter(context: StructuredAgentAdapterContext): StructuredAgentRuntimeAdapter { const { deps } = context @@ -22,10 +35,7 @@ function createPiRpcAdapter(context: StructuredAgentAdapterContext): StructuredA resolveLaunch: createPiRpcLaunchResolver({ store: context.store, resolveWorkspacePath: deps.resolveWorkspacePath, - resolveEnvironment: async () => ({ - ...(await context.environment.resolveBaseEnvironment()), - ...deps.resolveAgentLaunchEnv?.('pi') - }), + resolveEnvironment: piEnvironment(context), ...(deps.resolveAgentCommandSettings ? { resolveCommandSettings: deps.resolveAgentCommandSettings } : {}) @@ -51,6 +61,17 @@ function createPiRpcAdapter(context: StructuredAgentAdapterContext): StructuredA export const PI_RPC_RUNTIME_REGISTRATION: StructuredAgentRuntimeRegistration = { definition: PI_RPC_AGENT, createAdapter: createPiRpcAdapter, + modelCatalog: (context) => ({ + kind: 'probe', + // `--list-models` marks no model as the one Pi runs by default. + listingNamesConfiguredModel: false, + probe: createPiModelCatalogProbe({ + resolveEnvironment: piEnvironment(context), + ...(context.deps.resolveAgentCommandSettings + ? { resolveCommandSettings: context.deps.resolveAgentCommandSettings } + : {}) + }) + }), supportsLocation: supportsSupervisedProviderChildLocation, supportsLaunch: async ({ cwd, env, commandSettings }) => { let command: string diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index 8f4feb7e59ee..ee0a13c42c43 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -20,6 +20,7 @@ import { piRpcProviderLink, type PiRpcResolvedLaunch } from './rpc-launch-resolu import { PiRpcSession, type PiRpcSessionDeps, type PiRpcConnection } from './rpc-session' import { PiRpcPromptError, preparePiRpcPrompt } from './rpc-prompt' import { applyPiRpcSessionOption, readPiRpcSessionOptions } from './rpc-options' +import { withLiveCatalogListing } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' import { ClaudeDispatchContentError } from '../claude/claude-structured-dispatch-content' @@ -225,8 +226,10 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { const session = this.session(input.sessionId, input.fence) return applyPiRpcSessionOption(session.connection, session.selected, input.key, input.value) } - readOptions: NonNullable = (input) => - readPiRpcSessionOptions(this.session(input.sessionId, input.fence).connection) + readOptions: NonNullable = async (input) => + withLiveCatalogListing( + await readPiRpcSessionOptions(this.session(input.sessionId, input.fence).connection) + ) readCommands = (id: string) => this.sessions.get(id)?.commands readOptionRestoreFailures(id: string): readonly string[] { return this.sessions.get(id)?.skipped ?? [] diff --git a/src/main/provider-process/managed-provider-process.ts b/src/main/provider-process/managed-provider-process.ts index c73b0b8ccb06..31a869202a07 100644 --- a/src/main/provider-process/managed-provider-process.ts +++ b/src/main/provider-process/managed-provider-process.ts @@ -3,7 +3,8 @@ import { RetryableProcessExitProof } from '../../shared/child-process/retryable- import type { ProviderProcessLaunch } from './provider-process-launch' import { PROVIDER_SUPERVISOR_MAX_STOP_MS, - createProviderSpawnSpec + createProviderSpawnSpec, + type ProviderSupervisorLifetime } from './provider-process-supervisor' import { terminateProviderProcessTree, @@ -38,6 +39,8 @@ type ManagedProviderProcessOptions = { inheritedEnv?: NodeJS.ProcessEnv /** Defaults to "the root is gone". */ acceptClose?: (result: ProviderProcessCloseResult) => boolean + /** Defaults to `session`; a one-shot's stdin end completes its request instead of stopping it. */ + lifetime?: ProviderSupervisorLifetime /** Any stdout or stderr chunk: the child is doing something. */ onOutput?: () => void } @@ -73,7 +76,8 @@ export function spawnManagedProviderProcess( const closePolicy = options.policy ?? rootOnlyProviderClosePolicy const spec = createProviderSpawnSpec(launch, options.inheritedEnv ?? process.env, platform, { // A gone owner gets the close this provider's own close would make under the supervisor. - closeRequest: closePolicy(true).signalSupervisorOnClose ? 'stdin-end-and-sigterm' : 'stdin-end' + closeRequest: closePolicy(true).signalSupervisorOnClose ? 'stdin-end-and-sigterm' : 'stdin-end', + ...(options.lifetime ? { lifetime: options.lifetime } : {}) }) const policy = closePolicy(spec.supervised) if (spec.supervised && !(policy.gracefulExitMs >= PROVIDER_SUPERVISOR_MAX_STOP_MS)) { diff --git a/src/main/runtime/rpc/methods/client-native-chat-settings.test.ts b/src/main/runtime/rpc/methods/client-native-chat-settings.test.ts index dcd92f86cf63..96e5f5ca204b 100644 --- a/src/main/runtime/rpc/methods/client-native-chat-settings.test.ts +++ b/src/main/runtime/rpc/methods/client-native-chat-settings.test.ts @@ -35,6 +35,26 @@ describe('native-chat settings RPC', () => { expect(response).toMatchObject({ ok: true, result: { ok: true } }) }) + it.each(['opencode', 'pi', 'grok'])('saves picks for %s like any other agent', async (agent) => { + const updateClientNativeChatSessionOptions = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this method's handler reads only the two runtime members stubbed here. + const runtime = { + getRuntimeId: () => 'test-runtime', + updateClientNativeChatSessionOptions + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + const mutation = { + type: 'apply-picks' as const, + agent, + picks: [{ modelId: 'model-a', optionId: 'model' as const, value: 'model-a' }] + } + + const response = await dispatcher.dispatch(request(mutation)) + + expect(updateClientNativeChatSessionOptions).toHaveBeenCalledExactlyOnceWith(mutation) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + it('rejects malformed option deltas', async () => { const updateClientNativeChatSessionOptions = vi.fn() const runtime = { @@ -47,7 +67,7 @@ describe('native-chat settings RPC', () => { { type: 'apply-picks', agent: 'codex', picks: [] }, { type: 'apply-picks', - agent: 'opencode', + agent: 'not-an-agent', picks: [{ modelId: 'model', optionId: 'model', value: 'model' }] }, { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts index bfd15a7c7327..56db442ce2c2 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts @@ -103,6 +103,11 @@ describe('agentSession.modelCatalog', () => { ) expect(read).toHaveBeenCalledWith({ agent: 'codex', sessionId: SESSION, waitForListing: true }) }) + + it('passes a saved-only read through to the catalog', async () => { + await call('agentSession.modelCatalog', { agent: 'grok', savedOnly: true }, STRUCTURED_CLIENT) + expect(read).toHaveBeenCalledWith({ agent: 'grok', savedOnly: true }) + }) }) describe('agentSession.modelCatalog before anything built the host', () => { diff --git a/src/main/runtime/structured-agent-model-catalog-discovery.test.ts b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts new file mode 100644 index 000000000000..544676dc9290 --- /dev/null +++ b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts @@ -0,0 +1,133 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from './structured-agent-runtime-registrations' +import { resolveStructuredAgentCommand } from '../native-chat/structured-agent-command-resolution' +import { registeredModelCatalogDiscovery } from './structured-agent-model-catalog-wiring' +import { acpModelCatalogDiscovery } from './structured-agent-model-catalog-discovery' +import { acpLaunchSpecFor } from '../acp/acp-launch-specs' +import type { StructuredAgentModelCatalogContext } from './structured-agent-runtime-registrations' + +// What each probe would have spawned; nothing is. +const spawned = vi.hoisted((): { commands: string[] } => ({ commands: [] })) +vi.mock('../provider-process/managed-provider-process', () => ({ + spawnManagedProviderProcess: (launch: { command: string }) => { + spawned.commands.push(launch.command) + throw new Error('no spawn in this test') + } +})) +vi.mock('../agent-cli-version-probe', () => ({ + probeAgentCliVersion: async (input: { program: string }) => { + spawned.commands.push(input.program) + throw new Error('no spawn in this test') + } +})) + +function context(): StructuredAgentModelCatalogContext { + const unused = async (): Promise => { + throw new Error('building a probe resolves nothing') + } + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: building a probe only captures resolvers; none of these deps is read until a probe runs. + deps: { stateDirectory: '/state' } as StructuredAgentModelCatalogContext['deps'], + environment: { + resolveBaseEnvironment: unused, + resolveCodexEnvironment: unused, + resolveClaudeInheritedEnv: unused + } + } +} + +describe('the model catalog contract on every registration', () => { + it.each( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map( + (registration) => [registration.definition.agent, registration] as const + ) + )('%s declares how its models are listed without a session', (_agent, registration) => { + const discovery = registration.modelCatalog(context()) + expect(['probe', 'unavailable']).toContain(discovery.kind) + if (discovery.kind === 'probe') { + expect(discovery.probe).toBeTypeOf('function') + } else { + expect(discovery.reason).not.toBe('') + } + }) + + it('gives the catalog service a probe for exactly the registrations that have one', () => { + const { probes } = registeredModelCatalogDiscovery( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + context() + ) + const listing = STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.filter( + (registration) => registration.modelCatalog(context()).kind === 'probe' + ).map((registration) => registration.definition.agent) + expect(Object.keys(probes).sort()).toEqual([...listing].sort()) + // Every agent registered today but OMP lists its models without a session. + expect(listing.sort()).toEqual( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map((registration) => registration.definition.agent) + .filter((agent) => agent !== 'omp') + .sort() + ) + }) + + it('says per registration whether a listing names the model a new chat runs', () => { + const { listingNamesConfiguredModel } = registeredModelCatalogDiscovery( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + context() + ) + // Codex's `model/list` names it. Grok's session-free `currentModelId` can differ from what a + // session runs, so Grok, like the rest, learns it from a chat with no pick. + expect([...listingNamesConfiguredModel].sort()).toEqual(['codex']) + }) + + it('maps an ACP agent with no session-free listing to an unavailable registration', () => { + const spec = acpLaunchSpecFor('grok')! + const discovery = acpModelCatalogDiscovery( + { ...spec, modelDiscovery: { kind: 'unavailable', reason: 'no listing without a session' } }, + context() + ) + expect(discovery).toEqual({ kind: 'unavailable', reason: 'no listing without a session' }) + }) + + it('lists through the command each agent is set to run, never a bare name', async () => { + const rig = mkdtempSync(join(tmpdir(), 'orca-catalog-overrides-')) + const home = join(rig, 'home') + const standIn = (agent: string): string => join(rig, `${agent}-standin`) + for (const agent of ['claude', 'codex', 'grok']) { + // Never run: the spawn is mocked; the override must only be a runnable file. + writeFileSync(standIn(agent), '#!/bin/sh\nexit 1\n') + chmodSync(standIn(agent), 0o755) + } + const settings = { + agentCmdOverrides: { + claude: standIn('claude'), + codex: standIn('codex'), + grok: standIn('grok') + } + } + const env = async () => ({ PATH: '/usr/bin' }) + const { probes } = registeredModelCatalogDiscovery(STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a probe reads only the command, env and auth resolvers given here. + deps: { + stateDirectory: '/state', + resolveClaudeCommand: () => resolveStructuredAgentCommand('claude', settings), + resolveCodexCommand: (options) => resolveStructuredAgentCommand('codex', settings, options), + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: false }), + resolveAgentCommandSettings: () => settings + } as StructuredAgentModelCatalogContext['deps'], + environment: { + resolveBaseEnvironment: env, + resolveCodexEnvironment: env, + resolveClaudeInheritedEnv: env + } + }) + const variables = { claude: 'CLAUDE_CONFIG_DIR', codex: 'CODEX_HOME', grok: 'GROK_HOME' } + for (const [agent, variable] of Object.entries(variables)) { + spawned.commands.length = 0 + await expect(probes[agent]!({ variable, path: home })).rejects.toThrow() + expect(spawned.commands, agent).toEqual([standIn(agent)]) + } + rmSync(rig, { recursive: true, force: true }) + }) +}) diff --git a/src/main/runtime/structured-agent-model-catalog-discovery.ts b/src/main/runtime/structured-agent-model-catalog-discovery.ts new file mode 100644 index 000000000000..5bf0b86428e1 --- /dev/null +++ b/src/main/runtime/structured-agent-model-catalog-discovery.ts @@ -0,0 +1,66 @@ +// Each registered agent's catalog probe, built from the SAME resolvers its session launches use: +// a probe under another binary or env could list models the user's sessions cannot see. + +import { createAcpModelCatalogProbe } from '../acp/acp-model-catalog-probe' +import type { AcpLaunchSpec } from '../acp/acp-launch-specs' +import { createClaudeModelCatalogProbe } from '../claude/claude-model-catalog-probe' +import { createCodexModelCatalogProbe } from '../codex/codex-model-catalog-probe' +import type { AgentModelCatalogDiscovery } from '../native-chat/agent-model-catalog/agent-model-catalog-discovery' +import type { StructuredAgentModelCatalogContext } from './structured-agent-runtime-registrations' + +export function codexModelCatalogDiscovery({ + deps, + environment +}: StructuredAgentModelCatalogContext): AgentModelCatalogDiscovery { + return { + kind: 'probe', + // `model/list` marks the account's configured model as its default. + listingNamesConfiguredModel: true, + probe: createCodexModelCatalogProbe({ + resolveAccountKind: deps.resolveCodexAccountKind, + ...(deps.prepareCodexCatalogProbeHome + ? { prepareHome: deps.prepareCodexCatalogProbeHome } + : {}), + resolveEnvironment: environment.resolveCodexEnvironment, + ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) + }) + } +} + +export function claudeModelCatalogDiscovery({ + deps, + environment +}: StructuredAgentModelCatalogContext): AgentModelCatalogDiscovery { + return { + kind: 'probe', + // Claude's settings or env can pick a model other than the one its listing recommends. + listingNamesConfiguredModel: false, + probe: createClaudeModelCatalogProbe({ + resolveInheritedEnv: environment.resolveClaudeInheritedEnv, + resolveAuthPolicy: deps.resolveClaudeAuthPolicy, + ...(deps.resolveClaudeCommand ? { resolveCommand: deps.resolveClaudeCommand } : {}), + ...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}) + }) + } +} + +export function acpModelCatalogDiscovery( + spec: AcpLaunchSpec, + { deps, environment }: StructuredAgentModelCatalogContext +): AgentModelCatalogDiscovery { + const discovery = spec.modelDiscovery + if (discovery.kind === 'unavailable') { + return { kind: 'unavailable', reason: discovery.reason } + } + return { + kind: 'probe', + listingNamesConfiguredModel: discovery.listingNamesConfiguredModel, + probe: createAcpModelCatalogProbe(spec, { + resolveEnvironment: environment.resolveBaseEnvironment, + ...(deps.resolveAgentLaunchEnv ? { resolveLaunchEnv: deps.resolveAgentLaunchEnv } : {}), + ...(deps.resolveAgentCommandSettings + ? { resolveCommandSettings: deps.resolveAgentCommandSettings } + : {}) + }) + } +} diff --git a/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts b/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts new file mode 100644 index 000000000000..192bce87c4d0 --- /dev/null +++ b/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionAccountHome } from '../../shared/agent-session-account-home' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionModelOption } from '../../shared/agent-session-wire' +import { structuredAgentSessionSeedCatalog } from '../../shared/structured-agent-session-seed-catalog' +import { + applyStructuredAgentSessionModelCatalog, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../shared/structured-agent-session-options' +import { AcpStructuredOptions } from '../acp/acp-structured-options' +import { GROK_ACP_DIALECT } from '../acp/acp-dialects/grok-dialect' +import { grokModelCatalogFromState } from '../acp/acp-dialects/grok-model-catalog' +import { agentModelCatalogFingerprintForRecord } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' +import { createAgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' +import { workspaceMayOverrideDefaultModel } from '../native-chat/agent-model-catalog/agent-project-model-override' +import { + AgentModelCatalogStore, + withLiveCatalogListing, + type AgentModelCatalogProbe +} from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from './structured-agent-runtime-registrations' +import type { StructuredAgentModelCatalogContext } from './structured-agent-runtime-registrations' +import { registeredModelCatalogDiscovery } from './structured-agent-model-catalog-wiring' + +// The first frame a new chat paints, from the real registration list through the host service to +// the shared picker: nothing here passes the "names its default" answer by hand. + +function context(): StructuredAgentModelCatalogContext { + const unused = async (): Promise => { + throw new Error('building a probe resolves nothing') + } + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: building a probe only captures resolvers; none of these deps is read until a probe runs. + deps: { stateDirectory: '/state' } as StructuredAgentModelCatalogContext['deps'], + environment: { + resolveBaseEnvironment: unused, + resolveCodexEnvironment: unused, + resolveClaudeInheritedEnv: unused + } + } +} + +const HOMES: Record = { + grok: { variable: 'GROK_HOME', path: '/homes/grok' }, + pi: { variable: 'PI_CODING_AGENT_DIR', path: '/homes/pi' }, + claude: { variable: 'CLAUDE_CONFIG_DIR', path: '/homes/claude' } +} + +const GROK_EFFORT_META = { + supportsReasoningEffort: true, + reasoningEfforts: [ + { value: 'low', id: 'low' }, + { value: 'high', id: 'high', default: true } + ] +} + +// What Grok's `initialize` reports with no session; its `currentModelId` names no default. +const GROK_LISTING = grokModelCatalogFromState({ + currentModelId: 'grok-4', + availableModels: [ + { modelId: 'grok-4', name: 'Grok 4', _meta: GROK_EFFORT_META }, + { modelId: 'grok-3-mini', name: 'Grok 3 Mini' } + ] +}) + +function grokRecord(): AgentSessionRecord { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the catalog service reads only provider, accountHome and location. + return { + sessionId: 'grok-session', + provider: 'grok', + accountHome: HOMES.grok, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'ws-1', + workspaceKind: 'git-worktree' + } + } as AgentSessionRecord +} + +function service( + store: AgentModelCatalogStore, + listings: Record, + records: AgentSessionRecord[] = [] +) { + const registered = registeredModelCatalogDiscovery( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + context() + ) + const probes: Record = {} + for (const [agent, models] of Object.entries(listings)) { + probes[agent] = async () => ({ models, fastModeTierByModel: new Map(), origin: 'probe' }) + } + return createAgentModelCatalogService({ + store, + getRecord: (sessionId) => records.find((record) => record.sessionId === sessionId), + drivesRecord: () => true, + resolveAccountHome: async (agent) => HOMES[agent]!, + probes, + workspaceMayOverrideDefaultModel, + listingNamesConfiguredModel: registered.listingNamesConfiguredModel + }) +} + +async function newChatFirstFrame( + catalog: ReturnType, + agent: string, + workspacePath?: string +) { + const host = await catalog.read({ + agent, + waitForListing: true, + ...(workspacePath ? { workspacePath } : {}) + }) + const seed = structuredAgentSessionSeedCatalog(agent) + const state = applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState(agent, seed), + seed, + host, + { newLaunch: true } + ) + const snapshot = structuredAgentSessionOptionSnapshot(state) + const select = (id: string) => { + const descriptor = snapshot.find((entry) => entry.id === id) + return descriptor?.kind.type === 'select' ? descriptor.kind : null + } + return { host, state, model: select('model'), effort: select('effort') } +} + +describe('a new chat’s first frame from the host catalog', () => { + it('lists a warm Grok account’s models without naming the one `initialize` computed', async () => { + const catalog = service(new AgentModelCatalogStore(), { grok: GROK_LISTING }) + const { host, state, model } = await newChatFirstFrame(catalog, 'grok', '/repo') + // A session can run another model; a chat started with no pick names the default instead. + expect(host).toMatchObject({ origin: 'probe', listingNamesConfiguredModel: false }) + expect(model?.choices.map((choice) => choice.value)).toEqual(['grok-4', 'grok-3-mini']) + expect(model?.currentValue).toBeUndefined() + expect(state.record.model).toBeUndefined() + }) + + it('lists a warm Pi account’s models without naming one its listing does not name', async () => { + const catalog = service(new AgentModelCatalogStore(), { + pi: [ + { + id: 'anthropic/sonnet', + label: 'Sonnet', + isDefault: false, + efforts: [{ value: 'high', label: 'high' }] + } + ] + }) + const { host, model } = await newChatFirstFrame(catalog, 'pi') + expect(host).toMatchObject({ listingNamesConfiguredModel: false }) + expect(model?.choices.map((choice) => choice.value)).toEqual(['anthropic/sonnet']) + expect(model?.currentValue).toBeUndefined() + }) +}) + +describe('a running chat’s listing, saved for the next chat', () => { + it('keeps a Grok account warm from a live session, without its picks becoming defaults', async () => { + const store = new AgentModelCatalogStore() + const record = grokRecord() + const catalog = service(store, { grok: GROK_LISTING }, [record]) + await newChatFirstFrame(catalog, 'grok') + // The running chat switched to the mini model and reports a list with one more model. + const options = new AcpStructuredOptions(GROK_ACP_DIALECT) + options.adoptSession({ + models: { + currentModelId: 'grok-3-mini', + availableModels: [ + { modelId: 'grok-4', name: 'Grok 4', _meta: GROK_EFFORT_META }, + { modelId: 'grok-3-mini', name: 'Grok 3 Mini' }, + { modelId: 'grok-code', name: 'Grok Code' } + ] + } + }) + catalog.recordLiveListing( + record.sessionId, + withLiveCatalogListing(options.read()).catalogListing + ) + + const { host, model, effort } = await newChatFirstFrame(catalog, 'grok') + expect(host.origin).toBe('live-session') + expect(model?.choices.map((choice) => choice.value)).toEqual([ + 'grok-4', + 'grok-3-mini', + 'grok-code' + ]) + // A loaded session's model is its own, never the account's default. + expect(model?.currentValue).toBeUndefined() + expect(effort).toBeNull() + }) + + it('keeps per-model efforts a session only knows for the model it runs', async () => { + const store = new AgentModelCatalogStore() + const record: AgentSessionRecord = { + ...grokRecord(), + provider: 'opencode', + sessionId: 'oc', + accountHome: { kind: 'opencode', locator: { kind: 'unmanaged' } } + } + const listed: AgentSessionModelOption[] = [ + { id: 'a', label: 'A', isDefault: false, efforts: [{ value: 'max', label: 'Max' }] }, + { id: 'b', label: 'B', isDefault: false, efforts: [{ value: 'low', label: 'Low' }] } + ] + const catalog = service(store, {}, [record]) + store.recordSuccess( + agentModelCatalogFingerprintForRecord(record), + 'opencode', + { + models: listed, + fastModeTierByModel: new Map(), + origin: 'probe' + }, + 'discovery' + ) + catalog.recordLiveListing(record.sessionId, { + models: [ + { id: 'a', label: 'A', isDefault: false, efforts: [{ value: 'high', label: 'High' }] }, + { id: 'b', label: 'B', isDefault: false, efforts: [] } + ] + }) + const read = await catalog.read({ agent: 'opencode', sessionId: 'oc' }) + expect(read.origin === 'unknown' ? null : read.models.map((m) => m.efforts)).toEqual([ + [{ value: 'high', label: 'High' }], + [{ value: 'low', label: 'Low' }] + ]) + }) + + it('saves a Claude live listing through the same step as every other agent', async () => { + const store = new AgentModelCatalogStore() + const record = { ...grokRecord(), provider: 'claude', accountHome: HOMES.claude! } + const catalog = service(store, {}, [record]) + catalog.recordLiveListing(record.sessionId, { + models: [{ id: 'opus', label: 'Opus', isDefault: false, efforts: [] }] + }) + const read = await catalog.read({ agent: 'claude', sessionId: record.sessionId }) + expect(read.origin === 'unknown' ? null : read.models.map((m) => m.id)).toEqual(['opus']) + }) +}) diff --git a/src/main/runtime/structured-agent-model-catalog-wiring.ts b/src/main/runtime/structured-agent-model-catalog-wiring.ts index f6b22651f3ac..f72f5eb4b4f8 100644 --- a/src/main/runtime/structured-agent-model-catalog-wiring.ts +++ b/src/main/runtime/structured-agent-model-catalog-wiring.ts @@ -5,15 +5,19 @@ import { type AgentModelCatalogService, type AgentModelCatalogServiceDeps } from '../native-chat/agent-model-catalog/agent-model-catalog-service' -import { createCodexModelCatalogProbe } from '../codex/codex-model-catalog-probe' -import { createClaudeModelCatalogProbe } from '../claude/claude-model-catalog-probe' -import { workspaceMayOverrideDefaultModel } from '../native-chat/agent-model-catalog/agent-project-model-override' -import type { ClaudeStructuredLaunchResolverDeps } from '../claude/claude-structured-launch-resolution' -import type { CodexStructuredLaunchResolverDeps } from '../codex/codex-structured-launch-resolution' +import { + agentReadsProjectModelConfig, + workspaceMayOverrideDefaultModel +} from '../native-chat/agent-model-catalog/agent-project-model-override' +import type { AgentModelCatalogProbe } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import type { AgentSessionRecordStore } from './agent-session-record-store' -import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' +import type { + StructuredAgentModelCatalogContext, + StructuredAgentRuntimeRegistration +} from './structured-agent-runtime-registrations' import type { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' import { agentDrivesSession } from '../native-chat/agent-session-wire/structured-agent-session-provider-support' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' // The store is process-global; hydrate it from disk at most once per process. let persistenceAttached = false @@ -34,36 +38,43 @@ export async function attachAgentModelCatalogPersistenceOnce( } } +type CatalogRegistration = Pick + +/** What the catalog service learns from the registration list, the only agent roster. */ +export function registeredModelCatalogDiscovery( + registrations: readonly CatalogRegistration[], + context: StructuredAgentModelCatalogContext +): { + probes: Record + listingNamesConfiguredModel: Set +} { + const probes: Record = {} + const listingNamesConfiguredModel = new Set() + for (const registration of registrations) { + const { agent } = registration.definition + const discovery = registration.modelCatalog(context) + if (discovery.kind === 'probe') { + probes[agent] = discovery.probe + if (discovery.listingNamesConfiguredModel) { + listingNamesConfiguredModel.add(agent) + } + } + } + return { probes, listingNamesConfiguredModel } +} + /** * The host-deps slice for the catalog surface: hydrates the store from disk * once, then builds the service — or nothing, when the runtime cannot name * the currently selected account, in which case every catalog read answers * `unknown` rather than guessing a key. - * - * The probes list through the SAME invocation resolvers session launches use; - * a probe under a different env or binary could list models the user's - * sessions cannot see, under their key. */ export async function modelCatalogHostDeps(input: { - store: Pick + store: Pick agents: Pick - deps: Pick< - StructuredAgentSessionRuntimeDeps, - | 'stateDirectory' - | 'resolveAgentAccountHome' - | 'resolveCodexAccountKind' - | 'prepareCodexCatalogProbeHome' - | 'resolveCodexCommand' - | 'resolveClaudeCommand' - | 'resolveClaudeLaunchEnv' - | 'resolveClaudeAuthPolicy' - > - envResolvers: { - resolveCodexEnvironment: NonNullable - resolveClaudeInheritedEnv: NonNullable< - ClaudeStructuredLaunchResolverDeps['resolveInheritedEnv'] - > - } + registrations: readonly CatalogRegistration[] + deps: StructuredAgentModelCatalogContext['deps'] + environment: StructuredAgentModelCatalogContext['environment'] }): Promise<{ modelCatalog?: AgentModelCatalogService }> { await attachAgentModelCatalogPersistenceOnce(input.deps.stateDirectory) const { deps } = input @@ -75,27 +86,27 @@ export async function modelCatalogHostDeps(input: { getRecord: (sessionId) => input.store.getRecord(sessionId) ?? undefined, drivesRecord: (record) => agentDrivesSession(input.agents, record), resolveAccountHome: deps.resolveAgentAccountHome, + recordWorkspacePath: async (record) => + record.launchDirectory ?? + (await deps.resolveWorkspacePath(record.location.workspaceId).catch(() => null)), + agentReadsProjectModelConfig, workspaceMayOverrideDefaultModel, - probes: { - codex: createCodexModelCatalogProbe({ - resolveAccountKind: deps.resolveCodexAccountKind, - ...(deps.prepareCodexCatalogProbeHome - ? { prepareHome: deps.prepareCodexCatalogProbeHome } - : {}), - resolveEnvironment: input.envResolvers.resolveCodexEnvironment, - ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) - }), - claude: createClaudeModelCatalogProbe({ - resolveInheritedEnv: input.envResolvers.resolveClaudeInheritedEnv, - resolveAuthPolicy: deps.resolveClaudeAuthPolicy, - ...(deps.resolveClaudeCommand ? { resolveCommand: deps.resolveClaudeCommand } : {}), - ...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}) - }) - } + hasChatRecords: (agent) => + input.store.listRecords().some((record) => record.provider === agent), + ...registeredModelCatalogDiscovery(input.registrations, { + deps, + environment: input.environment + }) }) return { modelCatalog } } +/** The installed host lists what a changed account or agent setting made cold; with no host yet, + * its install lists everything anyway. */ +export function prewarmStructuredAgentModelCatalogs(): void { + void getStructuredAgentSessionHost()?.deps.modelCatalog?.prewarm() +} + // Re-exported so the runtime deps type can reference the resolver shape without // importing the service module directly. export type RuntimeAgentAccountHomeResolver = AgentModelCatalogServiceDeps['resolveAccountHome'] diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index b3fda4e25e24..bba20e17847d 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -51,6 +51,12 @@ import { } from '../acp/acp-structured-launch-resolution' import { AcpStructuredSessionAdapter } from '../acp/acp-structured-session-adapter' import { PI_RPC_RUNTIME_REGISTRATION } from '../pi/rpc-runtime-registration' +import type { AgentModelCatalogDiscovery } from '../native-chat/agent-model-catalog/agent-model-catalog-discovery' +import { + acpModelCatalogDiscovery, + claudeModelCatalogDiscovery, + codexModelCatalogDiscovery +} from './structured-agent-model-catalog-discovery' /** What an agent's adapter is built from: the open store and the runtime around it. */ export type StructuredAgentAdapterContext = { @@ -91,9 +97,18 @@ export type StructuredAgentAccountHomeServices = { workspaceTrustSettings: () => Parameters[0]['settings'] } +/** What a catalog probe is built from: the same runtime resolvers its sessions launch through. */ +export type StructuredAgentModelCatalogContext = Pick< + StructuredAgentAdapterContext, + 'deps' | 'environment' +> + export type StructuredAgentRuntimeRegistration = { definition: StructuredAgentDefinition createAdapter: (context: StructuredAgentAdapterContext) => StructuredAgentRuntimeAdapter + /** How this agent's models are listed before any session of it runs. Required: an agent that + * cannot list says so, rather than being left out of the shared catalog. */ + modelCatalog: (context: StructuredAgentModelCatalogContext) => AgentModelCatalogDiscovery /** Whether this agent's chats can run at `location`; answered without building the host. */ supportsLocation: (location: AgentSessionExecutionLocation) => boolean /** Whether the agent installed on this host runs a structured chat, asked at create with the @@ -187,8 +202,7 @@ function createClaudeAdapter( onDispatchSettledLate: followUps.onDispatchSettledLate, onSessionIdle: followUps.releaseUnansweredDispatches, ...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}), - ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), - modelCatalog: agentModelCatalogStore + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}) }) } @@ -215,6 +229,7 @@ function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistratio } : {}), resolveAccountHome: ({ launchEnv }) => spec.account.resolve({ launchEnv }), + modelCatalog: (context) => acpModelCatalogDiscovery(spec, context), createAdapter: (context) => { const { deps, store, followUps } = context const readJournal = (sessionId: string) => @@ -279,6 +294,7 @@ export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRun { definition: CODEX_STRUCTURED_AGENT, createAdapter: createCodexAdapter, + modelCatalog: codexModelCatalogDiscovery, supportsLocation: (location) => supportsCodexStructuredLocation(location), resolveAccountHome: async (request, services) => agentSessionAccountHome( @@ -289,6 +305,7 @@ export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRun { definition: CLAUDE_STRUCTURED_AGENT, createAdapter: createClaudeAdapter, + modelCatalog: claudeModelCatalogDiscovery, supportsLocation: supportsClaudeStructuredLocation, resolveAccountHome: async ({ launchEnv, location }, services) => agentSessionAccountHome( diff --git a/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts b/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts new file mode 100644 index 000000000000..7fe0c5945860 --- /dev/null +++ b/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts @@ -0,0 +1,61 @@ +// The host's catalog listings live exactly as long as the host: installing it starts the +// runtime-start prewarm, and tearing it down (which quit does too) stops every listing it started. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' +import type * as ModelCatalogWiring from './structured-agent-model-catalog-wiring' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' + +const catalog = vi.hoisted((): { service: AgentModelCatalogService | null } => ({ service: null })) + +vi.mock('./structured-agent-model-catalog-wiring', async (importOriginal) => ({ + ...(await importOriginal()), + modelCatalogHostDeps: async () => (catalog.service ? { modelCatalog: catalog.service } : {}) +})) + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-structured-catalog-lifetime-')) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime().catch(() => undefined) + catalog.service = null + await rm(root, { recursive: true, force: true }) +}) + +describe('the host catalog lifetime', () => { + it('prewarms once the host is installed and stops its listings at teardown', async () => { + const service = { + read: vi.fn(async () => ({ origin: 'unknown' as const })), + recordLiveListing: vi.fn(), + prewarm: vi.fn(async () => {}), + stop: vi.fn(), + providerStarted: vi.fn() + } + catalog.service = service + await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + resolveLaunchArgs: () => [], + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + }) + expect(service.prewarm).toHaveBeenCalledOnce() + expect(service.stop).not.toHaveBeenCalled() + + await stopStructuredAgentSessionRuntime() + expect(service.stop).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 8853d7428f20..5c7d87623c2c 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -326,7 +326,13 @@ async function installOnJournal( ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), ...(deps.onSessionTabHidden ? { onSessionTabHidden: deps.onSessionTabHidden } : {}), - ...(await modelCatalogHostDeps({ store, agents, deps, envResolvers })) + ...(await modelCatalogHostDeps({ + store, + agents, + registrations: STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + deps, + environment: envResolvers + })) }) if (deps.attentionDelivery) { const installed = host @@ -347,6 +353,9 @@ async function installOnJournal( }) } setStructuredAgentSessionHost(host) + // The host starts with its runtime, local or remote, so this is the runtime-start listing. + const modelCatalog = host.deps.modelCatalog + void modelCatalog?.prewarm() installAgentSessionAttachments({ stateDirectory: deps.stateDirectory, store, @@ -367,6 +376,9 @@ async function installOnJournal( adapter, journalDatabase, waitForRecovery: lifecycle.drain, - ...(stopVisualsSweep ? { stopBackgroundWork: stopVisualsSweep } : {}) + stopBackgroundWork: () => { + stopVisualsSweep?.() + modelCatalog?.stop() + } } } diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 4533ccc3e889..e55e8dcbd746 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -45,7 +45,6 @@ export type StructuredClaudeRuntimeAdapterDeps = { attachmentDirectory?: string openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] readProcessStartTime?: ClaudeStructuredSessionAdapterDeps['readProcessStartTime'] - modelCatalog?: ClaudeStructuredSessionAdapterDeps['modelCatalog'] onLifecycleEvent: (event: StructuredAgentSessionLifecycleEvent) => void onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'] onSessionIdle?: ClaudeStructuredSessionAdapterDeps['onSessionIdle'] @@ -167,8 +166,7 @@ export function createStructuredClaudeRuntimeAdapter( ...(deps.onChildWorkEvidence ? { onChildWorkEvidence: deps.onChildWorkEvidence } : {}), ...(deps.logger ? { logger: deps.logger } : {}), ...openClaudeConnectionOf(deps), - ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), - ...(deps.modelCatalog ? { modelCatalog: deps.modelCatalog } : {}) + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}) }) } diff --git a/src/main/startup/codex-launch-preparation.ts b/src/main/startup/codex-launch-preparation.ts index b4ad5117d806..716b4830ef88 100644 --- a/src/main/startup/codex-launch-preparation.ts +++ b/src/main/startup/codex-launch-preparation.ts @@ -60,3 +60,19 @@ export async function prepareCodexRuntimeHomeForLaunch( } return runtimeHomePath } + +/** The runtime's structured Codex home resolvers: a launch prepares the home that a record-less + * catalog read resolves without side effects, so a new chat's picker and the chat share one key. */ +export const codexStructuredLaunchHomeResolvers = { + prepareCodexStructuredLaunch: ({ launchEnv }: { launchEnv: NodeJS.ProcessEnv }) => + prepareCodexRuntimeHomeForLaunch(undefined, launchEnv), + // Why throw like prepare does: a null from an uninitialized service would + // map to the system home and key a catalog read to the wrong account. + resolveCodexStructuredLaunchHome: ({ launchEnv }: { launchEnv: NodeJS.ProcessEnv }) => { + const runtimeHome = state.codexRuntimeHome + if (!runtimeHome) { + throw new Error('Codex runtime home service is not initialized') + } + return runtimeHome.resolveHostCodexHomePathForLaunchReadOnly(launchEnv) + } +} diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index 07581d449114..d1ad8e1b3bb5 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -27,10 +27,23 @@ import { normalizeClaudeRuntimeSelection } from '../claude-accounts/runtime-sele import { agentHookServer } from '../agent-hooks/server' import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' import { browserManager } from '../browser/browser-manager' +import { prewarmStructuredAgentModelCatalogs } from '../runtime/structured-agent-model-catalog-wiring' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import { expireAgentModelCatalogFailuresForSettings } from '../native-chat/agent-model-catalog/agent-model-catalog-account-expiry' +import { createAgentModelCatalogSettingsExpiry } from '../native-chat/agent-model-catalog/agent-model-catalog-account-expiry' import { mainProcessState as state } from './main-process-state' +// Settings that change which account or binary a new chat's agent runs under. +const MODEL_CATALOG_ACCOUNT_SETTINGS = [ + 'activeCodexManagedAccountId', + 'activeCodexManagedAccountIdsByRuntime', + 'codexManagedAccounts', + 'activeClaudeManagedAccountId', + 'activeClaudeManagedAccountIdsByRuntime', + 'claudeManagedAccounts', + 'agentDefaultEnv', + 'agentCmdOverrides' +] as const + export function initializeMainProcessAccountServices(): void { const store = state.store if ( @@ -79,12 +92,20 @@ export function initializeMainProcessAccountServices(): void { state.rateLimits, store.getSettings() ) + const expireModelCatalogs = createAgentModelCatalogSettingsExpiry( + agentModelCatalogStore, + store.getSettings() + ) store.onSettingsChanged((updates, settings) => { - expireAgentModelCatalogFailuresForSettings(agentModelCatalogStore, updates) + expireModelCatalogs(updates, settings) // Why: auto is a live policy; retarget only providers whose settings-derived runtime changed. void syncAccountRuntimeTargets(updates, settings).catch((error) => console.warn('[rate-limits] Failed to apply account runtime target:', error) ) + // An account switch would otherwise leave the next chat's picker on a cold catalog. + if (MODEL_CATALOG_ACCOUNT_SETTINGS.some((key) => key in updates)) { + prewarmStructuredAgentModelCatalogs() + } if ('opencodeSessionCookie' in updates || 'opencodeWorkspaceId' in updates) { state.rateLimits?.invalidateOpenCodeGoCredentialState() void state.rateLimits?.refresh().catch((error: unknown) => { diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 5639540083a1..ffaf17c45a68 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -25,7 +25,10 @@ import { getPreferredPairingOffer } from '../../shared/runtime-environments' import { fingerprintOrchestrationPeer } from '../runtime/orchestration/environment-transport' import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' import { mainProcessState as state } from './main-process-state' -import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' +import { + codexStructuredLaunchHomeResolvers, + prepareCodexRuntimeHomeForLaunch +} from './codex-launch-preparation' import type { RuntimeDesktopWindowStatus } from '../../shared/runtime-types' import { ArtifactCloudService } from '../artifacts/artifact-cloud-service' import { SkillCloudService } from '../skills/skill-cloud-service' @@ -143,17 +146,7 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()), preparePinnedLaunchHome: (home) => prepareCodexPinnedLaunchHome(home) }), - prepareCodexStructuredLaunch: ({ launchEnv }) => - prepareCodexRuntimeHomeForLaunch(undefined, launchEnv), - // Why throw like prepare does: a null from an uninitialized service would - // map to the system home and key a catalog read to the wrong account. - resolveCodexStructuredLaunchHome: ({ launchEnv }) => { - const runtimeHome = state.codexRuntimeHome - if (!runtimeHome) { - throw new Error('Codex runtime home service is not initialized') - } - return runtimeHome.resolveHostCodexHomePathForLaunchReadOnly(launchEnv) - }, + ...codexStructuredLaunchHomeResolvers, prepareCodexCatalogProbeHome: (homePath) => state.codexRuntimeHome?.prepareHostCodexHomeForReadOnlyAppServer( homePath, diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index bab4c09e1e81..ebe655728115 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -17,6 +17,7 @@ import { useOsc52ClipboardDefaultOnNotice } from '../components/terminal-pane/os import { useWebSessionTabsSync } from '../runtime/web-session-tabs-sync' import { useLocalStructuredSessionTabsSync } from '../runtime/local-structured-session-tabs-sync' import { useHostStructuredAgentsSync } from '../runtime/host-structured-agents-sync' +import { useHostModelCatalogSnapshotsSync } from '../runtime/host-model-catalog-snapshots-sync' import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime-recovery-triggers' import { useTerminalViewerColorPublication } from './use-terminal-viewer-color-publication' import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/browser-user-agent-migration-notice' @@ -43,6 +44,7 @@ export function useAppShellServices(): void { useWebSessionTabsSync() useLocalStructuredSessionTabsSync() useHostStructuredAgentsSync() + useHostModelCatalogSnapshotsSync() // Subscribe to IPC push events useIpcEvents() useProfileStateSaveDelayNotice() diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx deleted file mode 100644 index a6221046e466..000000000000 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx +++ /dev/null @@ -1,109 +0,0 @@ -// @vitest-environment happy-dom - -// Against the real picker: a `/model` request opens it once with the search focused, and a period -// while the host still lists models neither opens it later nor reopens one the user closed. - -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { - SessionOptionDescriptor, - SessionOptionsSurface -} from '../../../../shared/native-chat-session-options' - -vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) - -vi.mock('@/i18n/i18n', () => ({ - translate: (_key: string, fallback: string, values?: Record) => - values - ? Object.entries(values).reduce( - (text, [name, value]) => text.replaceAll(`{{${name}}}`, String(value)), - fallback - ) - : fallback -})) - -import { TooltipProvider } from '@/components/ui/tooltip' -import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' -import type { NativeChatOptionPickerRequest } from './native-chat-composer-types' - -function model(pending: boolean): SessionOptionDescriptor { - return { - id: 'model', - label: 'Model', - category: 'model', - kind: { - type: 'select', - currentValue: 'opus', - choices: [ - { value: 'opus', label: 'Opus 4.8' }, - { value: 'sonnet', label: 'Sonnet 5' } - ] - }, - valueSource: 'applied', - transport: 'agent-session', - settable: !pending, - ...(pending ? { choicesPending: true as const } : {}) - } -} - -const surface: SessionOptionsSurface = { - getSnapshot: () => [], - setOption: vi.fn(async () => ({ snapshot: [] })), - invokeAction: vi.fn(async () => ({ snapshot: [] })), - subscribe: () => () => {} -} - -function view(pending: boolean, request: NativeChatOptionPickerRequest | null): React.JSX.Element { - return ( - -