diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 7652b56369266b155bd276bbb1d5af072e4a70ef..4bf007065a739b6b076b62278fba6e135c3576b9 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2806,6 +2806,75 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.each([ + { model: "claude-opus-5-5", expected: "assistant_message" }, + { model: "claude-fable-5-1", expected: "assistant_message" }, + { model: "claude-opus-5", expected: "reasoning" }, + { model: "claude-sonnet-4-6", expected: "reasoning" }, + ] as const)( + "shows $model thinking right before a tool call as $expected", + ({ model, expected }) => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("thinking-before-tool-attempt"), + text: "Check the memory file", + attachments: [], + }), + ); + const snapshot = (uuid: string, content: ReadonlyArray) => + claudeSdkFrame({ + type: "assistant", + uuid, + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + 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", + }, + ]), + snapshot("tool-snapshot", [ + { type: "tool_use", id: "tool-1", name: "Bash", input: { command: "ls" } }, + ]), + makeResultFrame({ uuid: "thinking-before-tool-result", result: "" }), + ]; + for (const frame of frames) yield* Queue.offer(harness.sdkMessages, frame); + yield* Queue.take(harness.terminalReceipts); + const latest = new Map( + harness.events.flatMap((event) => + event.type === "turn_item.updated" && + (event.turnItem.type === "reasoning" || event.turnItem.type === "assistant_message") + ? [[event.turnItem.id, event.turnItem] as const] + : [], + ), + ); + assert.deepEqual( + [...latest.values()].map((item) => [item.type, item.text]), + [ + ["reasoning", "Planning the check"], + [expected, "Found it: the memory file is the cause."], + ], + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect.each(["cancelled", "denied", "permission_denied", undefined])( "preserves native tool non-execution metadata %s without inferring a denial from text", (kind) => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 80eb474365345b461d5d9d98e555e59abc881961..7199f73040ef9fe8a5a2bd02efb963355ed4011d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1165,6 +1165,15 @@ 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. +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. @@ -2906,6 +2915,12 @@ 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; + } | null; readonly blocks: Map< string, { readonly startedAt: DateTime.Utc; readonly ordinal: number; text: string } @@ -6752,7 +6767,23 @@ 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?.messageId === message.message.id && + 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}`; @@ -6764,6 +6795,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ...(finalText ? { finalText } : {}), emitEmpty: false, }); + context.reasoning.lastThinking = finalText + ? { messageId: message.message.id, itemId, text: finalText } + : null; } } @@ -8441,6 +8475,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(), },