|
| 1 | +/** |
| 2 | + * @vitest-environment node |
| 3 | + */ |
| 4 | +import { describe, expect, it } from 'vitest' |
| 5 | +import type { ContentBlock } from '../../types' |
| 6 | +import { parseBlocks } from './message-content' |
| 7 | + |
| 8 | +function subagentStart(name: string, spanId: string, parentSpanId: string): ContentBlock { |
| 9 | + return { type: 'subagent', content: name, spanId, parentSpanId, timestamp: 1 } |
| 10 | +} |
| 11 | + |
| 12 | +function subagentToolCall( |
| 13 | + id: string, |
| 14 | + name: string, |
| 15 | + spanId: string, |
| 16 | + calledBy: string |
| 17 | +): ContentBlock { |
| 18 | + return { |
| 19 | + type: 'tool_call', |
| 20 | + toolCall: { id, name, status: 'success', calledBy }, |
| 21 | + spanId, |
| 22 | + timestamp: 1, |
| 23 | + } |
| 24 | +} |
| 25 | + |
| 26 | +describe('parseBlocks span-identity tree', () => { |
| 27 | + it('nests a deploy subagent inside the workflow subagent that spawned it', () => { |
| 28 | + const blocks: ContentBlock[] = [ |
| 29 | + subagentStart('workflow', 'S1', 'main'), |
| 30 | + subagentToolCall('t1', 'create_workflow', 'S1', 'workflow'), |
| 31 | + subagentStart('deploy', 'S2', 'S1'), |
| 32 | + subagentToolCall('t2', 'check_deployment_status', 'S2', 'deploy'), |
| 33 | + ] |
| 34 | + |
| 35 | + const segments = parseBlocks(blocks) |
| 36 | + |
| 37 | + expect(segments).toHaveLength(1) |
| 38 | + const workflow = segments[0] |
| 39 | + expect(workflow.type).toBe('agent_group') |
| 40 | + if (workflow.type !== 'agent_group') throw new Error('expected workflow group') |
| 41 | + expect(workflow.agentName).toBe('workflow') |
| 42 | + |
| 43 | + const nested = workflow.items.find((item) => item.type === 'agent_group') |
| 44 | + expect(nested).toBeDefined() |
| 45 | + if (!nested || nested.type !== 'agent_group') throw new Error('expected nested deploy group') |
| 46 | + expect(nested.group.agentName).toBe('deploy') |
| 47 | + // Deploy's own tool nests under deploy, not under workflow. |
| 48 | + expect(nested.group.items.some((item) => item.type === 'tool')).toBe(true) |
| 49 | + }) |
| 50 | + |
| 51 | + it('keeps two top-level subagents as siblings', () => { |
| 52 | + const blocks: ContentBlock[] = [ |
| 53 | + subagentStart('workflow', 'S1', 'main'), |
| 54 | + subagentStart('research', 'S3', 'main'), |
| 55 | + ] |
| 56 | + |
| 57 | + const segments = parseBlocks(blocks) |
| 58 | + const groups = segments.filter((s) => s.type === 'agent_group') |
| 59 | + expect(groups).toHaveLength(2) |
| 60 | + }) |
| 61 | + |
| 62 | + it('creates distinct groups for repeated deploy invocations (no collision)', () => { |
| 63 | + const blocks: ContentBlock[] = [ |
| 64 | + subagentStart('deploy', 'S2', 'main'), |
| 65 | + subagentToolCall('t1', 'deploy_api', 'S2', 'deploy'), |
| 66 | + subagentStart('deploy', 'S4', 'main'), |
| 67 | + subagentToolCall('t2', 'deploy_api', 'S4', 'deploy'), |
| 68 | + ] |
| 69 | + |
| 70 | + const segments = parseBlocks(blocks) |
| 71 | + const groups = segments.filter((s) => s.type === 'agent_group') |
| 72 | + expect(groups).toHaveLength(2) |
| 73 | + }) |
| 74 | + |
| 75 | + it('shows the delegating spinner while a span subagent is open with no output, and clears it once content arrives', () => { |
| 76 | + const openOnly = parseBlocks([subagentStart('deploy', 'S2', 'main')]) |
| 77 | + expect(openOnly).toHaveLength(1) |
| 78 | + if (openOnly[0].type !== 'agent_group') throw new Error('expected group') |
| 79 | + expect(openOnly[0].isDelegating).toBe(true) |
| 80 | + |
| 81 | + const withContent = parseBlocks([ |
| 82 | + subagentStart('deploy', 'S2', 'main'), |
| 83 | + { type: 'subagent_text', content: 'working on it', spanId: 'S2', timestamp: 2 }, |
| 84 | + ]) |
| 85 | + if (withContent[0].type !== 'agent_group') throw new Error('expected group') |
| 86 | + expect(withContent[0].isDelegating).toBe(false) |
| 87 | + }) |
| 88 | + |
| 89 | + it('prunes an empty nested subagent that started and ended without output', () => { |
| 90 | + const blocks: ContentBlock[] = [ |
| 91 | + subagentStart('workflow', 'S1', 'main'), |
| 92 | + subagentToolCall('t1', 'create_workflow', 'S1', 'workflow'), |
| 93 | + subagentStart('deploy', 'S2', 'S1'), |
| 94 | + { type: 'subagent_end', spanId: 'S2', parentSpanId: 'S1', timestamp: 3 }, |
| 95 | + ] |
| 96 | + const segments = parseBlocks(blocks) |
| 97 | + expect(segments).toHaveLength(1) |
| 98 | + if (segments[0].type !== 'agent_group') throw new Error('expected workflow group') |
| 99 | + // The empty, ended deploy group is pruned; only the workflow tool remains. |
| 100 | + expect(segments[0].items.some((item) => item.type === 'agent_group')).toBe(false) |
| 101 | + expect(segments[0].items.some((item) => item.type === 'tool')).toBe(true) |
| 102 | + }) |
| 103 | + |
| 104 | + it('falls back to legacy flat grouping when blocks have no span identity', () => { |
| 105 | + const blocks: ContentBlock[] = [ |
| 106 | + { type: 'subagent', content: 'workflow', parentToolCallId: 'tc-1', timestamp: 1 }, |
| 107 | + { |
| 108 | + type: 'tool_call', |
| 109 | + toolCall: { id: 't1', name: 'create_workflow', status: 'success', calledBy: 'workflow' }, |
| 110 | + parentToolCallId: 'tc-1', |
| 111 | + timestamp: 1, |
| 112 | + }, |
| 113 | + ] |
| 114 | + |
| 115 | + const segments = parseBlocks(blocks) |
| 116 | + const groups = segments.filter((s) => s.type === 'agent_group') |
| 117 | + expect(groups).toHaveLength(1) |
| 118 | + if (groups[0].type !== 'agent_group') throw new Error('expected group') |
| 119 | + expect(groups[0].agentName).toBe('workflow') |
| 120 | + }) |
| 121 | +}) |
0 commit comments