Skip to content

Commit 23bd6fd

Browse files
brandonkachencodebuff-teamcharleslien
authored andcommitted
[feat] --trace flag and /subagent slash command (#216)
Co-authored-by: Codebuff <noreply@codebuff.com> Co-authored-by: Charles Lien <charleslien97@gmail.com>
1 parent dc1a037 commit 23bd6fd

File tree

21 files changed

+1669
-64
lines changed

21 files changed

+1669
-64
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ jobs:
120120
with:
121121
timeout_minutes: 10
122122
max_attempts: 5
123-
command: cd ${{ matrix.package }} && bun test $(find src -name '*.test.ts' ! -name '*.integration.test.ts')
123+
command: cd ${{ matrix.package }} && find src -name '*.test.ts' ! -name '*.integration.test.ts' | sort | xargs -I {} bun test {}
124124

125125
# - name: Open interactive debug shell
126126
# if: ${{ failure() }}
@@ -180,7 +180,7 @@ jobs:
180180
with:
181181
timeout_minutes: 15
182182
max_attempts: 3
183-
command: cd ${{ matrix.package }} && bun test $(find src -name '*.integration.test.ts')
183+
command: cd ${{ matrix.package }} && find src -name '*.integration.test.ts' | sort | xargs -I {} bun test {}
184184

185185
# - name: Open interactive debug shell
186186
# if: ${{ failure() }}
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import { TEST_USER_ID } from '@codebuff/common/constants'
2+
import { getInitialSessionState } from '@codebuff/common/types/session-state'
3+
import {
4+
afterAll,
5+
beforeAll,
6+
beforeEach,
7+
describe,
8+
expect,
9+
it,
10+
Mock,
11+
mock,
12+
spyOn,
13+
} from 'bun:test'
14+
import { WebSocket } from 'ws'
15+
16+
import * as runAgentStep from '../run-agent-step'
17+
import * as agentRegistryModule from '../templates/agent-registry'
18+
import { AgentTemplate } from '../templates/types'
19+
import {
20+
handleSpawnAgents,
21+
SendSubagentChunk,
22+
} from '../tools/handlers/spawn-agents'
23+
import * as loggerModule from '../util/logger'
24+
import { mockFileContext, MockWebSocket } from './test-utils'
25+
26+
describe('Subagent Streaming', () => {
27+
let mockSendSubagentChunk: Mock<SendSubagentChunk>
28+
let mockLoopAgentSteps: Mock<(typeof runAgentStep)['loopAgentSteps']>
29+
30+
beforeAll(() => {
31+
// Mock dependencies
32+
spyOn(loggerModule.logger, 'debug').mockImplementation(() => {})
33+
spyOn(loggerModule.logger, 'error').mockImplementation(() => {})
34+
spyOn(loggerModule.logger, 'info').mockImplementation(() => {})
35+
spyOn(loggerModule.logger, 'warn').mockImplementation(() => {})
36+
spyOn(loggerModule, 'withLoggerContext').mockImplementation(
37+
async (context: any, fn: () => Promise<any>) => fn()
38+
)
39+
40+
// Mock sendSubagentChunk function to capture streaming messages
41+
mockSendSubagentChunk = mock(
42+
(data: {
43+
userInputId: string
44+
agentId: string
45+
agentType: string
46+
chunk: string
47+
prompt?: string
48+
}) => {}
49+
)
50+
51+
// Mock loopAgentSteps to simulate subagent execution with streaming
52+
mockLoopAgentSteps = spyOn(
53+
runAgentStep,
54+
'loopAgentSteps'
55+
).mockImplementation(async (ws, options) => {
56+
// Simulate streaming chunks by calling the callback
57+
if (options.onResponseChunk) {
58+
options.onResponseChunk('Thinking about the problem...')
59+
options.onResponseChunk('Found a solution!')
60+
}
61+
62+
return {
63+
agentState: {
64+
...options.agentState,
65+
messageHistory: [
66+
{ role: 'assistant', content: 'Test response from subagent' },
67+
],
68+
},
69+
}
70+
})
71+
72+
// Mock agent registry
73+
spyOn(agentRegistryModule.agentRegistry, 'initialize').mockImplementation(
74+
async () => {}
75+
)
76+
spyOn(
77+
agentRegistryModule.agentRegistry,
78+
'getAllTemplates'
79+
).mockImplementation(() => ({
80+
thinker: {
81+
id: 'thinker',
82+
name: 'Thinker',
83+
outputMode: 'last_message',
84+
promptSchema: {
85+
prompt: {
86+
safeParse: () => ({ success: true }),
87+
} as any,
88+
},
89+
purpose: '',
90+
model: '',
91+
includeMessageHistory: true,
92+
toolNames: [],
93+
spawnableAgents: [],
94+
initialAssistantMessage: '',
95+
initialAssistantPrefix: '',
96+
stepAssistantMessage: '',
97+
stepAssistantPrefix: '',
98+
systemPrompt: '',
99+
userInputPrompt: '',
100+
agentStepPrompt: '',
101+
},
102+
}))
103+
spyOn(agentRegistryModule.agentRegistry, 'getAgentName').mockImplementation(
104+
() => 'Thinker'
105+
)
106+
})
107+
108+
beforeEach(() => {
109+
mockSendSubagentChunk.mockClear()
110+
mockLoopAgentSteps.mockClear()
111+
})
112+
113+
afterAll(() => {
114+
mock.restore()
115+
})
116+
117+
it('should send subagent-response-chunk messages during agent execution', async () => {
118+
const ws = new MockWebSocket() as unknown as WebSocket
119+
const sessionState = getInitialSessionState(mockFileContext)
120+
const agentState = sessionState.mainAgentState
121+
122+
// Mock parent agent template that can spawn thinker
123+
const parentTemplate = {
124+
id: 'base',
125+
spawnableAgents: ['thinker'],
126+
} as unknown as AgentTemplate
127+
128+
const toolCall = {
129+
toolName: 'spawn_agents' as const,
130+
toolCallId: 'test-tool-call-id',
131+
args: {
132+
agents: [
133+
{
134+
agent_type: 'thinker',
135+
prompt: 'Think about this problem',
136+
},
137+
],
138+
},
139+
}
140+
141+
const { result } = handleSpawnAgents({
142+
previousToolCallFinished: Promise.resolve(),
143+
toolCall,
144+
fileContext: mockFileContext,
145+
clientSessionId: 'test-session',
146+
userInputId: 'test-input',
147+
getLatestState: () => ({ messages: [] }),
148+
state: {
149+
ws,
150+
fingerprintId: 'test-fingerprint',
151+
userId: TEST_USER_ID,
152+
agentTemplate: parentTemplate,
153+
sendSubagentChunk: mockSendSubagentChunk,
154+
messages: [],
155+
agentState,
156+
},
157+
})
158+
159+
await result
160+
161+
// Verify that subagent streaming messages were sent
162+
expect(mockSendSubagentChunk).toHaveBeenCalledTimes(2)
163+
164+
// Check first streaming chunk
165+
expect(mockSendSubagentChunk).toHaveBeenNthCalledWith(1, {
166+
userInputId: 'test-input',
167+
agentId: expect.any(String),
168+
agentType: 'thinker',
169+
chunk: 'Thinking about the problem...',
170+
prompt: 'Think about this problem',
171+
})
172+
173+
// Check second streaming chunk
174+
expect(mockSendSubagentChunk).toHaveBeenNthCalledWith(2, {
175+
userInputId: 'test-input',
176+
agentId: expect.any(String),
177+
agentType: 'thinker',
178+
chunk: 'Found a solution!',
179+
prompt: 'Think about this problem',
180+
})
181+
})
182+
183+
it('should include correct agentId and agentType in streaming messages', async () => {
184+
const ws = new MockWebSocket() as unknown as WebSocket
185+
const sessionState = getInitialSessionState(mockFileContext)
186+
const agentState = sessionState.mainAgentState
187+
188+
const parentTemplate = {
189+
id: 'base',
190+
spawnableAgents: ['thinker'],
191+
} as unknown as AgentTemplate
192+
193+
const toolCall = {
194+
toolName: 'spawn_agents' as const,
195+
toolCallId: 'test-tool-call-id-2',
196+
args: {
197+
agents: [
198+
{
199+
agent_type: 'thinker',
200+
prompt: 'Test prompt',
201+
},
202+
],
203+
},
204+
}
205+
206+
const { result } = handleSpawnAgents({
207+
previousToolCallFinished: Promise.resolve(),
208+
toolCall,
209+
fileContext: mockFileContext,
210+
clientSessionId: 'test-session',
211+
userInputId: 'test-input-123',
212+
getLatestState: () => ({ messages: [] }),
213+
state: {
214+
ws,
215+
fingerprintId: 'test-fingerprint',
216+
userId: TEST_USER_ID,
217+
agentTemplate: parentTemplate,
218+
sendSubagentChunk: mockSendSubagentChunk,
219+
messages: [],
220+
agentState,
221+
},
222+
})
223+
await result
224+
225+
// Verify the streaming messages have consistent agentId and correct agentType
226+
expect(mockSendSubagentChunk.mock.calls.length).toBeGreaterThanOrEqual(2)
227+
const calls = mockSendSubagentChunk.mock.calls as Array<
228+
[
229+
{
230+
userInputId: string
231+
agentId: string
232+
agentType: string
233+
chunk: string
234+
prompt?: string
235+
},
236+
]
237+
>
238+
const firstCall = calls[0][0]
239+
const secondCall = calls[1][0]
240+
241+
expect(firstCall.agentId).toBe(secondCall.agentId) // Same agent ID
242+
expect(firstCall.agentType).toBe('thinker')
243+
expect(secondCall.agentType).toBe('thinker')
244+
expect(firstCall.userInputId).toBe('test-input-123')
245+
expect(secondCall.userInputId).toBe('test-input-123')
246+
})
247+
})

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import { asyncAgentManager } from '../../async-agent-manager'
1212
import { agentRegistry } from '../../templates/agent-registry'
1313
import { AgentTemplate } from '../../templates/types'
1414
import { logger } from '../../util/logger'
15-
import { CodebuffToolCall, CodebuffToolHandlerFunction } from '../constants'
16-
import { handleSpawnAgents } from './spawn-agents'
1715

16+
import { CodebuffToolCall, CodebuffToolHandlerFunction } from '../constants'
17+
import { handleSpawnAgents, SendSubagentChunk } from './spawn-agents'
1818
export const handleSpawnAgentsAsync = ((params: {
1919
previousToolCallFinished: Promise<void>
2020
toolCall: CodebuffToolCall<'spawn_agents_async'>
@@ -29,6 +29,7 @@ export const handleSpawnAgentsAsync = ((params: {
2929
fingerprintId?: string
3030
userId?: string
3131
agentTemplate?: AgentTemplate
32+
sendSubagentChunk?: SendSubagentChunk
3233
messages?: CodebuffMessage[]
3334
agentState?: AgentState
3435
}
@@ -59,6 +60,7 @@ export const handleSpawnAgentsAsync = ((params: {
5960
fingerprintId,
6061
userId,
6162
agentTemplate: parentAgentTemplate,
63+
sendSubagentChunk,
6264
messages,
6365
} = state
6466
let { agentState } = state
@@ -88,6 +90,11 @@ export const handleSpawnAgentsAsync = ((params: {
8890
'Internal error for spawn_agents: Missing agentState in state'
8991
)
9092
}
93+
if (!sendSubagentChunk) {
94+
throw new Error(
95+
'Internal error for spawn_agents_async: Missing sendSubagentChunk in state'
96+
)
97+
}
9198

9299
// Initialize registry and get all templates
93100
agentRegistry.initialize(fileContext)
@@ -176,6 +183,7 @@ export const handleSpawnAgentsAsync = ((params: {
176183
try {
177184
// Import loopAgentSteps dynamically to avoid circular dependency
178185
const { loopAgentSteps } = await import('../../run-agent-step')
186+
179187
const result = await loopAgentSteps(ws, {
180188
userInputId: `${userInputId}-async-${agentType}-${agentId}`,
181189
prompt: prompt || '',
@@ -187,7 +195,14 @@ export const handleSpawnAgentsAsync = ((params: {
187195
toolResults: [],
188196
userId,
189197
clientSessionId,
190-
onResponseChunk: () => {}, // Async agents don't stream to parent
198+
onResponseChunk: (chunk: string) =>
199+
sendSubagentChunk({
200+
userInputId,
201+
agentId,
202+
agentType,
203+
chunk,
204+
prompt,
205+
}),
191206
})
192207

193208
// Send completion message to parent if agent has appropriate output mode

0 commit comments

Comments
 (0)