diff --git a/apps/web/src/features/conversation/narrative/__tests__/parallel-subagent-nesting.test.ts b/apps/web/src/features/conversation/narrative/__tests__/parallel-subagent-nesting.test.ts index f5c92111c..098ff570d 100644 --- a/apps/web/src/features/conversation/narrative/__tests__/parallel-subagent-nesting.test.ts +++ b/apps/web/src/features/conversation/narrative/__tests__/parallel-subagent-nesting.test.ts @@ -90,6 +90,35 @@ describe("parallel sub-agent nesting", () => { expect(counts.subagents).toBe(4); }); + it("never renders in-flight subagent children as active top-level rows", () => { + // Devin child markers carry subagent_context and never get their own + // terminal update, so they stay isComplete=false until the owning Agent + // resolves them. They must not leak into the top-level active-tool rows; + // that is what painted every narrative element as currently running. + const tools: ToolCall[] = [ + mkTool({ id: "call-spawn", toolName: "Agent", startedAt: 1000, isComplete: true }), + mkTool({ id: "agent-1", toolName: "Agent", startedAt: 1001, isComplete: false, parentToolCallId: "call-spawn" }), + mkTool({ id: "c1", toolName: "Read", startedAt: 2000, isComplete: false, parentToolCallId: "agent-1" }), + mkTool({ id: "c2", toolName: "Grep", startedAt: 2100, isComplete: false, parentToolCallId: "agent-1" }), + mkTool({ id: "c3", toolName: "Read", startedAt: 2200, isComplete: false, parentToolCallId: "agent-1" }), + ]; + + const { items } = buildNarrativeItems({ + toolCalls: tools, + hooks: [], + thoughtSegments: [], + streamingText: "", + isAgentRunning: true, + }); + + expect(items.filter((i) => i.type === "active-tool")).toEqual([]); + const agentItem = requiredSubagentItem( + items.find((i) => i.type === "subagent" && i.toolCall.id === "agent-1"), + ); + expect(agentItem.lifecycle).toBe("started"); + expect(agentItem.children.map((c) => c.id)).toEqual(["c1", "c2", "c3"]); + }); + it("treats empty-string parentToolCallId as top-level (cannot be a real parent id)", () => { // Defensive: if a bad event ever surfaces parentToolCallId: "" it must // be treated as no parent at all. Pre-fix, "" went into childrenMap[""] diff --git a/packages/providers/src/private/devin/__tests__/devin-acp-event-mapper.test.ts b/packages/providers/src/private/devin/__tests__/devin-acp-event-mapper.test.ts index 8b9e84c44..c64f5902f 100644 --- a/packages/providers/src/private/devin/__tests__/devin-acp-event-mapper.test.ts +++ b/packages/providers/src/private/devin/__tests__/devin-acp-event-mapper.test.ts @@ -311,6 +311,33 @@ describe("mapDevinAcpSessionNotification", () => { expect(state.pendingSubagentCallIds).toEqual([]); }); + it("attributes parallel subagent_started updates to their run_subagent calls in spawn order", () => { + const state = createDevinAcpTurnState(); + const runSubagent = (toolCallId: string) => + notification( + toolCall(toolCallId, { + kind: "other", + _meta: { "cognition.ai/inferenceToolName": "run_subagent" }, + }), + ); + const subagentStarted = (agentId: string) => + notification({ + sessionUpdate: "tool_call_update", + toolCallId: agentId, + status: "in_progress", + _meta: { "cognition.ai/subagent_started": { task: "work", title: "Work" } }, + }); + + mapDevinAcpSessionNotification(runSubagent("call-a"), THREAD, state); + mapDevinAcpSessionNotification(runSubagent("call-b"), THREAD, state); + + const first = mapDevinAcpSessionNotification(subagentStarted("agent-1"), THREAD, state); + const second = mapDevinAcpSessionNotification(subagentStarted("agent-2"), THREAD, state); + + expect(first[0]).toMatchObject({ toolCallId: "agent-1", parentToolCallId: "call-a" }); + expect(second[0]).toMatchObject({ toolCallId: "agent-2", parentToolCallId: "call-b" }); + }); + it("copies task to description on run_subagent markers so narrative extractors find it", () => { const state = createDevinAcpTurnState(); const events = mapDevinAcpSessionNotification( diff --git a/packages/providers/src/private/devin/devin-acp-event-mapper.ts b/packages/providers/src/private/devin/devin-acp-event-mapper.ts index 314e064cd..addbb149c 100644 --- a/packages/providers/src/private/devin/devin-acp-event-mapper.ts +++ b/packages/providers/src/private/devin/devin-acp-event-mapper.ts @@ -322,9 +322,10 @@ function subagentStartedEvents( threadId: string, state: DevinAcpTurnState, ): AgentEvent[] { - const parentToolCallId = state.pendingSubagentCallIds[ - state.pendingSubagentCallIds.length - 1 - ]; + // Devin emits subagent_started in spawn order, so pair it with the oldest + // unresolved run_subagent call. `last` would attach every parallel agent to + // the most recent call, hiding their subtrees under the wrong parent row. + const parentToolCallId = state.pendingSubagentCallIds.shift(); if (parentToolCallId) state.subagentParentByAgentId.set(toolCallId, parentToolCallId); const task = typeof started.task === "string" ? started.task : undefined; const title = typeof started.title === "string" ? started.title : undefined; @@ -423,8 +424,6 @@ function subagentCompletedEvents( ): AgentEvent[] { const parentToolCallId = state.subagentParentByAgentId.get(toolCallId); if (parentToolCallId) { - const idx = state.pendingSubagentCallIds.indexOf(parentToolCallId); - if (idx >= 0) state.pendingSubagentCallIds.splice(idx, 1); state.subagentParentByAgentId.delete(toolCallId); } state.accumulator.toolStartTimes.delete(toolCallId);