Skip to content

Commit 89f252f

Browse files
committed
Fix web researcher subagent output not displaying in CLI
Root causes: 1. extractSpawnAgentResultContent didn't handle lastMessage output mode where value is an array of Message objects 2. After subagent_start replaced temp agent IDs with real IDs, handleSpawnAgentsResult couldn't match blocks to apply results Changes: - Add spawnToolCallId and spawnIndex fields to AgentContentBlock - Add extractTextFromMessageContent helper to extract text from messages - Update extractSpawnAgentResultContent to handle lastMessage output mode - Update createAgentBlock to accept spawnToolCallId and spawnIndex - Refactor updateSpawnAgentBlocks to match by spawnToolCallId/spawnIndex - Preserve streamed content for agents like commander that stream output - Add test cases for lastMessage output handling
1 parent 57853ab commit 89f252f

File tree

5 files changed

+193
-83
lines changed

5 files changed

+193
-83
lines changed

cli/src/types/chat.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ export type AgentContentBlock = {
4848
params?: Record<string, any>
4949
isCollapsed?: boolean
5050
userOpened?: boolean
51+
/** The spawn_agents tool call ID that created this block, used to match results */
52+
spawnToolCallId?: string
53+
/** The index within the spawn_agents call, used to match the correct result */
54+
spawnIndex?: number
5155
}
5256
export type AgentListContentBlock = {
5357
type: 'agent-list'

cli/src/utils/__tests__/message-block-helpers.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,76 @@ describe('extractSpawnAgentResultContent', () => {
261261
const result = extractSpawnAgentResultContent(undefined)
262262
expect(result.hasError).toBe(false)
263263
})
264+
265+
test('extracts text from lastMessage output mode with Message array', () => {
266+
// This is the format returned by agents with outputMode: 'last_message'
267+
const result = extractSpawnAgentResultContent({
268+
type: 'lastMessage',
269+
value: [
270+
{
271+
role: 'assistant',
272+
content: [
273+
{ type: 'text', text: 'Here are the research findings:' },
274+
{ type: 'text', text: ' Important information found.' },
275+
],
276+
},
277+
],
278+
})
279+
expect(result).toEqual({
280+
content: 'Here are the research findings: Important information found.',
281+
hasError: false,
282+
})
283+
})
284+
285+
test('extracts text from multiple assistant messages in lastMessage output', () => {
286+
const result = extractSpawnAgentResultContent({
287+
type: 'lastMessage',
288+
value: [
289+
{
290+
role: 'assistant',
291+
content: [{ type: 'text', text: 'First message' }],
292+
},
293+
{
294+
role: 'tool',
295+
content: [{ type: 'json', value: {} }],
296+
},
297+
{
298+
role: 'assistant',
299+
content: [{ type: 'text', text: 'Second message' }],
300+
},
301+
],
302+
})
303+
expect(result).toEqual({
304+
content: 'First message\nSecond message',
305+
hasError: false,
306+
})
307+
})
308+
309+
test('handles lastMessage with empty content array', () => {
310+
const result = extractSpawnAgentResultContent({
311+
type: 'lastMessage',
312+
value: [
313+
{
314+
role: 'assistant',
315+
content: [],
316+
},
317+
],
318+
})
319+
expect(result).toEqual({ content: '', hasError: false })
320+
})
321+
322+
test('handles lastMessage with no assistant messages', () => {
323+
const result = extractSpawnAgentResultContent({
324+
type: 'lastMessage',
325+
value: [
326+
{
327+
role: 'tool',
328+
content: [{ type: 'json', value: {} }],
329+
},
330+
],
331+
})
332+
expect(result).toEqual({ content: '', hasError: false })
333+
})
264334
})
265335

