Files
orca/src/shared/native-chat-session-option-defaults.test.ts
T
Brennan Benson c8b8997762 feat(native-chat): offer Codex Ultrafast as a speed choice (#26962)
* feat(native-chat): offer Codex Ultrafast as a speed choice

Codex lists Ultrafast as a third service tier for eligible models and
accounts (GPT-6.1 Sol). Codex chats saved Fast as on/off, which cannot
hold a third tier. The Codex speed is now a Standard/Fast/Ultrafast
choice built from the tiers the model lists; the tier id is still
resolved from the catalog at each send. Older clients keep their Fast
toggle: the host still reports Fast on/off and accepts it as a speed.

* refactor(native-chat): save the Codex tier id instead of a speed name

The chat now keeps the tier id Codex lists (serviceTier: default,
priority, ultrafast) and sends it as-is on turn/start; Codex drops a
tier the model no longer lists with its own warning. This removes the
saved per-model tier map, the send-time lookup and the reconcile code.
The unused fastModeTierByModel catalog field is deleted. Older clients
still get Fast on/off, derived from the model's tier named Fast. Also
drops the pnpm-lock.yaml churn the first commit picked up.

* test(pi): drop the removed fastModeTierByModel catalog field

* fix(native-chat): review round 1 for Codex service tiers

- Accept an older client's fastMode write through the Codex adapter; it
  was refused before reaching the tier alias.
- Bump the saved model catalog schema so catalogs from before
  serviceTiers re-list instead of offering a Fast toggle with no tier.
- Keep only the reported tier on the record, not the Fast it implies.
- Drop a reported tier, not only a picked one, when the next model does
  not list it.
- Localize Standard inside the speed choice only.
- Return the options answer directly instead of a one-field wrapper.

* fix(native-chat): review round 2 for Codex service tiers

Keep a tier Codex reports for the thread (it accepts unlisted ones such
as flex); read the legacy Fast tier beside an empty serviceTiers list.

* fix(native-chat): review round 3 for Codex service tiers

A reported tier the old model listed and the new one does not resets to
Standard on a model switch, and the reported tier itself stays pickable.

* fix(native-chat): keep a re-picked reported Codex tier

The thread's reported tier is never reset by the unlisted-tier drop, so a
re-picked Flex survives the next read, a model switch, and the turn.

* refactor(native-chat): one rule for which Codex tier a model offers

Standard, a tier the model lists, or the thread's reported tier that no
model lists. The setter and the unlisted-tier reset share it, replacing
the separate reported-tier and model-switch guards.

* test(native-chat): drop the removed fastModeTierByModel field from main's new catalog test
2026-10-09 18:45:03 -07:00

188 lines
7.2 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
clearNativeChatSessionOptionModel,
narrowStructuredLaunchSeedOptions,
resolveNativeChatSessionOptionDefaults,
resolveStructuredLaunchSeedOptions,
updateNativeChatSessionOptionDefaults
} from './native-chat-session-option-defaults'
import type { PersistedNativeChatSessionOptions } from './native-chat-session-options'
const persistedGrok = (
model: string | undefined,
valuesByModel: Record<string, Record<string, string>> = {}
): PersistedNativeChatSessionOptions => ({
grok: { ...(model ? { model } : {}), valuesByModel }
})
describe('clearNativeChatSessionOptionModel', () => {
it('drops the model a retired id would otherwise launch as -m', () => {
const cleared = clearNativeChatSessionOptionModel(
persistedGrok('grok-build', { 'grok-build': { effort: 'low' } }),
'grok'
)
expect(cleared.grok?.model).toBeUndefined()
// Resolution keys off `model`, so clearing it is what stops the flag going out.
expect(resolveNativeChatSessionOptionDefaults(cleared, 'grok')).toBeUndefined()
})
it('keeps the per-model values so a reselect restores the old effort', () => {
const cleared = clearNativeChatSessionOptionModel(
persistedGrok('grok-build', { 'grok-build': { effort: 'low' } }),
'grok'
)
expect(cleared.grok?.valuesByModel).toEqual({ 'grok-build': { effort: 'low' } })
const reselected = updateNativeChatSessionOptionDefaults({
persisted: cleared,
agent: 'grok',
modelId: 'grok-build',
optionId: 'model',
value: 'grok-build'
})
expect(resolveNativeChatSessionOptionDefaults(reselected, 'grok')).toEqual({
model: 'grok-build',
effort: 'low'
})
})
it('leaves every other agent untouched', () => {
const cleared = clearNativeChatSessionOptionModel(
{ ...persistedGrok('grok-build'), claude: { model: 'opus', valuesByModel: {} } },
'grok'
)
expect(cleared.claude).toEqual({ model: 'opus', valuesByModel: {} })
})
it('is a no-op when nothing is persisted for the agent', () => {
expect(clearNativeChatSessionOptionModel(undefined, 'grok')).toEqual({})
expect(clearNativeChatSessionOptionModel({}, 'grok')).toEqual({})
const untouched = persistedGrok(undefined, { 'grok-4.5': { effort: 'high' } })
expect(clearNativeChatSessionOptionModel(untouched, 'grok')).toEqual(untouched)
})
})
describe('resolveNativeChatSessionOptionDefaults', () => {
it('emits nothing until a model is explicitly picked, preserving the CLI default', () => {
expect(resolveNativeChatSessionOptionDefaults(undefined, 'grok')).toBeUndefined()
expect(resolveNativeChatSessionOptionDefaults(persistedGrok(undefined), 'grok')).toBeUndefined()
expect(resolveNativeChatSessionOptionDefaults(persistedGrok(' '), 'grok')).toBeUndefined()
})
it('returns a stale id verbatim, which is why retirement happens upstream', () => {
// Nothing here validates the id against the host; a retired one still resolves
// and becomes `-m <id>`. Only clearing the persisted value prevents that.
expect(resolveNativeChatSessionOptionDefaults(persistedGrok('grok-build'), 'grok')).toEqual({
model: 'grok-build'
})
})
})
describe('resolveStructuredLaunchSeedOptions', () => {
const persistedCodex = (
valuesByModel: Record<string, Record<string, string | boolean>>
): PersistedNativeChatSessionOptions =>
({ codex: { model: 'gpt-5.6-sol', valuesByModel } }) as PersistedNativeChatSessionOptions
it('seeds the saved model and effort a structured create must apply', () => {
expect(
resolveStructuredLaunchSeedOptions(
persistedCodex({ 'gpt-5.6-sol': { effort: 'medium' } }),
'codex'
)
).toEqual({ model: 'gpt-5.6-sol', effort: 'medium' })
})
it('encodes Fast mode while dropping ids the providers only accept mid-session', () => {
expect(
resolveStructuredLaunchSeedOptions(
persistedCodex({
'gpt-5.6-sol': { effort: 'high', fastMode: true, personality: 'concise' }
}),
'codex'
)
).toEqual({ model: 'gpt-5.6-sol', effort: 'high', fastMode: 'true' })
})
it('seeds a saved service tier', () => {
expect(
resolveStructuredLaunchSeedOptions(
persistedCodex({ 'gpt-5.6-sol': { serviceTier: 'ultrafast' } }),
'codex'
)
).toEqual({ model: 'gpt-5.6-sol', serviceTier: 'ultrafast' })
})
it('drops a seeded id whose persisted value is not a usable string', () => {
// settings.json is user-writable, so a non-string `effort` must not reach a
// record typed Record<string, string> and be emitted as a turn option.
expect(
resolveStructuredLaunchSeedOptions(
persistedCodex({ 'gpt-5.6-sol': { effort: true } }),
'codex'
)
).toEqual({ model: 'gpt-5.6-sol' })
expect(
resolveStructuredLaunchSeedOptions(
persistedCodex({ 'gpt-5.6-sol': { effort: ' ' } }),
'codex'
)
).toEqual({ model: 'gpt-5.6-sol' })
})
it('seeds nothing when the stored values empty the model out', () => {
// `valuesByModel` is merged over the resolved model, so a stored `model` key
// can blank it. Emitting `{ model: '' }` fails the record's bounded-string
// guard, and that throw is not a wire refusal code — it escapes as a raw
// error the client reads as unknown, stranding the launch with no fallback.
expect(
resolveStructuredLaunchSeedOptions(persistedCodex({ 'gpt-5.6-sol': { model: '' } }), 'codex')
).toBeUndefined()
})
it('seeds nothing until a model is picked, so the CLI default survives', () => {
expect(resolveStructuredLaunchSeedOptions(undefined, 'codex')).toBeUndefined()
expect(
resolveStructuredLaunchSeedOptions({ codex: { valuesByModel: {} } }, 'codex')
).toBeUndefined()
})
})
describe('narrowStructuredLaunchSeedOptions', () => {
it('keeps the seedable ids an explicit selection names', () => {
expect(
narrowStructuredLaunchSeedOptions({ model: 'opus', effort: 'high', fastMode: false })
).toEqual({
model: 'opus',
effort: 'high',
fastMode: 'false'
})
})
// A never-picked Fast preference must stay absent, not default on: seeding `true`
// would silently move a user onto the faster tier they never chose.
it('seeds no fastMode at all when the user never picked one', () => {
expect(narrowStructuredLaunchSeedOptions({ model: 'opus', effort: 'high' })).toEqual({
model: 'opus',
effort: 'high'
})
})
it('drops ids no structured create may seed', () => {
expect(narrowStructuredLaunchSeedOptions({ model: 'opus', mode: 'plan' })).toEqual({
model: 'opus'
})
})
it.each([
['nothing at all', {}],
['whitespace only', { model: ' ', effort: '\t' }],
['no usable string', { model: 5, effort: null }],
['undefined', undefined]
])('resolves %s to undefined rather than {}', (_name, values) => {
// An empty map fails the durable record's bounded-string guard, and
// `agent_session_options_invalid` is not a wire refusal code: the throw escapes as a raw
// error the client reads as unknown, stranding the launch with no fallback.
expect(narrowStructuredLaunchSeedOptions(values as never)).toBeUndefined()
})
})