From 79a13d26c640e0b262f6f66525e19b89b9d259d7 Mon Sep 17 00:00:00 2001 From: weishu Date: Fri, 10 Apr 2026 11:50:03 +0800 Subject: [PATCH] Fix Codex reasoning effort resume and updates --- cli/src/agent/sessionBase.ts | 29 +++- cli/src/agent/sessionFactory.ts | 2 + cli/src/api/api.ts | 3 + cli/src/api/apiSession.ts | 1 + cli/src/api/types.ts | 2 + cli/src/codex/codexLocal.ts | 6 + cli/src/codex/codexLocalLauncher.ts | 1 + cli/src/codex/loop.ts | 2 + cli/src/codex/runCodex.ts | 41 +++++- cli/src/codex/session.ts | 9 +- hub/src/socket/handlers/cli/index.ts | 1 + .../socket/handlers/cli/sessionHandlers.ts | 1 + hub/src/store/index.ts | 31 ++++- hub/src/store/sessionStore.ts | 15 +- hub/src/store/sessions.ts | 41 +++++- hub/src/store/types.ts | 1 + hub/src/sync/rpcGateway.ts | 1 + hub/src/sync/sessionCache.ts | 39 +++++- hub/src/sync/sessionModel.test.ts | 129 +++++++++++++++++- hub/src/sync/syncEngine.ts | 10 +- hub/src/web/routes/cli.ts | 4 +- hub/src/web/routes/sessions.test.ts | 63 +++++++++ hub/src/web/routes/sessions.ts | 40 ++++++ shared/src/schemas.ts | 1 + shared/src/socket.ts | 1 + web/src/api/client.ts | 7 + .../AssistantChat/HappyComposer.tsx | 79 ++++++++++- .../codexReasoningEffortOptions.test.ts | 25 ++++ .../codexReasoningEffortOptions.ts | 50 +++++++ web/src/components/SessionChat.tsx | 27 +++- web/src/hooks/mutations/useSessionActions.ts | 19 +++ web/src/hooks/useSSE.ts | 8 +- web/src/lib/locales/en.ts | 1 + web/src/lib/locales/zh-CN.ts | 1 + 34 files changed, 664 insertions(+), 27 deletions(-) create mode 100644 web/src/components/AssistantChat/codexReasoningEffortOptions.test.ts create mode 100644 web/src/components/AssistantChat/codexReasoningEffortOptions.ts diff --git a/cli/src/agent/sessionBase.ts b/cli/src/agent/sessionBase.ts index fabe17f2..850f7adb 100644 --- a/cli/src/agent/sessionBase.ts +++ b/cli/src/agent/sessionBase.ts @@ -1,6 +1,13 @@ import { ApiClient, ApiSessionClient } from '@/lib'; import { MessageQueue2 } from '@/utils/MessageQueue2'; -import type { Metadata, SessionCollaborationMode, SessionEffort, SessionModel, SessionPermissionMode } from '@/api/types'; +import type { + Metadata, + SessionCollaborationMode, + SessionEffort, + SessionModel, + SessionModelReasoningEffort, + SessionPermissionMode +} from '@/api/types'; import { logger } from '@/ui/logger'; export type AgentSessionBaseOptions = { @@ -17,6 +24,7 @@ export type AgentSessionBaseOptions = { applySessionIdToMetadata: (metadata: Metadata, sessionId: string) => Metadata; permissionMode?: SessionPermissionMode; model?: SessionModel; + modelReasoningEffort?: SessionModelReasoningEffort; effort?: SessionEffort; collaborationMode?: SessionCollaborationMode; }; @@ -40,6 +48,7 @@ export class AgentSessionBase { private keepAliveInterval: NodeJS.Timeout | null = null; protected permissionMode?: SessionPermissionMode; protected model?: SessionModel; + protected modelReasoningEffort?: SessionModelReasoningEffort; protected effort?: SessionEffort; protected collaborationMode?: SessionCollaborationMode; @@ -57,6 +66,7 @@ export class AgentSessionBase { this.mode = opts.mode ?? 'local'; this.permissionMode = opts.permissionMode; this.model = opts.model; + this.modelReasoningEffort = opts.modelReasoningEffort; this.effort = opts.effort; this.collaborationMode = opts.collaborationMode; @@ -77,11 +87,12 @@ export class AgentSessionBase { this.client.keepAlive(this.thinking, mode, this.getKeepAliveRuntime()); const permissionLabel = this.permissionMode ?? 'unset'; const modelLabel = this.model === undefined ? 'unset' : (this.model ?? 'auto'); + const modelReasoningEffortLabel = this.modelReasoningEffort === undefined ? 'unset' : (this.modelReasoningEffort ?? 'default'); const effortLabel = this.effort === undefined ? 'unset' : (this.effort ?? 'auto'); const collaborationLabel = this.collaborationMode ?? 'unset'; logger.debug( `[${this.sessionLabel}] Mode switched to ${mode} ` + - `(permissionMode=${permissionLabel}, model=${modelLabel}, effort=${effortLabel}, collaborationMode=${collaborationLabel})` + `(permissionMode=${permissionLabel}, model=${modelLabel}, modelReasoningEffort=${modelReasoningEffortLabel}, effort=${effortLabel}, collaborationMode=${collaborationLabel})` ); this._onModeChange(mode); }; @@ -118,15 +129,23 @@ export class AgentSessionBase { { permissionMode?: SessionPermissionMode model?: SessionModel + modelReasoningEffort?: SessionModelReasoningEffort effort?: SessionEffort collaborationMode?: SessionCollaborationMode } | undefined { - if (this.permissionMode === undefined && this.model === undefined && this.effort === undefined && this.collaborationMode === undefined) { + if ( + this.permissionMode === undefined + && this.model === undefined + && this.modelReasoningEffort === undefined + && this.effort === undefined + && this.collaborationMode === undefined + ) { return undefined; } return { permissionMode: this.permissionMode, model: this.model, + modelReasoningEffort: this.modelReasoningEffort, effort: this.effort, collaborationMode: this.collaborationMode }; @@ -140,6 +159,10 @@ export class AgentSessionBase { return this.model; } + getModelReasoningEffort(): SessionModelReasoningEffort | undefined { + return this.modelReasoningEffort; + } + getEffort(): SessionEffort | undefined { return this.effort; } diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index 984664db..8fc1dfbc 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -23,6 +23,7 @@ export type SessionBootstrapOptions = { tag?: string agentState?: AgentState | null model?: string + modelReasoningEffort?: string effort?: string metadataOverrides?: Partial } @@ -133,6 +134,7 @@ export async function bootstrapSession(options: SessionBootstrapOptions): Promis metadata, state: agentState, model: options.model, + modelReasoningEffort: options.modelReasoningEffort, effort: options.effort }) diff --git a/cli/src/api/api.ts b/cli/src/api/api.ts index 1447f591..614b3d6b 100644 --- a/cli/src/api/api.ts +++ b/cli/src/api/api.ts @@ -19,6 +19,7 @@ export class ApiClient { metadata: Metadata state: AgentState | null model?: string + modelReasoningEffort?: string effort?: string }): Promise { const response = await axios.post( @@ -28,6 +29,7 @@ export class ApiClient { metadata: opts.metadata, agentState: opts.state, model: opts.model, + modelReasoningEffort: opts.modelReasoningEffort, effort: opts.effort }, { @@ -74,6 +76,7 @@ export class ApiClient { thinkingAt: raw.thinkingAt, todos: raw.todos, model: raw.model, + modelReasoningEffort: raw.modelReasoningEffort, effort: raw.effort, permissionMode: raw.permissionMode, collaborationMode: raw.collaborationMode diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 12aff384..795946ae 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -477,6 +477,7 @@ export class ApiSessionClient extends EventEmitter { runtime?: { permissionMode?: SessionPermissionMode model?: SessionModel + modelReasoningEffort?: string | null effort?: string | null collaborationMode?: SessionCollaborationMode } diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index 58bf1ca5..b37d3be1 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -24,6 +24,7 @@ export type { export type SessionPermissionMode = PermissionMode export type SessionCollaborationMode = CodexCollaborationMode export type SessionModel = string | null +export type SessionModelReasoningEffort = string | null export type SessionEffort = string | null export { AgentStateSchema, AttachmentMetadataSchema, MetadataSchema } @@ -100,6 +101,7 @@ export const CreateSessionResponseSchema = z.object({ thinkingAt: z.number(), todos: TodosSchema.optional(), model: z.string().nullable(), + modelReasoningEffort: z.string().nullable(), effort: z.string().nullable(), permissionMode: PermissionModeSchema.optional(), collaborationMode: CodexCollaborationModeSchema.optional() diff --git a/cli/src/codex/codexLocal.ts b/cli/src/codex/codexLocal.ts index b2399744..5e84eb43 100644 --- a/cli/src/codex/codexLocal.ts +++ b/cli/src/codex/codexLocal.ts @@ -2,6 +2,7 @@ import { logger } from '@/ui/logger'; import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard'; import { buildMcpServerConfigArgs, buildDeveloperInstructionsArg } from './utils/codexMcpConfig'; import { codexSystemPrompt } from './utils/systemPrompt'; +import type { ReasoningEffort } from './appServerTypes'; /** * Filter out 'resume' subcommand which is managed internally by hapi. @@ -27,6 +28,7 @@ export async function codexLocal(opts: { sessionId: string | null; path: string; model?: string; + modelReasoningEffort?: ReasoningEffort; sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access'; onSessionFound: (id: string) => void; codexArgs?: string[]; @@ -43,6 +45,10 @@ export async function codexLocal(opts: { args.push('--model', opts.model); } + if (opts.modelReasoningEffort) { + args.push('--model-reasoning-effort', opts.modelReasoningEffort); + } + if (opts.sandbox) { args.push('--sandbox', opts.sandbox); } diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index 9ef714a9..8f05ae91 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -42,6 +42,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch await codexLocal({ path: session.path, sessionId: resumeSessionId, + modelReasoningEffort: session.getModelReasoningEffort() ?? undefined, onSessionFound: handleSessionFound, abort: abortSignal, codexArgs, diff --git a/cli/src/codex/loop.ts b/cli/src/codex/loop.ts index 013fd07c..223807b1 100644 --- a/cli/src/codex/loop.ts +++ b/cli/src/codex/loop.ts @@ -30,6 +30,7 @@ interface LoopOptions { codexCliOverrides?: CodexCliOverrides; permissionMode?: PermissionMode; model?: string; + modelReasoningEffort?: ReasoningEffort; collaborationMode?: CodexCollaborationMode; resumeSessionId?: string; onSessionReady?: (session: CodexSession) => void; @@ -54,6 +55,7 @@ export async function loop(opts: LoopOptions): Promise { codexCliOverrides: opts.codexCliOverrides, permissionMode: opts.permissionMode ?? 'default', model: opts.model, + modelReasoningEffort: opts.modelReasoningEffort, collaborationMode: opts.collaborationMode ?? 'default' }); diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index d94286a1..c62b28ba 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -16,6 +16,8 @@ import type { ReasoningEffort } from './appServerTypes'; export { emitReadyIfIdle } from './utils/emitReadyIfIdle'; +const REASONING_EFFORTS = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']) + export async function runCodex(opts: { startedBy?: 'runner' | 'terminal'; codexArgs?: string[]; @@ -37,7 +39,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 +59,7 @@ export async function runCodex(opts: { let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; let currentModel = opts.model; - const currentModelReasoningEffort = opts.modelReasoningEffort; + let currentModelReasoningEffort: ReasoningEffort | undefined = opts.modelReasoningEffort; let currentCollaborationMode: EnhancedMode['collaborationMode'] = 'default'; const lifecycle = createRunnerLifecycle({ @@ -77,8 +80,13 @@ export async function runCodex(opts: { if (sessionModel !== undefined) { currentModel = sessionModel ?? undefined; } + const sessionModelReasoningEffort = sessionInstance.getModelReasoningEffort(); + if (sessionModelReasoningEffort !== undefined) { + currentModelReasoningEffort = sessionModelReasoningEffort ?? undefined; + } sessionInstance.setPermissionMode(currentPermissionMode); sessionInstance.setModel(currentModel ?? null); + sessionInstance.setModelReasoningEffort(currentModelReasoningEffort ?? null); sessionInstance.setCollaborationMode(currentCollaborationMode); logger.debug( `[Codex] Synced session config for keepalive: ` + @@ -96,6 +104,10 @@ export async function runCodex(opts: { if (sessionModel !== undefined) { currentModel = sessionModel ?? undefined; } + const sessionModelReasoningEffort = sessionWrapperRef.current?.getModelReasoningEffort(); + if (sessionModelReasoningEffort !== undefined) { + currentModelReasoningEffort = sessionModelReasoningEffort ?? undefined; + } const sessionCollaborationMode = sessionWrapperRef.current?.getCollaborationMode(); if (sessionCollaborationMode) { currentCollaborationMode = sessionCollaborationMode; @@ -145,22 +157,42 @@ 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; + }; + 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; modelReasoningEffort?: unknown; collaborationMode?: unknown }; if (config.permissionMode !== undefined) { currentPermissionMode = resolvePermissionMode(config.permissionMode); } + if (config.modelReasoningEffort !== undefined) { + currentModelReasoningEffort = resolveModelReasoningEffort(config.modelReasoningEffort); + } + if (config.collaborationMode !== undefined) { currentCollaborationMode = resolveCollaborationMode(config.collaborationMode); } syncSessionMode(); - return { applied: { permissionMode: currentPermissionMode, collaborationMode: currentCollaborationMode } }; + return { + applied: { + permissionMode: currentPermissionMode, + modelReasoningEffort: currentModelReasoningEffort ?? null, + collaborationMode: currentCollaborationMode + } + }; }); try { @@ -175,6 +207,7 @@ export async function runCodex(opts: { startedBy, permissionMode: currentPermissionMode, model: currentModel, + modelReasoningEffort: currentModelReasoningEffort, collaborationMode: currentCollaborationMode, resumeSessionId: opts.resumeSessionId, onModeChange: createModeChangeHandler(session), diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts index 5fb1c3ea..c490d748 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -4,7 +4,7 @@ import { AgentSessionBase } from '@/agent/sessionBase'; import type { EnhancedMode, PermissionMode } from './loop'; import type { CodexCliOverrides } from './utils/codexCliOverrides'; import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; -import type { SessionModel } from '@/api/types'; +import type { SessionModel, SessionModelReasoningEffort } from '@/api/types'; type LocalLaunchFailure = { message: string; @@ -33,6 +33,7 @@ export class CodexSession extends AgentSessionBase { codexCliOverrides?: CodexCliOverrides; permissionMode?: PermissionMode; model?: SessionModel; + modelReasoningEffort?: SessionModelReasoningEffort; collaborationMode?: EnhancedMode['collaborationMode']; }) { super({ @@ -52,6 +53,7 @@ export class CodexSession extends AgentSessionBase { }), permissionMode: opts.permissionMode, model: opts.model, + modelReasoningEffort: opts.modelReasoningEffort, collaborationMode: opts.collaborationMode }); @@ -61,6 +63,7 @@ export class CodexSession extends AgentSessionBase { this.startingMode = opts.startingMode; this.permissionMode = opts.permissionMode; this.model = opts.model; + this.modelReasoningEffort = opts.modelReasoningEffort; this.collaborationMode = opts.collaborationMode; } @@ -72,6 +75,10 @@ export class CodexSession extends AgentSessionBase { this.model = model; }; + setModelReasoningEffort = (modelReasoningEffort: SessionModelReasoningEffort): void => { + this.modelReasoningEffort = modelReasoningEffort; + }; + setCollaborationMode = (mode: EnhancedMode['collaborationMode']): void => { this.collaborationMode = mode; }; diff --git a/hub/src/socket/handlers/cli/index.ts b/hub/src/socket/handlers/cli/index.ts index 5c2ec6b5..2d0ca220 100644 --- a/hub/src/socket/handlers/cli/index.ts +++ b/hub/src/socket/handlers/cli/index.ts @@ -17,6 +17,7 @@ type SessionAlivePayload = { mode?: 'local' | 'remote' permissionMode?: PermissionMode model?: string | null + modelReasoningEffort?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode } diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index 8860421e..f7a76ec6 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -17,6 +17,7 @@ type SessionAlivePayload = { mode?: 'local' | 'remote' permissionMode?: PermissionMode model?: string | null + modelReasoningEffort?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode } diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index f70b3db2..f9ac76ba 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,6 +128,12 @@ export class Store { return } + if (currentVersion === 6 && SCHEMA_VERSION === 7) { + this.migrateFromV6ToV7() + this.setUserVersion(SCHEMA_VERSION) + return + } + if (currentVersion === 4 && SCHEMA_VERSION === 6) { this.migrateFromV4ToV5() this.migrateFromV5ToV6() @@ -135,6 +141,21 @@ export class Store { return } + if (currentVersion === 4 && SCHEMA_VERSION === 7) { + this.migrateFromV4ToV5() + this.migrateFromV5ToV6() + this.migrateFromV6ToV7() + this.setUserVersion(SCHEMA_VERSION) + return + } + + if (currentVersion === 5 && SCHEMA_VERSION === 7) { + this.migrateFromV5ToV6() + this.migrateFromV6ToV7() + this.setUserVersion(SCHEMA_VERSION) + return + } + if (currentVersion !== SCHEMA_VERSION) { throw this.buildSchemaMismatchError(currentVersion) } @@ -156,6 +177,7 @@ export class Store { agent_state TEXT, agent_state_version INTEGER DEFAULT 1, model TEXT, + model_reasoning_effort TEXT, effort TEXT, todos TEXT, todos_updated_at INTEGER, @@ -333,6 +355,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 831c8e09..c6af8ad4 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( @@ -65,6 +67,15 @@ export class SessionStore { return setSessionModel(this.db, id, model, namespace, options) } + setSessionModelReasoningEffort( + id: string, + modelReasoningEffort: string | null, + namespace: string, + options?: { touchUpdatedAt?: boolean } + ): boolean { + return setSessionModelReasoningEffort(this.db, id, modelReasoningEffort, namespace, options) + } + setSessionEffort(id: string, effort: string | null, namespace: string, options?: { touchUpdatedAt?: boolean }): boolean { return setSessionEffort(this.db, id, effort, namespace, options) } diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts index 4e30f6d6..95a33c5a 100644 --- a/hub/src/store/sessions.ts +++ b/hub/src/store/sessions.ts @@ -17,6 +17,7 @@ type DbSessionRow = { agent_state: string | null agent_state_version: number model: string | null + model_reasoning_effort: string | null effort: string | null todos: string | null todos_updated_at: number | null @@ -40,6 +41,7 @@ function toStoredSession(row: DbSessionRow): StoredSession { agentState: safeJsonParse(row.agent_state), agentStateVersion: row.agent_state_version, model: row.model, + modelReasoningEffort: row.model_reasoning_effort, effort: row.effort, todos: safeJsonParse(row.todos), todosUpdatedAt: row.todos_updated_at, @@ -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' @@ -80,6 +83,7 @@ export function getOrCreateSession( metadata, metadata_version, agent_state, agent_state_version, model, + model_reasoning_effort, effort, todos, todos_updated_at, active, active_at, seq @@ -88,6 +92,7 @@ export function getOrCreateSession( @metadata, 1, @agent_state, 1, @model, + @model_reasoning_effort, @effort, NULL, NULL, 0, NULL, 0 @@ -101,6 +106,7 @@ export function getOrCreateSession( metadata: metadataJson, agent_state: agentStateJson, model: model ?? null, + model_reasoning_effort: modelReasoningEffort ?? null, effort: effort ?? null }) @@ -270,6 +276,39 @@ export function setSessionModel( } } +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 setSessionEffort( db: Database, id: string, diff --git a/hub/src/store/types.ts b/hub/src/store/types.ts index 394c86f3..9297a5e6 100644 --- a/hub/src/store/types.ts +++ b/hub/src/store/types.ts @@ -10,6 +10,7 @@ export type StoredSession = { agentState: unknown | null agentStateVersion: number model: string | null + modelReasoningEffort: string | null effort: string | null todos: unknown | null todosUpdatedAt: number | null diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index d59ff3b6..5b16128f 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -94,6 +94,7 @@ export class RpcGateway { config: { permissionMode?: PermissionMode model?: string | null + modelReasoningEffort?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode } diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index b4abd386..ea4d3d57 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -62,9 +62,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') })() } @@ -136,6 +137,7 @@ export class SessionCache { todos, teamState, model: stored.model, + modelReasoningEffort: stored.modelReasoningEffort, effort: stored.effort, permissionMode: existing?.permissionMode, collaborationMode: existing?.collaborationMode @@ -160,6 +162,7 @@ export class SessionCache { mode?: 'local' | 'remote' permissionMode?: PermissionMode model?: string | null + modelReasoningEffort?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode }): void { @@ -173,6 +176,7 @@ export class SessionCache { const wasThinking = session.thinking const previousPermissionMode = session.permissionMode const previousModel = session.model + const previousModelReasoningEffort = session.modelReasoningEffort const previousEffort = session.effort const previousCollaborationMode = session.collaborationMode @@ -191,6 +195,14 @@ export class SessionCache { } session.model = payload.model } + if (payload.modelReasoningEffort !== undefined) { + if (payload.modelReasoningEffort !== session.modelReasoningEffort) { + this.store.sessions.setSessionModelReasoningEffort(payload.sid, payload.modelReasoningEffort, session.namespace, { + touchUpdatedAt: false + }) + } + session.modelReasoningEffort = payload.modelReasoningEffort + } if (payload.effort !== undefined) { if (payload.effort !== session.effort) { this.store.sessions.setSessionEffort(payload.sid, payload.effort, session.namespace, { @@ -207,6 +219,7 @@ export class SessionCache { const lastBroadcastAt = this.lastBroadcastAtBySessionId.get(session.id) ?? 0 const modeChanged = previousPermissionMode !== session.permissionMode || previousModel !== session.model + || previousModelReasoningEffort !== session.modelReasoningEffort || previousEffort !== session.effort || previousCollaborationMode !== session.collaborationMode const shouldBroadcast = (!wasActive && session.active) @@ -225,6 +238,7 @@ export class SessionCache { thinking: session.thinking, permissionMode: session.permissionMode, model: session.model, + modelReasoningEffort: session.modelReasoningEffort, effort: session.effort, collaborationMode: session.collaborationMode } @@ -283,6 +297,7 @@ export class SessionCache { config: { permissionMode?: PermissionMode model?: string | null + modelReasoningEffort?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode } @@ -306,6 +321,17 @@ export class SessionCache { } session.model = config.model } + if (config.modelReasoningEffort !== undefined) { + if (config.modelReasoningEffort !== session.modelReasoningEffort) { + 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 + } if (config.effort !== undefined) { if (config.effort !== session.effort) { const updated = this.store.sessions.setSessionEffort(sessionId, config.effort, session.namespace, { @@ -417,6 +443,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 (newStored.effort === null && oldStored.effort !== null) { const updated = this.store.sessions.setSessionEffort(newSessionId, oldStored.effort, namespace, { touchUpdatedAt: false diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 4d678136..a923118b 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -52,6 +52,25 @@ describe('session model', () => { expect(toSessionSummary(session).effort).toBe('high') }) + it('persists explicit model reasoning effort on Codex sessions', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-model-reasoning-effort', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default', + 'gpt-5.4', + undefined, + 'xhigh' + ) + + expect(session.modelReasoningEffort).toBe('xhigh') + expect(store.sessions.getSession(session.id)?.modelReasoningEffort).toBe('xhigh') + }) + it('preserves model from old session when merging into resumed session', async () => { const store = new Store(':memory:') const events: SyncEvent[] = [] @@ -146,6 +165,30 @@ describe('session model', () => { expect(store.sessions.getSession(session.id)?.effort).toBeNull() }) + it('persists applied session model reasoning effort updates, including clear-to-default', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-model-reasoning-config', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default', + 'gpt-5.4', + undefined, + 'high' + ) + + cache.applySessionConfig(session.id, { modelReasoningEffort: 'xhigh' }) + expect(cache.getSession(session.id)?.modelReasoningEffort).toBe('xhigh') + expect(store.sessions.getSession(session.id)?.modelReasoningEffort).toBe('xhigh') + + cache.applySessionConfig(session.id, { modelReasoningEffort: null }) + expect(cache.getSession(session.id)?.modelReasoningEffort).toBeNull() + expect(store.sessions.getSession(session.id)?.modelReasoningEffort).toBeNull() + }) + it('persists keepalive effort changes, including clearing the effort', () => { const store = new Store(':memory:') const events: SyncEvent[] = [] @@ -171,6 +214,32 @@ describe('session model', () => { expect(store.sessions.getSession(session.id)?.effort).toBeNull() }) + it('persists keepalive model reasoning effort changes, including clearing the value', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-model-reasoning-heartbeat', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default', + 'gpt-5.4', + undefined, + 'high' + ) + + cache.handleSessionAlive({ + sid: session.id, + time: Date.now(), + thinking: false, + modelReasoningEffort: null + }) + + expect(cache.getSession(session.id)?.modelReasoningEffort).toBeNull() + expect(store.sessions.getSession(session.id)?.modelReasoningEffort).toBeNull() + }) + it('tracks collaboration mode updates in memory from config and keepalive', () => { const store = new Store(':memory:') const events: SyncEvent[] = [] @@ -228,13 +297,14 @@ describe('session model', () => { engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) let capturedModel: string | undefined + let capturedModelReasoningEffort: string | undefined let capturedEffort: string | undefined ;(engine as any).rpcGateway.spawnSession = async ( _machineId: string, _directory: string, _agent: string, model?: string, - _modelReasoningEffort?: string, + modelReasoningEffort?: string, _yolo?: boolean, _sessionType?: string, _worktreeName?: string, @@ -242,6 +312,7 @@ describe('session model', () => { effort?: string ) => { capturedModel = model + capturedModelReasoningEffort = modelReasoningEffort capturedEffort = effort return { type: 'success', sessionId: session.id } } @@ -251,12 +322,68 @@ describe('session model', () => { expect(result).toEqual({ type: 'success', sessionId: session.id }) expect(capturedModel).toBe('gpt-5.4') + expect(capturedModelReasoningEffort).toBeUndefined() expect(capturedEffort).toBeUndefined() } finally { engine.stop() } }) + it('passes the stored model reasoning effort when respawning a resumed Codex session', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'session-model-reasoning-resume', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default', + 'gpt-5.4', + undefined, + 'xhigh' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + + let capturedModelReasoningEffort: string | undefined + ;(engine as any).rpcGateway.spawnSession = async ( + _machineId: string, + _directory: string, + _agent: string, + _model?: string, + modelReasoningEffort?: string + ) => { + capturedModelReasoningEffort = modelReasoningEffort + return { type: 'success', sessionId: session.id } + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.resumeSession(session.id, 'default') + + expect(result).toEqual({ type: 'success', sessionId: session.id }) + expect(capturedModelReasoningEffort).toBe('xhigh') + } finally { + engine.stop() + } + }) + it('passes resume session ID to rpc gateway when resuming claude session', async () => { const store = new Store(':memory:') const engine = new SyncEngine( diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index ffc5792b..11275952 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -188,6 +188,7 @@ export class SyncEngine { mode?: 'local' | 'remote' permissionMode?: PermissionMode model?: string | null + modelReasoningEffort?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode }): void { @@ -222,9 +223,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 { @@ -295,6 +297,7 @@ export class SyncEngine { config: { permissionMode?: PermissionMode model?: string | null + modelReasoningEffort?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode } @@ -307,6 +310,7 @@ export class SyncEngine { applied?: { permissionMode?: Session['permissionMode'] model?: Session['model'] + modelReasoningEffort?: Session['modelReasoningEffort'] effort?: Session['effort'] collaborationMode?: Session['collaborationMode'] } @@ -408,7 +412,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 8a81041f..f464b99b 100644 --- a/hub/src/web/routes/cli.ts +++ b/hub/src/web/routes/cli.ts @@ -13,6 +13,7 @@ const createOrLoadSessionSchema = z.object({ metadata: z.unknown(), agentState: z.unknown().nullable().optional(), model: z.string().optional(), + modelReasoningEffort: z.string().optional(), effort: z.string().optional() }) @@ -108,7 +109,8 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono): Session { thinking: false, thinkingAt: 1, model: 'gpt-5.4', + modelReasoningEffort: null, effort: null, permissionMode: 'default', collaborationMode: 'default' @@ -132,6 +133,68 @@ describe('sessions routes', () => { ]) }) + it('rejects model reasoning effort changes for non-Codex sessions', async () => { + const session = createSession({ + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'claude' + } + }) + const { app, applySessionConfigCalls } = createApp(session) + + const response = await app.request('/api/sessions/session-1/model-reasoning-effort', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ modelReasoningEffort: 'high' }) + }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: 'Model reasoning effort is only supported for Codex sessions' + }) + expect(applySessionConfigCalls).toEqual([]) + }) + + it('rejects model reasoning effort changes for local Codex sessions', async () => { + const session = createSession({ + agentState: { + controlledByUser: true, + requests: {}, + completedRequests: {} + } + }) + const { app, applySessionConfigCalls } = createApp(session) + + const response = await app.request('/api/sessions/session-1/model-reasoning-effort', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ modelReasoningEffort: 'high' }) + }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: 'Model reasoning effort can only be changed for remote Codex sessions' + }) + expect(applySessionConfigCalls).toEqual([]) + }) + + it('applies model reasoning effort changes for remote Codex sessions', async () => { + const { app, applySessionConfigCalls } = createApp(createSession()) + + const response = await app.request('/api/sessions/session-1/model-reasoning-effort', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ modelReasoningEffort: 'xhigh' }) + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true }) + expect(applySessionConfigCalls).toEqual([ + ['session-1', { modelReasoningEffort: 'xhigh' }] + ]) + }) + it('rejects effort changes for non-Claude sessions', async () => { const { app, applySessionConfigCalls } = createApp(createSession()) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 37eee90c..803fadff 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -18,6 +18,10 @@ const modelSchema = z.object({ model: z.string().trim().min(1).nullable() }) +const modelReasoningEffortSchema = z.object({ + modelReasoningEffort: z.string().trim().min(1).nullable() +}) + const effortSchema = z.object({ effort: z.string().trim().min(1).nullable() }) @@ -329,6 +333,42 @@ export function createSessionsRoutes(getSyncEngine: () => 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 flavor = sessionResult.session.metadata?.flavor ?? 'claude' + if (flavor !== 'codex') { + return c.json({ error: 'Model reasoning effort is only supported for Codex sessions' }, 400) + } + if (sessionResult.session.agentState?.controlledByUser === true) { + return c.json({ error: 'Model reasoning effort can only be changed for remote Codex sessions' }, 409) + } + + const body = await c.req.json().catch(() => null) + const parsed = modelReasoningEffortSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 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.post('/sessions/:id/effort', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 01db07cd..de212573 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -176,6 +176,7 @@ export const SessionSchema = z.object({ todos: TodosSchema.optional(), teamState: TeamStateSchema.optional(), model: z.string().nullable(), + modelReasoningEffort: z.string().nullable(), effort: z.string().nullable(), permissionMode: PermissionModeSchema.optional(), collaborationMode: CodexCollaborationModeSchema.optional() diff --git a/shared/src/socket.ts b/shared/src/socket.ts index 6feba3a4..39d78d16 100644 --- a/shared/src/socket.ts +++ b/shared/src/socket.ts @@ -140,6 +140,7 @@ export interface ClientToServerEvents { mode?: 'local' | 'remote' permissionMode?: PermissionMode model?: string | null + modelReasoningEffort?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode }) => void diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 163eb206..7f1083c8 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -327,6 +327,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 setEffort(sessionId: string, effort: string | null): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/effort`, { method: 'POST', diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index d7914074..d0f00faa 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -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 { getCodexComposerReasoningEffortOptions } from './codexReasoningEffortOptions' export interface TextInputState { text: string @@ -43,6 +44,7 @@ export function HappyComposer(props: { permissionMode?: PermissionMode collaborationMode?: CodexCollaborationMode model?: string | null + modelReasoningEffort?: string | null effort?: string | null active?: boolean allowSendWhenInactive?: boolean @@ -55,6 +57,7 @@ export function HappyComposer(props: { onCollaborationModeChange?: (mode: CodexCollaborationMode) => void onPermissionModeChange?: (mode: PermissionMode) => void onModelChange?: (model: string | null) => void + onModelReasoningEffortChange?: (modelReasoningEffort: string | null) => void onEffortChange?: (effort: string | null) => void onSwitchToRemote?: () => void onTerminal?: () => void @@ -73,6 +76,7 @@ export function HappyComposer(props: { permissionMode: rawPermissionMode, collaborationMode: rawCollaborationMode, model: rawModel, + modelReasoningEffort: rawModelReasoningEffort, effort: rawEffort, active = true, allowSendWhenInactive = false, @@ -85,6 +89,7 @@ export function HappyComposer(props: { onCollaborationModeChange, onPermissionModeChange, onModelChange, + onModelReasoningEffortChange, onEffortChange, onSwitchToRemote, onTerminal, @@ -101,6 +106,7 @@ export function HappyComposer(props: { const permissionMode = rawPermissionMode ?? 'default' const collaborationMode = rawCollaborationMode ?? 'default' const model = rawModel ?? null + const modelReasoningEffort = rawModelReasoningEffort ?? null const effort = rawEffort ?? null const api = useAssistantApi() @@ -271,6 +277,10 @@ export function HappyComposer(props: { () => getModelOptionsForFlavor(agentFlavor, model), [agentFlavor, model] ) + const codexReasoningEffortOptions = useMemo( + () => agentFlavor === 'codex' ? getCodexComposerReasoningEffortOptions(modelReasoningEffort) : [], + [agentFlavor, modelReasoningEffort] + ) const claudeEffortOptions = useMemo( () => getClaudeComposerEffortOptions(effort), [effort] @@ -438,6 +448,13 @@ export function HappyComposer(props: { haptic('light') }, [onModelChange, controlsDisabled, haptic]) + const handleModelReasoningEffortChange = useCallback((nextModelReasoningEffort: string | null) => { + if (!onModelReasoningEffortChange || controlsDisabled) return + onModelReasoningEffortChange(nextModelReasoningEffort) + setShowSettings(false) + haptic('light') + }, [onModelReasoningEffortChange, controlsDisabled, haptic]) + const handleEffortChange = useCallback((nextEffort: string | null) => { if (!onEffortChange || controlsDisabled) return onEffortChange(nextEffort) @@ -448,8 +465,15 @@ export function HappyComposer(props: { const showCollaborationSettings = Boolean(onCollaborationModeChange && collaborationModeOptions.length > 0) const showPermissionSettings = Boolean(onPermissionModeChange && permissionModeOptions.length > 0) const showModelSettings = Boolean(onModelChange && supportsModelChange(agentFlavor)) + const showModelReasoningEffortSettings = Boolean(onModelReasoningEffortChange && codexReasoningEffortOptions.length > 0) const showEffortSettings = Boolean(onEffortChange && supportsEffort(agentFlavor)) - const showSettingsButton = Boolean(showCollaborationSettings || showPermissionSettings || showModelSettings || showEffortSettings) + const showSettingsButton = Boolean( + showCollaborationSettings + || showPermissionSettings + || showModelSettings + || showModelReasoningEffortSettings + || showEffortSettings + ) const showAbortButton = true const voiceEnabled = Boolean(onVoiceToggle) @@ -458,7 +482,7 @@ export function HappyComposer(props: { }, [api]) const overlays = useMemo(() => { - if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showEffortSettings)) { + if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showModelReasoningEffortSettings || showEffortSettings)) { return (
@@ -499,7 +523,7 @@ export function HappyComposer(props: {
) : null} - {showCollaborationSettings && (showPermissionSettings || showModelSettings || showEffortSettings) ? ( + {showCollaborationSettings && (showPermissionSettings || showModelSettings || showModelReasoningEffortSettings || showEffortSettings) ? (
) : null} @@ -540,7 +564,7 @@ export function HappyComposer(props: {
) : null} - {(showCollaborationSettings || showPermissionSettings) && (showModelSettings || showEffortSettings) ? ( + {(showCollaborationSettings || showPermissionSettings) && (showModelSettings || showModelReasoningEffortSettings || showEffortSettings) ? (
) : null} @@ -581,7 +605,48 @@ export function HappyComposer(props: {
) : null} - {showModelSettings && showEffortSettings ? ( + {(showModelSettings || showModelReasoningEffortSettings) && showEffortSettings ? ( +
+ ) : null} + + {showModelReasoningEffortSettings ? ( +
+
+ {t('misc.reasoningEffort')} +
+ {codexReasoningEffortOptions.map((option) => ( + + ))} +
+ ) : null} + + {showModelReasoningEffortSettings && showEffortSettings ? (
) : null} @@ -646,8 +711,10 @@ export function HappyComposer(props: { showCollaborationSettings, showPermissionSettings, showModelSettings, + showModelReasoningEffortSettings, showEffortSettings, claudeModelOptions, + codexReasoningEffortOptions, claudeEffortOptions, suggestions, selectedIndex, @@ -655,12 +722,14 @@ export function HappyComposer(props: { collaborationMode, permissionMode, model, + modelReasoningEffort, effort, collaborationModeOptions, permissionModeOptions, handleCollaborationChange, handlePermissionChange, handleModelChange, + handleModelReasoningEffortChange, handleEffortChange, handleSuggestionSelect, t diff --git a/web/src/components/AssistantChat/codexReasoningEffortOptions.test.ts b/web/src/components/AssistantChat/codexReasoningEffortOptions.test.ts new file mode 100644 index 00000000..88a3272e --- /dev/null +++ b/web/src/components/AssistantChat/codexReasoningEffortOptions.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { getCodexComposerReasoningEffortOptions } from './codexReasoningEffortOptions' + +describe('getCodexComposerReasoningEffortOptions', () => { + it('includes the default option and preset values', () => { + expect(getCodexComposerReasoningEffortOptions(null)).toEqual([ + { value: null, label: 'Default' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'xhigh', label: 'XHigh' } + ]) + }) + + it('preserves non-preset current values', () => { + expect(getCodexComposerReasoningEffortOptions('minimal')).toEqual([ + { value: null, label: 'Default' }, + { value: 'minimal', label: 'Minimal' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'xhigh', label: 'XHigh' } + ]) + }) +}) diff --git a/web/src/components/AssistantChat/codexReasoningEffortOptions.ts b/web/src/components/AssistantChat/codexReasoningEffortOptions.ts new file mode 100644 index 00000000..85789aaa --- /dev/null +++ b/web/src/components/AssistantChat/codexReasoningEffortOptions.ts @@ -0,0 +1,50 @@ +export type CodexComposerReasoningEffortOption = { + value: string | null + label: string +} + +const CODEX_REASONING_EFFORT_PRESETS = ['low', 'medium', 'high', 'xhigh'] as const +const CODEX_REASONING_EFFORT_LABELS: Record<(typeof CODEX_REASONING_EFFORT_PRESETS)[number], string> = { + low: 'Low', + medium: 'Medium', + high: 'High', + xhigh: 'XHigh' +} + +function normalizeCodexComposerReasoningEffort(effort?: string | null): string | null { + const trimmedEffort = effort?.trim().toLowerCase() + if (!trimmedEffort || trimmedEffort === 'default') { + return null + } + + return trimmedEffort +} + +function formatCodexReasoningEffortLabel(effort: string): string { + return CODEX_REASONING_EFFORT_LABELS[effort as keyof typeof CODEX_REASONING_EFFORT_LABELS] + ?? `${effort.charAt(0).toUpperCase()}${effort.slice(1)}` +} + +export function getCodexComposerReasoningEffortOptions(currentEffort?: string | null): CodexComposerReasoningEffortOption[] { + const normalizedCurrentEffort = normalizeCodexComposerReasoningEffort(currentEffort) + const options: CodexComposerReasoningEffortOption[] = [ + { value: null, label: 'Default' } + ] + + if ( + normalizedCurrentEffort + && !CODEX_REASONING_EFFORT_PRESETS.includes(normalizedCurrentEffort as typeof CODEX_REASONING_EFFORT_PRESETS[number]) + ) { + options.push({ + value: normalizedCurrentEffort, + label: formatCodexReasoningEffortLabel(normalizedCurrentEffort) + }) + } + + options.push(...CODEX_REASONING_EFFORT_PRESETS.map((effort) => ({ + value: effort, + label: CODEX_REASONING_EFFORT_LABELS[effort] + }))) + + return options +} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 17dd463c..1180aeb3 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -63,7 +63,15 @@ 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, + setModelReasoningEffort, + setEffort + } = useSessionActions( props.api, props.session.id, agentFlavor, @@ -246,6 +254,17 @@ export function SessionChat(props: { } }, [setModel, 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]) + const handleEffortChange = useCallback(async (effort: string | null) => { try { await setEffort(effort) @@ -373,6 +392,7 @@ export function SessionChat(props: { permissionMode={props.session.permissionMode} collaborationMode={codexCollaborationModeSupported ? props.session.collaborationMode : undefined} model={props.session.model} + modelReasoningEffort={agentFlavor === 'codex' ? props.session.modelReasoningEffort : undefined} effort={props.session.effort} agentFlavor={agentFlavor} active={props.session.active} @@ -389,6 +409,11 @@ export function SessionChat(props: { } onPermissionModeChange={handlePermissionModeChange} onModelChange={handleModelChange} + onModelReasoningEffortChange={ + agentFlavor === 'codex' && props.session.active && !controlledByUser + ? handleModelReasoningEffortChange + : undefined + } onEffortChange={handleEffortChange} onSwitchToRemote={handleSwitchToRemote} onTerminal={props.session.active && terminalSupported ? handleViewTerminal : undefined} diff --git a/web/src/hooks/mutations/useSessionActions.ts b/web/src/hooks/mutations/useSessionActions.ts index 3f27e0e2..1ba66429 100644 --- a/web/src/hooks/mutations/useSessionActions.ts +++ b/web/src/hooks/mutations/useSessionActions.ts @@ -18,6 +18,7 @@ export function useSessionActions( setPermissionMode: (mode: PermissionMode) => Promise setCollaborationMode: (mode: CodexCollaborationMode) => Promise setModel: (model: string | null) => Promise + setModelReasoningEffort: (modelReasoningEffort: string | null) => Promise setEffort: (effort: string | null) => Promise renameSession: (name: string) => Promise deleteSession: () => Promise @@ -100,6 +101,22 @@ export function useSessionActions( onSuccess: () => void invalidateSession(), }) + const modelReasoningEffortMutation = useMutation({ + mutationFn: async (modelReasoningEffort: string | null) => { + if (!api || !sessionId) { + throw new Error('Session unavailable') + } + if (agentFlavor !== 'codex') { + throw new Error('Model reasoning effort is only supported for Codex sessions') + } + if (!codexCollaborationModeSupported) { + throw new Error('Model reasoning effort is only supported for remote Codex sessions') + } + await api.setModelReasoningEffort(sessionId, modelReasoningEffort) + }, + onSuccess: () => void invalidateSession(), + }) + const effortMutation = useMutation({ mutationFn: async (effort: string | null) => { if (!api || !sessionId) { @@ -142,6 +159,7 @@ export function useSessionActions( setPermissionMode: permissionMutation.mutateAsync, setCollaborationMode: collaborationMutation.mutateAsync, setModel: modelMutation.mutateAsync, + setModelReasoningEffort: modelReasoningEffortMutation.mutateAsync, setEffort: effortMutation.mutateAsync, renameSession: renameMutation.mutateAsync, deleteSession: deleteMutation.mutateAsync, @@ -151,6 +169,7 @@ export function useSessionActions( || permissionMutation.isPending || collaborationMutation.isPending || modelMutation.isPending + || modelReasoningEffortMutation.isPending || effortMutation.isPending || renameMutation.isPending || deleteMutation.isPending, diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index aa166419..7b43260c 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -30,7 +30,7 @@ const RECONNECT_MAX_DELAY_MS = 30_000 const RECONNECT_JITTER_MS = 500 const INVALIDATION_BATCH_MS = 16 -type SessionPatch = Partial> +type SessionPatch = Partial> function sortSessionSummaries(left: SessionSummary, right: SessionSummary): number { if (left.active !== right.active) { @@ -85,6 +85,10 @@ function getSessionPatch(value: unknown): SessionPatch | null { patch.model = value.model hasKnownPatch = true } + if (value.modelReasoningEffort === null || typeof value.modelReasoningEffort === 'string') { + patch.modelReasoningEffort = value.modelReasoningEffort + hasKnownPatch = true + } if (value.effort === null || typeof value.effort === 'string') { patch.effort = value.effort hasKnownPatch = true @@ -105,7 +109,7 @@ function hasUnknownSessionPatchKeys(value: unknown): boolean { if (!hasRecordShape(value)) { return false } - const knownKeys = new Set(['active', 'thinking', 'activeAt', 'updatedAt', 'model', 'effort', 'permissionMode', 'collaborationMode']) + const knownKeys = new Set(['active', 'thinking', 'activeAt', 'updatedAt', 'model', 'modelReasoningEffort', 'effort', 'permissionMode', 'collaborationMode']) return Object.keys(value).some((key) => !knownKeys.has(key)) } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index c8eff291..77b5e1fe 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -278,6 +278,7 @@ export default { 'misc.collaborationMode': 'Collaboration Mode', 'misc.permissionMode': 'Permission Mode', 'misc.model': 'Model', + 'misc.reasoningEffort': 'Reasoning Effort', 'misc.effort': 'Effort', 'misc.loading': 'Loading…', 'misc.loadOlder': 'Load older', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 32eaff2f..ca698dce 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -280,6 +280,7 @@ export default { 'misc.collaborationMode': '协作模式', 'misc.permissionMode': '权限模式', 'misc.model': '模型', + 'misc.reasoningEffort': '推理强度', 'misc.effort': '思考强度', 'misc.loading': '加载中…', 'misc.loadOlder': '加载更早的',