From 089ddad476a924be50a3378e5e28ee3af0eda3ab Mon Sep 17 00:00:00 2001 From: weishu Date: Fri, 15 May 2026 22:15:17 +0800 Subject: [PATCH] feat: support Codex goal slash command --- cli/src/codex/appServerTypes.ts | 52 ++++ cli/src/codex/codexAppServerClient.ts | 52 +++- cli/src/codex/codexRemoteLauncher.test.ts | 134 ++++++++++ cli/src/codex/codexRemoteLauncher.ts | 250 +++++++++++++++++- cli/src/codex/runCodex.ts | 21 ++ .../utils/appServerEventConverter.test.ts | 27 ++ .../codex/utils/appServerEventConverter.ts | 28 ++ cli/src/codex/utils/slashCommands.test.ts | 31 +++ cli/src/codex/utils/slashCommands.ts | 43 +++ cli/src/modules/common/slashCommands.test.ts | 1 + cli/src/modules/common/slashCommands.ts | 1 + shared/src/schemas.ts | 16 ++ shared/src/types.ts | 2 + web/src/chat/normalizeAgent.ts | 53 ++++ web/src/chat/presentation.test.ts | 26 ++ web/src/chat/presentation.ts | 27 ++ web/src/chat/reducer.ts | 25 +- web/src/chat/types.ts | 3 + .../AssistantChat/HappyComposer.tsx | 5 +- .../components/AssistantChat/StatusBar.tsx | 12 + web/src/components/SessionChat.tsx | 1 + web/src/lib/codexSlashCommands.test.ts | 1 + web/src/lib/codexSlashCommands.ts | 1 + web/src/types/api.ts | 2 + 24 files changed, 808 insertions(+), 6 deletions(-) diff --git a/cli/src/codex/appServerTypes.ts b/cli/src/codex/appServerTypes.ts index fa7192cc..b498d76f 100644 --- a/cli/src/codex/appServerTypes.ts +++ b/cli/src/codex/appServerTypes.ts @@ -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; +} + +export interface ExperimentalFeatureEnablementSetResponse { + enablement: Record; + [key: string]: unknown; +} diff --git a/cli/src/codex/codexAppServerClient.ts b/cli/src/codex/codexAppServerClient.ts index e5c5844b..758b5605 100644 --- a/cli/src/codex/codexAppServerClient.ts +++ b/cli/src/codex/codexAppServerClient.ts @@ -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 { + const response = await this.sendRequest('experimentalFeature/enablement/set', params, { + timeoutMs: 30_000 + }); + return response as ExperimentalFeatureEnablementSetResponse; + } + async startThread(params: ThreadStartParams, options?: { signal?: AbortSignal }): Promise { 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 { + 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 { + 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 { + const response = await this.sendRequest('thread/goal/clear', params, { + signal: options?.signal, + timeoutMs: 30_000 + }); + return response as ThreadGoalClearResponse; + } + async disconnect(): Promise { if (!this.connected) { return; diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index 7cd74ab6..348f5095 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -7,6 +7,8 @@ const harness = vi.hoisted(() => ({ registerRequestCalls: [] as string[], requestHandlers: new Map Promise | 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 | 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 { + harness.setFeatureEnablementCalls.push(params); + if (harness.failSetFeatureEnablement) { + throw new Error('unsupported feature enablement'); + } + return params; + } + registerRequestHandler(method: string, handler: (params: unknown) => Promise | 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 }> { + 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 | 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); 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(); diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 99036911..0a421e8d 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -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) => { + 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 => { + 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).threadId ?? (goal as unknown as Record).thread_id) ?? goal.threadId, + tokenBudget: (goal as unknown as Record).tokenBudget as number | null | undefined + ?? (goal as unknown as Record).token_budget as number | null | undefined + ?? null, + tokensUsed: (goal as unknown as Record).tokensUsed as number | undefined + ?? (goal as unknown as Record).tokens_used as number | undefined + ?? 0, + timeUsedSeconds: (goal as unknown as Record).timeUsedSeconds as number | undefined + ?? (goal as unknown as Record).time_used_seconds as number | undefined + ?? 0, + createdAt: (goal as unknown as Record).createdAt as number | undefined + ?? (goal as unknown as Record).created_at as number | undefined + ?? 0, + updatedAt: (goal as unknown as Record).updatedAt as number | undefined + ?? (goal as unknown as Record).updated_at as number | undefined + ?? 0 + }); + + const handleGoalCommand = async (message: QueuedMessage): Promise => { + 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 '); + 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 => { 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; } diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index 301ed5f3..8775203f 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -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) { diff --git a/cli/src/codex/utils/appServerEventConverter.test.ts b/cli/src/codex/utils/appServerEventConverter.test.ts index 311ba5bd..c1064bba 100644 --- a/cli/src/codex/utils/appServerEventConverter.test.ts +++ b/cli/src/codex/utils/appServerEventConverter.test.ts @@ -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', { diff --git a/cli/src/codex/utils/appServerEventConverter.ts b/cli/src/codex/utils/appServerEventConverter.ts index a57192cd..da3a1a19 100644 --- a/cli/src/codex/utils/appServerEventConverter.ts +++ b/cli/src/codex/utils/appServerEventConverter.ts @@ -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); diff --git a/cli/src/codex/utils/slashCommands.test.ts b/cli/src/codex/utils/slashCommands.test.ts index 99dac269..4a5dbbfa 100644 --- a/cli/src/codex/utils/slashCommands.test.ts +++ b/cli/src/codex/utils/slashCommands.test.ts @@ -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, diff --git a/cli/src/codex/utils/slashCommands.ts b/cli/src/codex/utils/slashCommands.ts index 161cedb1..5bd19399 100644 --- a/cli/src/codex/utils/slashCommands.ts +++ b/cli/src/codex/utils/slashCommands.ts @@ -5,6 +5,7 @@ import type { EnhancedMode } from '../loop'; import type { SlashCommand } from '@/modules/common/slashCommands'; const REASONING_EFFORTS = new Set(['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', diff --git a/cli/src/modules/common/slashCommands.test.ts b/cli/src/modules/common/slashCommands.test.ts index 2186077c..b0a75745 100644 --- a/cli/src/modules/common/slashCommands.test.ts +++ b/cli/src/modules/common/slashCommands.test.ts @@ -116,6 +116,7 @@ describe('listSlashCommands', () => { expect(commands.map((command) => command.name)).toEqual(expect.arrayContaining([ 'clear', 'compact', + 'goal', 'plan', 'status', 'model', diff --git a/cli/src/modules/common/slashCommands.ts b/cli/src/modules/common/slashCommands.ts index 2cafb344..2bb02ce9 100644 --- a/cli/src/modules/common/slashCommands.ts +++ b/cli/src/modules/common/slashCommands.ts @@ -35,6 +35,7 @@ const BUILTIN_COMMANDS: Record = { 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' }, diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index a13eae0a..6618bb43 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -138,6 +138,22 @@ export const TeamStateSchema = z.object({ export type TeamState = z.infer +export const ThreadGoalStatusSchema = z.enum(['active', 'paused', 'budgetLimited', 'complete']) +export type ThreadGoalStatus = z.infer + +export const ThreadGoalSchema = z.object({ + threadId: z.string(), + objective: z.string(), + status: ThreadGoalStatusSchema, + tokenBudget: z.number().nullable().optional(), + tokensUsed: z.number().optional().default(0), + timeUsedSeconds: z.number().optional().default(0), + createdAt: z.number().optional().default(0), + updatedAt: z.number().optional().default(0) +}) + +export type ThreadGoal = z.infer + export const AttachmentMetadataSchema = z.object({ id: z.string(), filename: z.string(), diff --git a/shared/src/types.ts b/shared/src/types.ts index 69caa1c9..cad2b4f0 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -11,6 +11,8 @@ export type { TeamMessage, TeamState, TeamTask, + ThreadGoal, + ThreadGoalStatus, TodoItem, WorktreeMetadata } from './schemas' diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index 6289e986..f7fab5ba 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -32,6 +32,25 @@ function normalizeAgentEvent(value: unknown): AgentEvent | null { return value as AgentEvent } +function normalizeThreadGoal(value: unknown) { + if (!isObject(value)) return null + const threadId = asString(value.threadId ?? value.thread_id) + const objective = asString(value.objective) + const status = asString(value.status) + if (!threadId || !objective || !status) return null + if (status !== 'active' && status !== 'paused' && status !== 'budgetLimited' && status !== 'complete') return null + return { + threadId, + objective, + status, + tokenBudget: asNumber(value.tokenBudget ?? value.token_budget), + tokensUsed: asNumber(value.tokensUsed ?? value.tokens_used) ?? 0, + timeUsedSeconds: asNumber(value.timeUsedSeconds ?? value.time_used_seconds) ?? 0, + createdAt: asNumber(value.createdAt ?? value.created_at) ?? 0, + updatedAt: asNumber(value.updatedAt ?? value.updated_at) ?? 0 + } +} + function normalizeCodexTokenUsage(value: unknown, data?: Record) { const info = isObject(value) ? value : null if (!info) return null @@ -518,6 +537,40 @@ export function normalizeAgentRecord( } : null } + if (data.type === 'thread_goal_updated') { + const goal = normalizeThreadGoal(data.goal) + if (!goal) return null + return { + id: messageId, + localId, + createdAt, + role: 'event', + content: { + type: 'thread-goal-updated', + threadId: asString(data.threadId ?? data.thread_id) ?? goal.threadId, + turnId: asString(data.turnId ?? data.turn_id) ?? undefined, + goal + }, + isSidechain: false, + meta + } + } + + if (data.type === 'thread_goal_cleared') { + return { + id: messageId, + localId, + createdAt, + role: 'event', + content: { + type: 'thread-goal-cleared', + threadId: asString(data.threadId ?? data.thread_id) ?? undefined + }, + isSidechain: false, + meta + } + } + if (data.type === 'tool-call' && typeof data.callId === 'string') { const uuid = asString(data.id) ?? messageId return { diff --git a/web/src/chat/presentation.test.ts b/web/src/chat/presentation.test.ts index 10f608cb..ecf47134 100644 --- a/web/src/chat/presentation.test.ts +++ b/web/src/chat/presentation.test.ts @@ -96,6 +96,32 @@ describe('getEventPresentation — token-count', () => { }) }) +describe('getEventPresentation — thread goals', () => { + it('formats goal status updates', () => { + const result = getEventPresentation({ + type: 'thread-goal-updated', + goal: { + threadId: 'thread-1', + objective: 'ship goal support', + status: 'budgetLimited', + tokenBudget: 5000, + tokensUsed: 4100, + timeUsedSeconds: 0, + createdAt: 1, + updatedAt: 2 + } + }) + + expect(result.text).toBe('Goal limited by budget · 4k / 5k') + }) + + it('formats goal clear events', () => { + const result = getEventPresentation({ type: 'thread-goal-cleared', threadId: 'thread-1' }) + + expect(result.text).toBe('Goal cleared') + }) +}) + describe('formatResetTime', () => { it('formats a unix timestamp to a non-empty string', () => { const result = formatResetTime(1774278000) diff --git a/web/src/chat/presentation.ts b/web/src/chat/presentation.ts index 8058fa9f..6058c82d 100644 --- a/web/src/chat/presentation.ts +++ b/web/src/chat/presentation.ts @@ -58,6 +58,27 @@ function formatTokenCount(value: number): string { return String(value) } +function formatGoalStatus(status: string): string { + if (status === 'active') return 'active' + if (status === 'paused') return 'paused' + if (status === 'budgetLimited') return 'limited by budget' + if (status === 'complete') return 'complete' + return status +} + +function formatThreadGoalEvent(event: AgentEvent): EventPresentation { + const goal = asRecord((event as Record).goal) + if (!goal) return { icon: null, text: 'Goal updated' } + const status = typeof goal.status === 'string' ? goal.status : 'updated' + const tokensUsed = asNumber(goal.tokensUsed ?? goal.tokens_used) + const tokenBudget = asNumber(goal.tokenBudget ?? goal.token_budget) + const parts = [`Goal ${formatGoalStatus(status)}`] + if (tokensUsed !== null && tokenBudget !== null) { + parts.push(`${formatTokenCount(tokensUsed)} / ${formatTokenCount(tokenBudget)}`) + } + return { icon: null, text: parts.join(' · ') } +} + function formatTokenCountEvent(event: AgentEvent): EventPresentation { const info = asRecord((event as Record).info) const total = asRecord(info?.total) ?? info @@ -148,6 +169,12 @@ export function getEventPresentation(event: AgentEvent): EventPresentation { if (event.type === 'compact') { return { icon: '📦', text: 'Conversation compacted' } } + if (event.type === 'thread-goal-updated') { + return formatThreadGoalEvent(event) + } + if (event.type === 'thread-goal-cleared') { + return { icon: null, text: 'Goal cleared' } + } if (event.type === 'token-count') { return formatTokenCountEvent(event) } diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index ee727542..47f31279 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -1,5 +1,6 @@ import type { AgentState } from '@/types/api' -import type { ChatBlock, NormalizedMessage, UsageData } from '@/chat/types' +import type { AgentEvent, ChatBlock, NormalizedMessage, UsageData } from '@/chat/types' +import type { ThreadGoal } from '@/types/api' import { traceMessages, type TracedMessage } from '@/chat/tracer' import { dedupeAgentEvents, foldApiErrorEvents } from '@/chat/reducerEvents' import { collectTitleChanges, collectToolIdsFromMessages, ensureToolBlock, getPermissions } from '@/chat/reducerTools' @@ -27,10 +28,23 @@ export type LatestUsage = { timestamp: number } +function getLatestThreadGoal(normalized: NormalizedMessage[]): ThreadGoal | null { + for (let i = normalized.length - 1; i >= 0; i--) { + const msg = normalized[i] + if (msg.role !== 'event') continue + const event = msg.content as AgentEvent + if (event.type === 'thread-goal-cleared') return null + if (event.type === 'thread-goal-updated') { + return (event as { goal?: ThreadGoal }).goal ?? null + } + } + return null +} + export function reduceChatBlocks( normalized: NormalizedMessage[], agentState: AgentState | null | undefined -): { blocks: ChatBlock[]; hasReadyEvent: boolean; latestUsage: LatestUsage | null } { +): { blocks: ChatBlock[]; hasReadyEvent: boolean; latestUsage: LatestUsage | null; latestGoal: ThreadGoal | null } { const permissionsById = getPermissions(agentState) const toolIdsInMessages = collectToolIdsFromMessages(normalized) const titleChangesByToolUseId = collectTitleChanges(normalized) @@ -116,5 +130,10 @@ export function reduceChatBlocks( } } - return { blocks: dedupeAgentEvents(foldApiErrorEvents(rootResult.blocks)), hasReadyEvent, latestUsage } + return { + blocks: dedupeAgentEvents(foldApiErrorEvents(rootResult.blocks)), + hasReadyEvent, + latestUsage, + latestGoal: getLatestThreadGoal(normalized) + } } diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 3da7e599..35081cc2 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -1,4 +1,5 @@ import type { AttachmentMetadata, MessageStatus } from '@/types/api' +import type { ThreadGoal } from '@/types/api' export type UsageData = { input_tokens: number @@ -23,6 +24,8 @@ export type AgentEvent = | { type: 'turn-duration'; durationMs: number; targetMessageId?: string } | { type: 'microcompact'; trigger: string; preTokens: number; tokensSaved: number } | { type: 'compact'; trigger: string; preTokens: number } + | { type: 'thread-goal-updated'; goal: ThreadGoal; threadId?: string; turnId?: string } + | { type: 'thread-goal-cleared'; threadId?: string } | ({ type: string } & Record) export type ToolResultPermission = { diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 4ab5ff6c..e183c47e 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -12,7 +12,7 @@ import { useRef, useState } from 'react' -import type { AgentState, CodexCollaborationMode, PermissionMode } from '@/types/api' +import type { AgentState, CodexCollaborationMode, PermissionMode, ThreadGoal } from '@/types/api' import type { Suggestion } from '@/hooks/useActiveSuggestions' import type { ConversationStatus } from '@/realtime/types' import { useActiveWord } from '@/hooks/useActiveWord' @@ -46,6 +46,7 @@ export function HappyComposer(props: { disabled?: boolean permissionMode?: PermissionMode collaborationMode?: CodexCollaborationMode + threadGoal?: ThreadGoal | null model?: string | null modelReasoningEffort?: string | null effort?: string | null @@ -82,6 +83,7 @@ export function HappyComposer(props: { disabled = false, permissionMode: rawPermissionMode, collaborationMode: rawCollaborationMode, + threadGoal, model: rawModel, modelReasoningEffort: rawModelReasoningEffort, effort: rawEffort, @@ -775,6 +777,7 @@ export function HappyComposer(props: { modelReasoningEffort={modelReasoningEffort} permissionMode={permissionMode} collaborationMode={collaborationMode} + threadGoal={threadGoal} agentFlavor={agentFlavor} voiceStatus={voiceStatus} /> diff --git a/web/src/components/AssistantChat/StatusBar.tsx b/web/src/components/AssistantChat/StatusBar.tsx index 879a570c..c1e14945 100644 --- a/web/src/components/AssistantChat/StatusBar.tsx +++ b/web/src/components/AssistantChat/StatusBar.tsx @@ -8,6 +8,7 @@ import type { PermissionModeTone } from '@hapi/protocol' import { useMemo } from 'react' import type { AgentState, CodexCollaborationMode, PermissionMode } from '@/types/api' import type { ConversationStatus } from '@/realtime/types' +import type { ThreadGoal } from '@/types/api' import { getContextBudgetTokens } from '@/chat/modelConfig' import { useTranslation } from '@/lib/use-translation' @@ -150,6 +151,7 @@ export function StatusBar(props: { modelReasoningEffort?: string | null permissionMode?: PermissionMode collaborationMode?: CodexCollaborationMode + threadGoal?: ThreadGoal | null agentFlavor?: string | null voiceStatus?: ConversationStatus }) { @@ -202,6 +204,11 @@ export function StatusBar(props: { const codexFastMode = props.agentFlavor === 'codex' ? isCodexFastMode(props.model, props.modelReasoningEffort) : false + const goalLabel = props.agentFlavor === 'codex' && props.threadGoal + ? props.threadGoal.status === 'active' + ? 'goal' + : `goal ${props.threadGoal.status === 'budgetLimited' ? 'limited' : props.threadGoal.status}` + : null return (
@@ -237,6 +244,11 @@ export function StatusBar(props: { fast ) : null} + {goalLabel ? ( + + {goalLabel} + + ) : null} {collaborationModeLabel ? ( {collaborationModeLabel} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index b36ac351..5cd202ae 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -501,6 +501,7 @@ export function SessionChat(props: { disabled={props.isSending} permissionMode={props.session.permissionMode} collaborationMode={codexCollaborationModeSupported ? props.session.collaborationMode : undefined} + threadGoal={reduced.latestGoal} model={props.session.model} modelReasoningEffort={agentFlavor === 'codex' ? props.session.modelReasoningEffort : undefined} effort={props.session.effort} diff --git a/web/src/lib/codexSlashCommands.test.ts b/web/src/lib/codexSlashCommands.test.ts index ff2f7a49..3915dbb9 100644 --- a/web/src/lib/codexSlashCommands.test.ts +++ b/web/src/lib/codexSlashCommands.test.ts @@ -11,6 +11,7 @@ describe('getBuiltinSlashCommands', () => { expect(getBuiltinSlashCommands('codex').map((command) => command.name)).toEqual(expect.arrayContaining([ 'clear', 'compact', + 'goal', 'plan', 'status', 'execute', diff --git a/web/src/lib/codexSlashCommands.ts b/web/src/lib/codexSlashCommands.ts index cfb61b3a..98c7d59a 100644 --- a/web/src/lib/codexSlashCommands.ts +++ b/web/src/lib/codexSlashCommands.ts @@ -14,6 +14,7 @@ const BUILTIN_COMMANDS: Record = { 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' }, diff --git a/web/src/types/api.ts b/web/src/types/api.ts index dcc22eb1..9675648b 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -18,6 +18,8 @@ export type { TeamMessage, TeamState, TeamTask, + ThreadGoal, + ThreadGoalStatus, TodoItem, WorktreeMetadata } from '@hapi/protocol/types'