@@ -60,6 +60,8 @@ const CANCEL_INTERRUPT_TIMEOUT_MS = 1_500;
6060const COMPACT_START_ACCEPT_TIMEOUT_MS = 15_000 ;
6161const COMPACT_NO_SIGNAL_SETTLE_MS = 5_000 ;
6262const COMPACT_HARD_TIMEOUT_MS = 120_000 ;
63+ const TERMINATED_TURN_CACHE_LIMIT = 200 ;
64+ const TERMINATED_COMPACT_TURN_CACHE_LIMIT = 80 ;
6365const DEFAULT_CODEX_SDK_CONTEXT_INJECTION_MAX_CHARS = 32_000 ;
6466const MIN_CODEX_SDK_CONTEXT_INJECTION_MAX_CHARS = 4_000 ;
6567const MAX_CODEX_SDK_CONTEXT_INJECTION_MAX_CHARS = 128_000 ;
@@ -435,6 +437,8 @@ interface CodexSdkSessionState {
435437 pendingSessionSystemTextUpdateTurnId ?: string ;
436438 completedTurnIds : Set < string > ;
437439 completedCompactTurnIds : Set < string > ;
440+ terminatedTurnIds : Set < string > ;
441+ terminatedCompactTurnIds : Set < string > ;
438442 generatedImageTracking : GeneratedImageTrackingSnapshot | null ;
439443 generatedImagePaths : string [ ] ;
440444 rawChecklistStartedAt : number ;
@@ -1570,6 +1574,8 @@ export class CodexSdkProvider implements TransportProvider {
15701574 pendingSessionSystemTextUpdateTurnId : undefined ,
15711575 completedTurnIds : existing ?. completedTurnIds ?? new Set ( ) ,
15721576 completedCompactTurnIds : existing ?. completedCompactTurnIds ?? new Set ( ) ,
1577+ terminatedTurnIds : existing ?. terminatedTurnIds ?? new Set ( ) ,
1578+ terminatedCompactTurnIds : existing ?. terminatedCompactTurnIds ?? new Set ( ) ,
15731579 generatedImageTracking : null ,
15741580 generatedImagePaths : [ ] ,
15751581 rawChecklistStartedAt : Date . now ( ) ,
@@ -1739,6 +1745,7 @@ export class CodexSdkProvider implements TransportProvider {
17391745 if ( ! this . sessions . has ( sessionId ) ) return ;
17401746 if ( state . runningTurnId !== turnId ) return ;
17411747 this . clearStatus ( sessionId , state ) ;
1748+ this . rememberTerminatedTurn ( state , turnId ) ;
17421749 state . runningTurnId = undefined ;
17431750 state . turnStartInFlight = false ; state . activeItemIds . clear ( ) ;
17441751 this . clearRawChecklistPollTimer ( state ) ;
@@ -1761,6 +1768,7 @@ export class CodexSdkProvider implements TransportProvider {
17611768 this . clearCompactTimers ( state ) ; this . clearRawChecklistPollTimer ( state ) ;
17621769 if ( ! options . clearSessions ) {
17631770 this . clearStatus ( sessionId , state ) ;
1771+ this . rememberTerminatedActiveTurn ( state ) ;
17641772 state . loaded = false ;
17651773 state . runningTurnId = undefined ;
17661774 state . turnStartInFlight = false ;
@@ -1926,6 +1934,7 @@ export class CodexSdkProvider implements TransportProvider {
19261934 state . nativePlanEventSeen = false ;
19271935 if ( state . runningTurnId ) {
19281936 state . completedTurnIds . delete ( state . runningTurnId ) ;
1937+ state . terminatedTurnIds . delete ( state . runningTurnId ) ;
19291938 }
19301939 if ( shouldInjectStableUpdate ) {
19311940 state . pendingSessionSystemTextUpdate = desiredSessionSystemText ;
@@ -1936,6 +1945,7 @@ export class CodexSdkProvider implements TransportProvider {
19361945 }
19371946 if ( state . runningTurnId ) this . armRawChecklistPolling ( sessionId , state ) ;
19381947 } catch ( err ) {
1948+ this . rememberTerminatedTurn ( state , state . runningTurnId ) ;
19391949 state . runningTurnId = undefined ;
19401950 state . turnStartInFlight = false ; state . activeItemIds . clear ( ) ;
19411951 this . clearRawChecklistPollTimer ( state ) ;
@@ -1990,6 +2000,7 @@ export class CodexSdkProvider implements TransportProvider {
19902000 }
19912001 } catch ( err ) {
19922002 this . clearCompactTimers ( state ) ; this . clearStatus ( sessionId , state ) ;
2003+ this . rememberTerminatedCompactTurn ( state , state . runningTurnId ) ;
19932004 state . runningCompact = false ;
19942005 state . runningTurnId = undefined ;
19952006 state . turnStartInFlight = false ;
@@ -2557,6 +2568,7 @@ export class CodexSdkProvider implements TransportProvider {
25572568 const state = sessionId ? this . sessions . get ( sessionId ) : null ;
25582569 if ( ! sessionId || ! state ) return ;
25592570 const turnId = readParamTurnId ( params ) ;
2571+ if ( turnId && ( state . cancelled || this . isClosedCodexTurn ( state , turnId ) ) ) return ;
25602572 if ( turnId && state . runningTurnId && turnId !== state . runningTurnId ) return ;
25612573 // Native plan event (codex >= 0.139). Render it AND suppress the legacy
25622574 // rollout-file scan for this session so old (file-scrape) + new never
@@ -2576,14 +2588,15 @@ export class CodexSdkProvider implements TransportProvider {
25762588 if ( ! sessionId || ! state ) return ;
25772589 if ( state . cancelled ) return ;
25782590 const turnId = readParamTurnId ( params ) ;
2579- if ( turnId && state . completedTurnIds . has ( turnId ) ) return ;
2591+ const closedTurn = this . isClosedCodexTurn ( state , turnId ) ;
25802592 // NEVER drop live assistant text. If our turn bookkeeping lags the
25812593 // app-server (turn/start's result carried no turn id, so runningTurnId was
25822594 // never set, or this delta's turnId is shaped differently), adopt the
25832595 // delta's turnId and render anyway — a real text update must always reach
2584- // the UI. Only a genuinely-completed turn (above) or an explicit cancel skips.
2585- if ( turnId && ! state . runningTurnId ) state . runningTurnId = turnId ;
2586- this . clearStatus ( sessionId , state ) ;
2596+ // the UI. Closed/terminated turns may still render late text, but they
2597+ // must never be adopted back into running state.
2598+ if ( turnId && ! closedTurn && ! state . runningTurnId ) state . runningTurnId = turnId ;
2599+ if ( ! closedTurn ) this . clearStatus ( sessionId , state ) ;
25872600 // Reset the streaming accumulator when a new agentMessage item starts so
25882601 // its deltas don't render prefixed with the previous message's full text
25892602 // (multi-message turns occur after every tool round). Guards the case
@@ -2609,17 +2622,20 @@ export class CodexSdkProvider implements TransportProvider {
26092622 const state = sessionId ? this . sessions . get ( sessionId ) : null ;
26102623 if ( ! sessionId || ! state ) return ;
26112624 const turnId = readParamTurnId ( params ) ;
2612- if ( turnId && ( state . completedTurnIds . has ( turnId ) || state . completedCompactTurnIds . has ( turnId ) ) ) return ;
2625+ if ( state . cancelled ) return ;
2626+ const closedTurn = this . isClosedCodexTurn ( state , turnId ) ;
26132627
26142628 const item = params . item as Record < string , any > | undefined ;
26152629 if ( ! item ) return ;
2630+ if ( closedTurn && item . type !== 'agentMessage' ) return ;
26162631 // NEVER drop a real provider item. If our turn bookkeeping lags the
26172632 // app-server (turn/start's result carried no turn id, or this event's
26182633 // turnId is shaped differently), adopt the event's turnId and process it
26192634 // anyway rather than silently dropping tool calls / reasoning / final
2620- // assistant text. Completed turns (above) and explicit cancels still skip.
2621- if ( turnId && ! state . runningTurnId ) state . runningTurnId = turnId ;
2622- this . trackCodexTurnItemActivity ( sessionId , state , method , item ) ;
2635+ // assistant text. Closed/terminated turns may still surface final
2636+ // assistant text, but they must never be adopted back into running state.
2637+ if ( turnId && ! closedTurn && ! state . runningTurnId ) state . runningTurnId = turnId ;
2638+ if ( ! closedTurn ) this . trackCodexTurnItemActivity ( sessionId , state , method , item ) ;
26232639
26242640 if ( item . type === 'contextCompaction' ) {
26252641 state . runningCompact = true ;
@@ -2637,8 +2653,6 @@ export class CodexSdkProvider implements TransportProvider {
26372653 return ;
26382654 }
26392655
2640- if ( state . cancelled ) return ;
2641-
26422656 if ( item . type === 'reasoning' ) {
26432657 this . emitStatus ( sessionId , state , {
26442658 status : 'thinking' ,
@@ -2647,7 +2661,7 @@ export class CodexSdkProvider implements TransportProvider {
26472661 return ;
26482662 }
26492663
2650- this . clearStatus ( sessionId , state ) ;
2664+ if ( ! closedTurn ) this . clearStatus ( sessionId , state ) ;
26512665
26522666 const tool = toolFromItem ( sessionId , item , method === 'item/started' ? 'started' : 'completed' ) ;
26532667 if ( tool ) {
@@ -2689,14 +2703,15 @@ export class CodexSdkProvider implements TransportProvider {
26892703 const status = turn . status ;
26902704 const turnId = readParamTurnId ( params ) ;
26912705
2692- if ( turnId && state . completedCompactTurnIds . has ( turnId ) ) {
2706+ if ( turnId && this . isClosedCompactTurn ( state , turnId ) ) {
26932707 return ;
26942708 }
2695- if ( turnId && state . completedTurnIds . has ( turnId ) ) {
2709+ if ( turnId && this . isClosedTurn ( state , turnId ) ) {
26962710 return ;
26972711 }
26982712
26992713 if ( status === 'failed' ) {
2714+ this . rememberTerminatedActiveTurn ( state , turnId ) ;
27002715 this . clearCancelTimer ( state ) ;
27012716 this . clearCompactTimers ( state ) ;
27022717 this . clearRawChecklistPollTimer ( state ) ;
@@ -2716,6 +2731,7 @@ export class CodexSdkProvider implements TransportProvider {
27162731 return ;
27172732 }
27182733 if ( status === 'interrupted' ) {
2734+ this . rememberTerminatedActiveTurn ( state , turnId ) ;
27192735 this . clearCancelTimer ( state ) ;
27202736 this . clearCompactTimers ( state ) ;
27212737 this . clearRawChecklistPollTimer ( state ) ;
@@ -2740,6 +2756,7 @@ export class CodexSdkProvider implements TransportProvider {
27402756 }
27412757
27422758 if ( state . cancelled ) {
2759+ this . rememberTerminatedActiveTurn ( state , turnId ) ;
27432760 this . clearCancelTimer ( state ) ;
27442761 this . clearRawChecklistPollTimer ( state ) ;
27452762 this . clearStatus ( sessionId , state ) ;
@@ -2824,6 +2841,29 @@ export class CodexSdkProvider implements TransportProvider {
28242841 if ( oldest ) state . completedTurnIds . delete ( oldest ) ;
28252842 }
28262843
2844+ private rememberTerminatedTurn ( state : CodexSdkSessionState , turnId ?: string ) : void {
2845+ if ( ! turnId ) return ;
2846+ state . terminatedTurnIds . add ( turnId ) ;
2847+ if ( state . terminatedTurnIds . size <= TERMINATED_TURN_CACHE_LIMIT ) return ;
2848+ const oldest = state . terminatedTurnIds . values ( ) . next ( ) . value ;
2849+ if ( oldest ) state . terminatedTurnIds . delete ( oldest ) ;
2850+ }
2851+
2852+ private isClosedTurn ( state : CodexSdkSessionState , turnId ?: string ) : boolean {
2853+ return Boolean ( turnId && ( state . completedTurnIds . has ( turnId ) || state . terminatedTurnIds . has ( turnId ) ) ) ;
2854+ }
2855+
2856+ private isClosedCompactTurn ( state : CodexSdkSessionState , turnId ?: string ) : boolean {
2857+ return Boolean ( turnId && (
2858+ state . completedCompactTurnIds . has ( turnId )
2859+ || state . terminatedCompactTurnIds . has ( turnId )
2860+ ) ) ;
2861+ }
2862+
2863+ private isClosedCodexTurn ( state : CodexSdkSessionState , turnId ?: string ) : boolean {
2864+ return this . isClosedTurn ( state , turnId ) || this . isClosedCompactTurn ( state , turnId ) ;
2865+ }
2866+
28272867 private request ( method : string , params : Record < string , any > , timeoutMs ?: number ) : Promise < any > {
28282868 if ( ! this . child ?. stdin . writable ) {
28292869 return Promise . reject ( new Error ( 'Codex app-server stdin is not writable' ) ) ;
@@ -2893,6 +2933,24 @@ export class CodexSdkProvider implements TransportProvider {
28932933 if ( oldest ) state . completedCompactTurnIds . delete ( oldest ) ;
28942934 }
28952935
2936+ private rememberTerminatedCompactTurn ( state : CodexSdkSessionState , turnId ?: string ) : void {
2937+ if ( ! turnId ) return ;
2938+ state . terminatedCompactTurnIds . add ( turnId ) ;
2939+ if ( state . terminatedCompactTurnIds . size <= TERMINATED_COMPACT_TURN_CACHE_LIMIT ) return ;
2940+ const oldest = state . terminatedCompactTurnIds . values ( ) . next ( ) . value ;
2941+ if ( oldest ) state . terminatedCompactTurnIds . delete ( oldest ) ;
2942+ }
2943+
2944+ private rememberTerminatedActiveTurn ( state : CodexSdkSessionState , turnId ?: string ) : void {
2945+ const resolvedTurnId = turnId ?? state . runningTurnId ;
2946+ if ( ! resolvedTurnId ) return ;
2947+ if ( state . runningCompact ) {
2948+ this . rememberTerminatedCompactTurn ( state , resolvedTurnId ) ;
2949+ return ;
2950+ }
2951+ this . rememberTerminatedTurn ( state , resolvedTurnId ) ;
2952+ }
2953+
28962954 /**
28972955 * Expose the `account/rateLimits/read` RPC over the already-connected
28982956 * app-server so callers (e.g. the daemon's rate-limit probe) can reuse
@@ -3030,6 +3088,7 @@ export class CodexSdkProvider implements TransportProvider {
30303088 this . clearCancelTimer ( state ) ;
30313089 this . clearCompactTimers ( state ) ;
30323090 this . clearRawChecklistPollTimer ( state ) ; this . clearStatus ( sessionId , state ) ;
3091+ this . rememberTerminatedCompactTurn ( state , state . runningTurnId ) ;
30333092 state . runningCompact = false ;
30343093 state . runningTurnId = undefined ;
30353094 state . turnStartInFlight = false ;
@@ -3107,6 +3166,7 @@ export class CodexSdkProvider implements TransportProvider {
31073166 if ( ! this . sessions . has ( sessionId ) ) return ;
31083167 if ( ! state . runningCompact ) return ;
31093168 this . clearCompactTimers ( state ) ; this . clearStatus ( sessionId , state ) ;
3169+ this . rememberTerminatedCompactTurn ( state , state . runningTurnId ) ;
31103170 state . runningCompact = false ;
31113171 state . runningTurnId = undefined ;
31123172 state . turnStartInFlight = false ;
0 commit comments