@@ -4,6 +4,147 @@ import contextPruner from '../context-pruner'
44
55import type { Message , ToolMessage } from '../types/util-types'
66
7+ /**
8+ * Regression test: Verify handleSteps can be serialized and run in isolation.
9+ * This catches bugs like CACHE_EXPIRY_MS not being defined when the function
10+ * is stringified and executed in a QuickJS sandbox.
11+ *
12+ * The handleSteps function is serialized to a string and executed in a sandbox
13+ * at runtime. Any variables referenced from outside the function scope will
14+ * cause "X is not defined" errors. This test ensures all constants and helper
15+ * functions are defined inside handleSteps.
16+ */
17+ describe ( 'context-pruner handleSteps serialization' , ( ) => {
18+ test ( 'handleSteps works when serialized and executed in isolation (regression test for external variable references)' , ( ) => {
19+ // Get the handleSteps function and convert it to a string, just like the SDK does
20+ const handleStepsString = contextPruner . handleSteps ! . toString ( )
21+
22+ // Verify it's a valid generator function string
23+ expect ( handleStepsString ) . toMatch ( / ^ f u n c t i o n \* \s * \( / )
24+
25+ // Create a new function from the string to simulate sandbox isolation.
26+ // This will fail if handleSteps references any external variables
27+ // (like CACHE_EXPIRY_MS was before the fix).
28+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
29+ const isolatedFunction = new Function ( `return (${ handleStepsString } )` ) ( )
30+
31+ // Create minimal mock data to run the function
32+ const mockAgentState = {
33+ messageHistory : [
34+ {
35+ role : 'user' ,
36+ content : [ { type : 'text' , text : 'Hello' } ] ,
37+ } ,
38+ {
39+ role : 'assistant' ,
40+ content : [ { type : 'text' , text : 'Hi there!' } ] ,
41+ } ,
42+ ] ,
43+ contextTokenCount : 100 , // Under the limit, so it won't prune
44+ }
45+
46+ const mockLogger = {
47+ debug : ( ) => { } ,
48+ info : ( ) => { } ,
49+ warn : ( ) => { } ,
50+ error : ( ) => { } ,
51+ }
52+
53+ // Run the isolated function - this will throw if any external variables are undefined
54+ const generator = isolatedFunction ( {
55+ agentState : mockAgentState ,
56+ logger : mockLogger ,
57+ params : { maxContextLength : 200000 } ,
58+ } )
59+
60+ // Consume the generator to ensure all code paths execute
61+ const results : unknown [ ] = [ ]
62+ let result = generator . next ( )
63+ while ( ! result . done ) {
64+ results . push ( result . value )
65+ result = generator . next ( )
66+ }
67+
68+ // Should have produced a result (set_messages call)
69+ expect ( results . length ) . toBeGreaterThan ( 0 )
70+ } )
71+
72+ test ( 'handleSteps works in isolation when pruning is triggered' , ( ) => {
73+ // Get the handleSteps function and convert it to a string
74+ const handleStepsString = contextPruner . handleSteps ! . toString ( )
75+
76+ // Create a new function from the string to simulate sandbox isolation
77+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
78+ const isolatedFunction = new Function ( `return (${ handleStepsString } )` ) ( )
79+
80+ // Create mock data that will trigger pruning (context over limit)
81+ const mockAgentState = {
82+ messageHistory : [
83+ {
84+ role : 'user' ,
85+ content : [ { type : 'text' , text : 'Please help me with a task' } ] ,
86+ } ,
87+ {
88+ role : 'assistant' ,
89+ content : [
90+ { type : 'text' , text : 'Sure, I can help with that' } ,
91+ {
92+ type : 'tool-call' ,
93+ toolCallId : 'call-1' ,
94+ toolName : 'read_files' ,
95+ input : { paths : [ 'test.ts' ] } ,
96+ } ,
97+ ] ,
98+ } ,
99+ {
100+ role : 'tool' ,
101+ toolCallId : 'call-1' ,
102+ toolName : 'read_files' ,
103+ content : [ { type : 'json' , value : { content : 'file content' } } ] ,
104+ } ,
105+ {
106+ role : 'user' ,
107+ content : [ { type : 'text' , text : 'Thanks!' } ] ,
108+ } ,
109+ ] ,
110+ contextTokenCount : 250000 , // Over the limit, will trigger pruning
111+ }
112+
113+ const mockLogger = {
114+ debug : ( ) => { } ,
115+ info : ( ) => { } ,
116+ warn : ( ) => { } ,
117+ error : ( ) => { } ,
118+ }
119+
120+ // Run the isolated function - exercises all the helper functions like
121+ // truncateLongText, estimateTokens, getTextContent, summarizeToolCall
122+ const generator = isolatedFunction ( {
123+ agentState : mockAgentState ,
124+ logger : mockLogger ,
125+ params : { maxContextLength : 200000 } ,
126+ } )
127+
128+ // Consume the generator
129+ const results : any [ ] = [ ]
130+ let result = generator . next ( )
131+ while ( ! result . done ) {
132+ results . push ( result . value )
133+ result = generator . next ( )
134+ }
135+
136+ // Should have produced a result
137+ expect ( results . length ) . toBeGreaterThan ( 0 )
138+
139+ // The result should contain a summary
140+ const setMessagesCall = results [ 0 ]
141+ expect ( setMessagesCall . toolName ) . toBe ( 'set_messages' )
142+ expect ( setMessagesCall . input . messages [ 0 ] . content [ 0 ] . text ) . toContain (
143+ '<conversation_summary>' ,
144+ )
145+ } )
146+ } )
147+
7148const createMessage = (
8149 role : 'user' | 'assistant' ,
9150 content : string ,
0 commit comments