diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index ec65e9a5ac5ced3219bfd20a343b987e33339c51..edaafff081e2e3f34fb1c244a7aef8bd66b57329 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2863,10 +2863,29 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - // Summarized thinking can't tell a progress update from reasoning, so both stay Thoughts. - it.effect.each(["claude-opus-5-5", "claude-fable-5-1", "claude-opus-5", "claude-sonnet-4-6"])( - "keeps %s thinking right before a tool call as reasoning", - (model) => + // Summarized display can't mark a progress update, so only the second of two + // thinking blocks before a tool call becomes text; a lone block is reasoning. + const plan = "Planning the check"; + const update = "Found it: the memory file is the cause."; + it.effect.each([ + { + model: "claude-opus-5-5", + texts: [plan, update], + expected: ["reasoning", "assistant_message"], + }, + { + model: "claude-fable-5-1", + texts: [plan, update], + expected: ["reasoning", "assistant_message"], + }, + { model: "claude-opus-5", texts: [plan, update], expected: ["reasoning", "reasoning"] }, + { model: "claude-sonnet-4-6", texts: [plan, update], expected: ["reasoning", "reasoning"] }, + { model: "claude-opus-5-5", texts: [plan], expected: ["reasoning"] }, + { model: "claude-opus-5-5", texts: [plan, " \n "], expected: ["reasoning", "reasoning"] }, + { model: "claude-opus-5-5", texts: [" \n ", update], expected: ["reasoning", "reasoning"] }, + ] as const)( + "shows $model thinking $texts before a tool call as $expected", + ({ model, texts, expected }) => Effect.gen(function* () { const harness = yield* makeWakeHarness; yield* harness.runtime.startTurn( @@ -2888,16 +2907,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { message: { id: "reply-message", model, content }, }); const frames = [ - snapshot("reasoning-snapshot", [ - { type: "thinking", thinking: "Planning the check", signature: "sig" }, - ]), - snapshot("reply-snapshot", [ - { - type: "thinking", - thinking: "Found it: the memory file is the cause.", - signature: "sig", - }, - ]), + ...texts.map((thinking, index) => + snapshot(`thinking-snapshot-${index}`, [ + { type: "thinking", thinking, signature: "sig" }, + ]), + ), snapshot("tool-snapshot", [ { type: "tool_use", id: "tool-1", name: "Bash", input: { command: "ls" } }, ]), @@ -2913,12 +2927,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { : [], ), ); + const items = [...latest.values()]; assert.deepEqual( - [...latest.values()].map((item) => [item.type, item.text]), - [ - ["reasoning", "Planning the check"], - ["reasoning", "Found it: the memory file is the cause."], - ], + items.map((item) => item.type), + [...expected], + ); + assert.deepEqual( + items.map((item) => item.text), + [...texts], ); }).pipe( Effect.scoped, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 9a871c49d4006142abc4956db49004ee5ee16583..c84aff8913c8ff0679f34454ec85b8a6dfb06879 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1275,6 +1275,17 @@ function textFromClaudeContent(content: SDKAssistantMessage["message"]["content" return content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join(""); } +// Claude 5.1 and later return the text written ahead of a tool call as a +// thinking block, at most one per call, instead of a text block. Summarized +// display makes it look like reasoning, so only the second of two thinking +// blocks right before a tool call is treated as that text. +function claudeWritesUpdatesAsThinking(model: string): boolean { + const match = /claude-[a-z]+-(\d+)(?:-(\d{1,2}))?(?!\d)/u.exec(model); + if (match === null) return false; + const major = Number(match[1]); + return major > 5 || (major === 5 && Number(match[2] ?? 0) >= 1); +} + // In SDK mode Claude reports `/goal` only through the transcript (its // `active_goal` event is remote-only): synthetic command output names the // goal, and each unmet evaluator check returns as Stop hook feedback. @@ -3016,6 +3027,13 @@ interface ActiveClaudeTurnContext { readonly nextBlockIndex: Map; readonly snapshotBlockIndex: Map; readonly snapshots: Set; + // The latest snapshot block, when it was non-empty thinking. + lastThinking: { + readonly messageId: string; + readonly itemId: string; + readonly text: string; + readonly followsThinking: boolean; + } | null; readonly blocks: Map< string, { readonly startedAt: DateTime.Utc; readonly ordinal: number; text: string } @@ -6962,18 +6980,45 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // The SDK emits one assistant snapshot per completed content block. // Count thinking blocks separately so snapshots share the streamed ID. for (const block of message.message.content) { - if (block.type !== "thinking") continue; + if (block.type !== "thinking") { + const update = context.reasoning.lastThinking; + context.reasoning.lastThinking = null; + if ( + block.type === "tool_use" && + update !== null && + update.messageId === message.message.id && + update.followsThinking && + claudeWritesUpdatesAsThinking(message.message.model) + ) { + // Same item ID, so the update replaces the Thought in place. + yield* emitAssistantTextArtifacts({ + context, + nativeItemId: update.itemId, + text: update.text, + }); + } + continue; + } const index = context.reasoning.snapshotBlockIndex.get(message.message.id) ?? 0; context.reasoning.snapshotBlockIndex.set(message.message.id, index + 1); const itemId = `${message.message.id}:thinking:${index}`; yield* ensureReasoningBlock(context, itemId); const finalText = block.thinking || context.reasoning.blocks.get(itemId)?.text; - yield* reasoningDeltas.complete({ + const completedText = yield* reasoningDeltas.complete({ turnId: context.nativeTurnId, itemId, ...(finalText ? { finalText } : {}), emitEmpty: false, }); + const previous = context.reasoning.lastThinking; + context.reasoning.lastThinking = completedText.trim() + ? { + messageId: message.message.id, + itemId, + text: completedText, + followsThinking: previous !== null && previous.messageId === message.message.id, + } + : null; } } @@ -8699,6 +8744,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( streamBlocks: new Map(), nextBlockIndex: new Map(), snapshotBlockIndex: new Map(), + lastThinking: null, snapshots: new Set(), blocks: new Map(), },