diff --git a/cli/src/codex/loop.ts b/cli/src/codex/loop.ts index a7c489f1..3f3d0c3d 100644 --- a/cli/src/codex/loop.ts +++ b/cli/src/codex/loop.ts @@ -11,6 +11,9 @@ import type { CodexCollaborationMode, CodexPermissionMode } from '@hapi/protocol export type PermissionMode = CodexPermissionMode; +/** Codex response style. Omit from mode to inherit config.toml / thread default. */ +export type CodexPersonality = 'friendly' | 'pragmatic' | 'none'; + export interface EnhancedMode { permissionMode: PermissionMode; model?: string; @@ -22,6 +25,8 @@ export interface EnhancedMode { * `'fast'` enables Fast mode, `null` selects the standard tier explicitly. */ serviceTier?: string | null; + /** When set, forwarded to app-server thread/turn params. */ + personality?: CodexPersonality; } interface LoopOptions { diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index ef5dd386..aa3da7cb 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -1,6 +1,6 @@ import { logger } from '@/ui/logger'; import { randomUUID } from 'node:crypto'; -import { loop, type EnhancedMode, type PermissionMode } from './loop'; +import { loop, type CodexPersonality, type EnhancedMode, type PermissionMode } from './loop'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import { hashObject } from '@/utils/deterministicJson'; import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; @@ -74,7 +74,8 @@ export async function runCodex(opts: { modelReasoningEffort: mode.modelReasoningEffort, collaborationMode: mode.collaborationMode, proactiveMultiAgent: mode.proactiveMultiAgent, - serviceTier: mode.serviceTier + serviceTier: mode.serviceTier, + personality: mode.personality })); const codexCliOverrides = parseCodexCliOverrides(opts.codexArgs); @@ -100,6 +101,8 @@ export async function runCodex(opts: { // thread immediately runs with the right tier; otherwise seed from the // persisted session. A persisted/absent `null` stays untouched (omitted). let currentServiceTier: string | null | undefined = opts.serviceTier ?? sessionInfo.serviceTier ?? undefined; + /** In-session override only. Undefined = omit app-server field (inherit Codex config/thread). */ + let currentPersonality: CodexPersonality | undefined; const lifecycle = createRunnerLifecycle({ session, @@ -146,6 +149,7 @@ export async function runCodex(opts: { collaborationMode?: EnhancedMode['collaborationMode']; serviceTier?: string | null; proactiveMultiAgent?: boolean; + personality?: CodexPersonality; } | undefined): void => { if (!updates) return; if (updates.permissionMode !== undefined) { @@ -166,6 +170,9 @@ export async function runCodex(opts: { if (updates.proactiveMultiAgent !== undefined) { currentProactiveMultiAgent = updates.proactiveMultiAgent; } + if (updates.personality !== undefined) { + currentPersonality = updates.personality; + } applyCurrentConfigToSession(); }; @@ -207,7 +214,8 @@ export async function runCodex(opts: { model: currentModel, modelReasoningEffort: currentModelReasoningEffort ?? undefined, serviceTier: currentServiceTier, - proactiveMultiAgent: currentProactiveMultiAgent + proactiveMultiAgent: currentProactiveMultiAgent, + personality: currentPersonality }); if (slash.kind === 'goal') { if (slash.message) { @@ -227,7 +235,8 @@ export async function runCodex(opts: { model: currentModel, modelReasoningEffort: currentModelReasoningEffort ?? undefined, collaborationMode: currentCollaborationMode, - serviceTier: currentServiceTier + serviceTier: currentServiceTier, + personality: currentPersonality }, localId); return; } @@ -267,7 +276,8 @@ export async function runCodex(opts: { modelReasoningEffort: currentModelReasoningEffort ?? undefined, collaborationMode: currentCollaborationMode, proactiveMultiAgent: currentProactiveMultiAgent, - serviceTier: currentServiceTier + serviceTier: currentServiceTier, + personality: currentPersonality }; if (isolatedCommandText) { messageQueue.pushIsolateAndClear(isolatedCommandText, enhancedMode, localId); @@ -282,7 +292,8 @@ export async function runCodex(opts: { modelReasoningEffort: currentModelReasoningEffort ?? undefined, collaborationMode: currentCollaborationMode, proactiveMultiAgent: currentProactiveMultiAgent, - serviceTier: currentServiceTier + serviceTier: currentServiceTier, + personality: currentPersonality }; messageQueue.push(formatMessageWithAttachments(message.content.text, message.content.attachments), enhancedMode, localId); } diff --git a/cli/src/codex/utils/appServerConfig.test.ts b/cli/src/codex/utils/appServerConfig.test.ts index b30b34a2..8feba235 100644 --- a/cli/src/codex/utils/appServerConfig.test.ts +++ b/cli/src/codex/utils/appServerConfig.test.ts @@ -241,6 +241,41 @@ describe('appServerConfig', () => { expect('serviceTier' in nullParams).toBe(false); }); + it('forwards personality only when explicitly set on the mode', () => { + const omitted = buildTurnStartParams({ + threadId: 'thread-1', + message: 'hello', + cwd: '/workspace/project', + mode: { permissionMode: 'default', model: 'gpt-5.5', collaborationMode: 'default' } + }); + expect('personality' in omitted).toBe(false); + + const set = buildTurnStartParams({ + threadId: 'thread-1', + message: 'hello', + cwd: '/workspace/project', + mode: { + permissionMode: 'default', + model: 'gpt-5.5', + collaborationMode: 'default', + personality: 'pragmatic' + } + }); + expect(set.personality).toBe('pragmatic'); + + const thread = buildThreadStartParams({ + cwd: '/workspace/project', + mode: { + permissionMode: 'default', + model: 'gpt-5.5', + collaborationMode: 'default', + personality: 'friendly' + }, + mcpServers + }); + expect(thread.personality).toBe('friendly'); + }); + it('builds turn params with mode defaults', () => { const params = buildTurnStartParams({ threadId: 'thread-1', diff --git a/cli/src/codex/utils/appServerConfig.ts b/cli/src/codex/utils/appServerConfig.ts index 28efcf8d..cca94a61 100644 --- a/cli/src/codex/utils/appServerConfig.ts +++ b/cli/src/codex/utils/appServerConfig.ts @@ -212,6 +212,9 @@ export function buildThreadStartParams(args: { if (args.mode.model) { params.model = args.mode.model; } + if (args.mode.personality) { + params.personality = args.mode.personality; + } const threadServiceTier = toAppServerServiceTier(args.mode.serviceTier); if (threadServiceTier !== undefined) { @@ -293,5 +296,9 @@ export function buildTurnStartParams(args: { params.serviceTier = turnServiceTier; } + if (args.mode?.personality) { + params.personality = args.mode.personality; + } + return params; } diff --git a/cli/src/codex/utils/slashCommands.test.ts b/cli/src/codex/utils/slashCommands.test.ts index 67e8a0bc..f1efe622 100644 --- a/cli/src/codex/utils/slashCommands.test.ts +++ b/cli/src/codex/utils/slashCommands.test.ts @@ -65,6 +65,40 @@ describe('resolveCodexSlashCommand', () => { }); }); + it('sets personality without inventing hub state or a fake clear', () => { + expect(resolveCodexSlashCommand('/personality', state)).toEqual({ + kind: 'handled', + message: 'Codex personality: unset (Codex config / thread sticky)' + }); + expect(resolveCodexSlashCommand('/personality', { ...state, personality: 'friendly' })).toEqual({ + kind: 'handled', + message: 'Codex personality: friendly' + }); + expect(resolveCodexSlashCommand('/personality pragmatic', state)).toEqual({ + kind: 'handled', + message: 'Codex personality set to pragmatic', + updates: { personality: 'pragmatic' } + }); + expect(resolveCodexSlashCommand('/personality none', state)).toEqual({ + kind: 'handled', + message: 'Codex personality set to none', + updates: { personality: 'none' } + }); + // Sticky: omit-after-override would leave the prior value active, so refuse clear aliases. + expect(resolveCodexSlashCommand('/personality default', { ...state, personality: 'friendly' })).toEqual({ + kind: 'handled', + message: 'Codex personality is sticky on the thread; set friendly, pragmatic, or none (cannot restore config.toml by clearing)' + }); + expect(resolveCodexSlashCommand('/personality clear', { ...state, personality: 'pragmatic' })).toEqual({ + kind: 'handled', + message: 'Codex personality is sticky on the thread; set friendly, pragmatic, or none (cannot restore config.toml by clearing)' + }); + expect(resolveCodexSlashCommand('/personality spicy', state)).toEqual({ + kind: 'handled', + message: 'Unknown Codex personality: spicy' + }); + }); + it('enables Codex fast mode', () => { expect(resolveCodexSlashCommand('/fast', state)).toEqual({ kind: 'handled', diff --git a/cli/src/codex/utils/slashCommands.ts b/cli/src/codex/utils/slashCommands.ts index 6a206cd6..accd4706 100644 --- a/cli/src/codex/utils/slashCommands.ts +++ b/cli/src/codex/utils/slashCommands.ts @@ -1,12 +1,14 @@ import { CODEX_PERMISSION_MODES } from '@hapi/protocol/modes'; import type { CodexPermissionMode } from '@hapi/protocol/types'; import type { ReasoningEffort } from '../appServerTypes'; -import type { EnhancedMode } from '../loop'; +import type { CodexPersonality, EnhancedMode } from '../loop'; import type { SlashCommand } from '@/modules/common/slashCommands'; import { parseReasoningEffortValue } from './reasoningEffort'; export const MAX_CODEX_GOAL_OBJECTIVE_CHARS = 4_000; +const CODEX_PERSONALITIES = ['friendly', 'pragmatic', 'none'] as const satisfies readonly CodexPersonality[]; + const UNSUPPORTED_CODEX_BUILTIN_COMMANDS = new Set([ 'compat', 'diff', @@ -34,6 +36,7 @@ export type CodexSlashResolution = modelReasoningEffort?: ReasoningEffort | null; serviceTier?: string | null; proactiveMultiAgent?: boolean; + personality?: CodexPersonality; }; } | { @@ -47,6 +50,7 @@ export type CodexSlashResolution = modelReasoningEffort?: ReasoningEffort | null; serviceTier?: string | null; proactiveMultiAgent?: boolean; + personality?: CodexPersonality; }; } | { @@ -66,6 +70,7 @@ export function resolveCodexSlashCommand( modelReasoningEffort?: ReasoningEffort; serviceTier?: string | null; proactiveMultiAgent?: boolean; + personality?: CodexPersonality; } ): CodexSlashResolution { const match = /^\s*\/([a-z0-9:_-]+)(?:\s+([\s\S]*))?$/i.exec(text); @@ -185,11 +190,40 @@ export function resolveCodexSlashCommand( `- permission: \`${state.permissionMode}\``, `- collaboration: \`${state.collaborationMode}\``, `- model: \`${state.model ?? 'auto'}\``, - `- reasoning: \`${state.modelReasoningEffort ?? 'default'}\`` + `- reasoning: \`${state.modelReasoningEffort ?? 'default'}\``, + `- personality: \`${state.personality ?? 'unset'}\`` ].join('\n') }; } + if (command === 'personality') { + if (!rest) { + return { + kind: 'handled', + message: `Codex personality: ${state.personality ?? 'unset (Codex config / thread sticky)'}` + }; + } + // turn/start.personality sticks for subsequent turns; omitting later does not + // restore config.toml. Only explicit friendly|pragmatic|none are valid. + if (rest === 'default' || rest === 'auto' || rest === 'clear') { + return { + kind: 'handled', + message: 'Codex personality is sticky on the thread; set friendly, pragmatic, or none (cannot restore config.toml by clearing)' + }; + } + if (!(CODEX_PERSONALITIES as readonly string[]).includes(rest)) { + return { + kind: 'handled', + message: `Unknown Codex personality: ${rest}` + }; + } + return { + kind: 'handled', + message: `Codex personality set to ${rest}`, + updates: { personality: rest as CodexPersonality } + }; + } + if (command === 'model') { if (!rest) { return { kind: 'handled', message: `Codex model: ${state.model ?? 'auto'}` }; @@ -282,6 +316,7 @@ export function resolveCodexSlashCommand( '- `/status` — show current Codex session config', '- `/model [name|auto]` — show or set model', '- `/reasoning [level|default]` — show or set reasoning effort', + '- `/personality [friendly|pragmatic|none]` — show or set response style (sticky on thread)', '- `/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', '', diff --git a/shared/src/slashCommands.ts b/shared/src/slashCommands.ts index dbc5b74c..a4f1aca7 100644 --- a/shared/src/slashCommands.ts +++ b/shared/src/slashCommands.ts @@ -24,6 +24,7 @@ export const BUILTIN_SLASH_COMMANDS = { { name: 'model', description: 'Show or set Codex model, e.g. /model gpt-5.5', source: 'builtin' }, { name: 'reasoning', description: 'Show or set reasoning effort', source: 'builtin' }, { name: 'effort', description: 'Alias for /reasoning', source: 'builtin' }, + { name: 'personality', description: 'Show or set response style: friendly, pragmatic, or none', source: 'builtin' }, { name: 'permissions', description: 'Show or set permission mode', source: 'builtin' }, { name: 'permission', description: 'Alias for /permissions', source: 'builtin' }, ],