mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -169,3 +169,11 @@ export interface TurnInterruptResponse {
|
||||
ok: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ThreadCompactStartParams {
|
||||
threadId: string;
|
||||
}
|
||||
|
||||
export interface ThreadCompactStartResponse {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import type {
|
||||
TurnStartParams,
|
||||
TurnStartResponse,
|
||||
TurnInterruptParams,
|
||||
TurnInterruptResponse
|
||||
TurnInterruptResponse,
|
||||
ThreadCompactStartParams,
|
||||
ThreadCompactStartResponse
|
||||
} from './appServerTypes';
|
||||
|
||||
type JsonRpcLiteRequest = {
|
||||
@@ -173,6 +175,17 @@ export class CodexAppServerClient {
|
||||
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> {
|
||||
if (!this.connected) {
|
||||
return;
|
||||
|
||||
@@ -9,6 +9,9 @@ const harness = vi.hoisted(() => ({
|
||||
startThreadIds: [] as string[],
|
||||
resumeThreadIds: [] as string[],
|
||||
startTurnThreadIds: [] as string[],
|
||||
interruptedTurns: [] as Array<{ threadId: string; turnId: string }>,
|
||||
compactThreadIds: [] as string[],
|
||||
suppressTurnCompletion: false,
|
||||
remainingThreadSystemErrors: 0
|
||||
}));
|
||||
|
||||
@@ -62,6 +65,10 @@ vi.mock('./codexAppServerClient', () => {
|
||||
return { turn: { id: turnId } };
|
||||
}
|
||||
|
||||
if (harness.suppressTurnCompletion) {
|
||||
return { turn: { id: turnId } };
|
||||
}
|
||||
|
||||
const completed = { status: 'Completed', turn: { id: turnId } };
|
||||
harness.notifications.push({ method: 'turn/completed', params: completed });
|
||||
this.notificationHandler?.('turn/completed', completed);
|
||||
@@ -69,7 +76,16 @@ vi.mock('./codexAppServerClient', () => {
|
||||
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 {};
|
||||
}
|
||||
|
||||
@@ -117,6 +133,7 @@ function createSessionStub(messages = ['hello from launcher test']) {
|
||||
const codexMessages: unknown[] = [];
|
||||
const thinkingChanges: boolean[] = [];
|
||||
const foundSessionIds: string[] = [];
|
||||
const resetThreadCalls: string[] = [];
|
||||
let currentModel: string | null | undefined;
|
||||
let agentState: FakeAgentState = {
|
||||
requests: {},
|
||||
@@ -168,6 +185,10 @@ function createSessionStub(messages = ['hello from launcher test']) {
|
||||
session.sessionId = id;
|
||||
foundSessionIds.push(id);
|
||||
},
|
||||
resetCodexThread() {
|
||||
resetThreadCalls.push(session.sessionId ?? 'none');
|
||||
session.sessionId = null;
|
||||
},
|
||||
sendAgentMessage(message: unknown) {
|
||||
client.sendAgentMessage(message);
|
||||
},
|
||||
@@ -185,6 +206,7 @@ function createSessionStub(messages = ['hello from launcher test']) {
|
||||
codexMessages,
|
||||
thinkingChanges,
|
||||
foundSessionIds,
|
||||
resetThreadCalls,
|
||||
rpcHandlers,
|
||||
getModel: () => currentModel,
|
||||
getAgentState: () => agentState
|
||||
@@ -199,6 +221,9 @@ describe('codexRemoteLauncher', () => {
|
||||
harness.startThreadIds = [];
|
||||
harness.resumeThreadIds = [];
|
||||
harness.startTurnThreadIds = [];
|
||||
harness.interruptedTurns = [];
|
||||
harness.compactThreadIds = [];
|
||||
harness.suppressTurnCompletion = false;
|
||||
harness.remainingThreadSystemErrors = 0;
|
||||
});
|
||||
|
||||
@@ -260,4 +285,105 @@ describe('codexRemoteLauncher', () => {
|
||||
expect(session.sessionId).toBe('thread-2');
|
||||
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'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AppServerEventConverter } from './utils/appServerEventConverter';
|
||||
import { registerAppServerPermissionHandlers } from './utils/appServerPermissionAdapter';
|
||||
import { buildThreadStartParams, buildTurnStartParams } from './utils/appServerConfig';
|
||||
import { shouldIgnoreTerminalEvent } from './utils/terminalEventGuard';
|
||||
import { parseCodexSpecialCommand } from './codexSpecialCommands';
|
||||
import {
|
||||
RemoteLauncherBase,
|
||||
type RemoteLauncherDisplayContext,
|
||||
@@ -609,6 +610,123 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
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) {
|
||||
logActiveHandles('loop-top');
|
||||
let message: QueuedMessage | null = pending;
|
||||
@@ -634,6 +752,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
messageBuffer.addMessage(message.message, 'user');
|
||||
|
||||
try {
|
||||
if (await handleSpecialCommand(message)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasThread) {
|
||||
const threadParams = buildThreadStartParams({
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { CodexCollaborationModeSchema, PermissionModeSchema } from '@hapi/protoc
|
||||
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
|
||||
import { getInvokedCwd } from '@/utils/invokedCwd';
|
||||
import type { ReasoningEffort } from './appServerTypes';
|
||||
import { parseCodexSpecialCommand } from './codexSpecialCommands';
|
||||
import { listSlashCommands } from '@/modules/common/slashCommands';
|
||||
import { resolveCodexSlashCommand } from './utils/slashCommands';
|
||||
|
||||
@@ -139,6 +140,7 @@ export async function runCodex(opts: {
|
||||
try {
|
||||
syncCurrentConfigFromSession();
|
||||
let text = message.content.text;
|
||||
let isolatedCommandText: string | null = null;
|
||||
const commands = await listSlashCommands('codex', workingDirectory).catch(() => []);
|
||||
const slash = resolveCodexSlashCommand(text, {
|
||||
commands,
|
||||
@@ -161,6 +163,12 @@ export async function runCodex(opts: {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -177,6 +185,10 @@ export async function runCodex(opts: {
|
||||
modelReasoningEffort: currentModelReasoningEffort,
|
||||
collaborationMode: currentCollaborationMode
|
||||
};
|
||||
if (isolatedCommandText) {
|
||||
messageQueue.pushIsolateAndClear(isolatedCommandText, enhancedMode, localId);
|
||||
return;
|
||||
}
|
||||
messageQueue.push(text, enhancedMode, localId);
|
||||
} catch (error) {
|
||||
logger.debug('[Codex] Failed to handle user message', error);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AgentSessionBase } from '@/agent/sessionBase';
|
||||
import type { EnhancedMode, PermissionMode } from './loop';
|
||||
import type { CodexCliOverrides } from './utils/codexCliOverrides';
|
||||
import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy';
|
||||
import type { SessionModel, SessionModelReasoningEffort } from '@/api/types';
|
||||
import type { Metadata, SessionModel, SessionModelReasoningEffort } from '@/api/types';
|
||||
|
||||
type LocalLaunchFailure = {
|
||||
message: string;
|
||||
@@ -95,6 +95,16 @@ export class CodexSession extends AgentSessionBase<EnhancedMode> {
|
||||
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 => {
|
||||
this.permissionMode = mode;
|
||||
};
|
||||
|
||||
@@ -7,8 +7,6 @@ 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',
|
||||
@@ -180,6 +178,8 @@ export function resolveCodexSlashCommand(
|
||||
'Supported Codex slash commands:',
|
||||
'/plan [prompt] — enable plan mode, optionally send prompt',
|
||||
'/plan off — return to default mode',
|
||||
'/clear — reset current Codex thread context',
|
||||
'/compact — compact current Codex thread context',
|
||||
'/status — show current Codex session config',
|
||||
'/model [name|auto] — show or set model',
|
||||
'/reasoning [low|medium|high|xhigh|default] — show or set reasoning effort',
|
||||
|
||||
@@ -114,6 +114,8 @@ describe('listSlashCommands', () => {
|
||||
const commands = await listSlashCommands('codex', projectDir)
|
||||
|
||||
expect(commands.map((command) => command.name)).toEqual(expect.arrayContaining([
|
||||
'clear',
|
||||
'compact',
|
||||
'plan',
|
||||
'status',
|
||||
'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 () => {
|
||||
await writeFile(
|
||||
join(codexHome, 'prompts', 'global-prompt.md'),
|
||||
|
||||
@@ -33,6 +33,8 @@ const BUILTIN_COMMANDS: Record<string, SlashCommand[]> = {
|
||||
{ name: 'plan', description: 'Toggle plan mode', source: 'builtin' },
|
||||
],
|
||||
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: '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' },
|
||||
|
||||
@@ -108,10 +108,12 @@ describe('alive incremental events', () => {
|
||||
expect(engine.getSession(session.id)?.activeAt).toBe(activeAtBeforeSend)
|
||||
expect(emittedSocketUpdates.length).toBeGreaterThan(0)
|
||||
|
||||
const update = events.find((event) => (
|
||||
event.type === 'session-updated'
|
||||
&& (event.data as { thinking?: unknown }).thinking === true
|
||||
))
|
||||
const update = events.find((event) => {
|
||||
return event.type === 'session-updated'
|
||||
&& typeof event.data === 'object'
|
||||
&& event.data !== null
|
||||
&& (event.data as { thinking?: unknown }).thinking === true
|
||||
})
|
||||
expect(update).toBeDefined()
|
||||
if (!update || update.type !== 'session-updated') {
|
||||
return
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ApiClient } from '@/api/client'
|
||||
import type { SlashCommand } from '@/types/api'
|
||||
import type { Suggestion } from '@/hooks/useActiveSuggestions'
|
||||
import { queryKeys } from '@/lib/query-keys'
|
||||
import { getBuiltinSlashCommands } from '@/lib/codexSlashCommands'
|
||||
import { getBuiltinSlashCommands, mergeSlashCommands } from '@/lib/codexSlashCommands'
|
||||
|
||||
function levenshteinDistance(a: string, b: string): number {
|
||||
if (a.length === 0) return b.length
|
||||
@@ -56,11 +56,7 @@ export function useSlashCommands(
|
||||
const builtin = getBuiltinSlashCommands(agentType)
|
||||
|
||||
if (query.data?.success && query.data.commands) {
|
||||
const commandMap = new Map<string, SlashCommand>()
|
||||
for (const command of [...builtin, ...query.data.commands]) {
|
||||
commandMap.set(command.name, command)
|
||||
}
|
||||
return Array.from(commandMap.values())
|
||||
return mergeSlashCommands([...builtin, ...query.data.commands])
|
||||
}
|
||||
|
||||
// Fallback to built-in commands only
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { findUnsupportedCodexBuiltinSlashCommand, getBuiltinSlashCommands } from './codexSlashCommands'
|
||||
import {
|
||||
findCodexCustomPromptExpansion,
|
||||
findUnsupportedCodexBuiltinSlashCommand,
|
||||
getBuiltinSlashCommands,
|
||||
mergeSlashCommands
|
||||
} from './codexSlashCommands'
|
||||
|
||||
describe('getBuiltinSlashCommands', () => {
|
||||
it('exposes HAPI-supported codex built-ins in remote web mode', () => {
|
||||
expect(getBuiltinSlashCommands('codex').map((command) => command.name)).toEqual(expect.arrayContaining([
|
||||
'clear',
|
||||
'compact',
|
||||
'plan',
|
||||
'status',
|
||||
'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', () => {
|
||||
it('detects unsupported codex built-ins', () => {
|
||||
expect(findUnsupportedCodexBuiltinSlashCommand(' /diff ', [])).toBe('diff')
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
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: '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' },
|
||||
@@ -44,6 +46,42 @@ export function getBuiltinSlashCommands(agentType: string): SlashCommand[] {
|
||||
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(
|
||||
text: string,
|
||||
availableCommands: readonly SlashCommand[]
|
||||
|
||||
Reference in New Issue
Block a user