diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 1add2822a01e9..840d713474a6e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6136,6 +6136,73 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // regression: #27238 the explanation an AskUserQuestion refers to must be visible text + it.effect.each([ + { tool: "AskUserQuestion", expected: ["assistant_message"] }, + { tool: "Bash", expected: ["reasoning"] }, + ] as const)( + "shows a lone Claude 5.1+ thinking block before $tool as $expected", + ({ tool, 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 explanation = "The memory file is the cause; deleting it loses your notes."; + 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: "claude-opus-5-5", content }, + }); + const frames = [ + snapshot("thinking-snapshot", [ + { type: "thinking", thinking: explanation, signature: "sig" }, + ]), + snapshot("tool-snapshot", [{ type: "tool_use", id: "tool-1", name: tool, input: {} }]), + 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] + : [], + ), + ); + const items = [...latest.values()]; + assert.deepEqual( + items.map((item) => item.type), + [...expected], + ); + assert.deepEqual( + items.map((item) => item.text), + [explanation], + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + // regression: #15892 non-execution metadata is carried through, not guessed from result text it.effect.each(["cancelled", "denied", "permission_denied", undefined])( "preserves native tool non-execution metadata %s without inferring a denial from text", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 7b63b7efc0b65..c86e5759d45a8 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2067,7 +2067,7 @@ function textFromClaudeContent(content: SDKAssistantMessage["message"]["content" return content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join(""); } -// Claude 5.1+ emits at most one pre-tool text block as thinking; treat only a second consecutive thinking block as text. +// Claude 5.1+ emits at most one pre-tool text block as thinking; treat a second consecutive thinking block, or a lone one before AskUserQuestion, as text. function claudeWritesUpdatesAsThinking(model: string): boolean { const match = /claude-[a-z]+-(\d+)(?:-(\d{1,2}))?(?!\d)/u.exec(model); if (match === null) return false; @@ -9126,7 +9126,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( block.type === "tool_use" && update !== null && update.messageId === message.message.id && - update.followsThinking && + (update.followsThinking || block.name === "AskUserQuestion") && claudeWritesUpdatesAsThinking(message.message.model) ) { yield* emitAssistantTextArtifacts({