@@ -200,4 +200,200 @@ describe("resolveModelFamilyPolicy", () => {
200200 expect ( gpt . toolDisciplineRules ) . toBeUndefined ( ) ;
201201 expect ( gpt . advertisedToolDeny ) . toEqual ( [ ] ) ;
202202 } ) ;
203+
204+ describe ( "astra repro trace (CL-9027)" , ( ) => {
205+ // Minimal failing session-trace fixture: 8 consecutive tool-only turns
206+ // from a gpt-6-astra leaf (tool names + args + result sizes per turn).
207+ // Fingerprint and repeat-count semantics mirror
208+ // scripts/tool-fingerprint-forensics.ts (stableJson exact signatures,
209+ // largest exact-repeat count per period 1-6): the shared threshold guard
210+ // fires only on exact repeats, so a loop that varies trivial argument
211+ // details escapes it. That is evasion, not threshold-tolerated waste —
212+ // and the Step-3 residual forbids exactly this variation. The
213+ // near-identical grouping below is test-only forensics; no signature
214+ // normalization ships in first-party code.
215+ interface ReproTurn {
216+ tool : string ;
217+ args : Record < string , unknown > ;
218+ resultTokens : number ;
219+ }
220+ const ASTRA_REPRO_TRACE : readonly ReproTurn [ ] = [
221+ {
222+ tool : "read" ,
223+ args : { path : "src/agent/prompts.ts" } ,
224+ resultTokens : 3200 ,
225+ } ,
226+ {
227+ tool : "read" ,
228+ args : { path : "./src/agent/prompts.ts" } ,
229+ resultTokens : 3200 ,
230+ } ,
231+ {
232+ tool : "read" ,
233+ args : { path : "src/agent/prompts.ts" , offset : 1 } ,
234+ resultTokens : 3180 ,
235+ } ,
236+ {
237+ tool : "grep" ,
238+ args : { pattern : "narrate" , path : "src/agent" } ,
239+ resultTokens : 420 ,
240+ } ,
241+ {
242+ tool : "read" ,
243+ args : { path : "src/agent/prompts.ts" } ,
244+ resultTokens : 3200 ,
245+ } ,
246+ {
247+ tool : "grep" ,
248+ args : { pattern : "narrate " , path : "src/agent" } ,
249+ resultTokens : 420 ,
250+ } ,
251+ {
252+ tool : "read" ,
253+ args : { path : "./src/agent/prompts.ts" , limit : 2000 } ,
254+ resultTokens : 3200 ,
255+ } ,
256+ {
257+ tool : "read" ,
258+ args : { path : "src/agent/prompts.ts" , offset : 0 } ,
259+ resultTokens : 3200 ,
260+ } ,
261+ ] ;
262+
263+ function stableJson ( value : unknown ) : string {
264+ if ( value === null || typeof value !== "object" )
265+ return JSON . stringify ( value ) ;
266+ if ( Array . isArray ( value ) ) return `[${ value . map ( stableJson ) . join ( "," ) } ]` ;
267+ const obj = value as Record < string , unknown > ;
268+ const keys = Object . keys ( obj ) . sort ( ) ;
269+ return `{${ keys . map ( ( k ) => `${ JSON . stringify ( k ) } :${ stableJson ( obj [ k ] ) } ` ) . join ( "," ) } }` ;
270+ }
271+
272+ function fingerprint ( turn : ReproTurn ) : string {
273+ return `${ turn . tool } :${ stableJson ( turn . args ) } ` ;
274+ }
275+
276+ function maxExactRepeats ( fps : readonly string [ ] ) : number {
277+ let best = 1 ;
278+ for ( let period = 1 ; period <= 6 ; period ++ ) {
279+ for ( let end = 1 ; end <= fps . length ; end ++ ) {
280+ const prefix = fps . slice ( 0 , end ) ;
281+ let i = prefix . length - 1 ;
282+ let j = i - period ;
283+ let matched = 0 ;
284+ while ( j >= 0 && prefix [ i ] === prefix [ j ] ) {
285+ matched ++ ;
286+ i -- ;
287+ j -- ;
288+ }
289+ const reps = Math . floor ( ( matched + period ) / period ) ;
290+ if ( reps > best ) best = reps ;
291+ }
292+ }
293+ return best ;
294+ }
295+
296+ function evasionKey ( turn : ReproTurn ) : string {
297+ const args = { ...turn . args } ;
298+ // The trivial deltas this trace varies: a leading ./ prefix, default
299+ // offset/limit values, and padding whitespace.
300+ if ( typeof args . path === "string" )
301+ args . path = args . path . replace ( / ^ \. \/ / , "" ) ;
302+ if ( args . offset === 0 || args . offset === 1 ) delete args . offset ;
303+ if ( typeof args . limit === "number" ) delete args . limit ;
304+ if ( typeof args . pattern === "string" ) args . pattern = args . pattern . trim ( ) ;
305+ return `${ turn . tool } :${ stableJson ( args ) } ` ;
306+ }
307+
308+ function classifyAstraTrace ( trace : readonly ReproTurn [ ] ) : string {
309+ if ( maxExactRepeats ( trace . map ( fingerprint ) ) >= 3 ) return "waste" ;
310+ const groups = new Map < string , number > ( ) ;
311+ for ( const turn of trace ) {
312+ const key = evasionKey ( turn ) ;
313+ groups . set ( key , ( groups . get ( key ) ?? 0 ) + 1 ) ;
314+ }
315+ return Math . max ( ...groups . values ( ) ) >= 3 ? "evasion" : "waste" ;
316+ }
317+
318+ test ( "classifies as evasion: no exact repeat trips the shared guard" , ( ) => {
319+ expect ( ASTRA_REPRO_TRACE ) . toHaveLength ( 8 ) ;
320+ expect ( maxExactRepeats ( ASTRA_REPRO_TRACE . map ( fingerprint ) ) ) . toBeLessThan (
321+ 3 ,
322+ ) ;
323+ expect ( classifyAstraTrace ( ASTRA_REPRO_TRACE ) ) . toBe ( "evasion" ) ;
324+ } ) ;
325+
326+ test ( "pins the offending pattern and the wasted turn/token delta" , ( ) => {
327+ const groups = new Map < string , number > ( ) ;
328+ for ( const turn of ASTRA_REPRO_TRACE ) {
329+ const key = evasionKey ( turn ) ;
330+ groups . set ( key , ( groups . get ( key ) ?? 0 ) + 1 ) ;
331+ }
332+ // Six re-reads of one file plus two re-greps of one pattern, each run
333+ // differing only by a trivial argument delta.
334+ expect ( groups . get ( 'read:{"path":"src/agent/prompts.ts"}' ) ) . toBe ( 6 ) ;
335+ expect ( groups . get ( 'grep:{"path":"src/agent","pattern":"narrate"}' ) ) . toBe (
336+ 2 ,
337+ ) ;
338+ const wastedTokens = ASTRA_REPRO_TRACE . reduce (
339+ ( sum , turn ) => sum + turn . resultTokens ,
340+ 0 ,
341+ ) ;
342+ expect ( wastedTokens ) . toBe ( 20020 ) ;
343+ } ) ;
344+ } ) ;
345+
346+ test ( "astra resolves to astra with the composed residual; thresholds stay default (CL-9027)" , ( ) => {
347+ const base = resolveModelFamilyPolicy ( {
348+ providerName : "unknown-provider" ,
349+ model : "unknown-model" ,
350+ } ) ;
351+ const gpt = resolveModelFamilyPolicy ( {
352+ providerName : "openai" ,
353+ model : "gpt-5.6" ,
354+ } ) ;
355+ for ( const orchestrator of [ false , true ] ) {
356+ const astra = resolveModelFamilyPolicy ( {
357+ providerName : "codex/default" ,
358+ model : "gpt-6-astra" ,
359+ orchestrator,
360+ } ) ;
361+ expect ( astra . family ) . toBe ( "astra" ) ;
362+ // Keeps the gpt narrate nudge and adds the evasion-specific rules.
363+ expect ( astra . promptResidual ) . toContain (
364+ "Narrate before tools (GPT worker):" ,
365+ ) ;
366+ expect ( astra . promptResidual ) . toContain ( "trivial argument changes" ) ;
367+ expect ( astra . promptResidual ) . not . toBe ( gpt . promptResidual ) ;
368+ // Evasion earns a forbidding residual, not tighter thresholds.
369+ expect ( astra . toolOnlyTurnNudgeAt ) . toBe ( base . toolOnlyTurnNudgeAt ) ;
370+ expect ( astra . subAgentStallTimeoutMs ) . toBe ( base . subAgentStallTimeoutMs ) ;
371+ expect ( astra . applyGrokFinishBias ) . toBe ( false ) ;
372+ expect ( astra . toolDisciplineRules ) . toBeUndefined ( ) ;
373+ expect ( astra . advertisedToolDeny ) . toEqual ( [ ] ) ;
374+ }
375+ } ) ;
376+
377+ test ( "sol and generic gpt stay gpt with the byte-identical narrate residual" , ( ) => {
378+ const generic = resolveModelFamilyPolicy ( {
379+ providerName : "openai" ,
380+ model : "gpt-5.6" ,
381+ } ) ;
382+ for ( const model of [
383+ "gpt-5.6-sol" ,
384+ "gpt-5.5" ,
385+ "gpt-5.6-luna" ,
386+ "gpt-5.6-terra" ,
387+ ] as const ) {
388+ for ( const orchestrator of [ false , true ] ) {
389+ const policy = resolveModelFamilyPolicy ( {
390+ providerName : "codex/default" ,
391+ model,
392+ orchestrator,
393+ } ) ;
394+ expect ( policy . family ) . toBe ( "gpt" ) ;
395+ expect ( policy . promptResidual ) . toBe ( generic . promptResidual ) ;
396+ }
397+ }
398+ } ) ;
203399} ) ;
0 commit comments