mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 02:27:43 +00:00
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
This commit is contained in:
@@ -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<AcpThinkingOptionsFixtureModule>(
|
||||
'../../../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' }))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<AgentSessionOptionsResult, 'models' | 'current'> {
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
@@ -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<typeof menu> = []
|
||||
): 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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
+134
@@ -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<unknown>) => 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 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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<AcpThinkingOptionsFixtureModule>(
|
||||
'../../../../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(
|
||||
<NativeChatSessionOptionPickers
|
||||
surface={{ ...surface, setOption }}
|
||||
snapshot={snapshot}
|
||||
isWorking={false}
|
||||
/>
|
||||
)
|
||||
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(
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { AgentSessionOptionsResult } from './agent-session-wire'
|
||||
|
||||
export type AcpThinkingOptionsFixtureModule = {
|
||||
recordedOmpOptions: (
|
||||
origin: 'new' | 'loaded',
|
||||
count?: number
|
||||
) => {
|
||||
read: () => Pick<AgentSessionOptionsResult, 'models' | 'current'>
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user