From 0979a801f107ae0e61dc828bdff958144a77433d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:00:59 -0700 Subject: [PATCH 01/30] feat(native-chat): one saved model catalog for every structured agent Every agent registration now declares how its models are listed without a session (a probe, or explicitly none). Probes share one supervised runner (15 s budget, bounded output, teardown on every path). Grok lists from initialize (or x.ai/models/list), OpenCode from models --verbose, Pi from --list-models; Claude moves onto the shared runner. ACP option reads no longer copy the running model's effort menu or selection onto every model. Desktop and phone pickers read the same catalog for every agent, with a non-pickable provider-default model pill before any list exists. --- ...se-mobile-structured-agent-options.test.ts | 24 +++ .../use-mobile-structured-agent-options.ts | 7 +- src/main/acp/acp-dialects/acp-dialect.ts | 9 +- src/main/acp/acp-dialects/grok-dialect.ts | 2 + .../acp/acp-dialects/grok-model-catalog.ts | 124 +++++++++++ src/main/acp/acp-launch-specs.ts | 33 ++- src/main/acp/acp-model-catalog-probe.test.ts | 200 ++++++++++++++++++ src/main/acp/acp-model-catalog-probe.ts | 81 +++++++ src/main/acp/acp-session-runtime.ts | 10 + src/main/acp/acp-structured-acquire.ts | 2 +- .../acp/acp-structured-launch-resolution.ts | 44 +++- src/main/acp/acp-structured-options.test.ts | 99 +++++++++ src/main/acp/acp-structured-options.ts | 25 ++- .../claude/claude-model-catalog-probe.test.ts | 134 ++++++------ src/main/claude/claude-model-catalog-probe.ts | 52 +++-- .../codex/codex-model-catalog-probe.test.ts | 12 +- src/main/codex/codex-model-catalog-probe.ts | 20 +- ...codex-short-lived-app-server-spawn.test.ts | 2 +- ...structured-session-options-catalog.test.ts | 4 +- .../agent-model-catalog-discovery.ts | 11 + .../agent-model-catalog-probe-runner.test.ts | 132 ++++++++++++ .../agent-model-catalog-probe-runner.ts | 162 ++++++++++++++ .../agent-model-catalog-service.test.ts | 70 +++++- .../agent-model-catalog-service.ts | 14 +- .../agent-model-catalog-store.test.ts | 8 +- .../agent-model-catalog-store.ts | 6 +- .../opencode-model-catalog-listing.test.ts | 82 +++++++ .../opencode-model-catalog-listing.ts | 88 ++++++++ src/main/pi/rpc-model-catalog-probe.test.ts | 64 ++++++ src/main/pi/rpc-model-catalog-probe.ts | 66 ++++++ src/main/pi/rpc-options.ts | 4 + src/main/pi/rpc-runtime-registration.ts | 27 ++- .../managed-provider-process.ts | 8 +- ...ured-agent-model-catalog-discovery.test.ts | 49 +++++ ...tructured-agent-model-catalog-discovery.ts | 53 +++++ .../structured-agent-model-catalog-wiring.ts | 63 +++--- .../structured-agent-runtime-registrations.ts | 18 ++ .../structured-agent-session-runtime.ts | 8 +- ...red-agent-registered-agent-surface.test.ts | 4 +- .../structured-agent-session-seed-catalog.ts | 21 -- ...e-structured-agent-session-option-state.ts | 2 +- .../use-structured-agent-session-options.ts | 2 +- ...d-agent-session-first-frame-picker.test.ts | 92 ++++++++ .../structured-agent-session-options.ts | 18 +- .../structured-agent-session-seed-catalog.ts | 21 ++ 45 files changed, 1751 insertions(+), 226 deletions(-) create mode 100644 src/main/acp/acp-dialects/grok-model-catalog.ts create mode 100644 src/main/acp/acp-model-catalog-probe.test.ts create mode 100644 src/main/acp/acp-model-catalog-probe.ts create mode 100644 src/main/acp/acp-structured-options.test.ts create mode 100644 src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts create mode 100644 src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.test.ts create mode 100644 src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.ts create mode 100644 src/main/opencode/opencode-model-catalog-listing.test.ts create mode 100644 src/main/opencode/opencode-model-catalog-listing.ts create mode 100644 src/main/pi/rpc-model-catalog-probe.test.ts create mode 100644 src/main/pi/rpc-model-catalog-probe.ts create mode 100644 src/main/runtime/structured-agent-model-catalog-discovery.test.ts create mode 100644 src/main/runtime/structured-agent-model-catalog-discovery.ts delete mode 100644 src/renderer/src/components/native-chat/structured-agent-session-seed-catalog.ts create mode 100644 src/shared/structured-agent-session-first-frame-picker.test.ts create mode 100644 src/shared/structured-agent-session-seed-catalog.ts 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..daace4b42ed1 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.test.ts @@ -396,3 +396,27 @@ 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() + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 54d14fd2a8ae..e7b6a6b66991 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 @@ -70,8 +70,9 @@ export function useMobileStructuredAgentOptions(args: { sessionId: string commands: readonly AgentSessionConversationCommand[] } | null>(null) + // Every agent reads the host's options, which at rest carry the host catalog for the chat's account. const optionCatalog = useMemo( - () => (agent === 'claude' || agent === 'codex' ? getAgentSessionOptionCatalog(agent) : null), + () => (agent ? structuredAgentSessionSeedCatalog(agent) : null), [agent] ) @@ -152,7 +153,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/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index c554e9b6115d..21e9ca9e2fe1 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -4,7 +4,12 @@ import type { AgentSessionPromptResponse } from '../../../shared/agent-session-q import type { NativeChatBackgroundTaskBlock } 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 { PermissionOption, ToolCallUpdate } from '../generated/acp-protocol.generated' +import type { AgentSessionOptionChoice } from '../../../shared/agent-session-wire' +import type { + ModelInfo, + PermissionOption, + ToolCallUpdate +} from '../generated/acp-protocol.generated' export type AcpRequestPresentation = { body: ProviderTimelineRequestBody @@ -52,6 +57,8 @@ export type AcpDialect = { ): AcpBackgroundTaskUpdate[] 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 609a9c313c5e..d0130121b159 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' const tokenCount = z.number().int().nonnegative() const toolMetaSchema = z.object({ 'x.ai/tool': z.object({ name: z.string().min(1) }) }) @@ -177,6 +178,7 @@ export const GROK_ACP_DIALECT: AcpDialect = { toolBackgroundTasks: grokToolBackgroundTasks, 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..1f5376502b5f --- /dev/null +++ b/src/main/acp/acp-dialects/grok-model-catalog.ts @@ -0,0 +1,124 @@ +// Grok's own model facts: the per-model effort menu it writes in each model's `_meta`, and the +// model state it computes 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 } : {}) } +} + +/** With no session, `currentModelId` is the account's configured model, so it is the default. */ +export function grokModelCatalogFromState(state: SessionModelState): AgentSessionModelOption[] { + return state.availableModels.map((model) => ({ + id: model.modelId, + label: model.name, + ...(model.description ? { description: model.description } : {}), + isDefault: model.modelId === state.currentModelId, + ...grokModelEfforts(model) + })) +} + +/** The models `initialize` computed, else `x.ai/models/list`; neither creates a session. */ +export async function readGrokModelCatalog( + initialized: InitializeResponse, + connection: { requestExtension(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.requestExtension('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 dcf91c1d4f8d..d4f6199be30a 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -17,6 +17,26 @@ import { openCodeStoredUserMessagesReader } from '../opencode/opencode-acp-store import type { AcpStoredUserMessagesReader } from './acp-recovery-history' import { 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' + +/** 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: { requestExtension(method: string, params: unknown): Promise } + ): Promise + } + | { kind: 'command'; args: readonly string[]; parse(stdout: string): AgentSessionModelOption[] } + | { kind: 'unavailable'; reason: string } export type AcpLaunchSpec = { /** The Orca agent id, which names the agent's records, its catalog label and its settings. */ @@ -49,6 +69,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 } const GROK_LAUNCH_SPEC: AcpLaunchSpec = { @@ -67,7 +88,9 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = { ? 'cached_token' : undefined, account: directoryAccountBinding('GROK_HOME', (homePath) => join(homePath, '.grok')), - installDirectories: ({ env }) => (env.GROK_HOME ? [join(env.GROK_HOME, 'bin')] : []) + installDirectories: ({ env }) => (env.GROK_HOME ? [join(env.GROK_HOME, 'bin')] : []), + // Grok computes its model state in `initialize`, before and without any session. + modelDiscovery: { kind: 'initialize', read: readGrokModelCatalog } } // OpenCode 1.x serves ACP in-process through `opencode acp`. OpenCode 2 (`opencode2`, and any @@ -89,7 +112,13 @@ const OPENCODE_LAUNCH_SPEC: AcpLaunchSpec = { // Stable 1.x from 1.18.31, the release the recorded sessions capture. supportsVersion: (version) => isStableCliVersionOnLine(version, { major: 1, floor: '1.18.31' }), imagePrompts: true, - readStoredUserMessages: openCodeStoredUserMessagesReader() + 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 + } } export const ACP_LAUNCH_SPECS: readonly AcpLaunchSpec[] = [GROK_LAUNCH_SPEC, OPENCODE_LAUNCH_SPEC] 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..07862e9ff544 --- /dev/null +++ b/src/main/acp/acp-model-catalog-probe.test.ts @@ -0,0 +1,200 @@ +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(), + requestExtension: (method, params) => runtime.requestExtension(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', + isDefault: true, + // 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() + }) + + it('reports an agent that declares no session-free listing', async () => { + const spec: AcpLaunchSpec = { + ...GROK, + modelDiscovery: { kind: 'unavailable', reason: 'no listing without a session' } + } + await expect( + createAcpModelCatalogProbe(spec, scriptedGrok({}).deps)(GROK_HOME) + ).rejects.toThrow('no listing without a session') + }) +}) 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..94b96395e820 --- /dev/null +++ b/src/main/acp/acp-model-catalog-probe.ts @@ -0,0 +1,81 @@ +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 + requestExtension(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): Promise => { + const discovery = spec.modelDiscovery + 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` } + ) + models = discovery.parse(stdout) + } else { + const launch = { command, args: spec.args({ fullAccess: false }), cwd, env, envToDelete } + models = await runAgentModelCatalogSession( + () => (deps.connect ?? createAcpAgentConnection)(launch), + async (connection) => discovery.read(await connection.initialize(), connection), + { label: spec.agent } + ) + } + 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 e205c1f67c46..2100b3cc9338 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 requestExtension(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 b9c8b41d209f..2f2b640b2dc6 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -96,7 +96,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( diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index 8c13e2f3e620..ea8918a11fb6 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 { @@ -114,6 +115,37 @@ export async function acpLaunchVersionSupported( ) } +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 @@ -134,17 +166,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) // Again at every launch: the binary on PATH may have changed since the chat was created. if (!(await acpLaunchVersionSupported(spec, { command, cwd, env }, deps.probeVersion))) { 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..b4c8d0780ff0 --- /dev/null +++ b/src/main/acp/acp-structured-options.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { AcpStructuredOptions } 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' }) + }) +}) diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts index 662c3c98c1a9..f8a7c0fabdee 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -11,6 +11,7 @@ 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 { SessionConfigSelectGroupSchema, SessionConfigSelectOptionSchema, @@ -48,6 +49,8 @@ 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 @@ -102,10 +105,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 +132,15 @@ 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 + return { + ...model, + isDefault: false, + ...(advertised ?? { efforts: model.id === currentModel ? sessionEfforts : [] }) + } + }) const confirmed = [...(currentModel ? ['model'] : []), ...(effortOption ? ['effort'] : [])] return { models, diff --git a/src/main/claude/claude-model-catalog-probe.test.ts b/src/main/claude/claude-model-catalog-probe.test.ts index e77d63d90b05..b50cc047362b 100644 --- a/src/main/claude/claude-model-catalog-probe.test.ts +++ b/src/main/claude/claude-model-catalog-probe.test.ts @@ -5,18 +5,22 @@ 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 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 @@ -34,113 +38,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() } ) @@ -173,7 +163,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' }] }) diff --git a/src/main/claude/claude-model-catalog-probe.ts b/src/main/claude/claude-model-catalog-probe.ts index f3a2646107cf..a027a5dd5b60 100644 --- a/src/main/claude/claude-model-catalog-probe.ts +++ b/src/main/claude/claude-model-catalog-probe.ts @@ -1,6 +1,9 @@ -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 { + 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 { claudeConfigDirEnvPatch } from './claude-config-dir-pin' import { resolveClaudeStructuredInvocation, @@ -10,56 +13,49 @@ 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' 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): 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', - 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 }) - }) - // 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) + const stdout = await (deps.runListing ?? runAgentModelCatalogListing)( + { command, args: [...CLAUDE_MODEL_LIST_ARGS], stdin: CLAUDE_MODEL_LIST_STDIN }, + { site: 'claude-model-catalog-probe', inheritedEnv: env } + ) + // 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/codex/codex-model-catalog-probe.test.ts b/src/main/codex/codex-model-catalog-probe.test.ts index a3aeeebc4af0..20cf9c177e02 100644 --- a/src/main/codex/codex-model-catalog-probe.test.ts +++ b/src/main/codex/codex-model-catalog-probe.test.ts @@ -46,7 +46,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. @@ -96,7 +96,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 } ]) @@ -109,7 +109,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'])( @@ -126,7 +128,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() } ) diff --git a/src/main/codex/codex-model-catalog-probe.ts b/src/main/codex/codex-model-catalog-probe.ts index 66def74598c2..5f5dfd34a2cd 100644 --- a/src/main/codex/codex-model-catalog-probe.ts +++ b/src/main/codex/codex-model-catalog-probe.ts @@ -5,15 +5,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' @@ -33,15 +31,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): Promise => { + const accountHomePath = requireLegacyAgentSessionAccountHome(accountHome).path const { command, environment } = await resolveCodexStructuredInvocation(deps) const run = deps.runSession ?? runCodexAppServerSession const listing = await run( @@ -50,7 +50,7 @@ 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 }, (rpc) => fetchCodexModelCatalogListing({ connection: rpc }) ) 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-session-options-catalog.test.ts b/src/main/codex/codex-structured-session-options-catalog.test.ts index 2bd83ae8b1da..71caf2963760 100644 --- a/src/main/codex/codex-structured-session-options-catalog.test.ts +++ b/src/main/codex/codex-structured-session-options-catalog.test.ts @@ -108,7 +108,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-discovery.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts new file mode 100644 index 000000000000..6b246857ec31 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts @@ -0,0 +1,11 @@ +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 } + | { kind: 'unavailable'; reason: string } 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..07f719835291 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.test.ts @@ -0,0 +1,132 @@ +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 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', async () => { + await expect( + runAgentModelCatalogListing( + { command: join(tmpdir(), 'orca-no-such-agent-binary'), args: [] }, + { site: 'test-listing' } + ) + ).rejects.toBeInstanceOf(AgentModelCatalogProbeError) + }) + + 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' }) + expect(closes).toEqual(['ok', 'crash', 'hang']) + }) +}) 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..bed2d8fd0917 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.ts @@ -0,0 +1,162 @@ +// 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' + +export class AgentModelCatalogProbeError extends Error { + constructor( + message: string, + readonly reason: AgentModelCatalogProbeFailure + ) { + 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 + /** 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 + 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 + try { + return await new Promise((resolve, reject) => { + const fail = (message: string, reason: AgentModelCatalogProbeFailure): void => + reject(new AgentModelCatalogProbeError(message, reason)) + 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) + await managed.close() + } +} + +/** 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 } +): Promise { + const timeoutMs = options.timeoutMs ?? AGENT_MODEL_CATALOG_PROBE_TIMEOUT_MS + const connection = open() + let timer: ReturnType | undefined + const deadline = new Promise((_resolve, reject) => { + 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) + await connection.close().catch(() => undefined) + } +} 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..cbd065864cc9 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')) 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,7 +92,7 @@ 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')) }) it('an account switch with no record reads and prewarms the NEW account, never the old entry', async () => { @@ -112,7 +113,7 @@ describe('agent model catalog service', () => { origin: 'unknown', listingInProgress: true }) - expect(probe).toHaveBeenCalledWith('/homes/new') + expect(probe).toHaveBeenCalledWith(CODEX_HOME('/homes/new')) await vi.waitFor(async () => { const result = await service.read({ agent: 'codex' }) expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-new') @@ -210,7 +211,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, @@ -490,4 +493,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 b56dc291d442..538f3dbc92b2 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 @@ -99,14 +99,12 @@ 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 { @@ -119,11 +117,13 @@ export function createAgentModelCatalogService( accountHome: resolved, wslDistro: null }) - accountHomePath = isLegacyAgentSessionAccountHome(resolved) ? resolved.path : null + probeHome = resolved } + const accountHomePath = + probeHome && isLegacyAgentSessionAccountHome(probeHome) ? probeHome.path : null let entry = deps.store.get(fingerprint) const probe = deps.probes?.[params.agent] - const home = accountHomePath + const home = probeHome // 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) { 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..26ccb525f029 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 @@ -103,7 +103,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,7 +218,9 @@ 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')) + const pending = store.refresh('fp-1', 'codex', probe, () => + probe({ variable: 'CODEX_HOME', path: '/homes/a' }) + ) store.recordSuccess('fp-1', 'codex', success('gpt-live')) settleProbe({ ...success('gpt-probe'), origin: 'probe' }) expect((await pending)!.models[0]!.id).toBe('gpt-probe') 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 f444ef36474b..aafe6a8e2512 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 @@ -2,6 +2,7 @@ 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' // The execution host's one model catalog per (agent, launch fingerprint): @@ -34,7 +35,10 @@ export type AgentModelCatalogSuccess = { origin: 'live-session' | 'probe' } -export type AgentModelCatalogProbe = (accountHomePath: string) => Promise +/** Lists an agent's models without a session, under the account a launch would pin. */ +export type AgentModelCatalogProbe = ( + accountHome: AgentSessionAccountHome +) => Promise /** Who lists, by identity: a live session's per-spawn handle, or the session-less probe. */ export type AgentModelCatalogLister = AgentModelCatalogSessionAccess | AgentModelCatalogProbe 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..2518655c00d4 --- /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): 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' } + ) + 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..1ba760747534 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,15 @@ function createPiRpcAdapter(context: StructuredAgentAdapterContext): StructuredA export const PI_RPC_RUNTIME_REGISTRATION: StructuredAgentRuntimeRegistration = { definition: PI_RPC_AGENT, createAdapter: createPiRpcAdapter, + modelCatalog: (context) => ({ + kind: 'probe', + 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/provider-process/managed-provider-process.ts b/src/main/provider-process/managed-provider-process.ts index c256aeda270f..bd6a3bf3a19d 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, @@ -36,6 +37,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 } export type ManagedProviderProcess = { @@ -66,7 +69,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/structured-agent-model-catalog-discovery.test.ts b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts new file mode 100644 index 000000000000..0ce9ee4085fc --- /dev/null +++ b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from './structured-agent-runtime-registrations' +import { registeredModelCatalogProbes } from './structured-agent-model-catalog-wiring' +import type { StructuredAgentModelCatalogContext } from './structured-agent-runtime-registrations' + +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 = registeredModelCatalogProbes(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 lists its models without a session. + expect(listing.sort()).toEqual( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map( + (registration) => registration.definition.agent + ).sort() + ) + }) +}) 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..fddab72531cf --- /dev/null +++ b/src/main/runtime/structured-agent-model-catalog-discovery.ts @@ -0,0 +1,53 @@ +// 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', + probe: createCodexModelCatalogProbe({ + resolveEnvironment: environment.resolveCodexEnvironment, + ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) + }) + } +} + +export function claudeModelCatalogDiscovery({ + deps, + environment +}: StructuredAgentModelCatalogContext): AgentModelCatalogDiscovery { + return { + kind: 'probe', + 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 { + return { + kind: 'probe', + 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-wiring.ts b/src/main/runtime/structured-agent-model-catalog-wiring.ts index 0b537dd04893..2636ad3442e2 100644 --- a/src/main/runtime/structured-agent-model-catalog-wiring.ts +++ b/src/main/runtime/structured-agent-model-catalog-wiring.ts @@ -5,13 +5,13 @@ 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 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' @@ -34,34 +34,33 @@ export async function attachAgentModelCatalogPersistenceOnce( } } +/** One probe per registration that has one: the registration list is the only agent roster. */ +export function registeredModelCatalogProbes( + registrations: readonly Pick[], + context: StructuredAgentModelCatalogContext +): Record { + const probes: Record = {} + for (const registration of registrations) { + const discovery = registration.modelCatalog(context) + if (discovery.kind === 'probe') { + probes[registration.definition.agent] = discovery.probe + } + } + return probes +} + /** * 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 agents: Pick - deps: Pick< - StructuredAgentSessionRuntimeDeps, - | 'stateDirectory' - | 'resolveAgentAccountHome' - | 'resolveCodexCommand' - | 'resolveClaudeCommand' - | 'resolveClaudeLaunchEnv' - | 'resolveClaudeAuthPolicy' - > - envResolvers: { - resolveCodexEnvironment: NonNullable - resolveClaudeInheritedEnv: NonNullable< - ClaudeStructuredLaunchResolverDeps['resolveInheritedEnv'] - > - } + registrations: readonly Pick[] + deps: StructuredAgentModelCatalogContext['deps'] + environment: StructuredAgentModelCatalogContext['environment'] }): Promise<{ modelCatalog?: AgentModelCatalogService }> { await attachAgentModelCatalogPersistenceOnce(input.deps.stateDirectory) const { deps } = input @@ -74,18 +73,10 @@ export async function modelCatalogHostDeps(input: { drivesRecord: (record) => agentDrivesSession(input.agents, record), resolveAccountHome: deps.resolveAgentAccountHome, workspaceMayOverrideDefaultModel, - probes: { - codex: createCodexModelCatalogProbe({ - 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 } : {}) - }) - } + probes: registeredModelCatalogProbes(input.registrations, { + deps, + environment: input.environment + }) }) return { modelCatalog } } diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index b65eb8ea0f5d..3c0ba68b9ea2 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 @@ -213,6 +228,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) => @@ -271,6 +287,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( @@ -281,6 +298,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.ts b/src/main/runtime/structured-agent-session-runtime.ts index b5e24dbcdcd9..a9e834240806 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -320,7 +320,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 diff --git a/src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts b/src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts index a44c2fa68c1c..c79037fec754 100644 --- a/src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts @@ -8,7 +8,7 @@ vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}), subscribe: () = import { structuredAgentAcceptsImages } from '@/runtime/use-host-structured-agent' import { isStructuredTab } from './structured-agent-session-tabs' -import { structuredAgentSessionSeedCatalog } from './structured-agent-session-seed-catalog' +import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' const chatTab = (agentSessionAgent: unknown) => ({ id: 'agent-session:grok_1', @@ -47,7 +47,7 @@ describe('a host-registered agent on the structured chat surface', () => { expect(isStructuredTab(reloadedTab('not an agent!'))).toBe(false) }) - it("starts an unshipped agent's picker empty and fills it only from the session", () => { + it("starts an unshipped agent's picker from no built-in list, so the host catalog or session fills it", () => { expect(structuredAgentSessionSeedCatalog('grok').models).toEqual([]) expect(structuredAgentSessionSeedCatalog('claude')).toBe(getAgentSessionOptionCatalog('claude')) expect(structuredAgentSessionSeedCatalog('codex')).toBe(getAgentSessionOptionCatalog('codex')) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-seed-catalog.ts b/src/renderer/src/components/native-chat/structured-agent-session-seed-catalog.ts deleted file mode 100644 index 690a70a1388a..000000000000 --- a/src/renderer/src/components/native-chat/structured-agent-session-seed-catalog.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { - getAgentSessionOptionCatalog, - type AgentSessionOptionCatalog -} from '../../../../shared/agent-session-option-catalog' -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' -import type { AgentType } from '../../../../shared/agent-status-types' - -// Over `agentSession.*` only the live read and `setOption` apply a pick, so no launch apply is named. -const LIVE_ONLY_CATALOG: AgentSessionOptionCatalog = { models: [], modelApply: {} } - -/** - * What a structured chat's picker shows before the session reports its own options. Only the agents - * every build ships have a seed written against their structured option ids; another agent's - * terminal catalog names CLI flags and models its structured session never offered, so its picker - * starts empty and lists exactly what `agentSession.options` reports. - */ -export function structuredAgentSessionSeedCatalog(agent: AgentType): AgentSessionOptionCatalog { - return isAgentSessionHandleProvider(agent) - ? (getAgentSessionOptionCatalog(agent) ?? LIVE_ONLY_CATALOG) - : LIVE_ONLY_CATALOG -} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts index 5734f7e7fafc..5d55ecbf1933 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts @@ -4,7 +4,7 @@ import type { AgentSessionOptionsResult } from '../../../../shared/agent-session import type { AgentSessionRewindSupport } from '../../../../shared/agent-session-rewind' import type { AgentType } from '../../../../shared/agent-status-types' import type { AgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' -import { structuredAgentSessionSeedCatalog } from './structured-agent-session-seed-catalog' +import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' import { applyStructuredAgentSessionOptions, createStructuredAgentSessionOptionState, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index 09ae0e5852d7..5079ca950978 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -5,7 +5,7 @@ import type { AgentSessionOptionsResult } from '../../../../shared/agent-session-wire' import type { AgentType } from '../../../../shared/agent-status-types' -import { structuredAgentSessionSeedCatalog } from './structured-agent-session-seed-catalog' +import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' import { applyStructuredAgentSessionOptions, diff --git a/src/shared/structured-agent-session-first-frame-picker.test.ts b/src/shared/structured-agent-session-first-frame-picker.test.ts new file mode 100644 index 000000000000..4bd8f4b72d41 --- /dev/null +++ b/src/shared/structured-agent-session-first-frame-picker.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionModelCatalogResult } from './agent-session-wire' +import { structuredAgentSessionSeedCatalog } from './structured-agent-session-seed-catalog' +import { + applyStructuredAgentSessionModelCatalog, + applyStructuredAgentSessionOptions, + canSetStructuredAgentSessionOption, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from './structured-agent-session-options' + +// Every agent a runtime registers today; the main-side contract test walks the registrations. +const AGENTS = ['claude', 'codex', 'grok', 'opencode', 'pi'] as const + +const HOST_CATALOG: AgentSessionModelCatalogResult = { + origin: 'probe', + models: [ + { + id: 'model-listed', + label: 'Model Listed', + isDefault: true, + defaultEffort: 'high', + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + } + ], + fetchedAt: 1_000 +} + +function firstFrame(agent: string, host: AgentSessionModelCatalogResult | null) { + const seed = structuredAgentSessionSeedCatalog(agent) + const cold = createStructuredAgentSessionOptionState(agent, seed) + const state = host + ? applyStructuredAgentSessionModelCatalog(cold, seed, host, { namesDefault: false }) + : cold + return { seed, state, snapshot: structuredAgentSessionOptionSnapshot(state) } +} + +describe('the first frame of every agent’s picker', () => { + it.each(AGENTS)( + '%s: a cold or failed listing shows a model pill nobody can pick from', + (agent) => { + for (const host of [null, { origin: 'unknown' as const }]) { + const { seed, state, snapshot } = firstFrame(agent, host) + const model = snapshot.find((descriptor) => descriptor.id === 'model') + expect(model).toBeDefined() + if (seed.models.length === 0) { + // The provider-default placeholder: no choice, so showing it can never seed a launch. + expect(model).toMatchObject({ + valueSource: 'unknown', + settable: false, + kind: { choices: [] } + }) + expect(canSetStructuredAgentSessionOption(state, 'model', 'anything')).toBe(false) + } else { + // A built-in list is presentation only: no value is tracked from it. + expect(state.record.model).toBeUndefined() + } + } + } + ) + + it.each(AGENTS)( + '%s: a warm host catalog lists its models before the session reports', + (agent) => { + const { state, snapshot } = firstFrame(agent, HOST_CATALOG) + expect(state.catalogSource).toBe('host') + const model = snapshot.find((descriptor) => descriptor.id === 'model') + expect( + model?.kind.type === 'select' && model.kind.choices.map((choice) => choice.value) + ).toEqual(['model-listed']) + // Listed, not picked: nothing reads as a value the chat confirmed or will launch with. + expect(state.record.model).toBeUndefined() + } + ) + + it.each(AGENTS)('%s: the live session corrects the host list', (agent) => { + const { seed, state } = firstFrame(agent, HOST_CATALOG) + const live = applyStructuredAgentSessionOptions(state, seed, { + models: [{ id: 'model-live', label: 'Model Live', isDefault: false, efforts: [] }], + current: { model: 'model-live', confirmed: ['model'] } + }) + expect(live.catalogSource).toBe('live') + expect(live.catalog?.models.map((model) => model.id)).toEqual(['model-live']) + // A later host answer never downgrades what the session reported. + expect( + applyStructuredAgentSessionModelCatalog(live, seed, HOST_CATALOG, { namesDefault: false }) + ).toBe(live) + }) +}) diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index 1d1d559eaae7..99130769d500 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -194,13 +194,26 @@ export function applyStructuredAgentSessionOptions( } } +/** Before an agent with no list yet reports, it runs its provider's default: shown as the model + * pill, never pickable, so displaying it can never become a launch pick. */ +const PROVIDER_DEFAULT_MODEL_PLACEHOLDER: SessionOptionDescriptor = { + id: 'model', + label: 'Model', + category: 'model', + kind: { type: 'select', choices: [] }, + valueSource: 'unknown', + transport: 'agent-session', + settable: false, + disabledReason: 'available-after-session-start' +} + export function structuredAgentSessionOptionSnapshot( state: StructuredAgentSessionOptionState ): SessionOptionDescriptor[] { if (!state.catalog) { return [] } - return buildNativeChatSessionOptionSnapshot({ + const snapshot = buildNativeChatSessionOptionSnapshot({ catalog: state.catalog, // A seeded default can name a model the static seed does not list yet. models: withTrackedNativeChatModel(state.catalog, state.catalog.models, state.record), @@ -209,6 +222,9 @@ export function structuredAgentSessionOptionSnapshot( modelLabel: 'Model', liveTransport: 'agent-session' }) + return snapshot.length > 0 || state.catalogSource === 'live' + ? snapshot + : [PROVIDER_DEFAULT_MODEL_PLACEHOLDER] } /** No launch holds a pick and no fence can carry one yet, so the picker only shows. */ diff --git a/src/shared/structured-agent-session-seed-catalog.ts b/src/shared/structured-agent-session-seed-catalog.ts new file mode 100644 index 000000000000..78874f7de8e0 --- /dev/null +++ b/src/shared/structured-agent-session-seed-catalog.ts @@ -0,0 +1,21 @@ +import { + getAgentSessionOptionCatalog, + type AgentSessionOptionCatalog +} from './agent-session-option-catalog' +import { isAgentSessionHandleProvider } from './agent-session-provider-handle' + +// Over `agentSession.*` only the live read and `setOption` apply a pick, so no launch apply is named. +const LIVE_ONLY_CATALOG: AgentSessionOptionCatalog = { models: [], modelApply: {} } + +/** + * What a structured chat's picker shows, on desktop and phone alike, before the host catalog or the + * session answers: one fallback rule for every agent. The agents every build ships a structured + * seed for start from that built-in list (presentation only; it never makes a launch pick); any + * other agent's terminal catalog names CLI flags its structured session never offered, so it starts + * from the provider-default placeholder instead. + */ +export function structuredAgentSessionSeedCatalog(agent: string): AgentSessionOptionCatalog { + return isAgentSessionHandleProvider(agent) + ? (getAgentSessionOptionCatalog(agent) ?? LIVE_ONLY_CATALOG) + : LIVE_ONLY_CATALOG +} From ea9e6e9c3d1379b9ca5401f476218f0cc351f662 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:23:02 -0700 Subject: [PATCH 02/30] fix(native-chat): host names a listing's configured model; live listings saved for every agent - The catalog answer carries an optional listingNamesConfiguredModel field, set per registration (Codex, Grok) and per workspace; pickers name the listed default from it instead of a Codex-only client flag (older hosts fall back). - Grok, OpenCode and Pi live option reads save their model list through one host step, keeping the account's known default and per-model efforts. - Phone reads the host catalog like the desktop and seeds every agent. - Saved picks accept any known agent id (OpenCode, Pi). - ACP agents with no session-free listing register as unavailable; Grok's live effort menu falls back to the session's when the advertised one is empty. --- .../use-mobile-host-model-catalog-upgrade.ts | 93 +++++++ ...se-mobile-structured-agent-options.test.ts | 94 ++++++- .../use-mobile-structured-agent-options.ts | 29 ++- ...e-mobile-structured-agent-session.test.tsx | 11 +- src/main/acp/acp-launch-specs.ts | 20 +- src/main/acp/acp-model-catalog-probe.test.ts | 10 - src/main/acp/acp-model-catalog-probe.ts | 1 + .../acp-structured-host-live-listing.test.ts | 31 +++ .../acp/acp-structured-host.test-support.ts | 11 +- src/main/acp/acp-structured-options.test.ts | 21 ++ src/main/acp/acp-structured-options.ts | 4 +- .../agent-model-catalog-discovery.ts | 8 +- .../agent-model-catalog-service.ts | 73 +++++- .../agent-project-model-override.ts | 6 +- .../structured-agent-session-options-read.ts | 4 + src/main/pi/rpc-runtime-registration.ts | 2 + .../client-native-chat-settings.test.ts | 22 +- ...ured-agent-model-catalog-discovery.test.ts | 35 ++- ...tructured-agent-model-catalog-discovery.ts | 9 + ...ed-agent-model-catalog-first-frame.test.ts | 236 ++++++++++++++++++ .../structured-agent-model-catalog-wiring.ts | 34 ++- .../structured-agent-runtime-registrations.ts | 5 + .../use-host-model-catalog-upgrade.ts | 10 +- ...-structured-agent-session-options.test.tsx | 5 +- .../use-structured-agent-session-options.ts | 2 +- .../agent-session-option-catalog-types.ts | 4 +- src/shared/agent-session-wire.ts | 3 + .../rpc-contract/client-settings-params.ts | 7 +- ...d-agent-session-first-frame-picker.test.ts | 52 +++- ...-session-model-catalog-application.test.ts | 21 +- .../structured-agent-session-options.ts | 9 +- 31 files changed, 806 insertions(+), 66 deletions(-) create mode 100644 mobile/src/session/use-mobile-host-model-catalog-upgrade.ts create mode 100644 src/main/acp/acp-structured-host-live-listing.test.ts create mode 100644 src/main/runtime/structured-agent-model-catalog-first-frame.test.ts 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..d99cbc6c32f5 --- /dev/null +++ b/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts @@ -0,0 +1,93 @@ +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, + 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. An older host + * refuses the method, and the seed stands until the options read lands. + */ +export function useMobileHostModelCatalogUpgrade(args: { + agent: string | null + client: RpcClient | null + sessionId: string | null + enabled: boolean + fence: number | null + optionCatalog: AgentSessionOptionCatalog | null + activeOptionRecordRef: MutableRefObject + optionMutationGeneration: MutableRefObject + updateOptionState: ( + update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState + ) => void +}): void { + const { + activeOptionRecordRef, + agent, + client, + enabled, + fence, + optionCatalog, + optionMutationGeneration, + sessionId, + updateOptionState + } = args + useEffect(() => { + if (!client || !sessionId || !enabled || !agent || !optionCatalog) { + return + } + let stale = false + const readGeneration = optionMutationGeneration.current + const read = (waitForListing: boolean): Promise => + waitForListing + ? callAgentSession( + client, + 'agentSession.modelCatalog', + { agent, sessionId, waitForListing }, + LISTING_WAIT_TIMEOUT_MS + ) + : callAgentSession(client, 'agentSession.modelCatalog', { agent, sessionId }) + const apply = (catalog: AgentSessionModelCatalogResult): void => { + if (stale || optionMutationGeneration.current !== readGeneration) { + return + } + // The phone opens chats that already exist, which may run a model picked in them. + updateOptionState((current) => + current.record === activeOptionRecordRef.current + ? applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, { + newLaunch: false + }) + : current + ) + } + void read(false) + .then((catalog) => + catalog.origin === 'unknown' && catalog.listingInProgress === true && !stale + ? read(true).then(apply) + : apply(catalog) + ) + .catch(() => undefined) + return () => { + stale = true + } + }, [ + activeOptionRecordRef, + agent, + client, + enabled, + fence, + optionCatalog, + optionMutationGeneration, + sessionId, + updateOptionState + ]) +} 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 daace4b42ed1..d9d2f54c326b 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' @@ -69,11 +70,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: () => () => {}, @@ -420,3 +435,80 @@ describe('useMobileStructuredAgentOptions for every agent', () => { 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('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 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 e7b6a6b66991..2a1f279f6d40 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -26,6 +26,7 @@ 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 { encodeStructuredAgentSessionOptionValue } from '../../../src/shared/structured-agent-session-option-codec' type StructuredOptionsController = { @@ -47,8 +48,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,20 +76,27 @@ export function useMobileStructuredAgentOptions(args: { sessionId: string commands: readonly AgentSessionConversationCommand[] } | null>(null) - // Every agent reads the host's options, which at rest carry the host catalog for the chat's account. - const optionCatalog = useMemo( - () => (agent ? structuredAgentSessionSeedCatalog(agent) : null), - [agent] - ) useEffect(() => { - const next = createStructuredAgentSessionOptionState(agent ?? 'codex') + const next = createStructuredAgentSessionOptionState(agent ?? 'codex', optionCatalog) optionMutationGeneration.current += 1 pendingOptionRef.current = null optionStateRef.current = next activeOptionRecordRef.current = next.record setOptionState(next) - }, [agent, enabled, fence, sessionId]) + }, [agent, enabled, fence, optionCatalog, sessionId]) + + useMobileHostModelCatalogUpgrade({ + agent, + client, + sessionId, + enabled, + fence, + optionCatalog, + activeOptionRecordRef, + optionMutationGeneration, + updateOptionState + }) useEffect(() => { if (!client || !sessionId || !enabled || !optionCatalog) { 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 04b8e87304a2..7183b47b405c 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' @@ -24,6 +25,11 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStor import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal' +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' } } } @@ -414,7 +420,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?', @@ -708,7 +715,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-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index d4f6199be30a..18aec78ecc2f 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -34,8 +34,15 @@ export type AcpModelDiscovery = initialized: InitializeResponse, connection: { requestExtension(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: 'command'; args: readonly string[]; parse(stdout: string): AgentSessionModelOption[] } | { kind: 'unavailable'; reason: string } export type AcpLaunchSpec = { @@ -90,7 +97,12 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = { account: directoryAccountBinding('GROK_HOME', (homePath) => join(homePath, '.grok')), installDirectories: ({ env }) => (env.GROK_HOME ? [join(env.GROK_HOME, 'bin')] : []), // Grok computes its model state in `initialize`, before and without any session. - modelDiscovery: { kind: 'initialize', read: readGrokModelCatalog } + // With no session, Grok's `currentModelId` is the account's configured model. + modelDiscovery: { + kind: 'initialize', + read: readGrokModelCatalog, + listingNamesConfiguredModel: true + } } // OpenCode 1.x serves ACP in-process through `opencode acp`. OpenCode 2 (`opencode2`, and any @@ -117,7 +129,9 @@ const OPENCODE_LAUNCH_SPEC: AcpLaunchSpec = { modelDiscovery: { kind: 'command', args: OPENCODE_MODEL_LISTING_ARGS, - parse: parseOpenCodeModelListing + parse: parseOpenCodeModelListing, + // The listing marks no default, and a project's `opencode.json` may pick the model. + listingNamesConfiguredModel: false } } diff --git a/src/main/acp/acp-model-catalog-probe.test.ts b/src/main/acp/acp-model-catalog-probe.test.ts index 07862e9ff544..4d469f4e0569 100644 --- a/src/main/acp/acp-model-catalog-probe.test.ts +++ b/src/main/acp/acp-model-catalog-probe.test.ts @@ -187,14 +187,4 @@ describe('ACP model catalog probes', () => { ) expect(runListing).not.toHaveBeenCalled() }) - - it('reports an agent that declares no session-free listing', async () => { - const spec: AcpLaunchSpec = { - ...GROK, - modelDiscovery: { kind: 'unavailable', reason: 'no listing without a session' } - } - await expect( - createAcpModelCatalogProbe(spec, scriptedGrok({}).deps)(GROK_HOME) - ).rejects.toThrow('no listing without a session') - }) }) diff --git a/src/main/acp/acp-model-catalog-probe.ts b/src/main/acp/acp-model-catalog-probe.ts index 94b96395e820..2771fd9c425c 100644 --- a/src/main/acp/acp-model-catalog-probe.ts +++ b/src/main/acp/acp-model-catalog-probe.ts @@ -43,6 +43,7 @@ export function createAcpModelCatalogProbe( ): AgentModelCatalogProbe { return async (accountHome): Promise => { const discovery = spec.modelDiscovery + // Registered as unavailable instead (`acpModelCatalogDiscovery`); never built for one. if (discovery.kind === 'unavailable') { throw new Error(discovery.reason) } 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..e1f82ac4202a --- /dev/null +++ b/src/main/acp/acp-structured-host-live-listing.test.ts @@ -0,0 +1,31 @@ +// 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() + } + const { host } = await openAttachedHostRig({}, 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 cc6dc7244913..f9e847862aa5 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' export const hello: AgentJournalMessageItem = { kind: 'message', @@ -83,6 +84,7 @@ export async function openHostRig( script?: (agent: AcpScriptedAgent) => void initialize?: Record deps?: Partial + modelCatalog?: AgentModelCatalogService } = {} ) { const state = hostTestState() @@ -113,7 +115,8 @@ export async function openHostRig( recoveryCapsule: new AgentSessionRecoveryCapsule(state.root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', - now: () => HOST_TEST_NOW + now: () => HOST_TEST_NOW, + ...(options.modelCatalog ? { modelCatalog: options.modelCatalog } : {}) }) hosted.host = host replaceHostTestState({ store, host }) @@ -178,10 +181,14 @@ export const framesOf = (child: FakeAcpChild, method: string) => child.agent.frames.filter((frame) => frame.method === method) /** A new Grok chat the host attached; Grok loads its session on a later start, counting each. */ -export async function openAttachedHostRig(deps: Partial = {}) { +export async function openAttachedHostRig( + deps: Partial = {}, + modelCatalog?: AgentModelCatalogService +) { const count = { loads: 0 } let resumed = false const rig = await openHostRig({ + ...(modelCatalog ? { modelCatalog } : {}), initialize: RESUMES, script: (agent) => agent.on('session/load', (frame) => { diff --git a/src/main/acp/acp-structured-options.test.ts b/src/main/acp/acp-structured-options.test.ts index b4c8d0780ff0..b4c4a96fdb24 100644 --- a/src/main/acp/acp-structured-options.test.ts +++ b/src/main/acp/acp-structured-options.test.ts @@ -96,4 +96,25 @@ describe('ACP session options', () => { ]) 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() + }) }) diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts index f8a7c0fabdee..8889d49c4561 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -135,10 +135,12 @@ export class AcpStructuredOptions { 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, - ...(advertised ?? { efforts: model.id === currentModel ? sessionEfforts : [] }) + // 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'] : [])] diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts index 6b246857ec31..a2c2ff6b8e42 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts @@ -7,5 +7,11 @@ import type { AgentModelCatalogProbe } from './agent-model-catalog-store' * placeholder until a live session reports. */ export type AgentModelCatalogDiscovery = - | { kind: 'probe'; probe: AgentModelCatalogProbe } + | { + 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-service.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts index 538f3dbc92b2..21399397f42a 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts @@ -1,4 +1,8 @@ -import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session-wire' +import type { + AgentSessionModelCatalogResult, + AgentSessionModelOption, + AgentSessionOptionsResult +} from '../../../shared/agent-session-wire' import type { AgentSessionAccountHome, AgentSessionRecord @@ -26,6 +30,10 @@ export type AgentModelCatalogServiceDeps = { resolveAccountHome: (agent: string) => Promise /** Session-less listers, one per agent that has one on this host. */ probes?: Readonly>> + /** Agents whose listing marks the model the account is configured to run as its default. */ + listingNamesConfiguredModel?: ReadonlySet + /** Agents whose live sessions' listings the host records; the rest record their own. */ + recordsLiveListingsOf?: ReadonlySet /** Whether the workspace's own config could pick a model other than the listed default. */ workspaceMayOverrideDefaultModel?: (input: { agent: string @@ -43,11 +51,17 @@ export type AgentModelCatalogService = { /** With no entry yet, answer from the listing this read starts or joins instead of `unknown`. */ waitForListing?: boolean }) => Promise + /** Saves what a running session listed as its account's catalog, so the next chat starts warm. */ + recordLiveListing: ( + sessionId: string, + listing: Pick + ) => void } function resultFromEntry( entry: AgentModelCatalogEntry, - namesDefault: boolean + namesDefault: boolean, + listingNamesConfiguredModel: boolean ): AgentSessionModelCatalogResult { return { origin: entry.origin, @@ -56,10 +70,37 @@ function resultFromEntry( namesDefault ? { ...model } : { ...model, isDefault: false } ), ...(entry.fastModeSupport ? { fastModeSupport: entry.fastModeSupport } : {}), - fetchedAt: entry.fetchedAt + fetchedAt: entry.fetchedAt, + listingNamesConfiguredModel: namesDefault && listingNamesConfiguredModel } } +/** A session names no default and may know only its running model's efforts: those facts stay as + * the account's last listing had them, while the session decides which models exist. */ +function liveListingOverKnown( + models: readonly AgentSessionModelOption[], + known: AgentModelCatalogEntry | null +): AgentSessionModelOption[] { + const namesDefault = models.some((model) => model.isDefault) + return models.map((model) => { + const previous = known?.models.find((entry) => entry.id === model.id) + if (!previous) { + return { ...model } + } + const keepEfforts = model.efforts.length === 0 && previous.efforts.length > 0 + return { + ...model, + ...(namesDefault ? {} : { isDefault: previous.isDefault }), + ...(keepEfforts + ? { + efforts: previous.efforts, + ...(previous.defaultEffort ? { defaultEffort: previous.defaultEffort } : {}) + } + : {}) + } + }) +} + /** A named workspace keeps the listed default only when none of its own config can replace it. */ async function workspaceKeepsListedDefault( deps: AgentModelCatalogServiceDeps, @@ -149,8 +190,32 @@ export function createAgentModelCatalogService( } return resultFromEntry( entry, - await workspaceKeepsListedDefault(deps, params.agent, params.workspacePath, accountHomePath) + await workspaceKeepsListedDefault( + deps, + params.agent, + params.workspacePath, + accountHomePath + ), + deps.listingNamesConfiguredModel?.has(params.agent) === true ) + }, + recordLiveListing(sessionId, listing) { + const record = deps.getRecord(sessionId) + if ( + !record || + !deps.recordsLiveListingsOf?.has(record.provider) || + !deps.drivesRecord(record) + ) { + return + } + // The record's pinned account and host: the account this child listed under. + const fingerprint = agentModelCatalogFingerprintForRecord(record) + deps.store.recordSuccess(fingerprint, record.provider, { + models: liveListingOverKnown(listing.models, deps.store.get(fingerprint)), + ...(listing.fastModeSupport ? { fastModeSupport: listing.fastModeSupport } : {}), + fastModeTierByModel: new Map(), + origin: 'live-session' + }) } } } 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..2235e6e7c92f 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 @@ -57,7 +57,11 @@ export async function workspaceMayOverrideDefaultModel(input: { workspacePath: string accountHomePath: string }): Promise { - // Claude's user settings or env can pick another model wherever it runs; only Codex's listed default is its configured one. + // Grok reads its default model from user, managed and env config only, never a project's. + if (input.agent === 'grok') { + return false + } + // Every other agent's own settings or env may pick another model; Codex's project layers are checked. if (input.agent !== 'codex') { return true } 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 48fb03b44c13..be9ff2179ddd 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 @@ -142,6 +142,10 @@ export async function readStructuredAgentSessionOptions( }) ) : null + if (live) { + // What a running child listed is its account's catalog too, so the next chat opens warm. + context.deps.modelCatalog?.recordLiveListing(sessionId, live) + } const options = live ?? (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) diff --git a/src/main/pi/rpc-runtime-registration.ts b/src/main/pi/rpc-runtime-registration.ts index 1ba760747534..ac78e88301a5 100644 --- a/src/main/pi/rpc-runtime-registration.ts +++ b/src/main/pi/rpc-runtime-registration.ts @@ -63,6 +63,8 @@ export const PI_RPC_RUNTIME_REGISTRATION: StructuredAgentRuntimeRegistration = { 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 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/structured-agent-model-catalog-discovery.test.ts b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts index 0ce9ee4085fc..bac0db11f031 100644 --- a/src/main/runtime/structured-agent-model-catalog-discovery.test.ts +++ b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from './structured-agent-runtime-registrations' -import { registeredModelCatalogProbes } from './structured-agent-model-catalog-wiring' +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' function context(): StructuredAgentModelCatalogContext { @@ -34,7 +36,10 @@ describe('the model catalog contract on every registration', () => { }) it('gives the catalog service a probe for exactly the registrations that have one', () => { - const probes = registeredModelCatalogProbes(STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, context()) + 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) @@ -46,4 +51,30 @@ describe('the model catalog contract on every registration', () => { ).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` and Grok's session-free `currentModelId` name it; the rest may be overridden. + expect([...listingNamesConfiguredModel].sort()).toEqual(['codex', 'grok']) + }) + + it('records live listings at the host for every agent whose adapter does not', () => { + const { recordsLiveListingsOf } = registeredModelCatalogDiscovery( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + context() + ) + expect([...recordsLiveListingsOf].sort()).toEqual(['grok', 'opencode', 'pi']) + }) + + 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' }) + }) }) diff --git a/src/main/runtime/structured-agent-model-catalog-discovery.ts b/src/main/runtime/structured-agent-model-catalog-discovery.ts index fddab72531cf..5075dcf9f905 100644 --- a/src/main/runtime/structured-agent-model-catalog-discovery.ts +++ b/src/main/runtime/structured-agent-model-catalog-discovery.ts @@ -14,6 +14,8 @@ export function codexModelCatalogDiscovery({ }: StructuredAgentModelCatalogContext): AgentModelCatalogDiscovery { return { kind: 'probe', + // `model/list` marks the account's configured model as its default. + listingNamesConfiguredModel: true, probe: createCodexModelCatalogProbe({ resolveEnvironment: environment.resolveCodexEnvironment, ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) @@ -27,6 +29,8 @@ export function claudeModelCatalogDiscovery({ }: 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, @@ -40,8 +44,13 @@ 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 } : {}), 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..10cf6b227afe --- /dev/null +++ b/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts @@ -0,0 +1,236 @@ +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, + 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: `currentModelId` is the configured model. +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, + recordsLiveListingsOf: registered.recordsLiveListingsOf + }) +} + +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('names a warm Grok account’s configured model and shows its effort pill', async () => { + const catalog = service(new AgentModelCatalogStore(), { grok: GROK_LISTING }) + // A workspace path is checked too: Grok's default model has no project-level config. + const { host, state, model, effort } = await newChatFirstFrame(catalog, 'grok', '/repo') + expect(host).toMatchObject({ origin: 'probe', listingNamesConfiguredModel: true }) + expect(model?.currentValue).toBe('grok-4') + expect(effort?.choices.map((choice) => choice.value)).toEqual(['low', 'high']) + expect(effort?.currentValue).toBe('high') + // Shown as what the launch runs, never stored as a pick the next launch replays. + 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, options.read()) + + 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' + ]) + // The account's configured model is still the default, not the one the session picked. + expect(model?.currentValue).toBe('grok-4') + expect(effort?.currentValue).toBe('high') + }) + + 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' + }) + 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('leaves agents whose adapters save their own listings to them', 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: [] }] + }) + expect(await catalog.read({ agent: 'claude', sessionId: record.sessionId })).toEqual({ + origin: 'unknown' + }) + }) +}) diff --git a/src/main/runtime/structured-agent-model-catalog-wiring.ts b/src/main/runtime/structured-agent-model-catalog-wiring.ts index 2636ad3442e2..b4792810cb95 100644 --- a/src/main/runtime/structured-agent-model-catalog-wiring.ts +++ b/src/main/runtime/structured-agent-model-catalog-wiring.ts @@ -34,19 +34,37 @@ export async function attachAgentModelCatalogPersistenceOnce( } } -/** One probe per registration that has one: the registration list is the only agent roster. */ -export function registeredModelCatalogProbes( - registrations: readonly Pick[], +type CatalogRegistration = Pick< + StructuredAgentRuntimeRegistration, + 'definition' | 'modelCatalog' | 'adapterRecordsLiveListings' +> + +/** What the catalog service learns from the registration list, the only agent roster. */ +export function registeredModelCatalogDiscovery( + registrations: readonly CatalogRegistration[], context: StructuredAgentModelCatalogContext -): Record { +): { + probes: Record + listingNamesConfiguredModel: Set + recordsLiveListingsOf: Set +} { const probes: Record = {} + const listingNamesConfiguredModel = new Set() + const recordsLiveListingsOf = new Set() for (const registration of registrations) { + const { agent } = registration.definition const discovery = registration.modelCatalog(context) if (discovery.kind === 'probe') { - probes[registration.definition.agent] = discovery.probe + probes[agent] = discovery.probe + if (discovery.listingNamesConfiguredModel) { + listingNamesConfiguredModel.add(agent) + } + } + if (!registration.adapterRecordsLiveListings) { + recordsLiveListingsOf.add(agent) } } - return probes + return { probes, listingNamesConfiguredModel, recordsLiveListingsOf } } /** @@ -58,7 +76,7 @@ export function registeredModelCatalogProbes( export async function modelCatalogHostDeps(input: { store: Pick agents: Pick - registrations: readonly Pick[] + registrations: readonly CatalogRegistration[] deps: StructuredAgentModelCatalogContext['deps'] environment: StructuredAgentModelCatalogContext['environment'] }): Promise<{ modelCatalog?: AgentModelCatalogService }> { @@ -73,7 +91,7 @@ export async function modelCatalogHostDeps(input: { drivesRecord: (record) => agentDrivesSession(input.agents, record), resolveAccountHome: deps.resolveAgentAccountHome, workspaceMayOverrideDefaultModel, - probes: registeredModelCatalogProbes(input.registrations, { + ...registeredModelCatalogDiscovery(input.registrations, { deps, environment: input.environment }) diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 3c0ba68b9ea2..ad7d8e9b3598 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -109,6 +109,9 @@ export type StructuredAgentRuntimeRegistration = { /** 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 + /** The adapter writes its live sessions' listings to the catalog itself, with facts the options + * answer lacks (Fast tiers, launched model); otherwise the host records them from that answer. */ + adapterRecordsLiveListings?: true /** 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 @@ -288,6 +291,7 @@ export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRun definition: CODEX_STRUCTURED_AGENT, createAdapter: createCodexAdapter, modelCatalog: codexModelCatalogDiscovery, + adapterRecordsLiveListings: true, supportsLocation: (location) => supportsCodexStructuredLocation(location), resolveAccountHome: async (request, services) => agentSessionAccountHome( @@ -299,6 +303,7 @@ export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRun definition: CLAUDE_STRUCTURED_AGENT, createAdapter: createClaudeAdapter, modelCatalog: claudeModelCatalogDiscovery, + adapterRecordsLiveListings: true, supportsLocation: supportsClaudeStructuredLocation, resolveAccountHome: async ({ launchEnv, location }, services) => agentSessionAccountHome( diff --git a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts index 292b17729fde..6890f4392905 100644 --- a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts +++ b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts @@ -35,7 +35,7 @@ export function useHostModelCatalogUpgrade(args: { /** The pane is on screen: a read can start a listing process, so hidden restored tabs must not. */ enabled: boolean /** A launch runs the CLI default when nothing is seeded; a reopened session may not. */ - namesDefault: boolean + newLaunch: boolean /** Where the launch runs: the host names no default its config could replace. */ worktree?: string fence: number | null @@ -49,7 +49,7 @@ export function useHostModelCatalogUpgrade(args: { agent, enabled, fence, - namesDefault, + newLaunch, optionCatalog, sessionId, target, @@ -66,7 +66,7 @@ export function useHostModelCatalogUpgrade(args: { return } let stale = false - const params = { agent, sessionId, ...(namesDefault && worktree ? { worktree } : {}) } + const params = { agent, sessionId, ...(newLaunch && worktree ? { worktree } : {}) } const read = (waitForListing: boolean): Promise => callStructuredAgentSession( target, @@ -77,7 +77,7 @@ export function useHostModelCatalogUpgrade(args: { updateOptionState((current) => current.record === activeOptionRecordRef.current ? applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, { - namesDefault + newLaunch }) : current ) @@ -119,7 +119,7 @@ export function useHostModelCatalogUpgrade(args: { agent, enabled, fence, - namesDefault, + newLaunch, optionCatalog, sessionId, target, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx index 3dba8823ab9f..4148971c223b 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx @@ -344,10 +344,11 @@ describe('useStructuredAgentSessionOptions', () => { ).toBe(true) }) expect(currentValue(result.current.optionSnapshot, 'model')).toBeNull() - // Nothing to withhold, so the read names no workspace for the host to inspect. + // Every new chat names its workspace: the host decides whether its listed default runs there. expect(mocks.call).toHaveBeenCalledWith(LOCAL_TARGET, 'agentSession.modelCatalog', { agent: 'claude', - sessionId: 'session-1' + sessionId: 'session-1', + worktree: 'id:wt-1' }) rerender({ ...claude, launchSeedOptions: SEED }) expect(currentValue(result.current.optionSnapshot, 'model')).toBe('gpt-5.5') diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index 5079ca950978..18e8f6740dbb 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -97,7 +97,7 @@ export function useStructuredAgentSessionOptions(args: { optionCatalog, enabled: args.isVisible, // A resumed conversation may keep its own model, so only a new one runs the listed default. - namesDefault: launch?.kind === 'new' && optionCatalog?.hostListingNamesConfiguredModel === true, + newLaunch: launch?.kind === 'new', ...(launch?.worktree ? { worktree: launch.worktree } : {}), fence, activeOptionRecordRef, diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts index 7c9f5d1d87dd..3ade08e6427e 100644 --- a/src/shared/agent-session-option-catalog-types.ts +++ b/src/shared/agent-session-option-catalog-types.ts @@ -76,8 +76,8 @@ export type AgentSessionOptionCatalog = { * model while the picker, which never reads launch args, still names the CLI default. * A real fix means threading `modelApply.removeAgentArgs` through to the surface. */ defaultModelIsCliDefault?: true - /** The host's model listing names the model the account is configured to run, so a new chat - * may run the listed default. Off where the agent's own settings or env may pick another. */ + /** For a host too old to say so itself: its model listing names the model the account is + * configured to run, so a new chat may run the listed default. */ hostListingNamesConfiguredModel?: true listModels?: { command: string diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index a3235cd12928..574de04c69a0 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -418,6 +418,9 @@ export type AgentSessionModelCatalogResult = models: AgentSessionModelOption[] fastModeSupport?: AgentSessionFastModeSupport fetchedAt: number + /** The listed default is the model a new chat here launches with: the agent's listing names + * its configured model and no workspace config can replace it. Absent from an older host. */ + listingNamesConfiguredModel?: boolean } /** One entry of the `/` menu the running provider reports for itself. `skill` diff --git a/src/shared/rpc-contract/client-settings-params.ts b/src/shared/rpc-contract/client-settings-params.ts index 6574c89a8bb7..4f70beb99739 100644 --- a/src/shared/rpc-contract/client-settings-params.ts +++ b/src/shared/rpc-contract/client-settings-params.ts @@ -41,18 +41,21 @@ export const NativeChatSessionOptionPick = z.union([ .strict() ]) +// Any agent Orca launches: picks are keyed by agent id, and every native chat agent remembers them. +const NativeChatSessionOptionAgent = z.string().refine(isTuiAgent, { message: 'Unknown agent' }) + export const NativeChatSessionOptionsMutation = z.discriminatedUnion('type', [ z .object({ type: z.literal('apply-picks'), - agent: z.enum(['claude', 'codex', 'gemini', 'cursor', 'grok']), + agent: NativeChatSessionOptionAgent, picks: z.array(NativeChatSessionOptionPick).min(1).max(8) }) .strict(), z .object({ type: z.literal('clear-model-if-missing'), - agent: z.enum(['claude', 'codex', 'gemini', 'cursor', 'grok']), + agent: NativeChatSessionOptionAgent, availableModelIds: z.array(z.string().trim().min(1).max(512)).min(1).max(256) }) .strict() diff --git a/src/shared/structured-agent-session-first-frame-picker.test.ts b/src/shared/structured-agent-session-first-frame-picker.test.ts index 4bd8f4b72d41..feda0e82c383 100644 --- a/src/shared/structured-agent-session-first-frame-picker.test.ts +++ b/src/shared/structured-agent-session-first-frame-picker.test.ts @@ -29,15 +29,25 @@ const HOST_CATALOG: AgentSessionModelCatalogResult = { fetchedAt: 1_000 } -function firstFrame(agent: string, host: AgentSessionModelCatalogResult | null) { +function firstFrame( + agent: string, + host: AgentSessionModelCatalogResult | null, + launch = { newLaunch: true } +) { const seed = structuredAgentSessionSeedCatalog(agent) const cold = createStructuredAgentSessionOptionState(agent, seed) - const state = host - ? applyStructuredAgentSessionModelCatalog(cold, seed, host, { namesDefault: false }) - : cold + const state = host ? applyStructuredAgentSessionModelCatalog(cold, seed, host, launch) : cold return { seed, state, snapshot: structuredAgentSessionOptionSnapshot(state) } } +function currentValue( + snapshot: ReturnType, + id: string +): string | undefined { + const descriptor = snapshot.find((entry) => entry.id === id) + return descriptor?.kind.type === 'select' ? descriptor.kind.currentValue : undefined +} + describe('the first frame of every agent’s picker', () => { it.each(AGENTS)( '%s: a cold or failed listing shows a model pill nobody can pick from', @@ -65,7 +75,10 @@ describe('the first frame of every agent’s picker', () => { it.each(AGENTS)( '%s: a warm host catalog lists its models before the session reports', (agent) => { - const { state, snapshot } = firstFrame(agent, HOST_CATALOG) + const { state, snapshot } = firstFrame(agent, { + ...HOST_CATALOG, + listingNamesConfiguredModel: false + }) expect(state.catalogSource).toBe('host') const model = snapshot.find((descriptor) => descriptor.id === 'model') expect( @@ -76,6 +89,33 @@ describe('the first frame of every agent’s picker', () => { } ) + it.each(AGENTS)( + '%s: a new chat names the listed model and its effort when the host says it runs them', + (agent) => { + const { state, snapshot } = firstFrame(agent, { + ...HOST_CATALOG, + listingNamesConfiguredModel: true + }) + expect(currentValue(snapshot, 'model')).toBe('model-listed') + const effort = snapshot.find((descriptor) => descriptor.id === 'effort') + expect(effort?.kind.type === 'select' && effort.kind.choices.map((c) => c.value)).toEqual([ + 'low', + 'high' + ]) + expect(currentValue(snapshot, 'effort')).toBe('high') + // Shown as the default it runs, never as a pick the next launch would replay. + expect(snapshot.find((descriptor) => descriptor.id === 'model')?.valueSource).toBe('default') + expect(state.record.model).toBeUndefined() + // A reopened chat may run a model picked in it, so it names none. + const reopened = firstFrame( + agent, + { ...HOST_CATALOG, listingNamesConfiguredModel: true }, + { newLaunch: false } + ) + expect(currentValue(reopened.snapshot, 'model')).toBeUndefined() + } + ) + it.each(AGENTS)('%s: the live session corrects the host list', (agent) => { const { seed, state } = firstFrame(agent, HOST_CATALOG) const live = applyStructuredAgentSessionOptions(state, seed, { @@ -86,7 +126,7 @@ describe('the first frame of every agent’s picker', () => { expect(live.catalog?.models.map((model) => model.id)).toEqual(['model-live']) // A later host answer never downgrades what the session reported. expect( - applyStructuredAgentSessionModelCatalog(live, seed, HOST_CATALOG, { namesDefault: false }) + applyStructuredAgentSessionModelCatalog(live, seed, HOST_CATALOG, { newLaunch: true }) ).toBe(live) }) }) diff --git a/src/shared/structured-agent-session-model-catalog-application.test.ts b/src/shared/structured-agent-session-model-catalog-application.test.ts index 7b8ac23d0e24..c88735092ece 100644 --- a/src/shared/structured-agent-session-model-catalog-application.test.ts +++ b/src/shared/structured-agent-session-model-catalog-application.test.ts @@ -27,7 +27,8 @@ const HOST_CATALOG: AgentSessionModelCatalogResult = { fetchedAt: 1_000 } -const LAUNCH = { namesDefault: true } +// HOST_CATALOG omits `listingNamesConfiguredModel`, as an older host's does. +const LAUNCH = { newLaunch: true } describe('structured option state from the host model catalog', () => { it('renders a pickable snapshot from the seed before any host or live answer', () => { @@ -61,7 +62,7 @@ describe('structured option state from the host model catalog', () => { createStructuredAgentSessionOptionState('codex', SEED), SEED, HOST_CATALOG, - { namesDefault: false } + { newLaunch: false } ) const snapshot = structuredAgentSessionOptionSnapshot(state) const model = snapshot.find((descriptor) => descriptor.id === 'model')! @@ -73,6 +74,22 @@ describe('structured option state from the host model catalog', () => { expect(effort?.kind.type === 'select' ? effort.kind.currentValue : undefined).toBeUndefined() }) + it('follows the host on whether its listed default is what a new chat runs', () => { + const currentModel = (listingNamesConfiguredModel: boolean): unknown => { + const state = applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState('codex', SEED), + SEED, + { ...HOST_CATALOG, listingNamesConfiguredModel }, + LAUNCH + ) + const model = structuredAgentSessionOptionSnapshot(state).find((d) => d.id === 'model')! + return model.kind.type === 'select' ? model.kind.currentValue : null + } + expect(currentModel(true)).toBe('gpt-hosted') + // A workspace whose own config may pick another model: the host says so, over the seed. + expect(currentModel(false)).toBeUndefined() + }) + it('names the default effort the listing states, and none it does not', () => { const effortOf = (catalog: AgentSessionModelCatalogResult) => { const state = applyStructuredAgentSessionModelCatalog( diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index 99130769d500..6a10bc346d2f 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -146,7 +146,7 @@ export function applyStructuredAgentSessionModelCatalog( state: StructuredAgentSessionOptionState, seed: AgentSessionOptionCatalog, catalog: AgentSessionModelCatalogResult, - options: { namesDefault: boolean } + options: { newLaunch: boolean } ): StructuredAgentSessionOptionState { if (state.catalogSource === 'live' || catalog.origin === 'unknown') { return state @@ -157,6 +157,11 @@ export function applyStructuredAgentSessionModelCatalog( if (models.length === 0) { return state } + // The host says whether its listed default is what this launch runs; an older host never says, + // so the client's own knowledge of the agent stands in. A reopened chat may keep its own model. + const namesDefault = + options.newLaunch && + (catalog.listingNamesConfiguredModel ?? seed.hostListingNamesConfiguredModel === true) return { ...state, // `isDefault` came from a real listing, so a launch's CLI default is nameable — @@ -164,7 +169,7 @@ export function applyStructuredAgentSessionModelCatalog( catalog: { ...seed, models, - ...(options.namesDefault ? { defaultModelIsCliDefault: true } : {}) + ...(namesDefault ? { defaultModelIsCliDefault: true } : {}) }, catalogSource: 'host' } From 4f8b642d84a92ef821549fa2a7ea9110d13681cb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:14:17 -0700 Subject: [PATCH 03/30] fix(native-chat): discovery and live listings kept apart; every agent's live listing saved by the host; phone-created chats name their model --- .../mobile-created-structured-sessions.ts | 24 +++ .../mobile-existing-agent-launch.test.ts | 5 + .../session/mobile-existing-agent-launch.ts | 4 + ...le-structured-agent-session-launch.test.ts | 5 + .../mobile-structured-agent-session-launch.ts | 2 + .../use-mobile-host-model-catalog-upgrade.ts | 22 ++- ...se-mobile-structured-agent-options.test.ts | 31 ++++ .../use-mobile-structured-agent-options.ts | 8 + .../acp/acp-structured-session-adapter.ts | 3 +- .../claude-structured-acquire-catalog.ts | 11 -- .../claude-structured-catalog-listing.ts | 86 +++++++++++ ...-structured-effort-default-at-rest.test.ts | 68 +++++--- .../claude-structured-retired-model.test.ts | 128 +++++++--------- .../claude/claude-structured-retired-model.ts | 18 --- .../claude-structured-session-acquisition.ts | 5 - ...e-structured-session-adapter-turns.test.ts | 8 +- .../claude-structured-session-options.ts | 94 ++---------- .../claude/claude-structured-session-state.ts | 8 - .../codex-structured-background-catalog.ts | 16 +- .../codex/codex-structured-fast-mode.test.ts | 69 ++++++--- ...structured-session-options-catalog.test.ts | 27 ++-- ...-model-catalog-discovery-freshness.test.ts | 145 ++++++++++++++++++ .../agent-model-catalog-entry.ts | 85 ++++++++++ .../agent-model-catalog-persistence.ts | 61 ++++++-- .../agent-model-catalog-service.test.ts | 36 ++++- .../agent-model-catalog-service.ts | 59 ++----- .../agent-model-catalog-store.test.ts | 74 ++++++--- .../agent-model-catalog-store.ts | 125 +++++++++------ .../structured-agent-session-adapter.ts | 13 +- .../structured-agent-session-options-read.ts | 15 +- src/main/pi/rpc-session-adapter.ts | 7 +- ...ured-agent-model-catalog-discovery.test.ts | 8 - ...ed-agent-model-catalog-first-frame.test.ts | 31 ++-- .../structured-agent-model-catalog-wiring.ts | 12 +- .../structured-agent-runtime-registrations.ts | 8 +- .../structured-claude-runtime-adapter.ts | 4 +- 36 files changed, 863 insertions(+), 462 deletions(-) create mode 100644 mobile/src/session/mobile-created-structured-sessions.ts delete mode 100644 src/main/claude/claude-structured-acquire-catalog.ts create mode 100644 src/main/claude/claude-structured-catalog-listing.ts create mode 100644 src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery-freshness.test.ts create mode 100644 src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.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..bb579df4cc70 --- /dev/null +++ b/mobile/src/session/mobile-created-structured-sessions.ts @@ -0,0 +1,24 @@ +// Structured chats this phone created this run, 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) +} 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 2deb3042ca21..c913cbfbc1d3 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[] @@ -95,6 +96,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 index d99cbc6c32f5..358e85ed40f7 100644 --- a/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts +++ b/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts @@ -22,6 +22,10 @@ export function useMobileHostModelCatalogUpgrade(args: { 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 @@ -36,10 +40,12 @@ export function useMobileHostModelCatalogUpgrade(args: { client, enabled, fence, + newLaunch, optionCatalog, optionMutationGeneration, sessionId, - updateOptionState + updateOptionState, + worktree } = args useEffect(() => { if (!client || !sessionId || !enabled || !agent || !optionCatalog) { @@ -47,25 +53,23 @@ export function useMobileHostModelCatalogUpgrade(args: { } let stale = false const readGeneration = optionMutationGeneration.current + const params = { agent, sessionId, ...(newLaunch && worktree ? { worktree } : {}) } const read = (waitForListing: boolean): Promise => waitForListing ? callAgentSession( client, 'agentSession.modelCatalog', - { agent, sessionId, waitForListing }, + { ...params, waitForListing }, LISTING_WAIT_TIMEOUT_MS ) - : callAgentSession(client, 'agentSession.modelCatalog', { agent, sessionId }) + : callAgentSession(client, 'agentSession.modelCatalog', params) const apply = (catalog: AgentSessionModelCatalogResult): void => { if (stale || optionMutationGeneration.current !== readGeneration) { return } - // The phone opens chats that already exist, which may run a model picked in them. updateOptionState((current) => current.record === activeOptionRecordRef.current - ? applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, { - newLaunch: false - }) + ? applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, { newLaunch }) : current ) } @@ -85,9 +89,11 @@ export function useMobileHostModelCatalogUpgrade(args: { client, enabled, fence, + newLaunch, optionCatalog, optionMutationGeneration, sessionId, - updateOptionState + updateOptionState, + worktree ]) } 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 d9d2f54c326b..5a9b4a306da4 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.test.ts @@ -13,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: [ @@ -481,6 +482,36 @@ describe('useMobileStructuredAgentOptions host catalog', () => { 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('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 diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 2a1f279f6d40..f4e1c66579a8 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -27,6 +27,7 @@ import { } 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 { mobileCreatedStructuredSession } from './mobile-created-structured-sessions' import { encodeStructuredAgentSessionOptionValue } from '../../../src/shared/structured-agent-session-option-codec' type StructuredOptionsController = { @@ -86,12 +87,19 @@ export function useMobileStructuredAgentOptions(args: { setOptionState(next) }, [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, optionMutationGeneration, diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 664580848706..422765baa0f8 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' export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapter { /** Live children, and ones whose exit is not yet proven; a proven exit removes its entry. */ @@ -221,7 +222,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte } readOptions = async (input: { sessionId: string; fence: number }) => - this.live(input.sessionId).options.read() + withLiveCatalogListing(this.live(input.sessionId).options.read()) readOptionRestoreFailures = (sessionId: string): readonly string[] => this.sessions.get(sessionId)?.restoreSkipped ?? [] 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..285cccd1e378 --- /dev/null +++ b/src/main/claude/claude-structured-catalog-listing.ts @@ -0,0 +1,86 @@ +// 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 { AgentModelCatalogLiveListing } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +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 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 + }) +} + +/** 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) + return { + models: catalogClaudeModels(session, discovered), + ...(support ? { fastModeSupport: support } : {}), + ...(launchOnly ? { launchOnlyModelId: launched } : {}) + } +} 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 dcdad5b868bf..1c1b42786a95 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 @@ -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,11 @@ 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) + } return adapter } @@ -140,14 +144,18 @@ 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 }) }) +} + +/** 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 +270,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 +353,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 0e10d929fa05..e3bd3024a16c 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, createClaudeJournalFailureHandler @@ -258,10 +257,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-state.ts b/src/main/claude/claude-structured-session-state.ts index e327e2316219..d0bca2ec210f 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -19,10 +19,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' @@ -131,8 +127,6 @@ export type ClaudeStructuredSessionAdapterDeps = { leafUuid: string fence: number }) => Promise - /** Host model catalog; sessions write their listings through. */ - modelCatalog?: AgentModelCatalogStore } export type ClaudeDispatchWaiter = { @@ -189,8 +183,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/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 71caf2963760..53e6a4015819 100644 --- a/src/main/codex/codex-structured-session-options-catalog.test.ts +++ b/src/main/codex/codex-structured-session-options-catalog.test.ts @@ -70,17 +70,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', () => { 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-entry.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts new file mode 100644 index 000000000000..c3f743c5961d --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts @@ -0,0 +1,85 @@ +import type { + AgentSessionFastModeSupport, + AgentSessionModelOption +} from '../../../shared/agent-session-wire' + +/** 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 +} + +export type AgentModelCatalogEntry = { + agent: string + fingerprint: string + discovered: AgentModelCatalogListing | null + live: AgentModelCatalogListing | 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 configured default and + * default efforts are discovery's, else what a live child reported while its model offers it. */ +function mergedModels( + discovered: AgentModelCatalogListing | null, + live: AgentModelCatalogListing | 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 defaultEffort = [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: discovered ? listed?.isDefault === true : model.isDefault, + efforts, + ...(defaultEffort ? { defaultEffort } : {}) + } + }) +} + +export function agentModelCatalogEntry( + agent: string, + fingerprint: string, + discovered: AgentModelCatalogListing | null, + live: AgentModelCatalogListing | 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, + models: mergedModels(discovered, live), + ...(fastModeSupport ? { fastModeSupport } : {}), + fastModeTierByModel: { + ...live?.fastModeTierByModel, + ...discovered?.fastModeTierByModel + }, + origin: newer.origin, + fetchedAt: newer.at + } +} 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..a2ecd6507718 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,32 @@ 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 + } + return agentModelCatalogEntry( + row.agent, + row.fingerprint, + parseListing(row.discovered), + parseListing(row.live) + ) +} + +/** Only the two listings 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 } } @@ -126,7 +154,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-service.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts index cbd065864cc9..3ebe3de70ef9 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 @@ -99,7 +99,7 @@ describe('agent model catalog service', () => { 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, @@ -125,8 +125,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, @@ -143,12 +153,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, @@ -426,7 +438,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({ @@ -445,7 +462,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, 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 21399397f42a..cdbd487a59c6 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts @@ -1,8 +1,4 @@ -import type { - AgentSessionModelCatalogResult, - AgentSessionModelOption, - AgentSessionOptionsResult -} from '../../../shared/agent-session-wire' +import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session-wire' import type { AgentSessionAccountHome, AgentSessionRecord @@ -14,6 +10,7 @@ import { } from './agent-model-catalog-fingerprint' import type { AgentModelCatalogEntry, + AgentModelCatalogLiveListing, AgentModelCatalogProbe, AgentModelCatalogStore } from './agent-model-catalog-store' @@ -32,8 +29,6 @@ export type AgentModelCatalogServiceDeps = { probes?: Readonly>> /** Agents whose listing marks the model the account is configured to run as its default. */ listingNamesConfiguredModel?: ReadonlySet - /** Agents whose live sessions' listings the host records; the rest record their own. */ - recordsLiveListingsOf?: ReadonlySet /** Whether the workspace's own config could pick a model other than the listed default. */ workspaceMayOverrideDefaultModel?: (input: { agent: string @@ -52,10 +47,7 @@ export type AgentModelCatalogService = { waitForListing?: boolean }) => Promise /** Saves what a running session listed as its account's catalog, so the next chat starts warm. */ - recordLiveListing: ( - sessionId: string, - listing: Pick - ) => void + recordLiveListing: (sessionId: string, listing: AgentModelCatalogLiveListing) => void } function resultFromEntry( @@ -75,32 +67,6 @@ function resultFromEntry( } } -/** A session names no default and may know only its running model's efforts: those facts stay as - * the account's last listing had them, while the session decides which models exist. */ -function liveListingOverKnown( - models: readonly AgentSessionModelOption[], - known: AgentModelCatalogEntry | null -): AgentSessionModelOption[] { - const namesDefault = models.some((model) => model.isDefault) - return models.map((model) => { - const previous = known?.models.find((entry) => entry.id === model.id) - if (!previous) { - return { ...model } - } - const keepEfforts = model.efforts.length === 0 && previous.efforts.length > 0 - return { - ...model, - ...(namesDefault ? {} : { isDefault: previous.isDefault }), - ...(keepEfforts - ? { - efforts: previous.efforts, - ...(previous.defaultEffort ? { defaultEffort: previous.defaultEffort } : {}) - } - : {}) - } - }) -} - /** A named workspace keeps the listed default only when none of its own config can replace it. */ async function workspaceKeepsListedDefault( deps: AgentModelCatalogServiceDeps, @@ -201,21 +167,16 @@ export function createAgentModelCatalogService( }, recordLiveListing(sessionId, listing) { const record = deps.getRecord(sessionId) - if ( - !record || - !deps.recordsLiveListingsOf?.has(record.provider) || - !deps.drivesRecord(record) - ) { + if (!record || !deps.drivesRecord(record)) { return } // The record's pinned account and host: the account this child listed under. - const fingerprint = agentModelCatalogFingerprintForRecord(record) - deps.store.recordSuccess(fingerprint, record.provider, { - models: liveListingOverKnown(listing.models, deps.store.get(fingerprint)), - ...(listing.fastModeSupport ? { fastModeSupport: listing.fastModeSupport } : {}), - fastModeTierByModel: new Map(), - origin: 'live-session' - }) + deps.store.recordSuccess( + agentModelCatalogFingerprintForRecord(record), + record.provider, + { ...listing, fastModeTierByModel: new Map(), origin: 'live-session' }, + 'live' + ) } } } 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 26ccb525f029..26b07def7233 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) @@ -221,12 +226,32 @@ describe('agent model catalog store', () => { const pending = store.refresh('fp-1', 'codex', probe, () => probe({ variable: 'CODEX_HOME', path: '/homes/a' }) ) - store.recordSuccess('fp-1', 'codex', success('gpt-live')) + 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 @@ -238,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() @@ -278,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() }) @@ -308,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) }) @@ -338,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() @@ -348,12 +373,17 @@ 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') }) @@ -361,7 +391,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') store.recordFailure('fp-2', 'timed out') await vi.waitFor( async () => { @@ -384,7 +414,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 aafe6a8e2512..3ebf17dcea4c 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 @@ -4,10 +4,21 @@ import type { } from '../../../shared/agent-session-wire' import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' import type { AgentModelCatalogPersistence } from './agent-model-catalog-persistence' +import { + agentModelCatalogEntry, + type AgentModelCatalogEntry, + type AgentModelCatalogListing +} from './agent-model-catalog-entry' + +export type { AgentModelCatalogEntry, AgentModelCatalogListing } from './agent-model-catalog-entry' // 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. @@ -17,22 +28,37 @@ export const AGENT_MODEL_CATALOG_FAILURE_TTL_MS = 30_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 -} +/** `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' 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 +} + +/** What a running session listed, as its adapter hands it to the host with an options read. */ +export type AgentModelCatalogLiveListing = Pick< + AgentModelCatalogSuccess, + 'models' | 'fastModeSupport' | 'launchOnlyModelId' +> + +/** A live options answer whose model rows are the account's listing as the child reported it. */ +export function withLiveCatalogListing< + T extends Pick +>(options: T): T & { catalogListing: AgentModelCatalogLiveListing } { + return { + ...options, + catalogListing: { + models: options.models, + ...(options.fastModeSupport ? { fastModeSupport: options.fastModeSupport } : {}) + } + } } /** Lists an agent's models without a session, under the account a launch would pin. */ @@ -60,29 +86,15 @@ 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 - ]) + 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)]) } export class AgentModelCatalogStore { @@ -125,8 +137,9 @@ export class AgentModelCatalogStore { return entry } + /** 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 !entry.discovered || this.now() - entry.discovered.at >= AGENT_MODEL_CATALOG_FRESH_MS } failureDetail(fingerprint: string): string | null { @@ -147,12 +160,14 @@ export class AgentModelCatalogStore { return true } + /** 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) + const entry = this.writeSuccess(fingerprint, agent, success, source, ++this.nextListingOrder) this.notifyListingWaiters(fingerprint) return entry } @@ -160,41 +175,51 @@ export class AgentModelCatalogStore { private entryFromSuccess( fingerprint: string, agent: string, - success: AgentModelCatalogSuccess + success: AgentModelCatalogSuccess, + source: AgentModelCatalogSource ): AgentModelCatalogEntry | null { - if (success.models.length === 0) { + const previous = this.entries.get(fingerprint) + 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 previous = this.entries.get(fingerprint) - return { - agent, - fingerprint, - models: withKnownDefaultEfforts(success.models, previous), + const listing: AgentModelCatalogListing = { + models: models.map((model) => ({ ...model })), ...(success.fastModeSupport ? { fastModeSupport: success.fastModeSupport } : {}), fastModeTierByModel: tierRecord(success.fastModeTierByModel), origin: success.origin, - fetchedAt: this.now() + at: this.now() } + return source === 'discovery' + ? agentModelCatalogEntry(agent, fingerprint, listing, previous?.live ?? null) + : agentModelCatalogEntry(agent, fingerprint, previous?.discovered ?? null, listing) } 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.delete(fingerprint) + if (source === 'discovery') { + this.failures.delete(fingerprint) + } this.evictOverCap() // Live sessions re-list every turn; an unchanged listing only refreshes the in-memory age. if (!previous || listingKey(previous) !== listingKey(entry)) { @@ -207,7 +232,7 @@ export class AgentModelCatalogStore { this.failures.set(fingerprint, { detail, failedAt: this.now() }) } - /** 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( @@ -232,11 +257,11 @@ export class AgentModelCatalogStore { const order = ++this.nextListingOrder const run = listModels().then( (success) => { - // An older session still receives its own result, but cannot replace a newer catalog. + // An older lister still receives its own result, but cannot replace a newer discovery. const entry = (this.latestWrittenOrder.get(fingerprint) ?? 0) > order && this.entries.has(fingerprint) - ? 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 }, 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 cb2dc19465a5..1b926d6c0808 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 @@ -48,6 +48,14 @@ import type { AgentSessionPromptResponse } from '../../../shared/agent-session-q import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' +import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-store' + +/** 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' @@ -399,7 +407,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 be9ff2179ddd..cd4f54374a4f 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' @@ -131,7 +132,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' @@ -142,11 +143,17 @@ export async function readStructuredAgentSessionOptions( }) ) : null + let options: Omit if (live) { - // What a running child listed is its account's catalog too, so the next chat opens warm. - context.deps.modelCatalog?.recordLiveListing(sessionId, 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) } - const options = live ?? (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/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index c746d15b2ebd..6b7488f3a1e9 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -19,6 +19,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' @@ -224,8 +225,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/runtime/structured-agent-model-catalog-discovery.test.ts b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts index bac0db11f031..044b0237e277 100644 --- a/src/main/runtime/structured-agent-model-catalog-discovery.test.ts +++ b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts @@ -61,14 +61,6 @@ describe('the model catalog contract on every registration', () => { expect([...listingNamesConfiguredModel].sort()).toEqual(['codex', 'grok']) }) - it('records live listings at the host for every agent whose adapter does not', () => { - const { recordsLiveListingsOf } = registeredModelCatalogDiscovery( - STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, - context() - ) - expect([...recordsLiveListingsOf].sort()).toEqual(['grok', 'opencode', 'pi']) - }) - it('maps an ACP agent with no session-free listing to an unavailable registration', () => { const spec = acpLaunchSpecFor('grok')! const discovery = acpModelCatalogDiscovery( diff --git a/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts b/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts index 10cf6b227afe..96d3835a7d9e 100644 --- a/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts +++ b/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts @@ -16,6 +16,7 @@ import { createAgentModelCatalogService } from '../native-chat/agent-model-catal 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' @@ -98,8 +99,7 @@ function service( resolveAccountHome: async (agent) => HOMES[agent]!, probes, workspaceMayOverrideDefaultModel, - listingNamesConfiguredModel: registered.listingNamesConfiguredModel, - recordsLiveListingsOf: registered.recordsLiveListingsOf + listingNamesConfiguredModel: registered.listingNamesConfiguredModel }) } @@ -177,7 +177,10 @@ describe('a running chat’s listing, saved for the next chat', () => { ] } }) - catalog.recordLiveListing(record.sessionId, options.read()) + catalog.recordLiveListing( + record.sessionId, + withLiveCatalogListing(options.read()).catalogListing + ) const { host, model, effort } = await newChatFirstFrame(catalog, 'grok') expect(host.origin).toBe('live-session') @@ -204,11 +207,16 @@ describe('a running chat’s listing, saved for the next chat', () => { { 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' - }) + 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' }] }, @@ -222,15 +230,14 @@ describe('a running chat’s listing, saved for the next chat', () => { ]) }) - it('leaves agents whose adapters save their own listings to them', async () => { + 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: [] }] }) - expect(await catalog.read({ agent: 'claude', sessionId: record.sessionId })).toEqual({ - origin: 'unknown' - }) + 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 b4792810cb95..b6ebb3f3a33a 100644 --- a/src/main/runtime/structured-agent-model-catalog-wiring.ts +++ b/src/main/runtime/structured-agent-model-catalog-wiring.ts @@ -34,10 +34,7 @@ export async function attachAgentModelCatalogPersistenceOnce( } } -type CatalogRegistration = Pick< - StructuredAgentRuntimeRegistration, - 'definition' | 'modelCatalog' | 'adapterRecordsLiveListings' -> +type CatalogRegistration = Pick /** What the catalog service learns from the registration list, the only agent roster. */ export function registeredModelCatalogDiscovery( @@ -46,11 +43,9 @@ export function registeredModelCatalogDiscovery( ): { probes: Record listingNamesConfiguredModel: Set - recordsLiveListingsOf: Set } { const probes: Record = {} const listingNamesConfiguredModel = new Set() - const recordsLiveListingsOf = new Set() for (const registration of registrations) { const { agent } = registration.definition const discovery = registration.modelCatalog(context) @@ -60,11 +55,8 @@ export function registeredModelCatalogDiscovery( listingNamesConfiguredModel.add(agent) } } - if (!registration.adapterRecordsLiveListings) { - recordsLiveListingsOf.add(agent) - } } - return { probes, listingNamesConfiguredModel, recordsLiveListingsOf } + return { probes, listingNamesConfiguredModel } } /** diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index ad7d8e9b3598..d5268639bf3c 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -109,9 +109,6 @@ export type StructuredAgentRuntimeRegistration = { /** 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 - /** The adapter writes its live sessions' listings to the catalog itself, with facts the options - * answer lacks (Fast tiers, launched model); otherwise the host records them from that answer. */ - adapterRecordsLiveListings?: true /** 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 @@ -203,8 +200,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 } : {}) }) } @@ -291,7 +287,6 @@ export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRun definition: CODEX_STRUCTURED_AGENT, createAdapter: createCodexAdapter, modelCatalog: codexModelCatalogDiscovery, - adapterRecordsLiveListings: true, supportsLocation: (location) => supportsCodexStructuredLocation(location), resolveAccountHome: async (request, services) => agentSessionAccountHome( @@ -303,7 +298,6 @@ export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRun definition: CLAUDE_STRUCTURED_AGENT, createAdapter: createClaudeAdapter, modelCatalog: claudeModelCatalogDiscovery, - adapterRecordsLiveListings: true, supportsLocation: supportsClaudeStructuredLocation, resolveAccountHome: async ({ launchEnv, location }, services) => agentSessionAccountHome( diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 269fd8423149..bb37101d3d76 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'] @@ -163,8 +162,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 } : {}) }) } From 4d8c645b9c0e1f6868874cf790ed874c54938e92 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:34:44 -0700 Subject: [PATCH 04/30] fix(native-chat): phone new-chat mark clears on the chat's first live answer; Claude's startup listing reaches the host catalog --- .../mobile-created-structured-sessions.ts | 10 ++++- ...se-mobile-structured-agent-options.test.ts | 44 +++++++++++++++++++ .../use-mobile-structured-agent-options.ts | 7 ++- .../claude-structured-session-startup.test.ts | 4 +- .../claude-structured-session-startup.ts | 21 +++++---- .../structured-agent-session-adapter.ts | 2 + ...red-agent-session-provider-started.test.ts | 23 ++++++++++ ...ructured-agent-session-provider-started.ts | 3 ++ 8 files changed, 99 insertions(+), 15 deletions(-) diff --git a/mobile/src/session/mobile-created-structured-sessions.ts b/mobile/src/session/mobile-created-structured-sessions.ts index bb579df4cc70..fc1792aefa11 100644 --- a/mobile/src/session/mobile-created-structured-sessions.ts +++ b/mobile/src/session/mobile-created-structured-sessions.ts @@ -1,5 +1,6 @@ -// Structured chats this phone created this run, 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. +// 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 @@ -22,3 +23,8 @@ export function mobileCreatedStructuredSession( ): { 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/use-mobile-structured-agent-options.test.ts b/mobile/src/session/use-mobile-structured-agent-options.test.ts index 5a9b4a306da4..8753f0b356c3 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.test.ts @@ -512,6 +512,50 @@ describe('useMobileStructuredAgentOptions host catalog', () => { } ) + 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 diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index f4e1c66579a8..035108c3f587 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -27,7 +27,10 @@ import { } 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 { mobileCreatedStructuredSession } from './mobile-created-structured-sessions' +import { + forgetMobileCreatedStructuredSession, + mobileCreatedStructuredSession +} from './mobile-created-structured-sessions' import { encodeStructuredAgentSessionOptionValue } from '../../../src/shared/structured-agent-session-option-codec' type StructuredOptionsController = { @@ -114,6 +117,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) => diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index 58a81a2ca6f4..f65ece3a0617 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -87,7 +87,9 @@ describe('Claude structured session publishes before the CLI answers initialize' acquisitionGeneration: expect.any(String), // What the child was launched with, carried so the host never asks the CLI again. reportedOptions: expect.objectContaining({ model: 'opus' }), - restoreSkippedOptions: [] + restoreSkippedOptions: [], + // The account listing initialize answered, for the host's catalog. + catalogListing: { models: [expect.objectContaining({ id: 'claude-sonnet' })] } }) expect(claude.connections[0].calls.map(({ subtype }) => subtype)).toEqual([ 'initialize', diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index e7e0c12f338e..ca3875af4739 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -94,7 +94,7 @@ export function createClaudeInitProof(): ClaudeInitProof { export type StructuredAgentSessionStartedOptions = Pick< StructuredAgentSessionStartedEvent, - 'reportedOptions' | 'restoreSkippedOptions' | 'retiredOptions' + 'reportedOptions' | 'restoreSkippedOptions' | 'retiredOptions' | 'catalogListing' > /** A lifecycle event the start reports, before the session's identity is stamped on it. */ @@ -209,18 +209,20 @@ function applyClaudeStartupFacts(session: ClaudeSession, facts: ClaudeStartupFac 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[] -): StructuredAgentSessionStartedOptions['reportedOptions'] { - const { current } = claudeStructuredSessionOptionsFrom(session, catalog) +): Pick { + const { current, catalogListing } = claudeStructuredSessionOptionsFrom(session, catalog) + 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 startup facts to the published session, which already takes input. Any failure faults @@ -260,10 +262,7 @@ export async function settleClaudeSessionStartup(input: { input.report({ type: 'started', // `list_models` is answered from this same initialize result, so nothing is re-read. - reportedOptions: claudeStartedReportedOptions( - session, - readClaudeModels(facts.initialization) - ), + ...claudeStartedReportedOptions(session, readClaudeModels(facts.initialization)), restoreSkippedOptions: [...session.restoreSkippedOptions], ...claudeRetiredOptions(session) }) 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 1b926d6c0808..f21d106b3e2f 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 @@ -233,6 +233,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 } /** A running child showed saved options it cannot run, as a model the provider reports missing. 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..c1aceed0e695 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,10 @@ beforeEach(async () => { journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', + modelCatalog: { + read: async () => ({ origin: 'unknown' }), + recordLiveListing: (sessionId, listing) => savedListings.push({ sessionId, listing }) + }, now: () => NOW }) host.subscribeStatus({ id: 'status-1', emit: (event) => statuses.push(event) }) @@ -163,4 +170,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 589bf37c2616..668287cd1d40 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 @@ -45,6 +45,9 @@ export function settleStructuredAgentSessionProviderStarted( ) { return } + if (event.catalogListing) { + context.deps.modelCatalog?.recordLiveListing(event.sessionId, event.catalogListing) + } try { await persistStartedOptions(context, event) } catch (error) { From 3997b59d3bcf56f17a99b7ccbc2bd9db1e38cf13 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:45:46 -0700 Subject: [PATCH 05/30] fix(native-chat): live-listing type lives beside the catalog entry so wire types don't pull the store's persistence into the phone --- .../claude-structured-catalog-listing.ts | 2 +- .../agent-model-catalog-entry.ts | 23 ++++++++++++++++ .../agent-model-catalog-store.ts | 26 +++++-------------- .../structured-agent-session-adapter.ts | 2 +- 4 files changed, 31 insertions(+), 22 deletions(-) diff --git a/src/main/claude/claude-structured-catalog-listing.ts b/src/main/claude/claude-structured-catalog-listing.ts index 285cccd1e378..8002d2eae4d8 100644 --- a/src/main/claude/claude-structured-catalog-listing.ts +++ b/src/main/claude/claude-structured-catalog-listing.ts @@ -4,7 +4,7 @@ import type { AgentSessionFastModeSupport, AgentSessionOptionsResult } from '../../shared/agent-session-wire' -import type { AgentModelCatalogLiveListing } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import type { 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' diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts index c3f743c5961d..b1dda6ecb7f5 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts @@ -13,6 +13,29 @@ export type AgentModelCatalogListing = { 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 +} + +/** A live options answer whose model rows are the account's listing as the child reported it. */ +export function withLiveCatalogListing< + T extends Pick +>(options: T): T & { catalogListing: AgentModelCatalogLiveListing } { + return { + ...options, + catalogListing: { + models: options.models, + ...(options.fastModeSupport ? { fastModeSupport: options.fastModeSupport } : {}) + } + } +} + export type AgentModelCatalogEntry = { agent: string fingerprint: string 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 3ebf17dcea4c..041f17dc0f23 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 @@ -10,7 +10,12 @@ import { type AgentModelCatalogListing } from './agent-model-catalog-entry' -export type { AgentModelCatalogEntry, AgentModelCatalogListing } from './agent-model-catalog-entry' +export type { + AgentModelCatalogEntry, + AgentModelCatalogListing, + AgentModelCatalogLiveListing +} from './agent-model-catalog-entry' +export { withLiveCatalogListing } from './agent-model-catalog-entry' // The execution host's one model catalog per (agent, launch fingerprint): // served immediately at any age, refreshed in the background when old, and @@ -42,25 +47,6 @@ export type AgentModelCatalogSuccess = { launchOnlyModelId?: string } -/** What a running session listed, as its adapter hands it to the host with an options read. */ -export type AgentModelCatalogLiveListing = Pick< - AgentModelCatalogSuccess, - 'models' | 'fastModeSupport' | 'launchOnlyModelId' -> - -/** A live options answer whose model rows are the account's listing as the child reported it. */ -export function withLiveCatalogListing< - T extends Pick ->(options: T): T & { catalogListing: AgentModelCatalogLiveListing } { - return { - ...options, - catalogListing: { - models: options.models, - ...(options.fastModeSupport ? { fastModeSupport: options.fastModeSupport } : {}) - } - } -} - /** Lists an agent's models without a session, under the account a launch would pin. */ export type AgentModelCatalogProbe = ( accountHome: AgentSessionAccountHome 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 455709c18586..48e0a343377f 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 @@ -48,7 +48,7 @@ import type { AgentSessionPromptResponse } from '../../../shared/agent-session-q import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' -import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-store' +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 From 7dc6d9c7bfcf272cf9ffa0622e8fc9ba2d55d261 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:51:40 -0700 Subject: [PATCH 06/30] fix(native-chat): the model picker never waits on a listing; a first listing lands in place --- .../use-mobile-host-model-catalog-upgrade.ts | 12 +- ...se-mobile-structured-agent-options.test.ts | 33 +++++ .../use-mobile-structured-agent-options.ts | 1 - ...SessionOptionPickers.menu-request.test.tsx | 109 ---------------- .../NativeChatSessionOptionPickers.test.tsx | 38 ------ .../NativeChatSessionOptionPickers.tsx | 8 +- .../native-chat/host-model-listing-waits.ts | 17 --- .../use-host-model-catalog-upgrade.test.tsx | 120 ++++-------------- .../use-host-model-catalog-upgrade.ts | 16 +-- .../use-structured-agent-session-options.ts | 13 +- src/shared/native-chat-session-options.ts | 2 - .../structured-agent-session-options.ts | 11 -- 12 files changed, 78 insertions(+), 302 deletions(-) delete mode 100644 src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx diff --git a/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts b/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts index 358e85ed40f7..17888d7c6d58 100644 --- a/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts +++ b/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts @@ -14,8 +14,9 @@ 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. An older host - * refuses the method, and the seed stands until the options read lands. + * host store while the session's own options read may still wait on its attach. It never holds the + * picker: a first listing still running lands in place. An older host refuses the method, and the + * seed stands until the options read lands. */ export function useMobileHostModelCatalogUpgrade(args: { agent: string | null @@ -29,7 +30,6 @@ export function useMobileHostModelCatalogUpgrade(args: { fence: number | null optionCatalog: AgentSessionOptionCatalog | null activeOptionRecordRef: MutableRefObject - optionMutationGeneration: MutableRefObject updateOptionState: ( update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState ) => void @@ -42,7 +42,6 @@ export function useMobileHostModelCatalogUpgrade(args: { fence, newLaunch, optionCatalog, - optionMutationGeneration, sessionId, updateOptionState, worktree @@ -52,7 +51,6 @@ export function useMobileHostModelCatalogUpgrade(args: { return } let stale = false - const readGeneration = optionMutationGeneration.current const params = { agent, sessionId, ...(newLaunch && worktree ? { worktree } : {}) } const read = (waitForListing: boolean): Promise => waitForListing @@ -63,8 +61,9 @@ export function useMobileHostModelCatalogUpgrade(args: { 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 || optionMutationGeneration.current !== readGeneration) { + if (stale) { return } updateOptionState((current) => @@ -91,7 +90,6 @@ export function useMobileHostModelCatalogUpgrade(args: { fence, newLaunch, optionCatalog, - optionMutationGeneration, sessionId, updateOptionState, worktree 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 8753f0b356c3..15698925bba6 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.test.ts @@ -575,6 +575,39 @@ describe('useMobileStructuredAgentOptions host catalog', () => { 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' })) diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 035108c3f587..b241ad21d714 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -105,7 +105,6 @@ export function useMobileStructuredAgentOptions(args: { ...(createdHere ? { worktree: createdHere.worktree } : {}), optionCatalog, activeOptionRecordRef, - optionMutationGeneration, updateOptionState }) 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 ( - -