mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
+84
-15
@@ -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 => {
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -69,15 +69,14 @@ describe('listSkills', () => {
|
||||
expect(skills.find((s) => s.name === 'alpha')?.description).toBe('Alpha from agents')
|
||||
})
|
||||
|
||||
it('lists user skills from ~/.codex/skills', async () => {
|
||||
it('lists user skills from ~/.codex/skills including Codex bundled system skills', async () => {
|
||||
await writeSkill(join(homeDir, '.agents', 'skills', 'amis'), 'amis', 'AMIS guide')
|
||||
await writeSkill(join(homeDir, '.codex', 'skills', 'hello-agents'), 'helloagents', 'Main skill')
|
||||
// Hidden directories (starting with .) are skipped
|
||||
await writeSkill(join(homeDir, '.codex', 'skills', '.system', 'skill-creator'), 'skill-creator', 'Create skills')
|
||||
|
||||
const skills = await listSkills()
|
||||
|
||||
expect(skills.map((skill) => skill.name)).toEqual(['amis', 'helloagents'])
|
||||
expect(skills.map((skill) => skill.name)).toEqual(['amis', 'helloagents', 'skill-creator'])
|
||||
})
|
||||
|
||||
it('falls back to directory name when frontmatter is missing', async () => {
|
||||
@@ -119,6 +118,20 @@ describe('listSkills', () => {
|
||||
expect(skills.map((skill) => skill.name)).toEqual(['claude-local', 'claude-root'])
|
||||
})
|
||||
|
||||
it('loads project skills from .codex/skills directories', async () => {
|
||||
const repoRoot = join(sandboxDir, 'repo')
|
||||
const workingDirectory = join(repoRoot, 'apps', 'web')
|
||||
|
||||
await mkdir(join(repoRoot, '.git'), { recursive: true })
|
||||
await writeSkill(join(repoRoot, '.codex', 'skills', 'codex-root'), 'codex-root', 'Codex root skill')
|
||||
await writeSkill(join(workingDirectory, '.codex', 'skills', 'codex-local'), 'codex-local', 'Codex local skill')
|
||||
await writeSkill(join(workingDirectory, '.codex', 'skills', '.system', 'codex-system'), 'codex-system', 'Codex system skill')
|
||||
|
||||
const skills = await listSkills(workingDirectory)
|
||||
|
||||
expect(skills.map((skill) => skill.name)).toEqual(['codex-local', 'codex-root', 'codex-system'])
|
||||
})
|
||||
|
||||
it('prefers .agents project skills over .claude project skills with same name', async () => {
|
||||
const repoRoot = join(sandboxDir, 'repo')
|
||||
const workingDirectory = join(repoRoot, 'apps', 'web')
|
||||
|
||||
@@ -38,6 +38,7 @@ function getProjectSkillsRoots(directory: string): string[] {
|
||||
return [
|
||||
join(directory, '.agents', 'skills'),
|
||||
join(directory, '.claude', 'skills'),
|
||||
join(directory, '.codex', 'skills'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -105,13 +106,25 @@ function extractSkillSummary(skillDir: string, fileContent: string): SkillSummar
|
||||
return { name, description };
|
||||
}
|
||||
|
||||
async function listTopLevelSkillDirs(skillsRoot: string): Promise<string[]> {
|
||||
async function listTopLevelSkillDirs(skillsRoot: string, options: { includeCodexSystem?: boolean } = {}): Promise<string[]> {
|
||||
try {
|
||||
const entries = await readdir(skillsRoot, { withFileTypes: true });
|
||||
const result: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.')) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.name.startsWith('.')) {
|
||||
if (options.includeCodexSystem && entry.name === '.system') {
|
||||
const systemEntries = await readdir(join(skillsRoot, entry.name), { withFileTypes: true }).catch(() => []);
|
||||
for (const systemEntry of systemEntries) {
|
||||
if (systemEntry.isDirectory() && !systemEntry.name.startsWith('.')) {
|
||||
result.push(join(skillsRoot, entry.name, systemEntry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -138,12 +151,18 @@ async function readSkillsFromDirs(skillDirs: string[]): Promise<SkillSummary[]>
|
||||
return skills.filter((skill): skill is SkillSummary => skill !== null);
|
||||
}
|
||||
|
||||
function isCodexSkillsRoot(root: string): boolean {
|
||||
return root.endsWith(join('.codex', 'skills'));
|
||||
}
|
||||
|
||||
export async function listSkills(workingDirectory?: string): Promise<SkillSummary[]> {
|
||||
const projectRoots = await listProjectSkillsRoots(workingDirectory);
|
||||
const userRoots = getUserSkillsRoots();
|
||||
const adminRoot = getAdminSkillsRoot();
|
||||
const [projectSkillDirs, userSkillDirs, adminSkillDirs] = await Promise.all([
|
||||
Promise.all(projectRoots.map(async (root) => await listTopLevelSkillDirs(root))).then((dirs) => dirs.flat()),
|
||||
Promise.all(getUserSkillsRoots().map(async (root) => await listTopLevelSkillDirs(root))).then((dirs) => dirs.flat()),
|
||||
listTopLevelSkillDirs(getAdminSkillsRoot()),
|
||||
Promise.all(projectRoots.map(async (root) => await listTopLevelSkillDirs(root, { includeCodexSystem: isCodexSkillsRoot(root) }))).then((dirs) => dirs.flat()),
|
||||
Promise.all(userRoots.map(async (root) => await listTopLevelSkillDirs(root, { includeCodexSystem: isCodexSkillsRoot(root) }))).then((dirs) => dirs.flat()),
|
||||
listTopLevelSkillDirs(adminRoot, { includeCodexSystem: true }),
|
||||
]);
|
||||
|
||||
const [projectSkills, userSkills, adminSkills] = await Promise.all([
|
||||
|
||||
@@ -6,19 +6,25 @@ import { listSlashCommands } from './slashCommands'
|
||||
|
||||
describe('listSlashCommands', () => {
|
||||
const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
const originalCodexHome = process.env.CODEX_HOME
|
||||
let sandboxDir: string
|
||||
let claudeConfigDir: string
|
||||
let codexHome: string
|
||||
let projectDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
sandboxDir = await mkdtemp(join(tmpdir(), 'hapi-slash-commands-'))
|
||||
claudeConfigDir = join(sandboxDir, 'global-claude')
|
||||
codexHome = join(sandboxDir, 'global-codex')
|
||||
projectDir = join(sandboxDir, 'project')
|
||||
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeConfigDir
|
||||
process.env.CODEX_HOME = codexHome
|
||||
|
||||
await mkdir(join(claudeConfigDir, 'commands'), { recursive: true })
|
||||
await mkdir(join(codexHome, 'prompts'), { recursive: true })
|
||||
await mkdir(join(projectDir, '.claude', 'commands'), { recursive: true })
|
||||
await mkdir(join(projectDir, '.codex', 'prompts'), { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -27,6 +33,11 @@ describe('listSlashCommands', () => {
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir
|
||||
}
|
||||
if (originalCodexHome === undefined) {
|
||||
delete process.env.CODEX_HOME
|
||||
} else {
|
||||
process.env.CODEX_HOME = originalCodexHome
|
||||
}
|
||||
|
||||
await rm(sandboxDir, { recursive: true, force: true })
|
||||
})
|
||||
@@ -98,4 +109,67 @@ describe('listSlashCommands', () => {
|
||||
|
||||
await expect(listSlashCommands('claude', nonExistentProjectDir)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('exposes HAPI-supported Codex built-ins', async () => {
|
||||
const commands = await listSlashCommands('codex', projectDir)
|
||||
|
||||
expect(commands.map((command) => command.name)).toEqual(expect.arrayContaining([
|
||||
'plan',
|
||||
'status',
|
||||
'model',
|
||||
'reasoning',
|
||||
'permissions',
|
||||
]))
|
||||
})
|
||||
|
||||
it('loads Codex global and project prompts', async () => {
|
||||
await writeFile(
|
||||
join(codexHome, 'prompts', 'global-prompt.md'),
|
||||
['---', 'description: Global Codex prompt', '---', '', 'Global Codex body'].join('\n')
|
||||
)
|
||||
await writeFile(
|
||||
join(projectDir, '.codex', 'prompts', 'project-prompt.md'),
|
||||
['---', 'description: Project Codex prompt', '---', '', 'Project Codex body'].join('\n')
|
||||
)
|
||||
|
||||
const commands = await listSlashCommands('codex', projectDir)
|
||||
|
||||
expect(commands.find(cmd => cmd.name === 'global-prompt')).toMatchObject({
|
||||
source: 'user',
|
||||
description: 'Global Codex prompt',
|
||||
content: 'Global Codex body',
|
||||
})
|
||||
expect(commands.find(cmd => cmd.name === 'project-prompt')).toMatchObject({
|
||||
source: 'project',
|
||||
description: 'Project Codex prompt',
|
||||
content: 'Project Codex body',
|
||||
})
|
||||
})
|
||||
|
||||
it('loads Codex project prompts from cwd up to repo root with nearest override', async () => {
|
||||
const repoRoot = join(sandboxDir, 'repo')
|
||||
const workingDirectory = join(repoRoot, 'apps', 'web')
|
||||
await mkdir(join(repoRoot, '.git'), { recursive: true })
|
||||
await mkdir(join(repoRoot, '.codex', 'prompts'), { recursive: true })
|
||||
await mkdir(join(workingDirectory, '.codex', 'prompts'), { recursive: true })
|
||||
|
||||
await writeFile(
|
||||
join(repoRoot, '.codex', 'prompts', 'shared.md'),
|
||||
['---', 'description: Root prompt', '---', '', 'Root body'].join('\n')
|
||||
)
|
||||
await writeFile(
|
||||
join(workingDirectory, '.codex', 'prompts', 'shared.md'),
|
||||
['---', 'description: Local prompt', '---', '', 'Local body'].join('\n')
|
||||
)
|
||||
|
||||
const commands = await listSlashCommands('codex', workingDirectory)
|
||||
const sharedCommands = commands.filter(cmd => cmd.name === 'shared')
|
||||
|
||||
expect(sharedCommands).toHaveLength(1)
|
||||
expect(sharedCommands[0]).toMatchObject({
|
||||
source: 'project',
|
||||
description: 'Local prompt',
|
||||
content: 'Local body',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readdir, readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { access, readdir, readFile } from 'fs/promises';
|
||||
import { dirname, join, resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
|
||||
@@ -32,7 +32,18 @@ const BUILTIN_COMMANDS: Record<string, SlashCommand[]> = {
|
||||
{ name: 'cost', description: 'Show session cost', source: 'builtin' },
|
||||
{ name: 'plan', description: 'Toggle plan mode', source: 'builtin' },
|
||||
],
|
||||
codex: [],
|
||||
codex: [
|
||||
{ name: 'help', description: 'Show supported HAPI Codex slash commands', source: 'builtin' },
|
||||
{ name: 'plan', description: 'Enable plan mode; use /plan off to return to default', source: 'builtin' },
|
||||
{ name: 'default', description: 'Return Codex collaboration mode to default', source: 'builtin' },
|
||||
{ name: 'execute', description: 'Return Codex collaboration mode to default', source: 'builtin' },
|
||||
{ name: 'status', description: 'Show current Codex session config', source: 'builtin' },
|
||||
{ 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: 'permissions', description: 'Show or set permission mode', source: 'builtin' },
|
||||
{ name: 'permission', description: 'Alias for /permissions', source: 'builtin' },
|
||||
],
|
||||
gemini: [
|
||||
{ name: 'about', description: 'About Gemini', source: 'builtin' },
|
||||
{ name: 'clear', description: 'Clear conversation', source: 'builtin' },
|
||||
@@ -115,6 +126,43 @@ function getProjectCommandsDir(agent: string, projectDir: string): string | null
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function listProjectCommandDirs(agent: string, projectDir?: string): Promise<string[]> {
|
||||
if (!projectDir) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resolvedProjectDir = resolve(projectDir);
|
||||
const directories = [resolvedProjectDir];
|
||||
let currentDirectory = resolvedProjectDir;
|
||||
|
||||
while (true) {
|
||||
if (await pathExists(join(currentDirectory, '.git'))) {
|
||||
return [...directories]
|
||||
.reverse()
|
||||
.map((directory) => getProjectCommandsDir(agent, directory))
|
||||
.filter((directory): directory is string => directory !== null);
|
||||
}
|
||||
|
||||
const parentDirectory = dirname(currentDirectory);
|
||||
if (parentDirectory === currentDirectory) {
|
||||
const dir = getProjectCommandsDir(agent, resolvedProjectDir);
|
||||
return dir ? [dir] : [];
|
||||
}
|
||||
|
||||
currentDirectory = parentDirectory;
|
||||
directories.push(currentDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a directory for commands (*.md files).
|
||||
* Returns commands with parsed frontmatter.
|
||||
@@ -199,16 +247,9 @@ async function scanUserCommands(agent: string): Promise<SlashCommand[]> {
|
||||
* Scan project-defined commands from <projectDir>/.claude/commands/ or equivalent.
|
||||
*/
|
||||
async function scanProjectCommands(agent: string, projectDir?: string): Promise<SlashCommand[]> {
|
||||
if (!projectDir) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dir = getProjectCommandsDir(agent, projectDir);
|
||||
if (!dir) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return scanCommandsDir(dir, 'project');
|
||||
const dirs = await listProjectCommandDirs(agent, projectDir);
|
||||
const commands = await Promise.all(dirs.map(async (dir) => await scanCommandsDir(dir, 'project')));
|
||||
return commands.flat();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -199,20 +199,12 @@ export function HappyComposer(props: {
|
||||
markSkillUsed(suggestion.text.slice(1))
|
||||
}
|
||||
|
||||
// For Codex user prompts with content, expand the content instead of command name
|
||||
let textToInsert = suggestion.text
|
||||
let addSpace = true
|
||||
if (agentFlavor === 'codex' && suggestion.source !== 'builtin' && suggestion.content) {
|
||||
textToInsert = suggestion.content
|
||||
addSpace = false
|
||||
}
|
||||
|
||||
const result = applySuggestion(
|
||||
inputState.text,
|
||||
inputState.selection,
|
||||
textToInsert,
|
||||
suggestion.text,
|
||||
autocompletePrefixes,
|
||||
addSpace
|
||||
true
|
||||
)
|
||||
|
||||
api.composer().setText(result.text)
|
||||
@@ -233,7 +225,7 @@ export function HappyComposer(props: {
|
||||
}, 0)
|
||||
|
||||
haptic('light')
|
||||
}, [api, suggestions, inputState, autocompletePrefixes, haptic, agentFlavor])
|
||||
}, [api, suggestions, inputState, autocompletePrefixes, haptic])
|
||||
|
||||
const abortDisabled = controlsDisabled || isAborting || !threadIsRunning
|
||||
const switchDisabled = controlsDisabled || isSwitching || !controlledByUser
|
||||
|
||||
@@ -20,8 +20,6 @@ import { HappyComposer } from '@/components/AssistantChat/HappyComposer'
|
||||
import { HappyThread } from '@/components/AssistantChat/HappyThread'
|
||||
import { useHappyRuntime } from '@/lib/assistant-runtime'
|
||||
import { createAttachmentAdapter } from '@/lib/attachmentAdapter'
|
||||
import { findUnsupportedCodexBuiltinSlashCommand } from '@/lib/codexSlashCommands'
|
||||
import { useToast } from '@/lib/toast-context'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
import { SessionHeader } from '@/components/SessionHeader'
|
||||
import { TeamPanel } from '@/components/TeamPanel'
|
||||
@@ -67,7 +65,6 @@ export function SessionChat(props: {
|
||||
availableSlashCommands?: readonly SlashCommand[]
|
||||
}) {
|
||||
const { haptic } = usePlatform()
|
||||
const { addToast } = useToast()
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const sessionInactive = !props.session.active
|
||||
@@ -349,26 +346,9 @@ export function SessionChat(props: {
|
||||
}, [navigate, props.session.id])
|
||||
|
||||
const handleSend = useCallback((text: string, attachments?: AttachmentMetadata[]) => {
|
||||
if (agentFlavor === 'codex') {
|
||||
const unsupportedCommand = findUnsupportedCodexBuiltinSlashCommand(
|
||||
text,
|
||||
props.availableSlashCommands ?? []
|
||||
)
|
||||
if (unsupportedCommand) {
|
||||
haptic.notification('error')
|
||||
addToast({
|
||||
title: t('composer.codexSlashUnsupported.title'),
|
||||
body: t('composer.codexSlashUnsupported.body', { command: `/${unsupportedCommand}` }),
|
||||
sessionId: props.session.id,
|
||||
url: `/sessions/${props.session.id}`
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
props.onSend(text, attachments)
|
||||
setForceScrollToken((token) => token + 1)
|
||||
}, [agentFlavor, props.availableSlashCommands, props.onSend, props.session.id, addToast, haptic, t])
|
||||
}, [props.onSend])
|
||||
|
||||
const attachmentAdapter = useMemo(() => {
|
||||
if (!props.session.active) {
|
||||
|
||||
@@ -49,16 +49,18 @@ export function useSlashCommands(
|
||||
retry: false, // Don't retry RPC failures
|
||||
})
|
||||
|
||||
// Merge built-in commands with user-defined and plugin commands from API
|
||||
// Merge local built-ins with commands discovered by the active CLI.
|
||||
// The CLI can expose agent-specific built-ins plus user/plugin/project commands;
|
||||
// keep local built-ins as an offline fallback, then append/override from RPC.
|
||||
const commands = useMemo(() => {
|
||||
const builtin = getBuiltinSlashCommands(agentType)
|
||||
|
||||
// If API succeeded, add user-defined and plugin commands
|
||||
if (query.data?.success && query.data.commands) {
|
||||
const extraCommands = query.data.commands.filter(
|
||||
cmd => cmd.source === 'user' || cmd.source === 'plugin' || cmd.source === 'project'
|
||||
)
|
||||
return [...builtin, ...extraCommands]
|
||||
const commandMap = new Map<string, SlashCommand>()
|
||||
for (const command of [...builtin, ...query.data.commands]) {
|
||||
commandMap.set(command.name, command)
|
||||
}
|
||||
return Array.from(commandMap.values())
|
||||
}
|
||||
|
||||
// Fallback to built-in commands only
|
||||
|
||||
@@ -2,14 +2,19 @@ import { describe, expect, it } from 'vitest'
|
||||
import { findUnsupportedCodexBuiltinSlashCommand, getBuiltinSlashCommands } from './codexSlashCommands'
|
||||
|
||||
describe('getBuiltinSlashCommands', () => {
|
||||
it('does not expose codex built-ins in remote web mode', () => {
|
||||
expect(getBuiltinSlashCommands('codex')).toEqual([])
|
||||
it('exposes HAPI-supported codex built-ins in remote web mode', () => {
|
||||
expect(getBuiltinSlashCommands('codex').map((command) => command.name)).toEqual(expect.arrayContaining([
|
||||
'plan',
|
||||
'status',
|
||||
'execute',
|
||||
'effort',
|
||||
'permission',
|
||||
]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('findUnsupportedCodexBuiltinSlashCommand', () => {
|
||||
it('detects unsupported codex built-ins', () => {
|
||||
expect(findUnsupportedCodexBuiltinSlashCommand('/status', [])).toBe('status')
|
||||
expect(findUnsupportedCodexBuiltinSlashCommand(' /diff ', [])).toBe('diff')
|
||||
})
|
||||
|
||||
|
||||
@@ -11,9 +11,18 @@ const BUILTIN_COMMANDS: Record<string, SlashCommand[]> = {
|
||||
{ name: 'stats', description: 'Show your Claude Code usage statistics and activity', source: 'builtin' },
|
||||
{ name: 'status', description: 'Show Claude Code status including version, model, account, and API connectivity', source: 'builtin' },
|
||||
],
|
||||
// Codex remote turns send slash-prefixed input as plain text to app-server.
|
||||
// Hide built-ins here until remote slash command execution is implemented end-to-end.
|
||||
codex: [],
|
||||
codex: [
|
||||
{ name: 'help', description: 'Show supported HAPI Codex slash commands', source: 'builtin' },
|
||||
{ name: 'plan', description: 'Enable plan mode; use /plan off to return to default', source: 'builtin' },
|
||||
{ name: 'default', description: 'Return Codex collaboration mode to default', source: 'builtin' },
|
||||
{ name: 'execute', description: 'Return Codex collaboration mode to default', source: 'builtin' },
|
||||
{ name: 'status', description: 'Show current Codex session config', source: 'builtin' },
|
||||
{ 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: 'permissions', description: 'Show or set permission mode', source: 'builtin' },
|
||||
{ name: 'permission', description: 'Alias for /permissions', source: 'builtin' },
|
||||
],
|
||||
gemini: [
|
||||
{ name: 'about', description: 'Show version info', source: 'builtin' },
|
||||
{ name: 'clear', description: 'Clear the screen and conversation history', source: 'builtin' },
|
||||
@@ -29,7 +38,6 @@ const UNSUPPORTED_CODEX_BUILTIN_COMMANDS = new Set([
|
||||
'compat',
|
||||
'undo',
|
||||
'diff',
|
||||
'status',
|
||||
])
|
||||
|
||||
export function getBuiltinSlashCommands(agentType: string): SlashCommand[] {
|
||||
|
||||
Reference in New Issue
Block a user