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;
[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,
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;
+127 -1
View File
@@ -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'
});
});
});
+122
View File
@@ -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 });
});
});
+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 { 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);
+11 -1
View File
@@ -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;
};
+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 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'),
+2
View File
@@ -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' },
+6 -4
View File
@@ -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
+2 -6
View File
@@ -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
+43 -1
View File
@@ -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')
+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' },
],
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[]