From b5f84e70d472712420aa82298c50ca22455e69fc Mon Sep 17 00:00:00 2001 From: "(CJ) Chukwudi Nwobodo" <142016413+chuks-qua@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:23:57 +0100 Subject: [PATCH] fix: attribute parallel Devin subagents to their spawn calls Devin emits subagent_started lifecycle updates in spawn order, but the mapper paired each one with the most recent pending run_subagent call. With parallel subagents every agent row attached to the wrong parent, which broke nested attribution and left child activity projected against an unrelated call. Consume the oldest unresolved run_subagent instead. Also adds narrative coverage pinning that in-flight subagent children never leak into top-level active-tool rows while still rendering nested under their agent. --- .../parallel-subagent-nesting.test.ts | 29 +++++++++++++++++++ .../__tests__/devin-acp-event-mapper.test.ts | 27 +++++++++++++++++ .../private/devin/devin-acp-event-mapper.ts | 9 +++--- 3 files changed, 60 insertions(+), 5 deletions(-) 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);