mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(native-chat): a running Claude chat keeps its model when two Claude Code versions list differently (#30022)
* fix(native-chat): a running Claude chat keeps its model when two Claude Code versions list differently A send's stand-in for a model the list lacks took its rows and default from the chat's own answer but kept only rows the account's saved list names, and every running chat rewrote that list whatever CLI version it ran. After Claude Code 2.1.280 -> 2.1.302 the filter dropped the default row and sends moved to Sonnet. - The options answer names the ids its agent listed (listedModelIds); a live catalog is verified only by its own listing. An older host's answer may move only to the chat's own default row, and only where the host's list names it. - Only a session's start (and the probe) writes the account's list; later reads no longer do, so an older running CLI can't replace a newer start's list. Tracked in STA-10947. * fix: keep running chat model selection within its own listing Preserve learned model menus on later reads and check launch-added model membership before saving. Tracked in STA-10947. * test: validate ACP menu regression fixtures Use the protocol schema for config options and supply the catalog row default flag. Tracked in STA-10947. --------- Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -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({})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<AgentSessionModelCatalogResult, { origin: 'unknown' }> {
|
||||
// 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<AgentModelCatalogEntry, 'models'> | 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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<AgentSessionModelCatalogResult>
|
||||
/** 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<AgentSessionModelCatalogResult, { origin: 'unknown' }> {
|
||||
// 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<AgentSessionModelCatalogResult> => {
|
||||
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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
@@ -39,6 +39,7 @@ function launchPermission(
|
||||
})
|
||||
const modelCatalog = {
|
||||
read,
|
||||
retainedLiveModelIds: vi.fn(() => []),
|
||||
recordLiveListing: vi.fn(),
|
||||
prewarm: vi.fn(async () => undefined),
|
||||
stop: vi.fn(),
|
||||
|
||||
+35
-11
@@ -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<AgentModelCatalogSuccess>(() => {}))
|
||||
@@ -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<unknown>) => 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)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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<AgentSessionOptionsResult, 'models' | 'fastModeSupport' | 'current'>
|
||||
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<AgentSessionOptionsResult, 'rewind'>
|
||||
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)
|
||||
}
|
||||
|
||||
+1
@@ -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(),
|
||||
|
||||
+1
@@ -56,6 +56,7 @@ function startHost(): void {
|
||||
},
|
||||
modelCatalog: {
|
||||
read: vi.fn(),
|
||||
retainedLiveModelIds: vi.fn(() => []),
|
||||
recordLiveListing: vi.fn(),
|
||||
prewarm: vi.fn(async () => {}),
|
||||
stop: vi.fn(),
|
||||
|
||||
+4
-1
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
|
||||
}
|
||||
|
||||
/** 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<StructuredAgentSessionStartListingContext, 'deps'>,
|
||||
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
|
||||
})
|
||||
|
||||
+1
@@ -88,6 +88,7 @@ async function startHost(): Promise<void> {
|
||||
},
|
||||
modelCatalog: {
|
||||
read: vi.fn(async () => ({ origin: 'unknown' as const })),
|
||||
retainedLiveModelIds: vi.fn(() => []),
|
||||
recordLiveListing: vi.fn(),
|
||||
prewarm: vi.fn(async () => {}),
|
||||
stop: vi.fn(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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')!
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<Record<string, string>> | undefined,
|
||||
held: Readonly<Record<string, string>>
|
||||
): Record<string, string> | 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') ||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user