diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 08b1f272353..1e7786cae45 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -100,6 +100,8 @@ export type AcpDialect = { notification?(method: string, params: unknown, at: number): AcpDialectNotification | undefined contextWindow?(models: unknown): number | undefined modelEfforts?(model: ModelInfo): { efforts: AgentSessionOptionChoice[]; defaultEffort?: string } + /** The agent clamps an effort another model lacks, so unlisted models' menus are unknown, not empty. */ + clampsUnlistedEffort?: true /** Claimed permission requests are never auto-approved. */ request?(method: string, params: unknown): AcpRequestPresentation | undefined settleRequest?(method: string, params: unknown): AcpRequestSettlement | undefined diff --git a/src/main/acp/acp-dialects/omp-dialect.ts b/src/main/acp/acp-dialects/omp-dialect.ts index 7af2b20e536..99ea622010c 100644 --- a/src/main/acp/acp-dialects/omp-dialect.ts +++ b/src/main/acp/acp-dialects/omp-dialect.ts @@ -113,6 +113,8 @@ function errorDetails(error: AcpAgentError): string | undefined { } export const OMP_ACP_DIALECT: AcpDialect = { + // OMP resolves any thinking level against the selected model (model-controls.ts setThinkingLevel). + clampsUnlistedEffort: true, normalizeToolUpdate, compactionReply, authenticationRequired: (error) => { diff --git a/src/main/acp/acp-structured-options.test.ts b/src/main/acp/acp-structured-options.test.ts index 2f54e1e7f50..22c011a96db 100644 --- a/src/main/acp/acp-structured-options.test.ts +++ b/src/main/acp/acp-structured-options.test.ts @@ -2,6 +2,8 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { AcpStructuredOptions } from './acp-structured-options' import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' +import { OMP_ACP_DIALECT } from './acp-dialects/omp-dialect' +import { OPENCODE_ACP_DIALECT } from './acp-dialects/opencode-dialect' const configOptions = [ { @@ -30,7 +32,8 @@ const configOptions = [ describe('ACP session options', () => { it('keeps the session’s picks out of the catalog facts', () => { - const options = new AcpStructuredOptions() + // OpenCode refuses an effort the selected model lacks, so another model's empty menu stays strict. + const options = new AcpStructuredOptions(OPENCODE_ACP_DIALECT) options.adoptSession({ configOptions }) const { models, current } = options.read() // Current values supply an effort menu, never a catalog default. @@ -49,6 +52,18 @@ describe('ACP session options', () => { expect(current).toEqual({ model: 'model-b', effort: 'high', confirmed: ['model', 'effort'] }) }) + it('leaves another model’s menu unknown for an agent that clamps unlisted efforts', () => { + const options = new AcpStructuredOptions(OMP_ACP_DIALECT) + options.adoptSession({ configOptions }) + expect(options.read().models[0]).toEqual({ + id: 'model-a', + label: 'Model A', + isDefault: false, + efforts: [], + effortsKnown: false + }) + }) + it('reads each model’s own menu where the agent advertises one', () => { const options = new AcpStructuredOptions(GROK_ACP_DIALECT) options.adoptSession({ diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts index c33219483a1..f262044e99d 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -51,7 +51,10 @@ function isSelect(option: SessionConfigOption): option is SelectOption { export class AcpStructuredOptions { constructor( - private readonly dialect: Pick = {} + private readonly dialect: Pick< + AcpDialect, + 'modelEfforts' | 'clampsUnlistedEffort' | 'withheldCommands' + > = {} ) {} private configOptions: SessionConfigOption[] = [] @@ -158,7 +161,13 @@ export class AcpStructuredOptions { const models: AgentSessionModelOption[] = listed.map((model) => { const info = this.models?.availableModels.find((entry) => entry.modelId === model.id) const advertised = info ? this.dialect.modelEfforts?.(info) : undefined - const sessionMenu = { efforts: model.id === currentModel ? sessionEfforts : [] } + // A clamping agent's session menu says nothing about another model's menu: unknown, not none. + const sessionMenu = + model.id === currentModel + ? { efforts: sessionEfforts } + : this.dialect.clampsUnlistedEffort && effortOption && !advertised + ? { efforts: [], effortsKnown: false as const } + : { efforts: [] } return { ...model, isDefault: false, 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 c1553272f9d..d194ae27741 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 @@ -39,3 +39,26 @@ describe('the effort menu of a model whose configured effort one listing lacks', expect(merged).not.toHaveProperty('defaultEffort') }) }) + +const unknownChat = (at: number): AgentModelCatalogListing => ({ + ...listing('live-session', at, []), + models: [ + { id: 'openai/gpt-6', label: 'GPT-6', isDefault: false, efforts: [], effortsKnown: false } + ] +}) + +describe('a model whose newer chat listing does not know its efforts', () => { + it('takes the menu the older listing supplies', () => { + const merged = model(listing('probe', 1, menu('off', 'high')), unknownChat(2)) + expect(merged?.efforts).toEqual(menu('off', 'high')) + expect(merged).not.toHaveProperty('effortsKnown') + }) + + it('keeps an older listing’s explicit empty menu', () => { + expect(model(listing('probe', 1, []), unknownChat(2))).not.toHaveProperty('effortsKnown') + }) + + it('stays unknown when no listing knows its menu', () => { + expect(model(unknownChat(1), unknownChat(2))).toMatchObject({ effortsKnown: false }) + }) +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts index ee66c39ee5a..eac0b909ff6 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts @@ -139,7 +139,8 @@ function mergedModels( return (newer?.models ?? []).map((model) => { const listed = discovered?.models.find((entry) => entry.id === model.id) const reported = live?.models.find((entry) => entry.id === model.id) - const olderEfforts = older?.models.find((entry) => entry.id === model.id)?.efforts ?? [] + const olderModel = older?.models.find((entry) => entry.id === model.id) + const olderEfforts = olderModel?.efforts ?? [] const runsConfigured = configured?.modelId === model.id || configured?.sameModelIds?.includes(model.id) === true const configuredEffort = runsConfigured ? configured?.effort : undefined @@ -152,9 +153,13 @@ function mergedModels( const defaultEffort = [configuredEffort, listed?.defaultEffort, reported?.defaultEffort].find( (effort) => effort !== undefined && efforts.some((choice) => choice.value === effort) ) - const { defaultEffort: _own, ...rest } = model + const { defaultEffort: _own, effortsKnown, ...rest } = model return { ...rest, + // Efforts stay unknown only when neither listing knows this model's menu. + ...(effortsKnown === false && (!olderModel || olderModel.effortsKnown === false) + ? { effortsKnown } + : {}), // Without discovery/configuration, live flags are the only available default evidence. isDefault: configured ? model.id === configured.modelId