@@ -4,6 +4,7 @@ import "../../test-helpers/pi-coding-agent-token-mock.js";
44import { estimateToolResultReductionPotential } from "../tool-result-truncation.js" ;
55
66let PREEMPTIVE_OVERFLOW_ERROR_TEXT : typeof import ( "./preemptive-compaction.js" ) . PREEMPTIVE_OVERFLOW_ERROR_TEXT ;
7+ let estimateLlmBoundaryTokenPressure : typeof import ( "./preemptive-compaction.js" ) . estimateLlmBoundaryTokenPressure ;
78let estimatePrePromptTokens : typeof import ( "./preemptive-compaction.js" ) . estimatePrePromptTokens ;
89let formatPrePromptPrecheckLog : typeof import ( "./preemptive-compaction.js" ) . formatPrePromptPrecheckLog ;
910let shouldPreemptivelyCompactBeforePrompt : typeof import ( "./preemptive-compaction.js" ) . shouldPreemptivelyCompactBeforePrompt ;
@@ -12,6 +13,7 @@ beforeAll(async () => {
1213 vi . resetModules ( ) ;
1314 ( {
1415 PREEMPTIVE_OVERFLOW_ERROR_TEXT ,
16+ estimateLlmBoundaryTokenPressure,
1517 estimatePrePromptTokens,
1618 formatPrePromptPrecheckLog,
1719 shouldPreemptivelyCompactBeforePrompt,
@@ -39,6 +41,32 @@ function makeToolResultMessage(...texts: string[]): AgentMessage {
3941 } as AgentMessage ;
4042}
4143
44+ function makeJsonToolResultMessage ( payload : unknown ) : AgentMessage {
45+ return {
46+ role : "toolResult" ,
47+ toolCallId : `call_${ timestamp } ` ,
48+ toolName : "json_tool" ,
49+ content : [ { type : "json" , payload } ] ,
50+ isError : false ,
51+ timestamp : timestamp ++ ,
52+ } as unknown as AgentMessage ;
53+ }
54+
55+ function makeAssistantToolCall ( args : unknown ) : AgentMessage {
56+ return {
57+ role : "assistant" ,
58+ content : [
59+ {
60+ type : "toolCall" ,
61+ id : `call_${ timestamp } ` ,
62+ name : "bulk_lookup" ,
63+ arguments : args ,
64+ } ,
65+ ] ,
66+ timestamp : timestamp ++ ,
67+ } as AgentMessage ;
68+ }
69+
4270describe ( "preemptive-compaction" , ( ) => {
4371 const verboseHistory =
4472 "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu " . repeat ( 40 ) ;
@@ -146,6 +174,85 @@ describe("preemptive-compaction", () => {
146174 expect ( result . estimatedPromptTokens ) . toBeGreaterThan ( result . promptBudgetBeforeReserve ) ;
147175 } ) ;
148176
177+ it ( "counts array/object tool-result payloads at the LLM boundary" , ( ) => {
178+ const objectPayload = {
179+ rows : Array . from ( { length : 120 } , ( _ , index ) => ( {
180+ path : `/tmp/generated-${ index } .txt` ,
181+ body : "x" . repeat ( 1_500 ) ,
182+ } ) ) ,
183+ } ;
184+ const messages = [ makeJsonToolResultMessage ( objectPayload ) ] ;
185+ const estimatedPromptTokens = estimateLlmBoundaryTokenPressure ( {
186+ messages,
187+ systemPrompt : "sys" ,
188+ prompt : "continue" ,
189+ } ) ;
190+
191+ expect ( estimatedPromptTokens ) . toBeGreaterThan ( 80_000 ) ;
192+
193+ const result = shouldPreemptivelyCompactBeforePrompt ( {
194+ messages,
195+ systemPrompt : "sys" ,
196+ prompt : "continue" ,
197+ contextTokenBudget : 96_000 ,
198+ reserveTokens : 20_000 ,
199+ } ) ;
200+
201+ expect ( result . route ) . not . toBe ( "fits" ) ;
202+ expect ( result . estimatedPromptTokens ) . toBe ( estimatedPromptTokens ) ;
203+ expect ( result . overflowTokens ) . toBeGreaterThan ( 0 ) ;
204+ } ) ;
205+
206+ it ( "counts assistant tool-call arguments instead of trusting text-only token estimates" , ( ) => {
207+ const messages = [
208+ makeAssistantToolCall ( {
209+ queryPlan : "find relevant files" ,
210+ candidates : Array . from ( { length : 100 } , ( _ , index ) => ( {
211+ path : `/repo/file-${ index } .ts` ,
212+ content : "z" . repeat ( 1_000 ) ,
213+ } ) ) ,
214+ } ) ,
215+ ] ;
216+ const estimatedPromptTokens = estimatePrePromptTokens ( {
217+ messages,
218+ systemPrompt : "sys" ,
219+ prompt : "continue" ,
220+ } ) ;
221+
222+ expect ( estimatedPromptTokens ) . toBeGreaterThan ( 30_000 ) ;
223+ } ) ;
224+
225+ it ( "prechecks a regression-sized synthetic tool-heavy transcript as over budget" , ( ) => {
226+ const toolResultCharsPerMessage = Math . ceil ( 427_000 / 120 ) ;
227+ const generalCharsPerMessage = Math . ceil ( ( 503_000 - 427_000 ) / 121 ) ;
228+ const messages : AgentMessage [ ] = [ ] ;
229+ for ( let index = 0 ; index < 241 ; index += 1 ) {
230+ if ( index % 2 === 0 ) {
231+ messages . push (
232+ makeToolResultMessage (
233+ "t" . repeat ( toolResultCharsPerMessage ) ,
234+ JSON . stringify ( { index, payload : "p" . repeat ( 80 ) } ) ,
235+ ) ,
236+ ) ;
237+ } else {
238+ messages . push ( makeAssistantHistory ( "h" . repeat ( generalCharsPerMessage ) ) ) ;
239+ }
240+ }
241+
242+ const result = shouldPreemptivelyCompactBeforePrompt ( {
243+ messages,
244+ systemPrompt : "system" . repeat ( 200 ) ,
245+ prompt : "continue" ,
246+ contextTokenBudget : 200_000 ,
247+ reserveTokens : 32_000 ,
248+ } ) ;
249+
250+ expect ( result . estimatedPromptTokens ) . toBeGreaterThan ( 200_000 ) ;
251+ expect ( result . promptBudgetBeforeReserve ) . toBe ( 168_000 ) ;
252+ expect ( result . route ) . not . toBe ( "fits" ) ;
253+ expect ( result . overflowTokens ) . toBeGreaterThan ( 0 ) ;
254+ } ) ;
255+
149256 it ( "caps reserve tokens so small context models keep usable prompt budget" , ( ) => {
150257 const result = shouldPreemptivelyCompactBeforePrompt ( {
151258 messages : [ makeAssistantHistory ( "short history" ) ] ,
0 commit comments