Skip to content

Commit f9dbc8c

Browse files
authored
fix: preserve ACP v1 prompt across goal continuations (#33)
Model: gpt-6
1 parent b256c82 commit f9dbc8c

11 files changed

Lines changed: 439 additions & 35 deletions

‎AGENTS.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333

3434
## Docs
3535

36+
- ACP v1 prompt completion follows native Goal continuations across turn boundaries. Keep
37+
the prompt and interaction handlers open until the goal stops and its last native turn
38+
drains, or the turn fails/is cancelled. Never issue another `turn/start` after a routed
39+
`/goal` turn; Codex owns continuation. Cancellation also pauses the goal before returning.
40+
- A persistent active goal does not create a prompt or live presence by itself. Goal metadata
41+
remains a separate Core snapshot; do not add a private execution lifecycle protocol.
3642
- Codex app-server usage: see https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md when touching protocol/transport details, adding or consuming JSON-RPC methods, handling approvals/turn events, or updating generated schema/clients.
3743
- App-server events: prefer `thread/*`, `turn/*`, and `item/*` event surfaces; avoid the deprecated `codex/event/*` API (planned removal). Keep implementations aligned with generated types in `src/app-server` (including `v2` exports).
3844
- Steer uses app-server `turn/steer` on the tracked active turn. Correlate `clientUserMessageId` and acknowledge only the matching `item/completed(userMessage)`; never emulate steer with a second `turn/start`.

‎docs/goal-extension.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,16 @@ Common statuses are `active`, `paused`, `blocked`, `limited`, and `complete`. Op
4141

4242
## Lifecycle architecture
4343

44-
A goal belongs to the ACP session, not to an individual `session/prompt` request. Goal activity and prompt activity are independent:
44+
A goal belongs to the ACP session. Its durable state is separate from the ACP v1 prompt lifecycle:
4545

4646
- `status: active` means the persistent objective can drive more work; it does not mean an ACP prompt is currently executing.
47-
- A prompt completes when its current backend turn reaches a quiescent boundary, even when the goal remains active.
48-
- A later autonomous cycle may publish more session updates outside that completed prompt.
49-
- While a turn is running, clients use steering or prompt queueing when advertised. While the session is quiescent, clients may send an ordinary `session/prompt`.
47+
- Once a prompt is executing an active goal, it remains open across native Codex turn completions and automatic continuations. Codex schedules those continuations; the adapter does not submit duplicate turns.
48+
- The prompt returns only after the goal becomes complete, paused, blocked, limited, or cleared and the final native turn has drained. A failed or interrupted turn also ends the prompt. Goal completion before the last text chunk does not truncate that output.
49+
- `/goal set` (an objective) and `/goal resume` follow the same lifecycle. An explicit continuation prompt is needed only when the goal control operation started no native turn at all.
50+
- Cancellation pauses the goal before returning, including when cancelled between native turns. Connection loss fails the pending prompt.
51+
- While a prompt is open, clients use steering or prompt queueing when advertised. A stored active goal outside a prompt does not establish live presence.
5052

51-
This separation prevents a persistent goal from monopolizing the session's prompt slot and lets clients model “working now” independently from “objective remains active.”
53+
This uses the standard ACP v1 request/response completion boundary: one prompt may contain multiple model exchanges. It requires no Core execution extension. A v2 implementation should use standard `state_update` notifications instead of treating prompt acceptance as completion.
5254

5355
## Codex mapping and compatibility
5456

‎src/CodexAcpClient.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -924,6 +924,12 @@ export class CodexAcpClient {
924924
}
925925
}
926926

