Skip to content

Commit 456858c

Browse files
committed
filter out system messages for spawned agent
1 parent 6c362c3 commit 456858c

File tree

3 files changed

+265
-2
lines changed

3 files changed

+265
-2
lines changed
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import { describe, expect, it, beforeEach, afterEach, mock, spyOn } from 'bun:test'
2+
import { handleSpawnAgents } from '../tools/handlers/tool/spawn-agents'
3+
import { TEST_USER_ID } from '@codebuff/common/constants'
4+
import { getInitialSessionState } from '@codebuff/common/types/session-state'
5+
import { mockFileContext, MockWebSocket } from './test-utils'
6+
import * as loggerModule from '../util/logger'
7+
import * as runAgentStep from '../run-agent-step'
8+
9+
import type { AgentTemplate } from '@codebuff/common/types/agent-template'
10+
import type { CodebuffToolCall } from '@codebuff/common/tools/list'
11+
import type { CodebuffMessage } from '@codebuff/common/types/message'
12+
import type { WebSocket } from 'ws'
13+
14+
describe('Spawn Agents Message History', () => {
15+
let mockSendSubagentChunk: any
16+
let mockLoopAgentSteps: any
17+
let capturedSubAgentState: any
18+
19+
beforeEach(() => {
20+
// Mock logger to reduce noise in tests
21+
spyOn(loggerModule.logger, 'debug').mockImplementation(() => {})
22+
spyOn(loggerModule.logger, 'error').mockImplementation(() => {})
23+
spyOn(loggerModule.logger, 'info').mockImplementation(() => {})
24+
spyOn(loggerModule.logger, 'warn').mockImplementation(() => {})
25+
spyOn(loggerModule, 'withLoggerContext').mockImplementation(
26+
async (context: any, fn: () => Promise<any>) => fn(),
27+
)
28+
29+
// Mock sendSubagentChunk
30+
mockSendSubagentChunk = mock(() => {})
31+
32+
// Mock loopAgentSteps to capture the subAgentState
33+
mockLoopAgentSteps = spyOn(
34+
runAgentStep,
35+
'loopAgentSteps',
36+
).mockImplementation(async (ws, options) => {
37+
capturedSubAgentState = options.agentState
38+
return {
39+
agentState: {
40+
...options.agentState,
41+
messageHistory: [
42+
...options.agentState.messageHistory,
43+
{ role: 'assistant', content: 'Mock agent response' },
44+
],
45+
},
46+
}
47+
})
48+
})
49+
50+
afterEach(() => {
51+
mock.restore()
52+
capturedSubAgentState = undefined
53+
})
54+
55+
const createMockAgent = (id: string, includeMessageHistory = true): AgentTemplate => ({
56+
id,
57+
displayName: `Mock ${id}`,
58+
outputMode: 'last_message' as const,
59+
inputSchema: {
60+
prompt: {
61+
safeParse: () => ({ success: true }),
62+
} as any,
63+
},
64+
spawnerPrompt: '',
65+
model: '',
66+
includeMessageHistory,
67+
toolNames: [],
68+
spawnableAgents: ['child-agent'],
69+
systemPrompt: '',
70+
instructionsPrompt: '',
71+
stepPrompt: '',
72+
})
73+
74+
const createSpawnToolCall = (agentType: string, prompt = 'test prompt'): CodebuffToolCall<'spawn_agents'> => ({
75+
toolName: 'spawn_agents' as const,
76+
toolCallId: 'test-tool-call-id',
77+
input: {
78+
agents: [{ agent_type: agentType, prompt }],
79+
},
80+
})
81+
82+
it('should exclude system messages from conversation history when includeMessageHistory is true', async () => {
83+
const parentAgent = createMockAgent('parent', true)
84+
const childAgent = createMockAgent('child-agent', true)
85+
const ws = new MockWebSocket() as unknown as WebSocket
86+
const sessionState = getInitialSessionState(mockFileContext)
87+
const toolCall = createSpawnToolCall('child-agent')
88+
89+
// Create mock messages including system message
90+
const mockMessages: CodebuffMessage[] = [
91+
{ role: 'system', content: 'This is the parent system prompt that should be excluded' },
92+
{ role: 'user', content: 'Hello' },
93+
{ role: 'assistant', content: 'Hi there!' },
94+
{ role: 'user', content: 'How are you?' },
95+
]
96+
97+
const { result } = handleSpawnAgents({
98+
previousToolCallFinished: Promise.resolve(),
99+
toolCall,
100+
fileContext: mockFileContext,
101+
clientSessionId: 'test-session',
102+
userInputId: 'test-input',
103+
getLatestState: () => ({ messages: mockMessages }),
104+
state: {
105+
ws,
106+
fingerprintId: 'test-fingerprint',
107+
userId: TEST_USER_ID,
108+
agentTemplate: parentAgent,
109+
localAgentTemplates: { 'child-agent': childAgent },
110+
sendSubagentChunk: mockSendSubagentChunk,
111+
messages: mockMessages,
112+
agentState: sessionState.mainAgentState,
113+
},
114+
})
115+
116+
await result
117+
118+
// Verify that the spawned agent was called
119+
expect(mockLoopAgentSteps).toHaveBeenCalledTimes(1)
120+
121+
// Verify that the subagent's message history contains the conversation history message
122+
expect(capturedSubAgentState.messageHistory).toHaveLength(1)
123+
const conversationHistoryMessage = capturedSubAgentState.messageHistory[0]
124+
expect(conversationHistoryMessage.role).toBe('user')
125+
expect(conversationHistoryMessage.content).toContain('conversation history between the user and an assistant')
126+
127+
// Parse the JSON content to verify system message is excluded
128+
const contentMatch = conversationHistoryMessage.content.match(/\[([\s\S]*)\]/)
129+
expect(contentMatch).toBeTruthy()
130+
const parsedMessages = JSON.parse(contentMatch![0])
131+
132+
// Verify system message is excluded
133+
expect(parsedMessages).toHaveLength(3) // Only user and assistant messages
134+
expect(parsedMessages.find((msg: any) => msg.role === 'system')).toBeUndefined()
135+
expect(parsedMessages.find((msg: any) => msg.content === 'This is the parent system prompt that should be excluded')).toBeUndefined()
136+
137+
// Verify user and assistant messages are included
138+
expect(parsedMessages.find((msg: any) => msg.content === 'Hello')).toBeTruthy()
139+
expect(parsedMessages.find((msg: any) => msg.content === 'Hi there!')).toBeTruthy()
140+
expect(parsedMessages.find((msg: any) => msg.content === 'How are you?')).toBeTruthy()
141+
})
142+
143+
it('should not include conversation history when includeMessageHistory is false', async () => {
144+
const parentAgent = createMockAgent('parent', true)
145+
const childAgent = createMockAgent('child-agent', false) // includeMessageHistory = false
146+
const ws = new MockWebSocket() as unknown as WebSocket
147+
const sessionState = getInitialSessionState(mockFileContext)
148+
const toolCall = createSpawnToolCall('child-agent')
149+
150+
const mockMessages: CodebuffMessage[] = [
151+
{ role: 'system', content: 'System prompt' },
152+
{ role: 'user', content: 'Hello' },
153+
{ role: 'assistant', content: 'Hi there!' },
154+
]
155+
156+
const { result } = handleSpawnAgents({
157+
previousToolCallFinished: Promise.resolve(),
158+
toolCall,
159+
fileContext: mockFileContext,
160+
clientSessionId: 'test-session',
161+
userInputId: 'test-input',
162+
getLatestState: () => ({ messages: mockMessages }),
163+
state: {
164+
ws,
165+
fingerprintId: 'test-fingerprint',
166+
userId: TEST_USER_ID,
167+
agentTemplate: parentAgent,
168+
localAgentTemplates: { 'child-agent': childAgent },
169+
sendSubagentChunk: mockSendSubagentChunk,
170+
messages: mockMessages,
171+
agentState: sessionState.mainAgentState,
172+
},
173+
})
174+
175+
await result
176+
177+
// Verify that the subagent's message history is empty when includeMessageHistory is false
178+
expect(capturedSubAgentState.messageHistory).toHaveLength(0)
179+
})
180+
181+
it('should handle empty message history gracefully', async () => {
182+
const parentAgent = createMockAgent('parent', true)
183+
const childAgent = createMockAgent('child-agent', true)
184+
const ws = new MockWebSocket() as unknown as WebSocket
185+
const sessionState = getInitialSessionState(mockFileContext)
186+
const toolCall = createSpawnToolCall('child-agent')
187+
188+
const mockMessages: CodebuffMessage[] = [] // Empty message history
189+
190+
const { result } = handleSpawnAgents({
191+
previousToolCallFinished: Promise.resolve(),
192+
toolCall,
193+
fileContext: mockFileContext,
194+
clientSessionId: 'test-session',
195+
userInputId: 'test-input',
196+
getLatestState: () => ({ messages: mockMessages }),
197+
state: {
198+
ws,
199+
fingerprintId: 'test-fingerprint',
200+
userId: TEST_USER_ID,
201+
agentTemplate: parentAgent,
202+
localAgentTemplates: { 'child-agent': childAgent },
203+
sendSubagentChunk: mockSendSubagentChunk,
204+
messages: mockMessages,
205+
agentState: sessionState.mainAgentState,
206+
},
207+
})
208+
209+
await result
210+
211+
// Verify that the subagent still gets a conversation history message, even if empty
212+
expect(capturedSubAgentState.messageHistory).toHaveLength(1)
213+
const conversationHistoryMessage = capturedSubAgentState.messageHistory[0]
214+
expect(conversationHistoryMessage.content).toContain('[]') // Empty array in JSON
215+
})
216+
217+
it('should handle message history with only system messages', async () => {
218+
const parentAgent = createMockAgent('parent', true)
219+
const childAgent = createMockAgent('child-agent', true)
220+
const ws = new MockWebSocket() as unknown as WebSocket
221+
const sessionState = getInitialSessionState(mockFileContext)
222+
const toolCall = createSpawnToolCall('child-agent')
223+
224+
const mockMessages: CodebuffMessage[] = [
225+
{ role: 'system', content: 'System prompt 1' },
226+
{ role: 'system', content: 'System prompt 2' },
227+
]
228+
229+
const { result } = handleSpawnAgents({
230+
previousToolCallFinished: Promise.resolve(),
231+
toolCall,
232+
fileContext: mockFileContext,
233+
clientSessionId: 'test-session',
234+
userInputId: 'test-input',
235+
getLatestState: () => ({ messages: mockMessages }),
236+
state: {
237+
ws,
238+
fingerprintId: 'test-fingerprint',
239+
userId: TEST_USER_ID,
240+
agentTemplate: parentAgent,
241+
localAgentTemplates: { 'child-agent': childAgent },
242+
sendSubagentChunk: mockSendSubagentChunk,
243+
messages: mockMessages,
244+
agentState: sessionState.mainAgentState,
245+
},
246+
})
247+
248+
await result
249+
250+
// Verify that all system messages are filtered out
251+
expect(capturedSubAgentState.messageHistory).toHaveLength(1)
252+
const conversationHistoryMessage = capturedSubAgentState.messageHistory[0]
253+
expect(conversationHistoryMessage.content).toContain('[]') // Empty array in JSON since all system messages filtered out
254+
})
255+
})

