From a05cb89f1efb24e1d92f1f878cbd280613bf054b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:25:58 -0700 Subject: [PATCH] Restore OMP thinking choices for non-running models (#36158) * fix(acp): restore thinking choices for non-running OMP models * test: cover OMP catalog persistence and menu scaling * test: keep OMP scaling measurements in assertions * test: load ACP fixtures before consumer tests * test: preserve catalog source types in persistence cases --- ...se-mobile-structured-agent-options.test.ts | 54 ++++++- src/main/acp/acp-dialects/acp-dialect.ts | 2 + src/main/acp/acp-dialects/omp-dialect.ts | 2 + src/main/acp/acp-structured-options.test.ts | 146 +++++++++++++++++- src/main/acp/acp-structured-options.ts | 9 +- .../acp/acp-thinking-options.test-fixture.ts | 28 ++++ .../agent-model-catalog-entry.test.ts | 39 +++-- .../agent-model-catalog-entry.ts | 15 +- ...agent-session-omp-menu-persistence.test.ts | 134 ++++++++++++++++ .../NativeChatSessionOptionPickers.test.tsx | 68 +++++++- ...-thinking-options.test-fixture-contract.ts | 10 ++ 11 files changed, 487 insertions(+), 20 deletions(-) create mode 100644 src/main/acp/acp-thinking-options.test-fixture.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-omp-menu-persistence.test.ts create mode 100644 src/shared/acp-thinking-options.test-fixture-contract.ts diff --git a/mobile/src/session/use-mobile-structured-agent-options.test.ts b/mobile/src/session/use-mobile-structured-agent-options.test.ts index 1eeb413620f..11979e27afc 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.test.ts @@ -1,5 +1,5 @@ import { act } from 'react-test-renderer' -import { describe, expect, it, vi } from 'vitest' +import { beforeAll, describe, expect, it, vi } from 'vitest' import type { AgentSessionModelCatalogResult, AgentSessionOptionResult, @@ -7,6 +7,7 @@ import type { } from '../../../src/shared/agent-session-wire' import type { StructuredAgentSessionMutationResult } from './mobile-structured-agent-session-rpc' import { rememberMobileCreatedStructuredSession } from './mobile-created-structured-sessions' +import type { AcpThinkingOptionsFixtureModule } from '../../../src/shared/acp-thinking-options.test-fixture-contract' import { type Controller, settle, @@ -24,6 +25,14 @@ import { BASE } from './use-mobile-structured-agent-options.test-fixture' +let recordedOmpOptions: AcpThinkingOptionsFixtureModule['recordedOmpOptions'] +beforeAll(async () => { + const fixtures = await vi.importActual( + '../../../src/main/acp/acp-thinking-options.test-fixture' + ) + recordedOmpOptions = fixtures.recordedOmpOptions +}) + describe('useMobileStructuredAgentOptions fast mode', () => { it('round-trips a boolean fastMode pick as the wire string and remembers the decoded pick', async () => { const client = optionsClient(queuedReads(FAST_OPTIONS, FAST_OPTIONS_ON)) @@ -121,6 +130,49 @@ describe('useMobileStructuredAgentOptions post-write refresh', () => { }) describe('useMobileStructuredAgentOptions reads', () => { + it('offers unknown OMP suggestions for a saved model without choosing effort, then sends the literal pick', async () => { + const { models } = recordedOmpOptions('loaded').read() + const answer: AgentSessionOptionsResult = { + models, + current: { model: 'target', defaulted: [], acceptedInputSources: { model: 'saved' } } + } + const client = optionsClient(queuedReads(answer), async () => ({ + origin: 'probe', + fetchedAt: 1, + models + })) + const { calls, mutate } = recordingMutate(async () => + accepted({ key: 'effort', value: 'auto', options: { model: 'target', effort: 'auto' } }, true) + ) + const harness = await mountOptions({ + ...BASE, + agent: 'omp', + client: client.client, + mutate, + sendSelection: true + }) + const effort = descriptorFor(harness.current().optionSnapshot, 'effort') + expect(effort).toMatchObject({ + settable: true, + kind: { + type: 'select', + choices: [ + { value: 'off', label: 'Off' }, + expect.objectContaining({ value: 'auto', label: 'Auto' }) + ] + } + }) + expect(currentValueOf(harness.current().optionSnapshot, 'effort')).toBeUndefined() + expect(harness.current().sendSelection()).toEqual({ selection: { model: 'target' } }) + await act(async () => { + expect(await harness.current().setStructuredOption('effort', 'auto')).toBe(true) + }) + expect(calls).toEqual([ + { method: 'agentSession.setOption', fields: { key: 'effort', value: 'auto' } } + ]) + await harness.unmount() + }) + it('re-reads the options each turn, as desktop does', async () => { const client = optionsClient(queuedReads(OPTIONS)) const { mutate } = recordingMutate(async () => ({ status: 'unknown' })) diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 0af70062ba4..496f7d70caa 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -107,6 +107,8 @@ export type AcpDialect = { ): { sessionId: string; promptId: string; stopReason: string } | undefined contextWindow?(models: unknown): number | undefined modelEfforts?(model: ModelInfo): { efforts: AgentSessionOptionChoice[]; defaultEffort?: string } + /** Native thinking choices remain valid across models; the agent clamps unsupported levels. */ + 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 5ea94fc6cfd..9b4ddb2b142 100644 --- a/src/main/acp/acp-structured-options.test.ts +++ b/src/main/acp/acp-structured-options.test.ts @@ -7,6 +7,15 @@ 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' import type { AcpCommandOptionsFixtureModule } from '../../shared/mobile-queued-edit-host.test-fixture-contract' +import { recordedOmpOptions } from './acp-thinking-options.test-fixture' +import { SessionConfigSelectOptionSchema } from './generated/acp-protocol.generated' +import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { agentModelCatalogListingKey } from '../native-chat/agent-model-catalog/agent-model-catalog-entry' +import { OMP_SESSION_OPTION_CATALOG } from '../../shared/agent-session-option-catalog-omp' +import { + applyStructuredAgentSessionModelCatalog, + createStructuredAgentSessionOptionState +} from '../../shared/structured-agent-session-options' const mobileProducer = { AcpStructuredOptions } satisfies AcpCommandOptionsFixtureModule @@ -108,6 +117,129 @@ describe('ACP session options', () => { } ) + it.each([10, 100])( + 'bounds native OMP menu work across %i models, repeated reports and an update', + async (count) => { + const options = recordedOmpOptions('loaded', count) + const read = vi.spyOn(options, 'read') + const extract = vi.spyOn(SessionConfigSelectOptionSchema, 'safeParse') + const session = { options, connection: { initialize: async () => ({ protocolVersion: 1 }) } } + const store = new AgentModelCatalogStore() + let previousKey: string | undefined + let visits = 0 + try { + for (const frame of ['initial', 'repeat', 'update']) { + if (frame === 'update') { + // A synthetic native update exercises adoption; the initial menu remains recorded output. + options.adoptConfigOptions([ + { + id: 'thinking', + name: 'Thinking', + category: 'thought_level', + type: 'select', + currentValue: 'auto', + options: [ + { value: 'auto', name: 'Auto' }, + { value: 'high', name: 'High' } + ] + } + ]) + } + const expected = + frame === 'update' + ? [ + { value: 'auto', label: 'Auto' }, + { value: 'high', label: 'High' } + ] + : [ + { value: 'off', label: 'Off' }, + { value: 'auto', label: 'Auto', description: 'Auto-detect per prompt' } + ] + read.mockClear() + extract.mockClear() + const result = await readAcpLiveOptions(session) + expect(read).toHaveBeenCalledTimes(1) + expect(extract).toHaveBeenCalledTimes(2) + expect(result.models).toHaveLength(count) + expect(result.models[0]!.efforts).toEqual(expected) + expect(result.catalogListing.models).toBe(result.models) + for (const model of result.models) { + expect(model.efforts).toBe(result.models[0]!.efforts) + expect(model.effortsKnown).toBe(model.id === 'running' ? undefined : false) + expect(model.defaultEffort).toBeUndefined() + } + for (const choice of result.models[0]!.efforts) { + const value = choice.value + Object.defineProperty(choice, 'value', { + enumerable: true, + get: () => { + visits++ + return value + } + }) + } + visits = 0 + const entry = store.recordSuccess( + 'omp-scaling', + 'omp', + { ...result.catalogListing, origin: 'live-session' }, + 'live' + )! + // A repeat/update compares old and new source keys; each contains M*E values. + const publicationVisits = visits + expect(publicationVisits).toBeLessThanOrEqual(count * 2 * 2) + expect(entry.models).toHaveLength(count) + expect(entry.live?.models).toEqual(result.models) + visits = 0 + const key = agentModelCatalogListingKey(entry) + expect(visits).toBe(count * 2) + expect(key.match(/"value":/g)).toHaveLength(count * 2) + const emptyKey = agentModelCatalogListingKey({ + ...entry, + live: entry.live + ? { + ...entry.live, + models: entry.live.models.map((model) => ({ ...model, efforts: [] })) + } + : null + }) + expect(key.length - emptyKey.length).toBe(count * (JSON.stringify(expected).length - 2)) + if (frame === 'repeat') { + expect(key).toBe(previousKey) + } + if (frame === 'update') { + expect(key).not.toBe(previousKey) + } + previousKey = key + visits = 0 + const state = applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState('omp'), + OMP_SESSION_OPTION_CATALOG, + { origin: 'probe', fetchedAt: 1, models: entry.models }, + { newLaunch: false } + ) + const installationVisits = visits + expect(installationVisits).toBe(count) + const descriptors = state.catalog!.models.flatMap((model) => + model.options.filter((option) => option.id === 'effort') + ) + expect(descriptors).toHaveLength(count) + let choices = 0 + for (const descriptor of descriptors) { + expect(descriptor.kind).toMatchObject({ type: 'select', choices: expected }) + expect(descriptor.kind).not.toHaveProperty('defaultIsCliDefault') + if (descriptor.kind.type === 'select') { + choices += descriptor.kind.choices.length + } + } + expect(choices).toBe(count * 2) + } + } finally { + extract.mockRestore() + } + } + ) + it.each([OPENCODE_ACP_DIALECT, OMP_ACP_DIALECT])( 'keeps native menus and observations separate from applied inputs', async (dialect) => { @@ -119,7 +251,19 @@ describe('ACP session options', () => { const { models, current } = options.read() // Current values supply an effort menu, never a catalog default. expect(models).toEqual([ - { id: 'model-a', label: 'Model A', isDefault: false, efforts: [], effortsKnown: false }, + { + id: 'model-a', + label: 'Model A', + isDefault: false, + efforts: + dialect === OMP_ACP_DIALECT + ? [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + : [], + effortsKnown: false + }, { id: 'model-b', label: 'Model B', diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts index 172314ca7b4..9676f9181b4 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -51,7 +51,7 @@ export class AcpStructuredOptions { constructor( private readonly dialect: Pick< AcpDialect, - 'modelEfforts' | 'withheldCommands' | 'effortChangesModel' + 'modelEfforts' | 'withheldCommands' | 'effortChangesModel' | 'clampsUnlistedEffort' > = {} ) {} @@ -136,7 +136,7 @@ export class AcpStructuredOptions { return null } - /** Effort menus describe models; current session values never establish catalog defaults. */ + /** Native choices never establish an unobserved model's capability or configured default. */ read(): Pick { const modelOption = this.select('model') const effortOption = this.select('thought_level') @@ -167,7 +167,10 @@ export class AcpStructuredOptions { model.id === currentModel ? { efforts: sessionEfforts } : (modelOption || effortOption) && !advertised - ? { efforts: [], effortsKnown: false as const } + ? { + efforts: this.dialect.clampsUnlistedEffort ? sessionEfforts : [], + effortsKnown: false as const + } : { efforts: [] } return { ...model, diff --git a/src/main/acp/acp-thinking-options.test-fixture.ts b/src/main/acp/acp-thinking-options.test-fixture.ts new file mode 100644 index 00000000000..981736912aa --- /dev/null +++ b/src/main/acp/acp-thinking-options.test-fixture.ts @@ -0,0 +1,28 @@ +import type { AcpThinkingOptionsFixtureModule } from '../../shared/acp-thinking-options.test-fixture-contract' +import { AcpStructuredOptions } from './acp-structured-options' +import { OMP_ACP_DIALECT } from './acp-dialects/omp-dialect' +import { NewSessionResponseSchema } from './generated/acp-protocol.generated' +import recorded from './fixtures/omp-v17-windows-new-no-model.json' + +export function recordedOmpOptions(origin: 'new' | 'loaded', count = 2): AcpStructuredOptions { + const options = new AcpStructuredOptions(OMP_ACP_DIALECT) + // The recorded native menu has no model list; these rows are synthetic catalog context. + options.adoptSession( + { + ...NewSessionResponseSchema.parse(recorded), + models: { + currentModelId: 'running', + availableModels: Array.from({ length: count }, (_, index) => ({ + modelId: index === 0 ? 'running' : index === 1 ? 'target' : `model-${index}`, + name: index === 0 ? 'Running' : index === 1 ? 'Target' : `Model ${index}` + })) + } + }, + origin + ) + return options +} + +export const thinkingOptionsProducer = { + recordedOmpOptions +} satisfies AcpThinkingOptionsFixtureModule 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 d194ae27741..9312deed011 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 @@ -40,11 +40,12 @@ describe('the effort menu of a model whose configured effort one listing lacks', }) }) -const unknownChat = (at: number): AgentModelCatalogListing => ({ - ...listing('live-session', at, []), - models: [ - { id: 'openai/gpt-6', label: 'GPT-6', isDefault: false, efforts: [], effortsKnown: false } - ] +const unknownChat = ( + at: number, + efforts: ReturnType = [] +): AgentModelCatalogListing => ({ + ...listing('live-session', at, efforts), + 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', () => { @@ -54,11 +55,31 @@ describe('a model whose newer chat listing does not know its efforts', () => { 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 }) }) + + it('prefers known probe evidence over an older unknown chat offering the configured effort', () => { + const merged = model( + listing('probe', 2, menu('low', 'high')), + unknownChat(1, menu('off', 'auto', 'xhigh')) + ) + expect(merged?.efforts).toEqual(menu('low', 'high')) + expect(merged).not.toHaveProperty('effortsKnown') + expect(merged).not.toHaveProperty('defaultEffort') + }) + + it('keeps native suggestions unknown without a known model menu', () => { + 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-entry.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts index 00b40936928..e8f805eb1ee 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 @@ -149,17 +149,22 @@ function mergedModels( // 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 = model.efforts.length > 0 && !keepsChatMenu ? model.efforts : olderEfforts + const { efforts, effortsKnown } = + olderModel && model.effortsKnown !== olderModel.effortsKnown + ? model.effortsKnown === false + ? olderModel + : model + : model.efforts.length > 0 && !keepsChatMenu + ? model + : (olderModel ?? model) const defaultEffort = [configuredEffort, listed?.defaultEffort, reported?.defaultEffort].find( (effort) => effort !== undefined && efforts.some((choice) => choice.value === effort) ) - const { defaultEffort: _own, effortsKnown, ...rest } = model + const { defaultEffort: _own, effortsKnown: _known, ...rest } = model return { ...rest, // Efforts stay unknown only when neither listing knows this model's menu. - ...(effortsKnown === false && (!olderModel || olderModel.effortsKnown === false) - ? { effortsKnown } - : {}), + ...(effortsKnown === false ? { effortsKnown } : {}), // Without discovery/configuration, live flags are the only available default evidence. isDefault: configured ? model.id === configured.modelId diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-omp-menu-persistence.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-omp-menu-persistence.test.ts new file mode 100644 index 00000000000..d97aa8f1923 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-omp-menu-persistence.test.ts @@ -0,0 +1,134 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { it, expect } from 'vitest' +import { OMP_SESSION_OPTION_CATALOG } from '../../../shared/agent-session-option-catalog-omp' +import { + applyStructuredAgentSessionOptions, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../../shared/structured-agent-session-options' +import { recordedOmpOptions } from '../../acp/acp-thinking-options.test-fixture' +import { + AgentModelCatalogStore, + type AgentModelCatalogSource +} from '../agent-model-catalog/agent-model-catalog-store' +import { createAgentModelCatalogFilePersistence } from '../agent-model-catalog/agent-model-catalog-persistence' +import { createAgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' +import { agentModelCatalogFingerprintForRecord } from '../agent-model-catalog/agent-model-catalog-fingerprint' +import { + hostTestRestingOptionsRecord as restingRecord, + hostTestRestingOptionsJournal as restingJournal, + hostTestOptionsReadStore +} from './structured-agent-session-host-test-data' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { readStructuredAgentSessionOptions } from './structured-agent-session-options-read' + +const SESSION = 'session-1' + +it.each( + (['discovery', 'live'] as const).flatMap((source) => [ + { source, knownFirst: undefined, efforts: undefined }, + ...[true, false].flatMap((knownFirst) => + [ + [], + [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + ].map((efforts) => ({ source, knownFirst, efforts })) + ) + ]) +)( + 'reloads $source OMP evidence (knownFirst=$knownFirst, menu=$efforts) through the resting picker', + async ({ source, knownFirst, efforts }) => { + const directory = await mkdtemp(join(tmpdir(), 'pr36158-menu-')) + const expected = efforts ?? [ + { value: 'off', label: 'Off' }, + { value: 'auto', label: 'Auto', description: 'Auto-detect per prompt' } + ] + try { + let at = 1 + const store = new AgentModelCatalogStore({ now: () => at++ }) + await store.attachPersistence(createAgentModelCatalogFilePersistence(directory)) + const record = { ...restingRecord(), provider: 'omp', options: { model: 'target' } } + const fingerprint = agentModelCatalogFingerprintForRecord(record) + const { models } = recordedOmpOptions('loaded').read() + const known = models.map((model) => + model.id === 'target' + ? { id: model.id, label: model.label, isDefault: false, efforts: expected } + : model + ) + const other = source === 'live' ? 'discovery' : 'live' + const order: AgentModelCatalogSource[] = + knownFirst === undefined ? [source] : knownFirst ? [source, other] : [other, source] + for (const currentSource of order) { + store.recordSuccess( + fingerprint, + 'omp', + { + origin: currentSource === 'live' ? 'live-session' : 'probe', + models: knownFirst !== undefined && currentSource === source ? known : models + }, + currentSource + ) + } + await store.flushPersistence() + const restarted = new AgentModelCatalogStore() + await restarted.attachPersistence(createAgentModelCatalogFilePersistence(directory)) + const modelCatalog = createAgentModelCatalogService({ + store: restarted, + ...hostTestOptionsReadStore(() => record), + drivesRecord: () => true, + resolveAccountHome: async () => record.accountHome + }) + const resting = { child: null, params: { provider: 'omp' }, journal: restingJournal() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read uses only these context members, as in the other options-read fixtures. + const context = { + deps: { + adapter: {}, + agents: NO_STRUCTURED_AGENTS, + store: hostTestOptionsReadStore(() => record), + modelCatalog + }, + serialize: (_id: string, task: () => Promise) => task(), + openConversation: async () => resting, + conversation: async () => resting + } as unknown as StructuredAgentSessionMutationContext + try { + const answer = await readStructuredAgentSessionOptions(context, SESSION, { + readsWithoutModel: true + }) + const target = answer.models.find((model) => model.id === 'target') + expect(target?.efforts).toEqual(expected) + if (knownFirst === undefined) { + expect(target?.effortsKnown).toBe(false) + } else { + expect(target).not.toHaveProperty('effortsKnown') + } + expect(target).not.toHaveProperty('defaultEffort') + expect(answer.current.effort).toBeUndefined() + const state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('omp'), + OMP_SESSION_OPTION_CATALOG, + answer + ) + const effort = structuredAgentSessionOptionSnapshot(state).find( + (entry) => entry.id === 'effort' + ) + if (expected.length === 0) { + expect(effort).toBeUndefined() + } else { + expect(effort).toMatchObject({ kind: { type: 'select', choices: expected } }) + expect(effort?.kind).not.toHaveProperty('currentValue') + expect(effort?.kind).not.toHaveProperty('defaultIsCliDefault') + } + } finally { + modelCatalog.stop() + } + } finally { + await rm(directory, { recursive: true, force: true }) + } + } +) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx index 9959db6c698..8c455dfe933 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -2,16 +2,29 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { useState } from 'react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { OMP_SESSION_OPTION_CATALOG } from '../../../../shared/agent-session-option-catalog-omp' +import type { AcpThinkingOptionsFixtureModule } from '../../../../shared/acp-thinking-options.test-fixture-contract' +import { structuredAgentSessionOptionView } from '../../../../shared/structured-agent-session-option-view' import { + applyStructuredAgentSessionModelCatalog, applyStructuredAgentSessionOptions, + canSetStructuredAgentSessionOption, createStructuredAgentSessionOptionState, + structuredAgentSessionOptionPicks, structuredAgentSessionOptionSnapshot } from '../../../../shared/structured-agent-session-options' import type { SessionOptionDescriptor } from '../../../../shared/native-chat/input/native-chat-session-options' import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' +let recordedOmpOptions: AcpThinkingOptionsFixtureModule['recordedOmpOptions'] +beforeAll(async () => { + const fixtures = await vi.importActual( + '../../../../main/acp/acp-thinking-options.test-fixture' + ) + recordedOmpOptions = fixtures.recordedOmpOptions +}) + const toastError = vi.hoisted(() => vi.fn()) vi.mock('sonner', () => ({ toast: { error: toastError } })) @@ -561,6 +574,59 @@ describe('NativeChatSessionOptionPickers', () => { expect(screen.queryByText('Default')).toBeNull() }) + it.each(['new', 'loaded'] as const)( + 'offers recorded OMP thinking choices for a non-running model in a %s chat without picking a default', + async (origin) => { + const projected = recordedOmpOptions(origin).read() + expect(projected.models[1]).toMatchObject({ effortsKnown: false }) + expect(projected.models[1]).not.toHaveProperty('defaultEffort') + const state = applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState('omp'), + OMP_SESSION_OPTION_CATALOG, + { origin: 'probe', fetchedAt: 1, models: projected.models }, + { newLaunch: origin === 'new' } + ) + const view = structuredAgentSessionOptionView(state, undefined, { model: 'target' }) + const snapshot = structuredAgentSessionOptionSnapshot(view) + expect(snapshot.find((entry) => entry.id === 'effort')).toMatchObject({ + valueSource: 'unknown', + settable: true, + kind: { + type: 'select', + choices: [ + { value: 'off', label: 'Off' }, + expect.objectContaining({ value: 'auto', label: 'Auto' }) + ] + } + }) + expect(snapshot.find((entry) => entry.id === 'effort')?.kind).not.toHaveProperty( + 'currentValue' + ) + expect(view.record.values.effort).toBeUndefined() + expect(structuredAgentSessionOptionPicks(view, { model: 'target' })).toEqual([ + { modelId: 'target', optionId: 'model', value: 'target' } + ]) + const remembered = structuredAgentSessionOptionView(state, undefined, { + model: 'target', + effort: 'high' + }) + expect(canSetStructuredAgentSessionOption(remembered, 'effort', 'high')).toBe(true) + + const setOption = vi.fn().mockResolvedValue({ snapshot: [] }) + render( + + ) + fireEvent.click(pill('Model Target')) + expect(setOption).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('radio', { name: 'Auto' })) + await waitFor(() => expect(setOption).toHaveBeenCalledExactlyOnceWith('effort', 'auto')) + } + ) + it('makes the effort levels one tab stop, moves between them with the arrow keys, and sends the one picked', async () => { const setOption = vi.fn().mockResolvedValue({ snapshot: [] }) render( diff --git a/src/shared/acp-thinking-options.test-fixture-contract.ts b/src/shared/acp-thinking-options.test-fixture-contract.ts new file mode 100644 index 00000000000..e2efeb32d7c --- /dev/null +++ b/src/shared/acp-thinking-options.test-fixture-contract.ts @@ -0,0 +1,10 @@ +import type { AgentSessionOptionsResult } from './agent-session-wire' + +export type AcpThinkingOptionsFixtureModule = { + recordedOmpOptions: ( + origin: 'new' | 'loaded', + count?: number + ) => { + read: () => Pick + } +}