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