Skip to content
Open
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
Binary file addedagentclientprotocol-codex-acp-1.6.2.tgz
Binary file not shown.
8 changes: 4 additions & 4 deletions src/CodexAcpClient.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -554,7 +554,7 @@ export class CodexAcpClient {
async runReview(
sessionId: string,
target: ReviewTarget,
onTurnStarted?: (turnId: string, threadId: string) => void,
onTurnStarted?: (turnId: string, threadId: string) => void | Promise<void>,
): Promise<TurnCompletedNotification> {
return await this.codexClient.runReview({
threadId: sessionId,
Expand All@@ -575,7 +575,7 @@ export class CodexAcpClient {
async setGoal(
sessionId: string,
objective: string,
onTurnStarted?: (turnId: string) => void,
onTurnStarted?: (turnId: string) => void | Promise<void>,
onGoalSet?: (goal: ThreadGoal) => void,
): Promise<TurnCompletedNotification | null> {
const params = {
Expand DownExpand Up@@ -605,7 +605,7 @@ export class CodexAcpClient {

async resumeGoal(
sessionId: string,
onTurnStarted?: (turnId: string) => void,
onTurnStarted?: (turnId: string) => void | Promise<void>,
onGoalSet?: (goal: ThreadGoal) => void,
): Promise<TurnCompletedNotification | null> {
const params = {
Expand DownExpand Up@@ -848,7 +848,7 @@ export class CodexAcpClient {
disableSummary: boolean,
cwd: string,
additionalDirectories: string[],
onTurnStarted?: (turnId: string) => void,
onTurnStarted?: (turnId: string) => void | Promise<void>,
shouldCancel?: () => boolean,
): Promise<TurnCompletedNotification | null> {
const input = buildPromptItems(request.prompt);
Expand Down
73 changes: 69 additions & 4 deletions src/CodexAcpServer.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -173,6 +173,15 @@ export interface SessionFailure {

const CODEX_PROCESS_EXITED_ERROR_CODE = 1001;

/**
* The metadata key naming a backend's acceptance of a turn.
*
* Versioned, because a client reads it to decide whether a conversation now
* exists, and a changed meaning under an unchanged key would be read as the
* old one.
*/
const SESSION_MATERIALIZATION_META = "executablemd.session-materialization/v1";

function clientSupportsAirCapability(
capabilities: acp.ClientCapabilities | null,
capability: string,
Expand DownExpand Up@@ -2255,6 +2264,25 @@ export class CodexAcpServer {
return turnId;
}

/**
* Tell the client this thread's backend accepted a turn.
*
* A client that defers durable session state until a conversation really
* exists cannot learn that from what a turn produces: text, a stop reason
* and a terminal response each say the adapter is talking, not that the
* backend took the turn. This says exactly that and nothing else, on the
* session it happened on.
*/
private async publishSessionMaterialization(sessionId: string): Promise<void> {
const session = new ACPSessionConnection(this.connection, sessionId);
await session.update({
sessionUpdate: "session_info_update",
_meta: {
[SESSION_MATERIALIZATION_META]: {state: "accepted"},
},
});
}

async prompt(
params: acp.PromptRequest,
signal?: AbortSignal,
Expand All@@ -2273,6 +2301,12 @@ export class CodexAcpServer {
: null;
let agentFileChangeReportTurnId: string | null = null;
let agentFileChangeReportUnavailableReason: AgentFileChangeReportUnavailableReason = "providerError";
// The App Server turn that produced this prompt's terminal response, so a
// client can name the exact point this conversation reached. Request-local
// and assigned only where a turn actually completed: `currentTurnId` is
// session state that a concurrent prompt on the same session moves, and
// reading it here would report another prompt's turn as this one's.
let checkpointTurnId: string | null = null;
let recoverableSessionFailure = sessionState.sessionFailure;
sessionState.currentTurnId = null;
sessionState.lastTokenUsage = null;
Expand DownExpand Up@@ -2356,7 +2390,7 @@ export class CodexAcpServer {
onTurnStartPending: () => {
ensurePendingTurnStart();
},
onTurnStarted: (turnId, threadId) => {
onTurnStarted: async (turnId, threadId) => {
const turn = {threadId, turnId};
activePrompt.currentTurn = turn;
if (this.promptShouldStop(params.sessionId, activePrompt)) {
Expand All@@ -2365,6 +2399,10 @@ export class CodexAcpServer {
}
sessionState.currentTurnId = turnId;
pendingTurnStart?.resolve(turnId);
// A command that starts a turn is a turn the backend
// accepted, exactly as an ordinary prompt is. A command that
// starts none never reaches here and publishes nothing.
await this.publishSessionMaterialization(params.sessionId);
onTurnStarted?.();
},
setConfigOption: async (configId, value) => {
Expand DownExpand Up@@ -2420,14 +2458,18 @@ export class CodexAcpServer {
}
if (commandResult.turnCompleted?.turn.status === "completed") {
agentFileChangeReportTurnId = commandResult.turnCompleted.turn.id;
checkpointTurnId = commandResult.turnCompleted.turn.id;
} else if (commandResult.turnCompleted === undefined) {
agentFileChangeReportUnavailableReason = "notReported";
}
await clearRecoveredSessionFailure(eventHandler);
return {
stopReason: "end_turn",
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
_meta: this.buildQuotaMeta(sessionState),
_meta: {
...this.buildQuotaMeta(sessionState),
...this.buildCheckpointMeta(checkpointTurnId),
},
};
}

Expand DownExpand Up@@ -2469,7 +2511,7 @@ export class CodexAcpServer {
disableSummary,
sessionState.cwd,
sessionState.additionalDirectories,
(turnId) => {
async (turnId) => {
const turn = {threadId: params.sessionId, turnId};
activePrompt.currentTurn = turn;
if (this.promptShouldStop(params.sessionId, activePrompt)) {
Expand All@@ -2478,6 +2520,7 @@ export class CodexAcpServer {
}
sessionState.currentTurnId = turnId;
pendingTurnStart?.resolve(turnId);
await this.publishSessionMaterialization(params.sessionId);
onTurnStarted?.();
},
() => this.promptShouldStop(params.sessionId, activePrompt),
Expand DownExpand Up@@ -2615,6 +2658,10 @@ export class CodexAcpServer {
}
if (turnCompleted.turn.status === "completed") {
agentFileChangeReportTurnId = turnCompleted.turn.id;
// `turnCompleted` has already been reassigned if a plan
// implementation turn ran, so this is the final turn — the one
// that produced the response being returned.
checkpointTurnId = turnCompleted.turn.id;
}

await clearRecoveredSessionFailure(eventHandler);
Expand All@@ -2627,7 +2674,10 @@ export class CodexAcpServer {
return {
stopReason: "end_turn",
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
_meta: this.buildQuotaMeta(sessionState),
_meta: {
...this.buildQuotaMeta(sessionState),
...this.buildCheckpointMeta(checkpointTurnId),
},
};
} catch (err) {
logger.error(`Prompt for session ${params.sessionId} failed`, err);
Expand DownExpand Up@@ -2742,6 +2792,21 @@ export class CodexAcpServer {
};
}

/**
* Which App Server turn this response is, when one completed.
*
* A client that keeps this can later say exactly where a conversation had
* reached, rather than "wherever that session is now". The value is the App
* Server's own turn id, carried out unchanged.
*
* Empty for a cancelled prompt, for a failed one, and for a command this
* adapter answered itself without ever starting a provider turn — none of
* those is a point anything could resume from.
*/
private buildCheckpointMeta(turnId: string | null): { codex?: { turnId: string } } {
return turnId === null ? {} : { codex: { turnId } };
}

private buildQuotaMeta(sessionState: SessionState): { quota: QuotaMeta } {
const lastTokenUsage = sessionState.lastTokenUsage;

Expand Down
23 changes: 17 additions & 6 deletions src/CodexAppServerClient.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -274,15 +274,17 @@ export class CodexAppServerClient {
return await this.sendRequest({ method: "turn/start", params: params });
}

async runTurn(params: TurnStartParams, onTurnStarted?: (turnId: string) => void): Promise<TurnCompletedNotification> {
async runTurn(params: TurnStartParams, onTurnStarted?: (turnId: string) => void | Promise<void>): Promise<TurnCompletedNotification> {
const capturedCompletions: Array<TurnCompletedNotification> = [];
const releaseCapture = this.captureTurnCompletions(params.threadId, (event) => {
capturedCompletions.push(event);
});

try {
const turnStarted = await this.turnStart(params);
onTurnStarted?.(turnStarted.turn.id);
// Awaited: a caller that publishes the acceptance of this turn has to
// publish it before anything the turn produces reaches the client.
await onTurnStarted?.(turnStarted.turn.id);
const earlyCompletion = capturedCompletions.find(event => event.turn.id === turnStarted.turn.id);
releaseCapture();
if (earlyCompletion) {
Expand All@@ -298,7 +300,7 @@ export class CodexAppServerClient {

async runReview(
params: ReviewStartParams,
onTurnStarted?: (turnId: string, threadId: string) => void,
onTurnStarted?: (turnId: string, threadId: string) => void | Promise<void>,
): Promise<TurnCompletedNotification> {
const capturedCompletions: Array<TurnCompletedNotification> = [];
const releaseCapture = this.captureTurnCompletions(params.threadId, (event) => {
Expand All@@ -307,7 +309,10 @@ export class CodexAppServerClient {

try {
const reviewStarted = await this.reviewStart(params);
onTurnStarted?.(reviewStarted.turn.id, reviewStarted.reviewThreadId);
// Awaited for the same reason `runTurn` awaits it: a caller that
// publishes this turn's acceptance has to publish it before
// anything the turn produces reaches the client.
await onTurnStarted?.(reviewStarted.turn.id, reviewStarted.reviewThreadId);
const earlyCompletion = capturedCompletions.find(event => event.turn.id === reviewStarted.turn.id);
releaseCapture();
if (earlyCompletion) {
Expand All@@ -321,7 +326,7 @@ export class CodexAppServerClient {

async runGoalSet(
params: ThreadGoalSetParams,
onTurnStarted?: (turnId: string) => void,
onTurnStarted?: (turnId: string) => void | Promise<void>,
runtimeEffectsGraceMs = GOAL_RUNTIME_EFFECTS_GRACE_MS,
onGoalSet?: (goal: ThreadGoal) => void,
): Promise<TurnCompletedNotification | null> {
Expand DownExpand Up@@ -349,12 +354,17 @@ export class CodexAppServerClient {
let expectedGoal: ThreadGoal | null = null;
const noGoalTurnStarted = this.createNoGoalTurnStartedPromise(runtimeEffectsGraceMs);
const capturedGoalUpdates: Array<ThreadGoalUpdatedNotification> = [];
// A goal turn is routed to us on a notification, so the callback runs
// where nothing can be awaited. What it starts is kept instead, and
// waited for below — before this returns, and therefore before the
// prompt it belongs to answers.
let turnStartedPublication: Promise<void> | undefined;
const releaseRoutingCapture = this.captureTurnRoutings(params.threadId, (turnId) => {
if (!goalUpdateHandled || goalTurnId !== null) {
return;
}
goalTurnId = turnId;
onTurnStarted?.(turnId);
turnStartedPublication = Promise.resolve(onTurnStarted?.(turnId)).then(() => {});
resolveGoalTurnStarted(turnId);
});
const releaseGoalUpdateCapture = this.captureThreadGoalUpdates(params.threadId, (event) => {
Expand DownExpand Up@@ -386,6 +396,7 @@ export class CodexAppServerClient {
return null;
}
const turnId = goalTurnId ?? await Promise.race([goalTurnStarted, noGoalTurnStarted.promise]);
await turnStartedPublication;
noGoalTurnStarted.release();
releaseRoutingCapture();
releaseStatusCapture();
Expand Down
24 changes: 14 additions & 10 deletions src/CodexCommands.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,7 +30,7 @@ export const GOAL_CONTINUATION_PROMPT: acp.ContentBlock[] = [{

export type CommandHandleOptions = {
onTurnStartPending?: () => void;
onTurnStarted?: (turnId: string, threadId: string) => void;
onTurnStarted?: (turnId: string, threadId: string) => void | Promise<void>;
setConfigOption?: (configId: string, value: string) => Promise<void>;
};

Expand DownExpand Up@@ -315,8 +315,8 @@ export class CodexCommands {
return await this.runWithProcessCheck(() => this.codexAcpClient.runReview(
sessionState.sessionId,
target,
(turnId, threadId) => {
this.handleCommandTurnStarted(sessionState, options, turnId, threadId);
async (turnId, threadId) => {
await this.handleCommandTurnStarted(sessionState, options, turnId, threadId);
},
));
}
Expand All@@ -341,8 +341,8 @@ export class CodexCommands {
options.onTurnStartPending?.();
return this.createGoalCommandResult(await this.runWithProcessCheck(() => this.codexAcpClient.resumeGoal(
sessionId,
(turnId) => {
this.handleCommandTurnStarted(sessionState, options, turnId, sessionId);
async (turnId) => {
await this.handleCommandTurnStarted(sessionState, options, turnId, sessionId);
},
)));
case "clear":
Expand All@@ -360,20 +360,24 @@ export class CodexCommands {
return this.createGoalCommandResult(await this.runWithProcessCheck(() => this.codexAcpClient.setGoal(
sessionId,
argument,
(turnId) => {
this.handleCommandTurnStarted(sessionState, options, turnId, sessionId);
async (turnId) => {
await this.handleCommandTurnStarted(sessionState, options, turnId, sessionId);
},
)));
}

private handleCommandTurnStarted(
private async handleCommandTurnStarted(
sessionState: SessionState,
options: CommandHandleOptions,
turnId: string,
threadId: string,
): void {
): Promise<void> {
if (options.onTurnStarted) {
options.onTurnStarted(turnId, threadId);
// Awaited, so a caller that publishes this turn's acceptance
// publishes it before the command goes on — every path that starts
// a turn reports it the same way, and a command that starts none
// never arrives here.
await options.onTurnStarted(turnId, threadId);
} else {
sessionState.currentTurnId = turnId;
}
Expand Down
Loading