@@ -242,6 +242,8 @@ export class CodexAppServerAgent extends BaseAcpAgent {
242242 private workspaceDirectory ?: string ;
243243 /** The in-flight turn's <proposed_plan>, streamed or completed (drives the implement handoff). */
244244 private planProposal ?: { itemId : string ; text : string } ;
245+ /** Structured plan tool call already emitted while the proposal streams. */
246+ private streamedPlanToolCallId ?: string ;
245247 /** Idle signal deferred while the plan handoff keeps this prompt busy. */
246248 private deferredTurnComplete ?: { usage : PromptResponse [ "usage" ] } ;
247249 /** Settles the pending plan-approval race on cancel/close/preempting prompt. */
@@ -879,6 +881,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
879881 this . lastAgentMessage = "" ;
880882 this . resetUsage ( ) ;
881883 this . planProposal = undefined ;
884+ this . streamedPlanToolCallId = undefined ;
882885 // A new turn owns the idle boundary; its own completion emits the signal.
883886 this . deferredTurnComplete = undefined ;
884887 const { completion, turn } = this . turns . begin ( ) ;
@@ -936,13 +939,22 @@ export class CodexAppServerAgent extends BaseAcpAgent {
936939 // Re-check after the await: a cancel that raced the response wins, so a
937940 // late accept can never start implementation on a cancelled prompt.
938941 if ( this . session . cancelled ) {
942+ if ( outcome . kind === "implement" ) {
943+ this . completePlanApprovalToolCall ( outcome . toolCallId , "failed" ) ;
944+ }
939945 result = { ...result , stopReason : "cancelled" } ;
940946 break ;
941947 }
942948 // A picker change while approval was open owns the mode. Never let a
943949 // stale approval overwrite it with a broader implementation mode.
944- if ( this . config . mode !== "plan" ) break ;
950+ if ( this . config . mode !== "plan" ) {
951+ if ( outcome . kind === "implement" ) {
952+ this . completePlanApprovalToolCall ( outcome . toolCallId , "failed" ) ;
953+ }
954+ break ;
955+ }
945956 if ( outcome . kind === "implement" ) {
957+ this . completePlanApprovalToolCall ( outcome . toolCallId , "completed" ) ;
946958 this . config . setOption ( "mode" , outcome . mode ) ;
947959 this . emitCurrentMode ( outcome . mode ) ;
948960 this . emitConfigOptions ( ) ;
@@ -994,28 +1006,17 @@ export class CodexAppServerAgent extends BaseAcpAgent {
9941006 itemId : string ;
9951007 text : string ;
9961008 } ) : Promise <
997- | { kind : "implement" ; mode : "auto" | "full-access" }
1009+ | {
1010+ kind : "implement" ;
1011+ mode : "auto" | "full-access" ;
1012+ toolCallId : string ;
1013+ }
9981014 | { kind : "feedback" ; feedback : string }
9991015 | { kind : "stay" }
10001016 > {
10011017 const toolCallId = `${ proposal . itemId } :implement` ;
1002- const toolCall = {
1003- toolCallId,
1004- title : "Ready to code?" ,
1005- kind : "switch_mode" ,
1006- content : [
1007- {
1008- type : "content" as const ,
1009- content : { type : "text" as const , text : proposal . text } ,
1010- } ,
1011- ] ,
1012- rawInput : { plan : proposal . text } ,
1013- } ;
1014- await this . emitPlanApprovalToolCall ( {
1015- sessionUpdate : "tool_call" ,
1016- ...toolCall ,
1017- status : "in_progress" ,
1018- } ) ;
1018+ const toolCall = this . buildPlanApprovalToolCall ( proposal ) ;
1019+ this . emitPlanProposal ( toolCall , proposal . text ) ;
10191020 const options = [
10201021 {
10211022 optionId : "auto" ,
@@ -1059,11 +1060,11 @@ export class CodexAppServerAgent extends BaseAcpAgent {
10591060 const settled = await Promise . race ( [ permission , cancelled ] ) ;
10601061 this . planHandoffCancel = undefined ;
10611062 if ( ! settled ) {
1062- await this . completePlanApprovalToolCall ( toolCallId , "failed" ) ;
1063+ this . completePlanApprovalToolCall ( toolCallId , "failed" ) ;
10631064 return { kind : "stay" } ;
10641065 }
10651066 if ( settled . failed ) {
1066- await this . completePlanApprovalToolCall ( toolCallId , "failed" ) ;
1067+ this . completePlanApprovalToolCall ( toolCallId , "failed" ) ;
10671068 this . logger . warn ( "plan implementation prompt failed; staying in plan" , {
10681069 error : String ( settled . err ) ,
10691070 } ) ;
@@ -1075,61 +1076,106 @@ export class CodexAppServerAgent extends BaseAcpAgent {
10751076 }
10761077 const response = settled . res ;
10771078 if ( this . session . cancelled || response . outcome . outcome !== "selected" ) {
1078- await this . completePlanApprovalToolCall ( toolCallId , "failed" ) ;
1079+ this . completePlanApprovalToolCall ( toolCallId , "failed" ) ;
10791080 return { kind : "stay" } ;
10801081 }
10811082 const optionId = response . outcome . optionId ;
10821083 if ( ! offered . has ( optionId ) ) {
1083- await this . completePlanApprovalToolCall ( toolCallId , "failed" ) ;
1084+ this . completePlanApprovalToolCall ( toolCallId , "failed" ) ;
10841085 return { kind : "stay" } ;
10851086 }
10861087 if ( optionId === "auto" ) {
1087- await this . completePlanApprovalToolCall ( toolCallId , "completed" ) ;
1088- return { kind : "implement" , mode : "auto" } ;
1088+ return { kind : "implement" , mode : "auto" , toolCallId } ;
10891089 }
10901090 // Double-gated: only ever offered under ALLOW_BYPASS, and re-checked here.
10911091 if ( optionId === "full-access" && ALLOW_BYPASS ) {
1092- await this . completePlanApprovalToolCall ( toolCallId , "completed" ) ;
1093- return { kind : "implement" , mode : "full-access" } ;
1092+ return { kind : "implement" , mode : "full-access" , toolCallId } ;
10941093 }
10951094 if ( optionId === "reject_with_feedback" ) {
10961095 const feedback = ( response as { _meta ?: { customInput ?: unknown } } ) . _meta
10971096 ?. customInput ;
10981097 if ( typeof feedback === "string" && feedback . trim ( ) ) {
1099- await this . completePlanApprovalToolCall ( toolCallId , "failed" ) ;
1098+ this . completePlanApprovalToolCall ( toolCallId , "failed" ) ;
11001099 return { kind : "feedback" , feedback : feedback . trim ( ) } ;
11011100 }
11021101 }
1103- await this . completePlanApprovalToolCall ( toolCallId , "failed" ) ;
1102+ this . completePlanApprovalToolCall ( toolCallId , "failed" ) ;
11041103 return { kind : "stay" } ;
11051104 }
11061105
1107- private async completePlanApprovalToolCall (
1106+ private buildPlanApprovalToolCall ( proposal : {
1107+ itemId : string ;
1108+ text : string ;
1109+ } ) : {
1110+ toolCallId : string ;
1111+ title : string ;
1112+ kind : "switch_mode" ;
1113+ content : Array < {
1114+ type : "content" ;
1115+ content : { type : "text" ; text : string } ;
1116+ } > ;
1117+ rawInput : { plan : string } ;
1118+ } {
1119+ return {
1120+ toolCallId : `${ proposal . itemId } :implement` ,
1121+ title : "Ready to code?" ,
1122+ kind : "switch_mode" ,
1123+ content : [
1124+ {
1125+ type : "content" ,
1126+ content : { type : "text" , text : proposal . text } ,
1127+ } ,
1128+ ] ,
1129+ rawInput : { plan : proposal . text } ,
1130+ } ;
1131+ }
1132+
1133+ private emitPlanProposal (
1134+ toolCall : ReturnType < CodexAppServerAgent [ "buildPlanApprovalToolCall" ] > ,
1135+ text : string ,
1136+ ) : void {
1137+ if ( this . streamedPlanToolCallId === toolCall . toolCallId ) {
1138+ this . emitPlanApprovalToolCall ( {
1139+ sessionUpdate : "tool_call_update" ,
1140+ toolCallId : toolCall . toolCallId ,
1141+ status : "in_progress" ,
1142+ content : [ { type : "content" , content : { type : "text" , text } } ] ,
1143+ rawInput : { plan : text } ,
1144+ } ) ;
1145+ return ;
1146+ }
1147+ this . streamedPlanToolCallId = toolCall . toolCallId ;
1148+ this . emitPlanApprovalToolCall ( {
1149+ sessionUpdate : "tool_call" ,
1150+ ...toolCall ,
1151+ status : "in_progress" ,
1152+ } ) ;
1153+ }
1154+
1155+ private completePlanApprovalToolCall (
11081156 toolCallId : string ,
11091157 status : "completed" | "failed" ,
1110- ) : Promise < void > {
1111- await this . emitPlanApprovalToolCall ( {
1158+ ) : void {
1159+ this . emitPlanApprovalToolCall ( {
11121160 sessionUpdate : "tool_call_update" ,
11131161 toolCallId,
11141162 status,
11151163 } ) ;
11161164 }
11171165
1118- private async emitPlanApprovalToolCall (
1119- update : Record < string , unknown > ,
1120- ) : Promise < void > {
1121- try {
1122- await this . client . sessionUpdate ( {
1123- sessionId : this . sessionId ,
1124- update,
1125- } as unknown as Parameters < AgentSideConnection [ "sessionUpdate" ] > [ 0 ] ) ;
1126- } catch ( error ) {
1166+ private emitPlanApprovalToolCall ( update : Record < string , unknown > ) : void {
1167+ const notification = {
1168+ sessionId : this . sessionId ,
1169+ update,
1170+ } as unknown as Parameters < AgentSideConnection [ "sessionUpdate" ] > [ 0 ] ;
1171+ this . appendNotification ( this . sessionId , notification ) ;
1172+ void this . client . sessionUpdate ( notification ) . catch ( ( error ) => {
11271173 this . logger . warn ( "Failed to emit plan approval tool call update" , {
11281174 error : String ( error ) ,
11291175 sessionUpdate : update . sessionUpdate ,
11301176 toolCallId : update . toolCallId ,
11311177 } ) ;
1132- }
1178+ } ) ;
11331179 }
11341180
11351181 /** Emit a plain agent message (user-facing status the model didn't produce). */
@@ -1414,6 +1460,12 @@ export class CodexAppServerAgent extends BaseAcpAgent {
14141460 ) ?. item ;
14151461 if ( item ?. type === "plan" && typeof item . text === "string" && item . text ) {
14161462 this . planProposal = { itemId : item . id ?? "codex-plan" , text : item . text } ;
1463+ if ( this . config . mode === "plan" && this . streamedPlanToolCallId ) {
1464+ this . emitPlanProposal (
1465+ this . buildPlanApprovalToolCall ( this . planProposal ) ,
1466+ this . planProposal . text ,
1467+ ) ;
1468+ }
14171469 }
14181470 }
14191471
@@ -1431,6 +1483,12 @@ export class CodexAppServerAgent extends BaseAcpAgent {
14311483 const previousText =
14321484 this . planProposal ?. itemId === proposalId ? this . planProposal . text : "" ;
14331485 this . planProposal = { itemId : proposalId , text : previousText + delta } ;
1486+ if ( this . config . mode === "plan" ) {
1487+ this . emitPlanProposal (
1488+ this . buildPlanApprovalToolCall ( this . planProposal ) ,
1489+ this . planProposal . text ,
1490+ ) ;
1491+ }
14341492 }
14351493
14361494 /** Compaction started: emit `_posthog/status` so the host sets `isCompacting` (gates steer/queue). */
0 commit comments