Skip to content

Commit bd1ba9b

Browse files
committed
fix(vscode): attribute DynamicWorkflow steps to their own subagent
Every subagent in a DynamicWorkflow batch shares one parentToolCallId, so the webview funnelled all of them into a single flat subagent_steps array and targeted the array tail as the "current step". Two agents streaming at once wrote text, thinking, and tool calls into each other's steps. Steps now carry the emitting agent's identity and are targeted per agent. The subagent lifecycle events the adapter already received but discarded (started/completed/failed/suspended) are mapped to a SubagentStatus event, which gives each lane a status, a duration, and a result or error. On top of that attribution, DynamicWorkflow renders as per-agent lanes instead of an escaped-JSON argument dump: live activity, a step count, a status, and a progress bar filled relative to the busiest lane. There is no per-agent step total to divide by, so an absolute percentage would be fabricated; the caption names the denominator. Lane derivation lives in lib/workflow-lanes.ts rather than the store module, and the pure tool-argument helpers move to lib/tool-args.ts. Tool rendering is mutually recursive, so WorkflowCard receives its step-item renderer as a prop rather than importing it and closing an import cycle. The new test file is excluded from the extension tsconfig, which is where the webview-side tests are kept out of the extension program.
1 parent 070d902 commit bd1ba9b

16 files changed

Lines changed: 827 additions & 92 deletions

