mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(codex): support dynamic reasoning efforts (#1012)
* fix(codex): support model-reported reasoning efforts * fix(web): prevent service worker edge caching * ci: retrigger stuck Actions run * fix(codex): accept dynamic reasoning effort values * fix(web): restore reasoning effort on model switch failure
This commit is contained in:
@@ -153,7 +153,9 @@ export type SandboxPolicy =
|
||||
excludeSlashTmp?: boolean;
|
||||
};
|
||||
|
||||
export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
// The app server reports supported effort identifiers per model. Keep this
|
||||
// open so newly introduced server values can flow through without a CLI update.
|
||||
export type ReasoningEffort = string;
|
||||
export type ReasoningSummary = 'auto' | 'none' | 'brief' | 'detailed';
|
||||
|
||||
export type CollaborationMode = {
|
||||
|
||||
@@ -111,6 +111,7 @@ vi.mock('./utils/codexCliOverrides', () => ({
|
||||
}))
|
||||
|
||||
import { runCodex as runCodexImpl } from './runCodex'
|
||||
import { RPC_METHODS } from '@hapi/protocol/rpcMethods'
|
||||
|
||||
describe('runCodex', () => {
|
||||
beforeEach(() => {
|
||||
@@ -242,4 +243,20 @@ describe('runCodex', () => {
|
||||
replayTranscriptHistoryOnStart: true
|
||||
}))
|
||||
})
|
||||
|
||||
it('accepts and normalizes model-reported reasoning efforts from session config', async () => {
|
||||
await runCodexImpl({ workingDirectory: '/tmp/project' })
|
||||
|
||||
const registration = harness.session.rpcHandlerManager.registerHandler.mock.calls.find(
|
||||
([method]) => method === RPC_METHODS.SetSessionConfig
|
||||
)
|
||||
const handler = registration?.[1] as ((payload: unknown) => Promise<unknown>) | undefined
|
||||
expect(handler).toBeTypeOf('function')
|
||||
|
||||
await handler?.({ modelReasoningEffort: 'max' })
|
||||
await handler?.({ modelReasoningEffort: ' EXTREME ' })
|
||||
|
||||
expect(mockCodexSession.setModelReasoningEffort).toHaveBeenNthCalledWith(2, 'max')
|
||||
expect(mockCodexSession.setModelReasoningEffort).toHaveBeenNthCalledWith(3, 'extreme')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,11 +19,10 @@ import type { ReasoningEffort } from './appServerTypes';
|
||||
import { parseCodexSpecialCommand } from './codexSpecialCommands';
|
||||
import { listSlashCommands } from '@/modules/common/slashCommands';
|
||||
import { resolveCodexSlashCommand } from './utils/slashCommands';
|
||||
import { parseReasoningEffortValue } from './utils/reasoningEffort';
|
||||
|
||||
export { emitReadyIfIdle } from './utils/emitReadyIfIdle';
|
||||
|
||||
const REASONING_EFFORTS = new Set<ReasoningEffort>(['none', 'minimal', 'low', 'medium', 'high', 'xhigh'])
|
||||
|
||||
export async function runCodex(opts: {
|
||||
startedBy?: 'runner' | 'terminal';
|
||||
codexArgs?: string[];
|
||||
@@ -304,16 +303,6 @@ export async function runCodex(opts: {
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
const resolveModelReasoningEffort = (value: unknown): ReasoningEffort | undefined => {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'string' || !REASONING_EFFORTS.has(value as ReasoningEffort)) {
|
||||
throw new Error('Invalid model reasoning effort');
|
||||
}
|
||||
return value as ReasoningEffort;
|
||||
};
|
||||
|
||||
const resolveModel = (value: unknown): string => {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error('Invalid model');
|
||||
@@ -363,7 +352,7 @@ export async function runCodex(opts: {
|
||||
}
|
||||
|
||||
if (config.modelReasoningEffort !== undefined) {
|
||||
currentModelReasoningEffort = resolveModelReasoningEffort(config.modelReasoningEffort);
|
||||
currentModelReasoningEffort = parseReasoningEffortValue(config.modelReasoningEffort);
|
||||
}
|
||||
|
||||
if (config.collaborationMode !== undefined) {
|
||||
|
||||
@@ -123,7 +123,7 @@ describe('appServerConfig', () => {
|
||||
it('passes model reasoning effort via thread config', () => {
|
||||
const params = buildThreadStartParams({
|
||||
cwd: '/workspace/project',
|
||||
mode: { permissionMode: 'default', modelReasoningEffort: 'xhigh', collaborationMode: 'default' },
|
||||
mode: { permissionMode: 'default', modelReasoningEffort: 'ultra', collaborationMode: 'default' },
|
||||
mcpServers
|
||||
});
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('appServerConfig', () => {
|
||||
args: ['mcp']
|
||||
},
|
||||
developer_instructions: codexSystemPrompt,
|
||||
model_reasoning_effort: 'xhigh'
|
||||
model_reasoning_effort: 'ultra'
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseReasoningEffortValue } from './reasoningEffort';
|
||||
|
||||
describe('parseReasoningEffortValue', () => {
|
||||
it('normalizes non-empty model-reported values', () => {
|
||||
expect(parseReasoningEffortValue(' EXTREME ')).toBe('extreme');
|
||||
});
|
||||
|
||||
it('maps null to the default effort', () => {
|
||||
expect(parseReasoningEffortValue(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects empty and non-string values', () => {
|
||||
expect(() => parseReasoningEffortValue(' ')).toThrow('Invalid model reasoning effort');
|
||||
expect(() => parseReasoningEffortValue(42)).toThrow('Invalid model reasoning effort');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ReasoningEffort } from '../appServerTypes';
|
||||
|
||||
export function parseReasoningEffortValue(value: unknown): ReasoningEffort | undefined {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error('Invalid model reasoning effort');
|
||||
}
|
||||
|
||||
const effort = value.trim().toLowerCase();
|
||||
if (!effort) {
|
||||
throw new Error('Invalid model reasoning effort');
|
||||
}
|
||||
return effort;
|
||||
}
|
||||
@@ -41,6 +41,12 @@ describe('resolveCodexSlashCommand', () => {
|
||||
expect(resolveCodexSlashCommand('/reasoning low', state)).toMatchObject({
|
||||
updates: { modelReasoningEffort: 'low' }
|
||||
});
|
||||
expect(resolveCodexSlashCommand('/reasoning max', state)).toMatchObject({
|
||||
updates: { modelReasoningEffort: 'max' }
|
||||
});
|
||||
expect(resolveCodexSlashCommand('/reasoning EXTREME', state)).toMatchObject({
|
||||
updates: { modelReasoningEffort: 'extreme' }
|
||||
});
|
||||
expect(resolveCodexSlashCommand('/permissions yolo', state)).toMatchObject({
|
||||
updates: { permissionMode: 'yolo' }
|
||||
});
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { CodexPermissionMode } from '@hapi/protocol/types';
|
||||
import type { ReasoningEffort } from '../appServerTypes';
|
||||
import type { EnhancedMode } from '../loop';
|
||||
import type { SlashCommand } from '@/modules/common/slashCommands';
|
||||
import { parseReasoningEffortValue } from './reasoningEffort';
|
||||
|
||||
const REASONING_EFFORTS = new Set<ReasoningEffort>(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']);
|
||||
export const MAX_CODEX_GOAL_OBJECTIVE_CHARS = 4_000;
|
||||
|
||||
const UNSUPPORTED_CODEX_BUILTIN_COMMANDS = new Set([
|
||||
@@ -186,16 +186,11 @@ export function resolveCodexSlashCommand(
|
||||
updates: { modelReasoningEffort: null }
|
||||
};
|
||||
}
|
||||
if (!REASONING_EFFORTS.has(rest as ReasoningEffort)) {
|
||||
return {
|
||||
kind: 'handled',
|
||||
message: `Unknown Codex reasoning effort: ${rest}`
|
||||
};
|
||||
}
|
||||
const effort = parseReasoningEffortValue(rest);
|
||||
return {
|
||||
kind: 'handled',
|
||||
message: `Codex reasoning effort set to ${rest}`,
|
||||
updates: { modelReasoningEffort: rest as ReasoningEffort }
|
||||
message: `Codex reasoning effort set to ${effort}`,
|
||||
updates: { modelReasoningEffort: effort }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -259,7 +254,7 @@ export function resolveCodexSlashCommand(
|
||||
'- `/compact` — compact current Codex thread context',
|
||||
'- `/status` — show current Codex session config',
|
||||
'- `/model [name|auto]` — show or set model',
|
||||
'- `/reasoning [low|medium|high|xhigh|default]` — show or set reasoning effort',
|
||||
'- `/reasoning [level|default]` — show or set reasoning effort',
|
||||
'- `/fast [on|off|status]` — toggle Fast mode (GPT-5.5 / GPT-5.4, ChatGPT login)',
|
||||
'- `/permissions [default|read-only|safe-yolo|yolo]` — show or set permission mode',
|
||||
'',
|
||||
|
||||
@@ -121,6 +121,20 @@ describe('codexCommand', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts and normalizes a dynamic model reasoning effort', async () => {
|
||||
await codexCommand.run(createCommandContext([
|
||||
'--started-by',
|
||||
'runner',
|
||||
'--model-reasoning-effort',
|
||||
' EXTREME '
|
||||
]))
|
||||
|
||||
expect(runCodexMock).toHaveBeenCalledWith({
|
||||
startedBy: 'runner',
|
||||
modelReasoningEffort: 'extreme'
|
||||
})
|
||||
})
|
||||
|
||||
it('prints the upgrade error and exits when the local version check fails', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
||||
|
||||
@@ -7,20 +7,7 @@ import { CODEX_PERMISSION_MODES } from '@hapi/protocol/modes'
|
||||
import type { CodexPermissionMode } from '@hapi/protocol/types'
|
||||
import type { ReasoningEffort } from '@/codex/appServerTypes'
|
||||
import { assertCodexLocalSupported } from '@/codex/utils/codexVersion'
|
||||
|
||||
function parseReasoningEffort(value: string): ReasoningEffort {
|
||||
switch (value) {
|
||||
case 'none':
|
||||
case 'minimal':
|
||||
case 'low':
|
||||
case 'medium':
|
||||
case 'high':
|
||||
case 'xhigh':
|
||||
return value
|
||||
default:
|
||||
throw new Error('Invalid --model-reasoning-effort value')
|
||||
}
|
||||
}
|
||||
import { parseReasoningEffortValue } from '@/codex/utils/reasoningEffort'
|
||||
|
||||
// Mirror the web /service-tier endpoint's enum so the internal resume spawn
|
||||
// path can never seed/persist an unsupported tier string.
|
||||
@@ -86,7 +73,7 @@ export const codexCommand: CommandDefinition = {
|
||||
if (!effort) {
|
||||
throw new Error('Missing --model-reasoning-effort value')
|
||||
}
|
||||
options.modelReasoningEffort = parseReasoningEffort(effort)
|
||||
options.modelReasoningEffort = parseReasoningEffortValue(effort)
|
||||
} else if (arg === '--service-tier') {
|
||||
const tier = commandArgs[++i]
|
||||
if (!tier) {
|
||||
|
||||
+11
-3
@@ -191,10 +191,18 @@ function findWebappDistDir(): { distDir: string; indexHtmlPath: string } {
|
||||
}
|
||||
|
||||
function serveEmbeddedAsset(asset: EmbeddedWebAsset): Response {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': asset.mimeType
|
||||
}
|
||||
|
||||
if (asset.path === '/sw.js') {
|
||||
headers['Cache-Control'] = 'no-store, no-cache, must-revalidate'
|
||||
headers['CDN-Cache-Control'] = 'no-store'
|
||||
headers['Cloudflare-CDN-Cache-Control'] = 'no-store'
|
||||
}
|
||||
|
||||
return new Response(Bun.file(asset.sourcePath), {
|
||||
headers: {
|
||||
'Content-Type': asset.mimeType
|
||||
}
|
||||
headers
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -471,7 +471,7 @@ export function HappyComposer(props: {
|
||||
? getCodexComposerReasoningEffortOptions(
|
||||
modelReasoningEffort,
|
||||
agentFlavor,
|
||||
agentFlavor === 'opencode' ? availableModelReasoningEffortOptions : undefined
|
||||
availableModelReasoningEffortOptions
|
||||
)
|
||||
: [],
|
||||
[agentFlavor, modelReasoningEffort, availableModelReasoningEffortOptions]
|
||||
|
||||
@@ -23,6 +23,39 @@ describe('getCodexComposerReasoningEffortOptions', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('uses arbitrary model-reported efforts for Codex', () => {
|
||||
expect(getCodexComposerReasoningEffortOptions('extreme', 'codex', [
|
||||
{ value: 'low' },
|
||||
{ value: 'medium' },
|
||||
{ value: 'high' },
|
||||
{ value: 'xhigh' },
|
||||
{ value: 'max' },
|
||||
{ value: 'ultra' },
|
||||
{ value: 'extreme' }
|
||||
])).toEqual([
|
||||
{ value: null, label: 'Default' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'xhigh', label: 'XHigh' },
|
||||
{ value: 'max', label: 'Max' },
|
||||
{ value: 'ultra', label: 'Ultra' },
|
||||
{ value: 'extreme', label: 'Extreme' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps an unsupported current Codex effort visible', () => {
|
||||
expect(getCodexComposerReasoningEffortOptions('ultra', 'codex', [
|
||||
{ value: 'low' },
|
||||
{ value: 'max' }
|
||||
])).toEqual([
|
||||
{ value: null, label: 'Default' },
|
||||
{ value: 'ultra', label: 'Ultra' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'max', label: 'Max' }
|
||||
])
|
||||
})
|
||||
|
||||
it('returns no options for OpenCode until dynamic options are available', () => {
|
||||
expect(getCodexComposerReasoningEffortOptions(null, 'opencode')).toEqual([])
|
||||
expect(getCodexComposerReasoningEffortOptions(null, 'opencode', [])).toEqual([])
|
||||
|
||||
@@ -14,7 +14,8 @@ const CODEX_REASONING_EFFORT_LABELS: Record<string, string> = {
|
||||
medium: 'Medium',
|
||||
high: 'High',
|
||||
xhigh: 'XHigh',
|
||||
max: 'Max'
|
||||
max: 'Max',
|
||||
ultra: 'Ultra'
|
||||
}
|
||||
|
||||
function normalizeCodexComposerReasoningEffort(effort?: string | null): string | null {
|
||||
@@ -31,7 +32,7 @@ function formatCodexReasoningEffortLabel(effort: string): string {
|
||||
?? `${effort.charAt(0).toUpperCase()}${effort.slice(1)}`
|
||||
}
|
||||
|
||||
function buildOpencodeComposerReasoningEffortOptions(
|
||||
function buildDynamicReasoningEffortOptions(
|
||||
currentEffort: string | null,
|
||||
dynamicOptions: ComposerReasoningEffortSourceOption[]
|
||||
): CodexComposerReasoningEffortOption[] {
|
||||
@@ -66,7 +67,11 @@ export function getCodexComposerReasoningEffortOptions(
|
||||
if (!dynamicOptions || dynamicOptions.length === 0) {
|
||||
return []
|
||||
}
|
||||
return buildOpencodeComposerReasoningEffortOptions(normalizedCurrentEffort, dynamicOptions)
|
||||
return buildDynamicReasoningEffortOptions(normalizedCurrentEffort, dynamicOptions)
|
||||
}
|
||||
|
||||
if (dynamicOptions && dynamicOptions.length > 0) {
|
||||
return buildDynamicReasoningEffortOptions(normalizedCurrentEffort, dynamicOptions)
|
||||
}
|
||||
|
||||
const options: CodexComposerReasoningEffortOption[] = [
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { AgentType, CodexReasoningEffort } from './types'
|
||||
import { CODEX_REASONING_EFFORT_OPTIONS } from './types'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
import { getCodexComposerReasoningEffortOptions } from '@/components/AssistantChat/codexReasoningEffortOptions'
|
||||
|
||||
export function ReasoningEffortSelector(props: {
|
||||
agent: AgentType
|
||||
value: CodexReasoningEffort
|
||||
availableOptions?: Array<{ value: string; name?: string }>
|
||||
isDisabled: boolean
|
||||
onChange: (value: CodexReasoningEffort) => void
|
||||
}) {
|
||||
@@ -14,6 +16,15 @@ export function ReasoningEffortSelector(props: {
|
||||
return null
|
||||
}
|
||||
|
||||
const options = props.agent === 'codex' && props.availableOptions?.length
|
||||
? getCodexComposerReasoningEffortOptions(null, props.agent, props.availableOptions).map((option) => ({
|
||||
value: option.value ?? 'default',
|
||||
label: option.label
|
||||
}))
|
||||
: CODEX_REASONING_EFFORT_OPTIONS.filter(
|
||||
(option) => props.agent === 'opencode' ? option.value !== 'xhigh' : option.value !== 'max'
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 px-3 py-3">
|
||||
<label className="text-xs font-medium text-[var(--app-hint)]">
|
||||
@@ -26,7 +37,7 @@ export function ReasoningEffortSelector(props: {
|
||||
disabled={props.isDisabled}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
|
||||
>
|
||||
{CODEX_REASONING_EFFORT_OPTIONS.filter((option) => props.agent === 'opencode' ? option.value !== 'xhigh' : option.value !== 'max').map((option) => (
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useActiveSuggestions, type Suggestion } from '@/hooks/useActiveSuggesti
|
||||
import { useDirectorySuggestions } from '@/hooks/useDirectorySuggestions'
|
||||
import { useRecentPaths } from '@/hooks/useRecentPaths'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
import { getCodexModelReasoningEfforts } from '@/lib/codexModelCapabilities'
|
||||
import {
|
||||
buildNewSessionCursorPickerState,
|
||||
isCursorEffortWireAllowed,
|
||||
@@ -199,6 +200,26 @@ export function NewSession(props: {
|
||||
}
|
||||
return options
|
||||
}, [codexModelsState.models, model])
|
||||
const codexSupportedReasoningEfforts = useMemo(
|
||||
() => getCodexModelReasoningEfforts(codexModelsState.models, model),
|
||||
[codexModelsState.models, model]
|
||||
)
|
||||
const codexReasoningEffortOptions = useMemo(
|
||||
() => codexSupportedReasoningEfforts?.map((value) => ({ value })),
|
||||
[codexSupportedReasoningEfforts]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
agent !== 'codex'
|
||||
|| modelReasoningEffort === 'default'
|
||||
|| !codexSupportedReasoningEfforts
|
||||
|| codexSupportedReasoningEfforts.includes(modelReasoningEffort)
|
||||
) {
|
||||
return
|
||||
}
|
||||
setModelReasoningEffort('default')
|
||||
}, [agent, codexSupportedReasoningEfforts, modelReasoningEffort])
|
||||
const cursorModelsState = useCursorModelsForMachine({
|
||||
api: props.api,
|
||||
machineId,
|
||||
@@ -719,7 +740,8 @@ export function NewSession(props: {
|
||||
<ReasoningEffortSelector
|
||||
agent={agent}
|
||||
value={modelReasoningEffort}
|
||||
isDisabled={isFormDisabled}
|
||||
availableOptions={agent === 'codex' ? codexReasoningEffortOptions : undefined}
|
||||
isDisabled={isFormDisabled || (agent === 'codex' && codexModelsState.isLoading)}
|
||||
onChange={setModelReasoningEffort}
|
||||
/>
|
||||
<YoloToggle
|
||||
|
||||
@@ -10,7 +10,8 @@ import type { AgentFlavor, ClaudeEffortLevel } from '@hapi/protocol'
|
||||
|
||||
export type AgentType = AgentFlavor
|
||||
export type SessionType = 'simple' | 'worktree'
|
||||
export type CodexReasoningEffort = 'default' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'
|
||||
// Codex reports supported efforts dynamically; keep this open for new server values.
|
||||
export type CodexReasoningEffort = string
|
||||
export type ClaudeEffort = 'auto' | ClaudeEffortLevel
|
||||
|
||||
function modelPresetOptions<TModel extends string>(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
applyModelChangeWithReasoningRollback,
|
||||
buildGoalStateMessages,
|
||||
isScratchlistHotkeyBlockedTarget,
|
||||
isScratchlistToggleHotkey,
|
||||
@@ -9,6 +10,42 @@ import {
|
||||
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
|
||||
import type { AttachmentMetadata, DecryptedMessage } from '@/types/api'
|
||||
|
||||
describe('applyModelChangeWithReasoningRollback', () => {
|
||||
it('restores the previous effort when the model switch fails after clearing it', async () => {
|
||||
const modelError = new Error('model switch failed')
|
||||
const setModel = vi.fn(async () => { throw modelError })
|
||||
const setModelReasoningEffort = vi.fn(async () => {})
|
||||
|
||||
await expect(applyModelChangeWithReasoningRollback({
|
||||
model: 'gpt-next',
|
||||
previousModelReasoningEffort: 'extreme',
|
||||
shouldClearReasoningEffort: true,
|
||||
setModel,
|
||||
setModelReasoningEffort
|
||||
})).rejects.toBe(modelError)
|
||||
|
||||
expect(setModelReasoningEffort.mock.calls).toEqual([[null], ['extreme']])
|
||||
expect(setModel).toHaveBeenCalledWith('gpt-next')
|
||||
})
|
||||
|
||||
it('keeps the cleared effort when the model switch succeeds', async () => {
|
||||
const setModel = vi.fn(async () => {})
|
||||
const setModelReasoningEffort = vi.fn(async () => {})
|
||||
|
||||
await applyModelChangeWithReasoningRollback({
|
||||
model: 'gpt-next',
|
||||
previousModelReasoningEffort: 'extreme',
|
||||
shouldClearReasoningEffort: true,
|
||||
setModel,
|
||||
setModelReasoningEffort
|
||||
})
|
||||
|
||||
expect(setModelReasoningEffort).toHaveBeenCalledOnce()
|
||||
expect(setModelReasoningEffort).toHaveBeenCalledWith(null)
|
||||
expect(setModel).toHaveBeenCalledWith('gpt-next')
|
||||
})
|
||||
})
|
||||
|
||||
function userMessage(props: {
|
||||
id: string
|
||||
createdAt: number
|
||||
|
||||
@@ -21,6 +21,10 @@ import { buildConversationOutline } from '@/chat/outline'
|
||||
import { buildVisibleChatBlocks, isToolGroupBlock, type ToolGroupBlock } from '@/chat/toolGroups'
|
||||
import { isQueuedForInvocation, mergeMessages } from '@/lib/messages'
|
||||
import { inactiveSessionCanResume } from '@/lib/sessionResume'
|
||||
import {
|
||||
getCodexModelReasoningEfforts,
|
||||
supportsCodexReasoningEffort
|
||||
} from '@/lib/codexModelCapabilities'
|
||||
import { HappyComposer, type ComposerSendError } from '@/components/AssistantChat/HappyComposer'
|
||||
import { codexModelAdvertisesFastTier } from '@/components/AssistantChat/codexFastMode'
|
||||
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
|
||||
@@ -64,6 +68,33 @@ import { useVoiceOptional } from '@/lib/voice-context'
|
||||
import { VoiceBackendSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime'
|
||||
import { isRemoteTerminalSupported } from '@/utils/terminalSupport'
|
||||
|
||||
type SessionModelSelection = { provider: string; modelId: string } | string | null
|
||||
|
||||
export async function applyModelChangeWithReasoningRollback(args: {
|
||||
model: SessionModelSelection
|
||||
previousModelReasoningEffort: string | null
|
||||
shouldClearReasoningEffort: boolean
|
||||
setModel: (model: SessionModelSelection) => Promise<void>
|
||||
setModelReasoningEffort: (effort: string | null) => Promise<void>
|
||||
}): Promise<void> {
|
||||
let clearedReasoningEffort = false
|
||||
|
||||
try {
|
||||
if (args.shouldClearReasoningEffort) {
|
||||
await args.setModelReasoningEffort(null)
|
||||
clearedReasoningEffort = true
|
||||
}
|
||||
await args.setModel(args.model)
|
||||
} catch (error) {
|
||||
if (clearedReasoningEffort && args.previousModelReasoningEffort) {
|
||||
await args.setModelReasoningEffort(args.previousModelReasoningEffort).catch((restoreError) => {
|
||||
console.error('Failed to restore model reasoning effort:', restoreError)
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a PendingSchedule should trigger an auto-clear timer.
|
||||
*
|
||||
@@ -516,6 +547,16 @@ function SessionChatInner(props: SessionChatProps) {
|
||||
}
|
||||
return options
|
||||
}, [agentFlavor, codexModelsState.models])
|
||||
const codexSupportedReasoningEfforts = useMemo(
|
||||
() => agentFlavor === 'codex'
|
||||
? getCodexModelReasoningEfforts(codexModelsState.models, props.session.model)
|
||||
: undefined,
|
||||
[agentFlavor, codexModelsState.models, props.session.model]
|
||||
)
|
||||
const codexReasoningEffortOptions = useMemo(
|
||||
() => codexSupportedReasoningEfforts?.map((value) => ({ value })),
|
||||
[codexSupportedReasoningEfforts]
|
||||
)
|
||||
const opencodeModelsState = useOpencodeModels({
|
||||
api: props.api,
|
||||
sessionId: props.session.id,
|
||||
@@ -909,16 +950,39 @@ function SessionChatInner(props: SessionChatProps) {
|
||||
}, [setCollaborationMode, props.onRefresh, haptic])
|
||||
|
||||
// Model mode change handler
|
||||
const handleModelChange = useCallback(async (model: { provider: string; modelId: string } | string | null) => {
|
||||
const handleModelChange = useCallback(async (model: SessionModelSelection) => {
|
||||
const previousModelReasoningEffort = props.session.modelReasoningEffort
|
||||
const shouldClearReasoningEffort = agentFlavor === 'codex'
|
||||
&& Boolean(previousModelReasoningEffort)
|
||||
&& supportsCodexReasoningEffort(
|
||||
codexModelsState.models,
|
||||
model,
|
||||
previousModelReasoningEffort
|
||||
) === false
|
||||
|
||||
try {
|
||||
await setModel(model)
|
||||
await applyModelChangeWithReasoningRollback({
|
||||
model,
|
||||
previousModelReasoningEffort,
|
||||
shouldClearReasoningEffort,
|
||||
setModel,
|
||||
setModelReasoningEffort
|
||||
})
|
||||
haptic.notification('success')
|
||||
props.onRefresh()
|
||||
} catch (e) {
|
||||
haptic.notification('error')
|
||||
console.error('Failed to set model:', e)
|
||||
}
|
||||
}, [setModel, props.onRefresh, haptic])
|
||||
}, [
|
||||
agentFlavor,
|
||||
codexModelsState.models,
|
||||
props.session.modelReasoningEffort,
|
||||
setModelReasoningEffort,
|
||||
setModel,
|
||||
props.onRefresh,
|
||||
haptic
|
||||
])
|
||||
|
||||
const handleCursorBaseModelChange = useCallback(async (baseKey: string | null) => {
|
||||
if (!cursorPicker) {
|
||||
@@ -1243,9 +1307,11 @@ function SessionChatInner(props: SessionChatProps) {
|
||||
piModels={agentFlavor === 'pi' ? (piModelsState.availableModels.length > 0 ? piModelsState.availableModels : piCachedModels) : undefined}
|
||||
piSelectedModel={agentFlavor === 'pi' ? piSelectedModel : undefined}
|
||||
availableModelReasoningEffortOptions={
|
||||
agentFlavor === 'opencode' && opencodeReasoningEffortState.options.length > 0
|
||||
? opencodeReasoningEffortState.options
|
||||
: undefined
|
||||
agentFlavor === 'codex'
|
||||
? codexReasoningEffortOptions
|
||||
: agentFlavor === 'opencode' && opencodeReasoningEffortState.options.length > 0
|
||||
? opencodeReasoningEffortState.options
|
||||
: undefined
|
||||
}
|
||||
active={props.session.active}
|
||||
allowSendWhenInactive
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { CodexModelSummary } from '@/types/api'
|
||||
import {
|
||||
getCodexModelReasoningEfforts,
|
||||
resolveCodexModel,
|
||||
supportsCodexReasoningEffort
|
||||
} from './codexModelCapabilities'
|
||||
|
||||
const models: CodexModelSummary[] = [
|
||||
{
|
||||
id: 'gpt-5.6-sol',
|
||||
displayName: 'GPT-5.6-Sol',
|
||||
isDefault: true,
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra']
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.6-luna',
|
||||
displayName: 'GPT-5.6-Luna',
|
||||
isDefault: false,
|
||||
supportedReasoningEfforts: ['LOW', 'medium', 'max', 'max', ' ']
|
||||
}
|
||||
]
|
||||
|
||||
describe('Codex model capabilities', () => {
|
||||
it('resolves auto and missing model ids to the reported default model', () => {
|
||||
expect(resolveCodexModel(models, 'auto')?.id).toBe('gpt-5.6-sol')
|
||||
expect(resolveCodexModel(models, null)?.id).toBe('gpt-5.6-sol')
|
||||
expect(resolveCodexModel(models, { modelId: 'gpt-5.6-luna' })?.id).toBe('gpt-5.6-luna')
|
||||
})
|
||||
|
||||
it('returns normalized, de-duplicated efforts for the selected model', () => {
|
||||
expect(getCodexModelReasoningEfforts(models, 'gpt-5.6-luna')).toEqual([
|
||||
'low',
|
||||
'medium',
|
||||
'max'
|
||||
])
|
||||
})
|
||||
|
||||
it('distinguishes unsupported efforts from unavailable capability data', () => {
|
||||
expect(supportsCodexReasoningEffort(models, 'gpt-5.6-sol', 'ultra')).toBe(true)
|
||||
expect(supportsCodexReasoningEffort(models, 'gpt-5.6-luna', 'ultra')).toBe(false)
|
||||
expect(supportsCodexReasoningEffort(models, 'unknown', 'ultra')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { CodexModelSummary } from '@/types/api'
|
||||
|
||||
type ModelSelection = string | { modelId: string } | null | undefined
|
||||
|
||||
function normalizeEfforts(efforts: readonly string[] | undefined): string[] | undefined {
|
||||
if (!efforts) return undefined
|
||||
|
||||
const normalized = [...new Set(
|
||||
efforts
|
||||
.map((effort) => effort.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
)]
|
||||
return normalized.length > 0 ? normalized : undefined
|
||||
}
|
||||
|
||||
export function resolveCodexModel(
|
||||
models: readonly CodexModelSummary[],
|
||||
model: ModelSelection
|
||||
): CodexModelSummary | null {
|
||||
const normalizedModelId = (typeof model === 'string' ? model : model?.modelId)?.trim()
|
||||
if (!normalizedModelId || normalizedModelId === 'auto') {
|
||||
return models.find((model) => model.isDefault) ?? models[0] ?? null
|
||||
}
|
||||
|
||||
return models.find((model) => model.id === normalizedModelId) ?? null
|
||||
}
|
||||
|
||||
export function getCodexModelReasoningEfforts(
|
||||
models: readonly CodexModelSummary[],
|
||||
model: ModelSelection
|
||||
): string[] | undefined {
|
||||
return normalizeEfforts(resolveCodexModel(models, model)?.supportedReasoningEfforts)
|
||||
}
|
||||
|
||||
export function supportsCodexReasoningEffort(
|
||||
models: readonly CodexModelSummary[],
|
||||
model: ModelSelection,
|
||||
effort: string | null | undefined
|
||||
): boolean | undefined {
|
||||
const supportedEfforts = getCodexModelReasoningEfforts(models, model)
|
||||
if (!supportedEfforts) return undefined
|
||||
if (!effort) return true
|
||||
return supportedEfforts.includes(effort.trim().toLowerCase())
|
||||
}
|
||||
Reference in New Issue
Block a user