@@ -59,8 +59,37 @@ const asTurnId = (value: string): TurnId => TurnId.make(value);
5959const asEventId = ( value : string ) : EventId => EventId . make ( value ) ;
6060const asItemId = ( value : string ) : ProviderItemId => ProviderItemId . make ( value ) ;
6161
62+ class TestQueue < A > {
63+ private readonly values : Array < A > = [ ] ;
64+ private readonly waiters : Array < ( value : A ) => void > = [ ] ;
65+
66+ offer ( value : A ) : void {
67+ const waiter = this . waiters . shift ( ) ;
68+ if ( waiter ) {
69+ waiter ( value ) ;
70+ return ;
71+ }
72+ this . values . push ( value ) ;
73+ }
74+
75+ take ( ) : Effect . Effect < A > {
76+ return Effect . promise (
77+ ( ) =>
78+ new Promise < A > ( ( resolve ) => {
79+ const value = this . values . shift ( ) ;
80+ if ( value !== undefined ) {
81+ resolve ( value ) ;
82+ return ;
83+ }
84+ this . waiters . push ( resolve ) ;
85+ } ) ,
86+ ) ;
87+ }
88+ }
89+
6290class FakeCodexRuntime implements CodexSessionRuntimeShape {
63- private readonly eventQueue = Effect . runSync ( Queue . unbounded < ProviderEvent > ( ) ) ;
91+ public readonly closed = new TestQueue < true > ( ) ;
92+ public readonly sentTurns = new TestQueue < CodexSessionRuntimeSendTurnInput > ( ) ;
6493 private readonly now = "2026-01-01T00:00:00.000Z" ;
6594
6695 public readonly startImpl = vi . fn ( ( ) =>
@@ -71,6 +100,7 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {
71100 threadId : this . options . threadId ,
72101 cwd : this . options . cwd ,
73102 ...( this . options . model ? { model : this . options . model } : { } ) ,
103+ resumeCursor : this . options . resumeCursor ?? { threadId : "provider-thread-1" } ,
74104 createdAt : this . now ,
75105 updatedAt : this . now ,
76106 } satisfies ProviderSession ) ,
@@ -114,12 +144,17 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {
114144 Promise . resolve ( undefined ) ,
115145 ) ;
116146
117- public readonly closeImpl = vi . fn ( ( ) => Promise . resolve ( undefined ) ) ;
147+ public readonly closeImpl = vi . fn ( ( ) => {
148+ this . closed . offer ( true ) ;
149+ return Promise . resolve ( undefined ) ;
150+ } ) ;
118151
119152 readonly options : CodexSessionRuntimeOptions ;
153+ private readonly eventQueue : Queue . Queue < ProviderEvent > ;
120154
121- constructor ( options : CodexSessionRuntimeOptions ) {
155+ constructor ( options : CodexSessionRuntimeOptions , eventQueue : Queue . Queue < ProviderEvent > ) {
122156 this . options = options ;
157+ this . eventQueue = eventQueue ;
123158 }
124159
125160 start ( ) {
@@ -129,6 +164,7 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {
129164 getSession = Effect . promise ( ( ) => this . startImpl ( ) ) ;
130165
131166 sendTurn ( input : CodexSessionRuntimeSendTurnInput ) {
167+ this . sentTurns . offer ( input ) ;
132168 return Effect . promise ( ( ) => this . sendTurnImpl ( input ) ) ;
133169 }
134170
@@ -161,16 +197,26 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {
161197 }
162198}
163199
164- function makeRuntimeFactory ( ) {
200+ function makeRuntimeFactory ( configure ?: ( runtime : FakeCodexRuntime , index : number ) => void ) {
165201 const runtimes : Array < FakeCodexRuntime > = [ ] ;
166- const factory = vi . fn ( ( options : CodexSessionRuntimeOptions ) => {
167- const runtime = new FakeCodexRuntime ( options ) ;
168- runtimes . push ( runtime ) ;
169- return Effect . succeed ( runtime ) ;
170- } ) ;
202+ const created = new TestQueue < FakeCodexRuntime > ( ) ;
203+ const factory = vi . fn ( ( options : CodexSessionRuntimeOptions ) =>
204+ Effect . gen ( function * ( ) {
205+ const eventQueue = yield * Queue . unbounded < ProviderEvent > ( ) ;
206+ const runtime = new FakeCodexRuntime ( options , eventQueue ) ;
207+ runtimes . push ( runtime ) ;
208+ configure ?.( runtime , runtimes . length - 1 ) ;
209+ created . offer ( runtime ) ;
210+ return runtime ;
211+ } ) ,
212+ ) ;
171213
172214 return {
173215 factory,
216+ created,
217+ get runtimes ( ) : ReadonlyArray < FakeCodexRuntime > {
218+ return runtimes ;
219+ } ,
174220 get lastRuntime ( ) : FakeCodexRuntime | undefined {
175221 return runtimes . at ( - 1 ) ;
176222 } ,
@@ -197,7 +243,8 @@ function makeScopedRuntimeFactory(options?: { readonly failConstruction?: boolea
197243 } ) ;
198244 }
199245
200- const runtime = new FakeCodexRuntime ( runtimeOptions ) ;
246+ const eventQueue = yield * Queue . unbounded < ProviderEvent > ( ) ;
247+ const runtime = new FakeCodexRuntime ( runtimeOptions , eventQueue ) ;
201248 runtimes . push ( runtime ) ;
202249 return runtime ;
203250 } ) ,
@@ -512,6 +559,268 @@ function startLifecycleRuntime() {
512559 } ) ;
513560}
514561
562+ function usageLimitCompletionEvent ( input ?: {
563+ readonly turnId ?: string ;
564+ readonly items ?: ReadonlyArray < unknown > ;
565+ } ) : ProviderEvent {
566+ const turnId = input ?. turnId ?? "turn-1" ;
567+ return {
568+ id : asEventId ( `evt-usage-limit-${ turnId } ` ) ,
569+ kind : "notification" ,
570+ provider : ProviderDriverKind . make ( "codex" ) ,
571+ createdAt : "2026-01-01T00:00:00.000Z" ,
572+ method : "turn/completed" ,
573+ threadId : asThreadId ( "thread-1" ) ,
574+ turnId : asTurnId ( turnId ) ,
575+ payload : {
576+ threadId : "provider-thread-1" ,
577+ turn : {
578+ id : turnId ,
579+ status : "failed" ,
580+ items : input ?. items ?? [ ] ,
581+ itemsView : "notLoaded" ,
582+ error : {
583+ message : "Usage limit exceeded" ,
584+ codexErrorInfo : "usageLimitExceeded" ,
585+ } ,
586+ } ,
587+ } ,
588+ } satisfies ProviderEvent ;
589+ }
590+
591+ function itemLifecycleEvent (
592+ method : "item/started" | "item/completed" ,
593+ item : { readonly id : string ; readonly type : string ; readonly [ key : string ] : unknown } ,
594+ ) : ProviderEvent {
595+ return {
596+ id : asEventId ( `evt-${ method } -${ item . id } ` ) ,
597+ kind : "notification" ,
598+ provider : ProviderDriverKind . make ( "codex" ) ,
599+ createdAt : "2026-01-01T00:00:00.000Z" ,
600+ method,
601+ threadId : asThreadId ( "thread-1" ) ,
602+ turnId : asTurnId ( "turn-1" ) ,
603+ itemId : asItemId ( item . id ) ,
604+ payload : {
605+ threadId : "provider-thread-1" ,
606+ turnId : "turn-1" ,
607+ item,
608+ ...( method === "item/started" ? { startedAtMs : 1 } : { completedAtMs : 2 } ) ,
609+ } ,
610+ } satisfies ProviderEvent ;
611+ }
612+
613+ function makeRecoveryTestLayer ( runtimeFactory : ReturnType < typeof makeRuntimeFactory > ) {
614+ return Layer . effect (
615+ CodexAdapter ,
616+ Effect . gen ( function * ( ) {
617+ const codexConfig = decodeCodexSettings ( { binaryPath : "codex-balanced" } ) ;
618+ return yield * makeCodexAdapter ( codexConfig , {
619+ makeRuntime : runtimeFactory . factory ,
620+ } ) ;
621+ } ) ,
622+ ) . pipe (
623+ Layer . provideMerge ( ServerConfig . layerTest ( process . cwd ( ) , process . cwd ( ) ) ) ,
624+ Layer . provideMerge ( ServerSettingsService . layerTest ( ) ) ,
625+ Layer . provideMerge ( providerSessionDirectoryTestLayer ) ,
626+ Layer . provideMerge ( NodeServices . layer ) ,
627+ ) ;
628+ }
629+
630+ function startRecoveryTurn (
631+ adapter : CodexAdapterShape ,
632+ runtimeFactory : ReturnType < typeof makeRuntimeFactory > ,
633+ ) {
634+ return Effect . gen ( function * ( ) {
635+ yield * adapter . startSession ( {
636+ provider : ProviderDriverKind . make ( "codex" ) ,
637+ threadId : asThreadId ( "thread-1" ) ,
638+ modelSelection : createModelSelection ( ProviderInstanceId . make ( "codex" ) , "gpt-5.3-codex" ) ,
639+ runtimeMode : "full-access" ,
640+ } ) ;
641+ const runtime = yield * runtimeFactory . created . take ( ) ;
642+ yield * adapter . sendTurn ( {
643+ threadId : asThreadId ( "thread-1" ) ,
644+ input : "continue this exact conversation" ,
645+ attachments : [ ] ,
646+ modelSelection : createModelSelection ( ProviderInstanceId . make ( "codex" ) , "gpt-5.3-codex" ) ,
647+ } ) ;
648+ yield * runtime . sentTurns . take ( ) ;
649+ return runtime ;
650+ } ) ;
651+ }
652+
653+ it . effect ( "restarts, resumes, and retries an empty Codex usage-limit turn once" , ( ) => {
654+ const runtimeFactory = makeRuntimeFactory ( ( runtime , index ) => {
655+ if ( index !== 1 ) return ;
656+ runtime . readThreadImpl . mockResolvedValue ( {
657+ threadId : "provider-thread-1" ,
658+ turns : [
659+ {
660+ id : asTurnId ( "turn-1" ) ,
661+ items : [
662+ {
663+ id : "user-message-1" ,
664+ type : "userMessage" ,
665+ content : [ { type : "text" , text : "continue this exact conversation" } ] ,
666+ } ,
667+ ] ,
668+ } ,
669+ ] ,
670+ } ) ;
671+ } ) ;
672+ const layer = makeRecoveryTestLayer ( runtimeFactory ) ;
673+
674+ return Effect . gen ( function * ( ) {
675+ const adapter = yield * CodexAdapter ;
676+ const firstRuntime = yield * startRecoveryTurn ( adapter , runtimeFactory ) ;
677+ const eventsFiber = yield * Stream . runCollect ( Stream . take ( adapter . streamEvents , 3 ) ) . pipe (
678+ Effect . forkChild ,
679+ ) ;
680+
681+ const userMessage = {
682+ id : "user-message-1" ,
683+ type : "userMessage" ,
684+ content : [ { type : "text" , text : "continue this exact conversation" } ] ,
685+ } ;
686+ yield * firstRuntime . emit ( itemLifecycleEvent ( "item/started" , userMessage ) ) ;
687+ yield * firstRuntime . emit ( itemLifecycleEvent ( "item/completed" , userMessage ) ) ;
688+ yield * firstRuntime . emit ( usageLimitCompletionEvent ( ) ) ;
689+ yield * Fiber . join ( eventsFiber ) ;
690+
691+ const recoveredRuntime = yield * runtimeFactory . created . take ( ) ;
692+ const retriedInput = yield * recoveredRuntime . sentTurns . take ( ) ;
693+
694+ NodeAssert . equal ( firstRuntime . closeImpl . mock . calls . length , 1 ) ;
695+ NodeAssert . equal ( recoveredRuntime . options . binaryPath , "codex-balanced" ) ;
696+ NodeAssert . equal ( recoveredRuntime . options . requireResume , true ) ;
697+ NodeAssert . deepStrictEqual ( recoveredRuntime . options . resumeCursor , {
698+ threadId : "provider-thread-1" ,
699+ } ) ;
700+ NodeAssert . equal ( recoveredRuntime . rollbackThreadImpl . mock . calls . length , 1 ) ;
701+ NodeAssert . deepStrictEqual ( retriedInput , {
702+ input : "continue this exact conversation" ,
703+ model : "gpt-5.3-codex" ,
704+ } ) ;
705+ } ) . pipe ( Effect . provide ( layer ) ) ;
706+ } ) ;
707+
708+ it . effect ( "does not replace a session started while usage-limit recovery is opening" , ( ) => {
709+ let releaseRecoveryStart : ( ( ) => void ) | undefined ;
710+ const recoveryStartGate = new Promise < void > ( ( resolve ) => {
711+ releaseRecoveryStart = resolve ;
712+ } ) ;
713+ const runtimeFactory = makeRuntimeFactory ( ( runtime , index ) => {
714+ if ( index !== 1 ) return ;
715+ const start = runtime . startImpl . getMockImplementation ( ) ;
716+ if ( ! start ) throw new Error ( "Expected the fake runtime start implementation" ) ;
717+ runtime . startImpl . mockImplementation ( async ( ) => {
718+ await recoveryStartGate ;
719+ return start ( ) ;
720+ } ) ;
721+ } ) ;
722+ const layer = makeRecoveryTestLayer ( runtimeFactory ) ;
723+
724+ return Effect . gen ( function * ( ) {
725+ const adapter = yield * CodexAdapter ;
726+ const firstRuntime = yield * startRecoveryTurn ( adapter , runtimeFactory ) ;
727+ const completedFiber = yield * Stream . runHead ( adapter . streamEvents ) . pipe ( Effect . forkChild ) ;
728+ yield * firstRuntime . emit ( usageLimitCompletionEvent ( ) ) ;
729+ yield * Fiber . join ( completedFiber ) ;
730+
731+ const recoveringRuntime = yield * runtimeFactory . created . take ( ) ;
732+ yield * adapter . startSession ( {
733+ provider : ProviderDriverKind . make ( "codex" ) ,
734+ threadId : asThreadId ( "thread-1" ) ,
735+ modelSelection : createModelSelection ( ProviderInstanceId . make ( "codex" ) , "gpt-5.3-codex" ) ,
736+ runtimeMode : "full-access" ,
737+ } ) ;
738+ const userStartedRuntime = yield * runtimeFactory . created . take ( ) ;
739+
740+ releaseRecoveryStart ?.( ) ;
741+ yield * recoveringRuntime . closed . take ( ) ;
742+
743+ yield * adapter . sendTurn ( {
744+ threadId : asThreadId ( "thread-1" ) ,
745+ input : "use the session I started" ,
746+ attachments : [ ] ,
747+ modelSelection : createModelSelection ( ProviderInstanceId . make ( "codex" ) , "gpt-5.3-codex" ) ,
748+ } ) ;
749+ const sentInput = yield * userStartedRuntime . sentTurns . take ( ) ;
750+ NodeAssert . equal ( sentInput . input , "use the session I started" ) ;
751+ NodeAssert . equal ( userStartedRuntime . closeImpl . mock . calls . length , 0 ) ;
752+ } ) . pipe ( Effect . provide ( layer ) ) ;
753+ } ) ;
754+
755+ it . effect ( "does not retry a usage-limit turn that emits provider activity" , ( ) => {
756+ const runtimeFactory = makeRuntimeFactory ( ) ;
757+ const layer = makeRecoveryTestLayer ( runtimeFactory ) ;
758+
759+ return Effect . gen ( function * ( ) {
760+ const adapter = yield * CodexAdapter ;
761+ const runtime = yield * startRecoveryTurn ( adapter , runtimeFactory ) ;
762+ const eventsFiber = yield * Stream . runCollect ( Stream . take ( adapter . streamEvents , 2 ) ) . pipe (
763+ Effect . forkChild ,
764+ ) ;
765+
766+ yield * runtime . emit (
767+ itemLifecycleEvent ( "item/started" , {
768+ id : "msg-1" ,
769+ type : "agentMessage" ,
770+ text : "partial output" ,
771+ } ) ,
772+ ) ;
773+ yield * runtime . emit ( usageLimitCompletionEvent ( ) ) ;
774+ yield * Fiber . join ( eventsFiber ) ;
775+
776+ NodeAssert . equal ( runtimeFactory . factory . mock . calls . length , 1 ) ;
777+ NodeAssert . equal ( runtime . closeImpl . mock . calls . length , 0 ) ;
778+ } ) . pipe ( Effect . provide ( layer ) ) ;
779+ } ) ;
780+
781+ it . effect ( "does not retry provider output reported only by turn completion" , ( ) => {
782+ const runtimeFactory = makeRuntimeFactory ( ) ;
783+ const layer = makeRecoveryTestLayer ( runtimeFactory ) ;
784+
785+ return Effect . gen ( function * ( ) {
786+ const adapter = yield * CodexAdapter ;
787+ const runtime = yield * startRecoveryTurn ( adapter , runtimeFactory ) ;
788+ const completedFiber = yield * Stream . runHead ( adapter . streamEvents ) . pipe ( Effect . forkChild ) ;
789+
790+ yield * runtime . emit (
791+ usageLimitCompletionEvent ( {
792+ items : [ { id : "msg-1" , type : "agentMessage" , text : "partial output" } ] ,
793+ } ) ,
794+ ) ;
795+ yield * Fiber . join ( completedFiber ) ;
796+
797+ NodeAssert . equal ( runtimeFactory . factory . mock . calls . length , 1 ) ;
798+ NodeAssert . equal ( runtime . closeImpl . mock . calls . length , 0 ) ;
799+ } ) . pipe ( Effect . provide ( layer ) ) ;
800+ } ) ;
801+
802+ it . effect ( "does not restart again when the retried turn also exhausts usage" , ( ) => {
803+ const runtimeFactory = makeRuntimeFactory ( ) ;
804+ const layer = makeRecoveryTestLayer ( runtimeFactory ) ;
805+
806+ return Effect . gen ( function * ( ) {
807+ const adapter = yield * CodexAdapter ;
808+ const firstRuntime = yield * startRecoveryTurn ( adapter , runtimeFactory ) ;
809+ const firstCompletedFiber = yield * Stream . runHead ( adapter . streamEvents ) . pipe ( Effect . forkChild ) ;
810+ yield * firstRuntime . emit ( usageLimitCompletionEvent ( ) ) ;
811+ yield * Fiber . join ( firstCompletedFiber ) ;
812+
813+ const recoveredRuntime = yield * runtimeFactory . created . take ( ) ;
814+ yield * recoveredRuntime . sentTurns . take ( ) ;
815+ const secondCompletedFiber = yield * Stream . runHead ( adapter . streamEvents ) . pipe ( Effect . forkChild ) ;
816+ yield * recoveredRuntime . emit ( usageLimitCompletionEvent ( ) ) ;
817+ yield * Fiber . join ( secondCompletedFiber ) ;
818+
819+ NodeAssert . equal ( runtimeFactory . factory . mock . calls . length , 2 ) ;
820+ NodeAssert . equal ( recoveredRuntime . closeImpl . mock . calls . length , 0 ) ;
821+ } ) . pipe ( Effect . provide ( layer ) ) ;
822+ } ) ;
823+
515824lifecycleLayer ( "CodexAdapterLive lifecycle" , ( it ) => {
516825 it . effect ( "maps completed agent message items to canonical item.completed events" , ( ) =>
517826 Effect . gen ( function * ( ) {
0 commit comments