@@ -277,4 +277,108 @@ describe("runAgentTurnWithFallback: transient connection/timeout retry (#87180)"
277277 // No transient retry through the timeout disjunct: a single cycle only.
278278 expect ( state . runWithModelFallbackMock ) . toHaveBeenCalledTimes ( 1 ) ;
279279 } ) ;
280+
281+ it ( "restores the configured provider retry budget as the whole-attempt retry count (#87180)" , async ( ) => {
282+ // settings.retry.provider.maxRetries is dropped in-window by the SDK pin, so
283+ // the outer owner restores it: with a resolved budget of 3, a persistently
284+ // transient error runs 1 initial cycle + 3 retries (4 cycles). Each retry
285+ // re-runs the whole fallback chain, so the session lock is reacquired per
286+ // attempt, then a terminal failure is surfaced once the budget is exhausted.
287+ state . runWithModelFallbackMock . mockRejectedValue ( new Error ( "Connection error." ) ) ;
288+
289+ const runAgentTurnWithFallback = await getRunAgentTurnWithFallback ( ) ;
290+ const followupRun = createFollowupRun ( ) ;
291+ followupRun . run . providerRetryMaxRetries = 3 ;
292+ vi . useFakeTimers ( ) ;
293+ try {
294+ const promise = runAgentTurnWithFallback ( {
295+ commandBody : "hello" ,
296+ followupRun,
297+ sessionCtx : {
298+ Provider : "whatsapp" ,
299+ MessageSid : "msg" ,
300+ } as unknown as TemplateContext ,
301+ opts : { } ,
302+ typingSignals : createMockTypingSignaler ( ) ,
303+ blockReplyPipeline : null ,
304+ blockStreamingEnabled : false ,
305+ resolvedBlockStreamingBreak : "message_end" ,
306+ applyReplyToMode : ( payload ) => payload ,
307+ shouldEmitToolResult : ( ) => true ,
308+ shouldEmitToolOutput : ( ) => false ,
309+ pendingToolTasks : new Set ( ) ,
310+ resetSessionAfterRoleOrderingConflict : async ( ) => false ,
311+ isHeartbeat : false ,
312+ sessionKey : "main" ,
313+ getActiveSessionEntry : ( ) => undefined ,
314+ resolvedVerboseLevel : "off" ,
315+ } ) ;
316+ // Three fixed backoffs (2_500ms each) separate the four cycles.
317+ await vi . advanceTimersByTimeAsync ( 8_000 ) ;
318+ const result = await promise ;
319+
320+ expect ( result . kind ) . toBe ( "final" ) ;
321+ // 1 initial cycle + exactly 3 configured retries.
322+ expect ( state . runWithModelFallbackMock ) . toHaveBeenCalledTimes ( 4 ) ;
323+ } finally {
324+ vi . useRealTimers ( ) ;
325+ }
326+ } ) ;
327+
328+ it ( "succeeds within the configured provider retry budget when a transient error clears (#87180)" , async ( ) => {
329+ // With a budget of 3, two transient failures are absorbed before a clean
330+ // cycle succeeds — the extra budget is used, not the shipped single retry.
331+ state . runWithModelFallbackMock
332+ . mockRejectedValueOnce ( new Error ( "Connection error." ) )
333+ . mockRejectedValueOnce ( new Error ( "socket hang up" ) )
334+ . mockImplementationOnce ( async ( params : FallbackRunnerParams ) => ( {
335+ result : await params . run ( "anthropic" , "claude" ) ,
336+ provider : "anthropic" ,
337+ model : "claude" ,
338+ attempts : [ ] ,
339+ } ) ) ;
340+ state . runEmbeddedAgentMock . mockResolvedValueOnce ( {
341+ payloads : [ { text : "recovered" } ] ,
342+ meta : {
343+ agentMeta : { sessionId : "session" , provider : "anthropic" , model : "claude" } ,
344+ } ,
345+ } ) ;
346+
347+ const runAgentTurnWithFallback = await getRunAgentTurnWithFallback ( ) ;
348+ const followupRun = createFollowupRun ( ) ;
349+ followupRun . run . providerRetryMaxRetries = 3 ;
350+ vi . useFakeTimers ( ) ;
351+ try {
352+ const promise = runAgentTurnWithFallback ( {
353+ commandBody : "hello" ,
354+ followupRun,
355+ sessionCtx : {
356+ Provider : "whatsapp" ,
357+ MessageSid : "msg" ,
358+ } as unknown as TemplateContext ,
359+ opts : { } ,
360+ typingSignals : createMockTypingSignaler ( ) ,
361+ blockReplyPipeline : null ,
362+ blockStreamingEnabled : false ,
363+ resolvedBlockStreamingBreak : "message_end" ,
364+ applyReplyToMode : ( payload ) => payload ,
365+ shouldEmitToolResult : ( ) => true ,
366+ shouldEmitToolOutput : ( ) => false ,
367+ pendingToolTasks : new Set ( ) ,
368+ resetSessionAfterRoleOrderingConflict : async ( ) => false ,
369+ isHeartbeat : false ,
370+ sessionKey : "main" ,
371+ getActiveSessionEntry : ( ) => undefined ,
372+ resolvedVerboseLevel : "off" ,
373+ } ) ;
374+ await vi . advanceTimersByTimeAsync ( 6_000 ) ;
375+ const result = await promise ;
376+
377+ expect ( result . kind ) . toBe ( "success" ) ;
378+ // 1 initial cycle + 2 retries before the third cycle recovers.
379+ expect ( state . runWithModelFallbackMock ) . toHaveBeenCalledTimes ( 3 ) ;
380+ } finally {
381+ vi . useRealTimers ( ) ;
382+ }
383+ } ) ;
280384} ) ;
0 commit comments