fix(omp): show the picked model's own thinking levels before the first chat message (#40933)

OMP's thinking menu belongs to the running model, but Orca copied it onto
every other OMP model in a new chat. Treat OMP like OpenCode: leave
non-running models' menus unknown, drop such copies saved by older
builds, and read a picked model's menu from a throwaway listing session
started on that model. Starting on it, rather than switching to it,
keeps OMP from recording the model as used in the user's account; if
OMP cannot start on it, the listing runs again without it.

A read that asks for a picked model's menu now reports the listing in
progress, so an open picker updates when the menu lands instead of on
the next open.

Fixes #39015
This commit is contained in:
Kelvin Amoaba
2026-11-11 00:27:08 +00:00
committed by GitHub
parent 48d5bfd76c
commit c20f528d3e
10 changed files with 481 additions and 174 deletions
@@ -0,0 +1,148 @@
import { setTimeout as delay } from 'node:timers/promises'
import { observeAcpForkSupport } from '../capabilities/acp-fork-support'
import {
assertStructuredAgentResolutionCurrent,
type StructuredAgentResolutionOperation
} from '../../native-chat/structured-agent-command-resolution'
import type { AgentSessionModelOption } from '../../../shared/agent-session/agent-session-wire'
import type { AcpSessionModelDiscovery } from './acp-model-discovery-policy'
import type { AcpLaunchSpec } from '../launch/acp-launch-contract'
import type { AcpSessionStarted, AcpSessionStartOptions } from '../protocol/acp-session-setup'
import { AcpStructuredOptions } from '../options/acp-structured-options'
import type {
InitializeResponse,
SetSessionConfigOptionResponse
} from '../generated/acp-protocol.generated'
import { withTimeout } from '../../../shared/promise-timeout-fallback'
import { AcpAgentError } from '../transport/acp-errors'
/** Session-free capability boundary: no session methods are exposed. */
export type AcpModelCatalogConnection = {
initialize(): Promise<InitializeResponse>
requestSessionFreeExtension(method: string, params: unknown): Promise<unknown>
close(): Promise<unknown>
}
export type AcpModelCatalogSessionConnection = Omit<
AcpModelCatalogConnection,
'requestSessionFreeExtension'
> & {
start(options: AcpSessionStartOptions): Promise<AcpSessionStarted>
setConfigOption(configId: string, value: string): Promise<SetSessionConfigOptionResponse>
deleteSession(sessionId: string): Promise<unknown>
}
// OpenCode can answer -32603 while its providers are still opening; a later attempt lists them.
async function startListingSession(
connection: AcpModelCatalogSessionConnection,
workspace: string,
operation?: StructuredAgentResolutionOperation
): Promise<AcpSessionStarted> {
for (const backoffMs of [250, 500]) {
try {
return await connection.start({ cwd: workspace, mcpServers: [] })
} catch (error) {
if (!(error instanceof AcpAgentError && error.code === -32603)) {
throw error
}
}
await delay(backoffMs, undefined, { signal: operation?.signal })
assertStructuredAgentResolutionCurrent(operation)
}
return connection.start({ cwd: workspace, mcpServers: [] })
}
/** Lists models from a disposable session, learning menus for the models it starts on or switches to. */
export async function readSessionModels(
spec: AcpLaunchSpec,
listing: AcpSessionModelDiscovery,
connection: AcpModelCatalogSessionConnection,
workspace: string,
operation?: StructuredAgentResolutionOperation,
selectedModels?: readonly string[],
launchedModel?: string,
ready?: (cleanup: () => Promise<unknown>) => void
): Promise<{ models: AgentSessionModelOption[]; attemptedModels: string[] }> {
const initialized = await connection.initialize()
assertStructuredAgentResolutionCurrent(operation)
observeAcpForkSupport(spec.agent, initialized)
const deletes = Boolean(initialized.agentCapabilities?.sessionCapabilities?.delete)
if (!deletes && !listing.sessionFolderArgs) {
throw new Error(`${spec.agent} cannot delete a session, so it lists its models only in chats`)
}
const started = await startListingSession(connection, workspace, operation)
let stopped = false
const cleanup = async (): Promise<void> => {
stopped = true
if (deletes) {
await connection.deleteSession(started.sessionId).catch((error: unknown) => {
console.warn(`[acp-model-catalog] ${spec.agent} kept its listing session:`, error)
})
}
}
// Startup and menu discovery have separate budgets; cleanup also runs on cancellation.
ready?.(cleanup)
const options = new AcpStructuredOptions(spec.dialect)
options.adoptSession(started.response, 'new')
let models = options.read().models
const attemptedModels: string[] = []
try {
if (launchedModel !== undefined) {
attemptedModels.push(launchedModel)
const running = options.read().current.model
// An unresolved --model leaves OMP on its default with a menu that is not that model's.
if (running !== launchedModel) {
models = models.map((row) =>
row.id === running ? { ...row, efforts: [], effortsKnown: false as const } : row
)
}
} else if (spec.dialect.effortChangesModel) {
const byId = new Map(models.map((model) => [model.id, model]))
const targets = selectedModels ? selectedModels.flatMap((id) => byId.get(id) ?? []) : models
const sweep = async () => {
for (const model of targets) {
if (stopped) {
return
}
assertStructuredAgentResolutionCurrent(operation)
if (model.effortsKnown !== false) {
continue
}
attemptedModels.push(model.id)
try {
for (let attempt = 0; attempt < 2 && !stopped; attempt++) {
const write = options.write('model', model.id)
if (write?.method !== 'config') {
break
}
const reply = await connection.setConfigOption(write.configId, write.value)
if (stopped) {
return
}
assertStructuredAgentResolutionCurrent(operation)
options.adoptConfigOptions(reply.configOptions)
const observed = options.read()
if (observed.current.model !== model.id) {
continue
}
const known = observed.models.find((row) => row.id === model.id)
if (known) {
models = models.map((row) => (row.id === model.id ? known : row))
}
break
}
} catch {
assertStructuredAgentResolutionCurrent(operation)
}
}
}
await withTimeout(sweep(), 5_000, undefined)
}
assertStructuredAgentResolutionCurrent(operation)
return { models, attemptedModels }
} finally {
if (!ready) {
await cleanup()
}
}
}
@@ -1,3 +1,6 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { acpLaunchSpecFor } from '../launch/acp-launch-registry'
import type { AcpLaunchSpec } from '../launch/acp-launch-contract'
@@ -5,6 +8,10 @@ import { createAcpModelCatalogProbe } from './acp-model-catalog-probe'
import { AcpSessionRuntime } from '../protocol/acp-session-runtime'
import { AcpScriptedAgent } from '../process/testing/acp-scripted-agent.test-support'
import { openCodeAcpAccountBinding } from '../../opencode/opencode-structured-account-home'
import { AcpAgentExitedError } from '../transport/acp-errors'
// Real `omp acp` 18.17.0 session/new replies: started on its default fake/wide, with
// `--model fake/narrow`, and with `--model fake/gone`, which it cannot resolve.
import recorded from '../fixtures/omp-v18.17.0-model-efforts.json'
// Bypass the app's managed-account service for this fixture.
const OPENCODE: AcpLaunchSpec = {
@@ -87,3 +94,103 @@ describe('ACP model catalog probes', () => {
])
})
})
async function listOmp(selectedModels?: string[]) {
const agent = new AcpScriptedAgent()
agents.push(agent)
let launchedModel: string | undefined
agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 1 }))
agent.on('session/new', (frame) =>
agent.reply(frame, {
sessionId: 'probe-session',
...(launchedModel === 'fake/narrow'
? recorded.narrow
: launchedModel === 'fake/gone'
? recorded.unresolved
: recorded.new)
})
)
const dataDirectory = await mkdtemp(join(tmpdir(), 'orca-omp-efforts-'))
try {
const listing = await createAcpModelCatalogProbe(acpLaunchSpecFor('omp')!, {
resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }),
resolveCommand: () => '/opt/omp/bin/omp',
probeVersion: async () => ({ version: '18.17.0' }),
homePath: '/home/user',
dataDirectory,
connectSession: (launch) => {
const at = launch.args.indexOf('--model')
launchedModel = at === -1 ? undefined : launch.args[at + 1]
if (launchedModel?.includes(':')) {
// OMP 18.17.0 prints `Model "fake/gone:high" not found.` and exits 1 before ACP starts.
const exited = async () => {
throw new AcpAgentExitedError('Model "fake/gone:high" not found.')
}
return {
initialize: exited,
start: exited,
setConfigOption: exited,
deleteSession: exited,
close: async () => {}
}
}
const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin)
return {
initialize: () => runtime.initialize(),
start: (options) => runtime.start(options),
setConfigOption: (id, value) => runtime.setConfigOption(id, value),
deleteSession: (sessionId) => runtime.deleteSession(sessionId),
close: async () => runtime.close()
}
}
})(
{ variable: 'PI_CODING_AGENT_DIR', path: '/homes/omp' },
{ workspacePath: '/work/project', ...(selectedModels ? { selectedModels } : {}) }
)
// A model switch would record usage in the user's OMP account.
expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize', 'session/new'])
return (id: string) => listing.models.find((model) => model.id === id)
} finally {
await rm(dataDirectory, { recursive: true, force: true })
}
}
const levels = (model: { efforts: { value: string }[] } | undefined) =>
model?.efforts.map((effort) => effort.value)
it("lists a picked OMP model with its own thinking levels, not the startup model's", async () => {
const listed = await listOmp()
expect(levels(listed('fake/wide'))).toEqual([
'off',
'auto',
'minimal',
'low',
'medium',
'high',
'xhigh'
])
expect(listed('fake/narrow')).toMatchObject({ efforts: [], effortsKnown: false })
const picked = await listOmp(['fake/narrow'])
expect(levels(picked('fake/narrow'))).toEqual(['off', 'auto', 'low', 'high'])
expect(picked('fake/narrow')).not.toHaveProperty('effortsKnown')
expect(picked('fake/wide')).toMatchObject({ efforts: [], effortsKnown: false })
})
it('trusts no menu when OMP cannot start on the picked model', async () => {
const picked = await listOmp(['fake/gone'])
expect(picked('fake/wide')).toMatchObject({ efforts: [], effortsKnown: false })
})
it('still lists OMP when it cannot start on the picked model', async () => {
const picked = await listOmp(['fake/gone:high'])
expect(levels(picked('fake/wide'))).toEqual([
'off',
'auto',
'minimal',
'low',
'medium',
'high',
'xhigh'
])
})
+56 -160
View File
@@ -1,10 +1,6 @@
import { observeAcpForkSupport } from '../capabilities/acp-fork-support'
import {
assertStructuredAgentResolutionCurrent,
type StructuredAgentResolutionOperation
} from '../../native-chat/structured-agent-command-resolution'
import { assertStructuredAgentResolutionCurrent } from '../../native-chat/structured-agent-command-resolution'
import { homedir } from 'node:os'
import { setTimeout as delay } from 'node:timers/promises'
import { join } from 'node:path'
import type { AgentCliVersionReader } from '../../agent-launch/preflight/agent-cli-version-probe'
import type { AgentModelCatalogProbe } from '../../native-chat/agent-model-catalog/agent-model-catalog-store'
@@ -20,40 +16,22 @@ import { acpRunFolder } from '../launch/acp-private-temp'
import {
acpListingDependsOnRelease,
acpListingNeedsChatFolder,
acpModelListingForRelease,
type AcpSessionModelDiscovery
acpModelListingForRelease
} from './acp-model-discovery-policy'
import type { AcpLaunchSpec } from '../launch/acp-launch-contract'
import type { AcpSessionStarted, AcpSessionStartOptions } from '../protocol/acp-session-setup'
import { checkAcpLaunchVersion } from '../launch/acp-launch-version'
import {
resolveAcpLaunchInvocation,
type AcpLaunchInvocationDeps
} from '../launch/acp-structured-launch-resolution'
import { AcpStructuredOptions } from '../options/acp-structured-options'
import type {
InitializeResponse,
SetSessionConfigOptionResponse
} from '../generated/acp-protocol.generated'
import { withTimeout } from '../../../shared/promise-timeout-fallback'
import { AcpAgentError } from '../transport/acp-errors'
import {
readSessionModels,
type AcpModelCatalogConnection,
type AcpModelCatalogSessionConnection
} from './acp-listing-session-models'
import { AcpAgentError, AcpAgentExitedError } from '../transport/acp-errors'
import { AgentModelCatalogUnavailableError } from '../../native-chat/agent-model-catalog/agent-model-catalog-unavailable'
/** Session-free capability boundary: no session methods are exposed. */
export type AcpModelCatalogConnection = {
initialize(): Promise<InitializeResponse>
requestSessionFreeExtension(method: string, params: unknown): Promise<unknown>
close(): Promise<unknown>
}
export type AcpModelCatalogSessionConnection = Omit<
AcpModelCatalogConnection,
'requestSessionFreeExtension'
> & {
start(options: AcpSessionStartOptions): Promise<AcpSessionStarted>
setConfigOption(configId: string, value: string): Promise<SetSessionConfigOptionResponse>
deleteSession(sessionId: string): Promise<unknown>
}
import { usesModelSpecificAcpEfforts } from '../../../shared/agent-session/options/agent-model-catalog-ownership'
export type AcpModelCatalogProbeDeps = AcpLaunchInvocationDeps & {
probeVersion?: AgentCliVersionReader
@@ -113,6 +91,11 @@ export function createAcpModelCatalogProbe(
envToDelete
}
const run = { label: spec.agent, signal: options?.signal }
// OMP records every model switch as account usage; starting on the picked model records none.
const launchedModel =
spec.launchesWithModel && usesModelSpecificAcpEfforts(spec.agent)
? options?.selectedModels?.[0]
: undefined
let models: AgentSessionModelOption[]
let attemptedModels: string[] | undefined
if (listing.kind === 'command') {
@@ -133,32 +116,49 @@ export function createAcpModelCatalogProbe(
}
// Remove disposable session storage after the process closes.
const folderArgs = listing.sessionFolderArgs
const folder = folderArgs
? await acpRunFolder(join(deps.dataDirectory, 'acp-listing-sessions', spec.agent))
: null
try {
assertStructuredAgentResolutionCurrent(operation)
const args =
folderArgs && folder ? [...launch.args, ...folderArgs(folder.path)] : launch.args
const result = await runAgentModelCatalogSession(
() => (deps.connectSession ?? createAcpAgentConnection)({ ...launch, args }),
(connection, ready) =>
readSessionModels(
spec,
listing,
connection,
workspace,
operation,
options?.selectedModels,
spec.dialect.effortChangesModel ? ready : undefined
),
run
)
models = result.models
attemptedModels = result.attemptedModels
} finally {
await folder?.remove()
const listInSession = async (model: string | undefined) => {
const folder = folderArgs
? await acpRunFolder(join(deps.dataDirectory, 'acp-listing-sessions', spec.agent))
: null
try {
assertStructuredAgentResolutionCurrent(operation)
const args = [
...prefixArgs,
...spec.args({ fullAccess: false, pluginDir: null, model }),
...(folderArgs && folder ? folderArgs(folder.path) : [])
]
return await runAgentModelCatalogSession(
() => (deps.connectSession ?? createAcpAgentConnection)({ ...launch, args }),
(connection, ready) =>
readSessionModels(
spec,
listing,
connection,
workspace,
operation,
options?.selectedModels,
model,
spec.dialect.effortChangesModel ? ready : undefined
),
run
)
} finally {
await folder?.remove()
}
}
let result: Awaited<ReturnType<typeof listInSession>>
try {
result = await listInSession(launchedModel)
} catch (error) {
// OMP exits before ACP on a model it cannot start (a stale `:level` id); list without it.
if (launchedModel === undefined || !(error instanceof AcpAgentExitedError)) {
throw error
}
assertStructuredAgentResolutionCurrent(operation)
result = { ...(await listInSession(undefined)), attemptedModels: [launchedModel] }
}
models = result.models
attemptedModels = result.attemptedModels
} else {
models = await runAgentModelCatalogSession(
() => (deps.connect ?? createAcpAgentConnection)(launch),
@@ -190,107 +190,3 @@ export function createAcpModelCatalogProbe(
const folderlessListing = (spec: AcpLaunchSpec) =>
new AgentModelCatalogListingStoppedError(`${spec.agent} lists in a chat's folder`)
// OpenCode can answer -32603 while its providers are still opening; a later attempt lists them.
async function startListingSession(
connection: AcpModelCatalogSessionConnection,
workspace: string,
operation?: StructuredAgentResolutionOperation
): Promise<AcpSessionStarted> {
for (const backoffMs of [250, 500]) {
try {
return await connection.start({ cwd: workspace, mcpServers: [] })
} catch (error) {
if (!(error instanceof AcpAgentError && error.code === -32603)) {
throw error
}
}
await delay(backoffMs, undefined, { signal: operation?.signal })
assertStructuredAgentResolutionCurrent(operation)
}
return connection.start({ cwd: workspace, mcpServers: [] })
}
async function readSessionModels(
spec: AcpLaunchSpec,
listing: AcpSessionModelDiscovery,
connection: AcpModelCatalogSessionConnection,
workspace: string,
operation?: StructuredAgentResolutionOperation,
selectedModels?: readonly string[],
ready?: (cleanup: () => Promise<unknown>) => void
): Promise<{ models: AgentSessionModelOption[]; attemptedModels: string[] }> {
const initialized = await connection.initialize()
assertStructuredAgentResolutionCurrent(operation)
observeAcpForkSupport(spec.agent, initialized)
const deletes = Boolean(initialized.agentCapabilities?.sessionCapabilities?.delete)
if (!deletes && !listing.sessionFolderArgs) {
throw new Error(`${spec.agent} cannot delete a session, so it lists its models only in chats`)
}
const started = await startListingSession(connection, workspace, operation)
let stopped = false
const cleanup = async (): Promise<void> => {
stopped = true
if (deletes) {
await connection.deleteSession(started.sessionId).catch((error: unknown) => {
console.warn(`[acp-model-catalog] ${spec.agent} kept its listing session:`, error)
})
}
}
// Startup and menu discovery have separate budgets; cleanup also runs on cancellation.
ready?.(cleanup)
const options = new AcpStructuredOptions(spec.dialect)
options.adoptSession(started.response, 'new')
let models = options.read().models
const attemptedModels: string[] = []
try {
if (spec.dialect.effortChangesModel) {
const byId = new Map(models.map((model) => [model.id, model]))
const targets = selectedModels ? selectedModels.flatMap((id) => byId.get(id) ?? []) : models
const sweep = async () => {
for (const model of targets) {
if (stopped) {
return
}
assertStructuredAgentResolutionCurrent(operation)
if (model.effortsKnown !== false) {
continue
}
attemptedModels.push(model.id)
try {
for (let attempt = 0; attempt < 2 && !stopped; attempt++) {
const write = options.write('model', model.id)
if (write?.method !== 'config') {
break
}
const reply = await connection.setConfigOption(write.configId, write.value)
if (stopped) {
return
}
assertStructuredAgentResolutionCurrent(operation)
options.adoptConfigOptions(reply.configOptions)
const observed = options.read()
if (observed.current.model !== model.id) {
continue
}
const known = observed.models.find((row) => row.id === model.id)
if (known) {
models = models.map((row) => (row.id === model.id ? known : row))
}
break
}
} catch {
assertStructuredAgentResolutionCurrent(operation)
}
}
}
await withTimeout(sweep(), 5_000, undefined)
}
assertStructuredAgentResolutionCurrent(operation)
return { models, attemptedModels }
} finally {
if (!ready) {
await cleanup()
}
}
}
@@ -0,0 +1,102 @@
{
"new": {
"configOptions": [
{
"id": "model",
"name": "Model",
"category": "model",
"type": "select",
"currentValue": "fake/wide",
"options": [
{ "value": "fake/wide", "name": "Wide", "description": "fake/wide" },
{ "value": "fake/narrow", "name": "Narrow", "description": "fake/narrow" },
{
"value": "apple/on-device",
"name": "Apple AFM 3 Core Advanced",
"description": "apple/on-device"
}
]
},
{
"id": "thinking",
"name": "Thinking",
"category": "thought_level",
"type": "select",
"currentValue": "high",
"options": [
{ "value": "off", "name": "Off" },
{ "value": "auto", "name": "Auto", "description": "Auto-detect per prompt" },
{ "value": "minimal", "name": "minimal" },
{ "value": "low", "name": "low" },
{ "value": "medium", "name": "medium" },
{ "value": "high", "name": "high" },
{ "value": "xhigh", "name": "xhigh" }
]
}
]
},
"narrow": {
"configOptions": [
{
"id": "model",
"name": "Model",
"category": "model",
"type": "select",
"currentValue": "fake/narrow",
"options": [
{ "value": "fake/wide", "name": "Wide", "description": "fake/wide" },
{ "value": "fake/narrow", "name": "Narrow", "description": "fake/narrow" },
{
"value": "apple/on-device",
"name": "Apple AFM 3 Core Advanced",
"description": "apple/on-device"
}
]
},
{
"id": "thinking",
"name": "Thinking",
"category": "thought_level",
"type": "select",
"currentValue": "high",
"options": [
{ "value": "off", "name": "Off" },
{ "value": "auto", "name": "Auto", "description": "Auto-detect per prompt" },
{ "value": "low", "name": "low" },
{ "value": "high", "name": "high" }
]
}
]
},
"unresolved": {
"configOptions": [
{
"id": "model",
"name": "Model",
"category": "model",
"type": "select",
"currentValue": "fake/wide",
"options": [
{ "value": "fake/wide", "name": "Wide", "description": "fake/wide" },
{ "value": "fake/narrow", "name": "Narrow", "description": "fake/narrow" },
{
"value": "apple/on-device",
"name": "Apple AFM 3 Core Advanced",
"description": "apple/on-device"
}
]
},
{
"id": "thinking",
"name": "Thinking",
"category": "thought_level",
"type": "select",
"currentValue": "off",
"options": [
{ "value": "off", "name": "Off" },
{ "value": "auto", "name": "Auto", "description": "Auto-detect per prompt" }
]
}
]
}
}
@@ -250,10 +250,7 @@ export class AcpStructuredOptions {
model.id === currentModel
? { efforts: sessionEfforts }
: (modelOption || effortOption) && !advertised
? {
efforts: this.dialect.clampsUnlistedEffort ? sessionEfforts : [],
effortsKnown: false as const
}
? { efforts: [], effortsKnown: false as const }
: { efforts: [] }
return {
...model,
@@ -0,0 +1,50 @@
import { expect, it, onTestFinished, vi } from 'vitest'
import { createAgentModelCatalogService } from './agent-model-catalog-service'
import { AgentModelCatalogStore, type AgentModelCatalogSuccess } from './agent-model-catalog-store'
it("reports a picked OMP model's menu discovery as a listing in progress", async () => {
const listings: PromiseWithResolvers<AgentModelCatalogSuccess>[] = []
const probe = vi.fn((_home: unknown, _options?: { selectedModels?: readonly string[] }) => {
const listing = Promise.withResolvers<AgentModelCatalogSuccess>()
listings.push(listing)
return listing.promise
})
const service = createAgentModelCatalogService({
store: new AgentModelCatalogStore(),
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
resolveAccountHome: async () => ({ variable: 'PI_CODING_AGENT_DIR', path: '/account' }),
probes: { omp: probe }
})
onTestFinished(() => {
for (const listing of listings) {
listing.resolve({ origin: 'probe', models: [] })
}
return service.stop()
})
const params = { agent: 'omp', workspacePath: '/work' }
void service.demand(params)
await vi.waitFor(() => expect(probe).toHaveBeenCalledOnce())
listings[0]!.resolve({
origin: 'probe',
models: [
{ id: 'wide', label: 'Wide', isDefault: false, efforts: [{ value: 'high', label: 'high' }] },
{ id: 'narrow', label: 'Narrow', isDefault: false, efforts: [], effortsKnown: false }
]
})
await vi.waitFor(async () =>
expect((await service.read(params)).listingInProgress).toBeUndefined()
)
// The picker reads once, as the RPC does, and waits for the listing only if told one runs.
const picked = { ...params, selectedModel: 'narrow' }
void service.demand(picked)
expect(await service.read(picked)).toMatchObject({ listingInProgress: true })
await vi.waitFor(() =>
expect(probe).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({ selectedModels: ['narrow'] })
)
)
})
@@ -74,14 +74,16 @@ describe('a model whose newer chat listing does not know its efforts', () => {
expect(merged).not.toHaveProperty('defaultEffort')
})
it('keeps native suggestions unknown without a known model menu', () => {
it('drops an OMP menu an older build borrowed from the running model', () => {
const saved = unknownChat(1, menu('low', 'medium', 'max'))
expect(agentModelCatalogEntry('omp', 'fp', saved, null, null)?.models[0]).toMatchObject({
efforts: [],
effortsKnown: false
})
})
it('keeps an older unknown menu when the newer listing has none', () => {
const older = unknownChat(1, menu('low', 'high'))
const newer = unknownChat(2, menu('off', 'auto'))
for (const discovered of [null, { ...older, origin: 'probe' as const }]) {
const merged = agentModelCatalogEntry('omp', 'fp', discovered, newer, null)?.models[0]
expect(merged).toMatchObject({ efforts: menu('off', 'auto'), effortsKnown: false })
expect(merged).not.toHaveProperty('defaultEffort')
}
expect(model({ ...older, origin: 'probe' }, unknownChat(2))).toMatchObject({
efforts: menu('low', 'high'),
effortsKnown: false
@@ -28,7 +28,7 @@ export function mergedAgentModelCatalogModels(
// Coarser probe menus must not discard a chat-confirmed effort unless a newer chat menu does.
const keepsChatMenu =
older === live && !offersConfigured(model.efforts) && offersConfigured(olderEfforts)
const { efforts, effortsKnown } = modelSpecificEfforts
const { efforts: keptEfforts, effortsKnown } = modelSpecificEfforts
? model.effortsKnown === false && olderModel
? olderModel
: model
@@ -39,6 +39,8 @@ export function mergedAgentModelCatalogModels(
: model.efforts.length > 0 && !keepsChatMenu
? model
: (olderModel ?? model)
// An unobserved per-model menu is another model's; older builds saved such copies.
const efforts = modelSpecificEfforts && effortsKnown === false ? [] : keptEfforts
const defaultEffort = [configuredEffort, listed?.defaultEffort, reported?.defaultEffort].find(
(effort) => effort !== undefined && efforts.some((choice) => choice.value === effort)
)
@@ -239,7 +239,10 @@ export function projectAgentModelCatalogSnapshot(
...(!params.savedOnly &&
!params.verifiedOnly &&
(deps.store.isListing(bound.fingerprint) ||
(resolving && deps.store.probeDue(bound.fingerprint))) &&
(resolving &&
(deps.store.probeDue(bound.fingerprint) ||
// The picked model's menu is listed by the refresh this read just asked for.
deps.store.efforts.due(bound.fingerprint, params.selectedModel)))) &&
(independent || !listed || usesModelSpecificAcpEfforts(params.agent))
? { listingInProgress: true as const }
: {}),
@@ -3,7 +3,7 @@ export function independentAgentModelCatalog(agent: string): boolean {
}
export function usesModelSpecificAcpEfforts(agent: string): boolean {
return agent === 'opencode' || agent === 'opencode2'
return agent === 'opencode' || agent === 'opencode2' || agent === 'omp'
}
export function usesInstalledAgentModelCatalog(agent: string, coherentCatalog?: boolean): boolean {