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;
|
ok: boolean;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ThreadCompactStartParams {
|
||||||
|
threadId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadCompactStartResponse {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { 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);
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
Reference in New Issue
Block a user