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
+52
View File
@@ -190,3 +190,55 @@ export interface ThreadCompactStartParams {
export interface ThreadCompactStartResponse {
[key: string]: unknown;
}
export type ThreadGoalStatus = 'active' | 'paused' | 'budgetLimited' | 'complete';
export interface ThreadGoal {
threadId: string;
objective: string;
status: ThreadGoalStatus;
tokenBudget: number | null;
tokensUsed: number;
timeUsedSeconds: number;
createdAt: number;
updatedAt: number;
}
export interface ThreadGoalSetParams {
threadId: string;
objective?: string | null;
status?: ThreadGoalStatus | null;
tokenBudget?: number | null;
}
export interface ThreadGoalSetResponse {
goal: ThreadGoal;
[key: string]: unknown;
}
export interface ThreadGoalGetParams {
threadId: string;
}
export interface ThreadGoalGetResponse {
goal: ThreadGoal | null;
[key: string]: unknown;
}
export interface ThreadGoalClearParams {
threadId: string;
}
export interface ThreadGoalClearResponse {
cleared: boolean;
[key: string]: unknown;
}
export interface ExperimentalFeatureEnablementSetParams {
enablement: Record<string, boolean>;
}
export interface ExperimentalFeatureEnablementSetResponse {
enablement: Record<string, boolean>;
[key: string]: unknown;
}
+51 -1
View File
@@ -16,7 +16,15 @@ import type {
TurnInterruptParams,
TurnInterruptResponse,
ThreadCompactStartParams,
ThreadCompactStartResponse
ThreadCompactStartResponse,
ThreadGoalSetParams,
ThreadGoalSetResponse,
ThreadGoalGetParams,
ThreadGoalGetResponse,
ThreadGoalClearParams,
ThreadGoalClearResponse,
ExperimentalFeatureEnablementSetParams,
ExperimentalFeatureEnablementSetResponse
} from './appServerTypes';
type JsonRpcLiteRequest = {
@@ -153,6 +161,15 @@ export class CodexAppServerClient {
return response as CollaborationModeListResponse;
}
async setExperimentalFeatureEnablement(
params: ExperimentalFeatureEnablementSetParams
): Promise<ExperimentalFeatureEnablementSetResponse> {
const response = await this.sendRequest('experimentalFeature/enablement/set', params, {
timeoutMs: 30_000
});
return response as ExperimentalFeatureEnablementSetResponse;
}
async startThread(params: ThreadStartParams, options?: { signal?: AbortSignal }): Promise<ThreadStartResponse> {
const response = await this.sendRequest('thread/start', params, {
signal: options?.signal,
@@ -195,6 +212,39 @@ export class CodexAppServerClient {
return response as ThreadCompactStartResponse;
}
async setThreadGoal(
params: ThreadGoalSetParams,
options?: { signal?: AbortSignal }
): Promise<ThreadGoalSetResponse> {
const response = await this.sendRequest('thread/goal/set', params, {
signal: options?.signal,
timeoutMs: 30_000
});
return response as ThreadGoalSetResponse;
}
async getThreadGoal(
params: ThreadGoalGetParams,
options?: { signal?: AbortSignal }
): Promise<ThreadGoalGetResponse> {
const response = await this.sendRequest('thread/goal/get', params, {
signal: options?.signal,
timeoutMs: 30_000
});
return response as ThreadGoalGetResponse;
}
async clearThreadGoal(
params: ThreadGoalClearParams,
options?: { signal?: AbortSignal }
): Promise<ThreadGoalClearResponse> {
const response = await this.sendRequest('thread/goal/clear', params, {
signal: options?.signal,
timeoutMs: 30_000
});
return response as ThreadGoalClearResponse;
}
async disconnect(): Promise<void> {
if (!this.connected) {
return;
+134
View File
@@ -7,6 +7,8 @@ const harness = vi.hoisted(() => ({
registerRequestCalls: [] as string[],
requestHandlers: new Map<string, (params: unknown) => Promise<unknown> | unknown>(),
initializeCalls: [] as unknown[],
setFeatureEnablementCalls: [] as unknown[],
failSetFeatureEnablement: false,
listCollaborationModeCalls: 0,
collaborationModeResponse: { data: [{ mode: 'default' }, { mode: 'plan' }] } as unknown,
failListCollaborationModes: false,
@@ -17,6 +19,10 @@ const harness = vi.hoisted(() => ({
startTurnErrors: [] as Error[],
interruptedTurns: [] as Array<{ threadId: string; turnId: string }>,
compactThreadIds: [] as string[],
goalSetCalls: [] as unknown[],
goalGetCalls: [] as unknown[],
goalClearCalls: [] as unknown[],
goal: null as Record<string, unknown> | null,
suppressTurnCompletion: false,
remainingThreadSystemErrors: 0,
startTurnMessages: [] as string[],
@@ -26,6 +32,7 @@ const harness = vi.hoisted(() => ({
deferThreadStatusNotifications: false,
emitChildThreadEvents: false,
emitChildUsageEvents: false,
emitChildGoalEvent: false,
emitChildReasoningBurst: false,
emitChildDoneStatusWithoutMessage: false,
emitChildWaitStructuredOutput: false,
@@ -69,6 +76,14 @@ vi.mock('./codexAppServerClient', () => {
return harness.collaborationModeResponse;
}
async setExperimentalFeatureEnablement(params: unknown): Promise<unknown> {
harness.setFeatureEnablementCalls.push(params);
if (harness.failSetFeatureEnablement) {
throw new Error('unsupported feature enablement');
}
return params;
}
registerRequestHandler(method: string, handler: (params: unknown) => Promise<unknown> | unknown): void {
harness.registerRequestCalls.push(method);
harness.requestHandlers.set(method, handler);
@@ -102,6 +117,42 @@ vi.mock('./codexAppServerClient', () => {
return {};
}
async setThreadGoal(params?: { threadId?: string; objective?: string; status?: string }): Promise<{ goal: Record<string, unknown> }> {
harness.goalSetCalls.push(params ?? {});
const threadId = params?.threadId ?? 'thread-unknown';
harness.goal = {
threadId,
objective: params?.objective ?? harness.goal?.objective ?? 'existing goal',
status: params?.status ?? 'active',
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1,
updatedAt: 2
};
const notification = { threadId, goal: harness.goal };
harness.notifications.push({ method: 'thread/goal/updated', params: notification });
this.notificationHandler?.('thread/goal/updated', notification);
return { goal: harness.goal };
}
async getThreadGoal(params?: { threadId?: string }): Promise<{ goal: Record<string, unknown> | null }> {
harness.goalGetCalls.push(params ?? {});
return { goal: harness.goal };
}
async clearThreadGoal(params?: { threadId?: string }): Promise<{ cleared: boolean }> {
harness.goalClearCalls.push(params ?? {});
const cleared = harness.goal !== null;
harness.goal = null;
if (cleared) {
const notification = { threadId: params?.threadId ?? 'thread-unknown' };
harness.notifications.push({ method: 'thread/goal/cleared', params: notification });
this.notificationHandler?.('thread/goal/cleared', notification);
}
return { cleared };
}
async startTurn(params?: { threadId?: string; input?: Array<{ text?: string }>; message?: string; userMessage?: string }): Promise<{ turn: { id?: string } }> {
harness.startTurnParams.push((params ?? {}) as Record<string, unknown>);
const nextError = harness.startTurnErrors.shift();
@@ -389,6 +440,24 @@ vi.mock('./codexAppServerClient', () => {
this.notificationHandler?.('thread/tokenUsage/updated', ambiguousUsage);
}
if (harness.emitChildGoalEvent) {
const childGoal = {
threadId: childThreadId,
goal: {
threadId: childThreadId,
objective: 'child-only goal',
status: 'active',
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1,
updatedAt: 2
}
};
harness.notifications.push({ method: 'thread/goal/updated', params: childGoal });
this.notificationHandler?.('thread/goal/updated', childGoal);
}
const childCommandStart = {
item: {
id: 'child-cmd-1',
@@ -781,6 +850,8 @@ describe('codexRemoteLauncher', () => {
harness.registerRequestCalls = [];
harness.requestHandlers = new Map();
harness.initializeCalls = [];
harness.setFeatureEnablementCalls = [];
harness.failSetFeatureEnablement = false;
harness.listCollaborationModeCalls = 0;
harness.collaborationModeResponse = { data: [{ mode: 'default' }, { mode: 'plan' }] };
harness.failListCollaborationModes = false;
@@ -791,6 +862,10 @@ describe('codexRemoteLauncher', () => {
harness.startTurnErrors = [];
harness.interruptedTurns = [];
harness.compactThreadIds = [];
harness.goalSetCalls = [];
harness.goalGetCalls = [];
harness.goalClearCalls = [];
harness.goal = null;
harness.suppressTurnCompletion = false;
harness.startTurnMessages = [];
harness.failResumeThreadIds = [];
@@ -800,6 +875,7 @@ describe('codexRemoteLauncher', () => {
harness.deferThreadStatusNotifications = false;
harness.emitChildThreadEvents = false;
harness.emitChildUsageEvents = false;
harness.emitChildGoalEvent = false;
harness.emitChildReasoningBurst = false;
harness.emitChildDoneStatusWithoutMessage = false;
harness.emitChildWaitStructuredOutput = false;
@@ -843,6 +919,7 @@ describe('codexRemoteLauncher', () => {
experimentalApi: true
}
}]);
expect(harness.setFeatureEnablementCalls).toEqual([{ enablement: { goals: true } }]);
expect(harness.notifications.map((entry) => entry.method)).toEqual([
'turn/started',
'item/started',
@@ -979,6 +1056,50 @@ describe('codexRemoteLauncher', () => {
});
});
it('sets a Codex goal without starting a normal turn', async () => {
const { session, sessionEvents, codexMessages, foundSessionIds } = createSessionStub(['/goal improve benchmark coverage']);
const exitReason = await codexRemoteLauncher(session as never);
expect(exitReason).toBe('exit');
expect(foundSessionIds).toEqual(['thread-1']);
expect(harness.startTurnParams).toHaveLength(0);
expect(harness.goalSetCalls).toEqual([{
threadId: 'thread-1',
objective: 'improve benchmark coverage',
status: 'active'
}]);
expect(sessionEvents).toContainEqual({
type: 'message',
message: 'Goal active'
});
expect(codexMessages).toEqual(expect.arrayContaining([
expect.objectContaining({
type: 'thread_goal_updated',
thread_id: 'thread-1',
goal: expect.objectContaining({
objective: 'improve benchmark coverage',
status: 'active'
})
})
]));
});
it('shows unsupported message when goals feature cannot be enabled', async () => {
harness.failSetFeatureEnablement = true;
const { session, sessionEvents } = createSessionStub(['/goal improve benchmark coverage']);
const exitReason = await codexRemoteLauncher(session as never);
expect(exitReason).toBe('exit');
expect(harness.goalSetCalls).toHaveLength(0);
expect(harness.startTurnParams).toHaveLength(0);
expect(sessionEvents).toContainEqual({
type: 'message',
message: 'Codex goals are not supported by this Codex runtime. Upgrade Codex or enable features.goals.'
});
});
it('switches collaboration mode to default after approving exit_plan_mode', async () => {
const { session, rpcHandlers, collaborationModes, getCollaborationMode } = createSessionStub(['plan this'], {
permissionMode: 'default',
@@ -1497,6 +1618,19 @@ describe('codexRemoteLauncher', () => {
}));
});
it('keeps child goal events out of the parent goal stream', async () => {
harness.emitChildThreadEvents = true;
harness.emitChildGoalEvent = true;
const { session, codexMessages } = createSessionStub();
await codexRemoteLauncher(session as never);
expect(codexMessages).not.toContainEqual(expect.objectContaining({
type: 'thread_goal_updated',
thread_id: 'child-thread'
}));
});
it('marks parent usage and compact events with parent scope', async () => {
harness.emitParentUsageEvents = true;
const { session, codexMessages } = createSessionStub();
+249 -1
View File
@@ -15,6 +15,7 @@ import { hasCodexCliOverrides } from './utils/codexCliOverrides';
import { AppServerEventConverter } from './utils/appServerEventConverter';
import { registerAppServerPermissionHandlers } from './utils/appServerPermissionAdapter';
import { buildThreadStartParams, buildTurnStartParams } from './utils/appServerConfig';
import type { ThreadGoal, ThreadGoalStatus } from './appServerTypes';
import { shouldIgnoreTerminalEvent } from './utils/terminalEventGuard';
import { parseCodexSpecialCommand } from './codexSpecialCommands';
import {
@@ -57,6 +58,8 @@ const CONTEXT_COMPACT_RETRYABLE_ERROR_PATTERNS = [
const SAME_THREAD_MAX_RETRIES = 3;
const SAME_THREAD_MAX_COMPACT_RETRIES = 1;
const SAME_THREAD_COMPACT_TIMEOUT_MS = 10 * 60 * 1000;
const CODEX_GOALS_UNSUPPORTED_MESSAGE = 'Codex goals are not supported by this Codex runtime. Upgrade Codex or enable features.goals.';
const MAX_CODEX_GOAL_OBJECTIVE_CHARS = 4_000;
function isSameThreadRetryableCodexError(error: string | null): boolean {
if (!error) {
@@ -74,6 +77,31 @@ function isContextCompactRetryableCodexError(error: string | null): boolean {
return CONTEXT_COMPACT_RETRYABLE_ERROR_PATTERNS.some((pattern) => normalized.includes(pattern));
}
function formatGoalStatus(status: unknown): string {
switch (status) {
case 'active':
return 'active';
case 'paused':
return 'paused';
case 'budgetLimited':
return 'limited by budget';
case 'complete':
return 'complete';
default:
return typeof status === 'string' ? status : 'updated';
}
}
function formatGoalUsage(goal: ThreadGoal): string {
const parts: string[] = [`Goal ${formatGoalStatus(goal.status)}`];
if (goal.tokenBudget !== null && goal.tokenBudget !== undefined) {
parts.push(`${goal.tokensUsed}/${goal.tokenBudget} tokens`);
} else if (goal.tokensUsed > 0) {
parts.push(`${goal.tokensUsed} tokens`);
}
return parts.join(' · ');
}
class CodexRemoteLauncher extends RemoteLauncherBase {
private readonly session: CodexSession;
private readonly appServerClient: CodexAppServerClient;
@@ -547,7 +575,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
};
const isScopeSensitiveCodexEvent = (type: string): boolean => {
return type === 'token_count' || type === 'context_compacted';
return type === 'token_count'
|| type === 'context_compacted'
|| type === 'thread_goal_updated'
|| type === 'thread_goal_cleared';
};
const hasKnownChildAgents = (): boolean => {
@@ -1741,6 +1772,22 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
return;
}
if (msgType === 'thread_goal_updated') {
session.sendAgentMessage({
...addCodexEventScope(msg, 'parent', eventThreadId ?? this.currentThreadId),
id: randomUUID()
});
return;
}
if (msgType === 'thread_goal_cleared') {
session.sendAgentMessage({
...addCodexEventScope(msg, 'parent', eventThreadId ?? this.currentThreadId),
id: randomUUID()
});
return;
}
if (msgType === 'task_started') {
const turnId = eventTurnId;
if (turnId) {
@@ -2229,6 +2276,14 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
});
let supportsTurnCollaborationMode = true;
let supportsPlanCollaborationMode = true;
let supportsGoals = true;
try {
await appServerClient.setExperimentalFeatureEnablement({ enablement: { goals: true } });
logger.debug('[Codex] goals feature enabled');
} catch (error) {
supportsGoals = false;
logger.debug(`[Codex] failed to enable goals feature: ${errorMessage(error)}`);
}
try {
const response = await appServerClient.listCollaborationModes();
const hasPlanMode = responseContainsPlanCollaborationMode(response);
@@ -2270,6 +2325,13 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
session.sendSessionEvent({ type: 'message', message });
};
const sendGoalEvent = (event: Record<string, unknown>) => {
session.sendAgentMessage({
...addCodexEventScope(event, 'parent', this.currentThreadId),
id: randomUUID()
});
};
const resetCurrentTurnState = () => {
turnInFlight = false;
allowAnonymousTerminalEvent = false;
@@ -2327,6 +2389,188 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
}
};
const parseGoalCommand = (text: string): {
action: 'show' | 'set' | 'pause' | 'resume' | 'clear';
objective?: string;
error?: string;
} | null => {
const match = /^\s*\/goal(?:\s+([\s\S]*))?$/i.exec(text);
if (!match) return null;
const rest = match[1]?.trim() ?? '';
if (!rest) return { action: 'show' };
switch (rest.toLowerCase()) {
case 'clear':
return { action: 'clear' };
case 'pause':
return { action: 'pause' };
case 'resume':
return { action: 'resume' };
default:
if ([...rest].length > MAX_CODEX_GOAL_OBJECTIVE_CHARS) {
return { action: 'set', error: `Goal objective must be at most ${MAX_CODEX_GOAL_OBJECTIVE_CHARS} characters.` };
}
return { action: 'set', objective: rest };
}
};
const ensureThreadForGoal = 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) {
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;
return threadId;
} catch (error) {
logger.warn(`[Codex] Failed to resume app-server thread ${resumeCandidate} for /goal`, error);
sendVisibleStatus(`Goal failed: Codex conversation ${resumeCandidate} could not be resumed`);
return null;
}
}
if (!hasThread) {
const threadParams = buildThreadStartParams({
cwd: session.path,
mode,
mcpServers,
cliOverrides: session.codexCliOverrides
});
const threadResponse = await appServerClient.startThread(threadParams, {
signal: this.abortController.signal
});
const threadRecord = asRecord(threadResponse);
const thread = threadRecord ? asRecord(threadRecord.thread) : null;
const threadId = asString(thread?.id);
applyResolvedModel(threadRecord?.model);
if (!threadId) {
throw new Error('app-server thread/start did not return thread.id');
}
this.currentThreadId = threadId;
session.onSessionFound(threadId);
hasThread = true;
return threadId;
}
return null;
};
const normalizeGoal = (goal: ThreadGoal): ThreadGoal => ({
...goal,
threadId: asString((goal as unknown as Record<string, unknown>).threadId ?? (goal as unknown as Record<string, unknown>).thread_id) ?? goal.threadId,
tokenBudget: (goal as unknown as Record<string, unknown>).tokenBudget as number | null | undefined
?? (goal as unknown as Record<string, unknown>).token_budget as number | null | undefined
?? null,
tokensUsed: (goal as unknown as Record<string, unknown>).tokensUsed as number | undefined
?? (goal as unknown as Record<string, unknown>).tokens_used as number | undefined
?? 0,
timeUsedSeconds: (goal as unknown as Record<string, unknown>).timeUsedSeconds as number | undefined
?? (goal as unknown as Record<string, unknown>).time_used_seconds as number | undefined
?? 0,
createdAt: (goal as unknown as Record<string, unknown>).createdAt as number | undefined
?? (goal as unknown as Record<string, unknown>).created_at as number | undefined
?? 0,
updatedAt: (goal as unknown as Record<string, unknown>).updatedAt as number | undefined
?? (goal as unknown as Record<string, unknown>).updated_at as number | undefined
?? 0
});
const handleGoalCommand = async (message: QueuedMessage): Promise<boolean> => {
const command = parseGoalCommand(message.message);
if (!command) {
return false;
}
await interruptActiveTurn();
resetCurrentTurnState();
if (command.error) {
sendVisibleStatus(command.error);
return true;
}
if (!supportsGoals) {
sendVisibleStatus(CODEX_GOALS_UNSUPPORTED_MESSAGE);
return true;
}
const threadId = await ensureThreadForGoal(message.mode);
if (!threadId) {
return true;
}
try {
if (command.action === 'show') {
const response = await appServerClient.getThreadGoal({ threadId }, {
signal: this.abortController.signal
});
const goal = response.goal ? normalizeGoal(response.goal) : null;
if (!goal) {
sendVisibleStatus('Usage: /goal <objective>');
sendGoalEvent({ type: 'thread_goal_cleared', thread_id: threadId });
return true;
}
sendVisibleStatus(formatGoalUsage(goal));
sendGoalEvent({ type: 'thread_goal_updated', thread_id: threadId, goal });
return true;
}
if (command.action === 'clear') {
const response = await appServerClient.clearThreadGoal({ threadId }, {
signal: this.abortController.signal
});
if (response.cleared) {
sendVisibleStatus('Goal cleared');
} else {
sendVisibleStatus('No goal to clear');
}
return true;
}
const status: ThreadGoalStatus = command.action === 'pause' ? 'paused' : 'active';
const response = await appServerClient.setThreadGoal({
threadId,
...(command.action === 'set' ? { objective: command.objective } : {}),
status
}, {
signal: this.abortController.signal
});
const goal = normalizeGoal(response.goal);
sendVisibleStatus(formatGoalUsage(goal));
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
if (/goals feature is disabled|unsupported remote app-server request|method not found/i.test(detail)) {
supportsGoals = false;
sendVisibleStatus(CODEX_GOALS_UNSUPPORTED_MESSAGE);
} else {
sendVisibleStatus(`Goal failed: ${detail}`);
}
}
return true;
};
const handleSpecialCommand = async (message: QueuedMessage): Promise<boolean> => {
const specialCommand = parseCodexSpecialCommand(message.message);
if (!specialCommand.type) {
@@ -2413,6 +2657,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
activeMessage = message;
try {
if (await handleGoalCommand(message)) {
continue;
}
if (await handleSpecialCommand(message)) {
continue;
}
+21
View File
@@ -149,6 +149,27 @@ export async function runCodex(opts: {
model: currentModel,
modelReasoningEffort: currentModelReasoningEffort
});
if (slash.kind === 'goal') {
if (slash.message) {
session.sendAgentMessage({
type: 'message',
message: slash.message,
id: randomUUID()
});
}
const goalCommand = slash.action === 'set'
? `/goal ${slash.objective ?? ''}`
: slash.action === 'show'
? '/goal'
: `/goal ${slash.action}`;
messageQueue.pushIsolateAndClear(goalCommand, {
permissionMode: currentPermissionMode ?? 'default',
model: currentModel,
modelReasoningEffort: currentModelReasoningEffort,
collaborationMode: currentCollaborationMode
}, localId);
return;
}
if (slash.kind !== 'passthrough') {
applySlashUpdates(slash.updates);
if (slash.message) {
@@ -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',
@@ -116,6 +116,7 @@ describe('listSlashCommands', () => {
expect(commands.map((command) => command.name)).toEqual(expect.arrayContaining([
'clear',
'compact',
'goal',
'plan',
'status',
'model',
+1
View File
@@ -35,6 +35,7 @@ const BUILTIN_COMMANDS: Record<string, SlashCommand[]> = {
codex: [
{ name: 'clear', description: 'Clear current Codex thread context', source: 'builtin' },
{ name: 'compact', description: 'Compact current Codex thread context', source: 'builtin' },
{ name: 'goal', description: 'Set, view, pause, resume, or clear a persistent Codex goal', 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' },