@@ -366,3 +366,172 @@ describe("ChatDirector inference-error recovery (CL-6910)", () => {
366366 ) ;
367367 } ) ;
368368} ) ;
369+
370+ // CL-7973: the director's live source id (which stamps retry decisions so a
371+ // mid-session /model switch remaps the xAI short-429 handling) is observable
372+ // only through the retry policy it hands to each infer action. An xAI-gated
373+ // capacity error retries when the tracked id is an xAI source and aborts
374+ // otherwise, so driving tracking events then invoking the attached policy
375+ // reads the tracked id without reaching into privates.
376+ type LiveRetryPolicy = ( situation : {
377+ attempt : number ;
378+ elapsedMs : number ;
379+ error : { category : "protocol_mismatch" ; message : string } ;
380+ } ) => Promise < { kind : string } > | { kind : string } ;
381+
382+ function textCompletion ( sourceId ?: string ) : ReactorInboundEvent {
383+ const turn = {
384+ role : "assistant" ,
385+ model : "test" ,
386+ timestamp : 0 ,
387+ content : [ { type : "text" , text : "done work" } ] ,
388+ } ;
389+ const event : Record < string , unknown > = {
390+ type : "inference.done" ,
391+ turn,
392+ usage : { input : 0 , output : 0 } ,
393+ } ;
394+ if ( sourceId !== undefined )
395+ event [ "source" ] = { sourceId, provider : "p" , model : "test" } ;
396+ return event as unknown as ReactorInboundEvent ;
397+ }
398+
399+ function stateWithCycleSource ( sourceId : string ) : ReactorState {
400+ return {
401+ turns : [ ] ,
402+ lastCycleSource : { sourceId, provider : "p" , model : "test" } ,
403+ } as unknown as ReactorState ;
404+ }
405+
406+ async function liveRetryPolicy (
407+ director : ReturnType < typeof createChatDirector > ,
408+ capabilities : ReactorCapabilities ,
409+ ) : Promise < LiveRetryPolicy > {
410+ await director . decide ( toolOnlyTurn ( "source-probe" ) , mockState , capabilities ) ;
411+ const actions = actionsArray (
412+ await director . decide (
413+ toolDoneEvent ( "source-probe" ) ,
414+ mockState ,
415+ capabilities ,
416+ ) ,
417+ ) ;
418+ const infer = actions . find ( ( a ) => a . type === "infer" ) as
419+ | { type : "infer" ; options ?: { retryPolicy ?: LiveRetryPolicy } }
420+ | undefined ;
421+ if ( infer ?. options ?. retryPolicy === undefined )
422+ throw new Error ( "expected an infer action carrying the live retry policy" ) ;
423+ return infer . options . retryPolicy ;
424+ }
425+
426+ async function isXaiStamped ( policy : LiveRetryPolicy ) : Promise < boolean > {
427+ const decision = await policy ( {
428+ attempt : 1 ,
429+ elapsedMs : 0 ,
430+ error : {
431+ category : "protocol_mismatch" ,
432+ message : "The model is currently at capacity" ,
433+ } ,
434+ } ) ;
435+ return decision . kind === "retry" ;
436+ }
437+
438+ describe ( "ChatDirector live source-id tracking (CL-7973)" , ( ) => {
439+ test ( "a sourceless or empty-string completion never wipes the learned id" , async ( ) => {
440+ const director = createChatDirector ( "system" , [ ] , {
441+ onTasksChange : ( ) => undefined ,
442+ provider : { providerName : "test-provider" } ,
443+ } ) ;
444+ const capabilities = makeCapabilities ( ) ;
445+ const policy = await liveRetryPolicy ( director , capabilities ) ;
446+
447+ // The seed id is not an xAI source, so the capacity error aborts.
448+ expect ( await isXaiStamped ( policy ) ) . toBe ( false ) ;
449+
450+ // A completion stamps the source that served it.
451+ await director . decide (
452+ textCompletion ( "xai/learned" ) ,
453+ mockState ,
454+ capabilities ,
455+ ) ;
456+ expect ( await isXaiStamped ( policy ) ) . toBe ( true ) ;
457+
458+ // A completion carrying no source keeps the learned id.
459+ await director . decide ( textCompletion ( ) , mockState , capabilities ) ;
460+ expect ( await isXaiStamped ( policy ) ) . toBe ( true ) ;
461+
462+ // An empty-string source id never clobbers the learned id.
463+ await director . decide ( textCompletion ( "" ) , mockState , capabilities ) ;
464+ expect ( await isXaiStamped ( policy ) ) . toBe ( true ) ;
465+
466+ // An empty-string cycle source never clobbers it either.
467+ await director . decide (
468+ toolDoneEvent ( "empty-cycle" ) ,
469+ stateWithCycleSource ( "" ) ,
470+ capabilities ,
471+ ) ;
472+ expect ( await isXaiStamped ( policy ) ) . toBe ( true ) ;
473+ } ) ;
474+
475+ test ( "a cycle source remaps tracking on a non-inference event" , async ( ) => {
476+ const director = createChatDirector ( "system" , [ ] , {
477+ onTasksChange : ( ) => undefined ,
478+ provider : { providerName : "test-provider" } ,
479+ } ) ;
480+ const capabilities = makeCapabilities ( ) ;
481+ const policy = await liveRetryPolicy ( director , capabilities ) ;
482+ expect ( await isXaiStamped ( policy ) ) . toBe ( false ) ;
483+
484+ // tool.done carries no source of its own; the harness's cycle source
485+ // covers the turn and remaps tracking from it.
486+ await director . decide (
487+ toolDoneEvent ( "cycle-remap" ) ,
488+ stateWithCycleSource ( "xai/cycle" ) ,
489+ capabilities ,
490+ ) ;
491+ expect ( await isXaiStamped ( policy ) ) . toBe ( true ) ;
492+ } ) ;
493+
494+ test ( "a drained fleet capitulates to the terminal action after the nudge budget" , async ( ) => {
495+ const director = createChatDirector ( "system" , [ ] , {
496+ onTasksChange : ( ) => undefined ,
497+ provider : { providerName : "test-provider" } ,
498+ } ) ;
499+ director . restoreTasks ( [ { id : "t1" , title : "keep going" , status : "todo" } ] ) ;
500+ const capabilities = makeCapabilities ( ) ;
501+
502+ // Without the idle-with-fleet allowance (drained fleet), a terminal base
503+ // action with open tasks re-infers with the open-task nudge a bounded
504+ // number of times, then lets the terminal action through — the accepted
505+ // loss stays locked in rather than resuming the nudge.
506+ for ( let i = 0 ; i < 3 ; i ++ ) {
507+ const actions = actionsArray (
508+ await director . decide ( textCompletion ( ) , mockState , capabilities ) ,
509+ ) ;
510+ expect ( actions . some ( ( a ) => a . type === "infer" ) ) . toBe ( true ) ;
511+ expect ( actions . some ( ( a ) => a . type === "reply" ) ) . toBe ( false ) ;
512+ }
513+ const terminal = actionsArray (
514+ await director . decide ( textCompletion ( ) , mockState , capabilities ) ,
515+ ) ;
516+ expect ( terminal . some ( ( a ) => a . type === "infer" ) ) . toBe ( false ) ;
517+ expect ( terminal . some ( ( a ) => a . type === "reply" ) ) . toBe ( true ) ;
518+ } ) ;
519+
520+ test ( "a cycle source wins over a contradictory event source" , async ( ) => {
521+ const director = createChatDirector ( "system" , [ ] , {
522+ onTasksChange : ( ) => undefined ,
523+ provider : { providerName : "test-provider" } ,
524+ } ) ;
525+ const capabilities = makeCapabilities ( ) ;
526+ const policy = await liveRetryPolicy ( director , capabilities ) ;
527+
528+ // The harness's call-start snapshot is authoritative over the event's
529+ // own stamp, so when both are present the cycle source defines tracking.
530+ await director . decide (
531+ textCompletion ( "other/default" ) ,
532+ stateWithCycleSource ( "xai/cycle" ) ,
533+ capabilities ,
534+ ) ;
535+ expect ( await isXaiStamped ( policy ) ) . toBe ( true ) ;
536+ } ) ;
537+ } ) ;
0 commit comments