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", () => {
28062806 ),
28072807 );
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
28092878 it.effect.each(["cancelled", "denied", "permission_denied", undefined])(
28102879 "preserves native tool non-execution metadata %s without inferring a denial from text",
28112880 (kind) =>
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+36-1
......@@ -1165,6 +1165,15 @@ function textFromClaudeContent(content: SDKAssistantMessage["message"]["content"
11651165 return content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
11661166}
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
11681177// In SDK mode Claude reports `/goal` only through the transcript (its
11691178// `active_goal` event is remote-only): synthetic command output names the
11701179// goal, and each unmet evaluator check returns as Stop hook feedback.
......@@ -2906,6 +2915,12 @@ interface ActiveClaudeTurnContext {
29062915 readonly nextBlockIndex: Map<string, number>;
29072916 readonly snapshotBlockIndex: Map<string, number>;
29082917 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;
29092924 readonly blocks: Map<
29102925 string,
29112926 { readonly startedAt: DateTime.Utc; readonly ordinal: number; text: string }
......@@ -6752,7 +6767,23 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
67526767 // The SDK emits one assistant snapshot per completed content block.
67536768 // Count thinking blocks separately so snapshots share the streamed ID.
67546769 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 }
67566787 const index = context.reasoning.snapshotBlockIndex.get(message.message.id) ?? 0;
67576788 context.reasoning.snapshotBlockIndex.set(message.message.id, index + 1);
67586789 const itemId = `${message.message.id}:thinking:${index}`;
......@@ -6764,6 +6795,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
67646795 ...(finalText ? { finalText } : {}),
67656796 emitEmpty: false,
67666797 });
6798 context.reasoning.lastThinking = finalText
6799 ? { messageId: message.message.id, itemId, text: finalText }
6800 : null;
67676801 }
67686802 }
67696803
......@@ -8441,6 +8475,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
84418475 streamBlocks: new Map(),
84428476 nextBlockIndex: new Map(),
84438477 snapshotBlockIndex: new Map(),
8478 lastThinking: null,
84448479 snapshots: new Set(),
84458480 blocks: new Map(),
84468481 },