@@ -61,4 +61,169 @@ describe('render', () => {
6161
6262 expect ( result . history ) . toEqual ( [ { text : '1' } , { text : '2' } ] ) ;
6363 } ) ;
64+
65+ it ( 'simulates the boundary knapsack problem (loose boundary policy)' , async ( ) => {
66+ // 10k, 20k, 40k, 5k
67+ const mockNodes : ConcreteNode [ ] = [
68+ {
69+ id : 'D' ,
70+ type : NodeType . USER_PROMPT ,
71+ payload : { } as Part ,
72+ } as unknown as ConcreteNode ,
73+ {
74+ id : 'C' ,
75+ type : NodeType . AGENT_THOUGHT ,
76+ payload : { } as Part ,
77+ } as unknown as ConcreteNode ,
78+ {
79+ id : 'B' ,
80+ type : NodeType . USER_PROMPT ,
81+ payload : { } as Part ,
82+ } as unknown as ConcreteNode ,
83+ {
84+ id : 'A' ,
85+ type : NodeType . AGENT_THOUGHT ,
86+ payload : { } as Part ,
87+ } as unknown as ConcreteNode ,
88+ ] ;
89+
90+ const tokenMap : Record < string , number > = {
91+ D : 5000 ,
92+ C : 40000 ,
93+ B : 20000 ,
94+ A : 10000 ,
95+ } ;
96+
97+ const orchestrator = {
98+ executeTriggerSync : vi . fn ( async ( trigger , nodes , agedOutNodes ) =>
99+ nodes . filter ( ( n : ConcreteNode ) => ! agedOutNodes . has ( n . id ) ) ,
100+ ) ,
101+ } as unknown as PipelineOrchestrator ;
102+
103+ const sidecar = {
104+ config : {
105+ budget : { maxTokens : 150000 , retainedTokens : 65000 } ,
106+ } ,
107+ } as unknown as ContextProfile ;
108+
109+ const currentTokens = 160000 ;
110+
111+ const env = {
112+ llmClient : {
113+ countTokens : vi . fn ( ) . mockResolvedValue ( { totalTokens : 1000 } ) ,
114+ } ,
115+ tokenCalculator : {
116+ calculateConcreteListTokens : vi . fn ( ( nodes ) => {
117+ if ( nodes . length === 1 ) return tokenMap [ nodes [ 0 ] . id ] ;
118+ return currentTokens ;
119+ } ) ,
120+ calculateTokenBreakdown : vi . fn ( ( ) => ( { } ) ) ,
121+ } ,
122+ graphMapper : {
123+ fromGraph : vi . fn ( ( nodes : readonly ConcreteNode [ ] ) =>
124+ nodes . map ( ( n ) => ( { text : n . id } ) ) ,
125+ ) ,
126+ } ,
127+ } as unknown as ContextEnvironment ;
128+
129+ const tracer = {
130+ logEvent : vi . fn ( ) ,
131+ } as unknown as ContextTracer ;
132+
133+ const result = await render (
134+ mockNodes ,
135+ orchestrator ,
136+ sidecar ,
137+ tracer ,
138+ env ,
139+ new Map ( ) ,
140+ 0 ,
141+ new Set ( ) ,
142+ ) ;
143+
144+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
145+ const surviving = result . history . map ( ( c : any ) => c . text ) ;
146+ // Loose Boundary: A (10k), B (20k), C (40k). Total = 70k.
147+ // Adding C pushes rolling total (70k) above retainedTokens (65k).
148+ // Under loose policy, C survives. D is strictly older and drops.
149+ expect ( surviving ) . toEqual ( [ 'C' , 'B' , 'A' ] ) ; // D is dropped
150+ } ) ;
151+
152+ it ( 'drops nodes that are STRICTLY older than the boundary node' , async ( ) => {
153+ const mockNodes : ConcreteNode [ ] = [
154+ {
155+ id : 'A' ,
156+ type : NodeType . USER_PROMPT ,
157+ payload : { } as Part ,
158+ } as unknown as ConcreteNode ,
159+ {
160+ id : 'B' ,
161+ type : NodeType . AGENT_THOUGHT ,
162+ payload : { } as Part ,
163+ } as unknown as ConcreteNode ,
164+ {
165+ id : 'C' ,
166+ type : NodeType . USER_PROMPT ,
167+ payload : { } as Part ,
168+ } as unknown as ConcreteNode ,
169+ ] ;
170+
171+ const tokenMap : Record < string , number > = {
172+ C : 40000 ,
173+ B : 40000 ,
174+ A : 10000 ,
175+ } ;
176+
177+ const orchestrator = {
178+ executeTriggerSync : vi . fn ( async ( trigger , nodes , agedOutNodes ) =>
179+ nodes . filter ( ( n : ConcreteNode ) => ! agedOutNodes . has ( n . id ) ) ,
180+ ) ,
181+ } as unknown as PipelineOrchestrator ;
182+
183+ const sidecar = {
184+ config : {
185+ budget : { maxTokens : 150000 , retainedTokens : 65000 } ,
186+ } ,
187+ } as unknown as ContextProfile ;
188+
189+ const currentTokens = 160000 ;
190+
191+ const env = {
192+ llmClient : {
193+ countTokens : vi . fn ( ) . mockResolvedValue ( { totalTokens : 1000 } ) ,
194+ } ,
195+ tokenCalculator : {
196+ calculateConcreteListTokens : vi . fn ( ( nodes ) => {
197+ if ( nodes . length === 1 ) return tokenMap [ nodes [ 0 ] . id ] ;
198+ return currentTokens ;
199+ } ) ,
200+ calculateTokenBreakdown : vi . fn ( ( ) => ( { } ) ) ,
201+ } ,
202+ graphMapper : {
203+ fromGraph : vi . fn ( ( nodes : readonly ConcreteNode [ ] ) =>
204+ nodes . map ( ( n ) => ( { text : n . id } ) ) ,
205+ ) ,
206+ } ,
207+ } as unknown as ContextEnvironment ;
208+
209+ const tracer = {
210+ logEvent : vi . fn ( ) ,
211+ } as unknown as ContextTracer ;
212+
213+ const result = await render (
214+ mockNodes ,
215+ orchestrator ,
216+ sidecar ,
217+ tracer ,
218+ env ,
219+ new Map ( ) ,
220+ 0 ,
221+ new Set ( ) ,
222+ ) ;
223+
224+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
225+ const surviving = result . history . map ( ( c : any ) => c . text ) ;
226+ // C(40k), B(40k). Adding B pushes total to 80k. B is the boundary node and survives. A drops.
227+ expect ( surviving ) . toEqual ( [ 'B' , 'C' ] ) ; // A is dropped
228+ } ) ;
64229} ) ;
0 commit comments