mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
'',
|
||||
|
||||
Reference in New Issue
Block a user