mirror of
https://github.com/stablyai/orca.git
synced 2026-10-06 08:02:28 +00:00
* feat(native-chat): track Claude models from the installed CLI per host (STA-3330) The Claude seed no longer pins version labels to aliases that resolve differently across CLI versions, and the catalog now defines listModels backed by a one-shot list_models control request over --print stream-json. Hosts whose CLI predates the request answer with a control error and keep the seed. Discovery also feeds Source Control AI via the commit-message spec, and the /model echo detector matches resolved model names. * fix(native-chat): preserve discovered Claude capabilities * fix(native-chat): tolerate malformed Claude model entries * fix(native-chat): discover models in folder workspaces * fix(native-chat): trust discovered Claude capabilities * fix(native-chat): remove Claude model fallbacks * fix(native-chat): keep the Claude model picker rendered The Claude picker rendered nothing until the per-host `list_models` probe returned, so it popped in ~1s after mount and never appeared at all when the probe failed — an old CLI without `list_models`, no `claude` on PATH, or an older remote runtime whose response omits `catalogOrigin`. Restore the version-neutral family seed as the starting list; discovery still replaces it wholesale on success, so a host with a real catalog never shows an obsolete hardcoded row. Separately, the tracked model could fall outside the active list: the terminal header scrape yields family ids (`opus`) while a current CLI lists `opus[1m]` and no plain `opus`. That blanked the picker trigger and dropped the model's effort and fast-mode controls. Reconcile the tracked id into the active list once, so the snapshot, the appliers, and typed command recording all see a labelled, operable row for it.
237 lines
8.9 KiB
TypeScript
237 lines
8.9 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { getAgentSessionOptionCatalog, mergeCatalogModels } from './agent-session-option-catalog'
|
|
import { resolveAgentSessionOptionLaunch } from './agent-session-option-launch'
|
|
import {
|
|
resolveNativeChatSessionOptionDefaults,
|
|
updateNativeChatSessionOptionDefaults
|
|
} from './native-chat-session-option-defaults'
|
|
|
|
describe('agent session option catalog', () => {
|
|
it('returns no catalog for unknown agents', () => {
|
|
expect(getAgentSessionOptionCatalog('future-agent')).toBeNull()
|
|
})
|
|
|
|
it('keeps Claude option sets model-scoped', () => {
|
|
const catalog = getAgentSessionOptionCatalog('claude')
|
|
expect(
|
|
catalog?.models.find((model) => model.id === 'opus')?.options.map(({ id }) => id)
|
|
).toEqual(['effort', 'fastMode'])
|
|
expect(catalog?.models.find((model) => model.id === 'haiku')?.options).toEqual([])
|
|
})
|
|
|
|
it('merges discovered labels while preserving cataloged option shapes', () => {
|
|
const seed = getAgentSessionOptionCatalog('cursor')!.models
|
|
const merged = mergeCatalogModels(seed, [
|
|
{ id: 'gpt-5.3-codex', label: 'GPT 5.3 (live)', options: [] },
|
|
{ id: 'new-account-model', label: 'new-account-model', options: [] }
|
|
])
|
|
expect(merged.find((model) => model.id === 'gpt-5.3-codex')).toMatchObject({
|
|
label: 'GPT 5.3 (live)',
|
|
options: expect.arrayContaining([expect.objectContaining({ id: 'effort' })])
|
|
})
|
|
expect(merged.at(-1)).toEqual({
|
|
id: 'new-account-model',
|
|
label: 'new-account-model',
|
|
options: []
|
|
})
|
|
})
|
|
|
|
it('labels Claude seed models by alias family so no host is mislabeled', () => {
|
|
const catalog = getAgentSessionOptionCatalog('claude')!
|
|
expect(catalog.models.map(({ id, label }) => ({ id, label }))).toEqual([
|
|
{ id: 'fable', label: 'Fable' },
|
|
{ id: 'opus', label: 'Opus' },
|
|
{ id: 'sonnet', label: 'Sonnet' },
|
|
{ id: 'haiku', label: 'Haiku' }
|
|
])
|
|
expect(catalog.models.find((model) => model.isDefault)?.id).toBe('sonnet')
|
|
})
|
|
|
|
it('parses Claude list_models discovery into catalog models with options', () => {
|
|
const stdout = JSON.stringify({
|
|
type: 'control_response',
|
|
response: {
|
|
subtype: 'success',
|
|
response: {
|
|
models: [
|
|
{
|
|
value: 'default',
|
|
displayName: 'Default (recommended)',
|
|
supportsEffort: true,
|
|
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
|
|
supportsFastMode: true
|
|
},
|
|
{
|
|
value: 'opus[1m]',
|
|
displayName: 'Opus (1M context)',
|
|
description: 'Opus 5 with 1M context',
|
|
supportsEffort: true,
|
|
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
|
|
supportsFastMode: true
|
|
},
|
|
{
|
|
value: 'sonnet',
|
|
displayName: 'Sonnet',
|
|
supportsEffort: true,
|
|
supportedEffortLevels: ['low', 'medium', 'high']
|
|
},
|
|
{ value: 'haiku', displayName: 'Haiku' }
|
|
]
|
|
}
|
|
}
|
|
})
|
|
const parsed = getAgentSessionOptionCatalog('claude')!.listModels!.parse(stdout)
|
|
expect(parsed.map(({ id }) => id)).toEqual(['opus[1m]', 'sonnet', 'haiku'])
|
|
expect(parsed[0]).toMatchObject({
|
|
label: 'Opus (1M context)',
|
|
description: 'Opus 5 with 1M context'
|
|
})
|
|
expect(parsed[0].options.map(({ id }) => id)).toEqual(['effort', 'fastMode'])
|
|
const opusEffort = parsed[0].options[0]
|
|
expect(opusEffort.kind).toMatchObject({ defaultValue: 'high' })
|
|
expect(
|
|
opusEffort.kind.type === 'select' ? opusEffort.kind.choices.map((c) => c.value) : []
|
|
).toEqual(['low', 'medium', 'high', 'xhigh', 'max'])
|
|
const sonnetEffort = parsed[1].options[0]
|
|
expect(
|
|
sonnetEffort.kind.type === 'select' ? sonnetEffort.kind.choices.map((c) => c.value) : []
|
|
).toEqual(['low', 'medium', 'high'])
|
|
expect(parsed[2].options).toEqual([])
|
|
})
|
|
|
|
it('keeps the Claude seed when list_models output is unsupported or malformed', () => {
|
|
const parse = getAgentSessionOptionCatalog('claude')!.listModels!.parse
|
|
const unsupported =
|
|
'{"type":"control_response","response":{"subtype":"error","request_id":"x","error":"Unsupported control request subtype: list_models"}}'
|
|
expect(parse(unsupported)).toEqual([])
|
|
expect(parse('')).toEqual([])
|
|
expect(parse('garbage')).toEqual([])
|
|
})
|
|
|
|
it('merges discovered Claude variants after the seed and overlays matched labels', () => {
|
|
const catalog = getAgentSessionOptionCatalog('claude')!
|
|
const merged = mergeCatalogModels(catalog.models, [
|
|
{ id: 'opus[1m]', label: 'Opus (1M context)', options: [] },
|
|
{ id: 'sonnet', label: 'Sonnet', description: 'Sonnet 5 · Efficient', options: [] }
|
|
])
|
|
expect(merged.map(({ id }) => id)).toEqual(['fable', 'opus', 'sonnet', 'haiku', 'opus[1m]'])
|
|
const sonnet = merged.find((model) => model.id === 'sonnet')!
|
|
expect(sonnet.description).toBe('Sonnet 5 · Efficient')
|
|
expect(sonnet.isDefault).toBe(true)
|
|
expect(sonnet.options.map(({ id }) => id)).toEqual(['effort'])
|
|
})
|
|
|
|
it('parses Cursor model discovery without treating headings as models', () => {
|
|
const parsed = getAgentSessionOptionCatalog('cursor')!.listModels!.parse(
|
|
'Available models:\n- auto (default)\n- gpt-5.3-codex\nmodels\n'
|
|
)
|
|
expect(parsed.map(({ id }) => id)).toEqual(['auto', 'gpt-5.3-codex'])
|
|
})
|
|
|
|
it('composes Cursor effort and fast mode into the supported slug form', () => {
|
|
const resolved = resolveAgentSessionOptionLaunch('cursor', {
|
|
model: 'gpt-5.3-codex',
|
|
effort: 'high',
|
|
fastMode: true
|
|
})
|
|
expect(resolved.args).toEqual(['--model', 'gpt-5.3-codex-high-fast'])
|
|
expect(resolved.appliedValues).toEqual({
|
|
model: 'gpt-5.3-codex',
|
|
effort: 'high',
|
|
fastMode: true
|
|
})
|
|
})
|
|
|
|
it('passes unknown model and option values through launch mappings', () => {
|
|
expect(
|
|
resolveAgentSessionOptionLaunch('claude', {
|
|
model: 'claude-future',
|
|
effort: 'future-effort'
|
|
})
|
|
).toEqual({ args: ['--model', 'claude-future'], appliedValues: { model: 'claude-future' } })
|
|
expect(
|
|
resolveAgentSessionOptionLaunch('claude', { model: 'opus', effort: 'future-effort' })
|
|
).toMatchObject({
|
|
args: ['--model', 'opus', '--effort', 'future-effort'],
|
|
appliedValues: { model: 'opus', effort: 'future-effort' }
|
|
})
|
|
})
|
|
|
|
it('resolves only stored values without leaking values across models', () => {
|
|
let persisted = updateNativeChatSessionOptionDefaults({
|
|
persisted: undefined,
|
|
agent: 'claude',
|
|
modelId: 'opus',
|
|
optionId: 'model',
|
|
value: 'opus'
|
|
})
|
|
persisted = updateNativeChatSessionOptionDefaults({
|
|
persisted,
|
|
agent: 'claude',
|
|
modelId: 'opus',
|
|
optionId: 'effort',
|
|
value: 'xhigh'
|
|
})
|
|
persisted = updateNativeChatSessionOptionDefaults({
|
|
persisted,
|
|
agent: 'claude',
|
|
modelId: 'sonnet',
|
|
optionId: 'model',
|
|
value: 'sonnet'
|
|
})
|
|
|
|
expect(resolveNativeChatSessionOptionDefaults(persisted, 'claude')).toEqual({
|
|
model: 'sonnet'
|
|
})
|
|
expect(persisted.claude?.valuesByModel?.opus).toEqual({ effort: 'xhigh' })
|
|
})
|
|
|
|
it('spawns vanilla when the user has not explicitly selected a model', () => {
|
|
// Regression (#9085): a fresh launch must not force the catalog default
|
|
// model/effort — the agent must spawn exactly as its own CLI would.
|
|
expect(resolveNativeChatSessionOptionDefaults(undefined, 'claude')).toBeUndefined()
|
|
expect(resolveNativeChatSessionOptionDefaults({}, 'claude')).toBeUndefined()
|
|
expect(resolveNativeChatSessionOptionDefaults({}, 'future-agent')).toBeUndefined()
|
|
})
|
|
|
|
it('resolves an explicitly selected model and only its stored options', () => {
|
|
let persisted = updateNativeChatSessionOptionDefaults({
|
|
persisted: undefined,
|
|
agent: 'claude',
|
|
modelId: 'opus',
|
|
optionId: 'model',
|
|
value: 'opus'
|
|
})
|
|
expect(resolveNativeChatSessionOptionDefaults(persisted, 'claude')).toEqual({
|
|
model: 'opus'
|
|
})
|
|
persisted = updateNativeChatSessionOptionDefaults({
|
|
persisted,
|
|
agent: 'claude',
|
|
modelId: 'opus',
|
|
optionId: 'effort',
|
|
value: 'xhigh'
|
|
})
|
|
expect(resolveNativeChatSessionOptionDefaults(persisted, 'claude')).toEqual({
|
|
model: 'opus',
|
|
effort: 'xhigh'
|
|
})
|
|
})
|
|
|
|
it('keeps catalog option defaults after the user explicitly selects a model', () => {
|
|
const persisted = updateNativeChatSessionOptionDefaults({
|
|
persisted: undefined,
|
|
agent: 'claude',
|
|
modelId: 'sonnet',
|
|
optionId: 'model',
|
|
value: 'sonnet'
|
|
})
|
|
const defaults = resolveNativeChatSessionOptionDefaults(persisted, 'claude')
|
|
|
|
expect(resolveAgentSessionOptionLaunch('claude', defaults)).toEqual({
|
|
args: ['--model', 'sonnet', '--effort', 'high'],
|
|
appliedValues: { model: 'sonnet', effort: 'high' }
|
|
})
|
|
})
|
|
})
|