@@ -8,14 +8,25 @@ import type {
88} from "@intx/types/runtime" ;
99import { createCompactionGovernor } from "./compaction.js" ;
1010import { compactionThresholdFor } from "../provider/context-window.js" ;
11+ import { COMPACTOR_KEEP_RECENT_TURNS , compactorNoOpFloor } from "../session/compactor.js" ;
1112
1213const capabilities = {
1314 infer : ( options ?: unknown ) => ( { type : "infer" , ...( options !== undefined ? { options } : { } ) } ) ,
1415 compact : ( compactor : string , reason : string ) => ( { type : "compact" , compactor, reason } ) ,
1516} as unknown as ReactorCapabilities ;
1617
18+ // Distinct, non-zero cacheRead/cacheWrite so a test asserting on the total
19+ // would fail if compaction.ts ever stopped routing through the shared
20+ // contextTokensFromUsage and summed only `input` again.
1721function usage ( input : number ) : TokenUsage {
18- return { input, output : 0 , cacheRead : 0 , cacheWrite : 0 , thinking : 0 } ;
22+ return { input, output : 0 , cacheRead : 3 , cacheWrite : 5 , thinking : 0 } ;
23+ }
24+
25+ // A provider that truly omits usage reports every field as zero, not just
26+ // `input` — distinct from usage(0), which still carries the fixture's
27+ // non-zero cache values above.
28+ function zeroUsage ( ) : TokenUsage {
29+ return { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 , thinking : 0 } ;
1930}
2031
2132function turnsOfLength ( count : number , textLength : number ) : ConversationTurn [ ] {
@@ -39,7 +50,7 @@ function inferenceDoneWithoutUsage(): Extract<ReactorInboundEvent, { type: "infe
3950 return {
4051 type : "inference.done" ,
4152 turn : { role : "assistant" , content : [ { type : "text" , text : "ok" } ] } ,
42- usage : usage ( 0 ) ,
53+ usage : zeroUsage ( ) ,
4354 source : { sourceId : "s" , provider : "p" , model : "m" } ,
4455 } as unknown as Extract < ReactorInboundEvent , { type : "inference.done" } > ;
4556}
@@ -281,4 +292,43 @@ describe("compaction governor", () => {
281292 expect ( actions ) . not . toBeNull ( ) ;
282293 expect ( actions ?. some ( ( a ) => a . type === "compact" ) ) . toBe ( true ) ;
283294 } ) ;
295+
296+ test ( "never arms at the exact turn count createPruningCompactor no-ops on" , ( ) => {
297+ // createPruningCompactor's own no-op floor (session/compactor.ts) is
298+ // compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS). Arming at or below it
299+ // would spend a reactor cycle that is guaranteed to shrink nothing.
300+ const floor = compactorNoOpFloor ( COMPACTOR_KEEP_RECENT_TURNS ) ;
301+ const governor = createCompactionGovernor ( ( ) => { } ) ;
302+ governor . noteInferenceDone ( inferenceDone ( overThreshold ) , turnsOfLength ( floor , 1 ) ) ;
303+ expect ( governor . interceptActions ( toolDone ( ) , inferAction , capabilities ) ) . toBeNull ( ) ;
304+ } ) ;
305+
306+ test ( "arms one turn past the floor createPruningCompactor no-ops on" , ( ) => {
307+ const floor = compactorNoOpFloor ( COMPACTOR_KEEP_RECENT_TURNS ) ;
308+ const governor = createCompactionGovernor ( ( ) => { } ) ;
309+ governor . noteInferenceDone ( inferenceDone ( overThreshold ) , turnsOfLength ( floor + 1 , 1 ) ) ;
310+ const actions = governor . interceptActions ( toolDone ( ) , inferAction , capabilities ) ;
311+ expect ( actions ) . not . toBeNull ( ) ;
312+ expect ( actions ?. some ( ( a ) => a . type === "compact" ) ) . toBe ( true ) ;
313+ } ) ;
314+
315+ test ( "does not catch a huge tool result mid-cycle when the provider reported real usage" , ( ) => {
316+ // Disclosed, accepted gap: the live tool.done re-check only re-derives
317+ // arming from the local estimate when the last inference.done snapshot
318+ // came from that same estimate (usingEstimate). When the provider
319+ // reported real usage under threshold, that snapshot is trusted as
320+ // authoritative until the next inference.done — a huge tool result
321+ // arriving in between is not caught until then, unlike the
322+ // usage-omitted case covered above.
323+ const governor = createCompactionGovernor ( ( ) => { } ) ;
324+ governor . noteInferenceDone ( inferenceDone ( 1000 ) , tenTurns ) ;
325+ expect ( governor . interceptActions ( toolDone ( ) , inferAction , capabilities ) ) . toBeNull ( ) ;
326+
327+ const overThresholdChars = ( compactionThresholdFor ( "m" ) + 1 ) * 4 ;
328+ governor . syncFromTurns ( turnsOfLength ( 10 , Math . ceil ( overThresholdChars / 10 ) ) ) ;
329+
330+ // Still null: the live estimate is now over threshold, but the last
331+ // arming decision trusted reported usage, so it is not re-checked here.
332+ expect ( governor . interceptActions ( toolDone ( ) , inferAction , capabilities ) ) . toBeNull ( ) ;
333+ } ) ;
284334} ) ;
0 commit comments