feat: support Codex goal slash command

This commit is contained in:
weishu
2026-05-15 22:15:17 +08:00
parent a099ae9199
commit 089ddad476
24 changed files with 808 additions and 6 deletions
@@ -16,6 +16,33 @@ describe('AppServerEventConverter', () => {
expect(events).toEqual([{ type: 'thread_started', thread_id: 'thread-2' }]);
});
it('maps thread goal updates and clears', () => {
const converter = new AppServerEventConverter();
const goal = {
threadId: 'thread-1',
objective: 'ship goal support',
status: 'active'
};
expect(converter.handleNotification('thread/goal/updated', {
threadId: 'thread-1',
turnId: 'turn-1',
goal
})).toEqual([{
type: 'thread_goal_updated',
thread_id: 'thread-1',
turn_id: 'turn-1',
goal
}]);
expect(converter.handleNotification('thread/goal/cleared', {
threadId: 'thread-1'
})).toEqual([{
type: 'thread_goal_cleared',
thread_id: 'thread-1'
}]);
});
it('maps thread systemError to a task failure', () => {
const converter = new AppServerEventConverter();
const events = converter.handleNotification('thread/status/changed', {
@@ -546,6 +546,34 @@ export class AppServerEventConverter {
return events;
}
if (method === 'thread/goal/updated') {
const goal = asRecord(paramsRecord.goal);
const threadId = asString(paramsRecord.threadId ?? paramsRecord.thread_id ?? goal?.threadId ?? goal?.thread_id);
if (!threadId || !goal) {
return events;
}
const turnId = asString(paramsRecord.turnId ?? paramsRecord.turn_id);
events.push({
type: 'thread_goal_updated',
thread_id: threadId,
...(turnId ? { turn_id: turnId } : {}),
goal
});
return events;
}
if (method === 'thread/goal/cleared') {
const threadId = asString(paramsRecord.threadId ?? paramsRecord.thread_id ?? eventScope.thread_id);
if (!threadId) {
return events;
}
events.push({
type: 'thread_goal_cleared',
thread_id: threadId
});
return events;
}
if (method === 'thread/started' || method === 'thread/resumed') {
const thread = asRecord(paramsRecord.thread) ?? paramsRecord;
const threadId = asString(thread.threadId ?? thread.thread_id ?? thread.id);
+31
View File
@@ -46,6 +46,37 @@ describe('resolveCodexSlashCommand', () => {
});
});
it('resolves Codex goal commands for native handling', () => {
expect(resolveCodexSlashCommand('/goal', state)).toEqual({
kind: 'goal',
action: 'show'
});
expect(resolveCodexSlashCommand('/goal improve benchmark coverage', state)).toEqual({
kind: 'goal',
action: 'set',
objective: 'improve benchmark coverage'
});
expect(resolveCodexSlashCommand('/goal pause', state)).toEqual({
kind: 'goal',
action: 'pause'
});
expect(resolveCodexSlashCommand('/goal resume', state)).toEqual({
kind: 'goal',
action: 'resume'
});
expect(resolveCodexSlashCommand('/goal clear', state)).toEqual({
kind: 'goal',
action: 'clear'
});
});
it('rejects oversized Codex goal objectives', () => {
expect(resolveCodexSlashCommand(`/goal ${'x'.repeat(4001)}`, state)).toEqual({
kind: 'handled',
message: 'Goal objective must be at most 4000 characters.'
});
});
it('expands custom Codex prompt commands', () => {
expect(resolveCodexSlashCommand('/review src/index.ts', {
...state,
+43
View File
@@ -5,6 +5,7 @@ import type { EnhancedMode } from '../loop';
import type { SlashCommand } from '@/modules/common/slashCommands';
const REASONING_EFFORTS = new Set<ReasoningEffort>(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']);
export const MAX_CODEX_GOAL_OBJECTIVE_CHARS = 4_000;
const UNSUPPORTED_CODEX_BUILTIN_COMMANDS = new Set([
'compat',
@@ -43,6 +44,12 @@ export type CodexSlashResolution =
model?: string | null;
modelReasoningEffort?: ReasoningEffort | null;
};
}
| {
kind: 'goal';
action: 'show' | 'set' | 'pause' | 'resume' | 'clear';
objective?: string;
message?: string;
};
export function resolveCodexSlashCommand(
@@ -97,6 +104,40 @@ export function resolveCodexSlashCommand(
};
}
if (command === 'goal') {
const lowerRest = rest.toLowerCase();
if (!rest) {
return { kind: 'goal', action: 'show' };
}
if (lowerRest === 'clear') {
return { kind: 'goal', action: 'clear' };
}
if (lowerRest === 'pause') {
return { kind: 'goal', action: 'pause' };
}
if (lowerRest === 'resume') {
return { kind: 'goal', action: 'resume' };
}
const objective = rest.trim();
if (!objective) {
return {
kind: 'handled',
message: 'Goal objective must not be empty.'
};
}
if ([...objective].length > MAX_CODEX_GOAL_OBJECTIVE_CHARS) {
return {
kind: 'handled',
message: `Goal objective must be at most ${MAX_CODEX_GOAL_OBJECTIVE_CHARS} characters.`
};
}
return {
kind: 'goal',
action: 'set',
objective
};
}
if (command === 'default' || command === 'execute') {
return {
kind: 'handled',
@@ -178,6 +219,8 @@ export function resolveCodexSlashCommand(
'Supported Codex slash commands:',
'/plan [prompt] — enable plan mode, optionally send prompt',
'/plan off — return to default mode',
'/goal [objective] — set or view the persistent goal',
'/goal pause|resume|clear — update the current goal',
'/clear — reset current Codex thread context',
'/compact — compact current Codex thread context',
'/status — show current Codex session config',