diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index 984664dbe..657bacda5 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -24,6 +24,7 @@ export type SessionBootstrapOptions = { agentState?: AgentState | null model?: string effort?: string + modelReasoningEffort?: string metadataOverrides?: Partial } @@ -133,7 +134,8 @@ export async function bootstrapSession(options: SessionBootstrapOptions): Promis metadata, state: agentState, model: options.model, - effort: options.effort + effort: options.effort, + modelReasoningEffort: options.modelReasoningEffort }) const session = api.sessionSyncClient(sessionInfo) diff --git a/cli/src/api/api.ts b/cli/src/api/api.ts index 1447f5913..44e397d8d 100644 --- a/cli/src/api/api.ts +++ b/cli/src/api/api.ts @@ -20,6 +20,7 @@ export class ApiClient { state: AgentState | null model?: string effort?: string + modelReasoningEffort?: string }): Promise { const response = await axios.post( `${configuration.apiUrl}/cli/sessions`, @@ -28,7 +29,8 @@ export class ApiClient { metadata: opts.metadata, agentState: opts.state, model: opts.model, - effort: opts.effort + effort: opts.effort, + modelReasoningEffort: opts.modelReasoningEffort }, { headers: { diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index d94286a14..367dcd1ac 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -37,7 +37,8 @@ export async function runCodex(opts: { startedBy, workingDirectory, agentState: state, - model: opts.model + model: opts.model, + modelReasoningEffort: opts.modelReasoningEffort }); const startingMode: 'local' | 'remote' = startedBy === 'runner' ? 'remote' : 'local'; @@ -56,7 +57,7 @@ export async function runCodex(opts: { let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; let currentModel = opts.model; - const currentModelReasoningEffort = opts.modelReasoningEffort; + let currentModelReasoningEffort = opts.modelReasoningEffort; let currentCollaborationMode: EnhancedMode['collaborationMode'] = 'default'; const lifecycle = createRunnerLifecycle({ @@ -145,11 +146,21 @@ export async function runCodex(opts: { return parsed.data; }; + const resolveModelReasoningEffort = (value: unknown): ReasoningEffort | undefined => { + if (value === null || value === undefined) { + return undefined; + } + if (typeof value !== 'string') { + throw new Error('Invalid model reasoning effort'); + } + return value as ReasoningEffort; + }; + session.rpcHandlerManager.registerHandler('set-session-config', async (payload: unknown) => { if (!payload || typeof payload !== 'object') { throw new Error('Invalid session config payload'); } - const config = payload as { permissionMode?: unknown; collaborationMode?: unknown }; + const config = payload as { permissionMode?: unknown; collaborationMode?: unknown; modelReasoningEffort?: unknown }; if (config.permissionMode !== undefined) { currentPermissionMode = resolvePermissionMode(config.permissionMode); @@ -159,8 +170,12 @@ export async function runCodex(opts: { currentCollaborationMode = resolveCollaborationMode(config.collaborationMode); } + if ('modelReasoningEffort' in config) { + currentModelReasoningEffort = resolveModelReasoningEffort(config.modelReasoningEffort); + } + syncSessionMode(); - return { applied: { permissionMode: currentPermissionMode, collaborationMode: currentCollaborationMode } }; + return { applied: { permissionMode: currentPermissionMode, collaborationMode: currentCollaborationMode, modelReasoningEffort: currentModelReasoningEffort ?? null } }; }); try { diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index f70b3db25..bac6c65c4 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -22,7 +22,7 @@ export { PushStore } from './pushStore' export { SessionStore } from './sessionStore' export { UserStore } from './userStore' -const SCHEMA_VERSION: number = 6 +const SCHEMA_VERSION: number = 7 const REQUIRED_TABLES = [ 'sessions', 'machines', @@ -128,9 +128,23 @@ export class Store { return } - if (currentVersion === 4 && SCHEMA_VERSION === 6) { + if (currentVersion === 5 && SCHEMA_VERSION === 7) { + this.migrateFromV5ToV6() + this.migrateFromV6ToV7() + this.setUserVersion(SCHEMA_VERSION) + return + } + + if (currentVersion === 6 && SCHEMA_VERSION === 7) { + this.migrateFromV6ToV7() + this.setUserVersion(SCHEMA_VERSION) + return + } + + if (currentVersion === 4 && SCHEMA_VERSION === 7) { this.migrateFromV4ToV5() this.migrateFromV5ToV6() + this.migrateFromV6ToV7() this.setUserVersion(SCHEMA_VERSION) return } @@ -157,6 +171,7 @@ export class Store { agent_state_version INTEGER DEFAULT 1, model TEXT, effort TEXT, + model_reasoning_effort TEXT, todos TEXT, todos_updated_at INTEGER, team_state TEXT, @@ -333,6 +348,13 @@ export class Store { } } + private migrateFromV6ToV7(): void { + const columns = this.getSessionColumnNames() + if (!columns.has('model_reasoning_effort')) { + this.db.exec('ALTER TABLE sessions ADD COLUMN model_reasoning_effort TEXT') + } + } + private getSessionColumnNames(): Set { const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }> return new Set(rows.map((row) => row.name)) diff --git a/hub/src/store/sessionStore.ts b/hub/src/store/sessionStore.ts index 831c8e09e..6e866ea0d 100644 --- a/hub/src/store/sessionStore.ts +++ b/hub/src/store/sessionStore.ts @@ -10,6 +10,7 @@ import { getSessionsByNamespace, setSessionEffort, setSessionModel, + setSessionModelReasoningEffort, setSessionTeamState, setSessionTodos, updateSessionAgentState, @@ -29,9 +30,10 @@ export class SessionStore { agentState: unknown, namespace: string, model?: string, - effort?: string + effort?: string, + modelReasoningEffort?: string ): StoredSession { - return getOrCreateSession(this.db, tag, metadata, agentState, namespace, model, effort) + return getOrCreateSession(this.db, tag, metadata, agentState, namespace, model, effort, modelReasoningEffort) } updateSessionMetadata( @@ -69,6 +71,10 @@ export class SessionStore { return setSessionEffort(this.db, id, effort, namespace, options) } + setSessionModelReasoningEffort(id: string, modelReasoningEffort: string | null, namespace: string, options?: { touchUpdatedAt?: boolean }): boolean { + return setSessionModelReasoningEffort(this.db, id, modelReasoningEffort, namespace, options) + } + getSession(id: string): StoredSession | null { return getSession(this.db, id) } diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts index 4e30f6d64..4e08a61ff 100644 --- a/hub/src/store/sessions.ts +++ b/hub/src/store/sessions.ts @@ -18,6 +18,7 @@ type DbSessionRow = { agent_state_version: number model: string | null effort: string | null + model_reasoning_effort: string | null todos: string | null todos_updated_at: number | null team_state: string | null @@ -41,6 +42,7 @@ function toStoredSession(row: DbSessionRow): StoredSession { agentStateVersion: row.agent_state_version, model: row.model, effort: row.effort, + modelReasoningEffort: row.model_reasoning_effort, todos: safeJsonParse(row.todos), todosUpdatedAt: row.todos_updated_at, teamState: safeJsonParse(row.team_state), @@ -58,7 +60,8 @@ export function getOrCreateSession( agentState: unknown, namespace: string, model?: string, - effort?: string + effort?: string, + modelReasoningEffort?: string ): StoredSession { const existing = db.prepare( 'SELECT * FROM sessions WHERE tag = ? AND namespace = ? ORDER BY created_at DESC LIMIT 1' @@ -81,6 +84,7 @@ export function getOrCreateSession( agent_state, agent_state_version, model, effort, + model_reasoning_effort, todos, todos_updated_at, active, active_at, seq ) VALUES ( @@ -89,6 +93,7 @@ export function getOrCreateSession( @agent_state, 1, @model, @effort, + @model_reasoning_effort, NULL, NULL, 0, NULL, 0 ) @@ -101,7 +106,8 @@ export function getOrCreateSession( metadata: metadataJson, agent_state: agentStateJson, model: model ?? null, - effort: effort ?? null + effort: effort ?? null, + model_reasoning_effort: modelReasoningEffort ?? null }) const row = getSession(db, id) @@ -303,6 +309,39 @@ export function setSessionEffort( } } +export function setSessionModelReasoningEffort( + db: Database, + id: string, + modelReasoningEffort: string | null, + namespace: string, + options?: { touchUpdatedAt?: boolean } +): boolean { + const now = Date.now() + const touchUpdatedAt = options?.touchUpdatedAt === true + + try { + const result = db.prepare(` + UPDATE sessions + SET model_reasoning_effort = @model_reasoning_effort, + updated_at = CASE WHEN @touch_updated_at = 1 THEN @updated_at ELSE updated_at END, + seq = seq + 1 + WHERE id = @id + AND namespace = @namespace + AND model_reasoning_effort IS NOT @model_reasoning_effort + `).run({ + id, + namespace, + model_reasoning_effort: modelReasoningEffort, + updated_at: now, + touch_updated_at: touchUpdatedAt ? 1 : 0 + }) + + return result.changes === 1 + } catch { + return false + } +} + export function getSession(db: Database, id: string): StoredSession | null { const row = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as DbSessionRow | undefined return row ? toStoredSession(row) : null diff --git a/hub/src/store/types.ts b/hub/src/store/types.ts index 394c86f32..76231de24 100644 --- a/hub/src/store/types.ts +++ b/hub/src/store/types.ts @@ -11,6 +11,7 @@ export type StoredSession = { agentStateVersion: number model: string | null effort: string | null + modelReasoningEffort: string | null todos: unknown | null todosUpdatedAt: number | null teamState: unknown | null diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index d59ff3b6d..9f050de3a 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -95,6 +95,7 @@ export class RpcGateway { permissionMode?: PermissionMode model?: string | null effort?: string | null + modelReasoningEffort?: string | null collaborationMode?: CodexCollaborationMode } ): Promise { diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 618447c7d..685ce3d82 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -61,9 +61,10 @@ export class SessionCache { agentState: unknown, namespace: string, model?: string, - effort?: string + effort?: string, + modelReasoningEffort?: string ): Session { - const stored = this.store.sessions.getOrCreateSession(tag, metadata, agentState, namespace, model, effort) + const stored = this.store.sessions.getOrCreateSession(tag, metadata, agentState, namespace, model, effort, modelReasoningEffort) return this.refreshSession(stored.id) ?? (() => { throw new Error('Failed to load session') })() } @@ -135,6 +136,7 @@ export class SessionCache { teamState, model: stored.model, effort: stored.effort, + modelReasoningEffort: stored.modelReasoningEffort ?? undefined, permissionMode: existing?.permissionMode, collaborationMode: existing?.collaborationMode } @@ -265,6 +267,7 @@ export class SessionCache { permissionMode?: PermissionMode model?: string | null effort?: string | null + modelReasoningEffort?: string | null collaborationMode?: CodexCollaborationMode } ): void { @@ -298,6 +301,18 @@ export class SessionCache { } session.effort = config.effort } + if (config.modelReasoningEffort !== undefined) { + const currentMre = session.modelReasoningEffort ?? null + if (config.modelReasoningEffort !== currentMre) { + const updated = this.store.sessions.setSessionModelReasoningEffort(sessionId, config.modelReasoningEffort, session.namespace, { + touchUpdatedAt: false + }) + if (!updated) { + throw new Error('Failed to update session model reasoning effort') + } + } + session.modelReasoningEffort = config.modelReasoningEffort ?? undefined + } if (config.collaborationMode !== undefined) { session.collaborationMode = config.collaborationMode } @@ -407,6 +422,15 @@ export class SessionCache { } } + if (newStored.modelReasoningEffort === null && oldStored.modelReasoningEffort !== null) { + const updated = this.store.sessions.setSessionModelReasoningEffort(newSessionId, oldStored.modelReasoningEffort, namespace, { + touchUpdatedAt: false + }) + if (!updated) { + throw new Error('Failed to preserve session model reasoning effort during merge') + } + } + if (oldStored.todos !== null && oldStored.todosUpdatedAt !== null) { this.store.sessions.setSessionTodos( newSessionId, diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 6b5be2f1c..60165e2ba 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -218,9 +218,10 @@ export class SyncEngine { agentState: unknown, namespace: string, model?: string, - effort?: string + effort?: string, + modelReasoningEffort?: string ): Session { - return this.sessionCache.getOrCreateSession(tag, metadata, agentState, namespace, model, effort) + return this.sessionCache.getOrCreateSession(tag, metadata, agentState, namespace, model, effort, modelReasoningEffort) } getOrCreateMachine(id: string, metadata: unknown, runnerState: unknown, namespace: string): Machine { @@ -292,6 +293,7 @@ export class SyncEngine { permissionMode?: PermissionMode model?: string | null effort?: string | null + modelReasoningEffort?: string | null collaborationMode?: CodexCollaborationMode } ): Promise { @@ -304,6 +306,7 @@ export class SyncEngine { permissionMode?: Session['permissionMode'] model?: Session['model'] effort?: Session['effort'] + modelReasoningEffort?: Session['modelReasoningEffort'] collaborationMode?: Session['collaborationMode'] } } @@ -404,7 +407,7 @@ export class SyncEngine { metadata.path, flavor, session.model ?? undefined, - undefined, + session.modelReasoningEffort ?? undefined, undefined, undefined, undefined, diff --git a/hub/src/web/routes/cli.ts b/hub/src/web/routes/cli.ts index 8a81041ff..7ba3ec587 100644 --- a/hub/src/web/routes/cli.ts +++ b/hub/src/web/routes/cli.ts @@ -13,7 +13,8 @@ const createOrLoadSessionSchema = z.object({ metadata: z.unknown(), agentState: z.unknown().nullable().optional(), model: z.string().optional(), - effort: z.string().optional() + effort: z.string().optional(), + modelReasoningEffort: z.string().optional() }) const createOrLoadMachineSchema = z.object({ @@ -108,7 +109,8 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono SyncEngine | null): Ho } }) + app.post('/sessions/:id/model-reasoning-effort', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) { + return sessionResult + } + + const body = await c.req.json().catch(() => null) + const parsed = modelReasoningEffortSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + + const flavor = sessionResult.session.metadata?.flavor ?? 'claude' + if (flavor !== 'codex') { + return c.json({ error: 'Model reasoning effort is only supported for Codex sessions' }, 400) + } + + try { + await engine.applySessionConfig(sessionResult.sessionId, { modelReasoningEffort: parsed.data.modelReasoningEffort }) + return c.json({ ok: true }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to apply model reasoning effort' + return c.json({ error: message }, 409) + } + }) + app.patch('/sessions/:id', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/shared/src/flavors.test.ts b/shared/src/flavors.test.ts index a8efe5e99..eef06cbcf 100644 --- a/shared/src/flavors.test.ts +++ b/shared/src/flavors.test.ts @@ -6,6 +6,7 @@ import { isKnownFlavor, supportsEffort, supportsModelChange, + supportsModelReasoningEffort, } from './flavors' describe('hasCapability', () => { @@ -27,6 +28,10 @@ describe('hasCapability', () => { expect(hasCapability('codex', Capabilities.Effort)).toBe(false) }) + test('codex supports model-reasoning-effort', () => { + expect(hasCapability('codex', Capabilities.ModelReasoningEffort)).toBe(true) + }) + test('cursor has no capabilities', () => { expect(hasCapability('cursor', Capabilities.ModelChange)).toBe(false) expect(hasCapability('cursor', Capabilities.Effort)).toBe(false) @@ -96,4 +101,11 @@ describe('convenience functions', () => { expect(supportsEffort('gemini')).toBe(false) expect(supportsEffort(null)).toBe(false) }) + + test('supportsModelReasoningEffort matches hasCapability', () => { + expect(supportsModelReasoningEffort('codex')).toBe(true) + expect(supportsModelReasoningEffort('claude')).toBe(false) + expect(supportsModelReasoningEffort('gemini')).toBe(false) + expect(supportsModelReasoningEffort(null)).toBe(false) + }) }) diff --git a/shared/src/flavors.ts b/shared/src/flavors.ts index 817d3dd99..370f09fa8 100644 --- a/shared/src/flavors.ts +++ b/shared/src/flavors.ts @@ -4,6 +4,7 @@ import type { AgentFlavor } from './modes' export const Capabilities = { ModelChange: 'model-change', Effort: 'effort', + ModelReasoningEffort: 'model-reasoning-effort', } as const export type Capability = typeof Capabilities[keyof typeof Capabilities] @@ -12,7 +13,7 @@ export type Capability = typeof Capabilities[keyof typeof Capabilities] const FLAVOR_CAPS: Record> = { claude: new Set([Capabilities.ModelChange, Capabilities.Effort]), gemini: new Set([Capabilities.ModelChange]), - codex: new Set([]), + codex: new Set([Capabilities.ModelReasoningEffort]), cursor: new Set([]), opencode: new Set([]), } @@ -49,3 +50,7 @@ export function supportsModelChange(flavor: string | null | undefined): boolean export function supportsEffort(flavor: string | null | undefined): boolean { return hasCapability(flavor, Capabilities.Effort) } + +export function supportsModelReasoningEffort(flavor: string | null | undefined): boolean { + return hasCapability(flavor, Capabilities.ModelReasoningEffort) +} diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 52ec83737..e32fdd89e 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -176,6 +176,7 @@ export const SessionSchema = z.object({ teamState: TeamStateSchema.optional(), model: z.string().nullable(), effort: z.string().nullable(), + modelReasoningEffort: z.string().nullable().optional(), permissionMode: PermissionModeSchema.optional(), collaborationMode: CodexCollaborationModeSchema.optional() }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 163eb206d..4baded42e 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -334,6 +334,13 @@ export class ApiClient { }) } + async setModelReasoningEffort(sessionId: string, modelReasoningEffort: string | null): Promise { + await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/model-reasoning-effort`, { + method: 'POST', + body: JSON.stringify({ modelReasoningEffort }) + }) + } + async approvePermission( sessionId: string, requestId: string, diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 101cca2d6..511424c4c 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -20,7 +20,7 @@ import { useActiveSuggestions } from '@/hooks/useActiveSuggestions' import { applySuggestion } from '@/utils/applySuggestion' import { usePlatform } from '@/hooks/usePlatform' import { usePWAInstall } from '@/hooks/usePWAInstall' -import { supportsEffort, supportsModelChange } from '@hapi/protocol' +import { supportsEffort, supportsModelChange, supportsModelReasoningEffort } from '@hapi/protocol' import { markSkillUsed } from '@/lib/recent-skills' import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay' import { Autocomplete } from '@/components/ChatInput/Autocomplete' @@ -30,6 +30,7 @@ import { AttachmentItem } from '@/components/AssistantChat/AttachmentItem' import { useTranslation } from '@/lib/use-translation' import { getModelOptionsForFlavor, getNextModelForFlavor } from './modelOptions' import { getClaudeComposerEffortOptions } from './claudeEffortOptions' +import { CODEX_REASONING_EFFORT_OPTIONS } from '@/components/NewSession/types' export interface TextInputState { text: string @@ -44,6 +45,7 @@ export function HappyComposer(props: { collaborationMode?: CodexCollaborationMode model?: string | null effort?: string | null + modelReasoningEffort?: string | null active?: boolean allowSendWhenInactive?: boolean thinking?: boolean @@ -55,6 +57,7 @@ export function HappyComposer(props: { onPermissionModeChange?: (mode: PermissionMode) => void onModelChange?: (model: string | null) => void onEffortChange?: (effort: string | null) => void + onModelReasoningEffortChange?: (modelReasoningEffort: string | null) => void onSwitchToRemote?: () => void onTerminal?: () => void terminalUnsupported?: boolean @@ -73,6 +76,7 @@ export function HappyComposer(props: { collaborationMode: rawCollaborationMode, model: rawModel, effort: rawEffort, + modelReasoningEffort: rawModelReasoningEffort, active = true, allowSendWhenInactive = false, thinking = false, @@ -84,6 +88,7 @@ export function HappyComposer(props: { onPermissionModeChange, onModelChange, onEffortChange, + onModelReasoningEffortChange, onSwitchToRemote, onTerminal, terminalUnsupported = false, @@ -100,6 +105,7 @@ export function HappyComposer(props: { const collaborationMode = rawCollaborationMode ?? 'default' const model = rawModel ?? null const effort = rawEffort ?? null + const modelReasoningEffort = rawModelReasoningEffort ?? null const api = useAssistantApi() const composerText = useAssistantState(({ composer }) => composer.text) @@ -437,11 +443,19 @@ export function HappyComposer(props: { haptic('light') }, [onEffortChange, controlsDisabled, haptic]) + const handleModelReasoningEffortChange = useCallback((nextModelReasoningEffort: string | null) => { + if (!onModelReasoningEffortChange || controlsDisabled) return + onModelReasoningEffortChange(nextModelReasoningEffort) + setShowSettings(false) + haptic('light') + }, [onModelReasoningEffortChange, controlsDisabled, haptic]) + const showCollaborationSettings = Boolean(onCollaborationModeChange && collaborationModeOptions.length > 0) const showPermissionSettings = Boolean(onPermissionModeChange && permissionModeOptions.length > 0) const showModelSettings = Boolean(onModelChange && supportsModelChange(agentFlavor)) const showEffortSettings = Boolean(onEffortChange && supportsEffort(agentFlavor)) - const showSettingsButton = Boolean(showCollaborationSettings || showPermissionSettings || showModelSettings || showEffortSettings) + const showModelReasoningEffortSettings = Boolean(onModelReasoningEffortChange && supportsModelReasoningEffort(agentFlavor)) + const showSettingsButton = Boolean(showCollaborationSettings || showPermissionSettings || showModelSettings || showEffortSettings || showModelReasoningEffortSettings) const showAbortButton = true const voiceEnabled = Boolean(onVoiceToggle) @@ -450,7 +464,7 @@ export function HappyComposer(props: { }, [api]) const overlays = useMemo(() => { - if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showEffortSettings)) { + if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showEffortSettings || showModelReasoningEffortSettings)) { return (
@@ -613,6 +627,43 @@ export function HappyComposer(props: { ))}
) : null} + + {showModelReasoningEffortSettings ? ( +
+
+ {t('misc.reasoningEffort')} +
+ {CODEX_REASONING_EFFORT_OPTIONS.map((option) => ( + + ))} +
+ ) : null} ) @@ -639,6 +690,7 @@ export function HappyComposer(props: { showPermissionSettings, showModelSettings, showEffortSettings, + showModelReasoningEffortSettings, claudeModelOptions, claudeEffortOptions, suggestions, @@ -648,12 +700,14 @@ export function HappyComposer(props: { permissionMode, model, effort, + modelReasoningEffort, collaborationModeOptions, permissionModeOptions, handleCollaborationChange, handlePermissionChange, handleModelChange, handleEffortChange, + handleModelReasoningEffortChange, handleSuggestionSelect, t ]) diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 841286245..e9d6e7c2d 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -63,7 +63,7 @@ export function SessionChat(props: { const agentFlavor = props.session.metadata?.flavor ?? null const controlledByUser = props.session.agentState?.controlledByUser === true const codexCollaborationModeSupported = agentFlavor === 'codex' && !controlledByUser - const { abortSession, switchSession, setPermissionMode, setCollaborationMode, setModel, setEffort } = useSessionActions( + const { abortSession, switchSession, setPermissionMode, setCollaborationMode, setModel, setEffort, setModelReasoningEffort } = useSessionActions( props.api, props.session.id, agentFlavor, @@ -257,6 +257,17 @@ export function SessionChat(props: { } }, [setEffort, props.onRefresh, haptic]) + const handleModelReasoningEffortChange = useCallback(async (modelReasoningEffort: string | null) => { + try { + await setModelReasoningEffort(modelReasoningEffort) + haptic.notification('success') + props.onRefresh() + } catch (e) { + haptic.notification('error') + console.error('Failed to set model reasoning effort:', e) + } + }, [setModelReasoningEffort, props.onRefresh, haptic]) + // Abort handler const handleAbort = useCallback(async () => { await abortSession() @@ -374,6 +385,7 @@ export function SessionChat(props: { collaborationMode={codexCollaborationModeSupported ? props.session.collaborationMode : undefined} model={props.session.model} effort={props.session.effort} + modelReasoningEffort={props.session.modelReasoningEffort} agentFlavor={agentFlavor} active={props.session.active} allowSendWhenInactive @@ -389,6 +401,7 @@ export function SessionChat(props: { onPermissionModeChange={handlePermissionModeChange} onModelChange={handleModelChange} onEffortChange={handleEffortChange} + onModelReasoningEffortChange={props.session.active ? handleModelReasoningEffortChange : undefined} onSwitchToRemote={handleSwitchToRemote} onTerminal={props.session.active && terminalSupported ? handleViewTerminal : undefined} terminalUnsupported={props.session.active && !terminalSupported} diff --git a/web/src/hooks/mutations/useSessionActions.ts b/web/src/hooks/mutations/useSessionActions.ts index 3f27e0e2f..607126cf2 100644 --- a/web/src/hooks/mutations/useSessionActions.ts +++ b/web/src/hooks/mutations/useSessionActions.ts @@ -19,6 +19,7 @@ export function useSessionActions( setCollaborationMode: (mode: CodexCollaborationMode) => Promise setModel: (model: string | null) => Promise setEffort: (effort: string | null) => Promise + setModelReasoningEffort: (modelReasoningEffort: string | null) => Promise renameSession: (name: string) => Promise deleteSession: () => Promise isPending: boolean @@ -110,6 +111,16 @@ export function useSessionActions( onSuccess: () => void invalidateSession(), }) + const modelReasoningEffortMutation = useMutation({ + mutationFn: async (modelReasoningEffort: string | null) => { + if (!api || !sessionId) { + throw new Error('Session unavailable') + } + await api.setModelReasoningEffort(sessionId, modelReasoningEffort) + }, + onSuccess: () => void invalidateSession(), + }) + const renameMutation = useMutation({ mutationFn: async (name: string) => { if (!api || !sessionId) { @@ -143,6 +154,7 @@ export function useSessionActions( setCollaborationMode: collaborationMutation.mutateAsync, setModel: modelMutation.mutateAsync, setEffort: effortMutation.mutateAsync, + setModelReasoningEffort: modelReasoningEffortMutation.mutateAsync, renameSession: renameMutation.mutateAsync, deleteSession: deleteMutation.mutateAsync, isPending: abortMutation.isPending @@ -152,6 +164,7 @@ export function useSessionActions( || collaborationMutation.isPending || modelMutation.isPending || effortMutation.isPending + || modelReasoningEffortMutation.isPending || renameMutation.isPending || deleteMutation.isPending, } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index c8eff2917..c61d47986 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -279,6 +279,7 @@ export default { 'misc.permissionMode': 'Permission Mode', 'misc.model': 'Model', 'misc.effort': 'Effort', + 'misc.reasoningEffort': 'Reasoning Effort', 'misc.loading': 'Loading…', 'misc.loadOlder': 'Load older', 'misc.newMessage': '{n} new message{s}', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 32eaff2f1..dba92c456 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -281,6 +281,7 @@ export default { 'misc.permissionMode': '权限模式', 'misc.model': '模型', 'misc.effort': '思考强度', + 'misc.reasoningEffort': '推理强度', 'misc.loading': '加载中…', 'misc.loadOlder': '加载更早的', 'misc.newMessage': '{n} 条新消息',