authorgravatar for 75276669+Gigioxx@users.noreply.github.comGuillermo Casanova <75276669+Gigioxx@users.noreply.github.com> 2026-10-11 19:24:59-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 15:24:59-07:00
log3f70c831c65f339e71c549a2e1395aaffa9ce17e
treeee451d5067c669d61b6c6117c509a0267f04435b
parentc0c495e4dc12bbb0bb44387657a5682661cdd05c
signature Signed by PGP key B5690EEEBB952194

fix(server): Claude 5.1+ text before a tool call shows as assistant text (#18364)


2 files changed, 105 insertions(+), 1 deletions(-)

apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+69
...@@ -2806,6 +2806,75 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2806,6 +2806,75 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2806 ),2806 ),
2807 );2807 );
28082808
2809 it.effect.each([
2810 { model: "claude-opus-5-5", expected: "assistant_message" },
2811 { model: "claude-fable-5-1", expected: "assistant_message" },
2812 { model: "claude-opus-5", expected: "reasoning" },
2813 { model: "claude-sonnet-4-6", expected: "reasoning" },
2814 ] as const)(
2815 "shows $model thinking right before a tool call as $expected",
2816 ({ model, expected }) =>
2817 Effect.gen(function* () {
2818 const harness = yield* makeWakeHarness;
2819 yield* harness.runtime.startTurn(
2820 makeClaudeTestTurnInput({
2821 threadId: harness.threadId,
2822 providerThread: harness.providerThread,
2823 now: yield* DateTime.now,
2824 attemptId: RunAttemptId.make("thinking-before-tool-attempt"),
2825 text: "Check the memory file",
2826 attachments: [],
2827 }),
2828 );
2829 const snapshot = (uuid: string, content: ReadonlyArray<unknown>) =>
2830 claudeSdkFrame({
2831 type: "assistant",
2832 uuid,
2833 session_id: WAKE_NATIVE_SESSION,
2834 parent_tool_use_id: null,
2835 message: { id: "reply-message", model, content },
2836 });
2837 const frames = [
2838 snapshot("reasoning-snapshot", [
2839 { type: "thinking", thinking: "Planning the check", signature: "sig" },
2840 ]),
2841 snapshot("reply-snapshot", [
2842 {
2843 type: "thinking",
2844 thinking: "Found it: the memory file is the cause.",
2845 signature: "sig",
2846 },
2847 ]),
2848 snapshot("tool-snapshot", [
2849 { type: "tool_use", id: "tool-1", name: "Bash", input: { command: "ls" } },
2850 ]),
2851 makeResultFrame({ uuid: "thinking-before-tool-result", result: "" }),
2852 ];
2853 for (const frame of frames) yield* Queue.offer(harness.sdkMessages, frame);
2854 yield* Queue.take(harness.terminalReceipts);
2855 const latest = new Map(
2856 harness.events.flatMap((event) =>
2857 event.type === "turn_item.updated" &&
2858 (event.turnItem.type === "reasoning" || event.turnItem.type === "assistant_message")
2859 ? [[event.turnItem.id, event.turnItem] as const]
2860 : [],
2861 ),
2862 );
2863 assert.deepEqual(
2864 [...latest.values()].map((item) => [item.type, item.text]),
2865 [
2866 ["reasoning", "Planning the check"],
2867 [expected, "Found it: the memory file is the cause."],
2868 ],
2869 );
2870 }).pipe(
2871 Effect.scoped,
2872 Effect.provide(
2873 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
2874 ),
2875 ),
2876 );
2877
2809 it.effect.each(["cancelled", "denied", "permission_denied", undefined])(2878 it.effect.each(["cancelled", "denied", "permission_denied", undefined])(
2810 "preserves native tool non-execution metadata %s without inferring a denial from text",2879 "preserves native tool non-execution metadata %s without inferring a denial from text",
2811 (kind) =>2880 (kind) =>
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+36-1
...@@ -1165,6 +1165,15 @@ function textFromClaudeContent(content: SDKAssistantMessage["message"]["content"...@@ -1165,6 +1165,15 @@ function textFromClaudeContent(content: SDKAssistantMessage["message"]["content"
1165 return content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");1165 return content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
1166}1166}
11671167
1168// Claude 5.1 and later return the text written ahead of a tool call as a
1169// thinking block, at most one per call, instead of a text block.
1170function claudeWritesUpdatesAsThinking(model: string): boolean {
1171 const match = /claude-[a-z]+-(\d+)(?:-(\d{1,2}))?(?!\d)/u.exec(model);
1172 if (match === null) return false;
1173 const major = Number(match[1]);
1174 return major > 5 || (major === 5 && Number(match[2] ?? 0) >= 1);
1175}
1176
1168// In SDK mode Claude reports `/goal` only through the transcript (its1177// In SDK mode Claude reports `/goal` only through the transcript (its
1169// `active_goal` event is remote-only): synthetic command output names the1178// `active_goal` event is remote-only): synthetic command output names the
1170// goal, and each unmet evaluator check returns as Stop hook feedback.1179// goal, and each unmet evaluator check returns as Stop hook feedback.
...@@ -2906,6 +2915,12 @@ interface ActiveClaudeTurnContext {...@@ -2906,6 +2915,12 @@ interface ActiveClaudeTurnContext {
2906 readonly nextBlockIndex: Map<string, number>;2915 readonly nextBlockIndex: Map<string, number>;
2907 readonly snapshotBlockIndex: Map<string, number>;2916 readonly snapshotBlockIndex: Map<string, number>;
2908 readonly snapshots: Set<string>;2917 readonly snapshots: Set<string>;
2918 // The latest snapshot block, when it was non-empty thinking.
2919 lastThinking: {
2920 readonly messageId: string;
2921 readonly itemId: string;
2922 readonly text: string;
2923 } | null;
2909 readonly blocks: Map<2924 readonly blocks: Map<
2910 string,2925 string,
2911 { readonly startedAt: DateTime.Utc; readonly ordinal: number; text: string }2926 { readonly startedAt: DateTime.Utc; readonly ordinal: number; text: string }
...@@ -6752,7 +6767,23 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -6752,7 +6767,23 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
6752 // The SDK emits one assistant snapshot per completed content block.6767 // The SDK emits one assistant snapshot per completed content block.
6753 // Count thinking blocks separately so snapshots share the streamed ID.6768 // Count thinking blocks separately so snapshots share the streamed ID.
6754 for (const block of message.message.content) {6769 for (const block of message.message.content) {
6755 if (block.type !== "thinking") continue;6770 if (block.type !== "thinking") {
6771 const update = context.reasoning.lastThinking;
6772 context.reasoning.lastThinking = null;
6773 if (
6774 block.type === "tool_use" &&
6775 update?.messageId === message.message.id &&
6776 claudeWritesUpdatesAsThinking(message.message.model)
6777 ) {
6778 // Same item ID, so the update replaces the Thought in place.
6779 yield* emitAssistantTextArtifacts({
6780 context,
6781 nativeItemId: update.itemId,
6782 text: update.text,
6783 });
6784 }
6785 continue;
6786 }
6756 const index = context.reasoning.snapshotBlockIndex.get(message.message.id) ?? 0;6787 const index = context.reasoning.snapshotBlockIndex.get(message.message.id) ?? 0;
6757 context.reasoning.snapshotBlockIndex.set(message.message.id, index + 1);6788 context.reasoning.snapshotBlockIndex.set(message.message.id, index + 1);
6758 const itemId = `${message.message.id}:thinking:${index}`;6789 const itemId = `${message.message.id}:thinking:${index}`;
...@@ -6764,6 +6795,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -6764,6 +6795,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
6764 ...(finalText ? { finalText } : {}),6795 ...(finalText ? { finalText } : {}),
6765 emitEmpty: false,6796 emitEmpty: false,
6766 });6797 });
6798 context.reasoning.lastThinking = finalText
6799 ? { messageId: message.message.id, itemId, text: finalText }
6800 : null;
6767 }6801 }
6768 }6802 }
67696803
...@@ -8441,6 +8475,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -8441,6 +8475,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
8441 streamBlocks: new Map(),8475 streamBlocks: new Map(),
8442 nextBlockIndex: new Map(),8476 nextBlockIndex: new Map(),
8443 snapshotBlockIndex: new Map(),8477 snapshotBlockIndex: new Map(),
8478 lastThinking: null,
8444 snapshots: new Set(),8479 snapshots: new Set(),
8445 blocks: new Map(),8480 blocks: new Map(),
8446 },8481 },