266336
describe('appendInterruptionNotice', () => {

cli/src/utils/__tests__/sdk-event-handlers.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,12 @@ describe('sdk-event-handlers', () => {
182182
test('handles spawn_agents tool results and clears streaming agents', () => {
183183
const { ctx, getMessages, getStreamingAgents } = createTestContext()
184184
ctx.message.updater.addBlock(
185-
createAgentBlock({ agentId: 'tool-1-0', agentType: 'temp' }),
185+
createAgentBlock({
186+
agentId: 'tool-1-0',
187+
agentType: 'temp',
188+
spawnToolCallId: 'tool-1',
189+
spawnIndex: 0,
190+
}),
186191
)
187192
ctx.streaming.setStreamingAgents(() => new Set(['tool-1-0']))
188193

cli/src/utils/message-block-helpers.ts

Lines changed: 63 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { has, isEqual } from 'lodash'
1+
import { isEqual } from 'lodash'
22

33
import { formatToolOutput } from './codebuff-client'
44
import { shouldCollapseByDefault } from './constants'
@@ -120,69 +120,79 @@ export interface SpawnAgentResultContent {
120120
hasError: boolean
121121
}
122122

123+
/**
124+
* Extracts text content from a Message object's content array.
125+
* Handles assistant messages with TextPart content.
126+
*/
127+
const extractTextFromMessageContent = (content: unknown): string => {
128+
if (!Array.isArray(content)) {
129+
return ''
130+
}
131+
return content
132+
.filter((part: any) => part?.type === 'text' && typeof part?.text === 'string')
133+
.map((part: any) => part.text)
134+
.join('')
135+
}
136+
123137
/**
124138
* Extracts displayable content from a spawn_agents result value.
125139
* Handles various nested structures that can come back from agent spawns.
126140
*/
127141
export const extractSpawnAgentResultContent = (
128142
resultValue: unknown,
129143
): SpawnAgentResultContent => {
144+
// Handle null/undefined
145+
if (!resultValue) {
146+
return { content: '', hasError: false }
147+
}
148+
149+
// Handle direct string
130150
if (typeof resultValue === 'string') {
131151
return { content: resultValue, hasError: false }
132152
}
133153

134-
if (resultValue && typeof resultValue === 'object') {
135-
const nestedValue = (resultValue as any).value
136-
137-
if (typeof nestedValue === 'string') {
138-
return { content: nestedValue, hasError: false }
139-
}
140-
141-
if (nestedValue && typeof nestedValue === 'object') {
142-
if (has(nestedValue, 'errorMessage') && (nestedValue as any).errorMessage) {
143-
return {
144-
content: String((nestedValue as any).errorMessage),
145-
hasError: true,
146-
}
147-
}
154+
if (typeof resultValue !== 'object') {
155+
return { content: '', hasError: false }
156+
}
148157

149-
if (has(nestedValue, 'message') && (nestedValue as any).message) {
150-
return { content: String((nestedValue as any).message), hasError: false }
151-
}
152-
}
158+
const obj = resultValue as Record<string, unknown>
153159

154-
if (
155-
typeof resultValue === 'object' &&
156-
Object.keys(resultValue as Record<string, unknown>).length === 0
157-
) {
158-
return { content: '', hasError: false }
159-
}
160+
// Handle empty object
161+
if (Object.keys(obj).length === 0) {
162+
return { content: '', hasError: false }
163+
}
160164

161-
// Handle error messages from failed agent spawns
162-
if (has(resultValue, 'errorMessage') && (resultValue as any).errorMessage) {
163-
return {
164-
content: String((resultValue as any).errorMessage),
165-
hasError: true,
166-
}
167-
}
165+
// Handle error messages (check both top-level and nested)
166+
if (obj.errorMessage) {
167+
return { content: String(obj.errorMessage), hasError: true }
168+
}
169+
if ((obj.value as any)?.errorMessage) {
170+
return { content: String((obj.value as any).errorMessage), hasError: true }
171+
}
168172

169-
// Handle nested value structure like { type: "lastMessage", value: "..." }
170-
if (
171-
has(resultValue, 'value') &&
172-
(resultValue as any).value &&
173-
typeof (resultValue as any).value === 'string'
174-
) {
175-
return { content: (resultValue as any).value, hasError: false }
176-
}
173+
// Handle lastMessage output mode: { type: "lastMessage", value: [Message array] }
174+
// This is common for agents like researcher-web
175+
if (obj.type === 'lastMessage' && Array.isArray(obj.value)) {
176+
const messages = obj.value as Array<{ role?: string; content?: unknown }>
177+
const textContent = messages
178+
.filter((msg) => msg?.role === 'assistant')
179+
.map((msg) => extractTextFromMessageContent(msg?.content))
180+
.filter(Boolean)
181+
.join('\n')
182+
return { content: textContent, hasError: false }
183+
}
177184

178-
// Handle message field
179-
if (has(resultValue, 'message') && (resultValue as any).message) {
180-
return { content: (resultValue as any).message, hasError: false }
181-
}
185+
// Handle nested string value: { value: "..." }
186+
if (typeof obj.value === 'string') {
187+
return { content: obj.value, hasError: false }
182188
}
183189

184-
if (!resultValue) {
185-
return { content: '', hasError: false }
190+
// Handle message field (top-level or nested)
191+
if (obj.message) {
192+
return { content: String(obj.message), hasError: false }
193+
}
194+
if ((obj.value as any)?.message) {
195+
return { content: String((obj.value as any).message), hasError: false }
186196
}
187197

188198
// Fallback to formatted output
@@ -224,6 +234,10 @@ export interface CreateAgentBlockOptions {
224234
agentType: string
225235
prompt?: string
226236
params?: Record<string, unknown>
237+
/** The spawn_agents tool call ID that created this block */
238+
spawnToolCallId?: string
239+
/** The index within the spawn_agents call */
240+
spawnIndex?: number
227241
}
228242

229243
/**
@@ -232,7 +246,7 @@ export interface CreateAgentBlockOptions {
232246
export const createAgentBlock = (
233247
options: CreateAgentBlockOptions,
234248
): AgentContentBlock => {
235-
const { agentId, agentType, prompt, params } = options
249+
const { agentId, agentType, prompt, params, spawnToolCallId, spawnIndex } = options
236250
return {
237251
type: 'agent',
238252
agentId,
@@ -243,6 +257,8 @@ export const createAgentBlock = (
243257
blocks: [] as ContentBlock[],
244258
initialPrompt: prompt || '',
245259
...(params && { params }),
260+
...(spawnToolCallId && { spawnToolCallId }),
261+
...(spawnIndex !== undefined && { spawnIndex }),
246262
...(shouldCollapseByDefault(agentType || '') && { isCollapsed: true }),
247263
}
248264
}

cli/src/utils/sdk-event-handlers.ts

Lines changed: 50 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { has } from 'lodash'
21
import { match } from 'ts-pattern'
32

43
import {
@@ -275,12 +274,15 @@ const handleSpawnAgentsToolCall = (
275274

276275
state.message.updater.updateAiMessageBlocks((blocks) => {
277276
const newAgentBlocks: ContentBlock[] = agents
278-
.filter((agent: any) => !shouldHideAgent(agent.agent_type || ''))
279-
.map((agent: any, index: number) =>
277+
.map((agent: any, originalIndex: number) => ({ agent, originalIndex }))
278+
.filter(({ agent }) => !shouldHideAgent(agent.agent_type || ''))
279+
.map(({ agent, originalIndex }) =>
280280
createAgentBlock({
281-
agentId: `${event.toolCallId}-${index}`,
281+
agentId: `${event.toolCallId}-${originalIndex}`,
282282
agentType: agent.agent_type || '',
283283
prompt: agent.prompt,
284+
spawnToolCallId: event.toolCallId,
285+
spawnIndex: originalIndex,
284286
}),
285287
)
286288

@@ -334,42 +336,56 @@ const handleToolCall = (state: EventHandlerState, event: PrintModeToolCall) => {
334336
updateStreamingAgents(state, { add: event.toolCallId })
335337
}
336338

337-
const handleSpawnAgentsResult = (
338-
state: EventHandlerState,
339+
/**
340+
* Recursively finds and updates agent blocks that match a spawn_agents tool call.
341+
*/
342+
const updateSpawnAgentBlocks = (
343+
blocks: ContentBlock[],
339344
toolCallId: string,
340345
results: any[],
341-
) => {
342-
// Replace placeholder spawn agent blocks with their final text/status output.
343-
state.message.updater.updateAiMessageBlocks((blocks) =>
344-
blocks.map((block) => {
345-
if (
346-
block.type === 'agent' &&
347-
block.agentId.startsWith(toolCallId) &&
348-
block.blocks
349-
) {
350-
const agentIndex = Number.parseInt(
351-
block.agentId.split('-').pop() || '0',
352-
10,
353-
)
354-
const result = results[agentIndex]
355-
356-
if (has(result, 'value') && result.value) {
357-
const { content, hasError } = extractSpawnAgentResultContent(
358-
result.value,
359-
)
360-
const resultTextBlock: ContentBlock = {
361-
type: 'text',
362-
content,
363-
}
346+
): ContentBlock[] => {
347+
return blocks.map((block) => {
348+
if (block.type !== 'agent') {
349+
return block
350+
}
351+
352+
if (block.spawnToolCallId === toolCallId && block.spawnIndex !== undefined && block.blocks) {
353+
const result = results[block.spawnIndex]
354+
355+
if (result?.value) {
356+
const { content, hasError } = extractSpawnAgentResultContent(result.value)
357+
// Preserve streamed content (agents like commander stream their output)
358+
const hasStreamedContent = block.blocks.length > 0
359+
if (hasError || content || hasStreamedContent) {
364360
return {
365361
...block,
366-
blocks: [resultTextBlock],
362+
blocks: hasStreamedContent ? block.blocks : [{ type: 'text', content } as ContentBlock],
367363
status: hasError ? ('failed' as const) : ('complete' as const),
368364
}
369365
}
370366
}
371-
return block
372-
}),
367+
}
368+
369+
// Recursively process nested agent blocks
370+
if (block.blocks?.length) {
371+
const updatedNestedBlocks = updateSpawnAgentBlocks(block.blocks, toolCallId, results)
372+
if (updatedNestedBlocks !== block.blocks) {
373+
return { ...block, blocks: updatedNestedBlocks }
374+
}
375+
}
376+
377+
return block
378+
})
379+
}
380+
381+
const handleSpawnAgentsResult = (
382+
state: EventHandlerState,
383+
toolCallId: string,
384+
results: any[],
385+
) => {
386+
// Replace placeholder spawn agent blocks with their final text/status output.
387+
state.message.updater.updateAiMessageBlocks((blocks) =>
388+
updateSpawnAgentBlocks(blocks, toolCallId, results),
373389
)
374390

375391
results.forEach((_, index: number) => {
@@ -390,9 +406,8 @@ const handleToolResult = (
390406
}),
391407
)
392408

393-
const firstOutputValue = has(event.output?.[0], 'value')
394-
? event.output?.[0]?.value
395-
: undefined
409+
const firstOutput = event.output?.[0]
410+
const firstOutputValue = firstOutput && 'value' in firstOutput ? firstOutput.value : undefined
396411
const isSpawnAgentsResult =
397412
Array.isArray(firstOutputValue) &&
398413
firstOutputValue.some((v: any) => v?.agentName || v?.agentType)

0 commit comments

Comments
 (0)