From fa18ccd2a091ad20dfced179050aa07c9252c0bf Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:54:10 -0700 Subject: [PATCH] fix(server): Claude subagent threads show their thinking Claude sends each subagent thinking block whole, in an assistant snapshot tagged with the subagent's parent_tool_use_id (it never streams subagent output). The adapter dropped those blocks: the reasoning path skips parent-tool frames, and subagent text routing keeps only text parts. A native subagent's thread showed its tool calls and final reply with none of the reasoning in between. Route subagent thinking blocks into the child thread as completed "Thinking" reasoning items, ordered with the child's other items, the same way subagent text already reaches it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Adapters/ClaudeAdapterV2.ts | 49 ++++++++++++++++++- .../output.ts | 35 +++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) 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(