Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions cli/src/utils/__tests__/sdk-event-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,104 @@ describe('sdk-event-handlers', () => {
expect(getStreamingAgents().size).toBe(0)
})

test('hides spawn_agents error placeholders with no user-facing output', () => {
const { ctx, getMessages, getStreamingAgents } = createTestContext()
ctx.message.updater.addBlock(
createAgentBlock({
agentId: 'tool-1-0',
agentType: 'basher',
spawnToolCallId: 'tool-1',
spawnIndex: 0,
}),
)
ctx.streaming.setStreamingAgents(() => new Set(['tool-1-0']))

const handleEvent = createEventHandler(ctx)
const toolResultEvent: ToolResultEvent = {
type: 'tool_result',
toolCallId: 'tool-1',
toolName: 'spawn_agents',
output: [
{
type: 'json',
value: [
{
agentName: 'basher',
value: {
errorMessage:
'Error spawning agent: Invalid params for agent basher',
},
},
],
},
],
}
handleEvent(toolResultEvent)

expect(getMessages()[0].blocks).toEqual([])
expect(getStreamingAgents().size).toBe(0)
})

test('renders spawn_agents error content when agent already streamed output', () => {
const { ctx, getMessages, getStreamingAgents } = createTestContext()
ctx.message.updater.updateAiMessageBlocks(() => [
{
type: 'agent',
agentId: 'tool-1-0',
agentName: 'Basher',
agentType: 'basher',
content: '',
status: 'running',
blocks: [
{
type: 'text',
content: 'Checking files...',
textType: 'text',
},
],
initialPrompt: '',
spawnToolCallId: 'tool-1',
spawnIndex: 0,
} as any,
])
ctx.streaming.setStreamingAgents(() => new Set(['tool-1-0']))

const handleEvent = createEventHandler(ctx)
const toolResultEvent: ToolResultEvent = {
type: 'tool_result',
toolCallId: 'tool-1',
toolName: 'spawn_agents',
output: [
{
type: 'json',
value: [
{
agentName: 'basher',
value: {
errorMessage:
'Error spawning agent: Invalid params for agent basher',
},
},
],
},
],
}
handleEvent(toolResultEvent)

const agentBlock = (getMessages()[0].blocks ?? [])[0] as AgentContentBlock
expect(agentBlock.status).toBe('complete')
expect(agentBlock.blocks).toHaveLength(2)
expect(agentBlock.blocks?.[0]).toMatchObject({
type: 'text',
content: 'Checking files...',
})
expect(agentBlock.blocks?.[1]).toMatchObject({
type: 'text',
content: 'Error spawning agent: Invalid params for agent basher',
})
expect(getStreamingAgents().size).toBe(0)
})

test('handles spawn_agents tool results for agents with tool blocks (lastMessage mode)', () => {
const { ctx, getMessages, getStreamingAgents } = createTestContext()

Expand Down
100 changes: 65 additions & 35 deletions cli/src/utils/sdk-event-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,50 +358,79 @@ const handleToolCall = (state: EventHandlerState, event: PrintModeToolCall) => {
/**
* Recursively finds and updates agent blocks that match a spawn_agents tool call.
*/
const updateSpawnAgentBlocks = (
blocks: ContentBlock[],
const updateSpawnAgentBlock = (
block: ContentBlock,
toolCallId: string,
results: any[],
): ContentBlock[] => {
return blocks.map((block) => {
if (block.type !== 'agent') {
return block
}
): ContentBlock | null => {
if (block.type !== 'agent') {
return block
}

if (block.spawnToolCallId === toolCallId && block.spawnIndex !== undefined && block.blocks) {
const result = results[block.spawnIndex]

if (result?.value) {
const { content, hasError } = extractSpawnAgentResultContent(result.value)
// Check if the agent already streamed text content (e.g., basher).
// Agents like thinker return all output at the end via lastMessage,
// so we should add final content even if they have tool blocks.
const hasStreamedTextContent = block.blocks.some(
(b) => b.type === 'text' && b.textType === 'text'
)
const finalBlocks = content && !hasStreamedTextContent
? [...block.blocks, { type: 'text', content } as ContentBlock]
: block.blocks
if (hasError || finalBlocks.length > 0) {
return {
...block,
blocks: finalBlocks,
status: hasError ? ('failed' as const) : ('complete' as const),
}
const spawnIndex = block.spawnIndex
const childBlocks = block.blocks
const isSpawnResultTarget =
block.spawnToolCallId === toolCallId &&
spawnIndex !== undefined &&
childBlocks

if (isSpawnResultTarget) {
const result = results[spawnIndex]
if (result?.value) {
const { content, hasError } = extractSpawnAgentResultContent(result.value)

if (hasError) {
if (childBlocks.length === 0) {
return null
}

return {
...block,
blocks: content
? [...childBlocks, { type: 'text', content } as ContentBlock]
: childBlocks,
status: 'complete' as const,
}
}
}

// Recursively process nested agent blocks
if (block.blocks?.length) {
const updatedNestedBlocks = updateSpawnAgentBlocks(block.blocks, toolCallId, results)
if (updatedNestedBlocks !== block.blocks) {
return { ...block, blocks: updatedNestedBlocks }
// Agents like thinker return all output at the end via lastMessage,
// while agents like basher may have already streamed their text.
const hasStreamedTextContent = childBlocks.some(
(b) => b.type === 'text' && b.textType === 'text',
)
const finalBlocks =
content && !hasStreamedTextContent
? [...childBlocks, { type: 'text', content } as ContentBlock]
: childBlocks

if (finalBlocks.length > 0) {
return {
...block,
blocks: finalBlocks,
status: 'complete' as const,
}
}
}
}

if (!childBlocks?.length) {
return block
})
}

return {
...block,
blocks: updateSpawnAgentBlocks(childBlocks, toolCallId, results),
}
}

const updateSpawnAgentBlocks = (
blocks: ContentBlock[],
toolCallId: string,
results: any[],
): ContentBlock[] => {
return blocks
.map((block) => updateSpawnAgentBlock(block, toolCallId, results))
.filter((block): block is ContentBlock => block !== null)
}

const handleSpawnAgentsResult = (
Expand Down Expand Up @@ -433,7 +462,8 @@ const handleToolResult = (
)

const firstOutput = event.output?.[0]
const firstOutputValue = firstOutput && 'value' in firstOutput ? firstOutput.value : undefined
const firstOutputValue =
firstOutput && 'value' in firstOutput ? firstOutput.value : undefined
const isSpawnAgentsResult =
Array.isArray(firstOutputValue) &&
firstOutputValue.some((v: any) => v?.agentName || v?.agentType)
Expand Down
Loading