@@ -254,6 +254,70 @@ test("injectAdditions: no user message at all → injection skipped, reported wi
254254 assert . equal ( reanchored [ 0 ] . anchorHash , null ) ;
255255} ) ;
256256
257+ // BITE — the LIFO bug (BACKLOG "READY — fix injectAdditions' LIFO stacking").
258+ // Real capture s-dc3f8071, n=372-397: an MCP-tool-discovery cascade produces
259+ // one new `additions` entry per request, all anchored to the SAME message
260+ // (the real conversation stays at 1 message the whole burst). The buggy
261+ // implementation re-finds the anchor fresh on every iteration (the search
262+ // excludes role==="system", so already-injected additions are invisible to
263+ // it) and always splices at anchorIdx+1 — so the newest addition always
264+ // lands closest to the anchor, pushing every earlier addition one slot
265+ // further back: a LIFO stack that reorders the already-forwarded prefix on
266+ // every new addition. Fix: a shared anchor's run stays in discovery order
267+ // (FIFO) — a new addition appends AFTER the additions already injected
268+ // there, so the forwarded prefix is a byte-stable prefix of every
269+ // subsequent output and only the tail of the run grows.
270+ test ( "injectAdditions: three additions sharing one anchor → output is discovery order (FIFO), not LIFO" , ( ) => {
271+ const u0 = { role : "user" , content : [ { type : "text" , text : "u0" } ] } ;
272+ const sharedAnchor = anchorHash ( u0 ) ;
273+ const addA = buildToolAdditionMessage ( [ "ToolA" ] ) ;
274+ const addB = buildToolAdditionMessage ( [ "ToolB" ] ) ;
275+ const addC = buildToolAdditionMessage ( [ "ToolC" ] ) ;
276+
277+ // additions array is in DISCOVERY order (oldest first), matching how
278+ // onRequest concatenates them across successive requests.
279+ const additions = [
280+ { names : [ "ToolA" ] , anchorHash : sharedAnchor , message : addA } ,
281+ { names : [ "ToolB" ] , anchorHash : sharedAnchor , message : addB } ,
282+ { names : [ "ToolC" ] , anchorHash : sharedAnchor , message : addC } ,
283+ ] ;
284+
285+ const { messages } = injectAdditions ( [ u0 ] , additions ) ;
286+ assert . deepEqual (
287+ messages . map ( ( m ) => m . content ?. [ 0 ] ?. tool ?. name ?? "u0" ) ,
288+ [ "u0" , "ToolA" , "ToolB" , "ToolC" ] ,
289+ "run stays in discovery order — ToolA first (oldest), ToolC last (newest), never reordered" ,
290+ ) ;
291+ } ) ;
292+
293+ test ( "injectAdditions: shared-anchor prefix stability — output N is a byte-prefix of output N+1" , ( ) => {
294+ const u0 = { role : "user" , content : [ { type : "text" , text : "u0" } ] } ;
295+ const sharedAnchor = anchorHash ( u0 ) ;
296+ const addA = buildToolAdditionMessage ( [ "ToolA" ] ) ;
297+ const addB = buildToolAdditionMessage ( [ "ToolB" ] ) ;
298+
299+ // Simulates two successive requests: first only ToolA has been discovered,
300+ // then ToolB arrives too (additions accumulate, oldest first — as onRequest
301+ // does via `additions.concat([...])`).
302+ const afterFirst = injectAdditions ( [ u0 ] , [ { names : [ "ToolA" ] , anchorHash : sharedAnchor , message : addA } ] ) ;
303+ const afterSecond = injectAdditions (
304+ [ u0 ] ,
305+ [
306+ { names : [ "ToolA" ] , anchorHash : sharedAnchor , message : addA } ,
307+ { names : [ "ToolB" ] , anchorHash : sharedAnchor , message : addB } ,
308+ ] ,
309+ ) ;
310+
311+ const prefixBytes = JSON . stringify ( afterFirst . messages ) ;
312+ const nextBytes = JSON . stringify ( afterSecond . messages . slice ( 0 , afterFirst . messages . length ) ) ;
313+ assert . equal (
314+ nextBytes ,
315+ prefixBytes ,
316+ "the already-forwarded prefix must be byte-identical once a new addition arrives — only the tail grows" ,
317+ ) ;
318+ assert . equal ( afterSecond . messages . length , 3 , "the new addition appends at the tail of the run" ) ;
319+ } ) ;
320+
257321test ( "forwardedTools: names covered by additions get defer_loading, others stay untouched" , ( ) => {
258322 const known = [ tool ( "Read" ) , tool ( "SendMessage" ) ] ;
259323 const additions = [ { names : [ "SendMessage" ] , anchorHash : "h" , message : { } } ] ;
@@ -449,6 +513,53 @@ test("onRequest: pruned anchor → re-anchor once, stable thereafter", async ()
449513 }
450514} ) ;
451515
516+ test ( "onRequest BITE: MCP-discovery cascade — same 1-message conversation, tools[] grows 3x → additions stack in discovery order, prefix stable" , async ( ) => {
517+ // Mirrors the real capture (s-dc3f8071, n=372-397): CC's own progressive
518+ // MCP-tool-discovery cascade at session boot sends one new tool batch per
519+ // request while the real conversation never grows past 1 message, so every
520+ // addition shares the identical anchor (messages[0]).
521+ const dir = await newTmp ( ) ;
522+ const headers = { "x-claude-code-session-id" : "sess-cascade" } ;
523+ try {
524+ await withEnvAsync ( { CACHE_FIX_TOOL_REWRITE : "1" } , async ( ) => {
525+ const u0 = { role : "user" , content : [ { type : "text" , text : "u0" } ] } ;
526+ const base = { system : [ ] , messages : [ u0 ] , model : "claude-opus-5" } ;
527+
528+ await runExt ( { ...base , tools : [ tool ( "Read" ) , tool ( "Bash" ) ] } , { headers, dir } ) ; // no-baseline
529+ const ctx1 = await runExt (
530+ { ...base , tools : [ tool ( "Read" ) , tool ( "Bash" ) , tool ( "ToolA" ) ] } ,
531+ { headers, dir } ,
532+ ) ;
533+ const ctx2 = await runExt (
534+ { ...base , tools : [ tool ( "Read" ) , tool ( "Bash" ) , tool ( "ToolA" ) , tool ( "ToolB" ) ] } ,
535+ { headers, dir } ,
536+ ) ;
537+ const ctx3 = await runExt (
538+ { ...base , tools : [ tool ( "Read" ) , tool ( "Bash" ) , tool ( "ToolA" ) , tool ( "ToolB" ) , tool ( "ToolC" ) ] } ,
539+ { headers, dir } ,
540+ ) ;
541+
542+ const names = ( ctx ) =>
543+ ctx . body . messages
544+ . filter ( ( m ) => m . role === "system" && Array . isArray ( m . content ) && m . content [ 0 ] ?. type === "tool_addition" )
545+ . flatMap ( ( m ) => m . content . map ( ( b ) => b . tool . name ) ) ;
546+
547+ assert . deepEqual ( names ( ctx1 ) , [ "ToolA" ] ) ;
548+ assert . deepEqual ( names ( ctx2 ) , [ "ToolA" , "ToolB" ] , "ToolA stays first — discovery order, not LIFO" ) ;
549+ assert . deepEqual ( names ( ctx3 ) , [ "ToolA" , "ToolB" , "ToolC" ] , "run grows only at the tail" ) ;
550+
551+ // The forwarded prefix already produced must be a byte-prefix of the
552+ // next request's output — this is the "reorders the already-forwarded
553+ // prefix" bust the probe measured.
554+ const prefixOf = ( ctx , n ) => JSON . stringify ( ctx . body . messages . slice ( 0 , n ) ) ;
555+ assert . equal ( prefixOf ( ctx2 , ctx1 . body . messages . length ) , JSON . stringify ( ctx1 . body . messages ) ) ;
556+ assert . equal ( prefixOf ( ctx3 , ctx2 . body . messages . length ) , JSON . stringify ( ctx2 . body . messages ) ) ;
557+ } ) ;
558+ } finally {
559+ await rm ( dir , { recursive : true , force : true } ) ;
560+ }
561+ } ) ;
562+
452563test ( "onRequest: a tool removed after an addition → HELD (rewrite, passthrough of held tool), no beta header (nothing new to defer)" , async ( ) => {
453564 const dir = await newTmp ( ) ;
454565 const headers = { "x-claude-code-session-id" : "sess-hold" } ;
0 commit comments