backend/src/tools/handlers/tool/spawn-agents-async.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,14 @@ export const handleSpawnAgentsAsync = ((params: {
117117
error?: string
118118
}> = []
119119

120+
// Filter out system messages from conversation history to avoid including parent's system prompt
121+
const messagesWithoutSystem = getLatestState().messages.filter(
122+
(message) => message.role !== 'system',
123+
)
120124
const conversationHistoryMessage: CodebuffMessage = {
121125
role: 'user',
122126
content: `For context, the following is the conversation history between the user and an assistant:\n\n${JSON.stringify(
123-
getLatestState().messages,
127+
messagesWithoutSystem,
124128
null,
125129
2,
126130
)}`,

backend/src/tools/handlers/tool/spawn-agents.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,14 @@ export const handleSpawnAgents = ((params: {
104104
}
105105

106106
const triggerSpawnAgents = async () => {
107+
// Filter out system messages from conversation history to avoid including parent's system prompt
108+
const messagesWithoutSystem = getLatestState().messages.filter(
109+
(message) => message.role !== 'system',
110+
)
107111
const conversationHistoryMessage: CodebuffMessage = {
108112
role: 'user',
109113
content: `For context, the following is the conversation history between the user and an assistant:\n\n${JSON.stringify(
110-
getLatestState().messages,
114+
messagesWithoutSystem,
111115
null,
112116
2,
113117
)}`,

0 commit comments

Comments
 (0)