From c20f528d3e7493642b7701bd366f3e5da6551bf2 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:27:08 +0000 Subject: [PATCH] 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 --- .../discovery/acp-listing-session-models.ts | 148 ++++++++++++ .../discovery/acp-model-catalog-probe.test.ts | 107 +++++++++ .../acp/discovery/acp-model-catalog-probe.ts | 216 +++++------------- .../fixtures/omp-v18.17.0-model-efforts.json | 102 +++++++++ .../acp/options/acp-structured-options.ts | 5 +- ...del-catalog-effort-target-progress.test.ts | 50 ++++ .../agent-model-catalog-entry.test.ts | 16 +- .../agent-model-catalog-models.ts | 4 +- .../agent-model-catalog-snapshot.ts | 5 +- .../options/agent-model-catalog-ownership.ts | 2 +- 10 files changed, 481 insertions(+), 174 deletions(-) create mode 100644 src/main/acp/discovery/acp-listing-session-models.ts create mode 100644 src/main/acp/fixtures/omp-v18.17.0-model-efforts.json create mode 100644 src/main/native-chat/agent-model-catalog/agent-model-catalog-effort-target-progress.test.ts diff --git a/src/main/acp/discovery/acp-listing-session-models.ts b/src/main/acp/discovery/acp-listing-session-models.ts new file mode 100644 index 00000000000..0c35c338adc --- /dev/null +++ b/src/main/acp/discovery/acp-listing-session-models.ts @@ -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 + requestSessionFreeExtension(method: string, params: unknown): Promise + close(): Promise +} + +export type AcpModelCatalogSessionConnection = Omit< + AcpModelCatalogConnection, + 'requestSessionFreeExtension' +> & { + start(options: AcpSessionStartOptions): Promise + setConfigOption(configId: string, value: string): Promise + deleteSession(sessionId: string): Promise +} + +// 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 { + 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) => 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 => { + 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() + } + } +} diff --git a/src/main/acp/discovery/acp-model-catalog-probe.test.ts b/src/main/acp/discovery/acp-model-catalog-probe.test.ts index bbb5943d4b2..85f040a7fb3 100644 --- a/src/main/acp/discovery/acp-model-catalog-probe.test.ts +++ b/src/main/acp/discovery/acp-model-catalog-probe.test.ts @@ -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' + ]) +}) diff --git a/src/main/acp/discovery/acp-model-catalog-probe.ts b/src/main/acp/discovery/acp-model-catalog-probe.ts index ecf6ad7ac0b..8b935339fc8 100644 --- a/src/main/acp/discovery/acp-model-catalog-probe.ts +++ b/src/main/acp/discovery/acp-model-catalog-probe.ts @@ -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 - requestSessionFreeExtension(method: string, params: unknown): Promise - close(): Promise -} - -export type AcpModelCatalogSessionConnection = Omit< - AcpModelCatalogConnection, - 'requestSessionFreeExtension' -> & { - start(options: AcpSessionStartOptions): Promise - setConfigOption(configId: string, value: string): Promise - deleteSession(sessionId: string): Promise -} +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> + 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 { - 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) => 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 => { - 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() - } - } -} diff --git a/src/main/acp/fixtures/omp-v18.17.0-model-efforts.json b/src/main/acp/fixtures/omp-v18.17.0-model-efforts.json new file mode 100644 index 00000000000..4ba4f7835a0 --- /dev/null +++ b/src/main/acp/fixtures/omp-v18.17.0-model-efforts.json @@ -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" } + ] + } + ] + } +} diff --git a/src/main/acp/options/acp-structured-options.ts b/src/main/acp/options/acp-structured-options.ts index 5327bde5116..8ba7d511888 100644 --- a/src/main/acp/options/acp-structured-options.ts +++ b/src/main/acp/options/acp-structured-options.ts @@ -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, diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-effort-target-progress.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-effort-target-progress.test.ts new file mode 100644 index 00000000000..01f17e28147 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-effort-target-progress.test.ts @@ -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[] = [] + const probe = vi.fn((_home: unknown, _options?: { selectedModels?: readonly string[] }) => { + const listing = Promise.withResolvers() + 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'] }) + ) + ) +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.test.ts index a7f586a5c91..9286e5f368a 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.test.ts @@ -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 diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-models.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-models.ts index d9945ac0782..3b6ffd9e712 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-models.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-models.ts @@ -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) ) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-snapshot.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-snapshot.ts index b737ff65017..55cb348482b 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-snapshot.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-snapshot.ts @@ -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 } : {}), diff --git a/src/shared/agent-session/options/agent-model-catalog-ownership.ts b/src/shared/agent-session/options/agent-model-catalog-ownership.ts index 601dd3323b1..9136e84c4d3 100644 --- a/src/shared/agent-session/options/agent-model-catalog-ownership.ts +++ b/src/shared/agent-session/options/agent-model-catalog-ownership.ts @@ -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 {