Skip to content

Commit 5be2648

Browse files
committed
CR: startNewTurnFromSteering revamp
1 parent 4ba52f8 commit 5be2648

1 file changed

Lines changed: 102 additions & 44 deletions

File tree

src/CodexAcpServer.ts

Lines changed: 102 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -925,80 +925,138 @@ export class CodexAcpServer {
925925
return queue;
926926
}
927927

928+
/**
929+
* Delivers a steering prompt to the session: injects it into the live turn
930+
* when there is one, otherwise starts a new turn.
931+
*
932+
* @param params The target session id and the prompt to steer with.
933+
* @returns "injected" when the prompt joined an existing turn, otherwise the
934+
* outcome of starting a new turn.
935+
*/
928936
private async performSteeringRequest(params: SessionSteerRequest): Promise<SessionSteeringResponse> {
929937
logger.log("Steering session requested", {
930938
sessionId: params.sessionId,
931939
prompt: params.prompt,
932940
});
933941
const sessionState = this.getSessionState(params.sessionId);
942+
this.assertSteerInputSupported(params, sessionState);
934943

935-
if (!sessionState.supportedInputModalities.includes("image") && params.prompt.some(b => b.type === "image")) {
936-
throw RequestError.invalidRequest("The current model does not support image input");
944+
const turnId = await this.getSteerableTurnId(sessionState);
945+
if (turnId) {
946+
const injected = await this.injectSteerIntoActiveTurn(params, turnId, sessionState);
947+
if (injected) {
948+
logger.log("Steering session injected", {sessionId: params.sessionId, turnId});
949+
return {outcome: "injected"};
950+
}
937951
}
952+
return await this.startNewTurnFromSteering(params);
953+
}
938954

939-
const activePrompt = this.activePrompts.get(params.sessionId);
940-
const turnId = await this.getSteerableTurnId(sessionState);
941-
if (!turnId) {
942-
return await this.startNewTurnFromSteering(params, activePrompt);
955+
/**
956+
* Rejects a steering prompt whose content the active model cannot accept
957+
* (currently: image blocks on a text-only model).
958+
*/
959+
private assertSteerInputSupported(params: SessionSteerRequest, sessionState: SessionState): void {
960+
const hasImage = params.prompt.some(block => block.type === "image");
961+
if (hasImage && !sessionState.supportedInputModalities.includes("image")) {
962+
throw RequestError.invalidRequest("The current model does not support image input");
943963
}
964+
}
944965

966+
/**
967+
* Attempts to inject the prompt into the given running turn.
968+
*
969+
* A failed injection is fatal only when the turn is still the session's
970+
* current turn and Codex reported something other than "no active turn to
971+
* steer". Otherwise the turn has already ended underneath us and the caller
972+
* should start a new turn instead.
973+
*
974+
* @returns true when the prompt was injected; false when the caller should
975+
* fall back to starting a new turn.
976+
*/
977+
private async injectSteerIntoActiveTurn(
978+
params: SessionSteerRequest,
979+
turnId: string,
980+
sessionState: SessionState,
981+
): Promise<boolean> {
945982
try {
946983
await this.runWithProcessCheck(() => this.codexAcpClient.steerTurn({
947984
threadId: params.sessionId,
948985
turnId,
949986
prompt: params.prompt,
950987
}));
988+
return true;
951989
} catch (err) {
952990
await this.codexAcpClient.waitForSessionNotifications(params.sessionId);
953-
if (sessionState.currentTurnId === turnId && !this.isNoActiveTurnToSteerError(err)) {
991+
const turnStillActive = sessionState.currentTurnId === turnId;
992+
if (turnStillActive && !this.isNoActiveTurnToSteerError(err)) {
954993
throw err;
955994
}
956-
return await this.startNewTurnFromSteering(params, activePrompt);
995+
return false;
957996
}
958-
959-
logger.log("Steering session injected", {
960-
sessionId: params.sessionId,
961-
turnId,
962-
});
963-
return {outcome: "injected"};
964997
}
965998

966-
private async startNewTurnFromSteering(
967-
params: SessionSteerRequest,
968-
previousPrompt: ActivePrompt | undefined,
969-
): Promise<SessionSteeringResponse> {
999+
/**
1000+
* Starts a new turn from a steering prompt when there is no live turn to
1001+
* inject into, and returns as soon as that turn is running.
1002+
*
1003+
* Waits for any previous prompt to drain first, then re-checks that the
1004+
* session is not closing — the await above is a window during which a close
1005+
* request can arrive.
1006+
*
1007+
* @param params The target session id and the prompt to steer with.
1008+
* @returns "startedNewTurn" once the turn is running; throws if the prompt
1009+
* fails or is cancelled before the turn starts.
1010+
*/
1011+
private async startNewTurnFromSteering(params: SessionSteerRequest): Promise<SessionSteeringResponse> {
1012+
// A prompt can outlive its turn (post-turn cleanup runs before it leaves
1013+
// activePrompts), so a steer can miss the turn while the prompt is still
1014+
// winding down. Starting a new turn now would run a second prompt on the
1015+
// same session, so wait for the current one to drain first (a no-op when idle).
1016+
const previousPrompt = this.activePrompts.get(params.sessionId);
9701017
await previousPrompt?.completion;
9711018
if (this.sessionIsClosing(params.sessionId)) {
9721019
throw RequestError.invalidRequest(`Session ${params.sessionId} is closing`);
9731020
}
9741021

975-
let resolveTurnStarted: () => void = () => {};
976-
const turnStarted = new Promise<void>((resolve) => {
977-
resolveTurnStarted = resolve;
978-
});
979-
const promptResult = this.prompt(params, undefined, resolveTurnStarted).then(
980-
(response) => ({status: "completed" as const, response}),
981-
(error: unknown) => ({status: "failed" as const, error}),
982-
);
983-
const acceptance = await Promise.race([
984-
turnStarted.then(() => ({status: "started" as const})),
985-
promptResult,
986-
]);
987-
988-
if (acceptance.status === "failed") {
989-
throw acceptance.error;
990-
}
991-
if (acceptance.status === "completed" && acceptance.response.stopReason === "cancelled") {
992-
throw RequestError.invalidRequest(`Session ${params.sessionId} was cancelled before the steering turn started`);
993-
}
994-
995-
void promptResult.then((result) => {
996-
if (result.status === "failed") {
997-
logger.error(`Steering-started prompt for session ${params.sessionId} failed`, result.error);
998-
}
1022+
return await new Promise<SessionSteeringResponse>((resolve, reject) => {
1023+
let turnStarted = false;
1024+
const promptDone = this.prompt(params, undefined, () => {
1025+
turnStarted = true;
1026+
logger.log("Steering session started a new turn", {sessionId: params.sessionId});
1027+
// The new turn is now running. This is the success path: answer the
1028+
// steer immediately ("a turn was started") and let prompt() finish the
1029+
// turn in the background.
1030+
resolve({outcome: "startedNewTurn"});
1031+
});
1032+
promptDone.then(
1033+
(response) => {
1034+
if (!turnStarted && response.stopReason === "cancelled") {
1035+
// The prompt ended without the turn ever starting, because it
1036+
// was cancelled. The steer never took, so fail the request.
1037+
reject(RequestError.invalidRequest(`Session ${params.sessionId} was cancelled before the steering turn started`));
1038+
} else {
1039+
// Either the turn already started (this is a no-op after the
1040+
// resolve in the callback above), or the prompt finished
1041+
// without ever starting a turn and was not cancelled (e.g. a
1042+
// command-only turn). Both count as a successfully accepted steer.
1043+
resolve({outcome: "startedNewTurn"});
1044+
}
1045+
},
1046+
(error: unknown) => {
1047+
if (turnStarted) {
1048+
// The turn had already started, so the steer was already
1049+
// answered "startedNewTurn". This is a failure of a turn running
1050+
// in the background — nothing to return, just log it.
1051+
logger.error(`Steering-started prompt for session ${params.sessionId} failed`, error);
1052+
} else {
1053+
// The prompt failed before the turn started. The steer never
1054+
// took, so surface the failure to the caller.
1055+
reject(error);
1056+
}
1057+
},
1058+
);
9991059
});
1000-
logger.log("Steering session started a new turn", {sessionId: params.sessionId});
1001-
return {outcome: "startedNewTurn"};
10021060
}
10031061

10041062
private isNoActiveTurnToSteerError(error: unknown): boolean {

0 commit comments

Comments
 (0)