1515 * immediate fallback, bounded probe-back, probe ok
1616 * clears the cooldown and the next turn restores the
1717 * primary.
18+ * no-hint-429-no-chain No hint AND no usable fallback chain: the turn
19+ * degrades to bounded same-model in-turn retries on
20+ * the exponential schedule instead of dying with
21+ * "Retry failed after 0 attempts".
1822 *
1923 * Every wait is bounded by a scripted hint measured in seconds, and every
2024 * assertion waits on an EVENT (never a fixed sleep), so runs are deterministic.
@@ -36,8 +40,14 @@ const FALLBACK_MARKER = "SENPI-QA-HINT429-FALLBACK-6c1b";
3640const IN_TURN_HINT_SECONDS = 8 ;
3741const PROBE_BACK_HINT_MS = 4000 ;
3842const PROBE_BACK_CAP_MS = 1000 ;
43+ const NO_CHAIN_BASE_DELAY_MS = 50 ;
3944
40- export const HINT_429_SCENARIOS = [ "hinted-429-in-turn" , "no-hint-429-fast-fallback" , "hinted-429-probe-back" ] ;
45+ export const HINT_429_SCENARIOS = [
46+ "hinted-429-in-turn" ,
47+ "no-hint-429-fast-fallback" ,
48+ "hinted-429-probe-back" ,
49+ "no-hint-429-no-chain" ,
50+ ] ;
4151
4252export function isHint429Scenario ( name ) {
4353 return HINT_429_SCENARIOS . includes ( name ) ;
@@ -251,15 +261,30 @@ export async function runHint429Scenario({ scenarioName, apiName, evidenceSlug }
251261 ? { primaryLimitedRequests : 2 , rateLimitHeaders : { "retry-after" : String ( IN_TURN_HINT_SECONDS ) } , rateLimitMessage : "primary rate limited" }
252262 : scenarioName === "no-hint-429-fast-fallback"
253263 ? { primaryLimitedRequests : 4 , rateLimitMessage : "All tokens rate limited" }
254- : { primaryLimitedRequests : 1 , rateLimitHeaders : { "retry-after-ms" : String ( PROBE_BACK_HINT_MS ) } , rateLimitMessage : "primary rate limited" } ;
264+ : scenarioName === "no-hint-429-no-chain"
265+ ? { primaryLimitedRequests : 2 , rateLimitMessage : "All tokens rate limited" }
266+ : { primaryLimitedRequests : 1 , rateLimitHeaders : { "retry-after-ms" : String ( PROBE_BACK_HINT_MS ) } , rateLimitMessage : "primary rate limited" } ;
255267 const server = await startHint429Server ( { ...script , primaryMarker : PRIMARY_MARKER , fallbackMarker : FALLBACK_MARKER } ) ;
256- writeMockModelsJson ( box . agentDir , server , API_NAME , { } , {
257- models : [ { id : HINT_429_FALLBACK_MODEL_ID } ] ,
258- retry :
259- scenarioName === "hinted-429-probe-back"
260- ? retrySettings ( { hintedWaitCapMs : PROBE_BACK_CAP_MS , probeBackMaxMs : 3_600_000 } )
261- : retrySettings ( ) ,
262- } ) ;
268+ // The no-chain scenario registers NO fallback model and NO chain, so the
269+ // shipped default chains cannot resolve for the mock primary either.
270+ const mockExtras =
271+ scenarioName === "no-hint-429-no-chain"
272+ ? {
273+ retry : {
274+ enabled : true ,
275+ maxRetries : 3 ,
276+ baseDelayMs : NO_CHAIN_BASE_DELAY_MS ,
277+ provider : { maxRetries : 0 , maxRetryDelayMs : 60000 } ,
278+ } ,
279+ }
280+ : {
281+ models : [ { id : HINT_429_FALLBACK_MODEL_ID } ] ,
282+ retry :
283+ scenarioName === "hinted-429-probe-back"
284+ ? retrySettings ( { hintedWaitCapMs : PROBE_BACK_CAP_MS , probeBackMaxMs : 3_600_000 } )
285+ : retrySettings ( ) ,
286+ } ;
287+ writeMockModelsJson ( box . agentDir , server , API_NAME , { } , mockExtras ) ;
263288 const client = new HintRpcClient ( {
264289 env : hermeticEnv ( box . env ) ,
265290 cwd : box . cwd ,
@@ -270,6 +295,7 @@ export async function runHint429Scenario({ scenarioName, apiName, evidenceSlug }
270295 await client . send ( { type : "get_state" } ) ; // ensure the session booted
271296 if ( scenarioName === "hinted-429-in-turn" ) await assertInTurn ( checks , client , server , texts ) ;
272297 else if ( scenarioName === "no-hint-429-fast-fallback" ) await assertFastFallback ( checks , client , server , texts ) ;
298+ else if ( scenarioName === "no-hint-429-no-chain" ) await assertNoChainDegrade ( checks , client , server , texts ) ;
273299 else await assertProbeBack ( checks , client , server , texts ) ;
274300 process . stdout . write ( `SENPI_QA_HINT429_TRANSCRIPT ${ transcript ( scenarioName , server , client ) } \n` ) ;
275301 checkRealAuthUnchanged ( checks , guard ) ;
@@ -357,6 +383,33 @@ async function assertFastFallback(checks, client, server, texts) {
357383 ) ;
358384}
359385
386+ /** No hint AND no usable chain: bounded same-model in-turn retries instead of a dead turn. */
387+ async function assertNoChainDegrade ( checks , client , server , texts ) {
388+ texts . push ( await runOneTurn ( client , `Return ${ PRIMARY_MARKER } once the scripted rate limit clears.` ) ) ;
389+ const models = server . requests . map ( ( request ) => request . model ) ;
390+ checks . ok (
391+ "no-hint-429-no-chain: all three attempts stay on the primary model" ,
392+ models . length === 3 && models . every ( ( model ) => model === HINT_429_PRIMARY_MODEL_ID ) ,
393+ `sequence=${ models . join ( " -> " ) || "none" } ` ,
394+ ) ;
395+ checks . ok (
396+ "no-hint-429-no-chain: zero fallback switches" ,
397+ client . events . filter ( ( event ) => event . type === "retry_fallback_applied" ) . length === 0 ,
398+ `retry_fallback_applied=${ client . events . filter ( ( event ) => event . type === "retry_fallback_applied" ) . length } ` ,
399+ ) ;
400+ const delays = client . events . filter ( ( event ) => event . type === "auto_retry_start" ) . map ( ( event ) => event . delayMs ) ;
401+ checks . ok (
402+ "no-hint-429-no-chain: two exponential in-turn waits are scheduled" ,
403+ delays . length === 2 && delays [ 0 ] === NO_CHAIN_BASE_DELAY_MS && delays [ 1 ] === NO_CHAIN_BASE_DELAY_MS * 2 ,
404+ `delayMs=${ delays . join ( "," ) || "none" } expected=${ NO_CHAIN_BASE_DELAY_MS } ,${ NO_CHAIN_BASE_DELAY_MS * 2 } ` ,
405+ ) ;
406+ checks . ok (
407+ "no-hint-429-no-chain: the degraded retry recovers on the primary model" ,
408+ texts [ 0 ] . includes ( PRIMARY_MARKER ) ,
409+ `text=${ texts [ 0 ] . slice ( 0 , 80 ) } ` ,
410+ ) ;
411+ }
412+
360413/** Tier 2: hint above the shrunk cap -> immediate fallback + bounded probe-back that restores the primary. */
361414async function assertProbeBack ( checks , client , server , texts ) {
362415 texts . push ( await runOneTurn ( client , `Return ${ FALLBACK_MARKER } from whichever model can serve this turn.` ) ) ;
0 commit comments