From a612be50d671471d0b90a27aa5705245f13a16ed Mon Sep 17 00:00:00 2001 From: NightWatcher314 Date: Wed, 29 Apr 2026 14:15:44 +0800 Subject: [PATCH] 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 --- cli/src/codex/appServerTypes.ts | 8 ++ cli/src/codex/codexAppServerClient.ts | 15 ++- cli/src/codex/codexRemoteLauncher.test.ts | 128 ++++++++++++++++++- cli/src/codex/codexRemoteLauncher.ts | 122 ++++++++++++++++++ cli/src/codex/codexSpecialCommands.test.ts | 27 ++++ cli/src/codex/codexSpecialCommands.ts | 29 +++++ cli/src/codex/runCodex.ts | 12 ++ cli/src/codex/session.ts | 12 +- cli/src/codex/utils/slashCommands.ts | 4 +- cli/src/modules/common/slashCommands.test.ts | 17 +++ cli/src/modules/common/slashCommands.ts | 2 + hub/src/sync/aliveEvents.test.ts | 10 +- web/src/hooks/queries/useSlashCommands.ts | 8 +- web/src/lib/codexSlashCommands.test.ts | 44 ++++++- web/src/lib/codexSlashCommands.ts | 38 ++++++ 15 files changed, 460 insertions(+), 16 deletions(-) create mode 100644 cli/src/codex/codexSpecialCommands.test.ts create mode 100644 cli/src/codex/codexSpecialCommands.ts diff --git a/cli/src/codex/appServerTypes.ts b/cli/src/codex/appServerTypes.ts index e0996657..2f13080a 100644 --- a/cli/src/codex/appServerTypes.ts +++ b/cli/src/codex/appServerTypes.ts @@ -169,3 +169,11 @@ export interface TurnInterruptResponse { ok: boolean; [key: string]: unknown; } + +export interface ThreadCompactStartParams { + threadId: string; +} + +export interface ThreadCompactStartResponse { + [key: string]: unknown; +} diff --git a/cli/src/codex/codexAppServerClient.ts b/cli/src/codex/codexAppServerClient.ts index d5a7b317..e3a1ac95 100644 --- a/cli/src/codex/codexAppServerClient.ts +++ b/cli/src/codex/codexAppServerClient.ts @@ -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 { + const response = await this.sendRequest('thread/compact/start', params, { + signal: options?.signal, + timeoutMs: CodexAppServerClient.DEFAULT_TIMEOUT_MS + }); + return response as ThreadCompactStartResponse; + } + async disconnect(): Promise { if (!this.connected) { return; diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index 22307e1d..39b8b2c9 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -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> { + async interruptTurn(params?: { threadId?: string; turnId?: string }): Promise> { + harness.interruptedTurns.push({ + threadId: params?.threadId ?? 'thread-unknown', + turnId: params?.turnId ?? 'turn-unknown' + }); + return {}; + } + + async compactThread(params?: { threadId?: string }): Promise> { + 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' + }); + }); }); diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 8251a2a3..d0612a1f 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -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 => { + 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 => { + 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, diff --git a/cli/src/codex/codexSpecialCommands.test.ts b/cli/src/codex/codexSpecialCommands.test.ts new file mode 100644 index 00000000..80028730 --- /dev/null +++ b/cli/src/codex/codexSpecialCommands.test.ts @@ -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 }); + }); +}); diff --git a/cli/src/codex/codexSpecialCommands.ts b/cli/src/codex/codexSpecialCommands.ts new file mode 100644 index 00000000..c6e4cb00 --- /dev/null +++ b/cli/src/codex/codexSpecialCommands.ts @@ -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 }; +} diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index 17985940..90423028 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -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); diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts index 524d21d7..701c69e9 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -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 { 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; }; diff --git a/cli/src/codex/utils/slashCommands.ts b/cli/src/codex/utils/slashCommands.ts index da66ed13..161cedb1 100644 --- a/cli/src/codex/utils/slashCommands.ts +++ b/cli/src/codex/utils/slashCommands.ts @@ -7,8 +7,6 @@ import type { SlashCommand } from '@/modules/common/slashCommands'; const REASONING_EFFORTS = new Set(['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', diff --git a/cli/src/modules/common/slashCommands.test.ts b/cli/src/modules/common/slashCommands.test.ts index 195b1356..2186077c 100644 --- a/cli/src/modules/common/slashCommands.test.ts +++ b/cli/src/modules/common/slashCommands.test.ts @@ -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'), diff --git a/cli/src/modules/common/slashCommands.ts b/cli/src/modules/common/slashCommands.ts index 41c38e90..2cafb344 100644 --- a/cli/src/modules/common/slashCommands.ts +++ b/cli/src/modules/common/slashCommands.ts @@ -33,6 +33,8 @@ const BUILTIN_COMMANDS: Record = { { 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' }, diff --git a/hub/src/sync/aliveEvents.test.ts b/hub/src/sync/aliveEvents.test.ts index ec74fdf1..a218f493 100644 --- a/hub/src/sync/aliveEvents.test.ts +++ b/hub/src/sync/aliveEvents.test.ts @@ -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 diff --git a/web/src/hooks/queries/useSlashCommands.ts b/web/src/hooks/queries/useSlashCommands.ts index 0fc3d2b0..1a3ce89f 100644 --- a/web/src/hooks/queries/useSlashCommands.ts +++ b/web/src/hooks/queries/useSlashCommands.ts @@ -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() - 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 diff --git a/web/src/lib/codexSlashCommands.test.ts b/web/src/lib/codexSlashCommands.test.ts index ba64cfc8..15b3c3d4 100644 --- a/web/src/lib/codexSlashCommands.test.ts +++ b/web/src/lib/codexSlashCommands.test.ts @@ -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') diff --git a/web/src/lib/codexSlashCommands.ts b/web/src/lib/codexSlashCommands.ts index 6cb52195..cfb61b3a 100644 --- a/web/src/lib/codexSlashCommands.ts +++ b/web/src/lib/codexSlashCommands.ts @@ -12,6 +12,8 @@ const BUILTIN_COMMANDS: Record = { { 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() + 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[]