Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/goal-extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ An agent advertises support in its `initialize` response:
"goal": {
"version": 1,
"controlMethod": "_session/goal",
"actions": ["pause", "resume", "clear"]
"actions": ["set", "pause", "resume", "clear"]
}
}
}
```

`actions` is the implementation-supported subset of `set`, `pause`, `resume`, and `clear`. Clients must not infer support for an action that is not advertised. The control request contains `sessionId` and `action`; a future version may add action-specific fields such as an objective for `set`.
`actions` is the implementation-supported subset of `set`, `pause`, `resume`, and `clear`. Clients must not infer support for an action that is not advertised. The control request contains `sessionId` and `action`; `set` additionally requires a non-blank `objective`.

## Session state

Expand Down
18 changes: 14 additions & 4 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,12 +501,17 @@ export class CodexAcpClient {
sessionId: string,
objective: string,
onTurnStarted?: (turnId: string) => void,
onGoalSet?: (goal: ThreadGoal) => void,
): Promise<TurnCompletedNotification | null> {
return await this.codexClient.runGoalSet({
const params = {
threadId: sessionId,
objective,
status: "active",
}, onTurnStarted);
} as const;
if (onGoalSet === undefined) {
return await this.codexClient.runGoalSet(params, onTurnStarted);
}
return await this.codexClient.runGoalSet(params, onTurnStarted, undefined, onGoalSet);
}

async setGoalStatus(sessionId: string, status: ThreadGoalStatus): Promise<ThreadGoal> {
Expand All @@ -526,11 +531,16 @@ export class CodexAcpClient {
async resumeGoal(
sessionId: string,
onTurnStarted?: (turnId: string) => void,
onGoalSet?: (goal: ThreadGoal) => void,
): Promise<TurnCompletedNotification | null> {
return await this.codexClient.runGoalSet({
const params = {
threadId: sessionId,
status: "active",
}, onTurnStarted);
} as const;
if (onGoalSet === undefined) {
return await this.codexClient.runGoalSet(params, onTurnStarted);
}
return await this.codexClient.runGoalSet(params, onTurnStarted, undefined, onGoalSet);
}

async clearGoal(sessionId: string): Promise<void> {
Expand Down
117 changes: 101 additions & 16 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
import type {McpStartupResult} from "./CodexAppServerClient";
import {type AcpClientConnection, ACPSessionConnection, type UpdateSessionEvent} from "./ACPSessionConnection";
import type {InputModality, ReasoningEffort} from "./app-server";
import type {Account, Model, ReasoningEffortOption, Thread, ThreadItem, UserInput} from "./app-server/v2";
import type {Account, Model, ReasoningEffortOption, Thread, ThreadGoal, ThreadItem, UserInput} from "./app-server/v2";
import type {RateLimitsMap} from "./RateLimitsMap";
import {ModelId} from "./ModelId";
import {AgentMode, MODE_CONFIG_ID} from "./AgentMode";
Expand All @@ -35,7 +35,7 @@ import {
} from "./ModelConfigOption";
import type {TokenCount} from "./TokenCount";
import {toPromptUsage} from "./TokenCount";
import {CodexCommands} from "./CodexCommands";
import {CodexCommands, GOAL_CONTINUATION_PROMPT} from "./CodexCommands";
import {SteeringQueue} from "./SteeringQueue";
import type {QuotaMeta} from "./QuotaMeta";
import {logger} from "./Logger";
Expand Down Expand Up @@ -175,6 +175,7 @@ export class CodexAcpServer {
private readonly closingSessions: Map<string, number>;
private readonly sessionGenerations: Map<string, number>;
private readonly sessionOpenGenerations: Map<string, number>;
private readonly goalControlGenerations: Map<string, number>;

constructor(
connection: AcpClientConnection,
Expand All @@ -191,6 +192,7 @@ export class CodexAcpServer {
this.closingSessions = new Map();
this.sessionGenerations = new Map();
this.sessionOpenGenerations = new Map();
this.goalControlGenerations = new Map();
this.connection = connection;
this.codexAcpClient = codexAcpClient;
this.defaultAuthRequest = defaultAuthRequest ?? null;
Expand Down Expand Up @@ -284,12 +286,51 @@ export class CodexAcpServer {
throw RequestError.invalidParams(undefined, `Unknown session: ${methodRequest.params.sessionId}`);
}
const sessionGeneration = this.getSessionGeneration(sessionState.sessionId);
if (methodRequest.params.action === "pause" || methodRequest.params.action === "resume") {
const status = methodRequest.params.action === "pause" ? "paused" : "active";
const goal = await this.runWithProcessCheck(() => this.codexAcpClient.setGoalStatus(sessionState.sessionId, status));
const goalControlGeneration = this.bumpGoalControlGeneration(sessionState.sessionId);
if (methodRequest.params.action === "set") {
const objective = methodRequest.params.objective;
let updatedGoal: ThreadGoal | null = null;
const turnCompleted = await this.runWithProcessCheck(() => this.codexAcpClient.setGoal(
sessionState.sessionId,
objective,
undefined,
(goal) => {
updatedGoal = goal;
},
));
if (turnCompleted === null && updatedGoal !== null) {
await this.startGoalContinuationIfCurrent(
sessionState,
sessionGeneration,
goalControlGeneration,
updatedGoal,
);
}
} else 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 === "resume") {
let updatedGoal: ThreadGoal | null = null;
const turnCompleted = await this.runWithProcessCheck(() => this.codexAcpClient.resumeGoal(
sessionState.sessionId,
undefined,
(goal) => {
updatedGoal = goal;
},
));
if (updatedGoal !== null && this.goalPublishIsCurrent(sessionState, sessionGeneration)) {
await this.publishGoalSnapshot(sessionState, toThreadGoalSnapshot(updatedGoal), false);
}
if (turnCompleted === null && updatedGoal !== null) {
await this.startGoalContinuationIfCurrent(
sessionState,
sessionGeneration,
goalControlGeneration,
updatedGoal,
);
}
} else if (methodRequest.params.action === "clear") {
await this.runWithProcessCheck(() => this.codexAcpClient.clearGoal(sessionState.sessionId));
if (this.goalPublishIsCurrent(sessionState, sessionGeneration)) {
Expand Down Expand Up @@ -397,6 +438,12 @@ export class CodexAcpServer {
return this.sessionGenerations.get(sessionId) ?? 0;
}

private bumpGoalControlGeneration(sessionId: string): number {
const generation = (this.goalControlGenerations.get(sessionId) ?? 0) + 1;
this.goalControlGenerations.set(sessionId, generation);
return generation;
}

private bumpSessionGeneration(sessionId: string): number {
const generation = this.getSessionGeneration(sessionId) + 1;
this.sessionGenerations.set(sessionId, generation);
Expand Down Expand Up @@ -618,6 +665,7 @@ export class CodexAcpServer {
this.pendingTurnStarts.delete(params.sessionId);
this.activePrompts.delete(params.sessionId);
this.steeringQueues.delete(params.sessionId);
this.goalControlGenerations.delete(params.sessionId);
}
this.endSessionCloseFence(params.sessionId);
}
Expand Down Expand Up @@ -1039,25 +1087,58 @@ export class CodexAcpServer {
* fails or is cancelled before the turn starts.
*/
private async startNewTurnFromSteering(params: SessionSteerRequest): Promise<SessionSteeringResponse> {
// A prompt can outlive its turn (post-turn cleanup runs before it leaves
// activePrompts), so a steer can miss the turn while the prompt is still
// winding down. Starting a new turn now would run a second prompt on the
// same session, so wait for the current one to drain first (a no-op when idle).
await this.startNewTurnFromExternalPrompt(params, "Steering");
return {outcome: "startedNewTurn"};
}

private async startGoalContinuationIfCurrent(
sessionState: SessionState,
sessionGeneration: number,
goalControlGeneration: number,
expectedGoal: ThreadGoal,
): Promise<void> {
await this.startNewTurnFromExternalPrompt({
sessionId: sessionState.sessionId,
prompt: GOAL_CONTINUATION_PROMPT,
}, "Goal continuation", async () => {
if (!this.goalPublishIsCurrent(sessionState, sessionGeneration)
|| this.goalControlGenerations.get(sessionState.sessionId) !== goalControlGeneration) {
return false;
}
const currentGoal = await this.runWithProcessCheck(() => this.codexAcpClient.getGoal(sessionState.sessionId));
return currentGoal?.status === "active"
&& currentGoal.objective === expectedGoal.objective
&& currentGoal.createdAt === expectedGoal.createdAt
&& this.goalControlGenerations.get(sessionState.sessionId) === goalControlGeneration;
});
}

private async startNewTurnFromExternalPrompt(
params: acp.PromptRequest,
source: string,
canStart: () => Promise<boolean> = async () => true,
): Promise<boolean> {
// A prompt can outlive its turn while post-turn cleanup runs. Starting a
// control-triggered turn during that window would run two prompts on the
// same session, so wait for the current prompt to drain first.
const previousPrompt = this.activePrompts.get(params.sessionId);
await previousPrompt?.completion;
if (this.sessionIsClosing(params.sessionId)) {
throw RequestError.invalidRequest(`Session ${params.sessionId} is closing`);
}
if (!await canStart()) {
return false;
}

return await new Promise<SessionSteeringResponse>((resolve, reject) => {
return await new Promise<boolean>((resolve, reject) => {
let turnStarted = false;
const promptDone = this.prompt(params, undefined, () => {
turnStarted = true;
logger.log("Steering session started a new turn", {sessionId: params.sessionId});
logger.log(`${source} started a new turn`, {sessionId: params.sessionId});
// The new turn is now running. This is the success path: answer the
// steer immediately ("a turn was started") and let prompt() finish the
// turn in the background.
resolve({outcome: "startedNewTurn"});
resolve(true);
});
promptDone.then(
(response) => {
Expand All @@ -1070,15 +1151,15 @@ export class CodexAcpServer {
// resolve in the callback above), or the prompt finished
// without ever starting a turn and was not cancelled (e.g. a
// command-only turn). Both count as a successfully accepted steer.
resolve({outcome: "startedNewTurn"});
resolve(turnStarted);
}
},
(error: unknown) => {
if (turnStarted) {
// The turn had already started, so the steer was already
// answered "startedNewTurn". This is a failure of a turn running
// in the background — nothing to return, just log it.
logger.error(`Steering-started prompt for session ${params.sessionId} failed`, error);
logger.error(`${source} prompt for session ${params.sessionId} failed`, error);
} else {
// The prompt failed before the turn started. The steer never
// took, so surface the failure to the caller.
Expand Down Expand Up @@ -1995,6 +2076,10 @@ export class CodexAcpServer {
};
}

const effectiveParams = commandResult.prompt === undefined
? params
: {...params, prompt: commandResult.prompt};

if (this.sessionIsClosing(params.sessionId)) {
return this.cancelledPromptResponse(sessionState);
}
Expand All @@ -2011,7 +2096,7 @@ export class CodexAcpServer {
});
}

if (!sessionState.supportedInputModalities.includes("image") && params.prompt.some(b => b.type === "image")) {
if (!sessionState.supportedInputModalities.includes("image") && effectiveParams.prompt.some(b => b.type === "image")) {
throw RequestError.invalidRequest("The current model does not support image input");
}
const agentMode = sessionState.agentMode;
Expand All @@ -2022,7 +2107,7 @@ export class CodexAcpServer {
ensurePendingTurnStart();
const sendPromptPromise = this.runWithProcessCheck(
() => this.codexAcpClient.sendPrompt(
params,
effectiveParams,
agentMode,
modelId,
serviceTier,
Expand Down
9 changes: 7 additions & 2 deletions src/CodexCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,14 @@ type ParsedSlashCommand = {
};

export type CommandHandleResult =
| { handled: false }
| { handled: false, prompt?: acp.ContentBlock[] }
| { handled: true, turnCompleted?: TurnCompletedNotification };

export const GOAL_CONTINUATION_PROMPT: acp.ContentBlock[] = [{
type: "text",
text: "Continue working toward the active goal.",
}];

export type CommandHandleOptions = {
onTurnStartPending?: () => void;
onTurnStarted?: (turnId: string, threadId: string) => void;
Expand Down Expand Up @@ -371,7 +376,7 @@ export class CodexCommands {

private createGoalCommandResult(turnCompleted: TurnCompletedNotification | null): CommandHandleResult {
if (turnCompleted === null) {
return { handled: true };
return { handled: false, prompt: GOAL_CONTINUATION_PROMPT };
}
return {
handled: true,
Expand Down
9 changes: 4 additions & 5 deletions src/GoalExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export const GOAL_EXTENSION_VERSION = 1;
export const GOAL_CONTROL_METHOD = "_session/goal";
export const LEGACY_GOAL_CONTROL_METHOD = "_codex/session/goal_control";

export const GOAL_CONTROL_ACTIONS = ["pause", "resume", "clear"] as const;
export const GOAL_CONTROL_ACTIONS = ["set", "pause", "resume", "clear"] as const;
export type GoalControlAction = typeof GOAL_CONTROL_ACTIONS[number];

export type GoalCapability = {
Expand All @@ -28,7 +28,6 @@ export type GoalSnapshot = {
controlMethod: typeof GOAL_CONTROL_METHOD;
}

export type GoalControlRequest = {
sessionId: SessionId;
action: GoalControlAction;
}
export type GoalControlRequest =
| { sessionId: SessionId; action: "set"; objective: string }
| { sessionId: SessionId; action: Exclude<GoalControlAction, "set"> }
Loading