Add Codex clear and compact slash commands (#541)

* Add Codex clear and compact slash commands

* Stabilize queued thinking event test

* Interrupt active Codex turn before slash commands
This commit is contained in:
NightWatcher314
2026-04-29 14:15:44 +08:00
committed by GitHub
parent 0160da4bb5
commit a612be50d6
15 changed files with 460 additions and 16 deletions
+8
View File
@@ -169,3 +169,11 @@ export interface TurnInterruptResponse {
ok: boolean; ok: boolean;
[key: string]: unknown; [key: string]: unknown;
} }
export interface ThreadCompactStartParams {
threadId: string;
}
export interface ThreadCompactStartResponse {
[key: string]: unknown;
}
+14 -1
View File
@@ -13,7 +13,9 @@ import type {
TurnStartParams, TurnStartParams,
TurnStartResponse, TurnStartResponse,
TurnInterruptParams, TurnInterruptParams,
TurnInterruptResponse TurnInterruptResponse,
ThreadCompactStartParams,
ThreadCompactStartResponse
} from './appServerTypes'; } from './appServerTypes';
type JsonRpcLiteRequest = { type JsonRpcLiteRequest = {
@@ -173,6 +175,17 @@ export class CodexAppServerClient {
return response as TurnInterruptResponse; return response as TurnInterruptResponse;
} }
async compactThread(
params: ThreadCompactStartParams,
options?: { signal?: AbortSignal }
): Promise<ThreadCompactStartResponse> {
const response = await this.sendRequest('thread/compact/start', params, {
signal: options?.signal,
timeoutMs: CodexAppServerClient.DEFAULT_TIMEOUT_MS
});
return response as ThreadCompactStartResponse;
}
async disconnect(): Promise<void> { async disconnect(): Promise<void> {
if (!this.connected) { if (!this.connected) {
return; return;
+127 -1
View File
@@ -9,6 +9,9 @@ const harness = vi.hoisted(() => ({
startThreadIds: [] as string[], startThreadIds: [] as string[],
resumeThreadIds: [] as string[], resumeThreadIds: [] as string[],
startTurnThreadIds: [] as string[], startTurnThreadIds: [] as string[],
interruptedTurns: [] as Array<{ threadId: string; turnId: string }>,
compactThreadIds: [] as string[],
suppressTurnCompletion: false,
remainingThreadSystemErrors: 0 remainingThreadSystemErrors: 0
})); }));
@@ -62,6 +65,10 @@ vi.mock('./codexAppServerClient', () => {
return { turn: { id: turnId } }; return { turn: { id: turnId } };
} }
if (harness.suppressTurnCompletion) {
return { turn: { id: turnId } };
}
const completed = { status: 'Completed', turn: { id: turnId } }; const completed = { status: 'Completed', turn: { id: turnId } };
harness.notifications.push({ method: 'turn/completed', params: completed }); harness.notifications.push({ method: 'turn/completed', params: completed });
this.notificationHandler?.('turn/completed', completed); this.notificationHandler?.('turn/completed', completed);
@@ -69,7 +76,16 @@ vi.mock('./codexAppServerClient', () => {
return { turn: { id: turnId } }; return { turn: { id: turnId } };
} }
async interruptTurn(): Promise<Record<string, never>> { async interruptTurn(params?: { threadId?: string; turnId?: string }): Promise<Record<string, never>> {
harness.interruptedTurns.push({
threadId: params?.threadId ?? 'thread-unknown',
turnId: params?.turnId ?? 'turn-unknown'
});
return {};
}
async compactThread(params?: { threadId?: string }): Promise<Record<string, never>> {
harness.compactThreadIds.push(params?.threadId ?? 'thread-unknown');
return {}; return {};
} }
@@ -117,6 +133,7 @@ function createSessionStub(messages = ['hello from launcher test']) {
const codexMessages: unknown[] = []; const codexMessages: unknown[] = [];
const thinkingChanges: boolean[] = []; const thinkingChanges: boolean[] = [];
const foundSessionIds: string[] = []; const foundSessionIds: string[] = [];
const resetThreadCalls: string[] = [];
let currentModel: string | null | undefined; let currentModel: string | null | undefined;
let agentState: FakeAgentState = { let agentState: FakeAgentState = {
requests: {}, requests: {},
@@ -168,6 +185,10 @@ function createSessionStub(messages = ['hello from launcher test']) {
session.sessionId = id; session.sessionId = id;
foundSessionIds.push(id); foundSessionIds.push(id);
}, },
resetCodexThread() {
resetThreadCalls.push(session.sessionId ?? 'none');
session.sessionId = null;
},
sendAgentMessage(message: unknown) { sendAgentMessage(message: unknown) {
client.sendAgentMessage(message); client.sendAgentMessage(message);
}, },
@@ -185,6 +206,7 @@ function createSessionStub(messages = ['hello from launcher test']) {
codexMessages, codexMessages,
thinkingChanges, thinkingChanges,
foundSessionIds, foundSessionIds,
resetThreadCalls,
rpcHandlers, rpcHandlers,
getModel: () => currentModel, getModel: () => currentModel,
getAgentState: () => agentState getAgentState: () => agentState
@@ -199,6 +221,9 @@ describe('codexRemoteLauncher', () => {
harness.startThreadIds = []; harness.startThreadIds = [];
harness.resumeThreadIds = []; harness.resumeThreadIds = [];
harness.startTurnThreadIds = []; harness.startTurnThreadIds = [];
harness.interruptedTurns = [];
harness.compactThreadIds = [];
harness.suppressTurnCompletion = false;
harness.remainingThreadSystemErrors = 0; harness.remainingThreadSystemErrors = 0;
}); });
@@ -260,4 +285,105 @@ describe('codexRemoteLauncher', () => {
expect(session.sessionId).toBe('thread-2'); expect(session.sessionId).toBe('thread-2');
expect(session.thinking).toBe(false); expect(session.thinking).toBe(false);
}); });
it('clears codex thread state without starting a turn', async () => {
const { session, sessionEvents, resetThreadCalls } = createSessionStub(['/clear', 'next message']);
const exitReason = await codexRemoteLauncher(session as never);
expect(exitReason).toBe('exit');
expect(resetThreadCalls).toEqual(['none']);
expect(harness.startThreadIds).toEqual(['thread-1']);
expect(harness.startTurnThreadIds).toEqual(['thread-1']);
expect(sessionEvents).toContainEqual({
type: 'message',
message: 'Context was reset'
});
expect(session.sessionId).toBe('thread-1');
});
it('interrupts an in-flight turn before clearing codex thread state', async () => {
harness.suppressTurnCompletion = true;
const { session, sessionEvents, resetThreadCalls } = createSessionStub(['first message', '/clear']);
const exitReason = await codexRemoteLauncher(session as never);
expect(exitReason).toBe('exit');
expect(harness.startThreadIds).toEqual(['thread-1']);
expect(harness.startTurnThreadIds).toEqual(['thread-1']);
expect(harness.interruptedTurns).toEqual([{ threadId: 'thread-1', turnId: 'turn-1' }]);
expect(resetThreadCalls).toEqual(['thread-1']);
expect(sessionEvents).toContainEqual({
type: 'message',
message: 'Context was reset'
});
expect(session.thinking).toBe(false);
});
it('compacts the current thread without starting a turn', async () => {
const { session, sessionEvents } = createSessionStub(['first message', '/compact']);
const exitReason = await codexRemoteLauncher(session as never);
expect(exitReason).toBe('exit');
expect(harness.startThreadIds).toEqual(['thread-1']);
expect(harness.startTurnThreadIds).toEqual(['thread-1']);
expect(harness.compactThreadIds).toEqual(['thread-1']);
expect(sessionEvents).toContainEqual({
type: 'message',
message: 'Compaction started'
});
expect(sessionEvents).toContainEqual({
type: 'message',
message: 'Compaction completed'
});
});
it('interrupts an in-flight turn before compacting the current thread', async () => {
harness.suppressTurnCompletion = true;
const { session, sessionEvents } = createSessionStub(['first message', '/compact']);
const exitReason = await codexRemoteLauncher(session as never);
expect(exitReason).toBe('exit');
expect(harness.startThreadIds).toEqual(['thread-1']);
expect(harness.startTurnThreadIds).toEqual(['thread-1']);
expect(harness.interruptedTurns).toEqual([{ threadId: 'thread-1', turnId: 'turn-1' }]);
expect(harness.compactThreadIds).toEqual(['thread-1']);
expect(sessionEvents).toContainEqual({
type: 'message',
message: 'Compaction completed'
});
expect(session.thinking).toBe(false);
});
it('reports nothing to compact when no codex thread exists', async () => {
const { session, sessionEvents } = createSessionStub(['/compact']);
const exitReason = await codexRemoteLauncher(session as never);
expect(exitReason).toBe('exit');
expect(harness.startThreadIds).toEqual([]);
expect(harness.startTurnThreadIds).toEqual([]);
expect(harness.compactThreadIds).toEqual([]);
expect(sessionEvents).toContainEqual({
type: 'message',
message: 'Nothing to compact'
});
});
it('rejects argument-bearing codex slash commands without starting a turn', async () => {
const { session, sessionEvents } = createSessionStub(['/compact now']);
const exitReason = await codexRemoteLauncher(session as never);
expect(exitReason).toBe('exit');
expect(harness.startThreadIds).toEqual([]);
expect(harness.startTurnThreadIds).toEqual([]);
expect(harness.compactThreadIds).toEqual([]);
expect(sessionEvents).toContainEqual({
type: 'message',
message: '/compact does not accept arguments'
});
});
}); });
+122
View File
@@ -16,6 +16,7 @@ import { AppServerEventConverter } from './utils/appServerEventConverter';
import { registerAppServerPermissionHandlers } from './utils/appServerPermissionAdapter'; import { registerAppServerPermissionHandlers } from './utils/appServerPermissionAdapter';
import { buildThreadStartParams, buildTurnStartParams } from './utils/appServerConfig'; import { buildThreadStartParams, buildTurnStartParams } from './utils/appServerConfig';
import { shouldIgnoreTerminalEvent } from './utils/terminalEventGuard'; import { shouldIgnoreTerminalEvent } from './utils/terminalEventGuard';
import { parseCodexSpecialCommand } from './codexSpecialCommands';
import { import {
RemoteLauncherBase, RemoteLauncherBase,
type RemoteLauncherDisplayContext, type RemoteLauncherDisplayContext,
@@ -609,6 +610,123 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
readyAfterTurnTimer.unref?.(); readyAfterTurnTimer.unref?.();
}; };
const sendVisibleStatus = (message: string) => {
messageBuffer.addMessage(message, 'status');
session.sendSessionEvent({ type: 'message', message });
};
const resetCurrentTurnState = () => {
turnInFlight = false;
allowAnonymousTerminalEvent = false;
this.currentTurnId = null;
permissionHandler.reset();
reasoningProcessor.abort();
diffProcessor.reset();
appServerEventConverter.reset();
session.onThinkingChange(false);
};
const interruptActiveTurn = async () => {
const threadId = this.currentThreadId;
const turnId = this.currentTurnId;
if (!threadId || !turnId) {
return;
}
try {
await appServerClient.interruptTurn({ threadId, turnId });
} catch (error) {
logger.debug('[Codex] Error interrupting app-server turn for slash command:', error);
}
};
const resumeExistingThreadForCompact = async (mode: EnhancedMode): Promise<string | null> => {
if (this.currentThreadId && this.currentThreadId !== invalidThreadId) {
hasThread = true;
return this.currentThreadId;
}
const resumeCandidate = session.sessionId && session.sessionId !== invalidThreadId
? session.sessionId
: null;
if (!resumeCandidate) {
return null;
}
const threadParams = buildThreadStartParams({
cwd: session.path,
mode,
mcpServers,
cliOverrides: session.codexCliOverrides
});
try {
const resumeResponse = await appServerClient.resumeThread({
threadId: resumeCandidate,
...threadParams
}, {
signal: this.abortController.signal
});
const resumeRecord = asRecord(resumeResponse);
const resumeThread = resumeRecord ? asRecord(resumeRecord.thread) : null;
const threadId = asString(resumeThread?.id) ?? resumeCandidate;
applyResolvedModel(resumeRecord?.model);
this.currentThreadId = threadId;
session.onSessionFound(threadId);
hasThread = true;
logger.debug(`[Codex] Resumed app-server thread ${threadId} for /compact`);
return threadId;
} catch (error) {
logger.warn(`[Codex] Failed to resume app-server thread ${resumeCandidate} for /compact`, error);
return null;
}
};
const handleSpecialCommand = async (message: QueuedMessage): Promise<boolean> => {
const specialCommand = parseCodexSpecialCommand(message.message);
if (!specialCommand.type) {
return false;
}
if (specialCommand.type === 'invalid') {
await interruptActiveTurn();
resetCurrentTurnState();
sendVisibleStatus(specialCommand.message);
return true;
}
if (specialCommand.type === 'clear') {
await interruptActiveTurn();
resetCurrentTurnState();
this.currentThreadId = null;
invalidThreadId = null;
hasThread = false;
session.resetCodexThread();
sendVisibleStatus('Context was reset');
return true;
}
await interruptActiveTurn();
resetCurrentTurnState();
const threadId = await resumeExistingThreadForCompact(message.mode);
if (!threadId) {
sendVisibleStatus('Nothing to compact');
return true;
}
sendVisibleStatus('Compaction started');
try {
await appServerClient.compactThread({ threadId }, {
signal: this.abortController.signal
});
sendVisibleStatus('Compaction completed');
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
sendVisibleStatus(`Compaction failed: ${detail}`);
}
return true;
};
while (!this.shouldExit) { while (!this.shouldExit) {
logActiveHandles('loop-top'); logActiveHandles('loop-top');
let message: QueuedMessage | null = pending; let message: QueuedMessage | null = pending;
@@ -634,6 +752,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
messageBuffer.addMessage(message.message, 'user'); messageBuffer.addMessage(message.message, 'user');
try { try {
if (await handleSpecialCommand(message)) {
continue;
}
if (!hasThread) { if (!hasThread) {
const threadParams = buildThreadStartParams({ const threadParams = buildThreadStartParams({
cwd: session.path, cwd: session.path,
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { parseCodexSpecialCommand } from './codexSpecialCommands';
describe('parseCodexSpecialCommand', () => {
it('accepts exact /clear and /compact commands', () => {
expect(parseCodexSpecialCommand(' /clear ')).toEqual({ type: 'clear' });
expect(parseCodexSpecialCommand('/compact')).toEqual({ type: 'compact' });
});
it('rejects argument-bearing special commands without treating them as prompts', () => {
expect(parseCodexSpecialCommand('/clear now')).toEqual({
type: 'invalid',
command: 'clear',
message: '/clear does not accept arguments'
});
expect(parseCodexSpecialCommand('/compact summarize this')).toEqual({
type: 'invalid',
command: 'compact',
message: '/compact does not accept arguments'
});
});
it('ignores regular slash-like messages', () => {
expect(parseCodexSpecialCommand('/clearing')).toEqual({ type: null });
expect(parseCodexSpecialCommand('please /clear')).toEqual({ type: null });
});
});
+29
View File
@@ -0,0 +1,29 @@
export type CodexSpecialCommand =
| { type: 'clear' | 'compact' }
| { type: 'invalid'; command: 'clear' | 'compact'; message: string }
| { type: null };
export function parseCodexSpecialCommand(message: string): CodexSpecialCommand {
const trimmed = message.trim();
if (trimmed === '/clear') {
return { type: 'clear' };
}
if (trimmed === '/compact') {
return { type: 'compact' };
}
if (trimmed.startsWith('/clear ')) {
return {
type: 'invalid',
command: 'clear',
message: '/clear does not accept arguments'
};
}
if (trimmed.startsWith('/compact ')) {
return {
type: 'invalid',
command: 'compact',
message: '/compact does not accept arguments'
};
}
return { type: null };
}
+12
View File
@@ -14,6 +14,7 @@ import { CodexCollaborationModeSchema, PermissionModeSchema } from '@hapi/protoc
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
import { getInvokedCwd } from '@/utils/invokedCwd'; import { getInvokedCwd } from '@/utils/invokedCwd';
import type { ReasoningEffort } from './appServerTypes'; import type { ReasoningEffort } from './appServerTypes';
import { parseCodexSpecialCommand } from './codexSpecialCommands';
import { listSlashCommands } from '@/modules/common/slashCommands'; import { listSlashCommands } from '@/modules/common/slashCommands';
import { resolveCodexSlashCommand } from './utils/slashCommands'; import { resolveCodexSlashCommand } from './utils/slashCommands';
@@ -139,6 +140,7 @@ export async function runCodex(opts: {
try { try {
syncCurrentConfigFromSession(); syncCurrentConfigFromSession();
let text = message.content.text; let text = message.content.text;
let isolatedCommandText: string | null = null;
const commands = await listSlashCommands('codex', workingDirectory).catch(() => []); const commands = await listSlashCommands('codex', workingDirectory).catch(() => []);
const slash = resolveCodexSlashCommand(text, { const slash = resolveCodexSlashCommand(text, {
commands, commands,
@@ -161,6 +163,12 @@ export async function runCodex(opts: {
return; return;
} }
text = slash.text; text = slash.text;
} else {
const specialCommand = parseCodexSpecialCommand(message.content.text);
if (specialCommand.type) {
logger.debug(`[Codex] Detected special command: ${specialCommand.type}`);
isolatedCommandText = message.content.text.trim();
}
} }
text = formatMessageWithAttachments(text, message.content.attachments); text = formatMessageWithAttachments(text, message.content.attachments);
@@ -177,6 +185,10 @@ export async function runCodex(opts: {
modelReasoningEffort: currentModelReasoningEffort, modelReasoningEffort: currentModelReasoningEffort,
collaborationMode: currentCollaborationMode collaborationMode: currentCollaborationMode
}; };
if (isolatedCommandText) {
messageQueue.pushIsolateAndClear(isolatedCommandText, enhancedMode, localId);
return;
}
messageQueue.push(text, enhancedMode, localId); messageQueue.push(text, enhancedMode, localId);
} catch (error) { } catch (error) {
logger.debug('[Codex] Failed to handle user message', error); logger.debug('[Codex] Failed to handle user message', error);
+11 -1
View File
@@ -4,7 +4,7 @@ import { AgentSessionBase } from '@/agent/sessionBase';
import type { EnhancedMode, PermissionMode } from './loop'; import type { EnhancedMode, PermissionMode } from './loop';
import type { CodexCliOverrides } from './utils/codexCliOverrides'; import type { CodexCliOverrides } from './utils/codexCliOverrides';
import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy';
import type { SessionModel, SessionModelReasoningEffort } from '@/api/types'; import type { Metadata, SessionModel, SessionModelReasoningEffort } from '@/api/types';
type LocalLaunchFailure = { type LocalLaunchFailure = {
message: string; message: string;
@@ -95,6 +95,16 @@ export class CodexSession extends AgentSessionBase<EnhancedMode> {
this.transcriptPath = null; this.transcriptPath = null;
} }
resetCodexThread(): void {
this.sessionId = null;
this.resetTranscriptPath();
this.client.updateMetadata((metadata: Metadata) => {
const updated = { ...metadata };
delete updated.codexSessionId;
return updated;
});
}
setPermissionMode = (mode: PermissionMode): void => { setPermissionMode = (mode: PermissionMode): void => {
this.permissionMode = mode; this.permissionMode = mode;
}; };
+2 -2
View File
@@ -7,8 +7,6 @@ import type { SlashCommand } from '@/modules/common/slashCommands';
const REASONING_EFFORTS = new Set<ReasoningEffort>(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']); const REASONING_EFFORTS = new Set<ReasoningEffort>(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']);
const UNSUPPORTED_CODEX_BUILTIN_COMMANDS = new Set([ const UNSUPPORTED_CODEX_BUILTIN_COMMANDS = new Set([
'clear',
'compact',
'compat', 'compat',
'diff', 'diff',
'init', 'init',
@@ -180,6 +178,8 @@ export function resolveCodexSlashCommand(
'Supported Codex slash commands:', 'Supported Codex slash commands:',
'/plan [prompt] — enable plan mode, optionally send prompt', '/plan [prompt] — enable plan mode, optionally send prompt',
'/plan off — return to default mode', '/plan off — return to default mode',
'/clear — reset current Codex thread context',
'/compact — compact current Codex thread context',
'/status — show current Codex session config', '/status — show current Codex session config',
'/model [name|auto] — show or set model', '/model [name|auto] — show or set model',
'/reasoning [low|medium|high|xhigh|default] — show or set reasoning effort', '/reasoning [low|medium|high|xhigh|default] — show or set reasoning effort',
@@ -114,6 +114,8 @@ describe('listSlashCommands', () => {
const commands = await listSlashCommands('codex', projectDir) const commands = await listSlashCommands('codex', projectDir)
expect(commands.map((command) => command.name)).toEqual(expect.arrayContaining([ expect(commands.map((command) => command.name)).toEqual(expect.arrayContaining([
'clear',
'compact',
'plan', 'plan',
'status', 'status',
'model', 'model',
@@ -122,6 +124,21 @@ describe('listSlashCommands', () => {
])) ]))
}) })
it('lets project codex prompts override same-name built-ins', async () => {
await writeFile(
join(projectDir, '.codex', 'prompts', 'clear.md'),
['---', 'description: Project clear', '---', '', 'Project clear prompt'].join('\n')
)
const commands = await listSlashCommands('codex', projectDir)
const clearCommands = commands.filter(cmd => cmd.name === 'clear')
expect(clearCommands).toHaveLength(1)
expect(clearCommands[0]?.source).toBe('project')
expect(clearCommands[0]?.description).toBe('Project clear')
expect(clearCommands[0]?.content).toBe('Project clear prompt')
})
it('loads Codex global and project prompts', async () => { it('loads Codex global and project prompts', async () => {
await writeFile( await writeFile(
join(codexHome, 'prompts', 'global-prompt.md'), join(codexHome, 'prompts', 'global-prompt.md'),
+2
View File
@@ -33,6 +33,8 @@ const BUILTIN_COMMANDS: Record<string, SlashCommand[]> = {
{ name: 'plan', description: 'Toggle plan mode', source: 'builtin' }, { name: 'plan', description: 'Toggle plan mode', source: 'builtin' },
], ],
codex: [ codex: [
{ name: 'clear', description: 'Clear current Codex thread context', source: 'builtin' },
{ name: 'compact', description: 'Compact current Codex thread context', source: 'builtin' },
{ name: 'help', description: 'Show supported HAPI Codex slash commands', source: 'builtin' }, { 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: '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: 'default', description: 'Return Codex collaboration mode to default', source: 'builtin' },
+5 -3
View File
@@ -108,10 +108,12 @@ describe('alive incremental events', () => {
expect(engine.getSession(session.id)?.activeAt).toBe(activeAtBeforeSend) expect(engine.getSession(session.id)?.activeAt).toBe(activeAtBeforeSend)
expect(emittedSocketUpdates.length).toBeGreaterThan(0) expect(emittedSocketUpdates.length).toBeGreaterThan(0)
const update = events.find((event) => ( const update = events.find((event) => {
event.type === 'session-updated' return event.type === 'session-updated'
&& typeof event.data === 'object'
&& event.data !== null
&& (event.data as { thinking?: unknown }).thinking === true && (event.data as { thinking?: unknown }).thinking === true
)) })
expect(update).toBeDefined() expect(update).toBeDefined()
if (!update || update.type !== 'session-updated') { if (!update || update.type !== 'session-updated') {
return return
+2 -6
View File
@@ -4,7 +4,7 @@ import type { ApiClient } from '@/api/client'
import type { SlashCommand } from '@/types/api' import type { SlashCommand } from '@/types/api'
import type { Suggestion } from '@/hooks/useActiveSuggestions' import type { Suggestion } from '@/hooks/useActiveSuggestions'
import { queryKeys } from '@/lib/query-keys' import { queryKeys } from '@/lib/query-keys'
import { getBuiltinSlashCommands } from '@/lib/codexSlashCommands' import { getBuiltinSlashCommands, mergeSlashCommands } from '@/lib/codexSlashCommands'
function levenshteinDistance(a: string, b: string): number { function levenshteinDistance(a: string, b: string): number {
if (a.length === 0) return b.length if (a.length === 0) return b.length
@@ -56,11 +56,7 @@ export function useSlashCommands(
const builtin = getBuiltinSlashCommands(agentType) const builtin = getBuiltinSlashCommands(agentType)
if (query.data?.success && query.data.commands) { if (query.data?.success && query.data.commands) {
const commandMap = new Map<string, SlashCommand>() return mergeSlashCommands([...builtin, ...query.data.commands])
for (const command of [...builtin, ...query.data.commands]) {
commandMap.set(command.name, command)
}
return Array.from(commandMap.values())
} }
// Fallback to built-in commands only // Fallback to built-in commands only
+43 -1
View File
@@ -1,9 +1,16 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { findUnsupportedCodexBuiltinSlashCommand, getBuiltinSlashCommands } from './codexSlashCommands' import {
findCodexCustomPromptExpansion,
findUnsupportedCodexBuiltinSlashCommand,
getBuiltinSlashCommands,
mergeSlashCommands
} from './codexSlashCommands'
describe('getBuiltinSlashCommands', () => { describe('getBuiltinSlashCommands', () => {
it('exposes HAPI-supported codex built-ins in remote web mode', () => { it('exposes HAPI-supported codex built-ins in remote web mode', () => {
expect(getBuiltinSlashCommands('codex').map((command) => command.name)).toEqual(expect.arrayContaining([ expect(getBuiltinSlashCommands('codex').map((command) => command.name)).toEqual(expect.arrayContaining([
'clear',
'compact',
'plan', 'plan',
'status', 'status',
'execute', 'execute',
@@ -13,6 +20,41 @@ describe('getBuiltinSlashCommands', () => {
}) })
}) })
describe('mergeSlashCommands', () => {
it('lets custom commands override same-name built-ins', () => {
const commands = mergeSlashCommands([
{ name: 'clear', source: 'builtin' },
{ name: 'compact', source: 'builtin' },
{ name: 'clear', source: 'project', content: 'project clear prompt' }
])
expect(commands).toEqual([
{ name: 'compact', source: 'builtin' },
{ name: 'clear', source: 'project', content: 'project clear prompt' }
])
})
})
describe('findCodexCustomPromptExpansion', () => {
it('expands exact custom codex prompt commands', () => {
expect(findCodexCustomPromptExpansion(' /clear ', [
{ name: 'clear', source: 'builtin' },
{ name: 'clear', source: 'project', content: 'custom clear prompt' }
])).toBe('custom clear prompt')
})
it('ignores built-ins and commands with arguments', () => {
const commands = [
{ name: 'compact', source: 'project', content: 'custom compact prompt' }
] as const
expect(findCodexCustomPromptExpansion('/compact now', commands)).toBeNull()
expect(findCodexCustomPromptExpansion('/clear', [
{ name: 'clear', source: 'builtin' }
])).toBeNull()
})
})
describe('findUnsupportedCodexBuiltinSlashCommand', () => { describe('findUnsupportedCodexBuiltinSlashCommand', () => {
it('detects unsupported codex built-ins', () => { it('detects unsupported codex built-ins', () => {
expect(findUnsupportedCodexBuiltinSlashCommand(' /diff ', [])).toBe('diff') expect(findUnsupportedCodexBuiltinSlashCommand(' /diff ', [])).toBe('diff')
+38
View File
@@ -12,6 +12,8 @@ const BUILTIN_COMMANDS: Record<string, SlashCommand[]> = {
{ name: 'status', description: 'Show Claude Code status including version, model, account, and API connectivity', source: 'builtin' }, { name: 'status', description: 'Show Claude Code status including version, model, account, and API connectivity', source: 'builtin' },
], ],
codex: [ codex: [
{ name: 'clear', description: 'Clear current Codex thread context', source: 'builtin' },
{ name: 'compact', description: 'Compact current Codex thread context', source: 'builtin' },
{ name: 'help', description: 'Show supported HAPI Codex slash commands', source: 'builtin' }, { 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: '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: 'default', description: 'Return Codex collaboration mode to default', source: 'builtin' },
@@ -44,6 +46,42 @@ export function getBuiltinSlashCommands(agentType: string): SlashCommand[] {
return BUILTIN_COMMANDS[agentType] ?? BUILTIN_COMMANDS.claude ?? [] return BUILTIN_COMMANDS[agentType] ?? BUILTIN_COMMANDS.claude ?? []
} }
export function mergeSlashCommands(commands: readonly SlashCommand[]): SlashCommand[] {
const commandMap = new Map<string, SlashCommand>()
for (const command of commands) {
const key = command.name.toLowerCase()
if (commandMap.has(key)) {
commandMap.delete(key)
}
commandMap.set(key, command)
}
return Array.from(commandMap.values())
}
export function findCodexCustomPromptExpansion(
text: string,
availableCommands: readonly SlashCommand[]
): string | null {
const trimmed = text.trim()
const match = /^\/([a-z0-9:_-]+)$/i.exec(trimmed)
if (!match) {
return null
}
const commandName = match[1]?.toLowerCase()
if (!commandName) {
return null
}
const command = availableCommands.find(
candidate => candidate.source !== 'builtin'
&& candidate.name.toLowerCase() === commandName
&& typeof candidate.content === 'string'
&& candidate.content.length > 0
)
return command?.content ?? null
}
export function findUnsupportedCodexBuiltinSlashCommand( export function findUnsupportedCodexBuiltinSlashCommand(
text: string, text: string,
availableCommands: readonly SlashCommand[] availableCommands: readonly SlashCommand[]