feat(codex): support slash controls and skill discovery (#545)

* feat(codex): resolve slash controls before sending to Codex

* feat(codex): discover commands and skills

* fix(codex): handle slash commands before attachments

* fix(codex): block remaining unsupported built-ins
This commit is contained in:
CoColate
2026-04-29 09:20:11 +08:00
committed by GitHub
parent 9c117679f0
commit 9d2dec137b
12 changed files with 571 additions and 81 deletions
+84 -15
View File
@@ -1,4 +1,5 @@
import { logger } from '@/ui/logger';
import { randomUUID } from 'node:crypto';
import { loop, type EnhancedMode, type PermissionMode } from './loop';
import { MessageQueue2 } from '@/utils/MessageQueue2';
import { hashObject } from '@/utils/deterministicJson';
@@ -13,6 +14,8 @@ import { CodexCollaborationModeSchema, PermissionModeSchema } from '@hapi/protoc
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
import { getInvokedCwd } from '@/utils/invokedCwd';
import type { ReasoningEffort } from './appServerTypes';
import { listSlashCommands } from '@/modules/common/slashCommands';
import { resolveCodexSlashCommand } from './utils/slashCommands';
export { emitReadyIfIdle } from './utils/emitReadyIfIdle';
@@ -89,7 +92,29 @@ export async function runCodex(opts: {
);
};
session.onUserMessage((message, localId) => {
const applySlashUpdates = (updates: {
permissionMode?: PermissionMode;
model?: string | null;
modelReasoningEffort?: ReasoningEffort | null;
collaborationMode?: EnhancedMode['collaborationMode'];
} | undefined): void => {
if (!updates) return;
if (updates.permissionMode !== undefined) {
currentPermissionMode = updates.permissionMode;
}
if (updates.model !== undefined) {
currentModel = updates.model ?? undefined;
}
if (updates.modelReasoningEffort !== undefined) {
currentModelReasoningEffort = updates.modelReasoningEffort ?? undefined;
}
if (updates.collaborationMode !== undefined) {
currentCollaborationMode = updates.collaborationMode;
}
applyCurrentConfigToSession();
};
const syncCurrentConfigFromSession = (): void => {
const sessionPermissionMode = sessionWrapperRef.current?.getPermissionMode();
if (sessionPermissionMode && isPermissionModeAllowedForFlavor(sessionPermissionMode, 'codex')) {
currentPermissionMode = sessionPermissionMode as PermissionMode;
@@ -106,22 +131,66 @@ export async function runCodex(opts: {
if (sessionCollaborationMode) {
currentCollaborationMode = sessionCollaborationMode;
}
};
const messagePermissionMode = currentPermissionMode;
logger.debug(
`[Codex] User message received with permission mode: ${currentPermissionMode}, ` +
`model: ${currentModel ?? 'auto'}, modelReasoningEffort: ${currentModelReasoningEffort ?? 'default'}, ` +
`collaborationMode: ${currentCollaborationMode}`
);
let userMessageChain: Promise<void> = Promise.resolve();
session.onUserMessage((message, localId) => {
userMessageChain = userMessageChain.then(async () => {
try {
syncCurrentConfigFromSession();
let text = message.content.text;
const commands = await listSlashCommands('codex', workingDirectory).catch(() => []);
const slash = resolveCodexSlashCommand(text, {
commands,
permissionMode: currentPermissionMode,
collaborationMode: currentCollaborationMode,
model: currentModel,
modelReasoningEffort: currentModelReasoningEffort
});
if (slash.kind !== 'passthrough') {
applySlashUpdates(slash.updates);
if (slash.message) {
session.sendAgentMessage({
type: 'message',
message: slash.message,
id: randomUUID()
});
}
if (slash.kind === 'handled') {
if (localId) session.emitMessagesConsumed([localId]);
return;
}
text = slash.text;
}
text = formatMessageWithAttachments(text, message.content.attachments);
const enhancedMode: EnhancedMode = {
permissionMode: messagePermissionMode ?? 'default',
model: currentModel,
modelReasoningEffort: currentModelReasoningEffort,
collaborationMode: currentCollaborationMode
};
const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments);
messageQueue.push(formattedText, enhancedMode, localId);
const messagePermissionMode = currentPermissionMode;
logger.debug(
`[Codex] User message received with permission mode: ${currentPermissionMode}, ` +
`model: ${currentModel ?? 'auto'}, modelReasoningEffort: ${currentModelReasoningEffort ?? 'default'}, ` +
`collaborationMode: ${currentCollaborationMode}`
);
const enhancedMode: EnhancedMode = {
permissionMode: messagePermissionMode ?? 'default',
model: currentModel,
modelReasoningEffort: currentModelReasoningEffort,
collaborationMode: currentCollaborationMode
};
messageQueue.push(text, enhancedMode, localId);
} catch (error) {
logger.debug('[Codex] Failed to handle user message', error);
const enhancedMode: EnhancedMode = {
permissionMode: currentPermissionMode ?? 'default',
model: currentModel,
modelReasoningEffort: currentModelReasoningEffort,
collaborationMode: currentCollaborationMode
};
messageQueue.push(formatMessageWithAttachments(message.content.text, message.content.attachments), enhancedMode, localId);
}
}).catch((error) => {
logger.debug('[Codex] User message handler chain failed', error);
});
});
const formatFailureReason = (message: string): string => {
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import { resolveCodexSlashCommand } from './slashCommands';
const state = {
permissionMode: 'default' as const,
collaborationMode: 'default' as const,
model: 'gpt-5.5',
modelReasoningEffort: 'high' as const
};
describe('resolveCodexSlashCommand', () => {
it('enables plan mode without sending a turn', () => {
expect(resolveCodexSlashCommand('/plan', state)).toEqual({
kind: 'handled',
message: 'Codex plan mode enabled',
updates: { collaborationMode: 'plan' }
});
});
it('enables plan mode and sends prompt when /plan has text', () => {
expect(resolveCodexSlashCommand('/plan design the fix', state)).toEqual({
kind: 'replace',
text: 'design the fix',
message: 'Codex plan mode enabled',
updates: { collaborationMode: 'plan' }
});
});
it('returns to default collaboration mode', () => {
expect(resolveCodexSlashCommand('/plan off', { ...state, collaborationMode: 'plan' })).toEqual({
kind: 'handled',
message: 'Codex plan mode disabled',
updates: { collaborationMode: 'default' }
});
});
it('sets model, reasoning effort, and permission mode', () => {
expect(resolveCodexSlashCommand('/model gpt-5.4', state)).toMatchObject({
updates: { model: 'gpt-5.4' }
});
expect(resolveCodexSlashCommand('/reasoning low', state)).toMatchObject({
updates: { modelReasoningEffort: 'low' }
});
expect(resolveCodexSlashCommand('/permissions yolo', state)).toMatchObject({
updates: { permissionMode: 'yolo' }
});
});
it('expands custom Codex prompt commands', () => {
expect(resolveCodexSlashCommand('/review src/index.ts', {
...state,
commands: [
{ name: 'review', source: 'project', content: 'Review this code.' }
]
})).toEqual({
kind: 'replace',
text: 'Review this code.\n\nUser arguments: src/index.ts',
message: 'Expanded /review'
});
});
it('handles unsupported Codex built-in commands instead of sending them to the model', () => {
for (const command of ['diff', 'undo', 'review', 'compat']) {
expect(resolveCodexSlashCommand(`/${command}`, state)).toEqual({
kind: 'handled',
message: `/${command} is a Codex CLI command that is not supported in HAPI sessions yet.`
});
}
});
it('expands custom prompts before checking unsupported built-in names', () => {
expect(resolveCodexSlashCommand('/review src/index.ts', {
...state,
commands: [
{ name: 'review', source: 'project', content: 'Review this code.' }
]
})).toEqual({
kind: 'replace',
text: 'Review this code.\n\nUser arguments: src/index.ts',
message: 'Expanded /review'
});
});
it('passes unknown slash commands through', () => {
expect(resolveCodexSlashCommand('/unknown', state)).toEqual({ kind: 'passthrough' });
});
});
+200
View File
@@ -0,0 +1,200 @@
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 { SlashCommand } from '@/modules/common/slashCommands';
const REASONING_EFFORTS = new Set<ReasoningEffort>(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']);
const UNSUPPORTED_CODEX_BUILTIN_COMMANDS = new Set([
'clear',
'compact',
'compat',
'diff',
'init',
'login',
'logout',
'mcp',
'new',
'prompts',
'quit',
'redo',
'review',
'undo'
]);
export type CodexSlashResolution =
| { kind: 'passthrough' }
| {
kind: 'handled';
message: string;
updates?: {
collaborationMode?: EnhancedMode['collaborationMode'];
permissionMode?: CodexPermissionMode;
model?: string | null;
modelReasoningEffort?: ReasoningEffort | null;
};
}
| {
kind: 'replace';
text: string;
message?: string;
updates?: {
collaborationMode?: EnhancedMode['collaborationMode'];
permissionMode?: CodexPermissionMode;
model?: string | null;
modelReasoningEffort?: ReasoningEffort | null;
};
};
export function resolveCodexSlashCommand(
text: string,
state: {
commands?: readonly SlashCommand[];
permissionMode: CodexPermissionMode;
collaborationMode: EnhancedMode['collaborationMode'];
model?: string;
modelReasoningEffort?: ReasoningEffort;
}
): CodexSlashResolution {
const match = /^\s*\/([a-z0-9:_-]+)(?:\s+([\s\S]*))?$/i.exec(text);
if (!match) return { kind: 'passthrough' };
const command = match[1]?.toLowerCase();
const rest = match[2]?.trim() ?? '';
if (!command) return { kind: 'passthrough' };
const custom = state.commands?.find((candidate) =>
candidate.source !== 'builtin' && candidate.name.toLowerCase() === command
);
if (custom?.content) {
return {
kind: 'replace',
text: rest ? `${custom.content}\n\nUser arguments: ${rest}` : custom.content,
message: `Expanded /${custom.name}`
};
}
if (command === 'plan') {
const lowerRest = rest.toLowerCase();
if (lowerRest === 'off' || lowerRest === 'default' || lowerRest === 'exit' || lowerRest === 'disable') {
return {
kind: 'handled',
message: 'Codex plan mode disabled',
updates: { collaborationMode: 'default' }
};
}
if (rest) {
return {
kind: 'replace',
text: rest,
message: 'Codex plan mode enabled',
updates: { collaborationMode: 'plan' }
};
}
return {
kind: 'handled',
message: 'Codex plan mode enabled',
updates: { collaborationMode: 'plan' }
};
}
if (command === 'default' || command === 'execute') {
return {
kind: 'handled',
message: 'Codex collaboration mode: default',
updates: { collaborationMode: 'default' }
};
}
if (command === 'status') {
return {
kind: 'handled',
message: [
`Codex status`,
`permission: ${state.permissionMode}`,
`collaboration: ${state.collaborationMode}`,
`model: ${state.model ?? 'auto'}`,
`reasoning: ${state.modelReasoningEffort ?? 'default'}`
].join('\n')
};
}
if (command === 'model') {
if (!rest) {
return { kind: 'handled', message: `Codex model: ${state.model ?? 'auto'}` };
}
const model = rest === 'auto' ? null : rest;
return {
kind: 'handled',
message: `Codex model set to ${model ?? 'auto'}`,
updates: { model }
};
}
if (command === 'reasoning' || command === 'effort') {
if (!rest) {
return { kind: 'handled', message: `Codex reasoning effort: ${state.modelReasoningEffort ?? 'default'}` };
}
if (rest === 'default' || rest === 'auto') {
return {
kind: 'handled',
message: 'Codex reasoning effort set to default',
updates: { modelReasoningEffort: null }
};
}
if (!REASONING_EFFORTS.has(rest as ReasoningEffort)) {
return {
kind: 'handled',
message: `Unknown Codex reasoning effort: ${rest}`
};
}
return {
kind: 'handled',
message: `Codex reasoning effort set to ${rest}`,
updates: { modelReasoningEffort: rest as ReasoningEffort }
};
}
if (command === 'permissions' || command === 'permission') {
if (!rest) {
return { kind: 'handled', message: `Codex permission mode: ${state.permissionMode}` };
}
if (!(CODEX_PERMISSION_MODES as readonly string[]).includes(rest)) {
return {
kind: 'handled',
message: `Unknown Codex permission mode: ${rest}`
};
}
return {
kind: 'handled',
message: `Codex permission mode set to ${rest}`,
updates: { permissionMode: rest as CodexPermissionMode }
};
}
if (command === 'help') {
return {
kind: 'handled',
message: [
'Supported Codex slash commands:',
'/plan [prompt] — enable plan mode, optionally send prompt',
'/plan off — return to default mode',
'/status — show current Codex session config',
'/model [name|auto] — show or set model',
'/reasoning [low|medium|high|xhigh|default] — show or set reasoning effort',
'/permissions [default|read-only|safe-yolo|yolo] — show or set permission mode',
'Custom /commands from .codex/prompts are expanded before sending.'
].join('\n')
};
}
if (UNSUPPORTED_CODEX_BUILTIN_COMMANDS.has(command)) {
return {
kind: 'handled',
message: `/${command} is a Codex CLI command that is not supported in HAPI sessions yet.`
};
}
return { kind: 'passthrough' };
}