Skip to content

Commit 1647ca7

Browse files
committed
Fix for context pruner: move referenced variables inside handleSteps. Add unit test
1 parent 26e276d commit 1647ca7

File tree

2 files changed

+412
-31
lines changed

2 files changed

+412
-31
lines changed

agents/__tests__/context-pruner.test.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,147 @@ import contextPruner from '../context-pruner'
44

55
import 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(/^function\*\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+
7148
const createMessage = (
8149
role: 'user' | 'assistant',
9150
content: string,

0 commit comments

Comments
 (0)