@@ -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