apps/vscode/shared/legacy-sdk.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,22 @@ export interface QuestionResponse {
126126
export interface SubagentEvent {
127127
parent_tool_call_id: string;
128128
event: LegacyWireEvent;
129+
/** Emitting subagent. */
130+
agent_id: string;
131+
/** Display name for the lane, e.g. "explore". Absent for non-DynamicWorkflow subagents. */
132+
agent_label?: string;
133+
/** Position within the DynamicWorkflow batch, 1-based. Absent when the subagent has none. */
134+
agent_index?: number;
135+
}
136+
137+
export interface SubagentStatusPayload {
138+
parent_tool_call_id: string;
139+
agent_id: string;
140+
agent_label?: string;
141+
agent_index?: number;
142+
status: 'spawned' | 'running' | 'done' | 'failed' | 'suspended';
143+
error?: string;
144+
result_summary?: string;
129145
}
130146

131147
export type LegacyWireEvent =
@@ -142,6 +158,7 @@ export type LegacyWireEvent =
142158
| { type: 'ToolResult'; payload: ToolResult }
143159
| { type: 'SteerInput'; payload: { user_input: string | ContentPart[] } }
144160
| { type: 'SubagentEvent'; payload: SubagentEvent }
161+
| { type: 'SubagentStatus'; payload: SubagentStatusPayload }
145162
| { type: string; payload: unknown };
146163

147164
export type StreamEvent =

apps/vscode/src/runtime/event-adapter.ts

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
DisplayBlock,
55
LegacyWireEvent,
66
StatusUpdate,
7+
SubagentStatusPayload,
78
TokenUsage,
89
TurnBegin,
910
} from '../../shared/legacy-sdk';
@@ -22,6 +23,9 @@ export interface AdapterTokenUsage {
2223
export interface SubagentParent {
2324
readonly parentAgentId: string;
2425
readonly parentToolCallId: string;
26+
readonly subagentName?: string;
27+
readonly description?: string;
28+
readonly dynamicWorkflowIndex?: number;
2529
}
2630

2731
export interface EventAdapterState {
@@ -89,22 +93,35 @@ export function adaptSdkEvent(
8993

9094
if (sdkEvent.type === 'subagent.spawned') {
9195
const parentAgentId = (sdkEvent as any).parentAgentId ?? (sdkEvent as any).callerAgentId ?? sdkEvent.agentId;
92-
return {
93-
state: {
94-
...state,
95-
subagentParents: {
96-
...state.subagentParents,
97-
[sdkEvent.subagentId]: {
98-
parentAgentId,
99-
parentToolCallId: scopedToolCallId(
100-
parentAgentId,
101-
sdkEvent.parentToolCallId,
102-
mainAgentId,
103-
),
104-
},
96+
const parentToolCallId = scopedToolCallId(parentAgentId, sdkEvent.parentToolCallId, mainAgentId);
97+
const nextState: EventAdapterState = {
98+
...state,
99+
subagentParents: {
100+
...state.subagentParents,
101+
[sdkEvent.subagentId]: {
102+
parentAgentId,
103+
parentToolCallId,
104+
subagentName: sdkEvent.subagentName,
105+
description: sdkEvent.description,
106+
dynamicWorkflowIndex: sdkEvent.dynamicWorkflowIndex,
105107
},
106108
},
107109
};
110+
const statusPayload: SubagentStatusPayload = {
111+
parent_tool_call_id: parentToolCallId,
112+
agent_id: sdkEvent.subagentId,
113+
agent_label: sdkEvent.subagentName,
114+
agent_index: sdkEvent.dynamicWorkflowIndex,
115+
status: 'spawned',
116+
};
117+
const routed = routeSubagentEvent(
118+
nextState,
119+
parentAgentId,
120+
{ type: 'SubagentStatus', payload: statusPayload },
121+
mainAgentId,
122+
);
123+
if (routed === undefined) return { state: nextState };
124+
return { state: nextState, event: withSessionId(routed, sdkEvent.sessionId) };
108125
}
109126

110127
if (sdkEvent.type === 'turn.started') {
@@ -320,6 +337,14 @@ function mapLegacyWireEvent(
320337
}
321338
case 'agent.status.updated':
322339
return mapStatusUpdate(state, sdkEvent);
340+
case 'subagent.started':
341+
return mapSubagentStatus(state, sdkEvent, 'running');
342+
case 'subagent.completed':
343+
return mapSubagentStatus(state, sdkEvent, 'done');
344+
case 'subagent.failed':
345+
return mapSubagentStatus(state, sdkEvent, 'failed');
346+
case 'subagent.suspended':
347+
return mapSubagentStatus(state, sdkEvent, 'suspended');
323348
case 'compaction.started':
324349
return {
325350
state,
@@ -368,6 +393,32 @@ function mapStatusUpdate(
368393
};
369394
}
370395

396+
function mapSubagentStatus(
397+
state: EventAdapterState,
398+
sdkEvent: Extract<
399+
Event,
400+
{ type: 'subagent.started' | 'subagent.completed' | 'subagent.failed' | 'subagent.suspended' }
401+
>,
402+
status: SubagentStatusPayload['status'],
403+
): MappedLegacyWireEvent {
404+
// subagent.spawned always precedes every other lifecycle event for the same
405+
// subagentId, so the parent is always known by the time this runs.
406+
const parent = state.subagentParents[sdkEvent.subagentId];
407+
if (parent === undefined) return { state };
408+
409+
const payload: SubagentStatusPayload = {
410+
parent_tool_call_id: parent.parentToolCallId,
411+
agent_id: sdkEvent.subagentId,
412+
agent_label: parent.subagentName,
413+
agent_index: parent.dynamicWorkflowIndex,
414+
status,
415+
error: sdkEvent.type === 'subagent.failed' ? sdkEvent.error : undefined,
416+
result_summary: sdkEvent.type === 'subagent.completed' ? sdkEvent.resultSummary : undefined,
417+
};
418+
419+
return { state, event: { type: 'SubagentStatus', payload } };
420+
}
421+
371422
function usageDelta(current: AdapterTokenUsage, previous: AdapterTokenUsage | undefined): TokenUsage {
372423
return {
373424
input_other: delta(current.inputOther, previous?.inputOther),
@@ -414,6 +465,9 @@ function routeSubagentEvent(
414465
type: 'SubagentEvent',
415466
payload: {
416467
parent_tool_call_id: parent.parentToolCallId,
468+
agent_id: currentAgentId,
469+
agent_label: parent.subagentName,
470+
agent_index: parent.dynamicWorkflowIndex,
417471
event: routed,
418472
},
419473
};

apps/vscode/src/runtime/replay-adapter.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,9 @@ function wrapSubagentEvent(
496496
invocation.parentAgentId,
497497
invocation.parentToolCallId,
498498
),
499+
// Replay history carries no persisted label or dynamicWorkflowIndex; the UI
500+
// falls back to the id's short form.
501+
agent_id: invocation.childAgentId,
499502
event: routed,
500503
},
501504
};

apps/vscode/test/event-adapter.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,8 @@ describe('event adapter (projects SDK events into the legacy Webview contract)',
360360
type: 'SubagentEvent',
361361
payload: {
362362
parent_tool_call_id: 'agent-call-1',
363+
agent_id: 'child-1',
364+
agent_label: 'coder',
363365
event: {
364366
type: 'ContentPart',
365367
payload: { type: 'text', text: 'Child result' },
@@ -394,6 +396,8 @@ describe('event adapter (projects SDK events into the legacy Webview contract)',
394396
type: 'SubagentEvent',
395397
payload: {
396398
parent_tool_call_id: 'agent-call-1',
399+
agent_id: 'child-1',
400+
agent_label: 'coder',
397401
event: {
398402
type: 'ToolCall',
399403
payload: {
@@ -410,6 +414,76 @@ describe('event adapter (projects SDK events into the legacy Webview contract)',
410414
});
411415
});
412416

417+
it('emits a spawned SubagentStatus the moment a subagent is queued', () => {
418+
const result = adaptSdkEvent(createEventAdapterState(), {
419+
type: 'subagent.spawned',
420+
sessionId: 'session-1',
421+
agentId: 'main',
422+
subagentId: 'child-1',
423+
subagentName: 'explore',
424+
parentToolCallId: 'wf-1',
425+
parentAgentId: 'main',
426+
dynamicWorkflowIndex: 2,
427+
runInBackground: false,
428+
});
429+
430+
expect(result.event).toEqual({
431+
type: 'SubagentStatus',
432+
payload: {
433+
parent_tool_call_id: 'wf-1',
434+
agent_id: 'child-1',
435+
agent_label: 'explore',
436+
agent_index: 2,
437+
status: 'spawned',
438+
},
439+
_sessionId: 'session-1',
440+
});
441+
});
442+
443+
it.each([
444+
['subagent.started' as const, 'running' as const, {}],
445+
['subagent.suspended' as const, 'suspended' as const, { reason: 'waiting on approval' }],
446+
['subagent.completed' as const, 'done' as const, { resultSummary: 'Explored 3 files' }],
447+
['subagent.failed' as const, 'failed' as const, { error: 'timed out' }],
448+
])('routes each %s lifecycle event to one SubagentStatus on the spawning tool call', (sdkType, status, extra) => {
449+
const spawned = adaptSdkEvent(createEventAdapterState(), {
450+
type: 'subagent.spawned',
451+
sessionId: 'session-1',
452+
agentId: 'main',
453+
subagentId: 'child-1',
454+
subagentName: 'explore',
455+
parentToolCallId: 'wf-1',
456+
parentAgentId: 'main',
457+
dynamicWorkflowIndex: 2,
458+
runInBackground: false,
459+
});
460+
461+
const lifecycle = adaptSdkEvent(spawned.state, {
462+
type: sdkType,
463+
sessionId: 'session-1',
464+
agentId: 'main',
465+
subagentId: 'child-1',
466+
parentToolCallId: 'wf-1',
467+
...extra,
468+
} as any);
469+
470+
const expectedPayload: Record<string, unknown> = {
471+
parent_tool_call_id: 'wf-1',
472+
agent_id: 'child-1',
473+
agent_label: 'explore',
474+
agent_index: 2,
475+
status,
476+
};
477+
if ('error' in extra) expectedPayload['error'] = (extra as { error: string }).error;
478+
if ('resultSummary' in extra) expectedPayload['result_summary'] = (extra as { resultSummary: string }).resultSummary;
479+
480+
expect(lifecycle.event).toEqual({
481+
type: 'SubagentStatus',
482+
payload: expectedPayload,
483+
_sessionId: 'session-1',
484+
});
485+
});
486+
413487
it('emits compaction begin when SDK compaction starts', () => {
414488
const result = adaptSdkEvent(createEventAdapterState(), {
415489
type: 'compaction.started',

0 commit comments

Comments
 (0)