Skip to content

Commit 99e4f7f

Browse files
committed
Prevent image content propagation to spawned subagents
- Add tests verifying images are NOT passed via content parameter to subagents - Images should only be visible through includeMessageHistory, not propagated - Prevents duplicate images when subagents inherit message history
1 parent 3ee36e8 commit 99e4f7f

File tree

2 files changed

+359
-0
lines changed

2 files changed

+359
-0
lines changed
Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
import { TEST_USER_ID } from '@codebuff/common/old-constants'
2+
import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime'
3+
import { getInitialSessionState } from '@codebuff/common/types/session-state'
4+
import {
5+
assistantMessage,
6+
userMessage,
7+
} from '@codebuff/common/util/messages'
8+
import {
9+
describe,
10+
expect,
11+
it,
12+
beforeEach,
13+
afterEach,
14+
mock,
15+
spyOn,
16+
} from 'bun:test'
17+
18+
import { mockFileContext } from './test-utils'
19+
import * as runAgentStep from '../run-agent-step'
20+
import { handleSpawnAgentInline } from '../tools/handlers/tool/spawn-agent-inline'
21+
import { handleSpawnAgents } from '../tools/handlers/tool/spawn-agents'
22+
23+
import type { CodebuffToolCall } from '@codebuff/common/tools/list'
24+
import type { AgentTemplate } from '@codebuff/common/types/agent-template'
25+
import type { ParamsExcluding } from '@codebuff/common/types/function-params'
26+
import type { ImagePart, TextPart } from '@codebuff/common/types/messages/content-part'
27+
28+
/**
29+
* Tests to verify that image content is NOT propagated to spawned subagents via the `content` parameter.
30+
*
31+
* Why images should NOT be passed via `content` to subagents:
32+
* 1. When `includeMessageHistory: true`, subagents already see images through the inherited message history
33+
* 2. The `content` parameter is used to build the subagent's OWN initial user message
34+
* 3. Passing parent's `content` creates a hybrid message: subagent's prompt + parent's images
35+
* 4. This causes duplicate images: once in history, once in the new USER_PROMPT message
36+
*
37+
* If subagents need to see images, they get them through `includeMessageHistory: true`,
38+
* not by propagating images in the `content` parameter.
39+
*/
40+
describe('Spawn Agents Image Content Propagation', () => {
41+
let mockSendSubagentChunk: any
42+
let mockLoopAgentSteps: any
43+
let capturedLoopAgentStepsParams: any
44+
45+
let handleSpawnAgentsBaseParams: ParamsExcluding<
46+
typeof handleSpawnAgents,
47+
'agentState' | 'agentTemplate' | 'localAgentTemplates' | 'toolCall'
48+
>
49+
50+
beforeEach(() => {
51+
// Mock sendSubagentChunk
52+
mockSendSubagentChunk = mock(() => {})
53+
54+
// Mock loopAgentSteps to capture all parameters passed to it
55+
mockLoopAgentSteps = spyOn(
56+
runAgentStep,
57+
'loopAgentSteps',
58+
).mockImplementation(async (options) => {
59+
capturedLoopAgentStepsParams = options
60+
return {
61+
agentState: {
62+
...options.agentState,
63+
messageHistory: [
64+
...options.agentState.messageHistory,
65+
assistantMessage('Mock agent response'),
66+
],
67+
},
68+
output: { type: 'lastMessage', value: [assistantMessage('Mock agent response')] },
69+
}
70+
})
71+
72+
handleSpawnAgentsBaseParams = {
73+
...TEST_AGENT_RUNTIME_IMPL,
74+
ancestorRunIds: [],
75+
clientSessionId: 'test-session',
76+
fingerprintId: 'test-fingerprint',
77+
fileContext: mockFileContext,
78+
repoId: undefined,
79+
repoUrl: undefined,
80+
previousToolCallFinished: Promise.resolve(),
81+
sendSubagentChunk: mockSendSubagentChunk,
82+
signal: new AbortController().signal,
83+
system: 'Test system prompt',
84+
tools: {},
85+
userId: TEST_USER_ID,
86+
userInputId: 'test-input',
87+
writeToClient: () => {},
88+
}
89+
})
90+
91+
afterEach(() => {
92+
mock.restore()
93+
capturedLoopAgentStepsParams = undefined
94+
})
95+
96+
const createMockAgent = (
97+
id: string,
98+
includeMessageHistory = true,
99+
): AgentTemplate => ({
100+
id,
101+
displayName: `Mock ${id}`,
102+
outputMode: 'last_message' as const,
103+
inputSchema: {
104+
prompt: {
105+
safeParse: () => ({ success: true }),
106+
} as any,
107+
},
108+
spawnerPrompt: '',
109+
model: '',
110+
includeMessageHistory,
111+
inheritParentSystemPrompt: false,
112+
mcpServers: {},
113+
toolNames: [],
114+
spawnableAgents: ['child-agent'],
115+
systemPrompt: '',
116+
instructionsPrompt: '',
117+
stepPrompt: '',
118+
})
119+
120+
const createSpawnToolCall = (
121+
agentType: string,
122+
prompt = 'test prompt',
123+
): CodebuffToolCall<'spawn_agents'> => ({
124+
toolName: 'spawn_agents' as const,
125+
toolCallId: 'test-tool-call-id',
126+
input: {
127+
agents: [{ agent_type: agentType, prompt }],
128+
},
129+
})
130+
131+
const createInlineSpawnToolCall = (
132+
agentType: string,
133+
prompt = 'test prompt',
134+
): CodebuffToolCall<'spawn_agent_inline'> => ({
135+
toolName: 'spawn_agent_inline' as const,
136+
toolCallId: 'test-tool-call-id',
137+
input: {
138+
agent_type: agentType,
139+
prompt,
140+
},
141+
})
142+
143+
const createImageContent = (): Array<TextPart | ImagePart> => [
144+
{ type: 'text', text: '<user_message>Check this image</user_message>' },
145+
{
146+
type: 'image',
147+
image: 'base64-encoded-image-data-here',
148+
mediaType: 'image/png',
149+
},
150+
]
151+
152+
describe('handleSpawnAgents - image content should NOT be passed to subagents', () => {
153+
it('should NOT pass image content to spawned subagent', async () => {
154+
const parentAgent = createMockAgent('parent', true)
155+
const childAgent = createMockAgent('child-agent', true)
156+
const sessionState = getInitialSessionState(mockFileContext)
157+
const toolCall = createSpawnToolCall('child-agent', 'analyze the image')
158+
159+
// Simulate that parent was called with image content
160+
const imageContent = createImageContent()
161+
162+
sessionState.mainAgentState.messageHistory = [
163+
userMessage('Hello'),
164+
assistantMessage('Hi there!'),
165+
]
166+
167+
// Call handleSpawnAgents with content parameter (simulating parent had images)
168+
await handleSpawnAgents({
169+
...handleSpawnAgentsBaseParams,
170+
agentState: sessionState.mainAgentState,
171+
agentTemplate: parentAgent,
172+
localAgentTemplates: { 'child-agent': childAgent },
173+
toolCall,
174+
// This is the key: parent context includes image content
175+
content: imageContent,
176+
} as any)
177+
178+
// Verify that loopAgentSteps was called
179+
expect(mockLoopAgentSteps).toHaveBeenCalledTimes(1)
180+
181+
// The spawned subagent should NOT receive the image content
182+
// Images should only be attached to the original user message, not propagated
183+
expect(capturedLoopAgentStepsParams.content).toBeUndefined()
184+
})
185+
186+
it('should NOT include images in spawned subagent initial messages', async () => {
187+
const parentAgent = createMockAgent('parent', true)
188+
const childAgent = createMockAgent('child-agent', true)
189+
const sessionState = getInitialSessionState(mockFileContext)
190+
const toolCall = createSpawnToolCall('child-agent', 'do something')
191+
192+
const imageContent = createImageContent()
193+
194+
sessionState.mainAgentState.messageHistory = [
195+
userMessage('Hello'),
196+
]
197+
198+
await handleSpawnAgents({
199+
...handleSpawnAgentsBaseParams,
200+
agentState: sessionState.mainAgentState,
201+
agentTemplate: parentAgent,
202+
localAgentTemplates: { 'child-agent': childAgent },
203+
toolCall,
204+
content: imageContent,
205+
} as any)
206+
207+
expect(mockLoopAgentSteps).toHaveBeenCalledTimes(1)
208+
209+
// Verify no image content was passed
210+
const contentParam = capturedLoopAgentStepsParams.content
211+
expect(contentParam).toBeUndefined()
212+
})
213+
214+
it('should pass prompt to subagent but NOT image content', async () => {
215+
const parentAgent = createMockAgent('parent', true)
216+
const childAgent = createMockAgent('child-agent', true)
217+
const sessionState = getInitialSessionState(mockFileContext)
218+
const subagentPrompt = 'Please analyze this for me'
219+
const toolCall = createSpawnToolCall('child-agent', subagentPrompt)
220+
221+
const imageContent = createImageContent()
222+
223+
sessionState.mainAgentState.messageHistory = []
224+
225+
await handleSpawnAgents({
226+
...handleSpawnAgentsBaseParams,
227+
agentState: sessionState.mainAgentState,
228+
agentTemplate: parentAgent,
229+
localAgentTemplates: { 'child-agent': childAgent },
230+
toolCall,
231+
content: imageContent,
232+
} as any)
233+
234+
expect(mockLoopAgentSteps).toHaveBeenCalledTimes(1)
235+
236+
// Prompt should be passed
237+
expect(capturedLoopAgentStepsParams.prompt).toBe(subagentPrompt)
238+
239+
// But content (images) should NOT be passed
240+
expect(capturedLoopAgentStepsParams.content).toBeUndefined()
241+
})
242+
})
243+
244+
describe('handleSpawnAgentInline - image content should NOT be passed to inline subagents', () => {
245+
it('should NOT pass image content to inline spawned subagent', async () => {
246+
const parentAgent = createMockAgent('parent', true)
247+
const childAgent = createMockAgent('child-agent', true)
248+
const sessionState = getInitialSessionState(mockFileContext)
249+
const toolCall = createInlineSpawnToolCall('child-agent', 'inline task')
250+
251+
const imageContent = createImageContent()
252+
253+
sessionState.mainAgentState.messageHistory = [
254+
userMessage('Hello'),
255+
]
256+
257+
await handleSpawnAgentInline({
258+
...handleSpawnAgentsBaseParams,
259+
agentState: sessionState.mainAgentState,
260+
agentTemplate: parentAgent,
261+
localAgentTemplates: { 'child-agent': childAgent },
262+
toolCall,
263+
content: imageContent,
264+
} as any)
265+
266+
expect(mockLoopAgentSteps).toHaveBeenCalledTimes(1)
267+
268+
// The inline spawned subagent should NOT receive the image content
269+
expect(capturedLoopAgentStepsParams.content).toBeUndefined()
270+
})
271+
272+
it('should NOT propagate images through multiple spawn levels', async () => {
273+
const parentAgent = createMockAgent('parent', true)
274+
const childAgent = createMockAgent('child-agent', true)
275+
const sessionState = getInitialSessionState(mockFileContext)
276+
const toolCall = createInlineSpawnToolCall('child-agent', 'nested task')
277+
278+
const imageContent = createImageContent()
279+
280+
sessionState.mainAgentState.messageHistory = []
281+
282+
await handleSpawnAgentInline({
283+
...handleSpawnAgentsBaseParams,
284+
agentState: sessionState.mainAgentState,
285+
agentTemplate: parentAgent,
286+
localAgentTemplates: { 'child-agent': childAgent },
287+
toolCall,
288+
content: imageContent,
289+
} as any)
290+
291+
expect(mockLoopAgentSteps).toHaveBeenCalledTimes(1)
292+
293+
// Verify content is undefined (not propagated)
294+
expect(capturedLoopAgentStepsParams.content).toBeUndefined()
295+
})
296+
})
297+
298+
describe('Multiple subagent spawns - images should not multiply', () => {
299+
it('should NOT pass image content to any of multiple spawned subagents', async () => {
300+
const parentAgent = createMockAgent('parent', true)
301+
parentAgent.spawnableAgents = ['child-agent', 'another-agent']
302+
const childAgent = createMockAgent('child-agent', true)
303+
const anotherAgent = createMockAgent('another-agent', true)
304+
const sessionState = getInitialSessionState(mockFileContext)
305+
306+
const imageContent = createImageContent()
307+
308+
const toolCall: CodebuffToolCall<'spawn_agents'> = {
309+
toolName: 'spawn_agents' as const,
310+
toolCallId: 'test-tool-call-id',
311+
input: {
312+
agents: [
313+
{ agent_type: 'child-agent', prompt: 'first task' },
314+
{ agent_type: 'another-agent', prompt: 'second task' },
315+
],
316+
},
317+
}
318+
319+
sessionState.mainAgentState.messageHistory = []
320+
321+
// Capture all calls
322+
const allCapturedParams: any[] = []
323+
mockLoopAgentSteps.mockImplementation(async (options: any) => {
324+
allCapturedParams.push({ ...options })
325+
return {
326+
agentState: {
327+
...options.agentState,
328+
messageHistory: [assistantMessage('Mock response')],
329+
},
330+
output: { type: 'lastMessage', value: [assistantMessage('Mock response')] },
331+
}
332+
})
333+
334+
await handleSpawnAgents({
335+
...handleSpawnAgentsBaseParams,
336+
agentState: sessionState.mainAgentState,
337+
agentTemplate: parentAgent,
338+
localAgentTemplates: {
339+
'child-agent': childAgent,
340+
'another-agent': anotherAgent,
341+
},
342+
toolCall,
343+
content: imageContent,
344+
} as any)
345+
346+
// Both subagents should have been spawned
347+
expect(mockLoopAgentSteps).toHaveBeenCalledTimes(2)
348+
349+
// Neither subagent should have received image content
350+
for (const params of allCapturedParams) {
351+
expect(params.content).toBeUndefined()
352+
}
353+
})
354+
})
355+
})

packages/agent-runtime/src/tools/handlers/tool/spawn-agent-utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,10 @@ export async function executeSubagent(
269269

270270
const result = await loopAgentSteps({
271271
...withDefaults,
272+
// Don't propagate parent's image content to subagents.
273+
// If subagents need to see images, they get them through includeMessageHistory,
274+
// not by creating new image-containing messages for their prompts.
275+
content: undefined,
272276
ancestorRunIds: [...ancestorRunIds, parentAgentState.runId ?? ''],
273277
agentType: agentTemplate.id,
274278
})

0 commit comments

Comments
 (0)