diff --git a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx index 925de3288ce..6a3cac4d48f 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx @@ -221,6 +221,7 @@ describe('mobile structured send actions', () => { { id: 'opus[1m]', label: 'Opus', isDefault: true, efforts }, { id: 'claude-gone', label: 'claude-gone', isDefault: false, efforts: [] } ], + listedModelIds: ['opus[1m]'], current: { model: 'claude-gone', effort: 'high' } }) : ok({}) diff --git a/src/main/acp/acp-structured-host-live-listing.test.ts b/src/main/acp/acp-structured-host-live-listing.test.ts index f077684b1a4..201ff58b85e 100644 --- a/src/main/acp/acp-structured-host-live-listing.test.ts +++ b/src/main/acp/acp-structured-host-live-listing.test.ts @@ -15,6 +15,7 @@ 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 })), + retainedLiveModelIds: vi.fn(() => []), recordLiveListing: vi.fn(), prewarm: vi.fn(async () => {}), stop: vi.fn(), @@ -38,7 +39,8 @@ describe('a live Grok listing', () => { it('starts the chat even when saving its start listing throws', async () => { const modelCatalog: AgentModelCatalogService = { read: vi.fn(async () => ({ origin: 'unknown' as const })), - recordLiveListing: vi.fn(() => { + retainedLiveModelIds: vi.fn(() => []), + recordLiveListing: vi.fn().mockImplementationOnce(() => { throw new Error('catalog unavailable') }), prewarm: vi.fn(async () => {}), @@ -46,8 +48,10 @@ describe('a live Grok listing', () => { providerStarted: vi.fn() } // The rig's attach must succeed: bookkeeping never fails a proven start. - const { store } = await openAttachedHostRig({}, undefined, modelCatalog) + const { host, store } = await openAttachedHostRig({}, undefined, modelCatalog) expect(modelCatalog.recordLiveListing).toHaveBeenCalledTimes(1) expect(store.getRecord(SESSION)?.lease.ownerProcess).not.toBeNull() + await expect(host.readOptions(SESSION)).resolves.toHaveProperty('models') + expect(modelCatalog.recordLiveListing).toHaveBeenCalledTimes(2) }) }) 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 da7593e91c7..79b49d284a0 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 @@ -1,6 +1,7 @@ import type { AgentSessionUnavailable } from '../../../shared/agent-session-availability' import type { AgentSessionFastModeSupport, + AgentSessionModelCatalogResult, AgentSessionModelOption } from '../../../shared/agent-session-wire' @@ -120,6 +121,25 @@ export type AgentModelCatalogEntry = { fetchedAt: number } +export function agentModelCatalogResultFromEntry( + entry: AgentModelCatalogEntry, + namesDefault: boolean, + listingNamesConfiguredModel: boolean +): Exclude { + // A CLI-resolved default names the configured model even where the listing names none. + const namesConfigured = listingNamesConfiguredModel || entry.configured !== null + return { + origin: entry.origin, + // Without a default the picker names nothing until the chat reports its model. + models: entry.models.map((model) => + namesDefault ? { ...model } : { ...model, isDefault: false } + ), + ...(entry.fastModeSupport ? { fastModeSupport: entry.fastModeSupport } : {}), + fetchedAt: entry.fetchedAt, + listingNamesConfiguredModel: namesDefault && namesConfigured + } +} + /** Which models exist and their menus follow the newer listing; the default model and effort are * the CLI-resolved configured default's, else discovery's, else what a live child reported while * its model offers it. */ @@ -189,6 +209,17 @@ export function agentModelCatalogEntry( } } +/** Only a launch-added row needs independent account membership; ordinary incoming rows stand. */ +export function retainedAgentModelCatalogModels( + previous: Pick | null | undefined, + listing: AgentModelCatalogLiveListing +): AgentSessionModelOption[] { + const launchOnly = listing.launchOnlyModelId + return launchOnly !== undefined && !previous?.models.some((model) => model.id === launchOnly) + ? listing.models.filter((model) => model.id !== launchOnly) + : listing.models +} + /** The entry `success` makes of `previous`, replacing only the listing of its source. */ export function agentModelCatalogEntryWithSuccess( previous: AgentModelCatalogEntry | undefined, @@ -197,11 +228,7 @@ export function agentModelCatalogEntryWithSuccess( source: AgentModelCatalogSource, at: number ): AgentModelCatalogEntry | null { - const launchOnly = success.launchOnlyModelId - const models = - launchOnly !== undefined && !previous?.models.some((model) => model.id === launchOnly) - ? success.models.filter((model) => model.id !== launchOnly) - : success.models + const models = retainedAgentModelCatalogModels(previous, success) if (models.length === 0) { // An empty list identifies no model; it is doubt, not a catalog. return null diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts index fcd20b48931..e15763eef43 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts @@ -13,7 +13,7 @@ import { * Everything that changes which models a listing can answer with: the agent, * the account home the CLI reads credentials/config from, and the execution * host that runs the binary. Login-state or CLI-version drift under the same - * key is corrected by the next refresh, never by the fingerprint. + * key is corrected by later listings, never by the fingerprint. */ export type AgentModelCatalogIdentity = { agent: string diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts index 290313f8f65..b74a7f74823 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts @@ -288,6 +288,11 @@ describe('one account, one catalog key', () => { }) // What the chat saved, through the host and through the Codex adapter's own handle… service.recordLiveListing('session-1', { models: listing('gpt-live').models }) + const incoming = { models: [...listing('gpt-live').models, ...listing('gpt-new').models] } + const retained = (launchOnlyModelId: string) => + service.retainedLiveModelIds('session-1', { ...incoming, launchOnlyModelId }) + expect(retained('gpt-live')).toEqual(['gpt-live', 'gpt-new']) + expect(retained('gpt-new')).toEqual(['gpt-live']) const adapterAccess = agentModelCatalogSessionAccess( store, { agent: 'codex', accountHomeVariable: 'CODEX_HOME' }, 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 b0530e834e5..275f3d43d4c 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 @@ -9,7 +9,11 @@ import { agentModelCatalogFingerprint, agentModelCatalogFingerprintForRecord } from './agent-model-catalog-fingerprint' -import type { AgentModelCatalogConfiguredChoice } from './agent-model-catalog-entry' +import { + agentModelCatalogResultFromEntry, + retainedAgentModelCatalogModels, + type AgentModelCatalogConfiguredChoice +} from './agent-model-catalog-entry' import type { AgentModelCatalogEntry, AgentModelCatalogLiveListing, @@ -62,7 +66,9 @@ export type AgentModelCatalogService = { /** Answer only from the saved entry and held reason; start, join or re-check no listing. */ savedOnly?: boolean }) => Promise - /** Saves what a running session listed as its account's catalog, so the next chat starts warm. */ + /** Incoming listing ids, checking only a launch-added row against the pinned account. */ + retainedLiveModelIds: (sessionId: string, listing: AgentModelCatalogLiveListing) => string[] + /** Saves a session's listing and newly learned menus so the next chat starts warm. */ recordLiveListing: (sessionId: string, listing: AgentModelCatalogLiveListing) => void /** Lists, in the background, every agent the user has used here (a saved catalog or a chat) * whose catalog for the account a new chat would pin is missing or old, so a picker never meets @@ -79,25 +85,6 @@ export type AgentModelCatalogService = { // At most this many agents list at once: each listing spawns that agent's CLI. const PREWARM_CONCURRENCY = 2 -function resultFromEntry( - entry: AgentModelCatalogEntry, - namesDefault: boolean, - listingNamesConfiguredModel: boolean -): Exclude { - // A CLI-resolved default names the configured model even where the listing names none. - const namesConfigured = listingNamesConfiguredModel || entry.configured !== null - return { - origin: entry.origin, - // Without a default the picker names nothing until the chat reports its model. - models: entry.models.map((model) => - namesDefault ? { ...model } : { ...model, isDefault: false } - ), - ...(entry.fastModeSupport ? { fastModeSupport: entry.fastModeSupport } : {}), - fetchedAt: entry.fetchedAt, - listingNamesConfiguredModel: namesDefault && namesConfigured - } -} - /** A named workspace keeps the listed default only when none of its own config can replace it. */ async function workspaceKeepsListedDefault( deps: AgentModelCatalogServiceDeps, @@ -258,7 +245,7 @@ export function createAgentModelCatalogService( ): Promise => { const unavailable = deps.store.failure(fingerprint)?.unavailable const result = listed - ? resultFromEntry( + ? agentModelCatalogResultFromEntry( listed, await workspaceKeepsListedDefault(deps, params.agent, workspacePath, accountHomePath), deps.listingNamesConfiguredModel?.has(params.agent) === true @@ -314,6 +301,14 @@ export function createAgentModelCatalogService( } return answer(entry) }, + retainedLiveModelIds(sessionId, listing) { + const record = deps.getRecord(sessionId) + const entry = + record && deps.drivesRecord(record) + ? deps.store.get(agentModelCatalogFingerprintForRecord(record)) + : undefined + return retainedAgentModelCatalogModels(entry, listing).map((model) => model.id) + }, recordLiveListing(sessionId, listing) { const record = deps.getRecord(sessionId) if (!record || !deps.drivesRecord(record)) { 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 6a2a6e58192..fa412f2e299 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 @@ -35,11 +35,11 @@ export type { AgentModelCatalogFailure } // The execution host's one model catalog per (agent, launch fingerprint): // served immediately at any age, refreshed in the background when old, and -// written through by every successful listing a live session already performs. +// written through by each session's startup and later capability listings. // 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. +// refresh follows) and the last received session listing (which models exist +// and their efforts). Readers see the two merged; neither write erases the other. // Success-only: a failure, timeout or empty list is never stored as a catalog // and never persisted — it is held separately under a short TTL so a burst of // picker opens does not hammer a dead binary, then dies on its own (see diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts index 18c830f1797..48a64a13d9d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts @@ -39,6 +39,7 @@ function launchPermission( }) const modelCatalog = { read, + retainedLiveModelIds: vi.fn(() => []), recordLiveListing: vi.fn(), prewarm: vi.fn(async () => undefined), stop: vi.fn(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index 7097f3f2a14..28bf3f77071 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -9,6 +9,10 @@ import { } from '../agent-model-catalog/agent-model-catalog-store' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { readStructuredAgentSessionOptions } from './structured-agent-session-options-read' +import { AcpStructuredOptions } from '../../acp/acp-structured-options' +import { GROK_CONFIG_OPTIONS } from '../../acp/acp-structured-adapter.test-support' +import { SessionConfigOptionSchema } from '../../acp/generated/acp-protocol.generated' +import { withLiveCatalogListing } from '../agent-model-catalog/agent-model-catalog-entry' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' @@ -70,7 +74,7 @@ describe('options at rest', () => { }) it.each(['codex', 'omp', 'grok', 'opencode'] as const)( - 'omits an unknown model for %s while the catalog is unavailable', + '%s keeps an unknown model and learns menus from later options', async (provider) => { const record = { ...restingRecord(), provider } const probe = vi.fn(() => new Promise(() => {})) @@ -81,40 +85,60 @@ describe('options at rest', () => { resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/homes/a' }), probes: { codex: probe } }) + let live = false + const reader = new AcpStructuredOptions() + const configOptions = SessionConfigOptionSchema.array().parse(GROK_CONFIG_OPTIONS) const resting = { child: null, params: { provider: 'codex' }, journal: { threadGoal: () => null, context: { floor: () => null } } } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: options reads touch only these context members. const context = { deps: { - adapter: {}, + adapter: { readOptions: async () => withLiveCatalogListing(reader.read()) }, agents: NO_STRUCTURED_AGENTS, store: { getRecord: () => record }, modelCatalog }, serialize: (_sessionId: string, task: () => Promise) => task(), - openConversation: async () => resting, + openConversation: async () => (live ? { ...resting, child: { fence: 7 } } : resting), conversation: async () => resting } as unknown as StructuredAgentSessionMutationContext - const result = await readStructuredAgentSessionOptions(context, SESSION, { - readsWithoutModel: true - }) + const read = () => + readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) + const result = await read() expect(probe).not.toHaveBeenCalled() expect(result.models).toEqual([]) expect(result.current).not.toHaveProperty('model') record.options = { model: 'later-model', effort: 'off' } // Only Codex is registered here, and a mode is stated only for a registered agent's chat. - expect( - (await readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true })) - .current - ).toEqual({ + expect((await read()).current).toEqual({ model: 'later-model', effort: 'off', ...(provider === 'codex' ? { permissionMode: 'ask' } : {}) }) + for (const emptyStart of [false, true]) { + modelCatalog.recordLiveListing(SESSION, { + models: [{ id: 'old', label: 'Old', isDefault: true, efforts: [] }] + }) + reader.adoptSession(emptyStart ? {} : { configOptions }) + modelCatalog.recordLiveListing(SESSION, reader.startListing()) + reader.adoptConfigOptions( + configOptions.map((option) => + option.type === 'select' && option.category === 'model' + ? { ...option, currentValue: 'grok-4.6' } + : option + ) + ) + record.options = { model: 'grok-4.6', effort: 'high' } + live = true + await read() + live = false + const answer = await read() + expect(answer.models).toEqual(reader.read().models) + } } ) }) 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 7764e1a48a1..7d351922809 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 @@ -17,6 +17,7 @@ import type { AgentJournalThreadGoal } from '../../../shared/agent-session-journal-types' import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' +import { finishedListingIds } from '../../../shared/structured-agent-session-options' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID, agentChatPermissionModes @@ -26,13 +27,18 @@ import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-fa import type { StructuredAgentDefinition } from './structured-agent-definition' import type { StructuredAgentRegistry } from './structured-agent-registry' import type { StructuredAgentSessionLiveOptions } from './structured-agent-session-adapter' +import { retainedAgentModelCatalogModels } from '../agent-model-catalog/agent-model-catalog-entry' +import { saveStructuredAgentSessionListing } from './structured-agent-session-start-listing' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { structuredAgentSessionOptionModels } from './structured-agent-session-option-models' import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' -type RestingOptions = Pick +type RestingOptions = Pick< + AgentSessionOptionsResult, + 'models' | 'listedModelIds' | 'fastModeSupport' | 'current' +> /** What the asking client takes: whether it reads an answer that names no model. */ export type StructuredAgentSessionOptionsReader = { readsWithoutModel: boolean } @@ -119,6 +125,7 @@ async function readStructuredAgentSessionOptionsAtRest( // With no catalog for the account, the list a running child of this agent falls back to. const listed = catalog.origin === 'unknown' ? (rules?.fallbackModels() ?? null) : catalog.models const models = listed ?? [] + const listedModelIds = finishedListingIds(catalog) const fastMode = saved.fastMode === undefined ? null @@ -137,6 +144,7 @@ async function readStructuredAgentSessionOptionsAtRest( : undefined) return { models: listed ? structuredAgentSessionOptionModels(listed, model, (row) => row) : [], + ...(listedModelIds ? { listedModelIds } : {}), ...(catalog.origin !== 'unknown' && catalog.fastModeSupport ? { fastModeSupport: catalog.fastModeSupport } : {}), @@ -228,11 +236,15 @@ export async function readStructuredAgentSessionOptions( let options: Omit if (live) { const { catalogListing, ...answer } = live - if (catalogListing) { - // What a running child listed is its account's catalog too, so the next chat opens warm. - context.deps.modelCatalog?.recordLiveListing(sessionId, catalogListing) - } options = answer + if (catalogListing) { + // Check prior membership before saving: a launch-added row cannot verify itself. + const listedModelIds = context.deps.modelCatalog + ? context.deps.modelCatalog.retainedLiveModelIds(sessionId, catalogListing) + : retainedAgentModelCatalogModels(undefined, catalogListing).map((model) => model.id) + saveStructuredAgentSessionListing(context, sessionId, catalogListing) + options = { ...answer, listedModelIds } + } } else { options = await readStructuredAgentSessionOptionsAtRest(context.deps, sessionId, reader) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts index eb1594768d6..a141ee6f37f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts @@ -319,6 +319,7 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { fetchedAt: 1, models: rows.map((row) => ({ ...row, label: row.id, efforts: [] })) })), + retainedLiveModelIds: vi.fn(() => []), recordLiveListing: vi.fn(), prewarm: vi.fn(async () => undefined), stop: vi.fn(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts index a256f02037e..a3f54d8a058 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts @@ -56,6 +56,7 @@ function startHost(): void { }, modelCatalog: { read: vi.fn(), + retainedLiveModelIds: vi.fn(() => []), recordLiveListing: vi.fn(), prewarm: vi.fn(async () => {}), stop: vi.fn(), 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 e36758ff177..c8de9f74b55 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 @@ -103,6 +103,7 @@ beforeEach(async () => { mintSpawnToken: () => 'spawn-a', modelCatalog: { read: async () => ({ origin: 'unknown' }), + retainedLiveModelIds: vi.fn(() => []), recordLiveListing: (sessionId, listing) => savedListings.push({ sessionId, listing }), prewarm: async () => {}, stop: () => {}, @@ -239,8 +240,10 @@ describe('a publish-first Claude create whose init is slow', () => { configuredDefault: expect.objectContaining({ modelId: 'claude-opus-9' }) }) }) - // A later options read lists the models and says nothing more about the default. + // A later read refreshes the listing without re-learning the configured default. + const saved = savedListings.length await host.readOptions(SESSION) + expect(savedListings).toHaveLength(saved + 1) expect(savedListings.at(-1)?.listing).not.toHaveProperty('configuredDefault') }) 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 8c44ce53ecb..e0e6c313002 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 @@ -15,7 +15,7 @@ import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { - saveStructuredAgentSessionStartListing, + saveStructuredAgentSessionListing, saveStructuredAgentSessionStartReadbackListing } from './structured-agent-session-start-listing' import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' @@ -79,7 +79,7 @@ export async function settleStructuredAgentSessionProviderStarted( void context.wakeDelivery(event.sessionId) noteStructuredAgentSessionProviderStarted(context.deps, event.sessionId) if (event.catalogListing) { - saveStructuredAgentSessionStartListing(context, event.sessionId, event.catalogListing) + saveStructuredAgentSessionListing(context, event.sessionId, event.catalogListing) } context.publishStatus?.(event.sessionId) return undefined diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-listing.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-listing.ts index 4b102930838..93bb98e3362 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-listing.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-listing.ts @@ -1,6 +1,5 @@ -// What a start listed reaches the account's model catalog once, with the configured default its -// start resolved: on `started`, or on Claude's settings readback that follows it. Bookkeeping: a -// failure is logged, never the start's. +// Session listings warm the account catalog; only startup/readback also resolves its default. +// Catalog bookkeeping failures never reject the session action that supplied the listing. import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' import type { @@ -15,8 +14,8 @@ export type StructuredAgentSessionStartListingContext = { serialize: (sessionId: string, task: () => Promise) => Promise } -/** A proven start's listing, saved inside its serialized step. */ -export function saveStructuredAgentSessionStartListing( +/** Catalog persistence must not reject a valid start or options answer. */ +export function saveStructuredAgentSessionListing( context: Pick, sessionId: string, listing: AgentModelCatalogLiveListing @@ -24,8 +23,8 @@ export function saveStructuredAgentSessionStartListing( try { context.deps.modelCatalog?.recordLiveListing(sessionId, listing) } catch (error) { - context.deps.logger.warn('saving what a started provider listed failed', { - scope: 'provider-started-catalog', + context.deps.logger.warn('saving what a provider listed failed', { + scope: 'provider-catalog', sessionId, error }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts index cd7b1dd37f8..ae6c69fb113 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts @@ -88,6 +88,7 @@ async function startHost(): Promise { }, modelCatalog: { read: vi.fn(async () => ({ origin: 'unknown' as const })), + retainedLiveModelIds: vi.fn(() => []), recordLiveListing: vi.fn(), prewarm: vi.fn(async () => {}), stop: vi.fn(), diff --git a/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts b/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts index b104e572a6a..be66bc127d7 100644 --- a/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts @@ -36,6 +36,7 @@ describe('the host catalog lifetime', () => { it('prewarms once the host is installed and stops its listings at teardown', async () => { const service = { read: vi.fn(async () => ({ origin: 'unknown' as const })), + retainedLiveModelIds: vi.fn(() => []), recordLiveListing: vi.fn(), prewarm: vi.fn(async () => {}), stop: vi.fn(), diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 4ccf7c2b5bc..72f485ec699 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -480,6 +480,9 @@ export type AgentSessionOptionsResult = { /** This session takes image attachments. Absent = unknown (an older host). */ acceptsImages?: boolean models: AgentSessionModelOption[] + /** The `models` ids the agent's own finished listing names, excluding host-added rows and + * unverified launch-only rows. Absent: nothing verified, as from an older host. */ + listedModelIds?: string[] /** Session/account/transport support. Absent means unknown, never unsupported. */ fastModeSupport?: AgentSessionFastModeSupport current: { 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 9947fdd2ed6..a75083c4ebc 100644 --- a/src/shared/structured-agent-session-model-catalog-application.test.ts +++ b/src/shared/structured-agent-session-model-catalog-application.test.ts @@ -136,7 +136,8 @@ describe('structured option state from the host model catalog', () => { // The host list stays; the answer only says it is no longer a finished listing. expect(applyStructuredAgentSessionModelCatalog(hosted, SEED, answer, LAUNCH)).toEqual({ ...hosted, - verifiedModelIds: undefined + verifiedModelIds: undefined, + hostListedModelIds: undefined }) } }) @@ -151,9 +152,10 @@ describe('structured option state from the host model catalog', () => { } ) expect(live.catalogSource).toBe('live') + // The host's ids are kept apart: they never verify the live answer's rows. expect(applyStructuredAgentSessionModelCatalog(live, SEED, HOST_CATALOG, LAUNCH)).toEqual({ ...live, - verifiedModelIds: ['gpt-hosted'] + hostListedModelIds: ['gpt-hosted'] }) const snapshot = structuredAgentSessionOptionSnapshot(live) const model = snapshot.find((descriptor) => descriptor.id === 'model')! diff --git a/src/shared/structured-agent-session-option-view.test.ts b/src/shared/structured-agent-session-option-view.test.ts index 2f86af05124..1e0eea292b5 100644 --- a/src/shared/structured-agent-session-option-view.test.ts +++ b/src/shared/structured-agent-session-option-view.test.ts @@ -28,10 +28,12 @@ const withHostList = (state = fresh(), catalog: AgentSessionModelCatalogResult = applyStructuredAgentSessionModelCatalog(state, CLAUDE_SESSION_OPTION_CATALOG, catalog, { newLaunch: true }) -// A stopped chat's own answer lists the host's rows plus its own model, named by its id. -const stopped = (model: string, rows = [OPUS]) => +// A chat's own answer lists its listing's rows plus its own model, named by its id; `listed` are +// the ids its listing names (none from an older host). +const stopped = (model: string, rows = [OPUS], listed = rows.map((row) => row.id)) => applyStructuredAgentSessionOptions(fresh(), CLAUDE_SESSION_OPTION_CATALOG, { models: [...rows, { id: model, label: model, isDefault: false, efforts: [] }], + listedModelIds: listed, current: { model, effort: 'high' } }) @@ -80,8 +82,8 @@ describe('a Claude model the verified list no longer offers', () => { }) }) - it('moves a model only the session itself lists, as the listing does not name it', () => { - const live = withHostList(stopped('sonnet', [OPUS, SONNET])) + it('moves a model only the session itself lists, as its listing does not name it', () => { + const live = stopped('sonnet', [OPUS, SONNET], ['opus[1m]']) expect(structuredAgentSessionListedPicks(live, undefined, {})).toEqual({ model: 'opus[1m]', effort: 'xhigh' @@ -100,13 +102,6 @@ describe('a Claude model the verified list no longer offers', () => { ])('keeps the model while the list is %s', (_name, catalog) => { const state = withHostList(fresh(), catalog) expect(structuredAgentSessionListedPicks(state, remembered, {})).toBeNull() - expect( - structuredAgentSessionListedPicks( - withHostList(stopped('claude-gone'), catalog), - undefined, - {} - ) - ).toBeNull() }) it('takes no row the finished listing does not name, as from a built-in fallback list', () => { @@ -132,15 +127,17 @@ describe('a Claude model the verified list no longer offers', () => { ['a row with no efforts', [{ id: 'opus[1m]', label: 'Opus', isDefault: false }]], ['no rows', []] ])('keeps the model when the host answer has %s', (_name, models) => { - const live = stopped('claude-gone') // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a malformed host answer is the case under test. const answer = { origin: 'probe', fetchedAt: 1, models } as AgentSessionModelCatalogResult - expect(structuredAgentSessionListedPicks(withHostList(live, answer), undefined, {})).toBeNull() + const state = withHostList(stopped('claude-gone', [OPUS], []), answer) + expect(structuredAgentSessionListedPicks(state, undefined, {})).toBeNull() }) - it('keeps the model before any host answer', () => { + it('keeps the model before any host answer, and on an answer that verifies no row', () => { expect(structuredAgentSessionListedPicks(fresh(), remembered, {})).toBeNull() - expect(structuredAgentSessionListedPicks(stopped('claude-gone'), undefined, {})).toBeNull() + // An older host's answer names no listed ids. + const unverified = withHostList(stopped('claude-gone', [OPUS], [])) + expect(structuredAgentSessionListedPicks(unverified, undefined, {})).toBeNull() }) }) @@ -171,10 +168,26 @@ describe('what a send carries', () => { const named = { ...SONNET, isDefault: true } const listing = { ...LISTED, models: [OPUS, named], listingNamesConfiguredModel: true } expect(carried(withHostList(fresh(), listing))).toEqual({ model: 'sonnet' }) - const listing2: AgentSessionModelCatalogResult = { ...LISTED, listingInProgress: true } - expect(carried(withHostList(stopped('claude-gone'), listing2))).toEqual({ - model: 'claude-gone' - }) + expect(carried(stopped('claude-gone', [OPUS], []))).toEqual({ model: 'claude-gone' }) + }) + + it("keeps a model the chat's own listing names, whichever CLI version wrote the host's", () => { + // Claude Code 2.1.280 lists opus[1m] as its default and no plain opus; 2.1.302 the reverse. + const row = (id: string, isDefault = false) => ({ ...SONNET, id, label: id, isDefault }) + const cli280 = [row('opus[1m]', true), row('sonnet'), row('sonnet[1m]'), row('haiku')] + const cli302 = [row('opus', true), row('sonnet'), row('haiku')] + const sent = (child: typeof cli280, model: string, host: typeof cli280) => + carried(withHostList(stopped(model, child), { ...LISTED, models: host }))?.model + expect(sent(cli280, 'opus[1m]', cli302)).toBe('opus[1m]') + expect(sent(cli302, 'opus', cli280)).toBe('opus') + // A model the chat's own CLI no longer lists still moves to that CLI's default. + expect(sent(cli302, 'opus[1m]', cli280)).toBe('opus') + // An older host names no listed ids: only the chat's own default, where the host lists it. + const older = (child: typeof cli280, model: string, host: typeof cli280) => + carried(withHostList(stopped(model, child, []), { ...LISTED, models: host }))?.model + expect(older(cli280, 'opus[1m]', cli302)).toBe('opus[1m]') + expect(older(cli302, 'opus', cli280)).toBe('opus') + expect(older(cli302, 'opus[1m]', cli302)).toBe('opus') }) it('resolves a pick on its way as the picker shows it, a gone model it names included', () => { diff --git a/src/shared/structured-agent-session-option-view.ts b/src/shared/structured-agent-session-option-view.ts index ad73cb341f1..c049a1c1deb 100644 --- a/src/shared/structured-agent-session-option-view.ts +++ b/src/shared/structured-agent-session-option-view.ts @@ -105,19 +105,24 @@ export function nearestAgentEffort( .sort((a, b) => distance(a) - distance(b) || rank(b) - rank(a))[0] } -/** What a send moves a Claude chat to when the host's finished listing does not name its model - * (held, else recorded, else seeded): the listed row marked default, else the first, with the +/** What a send moves a Claude chat to when the finished listing the picker shows does not name its + * model (held, else recorded, else seeded): that listing's default row, else its first, with the * effort moved to the nearest level offered. Null keeps the model. */ export function structuredAgentSessionListedPicks( state: StructuredAgentSessionOptionState, seed: Readonly> | undefined, held: Readonly> ): Record | null { - const { catalog, catalogSource, record, verifiedModelIds: verified = [] } = state + const { catalog, catalogSource, record, verifiedModelIds: own } = state const tracked = record.model?.value const model = held.model ?? (typeof tracked === 'string' ? tracked : seed?.model) - // Only the listing proves a row: the session's own unlisted model is shown but never listed. - const rows = catalog?.models.filter((row) => verified.includes(row.id)) ?? [] + // Only the listing proves a row: the session's own unlisted model is shown but never listed. An + // older host's answer proves none, so only its own default may stand in, where the host lists it. + const verified = own ?? state.hostListedModelIds ?? [] + const rows = + catalog?.models.filter( + (row) => verified.includes(row.id) && (own !== undefined || row.isDefault) + ) ?? [] if ( record.agent !== 'claude' || (catalogSource !== 'host' && catalogSource !== 'live') || diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index 71af405ff0d..943f6d9ad90 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -52,9 +52,12 @@ export type StructuredAgentSessionOptionState = { * built-in list before the host answered (shown as the quiet placeholder); `builtin` is the * same list once the host said it has none, usable but naming nothing the host may replace. */ catalogSource: 'seed' | 'builtin' | 'host' | 'live' | null - /** The models the host's latest answer names when it is a finished, well-formed listing: the - * only rows that can stand in for a model it lacks, whatever list the picker shows. */ + /** The rows of `catalog` its own finished listing names: the only ones that can stand in for a + * model it lacks. Never another listing's, which may come from another CLI version. */ verifiedModelIds?: readonly string[] + /** What the host's finished account list names, for a live answer from an older host that + * names no listed ids of its own. */ + hostListedModelIds?: readonly string[] record: NativeChatSessionOptionRecord /** The permission mode the host or launch last stated; null shows no picker. */ permissionMode: string | null @@ -81,17 +84,20 @@ export function createStructuredAgentSessionOptionState( export function settleStructuredAgentSessionBuiltinCatalog( state: StructuredAgentSessionOptionState ): StructuredAgentSessionOptionState { - const settled = withVerifiedModelIds(state, undefined) + const settled = withHostListedModelIds(state, undefined) return settled.catalogSource === 'seed' ? { ...settled, catalogSource: 'builtin' } : settled } -function withVerifiedModelIds( +/** The host list's ids, which verify the catalog only while the host's list is what it shows. */ +function withHostListedModelIds( state: StructuredAgentSessionOptionState, ids: readonly string[] | undefined ): StructuredAgentSessionOptionState { - return String(state.verifiedModelIds) === String(ids) + const verified = state.catalogSource === 'live' ? state.verifiedModelIds : ids + return String(state.hostListedModelIds) === String(ids) && + String(state.verifiedModelIds) === String(verified) ? state - : { ...state, verifiedModelIds: ids } + : { ...state, verifiedModelIds: verified, hostListedModelIds: ids } } function isListedModelRow(row: unknown): row is { id: string } { @@ -106,7 +112,7 @@ function isListedModelRow(row: unknown): row is { id: string } { } /** The ids a finished listing names; undefined for one still running, failed, empty or malformed. */ -function finishedListingIds(catalog: AgentSessionModelCatalogResult): string[] | undefined { +export function finishedListingIds(catalog: AgentSessionModelCatalogResult): string[] | undefined { const rows: unknown = catalog.origin === 'unknown' ? null : catalog.models return Array.isArray(rows) && rows.length > 0 && @@ -120,8 +126,8 @@ function finishedListingIds(catalog: AgentSessionModelCatalogResult): string[] | /** * Applies the host's stored model catalog: models only, no current selection * and no record writes, so nothing here reads as a committed value — the pick - * stays provisional until a live options result confirms it. A live catalog - * is never downgraded by this. + * stays provisional until a live options result confirms it. A live catalog, + * and the ids its own answer verified, are never replaced by this. */ export function applyStructuredAgentSessionModelCatalog( state: StructuredAgentSessionOptionState, @@ -129,9 +135,9 @@ export function applyStructuredAgentSessionModelCatalog( catalog: AgentSessionModelCatalogResult, options: { newLaunch: boolean } ): StructuredAgentSessionOptionState { - const verifiedModelIds = finishedListingIds(catalog) + const hostListedModelIds = finishedListingIds(catalog) if (state.catalogSource === 'live') { - return withVerifiedModelIds(state, verifiedModelIds) + return withHostListedModelIds(state, hostListedModelIds) } const models = catalog.origin === 'unknown' @@ -157,7 +163,8 @@ export function applyStructuredAgentSessionModelCatalog( ...(namesDefault ? { defaultModelIsCliDefault: true } : {}) }, catalogSource: 'host', - verifiedModelIds + verifiedModelIds: hostListedModelIds, + hostListedModelIds } } @@ -186,6 +193,7 @@ export function applyStructuredAgentSessionOptions( ...state, catalog: structuredAgentSessionOptionCatalog(seed, result), catalogSource: 'live', + verifiedModelIds: result.listedModelIds?.length ? result.listedModelIds : undefined, permissionMode: result.current.permissionMode ?? null } }