diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts index 2a6b359e..5a10ea5e 100644 --- a/src/AcpExtensions.ts +++ b/src/AcpExtensions.ts @@ -7,6 +7,7 @@ import type { } from "@agentclientprotocol/sdk"; export const LEGACY_SET_SESSION_MODEL_METHOD = "session/set_model"; +export const GOAL_CONTROL_METHOD = "_codex/session/goal_control"; export type LegacySessionModel = { modelId: string; @@ -42,11 +43,13 @@ export type ExtMethodRequest = AuthenticationStatusRequest | AuthenticationLogoutRequest | LegacySetSessionModelExtRequest + | GoalControlExtRequest export function isExtMethodRequest(request: { method: string, params: Record }): request is ExtMethodRequest { return request.method === "authentication/status" || request.method === "authentication/logout" - || request.method === LEGACY_SET_SESSION_MODEL_METHOD; + || request.method === LEGACY_SET_SESSION_MODEL_METHOD + || request.method === GOAL_CONTROL_METHOD; } export type AuthenticationStatusRequest = { method: "authentication/status", params: {} } @@ -60,6 +63,16 @@ export type LegacySetSessionModelExtRequest = { params: LegacySetSessionModelRequest; } +export type GoalControlRequest = { + sessionId: SessionId; + action: "pause" | "clear"; +} + +export type GoalControlExtRequest = { + method: typeof GOAL_CONTROL_METHOD; + params: GoalControlRequest; +} + export async function legacySetSessionModel( connection: Pick, params: LegacySetSessionModelRequest, diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 2ea875b5..b7664d36 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -33,6 +33,7 @@ import type { SkillsListResponse, SandboxPolicy, Thread, + ThreadGoal, ThreadGoalStatus, ThreadSourceKind, TurnCompletedNotification, @@ -40,6 +41,8 @@ import type { } from "./app-server/v2"; import packageJson from "../package.json"; import type {AuthenticationStatusResponse} from "./AcpExtensions"; +import {createCodexCollaborationMode} from "./CollaborationModeConfig"; +import type {ModeKind} from "./app-server/ModeKind"; /** * Well-known provider id for the client-configurable custom LLM gateway. @@ -84,7 +87,10 @@ export class CodexAcpClient { async initialize(request: acp.InitializeRequest): Promise { await this.codexClient.initialize({ - capabilities: null, + capabilities: { + experimentalApi: true, + requestAttestation: false, + }, clientInfo: { name: request.clientInfo?.name ?? this.defaultClientInfo.name, version: request.clientInfo?.version ?? this.defaultClientInfo.version, @@ -330,6 +336,7 @@ export class CodexAcpClient { sessionId: request.sessionId, currentModelId: currentModelId, models: codexModels, + collaborationMode: this.getCollaborationMode(response.thread.id), modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, additionalDirectories, @@ -357,6 +364,7 @@ export class CodexAcpClient { sessionId: request.sessionId, currentModelId: currentModelId, models: codexModels, + collaborationMode: this.getCollaborationMode(response.thread.id), modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, thread: historyResponse.thread, @@ -383,6 +391,7 @@ export class CodexAcpClient { sessionId: response.thread.id, currentModelId: currentModelId, models: codexModels, + collaborationMode: this.getCollaborationMode(response.thread.id), modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, additionalDirectories, @@ -417,6 +426,11 @@ export class CodexAcpClient { await this.codexClient.runCompact({threadId: sessionId}); } + async getGoal(sessionId: string): Promise { + const response = await this.codexClient.threadGoalGet({threadId: sessionId}); + return response?.goal ?? null; + } + async setGoal( sessionId: string, objective: string, @@ -429,11 +443,18 @@ export class CodexAcpClient { }, onTurnStarted); } - async setGoalStatus(sessionId: string, status: ThreadGoalStatus): Promise { + async setGoalStatus(sessionId: string, status: ThreadGoalStatus): Promise { + let updatedGoal: ThreadGoal | null = null; await this.codexClient.runGoalSet({ threadId: sessionId, status, + }, undefined, undefined, (goal) => { + updatedGoal = goal; }); + if (updatedGoal === null) { + throw new Error(`Goal update for session ${sessionId} returned no goal`); + } + return updatedGoal; } async resumeGoal( @@ -679,6 +700,17 @@ export class CodexAcpClient { }, onTurnStarted); } + async setCollaborationMode(sessionId: string, mode: ModeKind, currentModelId: string): Promise { + await this.codexClient.threadSettingsUpdate({ + threadId: sessionId, + collaborationMode: createCodexCollaborationMode(mode, currentModelId), + }); + } + + private getCollaborationMode(sessionId: string): ModeKind { + return this.codexClient.getThreadSettings(sessionId)?.collaborationMode.mode ?? "default"; + } + resolveTurnInterrupted(params: { threadId: string, turnId: string }): void { this.codexClient.resolveTurnInterrupted(params.threadId, params.turnId); } @@ -845,6 +877,7 @@ export type SessionMetadata = { sessionId: string, currentModelId: string, models: Model[], + collaborationMode: ModeKind, modelProvider?: string | null, currentServiceTier?: ServiceTier | null, additionalDirectories: string[], diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index adc4e079..eedaf6a3 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -13,13 +13,18 @@ import type { Model, ReasoningEffortOption, Thread, - ThreadGoalStatus, ThreadItem, UserInput } from "./app-server/v2"; import type {RateLimitsMap} from "./RateLimitsMap"; import {ModelId} from "./ModelId"; import {AgentMode, MODE_CONFIG_ID} from "./AgentMode"; +import { + COLLABORATION_MODE_CONFIG_ID, + createCollaborationModeConfigOption, + parseCollaborationMode, +} from "./CollaborationModeConfig"; +import type {ModeKind} from "./app-server/ModeKind"; import { createModelConfigOption, createReasoningEffortConfigOption, @@ -41,6 +46,7 @@ import { type LegacySessionModelState, type LegacySetSessionModelRequest, type LegacySetSessionModelResponse, + GOAL_CONTROL_METHOD, isExtMethodRequest, LEGACY_SET_SESSION_MODEL_METHOD, } from "./AcpExtensions"; @@ -74,12 +80,11 @@ import { createAgentTextThoughtChunk, createUserMessageChunk, } from "./ContentChunks"; - -export interface ThreadGoalSnapshot { - objective: string; - status: ThreadGoalStatus; - tokenBudget: number | null; -} +import { + sameThreadGoalSnapshot, + type ThreadGoalSnapshot, + toThreadGoalSnapshot, +} from "./ThreadGoalSnapshot"; export interface SessionState { sessionId: string, @@ -88,6 +93,7 @@ export interface SessionState { supportedReasoningEfforts: Array, supportedInputModalities: Array, agentMode: AgentMode, + collaborationMode: ModeKind, currentTurnId: string | null; lastTokenUsage: TokenCount | null; totalTokenUsage: TokenCount | null; @@ -103,6 +109,7 @@ export interface SessionState { sessionMcpServers?: Array; terminalOutputMode: TerminalOutputMode; currentGoal?: ThreadGoalSnapshot | null; + goalRevision: number; sessionTitle: string | null; sessionTitleSource: "unset" | "fallback" | "explicit" | "unknown"; } @@ -247,6 +254,25 @@ export class CodexAcpServer { } case LEGACY_SET_SESSION_MODEL_METHOD: return await this.unstable_setSessionModel(this.parseLegacySetSessionModelParams(methodRequest.params)); + case GOAL_CONTROL_METHOD: { + const sessionState = this.sessions.get(methodRequest.params.sessionId); + if (!sessionState) { + throw RequestError.invalidParams(undefined, `Unknown session: ${methodRequest.params.sessionId}`); + } + const sessionGeneration = this.getSessionGeneration(sessionState.sessionId); + if (methodRequest.params.action === "pause") { + const goal = await this.runWithProcessCheck(() => this.codexAcpClient.setGoalStatus(sessionState.sessionId, "paused")); + if (this.goalPublishIsCurrent(sessionState, sessionGeneration)) { + await this.publishGoalSnapshot(sessionState, toThreadGoalSnapshot(goal), false); + } + } else if (methodRequest.params.action === "clear") { + await this.runWithProcessCheck(() => this.codexAcpClient.clearGoal(sessionState.sessionId)); + if (this.goalPublishIsCurrent(sessionState, sessionGeneration)) { + await this.publishGoalSnapshot(sessionState, null, false); + } + } + return {}; + } } } @@ -405,6 +431,7 @@ export class CodexAcpServer { supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [], supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"], agentMode: AgentMode.getInitialAgentMode(), + collaborationMode: sessionMetadata.collaborationMode, currentTurnId: null, lastTokenUsage: null, totalTokenUsage: null, @@ -419,6 +446,7 @@ export class CodexAcpServer { currentModelSupportsFast: currentModelSupportsFast, sessionMcpServers: sessionMcpServers, terminalOutputMode: this.terminalOutputMode, + goalRevision: 0, sessionTitle: null, sessionTitleSource: "sessionId" in request ? "unknown" : "unset", }; @@ -434,6 +462,9 @@ export class CodexAcpServer { } this.publishAvailableCommandsAsync(sessionState); + if ("sessionId" in request) { + this.publishCurrentGoalAsync(sessionState, sessionGeneration); + } const sessionModelState: LegacySessionModelState = this.createModelState(models, currentModelId); const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); @@ -690,6 +721,14 @@ export class CodexAcpServer { const sessionState = this.sessions.get(params.sessionId); if (!sessionState) throw new Error(`Session ${params.sessionId} not found`); + await this.applySessionConfigOption(sessionState, params); + + return { + configOptions: this.createSessionConfigOptions(sessionState), + }; + } + + private async applySessionConfigOption(sessionState: SessionState, params: acp.SetSessionConfigOptionRequest): Promise { switch (params.configId) { case FAST_MODE_CONFIG_ID: this.applyFastModeChange(sessionState, params); @@ -697,6 +736,9 @@ export class CodexAcpServer { case MODE_CONFIG_ID: this.applyModeChange(sessionState, this.stringConfigValue(params)); break; + case COLLABORATION_MODE_CONFIG_ID: + await this.applyCollaborationModeChange(sessionState, this.stringConfigValue(params)); + break; case MODEL_CONFIG_ID: this.applyModelChange(sessionState, this.stringConfigValue(params)); break; @@ -706,10 +748,6 @@ export class CodexAcpServer { default: throw RequestError.invalidParams(); } - - return { - configOptions: this.createSessionConfigOptions(sessionState), - }; } private applyFastModeChange(sessionState: SessionState, params: acp.SetSessionConfigOptionRequest): void { @@ -739,6 +777,15 @@ export class CodexAcpServer { sessionState.agentMode = newMode; } + private async applyCollaborationModeChange(sessionState: SessionState, value: string): Promise { + const mode = parseCollaborationMode(value); + if (mode === null) { + throw RequestError.invalidParams(); + } + await this.codexAcpClient.setCollaborationMode(sessionState.sessionId, mode, sessionState.currentModelId); + sessionState.collaborationMode = mode; + } + private applyModelChange(sessionState: SessionState, value: string): void { const model = sessionState.availableModels.find(m => m.id === value); if (!model) { @@ -817,6 +864,7 @@ export class CodexAcpServer { const currentModelId = ModelId.fromString(sessionState.currentModelId); const configOptions = [ sessionState.agentMode.toConfigOption(), + createCollaborationModeConfigOption(sessionState.collaborationMode), createModelConfigOption(sessionState.availableModels, currentModelId.model), ]; if (sessionState.supportedReasoningEfforts.length > 0) { @@ -853,6 +901,67 @@ export class CodexAcpServer { void this.availableCommands.publish(sessionState); } + private publishCurrentGoalAsync(sessionState: SessionState, sessionGeneration: number): void { + void this.publishCurrentGoalBestEffort(sessionState, sessionGeneration, true); + } + + private async publishCurrentGoalBestEffort( + sessionState: SessionState, + sessionGeneration: number, + force: boolean, + ): Promise { + try { + await this.publishCurrentGoal(sessionState, sessionGeneration, force); + } catch (err) { + logger.error(`Failed to publish current goal for session ${sessionState.sessionId}`, err); + } + } + + private async publishCurrentGoal( + sessionState: SessionState, + sessionGeneration: number, + force: boolean, + ): Promise { + const requestRevision = ++sessionState.goalRevision; + const goal = await this.runWithProcessCheck(() => this.codexAcpClient.getGoal(sessionState.sessionId)); + const snapshot = goal === null ? null : toThreadGoalSnapshot(goal); + if (!this.goalPublishIsCurrent(sessionState, sessionGeneration) + || sessionState.goalRevision !== requestRevision) { + return; + } + await this.publishGoalSnapshot(sessionState, snapshot, force, false); + } + + private goalPublishIsCurrent(sessionState: SessionState, sessionGeneration: number): boolean { + return this.sessions.get(sessionState.sessionId) === sessionState + && this.getSessionGeneration(sessionState.sessionId) === sessionGeneration + && !this.sessionIsClosing(sessionState.sessionId); + } + + private async publishGoalSnapshot( + sessionState: SessionState, + snapshot: ThreadGoalSnapshot | null, + force: boolean, + incrementRevision = true, + ): Promise { + if (incrementRevision) { + sessionState.goalRevision += 1; + } + if (!force && sameThreadGoalSnapshot(sessionState.currentGoal, snapshot)) { + return; + } + sessionState.currentGoal = snapshot; + const session = new ACPSessionConnection(this.connection, sessionState.sessionId); + await session.update({ + sessionUpdate: "session_info_update", + _meta: { + codex: { + goal: snapshot, + }, + }, + }); + } + private findCurrentModel(models: Model[], currentModelId: string): Model | undefined { const modelId = ModelId.fromString(currentModelId); return models.find(m => m.id === modelId.model); @@ -936,6 +1045,7 @@ export class CodexAcpServer { supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [], supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"], agentMode: AgentMode.getInitialAgentMode(), + collaborationMode: sessionMetadata.collaborationMode, currentTurnId: null, lastTokenUsage: null, totalTokenUsage: null, @@ -950,6 +1060,7 @@ export class CodexAcpServer { currentModelSupportsFast: currentModelSupportsFast, sessionMcpServers: sessionMcpServers, terminalOutputMode: this.terminalOutputMode, + goalRevision: 0, sessionTitle: null, sessionTitleSource: "unset", }; @@ -965,6 +1076,7 @@ export class CodexAcpServer { } await this.availableCommands.publish(sessionState); + await this.publishCurrentGoalBestEffort(sessionState, requestedSessionGeneration, true); const sessionModelState: LegacySessionModelState = this.createModelState(models, currentModelId); const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); @@ -1549,6 +1661,18 @@ export class CodexAcpServer { sessionState.currentTurnId = turnId; pendingTurnStart?.resolve(turnId); }, + setConfigOption: async (configId, value) => { + await this.applySessionConfigOption(sessionState, { + sessionId: sessionState.sessionId, + configId, + value, + }); + const session = new ACPSessionConnection(this.connection, sessionState.sessionId); + await session.update({ + sessionUpdate: "config_option_update", + configOptions: this.createSessionConfigOptions(sessionState), + }); + }, }); void commandPromise.catch((err) => { if (this.activePrompts.get(params.sessionId) !== activePrompt) { diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 11d61a6e..aa88155d 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -37,6 +37,8 @@ import type { ThreadGoalClearedNotification, ThreadGoalClearParams, ThreadGoalClearResponse, + ThreadGoalGetParams, + ThreadGoalGetResponse, ThreadGoalSetParams, ThreadGoalSetResponse, ThreadLoadedListParams, @@ -47,6 +49,7 @@ import type { ThreadReadResponse, ThreadResumeParams, ThreadResumeResponse, + ThreadSettings, ThreadStartParams, ThreadStartResponse, ThreadUnsubscribeParams, @@ -139,6 +142,7 @@ export class CodexAppServerClient { private readonly threadStatusCaptures = new Map void>>(); private readonly threadGoalUpdateCaptures = new Map void>>(); private readonly threadGoalClearedCaptures = new Map void>>(); + private readonly threadSettings = new Map(); private readonly staleTurnIds = new Map>(); constructor(connection: MessageConnection) { @@ -169,6 +173,9 @@ export class CodexAppServerClient { if (isThreadGoalClearedNotification(serverNotification)) { this.recordThreadGoalCleared(serverNotification.params); } + if (serverNotification.method === "thread/settings/updated") { + this.threadSettings.set(serverNotification.params.threadId, serverNotification.params.threadSettings); + } const routing = extractTurnRouting(serverNotification); if (this.handleStaleTurnNotification(serverNotification, routing)) { return; @@ -310,6 +317,7 @@ export class CodexAppServerClient { params: ThreadGoalSetParams, onTurnStarted?: (turnId: string) => void, runtimeEffectsGraceMs = GOAL_RUNTIME_EFFECTS_GRACE_MS, + onGoalSet?: (goal: ThreadGoal) => void, ): Promise { let goalTurnId: string | null = null; const capturedCompletions: Array = []; @@ -361,6 +369,7 @@ export class CodexAppServerClient { try { const goalSetResponse = await this.threadGoalSet(params); expectedGoal = goalSetResponse.goal; + onGoalSet?.(expectedGoal); if (capturedGoalUpdates.some(event => goalsMatch(event.goal, expectedGoal!))) { goalUpdateHandled = true; resolveGoalUpdateHandled(); @@ -513,6 +522,14 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "thread/resume", params: params }); } + getThreadSettings(threadId: string): ThreadSettings | undefined { + return this.threadSettings.get(threadId); + } + + async threadSettingsUpdate(params: ExperimentalThreadSettingsUpdateParams): Promise { + await this.connection.sendRequest("thread/settings/update", params); + } + async threadList(params: ThreadListParams): Promise { return await this.sendRequest({ method: "thread/list", params: params }); } @@ -541,6 +558,10 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "thread/goal/set", params: params }); } + async threadGoalGet(params: ThreadGoalGetParams): Promise { + return await this.sendRequest({ method: "thread/goal/get", params: params }); + } + async threadGoalClear(params: ThreadGoalClearParams): Promise { return await this.sendRequest({ method: "thread/goal/clear", params: params }); } @@ -947,6 +968,18 @@ type DistributiveOmit = T extends any ? Omit : never; +export interface ExperimentalThreadSettingsUpdateParams { + threadId: string; + collaborationMode: { + mode: "default" | "plan"; + settings: { + model: string; + reasoning_effort: string | null; + developer_instructions: string | null; + }; + }; +} + type McpServerStartupSnapshot = { status: McpServerStartupState; error: string | null; diff --git a/src/CodexCommands.ts b/src/CodexCommands.ts index 19df71e6..e6a837ed 100644 --- a/src/CodexCommands.ts +++ b/src/CodexCommands.ts @@ -8,6 +8,11 @@ import type {RateLimitsMap} from "./RateLimitsMap"; import type {TokenCount} from "./TokenCount"; import {logger} from "./Logger"; import {createAgentTextMessageChunk} from "./ContentChunks"; +import { + COLLABORATION_MODE_CONFIG_ID, + DEFAULT_COLLABORATION_MODE, + PLAN_COLLABORATION_MODE, +} from "./CollaborationModeConfig"; type ParsedSlashCommand = { name: string; @@ -21,6 +26,7 @@ export type CommandHandleResult = export type CommandHandleOptions = { onTurnStartPending?: () => void; onTurnStarted?: (turnId: string, threadId: string) => void; + setConfigOption?: (configId: string, value: string) => Promise; }; export type LogoutHandler = () => void | Promise; @@ -94,6 +100,20 @@ export class CodexCommands { */ private getBuiltinCommands(): AvailableCommand[] { return [ + { + name: "plan", + description: "Turn plan mode on.", + input: null, + _meta: { + commandAction: { + kind: "setConfigOption", + configId: COLLABORATION_MODE_CONFIG_ID, + value: PLAN_COLLABORATION_MODE, + resetValue: DEFAULT_COLLABORATION_MODE, + presentation: "state", + }, + }, + }, { name: "mcp", description: "List configured Model Context Protocol (MCP) tools.", @@ -131,8 +151,14 @@ export class CodexCommands { }, { name: "goal", - description: "Set, pause, resume, or clear a task goal.", - input: { hint: "[|clear|pause|resume]" } + description: "Set a goal to keep pursuing.", + input: { hint: "[|clear|pause|resume]" }, + _meta: { + commandAction: { + kind: "prefixPrompt", + presentation: "state", + }, + }, }, { name: "logout", @@ -173,6 +199,17 @@ export class CodexCommands { const sessionId = sessionState.sessionId; switch (commandName) { + case "plan": { + if (command.rest.length > 0) { + await this.sendCommandUsageMessage(commandName, "no arguments", sessionId); + return { handled: true }; + } + const mode = sessionState.collaborationMode === PLAN_COLLABORATION_MODE + ? DEFAULT_COLLABORATION_MODE + : PLAN_COLLABORATION_MODE; + await options.setConfigOption?.(COLLABORATION_MODE_CONFIG_ID, mode); + return { handled: options.setConfigOption !== undefined }; + } case "compact": { await this.runWithProcessCheck(() => this.codexAcpClient.runCompact(sessionId)); return { handled: true }; diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index ea1de4a8..22e8248b 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -3,7 +3,7 @@ import type { FuzzyFileSearchSessionUpdatedNotification, ServerNotification } from "./app-server"; -import type {SessionState, ThreadGoalSnapshot} from "./CodexAcpServer"; +import type {SessionState} from "./CodexAcpServer"; import {type PlanEntry, RequestError} from "@agentclientprotocol/sdk"; import {ACPSessionConnection, type AcpClientConnection, type UpdateSessionEvent} from "./ACPSessionConnection"; import type { @@ -62,6 +62,7 @@ import { createAgentTextMessageChunk, createAgentTextThoughtChunk, } from "./ContentChunks"; +import {sameThreadGoalSnapshot, toThreadGoalSnapshot} from "./ThreadGoalSnapshot"; export { stripShellPrefix }; @@ -256,8 +257,9 @@ export class CodexEventHandler { } private createThreadGoalUpdatedEvent(event: ThreadGoalUpdatedNotification): UpdateSessionEvent | null { - const goalSnapshot = this.createThreadGoalSnapshot(event); - if (this.sameThreadGoalSnapshot(this.sessionState.currentGoal, goalSnapshot)) { + this.sessionState.goalRevision += 1; + const goalSnapshot = toThreadGoalSnapshot(event.goal); + if (sameThreadGoalSnapshot(this.sessionState.currentGoal, goalSnapshot)) { return null; } this.sessionState.currentGoal = goalSnapshot; @@ -268,6 +270,7 @@ export class CodexEventHandler { } private createThreadGoalClearedEvent(_event: ThreadGoalClearedNotification): UpdateSessionEvent | null { + this.sessionState.goalRevision += 1; if (this.sessionState.currentGoal === null) { return null; } @@ -278,25 +281,6 @@ export class CodexEventHandler { }); } - private createThreadGoalSnapshot(event: ThreadGoalUpdatedNotification): ThreadGoalSnapshot { - return { - objective: event.goal.objective.trim(), - status: event.goal.status, - tokenBudget: event.goal.tokenBudget, - }; - } - - private sameThreadGoalSnapshot( - left: ThreadGoalSnapshot | null | undefined, - right: ThreadGoalSnapshot - ): boolean { - return left !== null - && left !== undefined - && left.objective === right.objective - && left.status === right.status - && left.tokenBudget === right.tokenBudget; - } - private createReasoningDeltaEvent( event: ReasoningSummaryTextDeltaNotification | ReasoningTextDeltaNotification ): UpdateSessionEvent { diff --git a/src/CollaborationModeConfig.ts b/src/CollaborationModeConfig.ts new file mode 100644 index 00000000..b1e651e8 --- /dev/null +++ b/src/CollaborationModeConfig.ts @@ -0,0 +1,41 @@ +import type * as acp from "@agentclientprotocol/sdk"; +import type {ReasoningEffort} from "./app-server"; +import type {ModeKind} from "./app-server/ModeKind"; +import {ModelId} from "./ModelId"; + +export const COLLABORATION_MODE_CONFIG_ID = "collaboration_mode"; +export const DEFAULT_COLLABORATION_MODE: ModeKind = "default"; +export const PLAN_COLLABORATION_MODE: ModeKind = "plan"; + +export function createCollaborationModeConfigOption(currentValue: ModeKind): acp.SessionConfigOption { + return { + id: COLLABORATION_MODE_CONFIG_ID, + name: "Collaboration mode", + description: "How Codex collaborates for subsequent turns", + category: "collaboration_mode", + type: "select", + currentValue, + options: [ + {value: DEFAULT_COLLABORATION_MODE, name: "Default"}, + {value: PLAN_COLLABORATION_MODE, name: "Plan", description: "Plan before making changes"}, + ], + }; +} + +export function parseCollaborationMode(value: unknown): ModeKind | null { + if (value === DEFAULT_COLLABORATION_MODE) return DEFAULT_COLLABORATION_MODE; + if (value === PLAN_COLLABORATION_MODE) return PLAN_COLLABORATION_MODE; + return null; +} + +export function createCodexCollaborationMode(mode: ModeKind, currentModelId: string) { + const modelId = ModelId.fromString(currentModelId); + return { + mode, + settings: { + model: modelId.model, + reasoning_effort: modelId.effort as ReasoningEffort | null, + developer_instructions: null, + }, + }; +} diff --git a/src/ThreadGoalSnapshot.ts b/src/ThreadGoalSnapshot.ts new file mode 100644 index 00000000..bb950af8 --- /dev/null +++ b/src/ThreadGoalSnapshot.ts @@ -0,0 +1,34 @@ +import {GOAL_CONTROL_METHOD} from "./AcpExtensions"; +import type {ThreadGoal} from "./app-server/v2"; + +export interface ThreadGoalSnapshot { + objective: string; + status: ThreadGoal["status"]; + tokenBudget: number | null; + timeUsedSeconds: number; + createdAt: number; + controlMethod: typeof GOAL_CONTROL_METHOD; +} + +export function toThreadGoalSnapshot(goal: ThreadGoal): ThreadGoalSnapshot { + return { + objective: goal.objective.trim(), + status: goal.status, + tokenBudget: goal.tokenBudget, + timeUsedSeconds: goal.timeUsedSeconds, + createdAt: goal.createdAt, + controlMethod: GOAL_CONTROL_METHOD, + }; +} + +export function sameThreadGoalSnapshot( + left: ThreadGoalSnapshot | null | undefined, + right: ThreadGoalSnapshot | null, +): boolean { + if (left === undefined) return false; + if (left === null || right === null) return left === right; + return left.objective === right.objective + && left.status === right.status + && left.tokenBudget === right.tokenBudget + && left.createdAt === right.createdAt; +} diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 255d4ab2..4399afc8 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -16,6 +16,7 @@ import {AgentMode} from "../../AgentMode"; import type {Model, ReviewStartResponse, ThreadGoal, TurnCompletedNotification, TurnStartParams} from "../../app-server/v2"; import type {RateLimitsMap} from "../../RateLimitsMap"; import {ModelId} from "../../ModelId"; +import {GOAL_CONTROL_METHOD} from "../../AcpExtensions"; describe('ACP server test', { timeout: 40_000 }, () => { @@ -453,6 +454,61 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); }); + it('restores collaboration mode for resumed and loaded sessions', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAcpAgent = mockFixture.getCodexAcpAgent(); + const codexAcpClient = mockFixture.getCodexAcpClient(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + + vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); + vi.spyOn(codexAppServerClient, "skillsExtraRootsSet").mockResolvedValue(undefined); + vi.spyOn(codexAppServerClient, "listSkills").mockResolvedValue({data: []}); + vi.spyOn(codexAppServerClient, "threadResume").mockImplementation(async ({threadId}) => { + mockFixture.sendServerNotification({ + method: "thread/settings/updated", + params: { + threadId, + threadSettings: { + collaborationMode: { + mode: "plan", + settings: {}, + }, + }, + }, + }); + return { + thread: {id: threadId}, + model: "gpt-5", + modelProvider: "openai", + reasoningEffort: "medium", + serviceTier: null, + } as any; + }); + vi.spyOn(codexAppServerClient, "threadRead").mockImplementation(async ({threadId}) => ({ + thread: {id: threadId, turns: []}, + } as any)); + vi.spyOn(codexAppServerClient, "listModels").mockResolvedValue({ + data: [createTestModel({id: "gpt-5"})], + nextCursor: null, + }); + + const resumed = await codexAcpAgent.resumeSession({ + sessionId: "resume-id", + cwd: "/workspace", + }); + const loaded = await codexAcpAgent.loadSession({ + sessionId: "load-id", + cwd: "/workspace", + mcpServers: [], + }); + + expect(codexAcpAgent.getSessionState("resume-id").collaborationMode).toBe("plan"); + expect(codexAcpAgent.getSessionState("load-id").collaborationMode).toBe("plan"); + expect(resumed.configOptions?.find(option => option.id === "collaboration_mode")).toMatchObject({currentValue: "plan"}); + expect(loaded.configOptions?.find(option => option.id === "collaboration_mode")).toMatchObject({currentValue: "plan"}); + }); + it('uses configured model provider when resuming sessions without an explicit provider', async () => { const mockFixture = createCodexMockTestFixture(); const codexAcpClient = mockFixture.getCodexAcpClient(); @@ -1496,9 +1552,12 @@ describe('ACP server test', { timeout: 40_000 }, () => { it('handles goal slash commands through Codex app server', async () => { const { mockFixture, turnStartSpy } = setupPromptFixture(); const goalRunSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "runGoalSet") - .mockResolvedValue({ - threadId: "session-id", - turn: createTurn("goal-turn-id", "completed"), + .mockImplementation(async (_params, _onTurnStarted, _runtimeEffectsGraceMs, onGoalSet) => { + onGoalSet?.(createThreadGoal()); + return { + threadId: "session-id", + turn: createTurn("goal-turn-id", "completed"), + }; }); const goalClearSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "runGoalClear") .mockResolvedValue(undefined); @@ -1528,7 +1587,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(goalRunSpy).toHaveBeenNthCalledWith(2, { threadId: "session-id", status: "paused", - }); + }, undefined, undefined, expect.any(Function)); expect(goalRunSpy).toHaveBeenNthCalledWith(3, { threadId: "session-id", status: "active", @@ -1667,9 +1726,12 @@ describe('ACP server test', { timeout: 40_000 }, () => { _meta: { codex: { goal: { - objective: "Ship the migration and keep tests green", - status: "active", - tokenBudget: null, + objective: "Ship the migration and keep tests green", + status: "active", + tokenBudget: null, + timeUsedSeconds: 0, + createdAt: 1710000000, + controlMethod: "_codex/session/goal_control", }, }, }, @@ -2388,6 +2450,86 @@ describe('ACP server test', { timeout: 40_000 }, () => { await expect(promptPromise).resolves.toMatchObject({stopReason: "cancelled"}); }); + it('controls an active goal through the out-of-band session extension', async () => { + const { mockFixture, sessionState } = setupPromptFixture(); + // @ts-expect-error - registering local session state for the extension request path + mockFixture.getCodexAcpAgent().sessions.set("session-id", sessionState); + const pausedGoal = createThreadGoal({status: "paused", timeUsedSeconds: 12}); + const setStatusSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "setGoalStatus").mockResolvedValue(pausedGoal); + const clearGoalSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "clearGoal").mockResolvedValue(undefined); + const getGoalSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "getGoal"); + mockFixture.clearAcpConnectionDump(); + + await expect(mockFixture.getCodexAcpAgent().extMethod(GOAL_CONTROL_METHOD, { + sessionId: "session-id", + action: "pause", + })).resolves.toEqual({}); + await expect(mockFixture.getCodexAcpAgent().extMethod(GOAL_CONTROL_METHOD, { + sessionId: "session-id", + action: "clear", + })).resolves.toEqual({}); + + expect(setStatusSpy).toHaveBeenCalledWith("session-id", "paused"); + expect(clearGoalSpy).toHaveBeenCalledWith("session-id"); + expect(getGoalSpy).not.toHaveBeenCalled(); + const goalUpdates = mockFixture.getAcpConnectionEvents([]).filter(event => + event.method === "sessionUpdate" + && "args" in event + && event.args[0]?.update?.sessionUpdate === "session_info_update" + ); + expect(goalUpdates).toEqual(expect.arrayContaining([ + expect.objectContaining({ + args: [expect.objectContaining({ + update: expect.objectContaining({ + _meta: {codex: {goal: expect.objectContaining({status: "paused"})}}, + }), + })], + }), + expect.objectContaining({ + args: [expect.objectContaining({ + update: expect.objectContaining({ + _meta: {codex: {goal: null}}, + }), + })], + }), + ])); + }); + + it('ignores an older goal refresh that completes after a newer refresh', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAcpAgent = mockFixture.getCodexAcpAgent(); + const sessionState = createTestSessionState({sessionId: "session-id"}); + // @ts-expect-error - registering local session state for the refresh race + codexAcpAgent.sessions.set("session-id", sessionState); + const staleGoal = createThreadGoal({objective: "stale", createdAt: 100}); + const currentGoal = createThreadGoal({objective: "current", createdAt: 200}); + const staleResponse = deferred(); + const currentResponse = deferred(); + const getGoal = vi.spyOn(mockFixture.getCodexAcpClient(), "getGoal") + .mockReturnValueOnce(staleResponse.promise) + .mockReturnValueOnce(currentResponse.promise); + + // @ts-expect-error - exercising the private refresh interleaving directly + const stalePublish = codexAcpAgent.publishCurrentGoal(sessionState, 0, true); + await vi.waitFor(() => expect(getGoal).toHaveBeenCalledTimes(1)); + // @ts-expect-error - exercising the private refresh interleaving directly + const currentPublish = codexAcpAgent.publishCurrentGoal(sessionState, 0, true); + currentResponse.resolve(currentGoal); + await currentPublish; + staleResponse.resolve(staleGoal); + await stalePublish; + + expect(sessionState.currentGoal).toMatchObject({objective: "current", createdAt: 200}); + const goalUpdates = mockFixture.getAcpConnectionEvents([]).filter(event => + event.method === "sessionUpdate" + && event.args[0]?.update?.sessionUpdate === "session_info_update" + ); + expect(goalUpdates).toHaveLength(1); + expect(goalUpdates[0]?.args[0]?.update?._meta).toEqual({ + codex: {goal: expect.objectContaining({objective: "current", createdAt: 200})}, + }); + }); + it('suppresses the first routed goal notification after cancellation marks the turn stale', async () => { const { mockFixture } = setupPromptFixture(); const codexAppServerClient = mockFixture.getCodexAppServerClient(); @@ -2678,12 +2820,14 @@ describe('ACP server test', { timeout: 40_000 }, () => { sessionId: "session-1", currentModelId, models: [model], + collaborationMode: "default", additionalDirectories: [], }) .mockResolvedValueOnce({ sessionId: "session-2", currentModelId, models: [model], + collaborationMode: "default", additionalDirectories: [], }); const logoutSpy = vi.spyOn(codexAcpClient, "logout").mockResolvedValue(); @@ -2742,6 +2886,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { sessionId: "openai-session", currentModelId, models: [model], + collaborationMode: "default", modelProvider: "openai", additionalDirectories: [], }); @@ -2794,6 +2939,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { sessionId: "custom-provider-session", currentModelId, models: [model], + collaborationMode: "default", additionalDirectories: [], }); const logoutSpy = vi.spyOn(codexAcpClient, "logout").mockResolvedValue(); diff --git a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json index 26e78cbb..d734fe73 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json @@ -6,6 +6,20 @@ "update": { "sessionUpdate": "available_commands_update", "availableCommands": [ + { + "name": "plan", + "description": "Turn plan mode on.", + "input": null, + "_meta": { + "commandAction": { + "kind": "setConfigOption", + "configId": "collaboration_mode", + "value": "plan", + "resetValue": "default", + "presentation": "state" + } + } + }, { "name": "mcp", "description": "List configured Model Context Protocol (MCP) tools.", @@ -49,9 +63,15 @@ }, { "name": "goal", - "description": "Set, pause, resume, or clear a task goal.", + "description": "Set a goal to keep pursuing.", "input": { "hint": "[|clear|pause|resume]" + }, + "_meta": { + "commandAction": { + "kind": "prefixPrompt", + "presentation": "state" + } } }, { diff --git a/src/__tests__/CodexACPAgent/data/available-commands-skills.json b/src/__tests__/CodexACPAgent/data/available-commands-skills.json index 59987072..d6c15414 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-skills.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-skills.json @@ -6,6 +6,20 @@ "update": { "sessionUpdate": "available_commands_update", "availableCommands": [ + { + "name": "plan", + "description": "Turn plan mode on.", + "input": null, + "_meta": { + "commandAction": { + "kind": "setConfigOption", + "configId": "collaboration_mode", + "value": "plan", + "resetValue": "default", + "presentation": "state" + } + } + }, { "name": "mcp", "description": "List configured Model Context Protocol (MCP) tools.", @@ -49,9 +63,15 @@ }, { "name": "goal", - "description": "Set, pause, resume, or clear a task goal.", + "description": "Set a goal to keep pursuing.", "input": { "hint": "[|clear|pause|resume]" + }, + "_meta": { + "commandAction": { + "kind": "prefixPrompt", + "presentation": "state" + } } }, { diff --git a/src/__tests__/CodexACPAgent/data/load-session-history.json b/src/__tests__/CodexACPAgent/data/load-session-history.json index bc5b3f96..106786b4 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-history.json +++ b/src/__tests__/CodexACPAgent/data/load-session-history.json @@ -6,6 +6,20 @@ "update": { "sessionUpdate": "available_commands_update", "availableCommands": [ + { + "name": "plan", + "description": "Turn plan mode on.", + "input": null, + "_meta": { + "commandAction": { + "kind": "setConfigOption", + "configId": "collaboration_mode", + "value": "plan", + "resetValue": "default", + "presentation": "state" + } + } + }, { "name": "mcp", "description": "List configured Model Context Protocol (MCP) tools.", @@ -49,9 +63,15 @@ }, { "name": "goal", - "description": "Set, pause, resume, or clear a task goal.", + "description": "Set a goal to keep pursuing.", "input": { "hint": "[|clear|pause|resume]" + }, + "_meta": { + "commandAction": { + "kind": "prefixPrompt", + "presentation": "state" + } } }, { @@ -64,6 +84,29 @@ } ] } +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "session-1", + "update": { + "sessionUpdate": "session_info_update", + "_meta": { + "codex": { + "goal": { + "objective": "Keep the restored migration green", + "status": "paused", + "tokenBudget": null, + "timeUsedSeconds": 46, + "createdAt": 1710000000, + "controlMethod": "_codex/session/goal_control" + } + } + } + } + } + ] +} { "method": "sessionUpdate", "args": [ diff --git a/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json b/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json index e960c459..b314d869 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json +++ b/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json @@ -6,6 +6,20 @@ "update": { "sessionUpdate": "available_commands_update", "availableCommands": [ + { + "name": "plan", + "description": "Turn plan mode on.", + "input": null, + "_meta": { + "commandAction": { + "kind": "setConfigOption", + "configId": "collaboration_mode", + "value": "plan", + "resetValue": "default", + "presentation": "state" + } + } + }, { "name": "mcp", "description": "List configured Model Context Protocol (MCP) tools.", @@ -49,9 +63,15 @@ }, { "name": "goal", - "description": "Set, pause, resume, or clear a task goal.", + "description": "Set a goal to keep pursuing.", "input": { "hint": "[|clear|pause|resume]" + }, + "_meta": { + "commandAction": { + "kind": "prefixPrompt", + "presentation": "state" + } } }, { @@ -64,6 +84,22 @@ } ] } +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "session-legacy", + "update": { + "sessionUpdate": "session_info_update", + "_meta": { + "codex": { + "goal": null + } + } + } + } + ] +} { "method": "sessionUpdate", "args": [ diff --git a/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json b/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json index c524584c..6008d56e 100644 --- a/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json +++ b/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json @@ -10,7 +10,10 @@ "goal": { "objective": "First task\nSecond task", "status": "budgetLimited", - "tokenBudget": 1000 + "tokenBudget": 1000, + "timeUsedSeconds": 30, + "createdAt": 1710000000, + "controlMethod": "_codex/session/goal_control" } } } diff --git a/src/__tests__/CodexACPAgent/data/thread-goal-updated.json b/src/__tests__/CodexACPAgent/data/thread-goal-updated.json index bc14e9cb..ed17b6da 100644 --- a/src/__tests__/CodexACPAgent/data/thread-goal-updated.json +++ b/src/__tests__/CodexACPAgent/data/thread-goal-updated.json @@ -10,7 +10,10 @@ "goal": { "objective": "Ship the goal update", "status": "active", - "tokenBudget": null + "tokenBudget": null, + "timeUsedSeconds": 12, + "createdAt": 1710000000, + "controlMethod": "_codex/session/goal_control" } } } diff --git a/src/__tests__/CodexACPAgent/fast-mode-config.test.ts b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts index 07d42444..6007d529 100644 --- a/src/__tests__/CodexACPAgent/fast-mode-config.test.ts +++ b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts @@ -43,6 +43,7 @@ describe("Fast mode session config", () => { sessionId: "session-id", currentModelId: "fast-model[medium]", models: [fastModel, slowModel], + collaborationMode: "default", currentServiceTier, additionalDirectories: [], }); diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index 79b46f59..3180835b 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -85,6 +85,22 @@ describe('CodexACPAgent - initialize', () => { ])); }); + it('enables experimental thread settings without requesting attestation', async () => { + await agent.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: { + elicitation: { form: {}, url: {} }, + }, + }); + + expect(mockCodexConnection.sendRequest).toHaveBeenCalledWith("initialize", expect.objectContaining({ + capabilities: { + experimentalApi: true, + requestAttestation: false, + }, + })); + }); + it('should advertise API key auth with the legacy metadata method', () => { expect(getCodexAuthMethods()).toEqual(expect.arrayContaining([ expect.objectContaining({ diff --git a/src/__tests__/CodexACPAgent/list-sessions.test.ts b/src/__tests__/CodexACPAgent/list-sessions.test.ts index 36529576..845ee0fd 100644 --- a/src/__tests__/CodexACPAgent/list-sessions.test.ts +++ b/src/__tests__/CodexACPAgent/list-sessions.test.ts @@ -168,6 +168,7 @@ describe("CodexACPAgent - list sessions", () => { defaultServiceTier: null, isDefault: true, }], + collaborationMode: "default", currentServiceTier: null, additionalDirectories: ["/repo/extra"], }); diff --git a/src/__tests__/CodexACPAgent/load-session.test.ts b/src/__tests__/CodexACPAgent/load-session.test.ts index f8aea520..1b2b81e0 100644 --- a/src/__tests__/CodexACPAgent/load-session.test.ts +++ b/src/__tests__/CodexACPAgent/load-session.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createCodexMockTestFixture, createTestModel } from "../acp-test-utils"; -import type { Model, Thread } from "../../app-server/v2"; +import type { Model, Thread, ThreadGoal } from "../../app-server/v2"; describe("CodexACPAgent - loadSession", () => { it("should replay history during loadSession", async () => { @@ -190,6 +190,17 @@ describe("CodexACPAgent - loadSession", () => { codexAppServerClient.threadRead = vi.fn().mockResolvedValue({ thread: thread, }); + const goal: ThreadGoal = { + threadId: thread.id, + objective: "Keep the restored migration green", + status: "paused", + tokenBudget: null, + tokensUsed: 42, + timeUsedSeconds: 46, + createdAt: 1710000000, + updatedAt: 1710000046, + }; + codexAppServerClient.threadGoalGet = vi.fn().mockResolvedValue({ goal }); await codexAcpAgent.initialize({ protocolVersion: 1 }); @@ -204,6 +215,7 @@ describe("CodexACPAgent - loadSession", () => { threadId: thread.id, includeTurns: true, }); + expect(codexAppServerClient.threadGoalGet).toHaveBeenCalledWith({ threadId: thread.id }); await expect(fixture.getAcpConnectionDump([])).toMatchFileSnapshot( "data/load-session-history.json" ); diff --git a/src/__tests__/CodexACPAgent/model-filtering.test.ts b/src/__tests__/CodexACPAgent/model-filtering.test.ts index 280410a3..f5344242 100644 --- a/src/__tests__/CodexACPAgent/model-filtering.test.ts +++ b/src/__tests__/CodexACPAgent/model-filtering.test.ts @@ -128,6 +128,7 @@ describe("Model filtering", () => { sessionId: "session-id", currentModelId: "gpt-5.2[medium]", models, + collaborationMode: "default", additionalDirectories: [], }); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); diff --git a/src/__tests__/CodexACPAgent/new-session-logout.test.ts b/src/__tests__/CodexACPAgent/new-session-logout.test.ts index c8603f13..f2f331eb 100644 --- a/src/__tests__/CodexACPAgent/new-session-logout.test.ts +++ b/src/__tests__/CodexACPAgent/new-session-logout.test.ts @@ -64,6 +64,7 @@ describe("New session logout handling", () => { sessionId: "openai-session", currentModelId, models: [model], + collaborationMode: "default", modelProvider: "openai", additionalDirectories: [], }) @@ -71,6 +72,7 @@ describe("New session logout handling", () => { sessionId: "custom-provider-session", currentModelId, models: [model], + collaborationMode: "default", modelProvider: "custom-provider", additionalDirectories: [], }) diff --git a/src/__tests__/CodexACPAgent/session-close.test.ts b/src/__tests__/CodexACPAgent/session-close.test.ts index 032abe3e..5effd4b0 100644 --- a/src/__tests__/CodexACPAgent/session-close.test.ts +++ b/src/__tests__/CodexACPAgent/session-close.test.ts @@ -457,6 +457,7 @@ async function createSession(options: { sessionId, currentModelId: "model-id[medium]", models: [model], + collaborationMode: "default", currentServiceTier: null, additionalDirectories: [], }); @@ -503,6 +504,7 @@ function createSessionMetadata(): SessionMetadata { sessionId, currentModelId: "model-id[medium]", models: [createTestModel()], + collaborationMode: "default", currentServiceTier: null, additionalDirectories: [], }; diff --git a/src/__tests__/CodexACPAgent/session-config-options.test.ts b/src/__tests__/CodexACPAgent/session-config-options.test.ts index 35913f4a..ab669916 100644 --- a/src/__tests__/CodexACPAgent/session-config-options.test.ts +++ b/src/__tests__/CodexACPAgent/session-config-options.test.ts @@ -7,6 +7,10 @@ import { } from "../../ModelConfigOption"; import type {Model, ReasoningEffortOption} from "../../app-server/v2"; import {LEGACY_SET_SESSION_MODEL_METHOD} from "../../AcpExtensions"; +import { + COLLABORATION_MODE_CONFIG_ID, + PLAN_COLLABORATION_MODE, +} from "../../CollaborationModeConfig"; const lowEffort: ReasoningEffortOption = {reasoningEffort: "low", description: "Fast"}; const mediumEffort: ReasoningEffortOption = {reasoningEffort: "medium", description: "Balanced"}; @@ -42,11 +46,12 @@ async function createSession(currentModelId: string, availableModels: Array { @@ -55,7 +60,7 @@ describe("Session config options", () => { const {response} = await createSession("fast-model[medium]", [fast, slow]); const ids = response.configOptions?.map(o => o.id); - expect(ids).toEqual([MODE_CONFIG_ID, MODEL_CONFIG_ID, REASONING_EFFORT_CONFIG_ID, "fast-mode"]); + expect(ids).toEqual([MODE_CONFIG_ID, COLLABORATION_MODE_CONFIG_ID, MODEL_CONFIG_ID, REASONING_EFFORT_CONFIG_ID, "fast-mode"]); const modelOption = response.configOptions?.find(o => o.id === MODEL_CONFIG_ID); expect(modelOption).toMatchObject({ @@ -96,7 +101,7 @@ describe("Session config options", () => { const {codexAcpAgent, response} = await createSession("custom-model[high]", [fast, slow]); const ids = response.configOptions?.map(o => o.id); - expect(ids).toEqual([MODE_CONFIG_ID, MODEL_CONFIG_ID]); + expect(ids).toEqual([MODE_CONFIG_ID, COLLABORATION_MODE_CONFIG_ID, MODEL_CONFIG_ID]); const modelOption = response.configOptions?.find(o => o.id === MODEL_CONFIG_ID); expect(modelOption).toMatchObject({ @@ -149,6 +154,80 @@ describe("Session config options", () => { expect((modeOption as any).currentValue).toBe(AgentMode.ReadOnly.id); }); + it("changes collaboration mode without starting a model turn", async () => { + const {fast} = buildModels(); + const {codexAcpAgent, codexAcpClient} = await createSession("fast-model[medium]", [fast]); + const update = vi.spyOn((codexAcpClient as any).codexClient, "threadSettingsUpdate").mockResolvedValue(undefined); + + const result = await codexAcpAgent.setSessionConfigOption({ + sessionId: "session-id", + configId: COLLABORATION_MODE_CONFIG_ID, + value: PLAN_COLLABORATION_MODE, + }); + + expect(update).toHaveBeenCalledWith(expect.objectContaining({ + threadId: "session-id", + collaborationMode: expect.objectContaining({mode: "plan"}), + })); + expect(codexAcpAgent.getSessionState("session-id").collaborationMode).toBe("plan"); + expect(result.configOptions?.find(o => o.id === COLLABORATION_MODE_CONFIG_ID)).toMatchObject({currentValue: "plan"}); + }); + + it("toggles collaboration mode with /plan without starting a model turn", async () => { + const {fast} = buildModels(); + const {fixture, codexAcpAgent, codexAcpClient} = await createSession("fast-model[medium]", [fast]); + const update = vi.spyOn((codexAcpClient as any).codexClient, "threadSettingsUpdate").mockResolvedValue(undefined); + const turnStart = vi.spyOn(fixture.getCodexAppServerClient(), "turnStart"); + + const enabledResponse = await codexAcpAgent.prompt({ + sessionId: "session-id", + prompt: [{type: "text", text: "/plan"}], + }); + + expect(enabledResponse.stopReason).toBe("end_turn"); + expect(turnStart).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledWith(expect.objectContaining({ + threadId: "session-id", + collaborationMode: expect.objectContaining({mode: "plan"}), + })); + expect(codexAcpAgent.getSessionState("session-id").collaborationMode).toBe("plan"); + + const disabledResponse = await codexAcpAgent.prompt({ + sessionId: "session-id", + prompt: [{type: "text", text: "/plan"}], + }); + + expect(disabledResponse.stopReason).toBe("end_turn"); + expect(turnStart).not.toHaveBeenCalled(); + expect(update).toHaveBeenLastCalledWith(expect.objectContaining({ + threadId: "session-id", + collaborationMode: expect.objectContaining({mode: "default"}), + })); + expect(codexAcpAgent.getSessionState("session-id").collaborationMode).toBe("default"); + expect(fixture.getAcpConnectionEvents([])).toContainEqual(expect.objectContaining({ + method: "sessionUpdate", + args: [expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: "config_option_update", + configOptions: expect.arrayContaining([ + expect.objectContaining({id: COLLABORATION_MODE_CONFIG_ID, currentValue: "plan"}), + ]), + }), + })], + })); + expect(fixture.getAcpConnectionEvents([])).toContainEqual(expect.objectContaining({ + method: "sessionUpdate", + args: [expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: "config_option_update", + configOptions: expect.arrayContaining([ + expect.objectContaining({id: COLLABORATION_MODE_CONFIG_ID, currentValue: "default"}), + ]), + }), + })], + })); + }); + it("changes the model and keeps the current reasoning effort when supported", async () => { const {fast, slow} = buildModels(); const {codexAcpAgent} = await createSession("fast-model[medium]", [fast, slow]); @@ -200,6 +279,7 @@ describe("Session config options", () => { sessionId: "session-id", currentModelId: "fast-model[medium]", models: [fast], + collaborationMode: "default", additionalDirectories: [], }); await codexAcpAgent.newSession({cwd: "/test/cwd", mcpServers: []}); diff --git a/src/__tests__/CodexACPAgent/session-delete.test.ts b/src/__tests__/CodexACPAgent/session-delete.test.ts index cc8285e6..ba0207ed 100644 --- a/src/__tests__/CodexACPAgent/session-delete.test.ts +++ b/src/__tests__/CodexACPAgent/session-delete.test.ts @@ -128,6 +128,7 @@ async function createSession(): Promise<{ sessionId, currentModelId: "model-id[medium]", models: [model], + collaborationMode: "default", currentServiceTier: null, additionalDirectories: [], }); diff --git a/src/__tests__/CodexACPAgent/thread-goal-events.test.ts b/src/__tests__/CodexACPAgent/thread-goal-events.test.ts index fc690994..bc88ac54 100644 --- a/src/__tests__/CodexACPAgent/thread-goal-events.test.ts +++ b/src/__tests__/CodexACPAgent/thread-goal-events.test.ts @@ -131,12 +131,55 @@ describe("CodexEventHandler - thread goal events", () => { objective: "Ship the goal update", status: "active", tokenBudget: null, + timeUsedSeconds: 12, + createdAt: 1710000000, + controlMethod: "_codex/session/goal_control", }, }, }, }); }); + it("should publish a replacement goal with the same contents and a different creation time", async () => { + const firstGoal: ServerNotification = { + method: "thread/goal/updated", + params: { + threadId: sessionId, + turnId: null, + goal: { + threadId: sessionId, + objective: "Ship the goal update", + status: "active", + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 1710000000, + updatedAt: 1710000000, + }, + }, + }; + const replacementGoal: ServerNotification = { + ...firstGoal, + params: { + ...firstGoal.params, + goal: { + ...firstGoal.params.goal, + createdAt: 1710000100, + updatedAt: 1710000100, + }, + }, + }; + + await setupPromptAndSendNotifications(mockFixture, sessionId, createSessionState(), [firstGoal, replacementGoal]); + + const events = mockFixture.getAcpConnectionEvents([]); + expect(events).toHaveLength(2); + expect(events.map(event => event.args[0].update._meta?.codex?.goal?.createdAt)).toEqual([ + 1710000000, + 1710000100, + ]); + }); + it("should not append completed goal updates to preceding agent text", async () => { const goalCompletedNotification: ServerNotification = { method: "thread/goal/updated", @@ -187,6 +230,9 @@ describe("CodexEventHandler - thread goal events", () => { objective: "tell me a joke", status: "complete", tokenBudget: null, + timeUsedSeconds: 12, + createdAt: 1710000000, + controlMethod: "_codex/session/goal_control", }, }, }, diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index 13505155..de4ad962 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -11,6 +11,7 @@ import path from "node:path"; import fs from "node:fs"; import os from "node:os"; import {AgentMode} from "../AgentMode"; +import {DEFAULT_COLLABORATION_MODE} from "../CollaborationModeConfig"; import {expect, vi} from "vitest"; import type {Model, ReasoningEffortOption} from "../app-server/v2"; @@ -383,9 +384,11 @@ export function createTestSessionState(overrides?: Partial): Sessi supportedReasoningEfforts: [], supportedInputModalities: ["text", "image"], agentMode: AgentMode.DEFAULT_AGENT_MODE, + collaborationMode: DEFAULT_COLLABORATION_MODE, fastModeEnabled: false, currentModelSupportsFast: false, terminalOutputMode: "terminal_output_delta", + goalRevision: 0, sessionTitle: null, sessionTitleSource: "unknown", ...overrides, diff --git a/src/index.ts b/src/index.ts index ca0b3d37..b2edd2c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ import packageJson from "../package.json"; import {logger} from "./Logger"; import {runLoginCommand} from "./login"; import {runCodexCli} from "./CodexCli"; -import {LEGACY_SET_SESSION_MODEL_METHOD} from "./AcpExtensions"; +import {GOAL_CONTROL_METHOD, LEGACY_SET_SESSION_MODEL_METHOD} from "./AcpExtensions"; const emptyExtensionParamsParser = z.preprocess( (params) => params ?? {}, @@ -24,6 +24,11 @@ const legacySetSessionModelParamsParser = z.object({ modelId: z.string(), }).passthrough(); +const goalControlParamsParser = z.object({ + sessionId: z.string(), + action: z.enum(["pause", "clear"]), +}).passthrough(); + if (process.argv.includes("--version")) { console.log(`${packageJson.name} ${packageJson.version}`); process.exit(0); @@ -132,5 +137,6 @@ function startAcpServer() { .onRequest("authentication/status", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/status", ctx.params)) .onRequest("authentication/logout", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/logout", ctx.params)) .onRequest(LEGACY_SET_SESSION_MODEL_METHOD, legacySetSessionModelParamsParser, (ctx) => getAgent().extMethod(LEGACY_SET_SESSION_MODEL_METHOD, ctx.params)) + .onRequest(GOAL_CONTROL_METHOD, goalControlParamsParser, (ctx) => getAgent().extMethod(GOAL_CONTROL_METHOD, ctx.params)) .connect(acpJsonStream); }