feat(codex): /personality slash + in-session app-server params (#1265)

* feat(codex): support /personality via in-session override

Intercept /personality in the Codex slash layer, keep the value in CLI
memory only, and forward it on thread/turn start when set. Unset means
omit the field so Codex config/thread defaults apply—no Hub DB or web UI.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(codex): refuse fake /personality clear (sticky thread setting)

turn/start.personality sticks for later turns; omitting the field does not
restore config.toml. Drop default|auto|clear success paths and require an
explicit friendly|pragmatic|none instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SSU-WEI HUANG
2026-08-01 23:26:32 +08:00
committed by GitHub
co-authored by Cursor
parent 0537ddf84a
commit bbe99f5c4c
7 changed files with 136 additions and 8 deletions
+5
View File
@@ -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 {
+17 -6
View File
@@ -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);
}
@@ -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',
+7
View File
@@ -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;
}
+34
View File
@@ -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',
+37 -2
View File
@@ -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',
'',