927+
onConnectionClosed(callback: () => void): () => void {
928+
const close = this.codexClient.connection.onClose(callback);
929+
const dispose = this.codexClient.connection.onDispose(callback);
930+
return () => { close.dispose(); dispose.dispose(); };
931+
}
932+
927933
private enqueueSessionNotification(sessionId: string, operation: () => void | Promise<void>): void {
928934
const run = async () => {
929935
try {

‎src/CodexAcpServer.ts‎

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
import type {TokenCount} from "./TokenCount";
5757
import {toPromptUsage} from "./TokenCount";
5858
import {CodexCommands, GOAL_CONTINUATION_PROMPT} from "./CodexCommands";
59+
import {GoalPromptLifecycle} from "./GoalPromptLifecycle";
5960
import {SteeringQueue} from "./SteeringQueue";
6061
import type {QuotaMeta} from "./QuotaMeta";
6162
import {logger} from "./Logger";
@@ -259,6 +260,7 @@ interface ActivePrompt {
259260
cancelSignal: Promise<null>;
260261
signal: AbortSignal;
261262
currentTurn: { threadId: string, turnId: string } | null;
263+
hasCompletedTurn: boolean;
262264
requestCancel: () => void;
263265
requestClose: () => void;
264266
complete: () => void;
@@ -2771,6 +2773,7 @@ export class CodexAcpServer {
27712773
cancelSignal,
27722774
signal: abortController.signal,
27732775
currentTurn: null,
2776+
hasCompletedTurn: false,
27742777
requestCancel: () => {
27752778
if (abortController.signal.aborted) {
27762779
return;
@@ -3020,6 +3023,12 @@ export class CodexAcpServer {
30203023
let recoverableSessionFailure = sessionState.sessionFailure;
30213024
sessionState.currentTurnId = null;
30223025
const activePrompt = this.trackActivePrompt(params.sessionId);
3026+
const goalLifecycle = new GoalPromptLifecycle(params.sessionId, sessionState.currentGoal?.status === "active");
3027+
const cancelGoalLifecycle = () => goalLifecycle.cancel();
3028+
activePrompt.signal.addEventListener("abort", cancelGoalLifecycle);
3029+
const disposeGoalConnection = this.codexAcpClient.onConnectionClosed(() => {
3030+
goalLifecycle.fail(new Error("Codex connection closed during goal continuation"));
3031+
});
30233032
let pendingTurnStart: PendingTurnStart | null = null;
30243033
const ensurePendingTurnStart = (): PendingTurnStart => {
30253034
if (pendingTurnStart === null) {
@@ -3078,6 +3087,25 @@ export class CodexAcpServer {
30783087
};
30793088
await this.codexAcpClient.subscribeToSessionEvents(params.sessionId,
30803089
async (event) => {
3090+
if (this.activePrompts.get(params.sessionId) === activePrompt) {
3091+
goalLifecycle.observe(event);
3092+
if (event.method === "turn/started" && event.params.threadId === params.sessionId) {
3093+
const turn = {threadId: params.sessionId, turnId: event.params.turn.id};
3094+
activePrompt.currentTurn = turn;
3095+
if (this.promptShouldStop(params.sessionId, activePrompt)) {
3096+
this.interruptLateStartedTurn(turn);
3097+
return;
3098+
}
3099+
recoverableSessionFailure = sessionState.sessionFailure;
3100+
promptNotificationsActive = true;
3101+
}
3102+
if (event.method === "turn/completed" &&
3103+
event.params.threadId === params.sessionId &&
3104+
activePrompt.currentTurn?.turnId === event.params.turn.id) {
3105+
activePrompt.currentTurn = null;
3106+
activePrompt.hasCompletedTurn = true;
3107+
}
3108+
}
30813109
await this.handleSteerAppliedNotification(params.sessionId, event, activePrompt);
30823110
await observeInteraction(event);
30833111
if (!promptNotificationsActive) {
@@ -3111,6 +3139,7 @@ export class CodexAcpServer {
31113139
},
31123140
onTurnStarted: (turnId, threadId) => {
31133141
const turn = {threadId, turnId};
3142+
if (threadId === params.sessionId) goalLifecycle.startTurn(turnId);
31143143
activePrompt.currentTurn = turn;
31153144
if (this.promptShouldStop(params.sessionId, activePrompt)) {
31163145
this.interruptLateStartedTurn(turn);
@@ -3138,7 +3167,7 @@ export class CodexAcpServer {
31383167
logger.error(`Command for cancelled prompt ${params.sessionId} failed after prompt returned`, err);
31393168
}
31403169
});
3141-
const commandResult = await Promise.race([
3170+
let commandResult = await Promise.race([
31423171
commandPromise,
31433172
activePrompt.closeSignal,
31443173
this.cancelBeforeTurnStarted(activePrompt),
@@ -3147,6 +3176,15 @@ export class CodexAcpServer {
31473176
return cancelledPromptResponse();
31483177
}
31493178
if (commandResult.handled) {
3179+
if (commandResult.turnCompleted) {
3180+
await this.codexAcpClient.waitForSessionNotifications(params.sessionId);
3181+
const firstCommandTurn = commandResult.turnCompleted;
3182+
const completed = await this.runWithProcessCheck(() => Promise.race([
3183+
goalLifecycle.waitForCompletion(firstCommandTurn), activePrompt.closeSignal,
3184+
]));
3185+
if (completed === null) return cancelledPromptResponse();
3186+
commandResult = {...commandResult, turnCompleted: completed};
3187+
}
31503188
promptNotificationsActive = false;
31513189
logger.log("Prompt handled by a command");
31523190
await this.codexAcpClient.waitForSessionNotifications(params.sessionId);
@@ -3214,6 +3252,7 @@ export class CodexAcpServer {
32143252
);
32153253
sessionState.lastTokenUsage = null;
32163254
ensurePendingTurnStart();
3255+
goalLifecycle.prepareTurn();
32173256
const sendPromptPromise = this.runWithProcessCheck(
32183257
() => this.codexAcpClient.sendPrompt(
32193258
effectiveParams,
@@ -3225,6 +3264,11 @@ export class CodexAcpServer {
32253264
sessionState.additionalDirectories,
32263265
(turnId) => {
32273266
const turn = {threadId: params.sessionId, turnId};
3267+
if (!goalLifecycle.startSubmittedTurn(turnId)) {
3268+
pendingTurnStart?.resolve(turnId);
3269+
onTurnStarted?.();
3270+
return;
3271+
}
32283272
activePrompt.currentTurn = turn;
32293273
if (this.promptShouldStop(params.sessionId, activePrompt)) {
32303274
this.interruptLateStartedTurn(turn);
@@ -3251,6 +3295,12 @@ export class CodexAcpServer {
32513295
return cancelledPromptResponse();
32523296
}
32533297

3298+
await this.codexAcpClient.waitForSessionNotifications(params.sessionId);
3299+
const firstPromptTurn = turnCompleted;
3300+
turnCompleted = await this.runWithProcessCheck(() => Promise.race([
3301+
goalLifecycle.waitForCompletion(firstPromptTurn), activePrompt.closeSignal,
3302+
]));
3303+
if (turnCompleted === null) return cancelledPromptResponse();
32543304
await this.codexAcpClient.waitForSessionNotifications(params.sessionId);
32553305
if (turnCompleted.turn.status === "completed") {
32563306
await eventHandler.waitForNativeSubagents(activePrompt.signal);
@@ -3314,6 +3364,7 @@ export class CodexAcpServer {
33143364
};
33153365
activePrompt.currentTurn = null;
33163366
sessionState.currentTurnId = null;
3367+
goalLifecycle.prepareTurn();
33173368
const implementationPromise = this.runWithProcessCheck(
33183369
() => this.codexAcpClient.sendPrompt(
33193370
implementationRequest,
@@ -3325,6 +3376,7 @@ export class CodexAcpServer {
33253376
sessionState.additionalDirectories,
33263377
(turnId) => {
33273378
const turn = {threadId: params.sessionId, turnId};
3379+
if (!goalLifecycle.startSubmittedTurn(turnId)) return;
33283380
activePrompt.currentTurn = turn;
33293381
if (this.promptShouldStop(params.sessionId, activePrompt)) {
33303382
this.interruptLateStartedTurn(turn);
@@ -3354,6 +3406,12 @@ export class CodexAcpServer {
33543406
return cancelledPromptResponse();
33553407
}
33563408

3409+
await this.codexAcpClient.waitForSessionNotifications(params.sessionId);
3410+
const firstImplementationTurn = turnCompleted;
3411+
turnCompleted = await this.runWithProcessCheck(() => Promise.race([
3412+
goalLifecycle.waitForCompletion(firstImplementationTurn), activePrompt.closeSignal,
3413+
]));
3414+
if (turnCompleted === null) return cancelledPromptResponse();
33573415
await this.codexAcpClient.waitForSessionNotifications(params.sessionId);
33583416
if (turnCompleted.turn.status === "completed") {
33593417
await eventHandler.waitForNativeSubagents(activePrompt.signal);
@@ -3445,6 +3503,16 @@ export class CodexAcpServer {
34453503
}
34463504
throw err;
34473505
} finally {
3506+
// A cancelled v1 prompt must not leave Codex's idle goal scheduler
3507+
// starting more foreground turns after the response is returned.
3508+
if ((promptWasCancelled || activePrompt.signal.aborted) && sessionState.currentGoal?.status === "active") {
3509+
try {
3510+
const goal = await this.runWithProcessCheck(() => this.codexAcpClient.setGoalStatus(params.sessionId, "paused"));
3511+
await this.publishGoalSnapshot(sessionState, toThreadGoalSnapshot(goal), false);
3512+
} catch (error) {
3513+
logger.error("Failed to pause goal after prompt cancellation", error);
3514+
}
3515+
}
34483516
// The app-server subscription is session-scoped and outlives this prompt. Flip routing before
34493517
// awaiting disposal so queued late notifications cannot enter prompt-local buffers.
34503518
promptNotificationsActive = false;
@@ -3469,6 +3537,9 @@ export class CodexAcpServer {
34693537
}
34703538
logger.log("Prompt completed", {sessionId: params.sessionId});
34713539
await eventHandler?.dispose();
3540+
disposeGoalConnection();
3541+
goalLifecycle.fail(new Error("ACP prompt closed"));
3542+
activePrompt.signal.removeEventListener("abort", cancelGoalLifecycle);
34723543
disposePromptRequestCancellation();
34733544
sessionState.currentTurnId = null;
34743545
const registeredPendingTurnStart = this.pendingTurnStarts.get(params.sessionId);
@@ -3601,6 +3672,14 @@ export class CodexAcpServer {
36013672
return;
36023673
}
36033674

3675+
const activePrompt = this.activePrompts.get(params.sessionId);
3676+
// There may be no native turn in the gap before automatic continuation.
3677+
// Abort the owning prompt without interrupting its already-completed turn.
3678+
if (activePrompt?.hasCompletedTurn && activePrompt.currentTurn === null) {
3679+
activePrompt.requestCancel();
3680+
return;
3681+
}
3682+
if (activePrompt?.currentTurn) activePrompt.requestCancel();
36043683
// After turnInterrupt(), Codex will send turn/completed, which naturally completes awaitTurnCompleted().
36053684
await this.interruptSessionTurn(sessionState, "Cancel", false);
36063685
}

‎src/CodexCommands.ts‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,14 @@ export const GOAL_CONTINUATION_PROMPT: acp.ContentBlock[] = [{
3636
/**
3737
* Slash `/goal set|resume` can finish a short Codex setup turn before real work
3838
* begins. Native Codex CLI continues active goals at idle via `on_thread_idle`;
39-
* Lody's UI `_session/goal` path chains continuation when `setGoal`/`resumeGoal`
40-
* return no turn. Slash commands must chain the same way when a setup turn
41-
* completes (or when no turn starts), otherwise the prompt ends with
42-
* `stopReason: end_turn` while the goal banner stays active. Codex may also
43-
* continue internally; this adapter keeps work bound to the slash prompt lifecycle.
39+
* If Codex started a turn, the ACP prompt follows its native goal continuations.
40+
* Only bootstrap with an explicit prompt when goal setup started no turn at all;
41+
* starting another turn after a completed setup races Codex's idle scheduler.
4442
*/
4543
export function resolveGoalCommandHandleResult(
4644
turnCompleted: TurnCompletedNotification | null,
4745
): CommandHandleResult {
48-
if (turnCompleted?.turn.status === "interrupted") {
46+
if (turnCompleted !== null) {
4947
return { handled: true, turnCompleted };
5048
}
5149
return { handled: false, prompt: GOAL_CONTINUATION_PROMPT };

‎src/GoalPromptLifecycle.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import type {ServerNotification} from "./app-server";
2+
import type {TurnCompletedNotification} from "./app-server/v2";
3+
4+
/**
5+
* One ACP v1 prompt can own several native Codex turns. Observe the native
6+
* continuation rather than starting another turn (Codex owns goal scheduling).
7+
* This observer exists only while a prompt is open; a stored goal alone never
8+
* starts a prompt or establishes live presence.
9+
*/
10+
export class GoalPromptLifecycle {
11+
private goalActive: boolean;
12+
private currentTurnId: string | null = null;
13+
private completed: TurnCompletedNotification | null = null;
14+
private failure: Error | null = null;
15+
private waiter: (() => void) | null = null;
16+
private cancelled = false;
17+
18+
constructor(private readonly sessionId: string, goalActive: boolean) {
19+
this.goalActive = goalActive;
20+
}
21+
22+
startTurn(turnId: string): void {
23+
if (this.currentTurnId === turnId) return;
24+
this.currentTurnId = turnId;
25+
this.completed = null;
26+
}
27+
28+
prepareTurn(): void {
29+
this.currentTurnId = null;
30+
this.completed = null;
31+
}
32+
33+
/** A delayed turn/start response must not overwrite already-observed native work. */
34+
startSubmittedTurn(turnId: string): boolean {
35+
if (this.currentTurnId !== null || this.completed !== null) return false;
36+
this.startTurn(turnId);
37+
return true;
38+
}
39+
40+
observe(event: ServerNotification): void {
41+
if (event.method === "thread/goal/updated" && event.params.threadId === this.sessionId) {
42+
this.goalActive = event.params.goal.status === "active";
43+
} else if (event.method === "thread/goal/cleared" && event.params.threadId === this.sessionId) {
44+
this.goalActive = false;
45+
} else if (event.method === "turn/started" && event.params.threadId === this.sessionId) {
46+
this.startTurn(event.params.turn.id);
47+
} else if (event.method === "turn/completed" && event.params.threadId === this.sessionId) {
48+
this.recordCompletion(event.params);
49+
} else {
50+
return;
51+
}
52+
this.waiter?.();
53+
}
54+
55+
private recordCompletion(event: TurnCompletedNotification): void {
56+
this.completed = event;
57+
if (this.currentTurnId === event.turn.id) this.currentTurnId = null;
58+
}
59+
60+
cancel(): void {
61+
this.cancelled = true;
62+
this.waiter?.();
63+
}
64+
65+
fail(error: Error): void {
66+
this.failure = error;
67+
this.waiter?.();
68+
}
69+
70+
async waitForCompletion(first: TurnCompletedNotification): Promise<TurnCompletedNotification | null> {
71+
// /review may execute on another thread, outside this session's goal.
72+
if (first.threadId !== this.sessionId) return first;
73+
// The serialized event handler may already have observed later turns by
74+
// the time runTurn's promise resumes. Never replace their state with A.
75+
if (this.completed === null) this.recordCompletion(first);
76+
const result = await new Promise<TurnCompletedNotification | null>((resolve, reject) => {
77+
this.waiter = () => {
78+
if (this.failure) reject(this.failure);
79+
else if (this.cancelled && this.currentTurnId === null) resolve(null);
80+
else if (this.completed && this.currentTurnId === null &&
81+
(!this.goalActive || this.completed.turn.status !== "completed")) {
82+
resolve(this.completed);
83+
}
84+
};
85+
this.waiter();
86+
}).finally(() => { this.waiter = null; });
87+
return result;
88+
}
89+
}

0 commit comments

Comments
 (0)