diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index e7c113405eb7..f58861da57b2 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -5362,8 +5362,55 @@ export function makeClaudeAdapterV2( context.toolCalls.delete(toolCall.nativeItemId); } - const assistantText = assistantTextFromSdkMessage(message); const assistantParentToolUseId = parentToolUseIdFromSdkMessage(message); + if (message.type === "assistant" && assistantParentToolUseId !== null) { + // Claude never streams subagent output, so each thinking block + // arrives whole in its own snapshot and lands in the child thread. + const thinking = message.message.content.flatMap((block) => + block.type === "thinking" && block.thinking.trim().length > 0 ? [block.thinking] : [], + ); + const subagent = + thinking.length === 0 + ? undefined + : yield* resolveSubagentByToolUseId(context, assistantParentToolUseId); + if (subagent !== undefined) { + const now = yield* DateTime.now; + for (const [index, text] of thinking.entries()) { + const nativeItemId = `${message.uuid}:thinking:${index}`; + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: { + id: idAllocator.derive.turnItemFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId, + }), + threadId: subagent.childThreadId, + runId: null, + nodeId: subagent.childRootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: { + driver: CLAUDE_PROVIDER, + nativeId: nativeItemId, + strength: "strong", + }, + parentItemId: null, + ordinal: ++subagent.nextChildItemOrdinal, + type: "reasoning", + title: "Thinking", + text, + streaming: false, + status: "completed", + startedAt: now, + completedAt: now, + updatedAt: now, + }, + }); + } + } + } + const assistantText = assistantTextFromSdkMessage(message); if ( assistantText !== null && assistantText.text.length > 0 && diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts index c3b9d297cc4d..7be360e0f701 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts @@ -145,6 +145,41 @@ export function assertClaudeBackgroundSubagentLifecycleOutput( assert.isDefined(agentAChild); assert.deepEqual(assistantTexts(agentAChild), ["A_FIRST", "A_SECOND"]); + // The recording's subagent thinking lands in each child thread, before the + // reply it led to, and never in the parent. + const thinkingOf = (child: OrchestrationV2ThreadProjection | undefined) => + (child?.turnItems ?? []).flatMap((item) => + item.type === "reasoning" && item.title === "Thinking" ? [item] : [], + ); + const agentAThinking = thinkingOf(agentAChild); + assert.lengthOf(agentAThinking, 2, "both of Agent A's runs thought before replying"); + assert.include(agentAThinking[0]?.text, '"A_FIRST"'); + assert.include(agentAThinking[1]?.text, '"A_SECOND"'); + for (const item of agentAThinking) { + assert.equal(item.status, "completed"); + assert.isFalse(item.streaming); + assert.isNull(item.runId); + } + const agentAOrder = agentAChild.turnItems.flatMap((item) => + item.type === "assistant_message" + ? [item.text.trim()] + : item.type === "reasoning" && item.title === "Thinking" + ? ["thinking"] + : [], + ); + assert.deepEqual(agentAOrder, ["thinking", "A_FIRST", "thinking", "A_SECOND"]); + const agentBChild = + agentB?.childThreadId == null ? undefined : result.projections.get(agentB.childThreadId); + assert.lengthOf(thinkingOf(agentBChild), 1); + assert.include(thinkingOf(agentBChild)[0]?.text, "B_DONE"); + const subagentThinking = new Set( + [...agentAThinking, ...thinkingOf(agentBChild)].map((item) => item.text), + ); + assert.isFalse( + thinkingOf(projection).some((item) => subagentThinking.has(item.text)), + "subagent thinking leaked into the parent thread", + ); + // Subagents appear in background_tasks_changed but never on the roster. assert.isFalse( result.domainEvents.some(