@@ -157,7 +157,7 @@ describe("reconnect recovery accept gate", () => {
157157} ) ;
158158
159159describe ( "reconnect recovery selection" , ( ) => {
160- test ( "reconnects the failed scope and replays the turn once " , ( ) => {
160+ test ( "reconnects the failed scope and replays only after success " , ( ) => {
161161 const state = createReconnectRecoveryState ( ) ;
162162 const attempt = state . begin ( operatorMessage ( ) , "xai" ) ;
163163 state . observe ( attempt , credentialRetry ( ) ) ;
@@ -167,12 +167,14 @@ describe("reconnect recovery selection", () => {
167167 const seen : string [ ] = [ ] ;
168168 let armed : number | null = null ;
169169 const delivered : InboundMessage [ ] = [ ] ;
170+ let completeReconnect : ( ( connected : boolean ) => void ) | undefined ;
170171 const outcome = applyReconnectRecoverySelection ( {
171172 state,
172173 generation : pending . generation ,
173174 selectedId : reconnectRecoveryItemId ( pending . scope ) ,
174- reconnect : ( scope ) => {
175+ reconnect : ( scope , onComplete ) => {
175176 seen . push ( `${ scope . kind } /${ scope . profile } ` ) ;
177+ completeReconnect = onComplete ;
176178 } ,
177179 armContinuation : ( generation ) => {
178180 armed = generation ;
@@ -184,10 +186,45 @@ describe("reconnect recovery selection", () => {
184186 delivered . push ( message ) ;
185187 } ,
186188 } ) ;
187- expect ( outcome ) . toBe ( "continued " ) ;
189+ expect ( outcome ) . toBe ( "reconnected " ) ;
188190 expect ( seen ) . toEqual ( [ "xai/default-2" ] ) ;
191+ expect ( armed ) . toBeNull ( ) ;
192+ expect ( delivered ) . toEqual ( [ ] ) ;
193+ completeReconnect ?.( true ) ;
189194 expect ( armed ?? - 1 ) . toBe ( pending . generation ) ;
190195 expect ( delivered ) . toHaveLength ( 1 ) ;
196+ completeReconnect ?.( true ) ;
197+ expect ( delivered ) . toHaveLength ( 1 ) ;
198+ } ) ;
199+
200+ test ( "cancel, failure, and committed attempts never replay" , ( ) => {
201+ for ( const connected of [ false , true ] ) {
202+ const state = createReconnectRecoveryState ( ) ;
203+ const attempt = state . begin ( operatorMessage ( ) , "xai" ) ;
204+ state . observe ( attempt , credentialRetry ( ) ) ;
205+ if ( connected ) {
206+ state . observe ( attempt , { type : "inference.tool-call" , data : { } } ) ;
207+ }
208+ state . observe ( attempt , credentialFailure ( "xai/default-2" ) ) ;
209+ const pending = required ( state . settle ( attempt ) , "pending recovery" ) ;
210+ let completeReconnect : ( ( result : boolean ) => void ) | undefined ;
211+ applyReconnectRecoverySelection ( {
212+ state,
213+ generation : pending . generation ,
214+ selectedId : reconnectRecoveryItemId ( pending . scope ) ,
215+ reconnect : ( _scope , onComplete ) => {
216+ completeReconnect = onComplete ;
217+ } ,
218+ armContinuation : ( ) => {
219+ throw new Error ( "should not arm" ) ;
220+ } ,
221+ cancelContinuation : ( ) => undefined ,
222+ deliverContinuation : ( ) => {
223+ throw new Error ( "should not deliver" ) ;
224+ } ,
225+ } ) ;
226+ completeReconnect ?.( connected ) ;
227+ }
191228 } ) ;
192229
193230 test ( "reconnect failure and stale generations never arm a continuation" , ( ) => {
@@ -254,6 +291,7 @@ describe("reconnect recovery presenter (runTUI wiring)", () => {
254291 } ;
255292 const dialogs : ReconnectDialog [ ] = [ ] ;
256293 const reconnected : ReconnectScope [ ] = [ ] ;
294+ let completeReconnect : ( ( connected : boolean ) => void ) | undefined ;
257295 const armed : number [ ] = [ ] ;
258296 const cancelled : number [ ] = [ ] ;
259297 const delivered : InboundMessage [ ] = [ ] ;
@@ -263,8 +301,9 @@ describe("reconnect recovery presenter (runTUI wiring)", () => {
263301 dialogs . push ( next ) ;
264302 return dialogOpens ;
265303 } ,
266- openReconnect : ( scope ) => {
304+ openReconnect : ( scope , onComplete ?: ( connected : boolean ) => void ) => {
267305 reconnected . push ( scope ) ;
306+ completeReconnect = onComplete ;
268307 return options ?. reconnectOpens ?? true ;
269308 } ,
270309 readDirector : ( ) =>
@@ -290,17 +329,22 @@ describe("reconnect recovery presenter (runTUI wiring)", () => {
290329 pending,
291330 dialog,
292331 reconnected,
332+ completeReconnect : ( connected : boolean ) => completeReconnect ?.( connected ) ,
293333 armed,
294334 cancelled,
295335 delivered,
296336 } ;
297337 }
298338
299- test ( "accept re-keys the failed scope and replays the turn once" , ( ) => {
339+ test ( "accept waits for a successful re-key before replaying the turn once" , ( ) => {
300340 const wired = wirePresenter ( ) ;
301341 expect ( wired . dialog . scope ) . toEqual ( wired . pending . scope ) ;
302342 wired . dialog . onAccept ( reconnectRecoveryItemId ( wired . pending . scope ) ) ;
303343 expect ( wired . reconnected ) . toEqual ( [ wired . pending . scope ] ) ;
344+ expect ( wired . armed ) . toEqual ( [ ] ) ;
345+ expect ( wired . delivered ) . toEqual ( [ ] ) ;
346+
347+ wired . completeReconnect ( true ) ;
304348 expect ( wired . armed ) . toEqual ( [ wired . pending . generation ] ) ;
305349 expect ( wired . cancelled ) . toEqual ( [ ] ) ;
306350 // The continuation message carries a build-time timestamp, so pin the
@@ -314,6 +358,9 @@ describe("reconnect recovery presenter (runTUI wiring)", () => {
314358 expect ( continuation . headers . interchangeCorrelationId ) . toBe (
315359 String ( wired . pending . generation ) ,
316360 ) ;
361+ wired . completeReconnect ( true ) ;
362+ expect ( wired . armed ) . toEqual ( [ wired . pending . generation ] ) ;
363+ expect ( wired . delivered ) . toHaveLength ( 1 ) ;
317364 // The generation is consumed: a late cancel is a no-op.
318365 expect ( wired . recovery . cancel ( wired . pending . generation ) ) . toBe ( false ) ;
319366 } ) ;
0 commit comments