authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-13 03:12:30+00:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-13 03:12:30+00:00
log06620faf6de7dc9dc02f245c9fedc49644c2ee08
tree2a6b405cc91b7e72a1c85bad19cfe95477d2bad0
parentdc192851f0abfd812ffadc5b3eb1c5c152171cc5
signature Signed by PGP key B5690EEEBB952194

fix(server): Claude thinking before a tool call stays a collapsed Thought (#18887)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

2 files changed, 6 insertions(+), 45 deletions(-)

apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+5-9
......@@ -2809,14 +2809,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {
28092809 ),
28102810 );
28112811
2812 it.effect.each([
2813 { model: "claude-opus-5-5", expected: "assistant_message" },
2814 { model: "claude-fable-5-1", expected: "assistant_message" },
2815 { model: "claude-opus-5", expected: "reasoning" },
2816 { model: "claude-sonnet-4-6", expected: "reasoning" },
2817 ] as const)(
2818 "shows $model thinking right before a tool call as $expected",
2819 ({ model, expected }) =>
2812 // Summarized thinking can't tell a progress update from reasoning, so both stay Thoughts.
2813 it.effect.each(["claude-opus-5-5", "claude-fable-5-1", "claude-opus-5", "claude-sonnet-4-6"])(
2814 "keeps %s thinking right before a tool call as reasoning",
2815 (model) =>
28202816 Effect.gen(function* () {
28212817 const harness = yield* makeWakeHarness;
28222818 yield* harness.runtime.startTurn(
......@@ -2867,7 +2863,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
28672863 [...latest.values()].map((item) => [item.type, item.text]),
28682864 [
28692865 ["reasoning", "Planning the check"],
2870 [expected, "Found it: the memory file is the cause."],
2866 ["reasoning", "Found it: the memory file is the cause."],
28712867 ],
28722868 );
28732869 }).pipe(
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+1-36
......@@ -1165,15 +1165,6 @@ 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
11771168// In SDK mode Claude reports `/goal` only through the transcript (its
11781169// `active_goal` event is remote-only): synthetic command output names the
11791170// goal, and each unmet evaluator check returns as Stop hook feedback.
......@@ -2915,12 +2906,6 @@ interface ActiveClaudeTurnContext {
29152906 readonly nextBlockIndex: Map<string, number>;
29162907 readonly snapshotBlockIndex: Map<string, number>;
29172908 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;
29242909 readonly blocks: Map<
29252910 string,
29262911 { readonly startedAt: DateTime.Utc; readonly ordinal: number; text: string }
......@@ -6862,23 +6847,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
68626847 // The SDK emits one assistant snapshot per completed content block.
68636848 // Count thinking blocks separately so snapshots share the streamed ID.
68646849 for (const block of message.message.content) {
6865 if (block.type !== "thinking") {
6866 const update = context.reasoning.lastThinking;
6867 context.reasoning.lastThinking = null;
6868 if (
6869 block.type === "tool_use" &&
6870 update?.messageId === message.message.id &&
6871 claudeWritesUpdatesAsThinking(message.message.model)
6872 ) {
6873 // Same item ID, so the update replaces the Thought in place.
6874 yield* emitAssistantTextArtifacts({
6875 context,
6876 nativeItemId: update.itemId,
6877 text: update.text,
6878 });
6879 }
6880 continue;
6881 }
6850 if (block.type !== "thinking") continue;
68826851 const index = context.reasoning.snapshotBlockIndex.get(message.message.id) ?? 0;
68836852 context.reasoning.snapshotBlockIndex.set(message.message.id, index + 1);
68846853 const itemId = `${message.message.id}:thinking:${index}`;
......@@ -6890,9 +6859,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
68906859 ...(finalText ? { finalText } : {}),
68916860 emitEmpty: false,
68926861 });
6893 context.reasoning.lastThinking = finalText
6894 ? { messageId: message.message.id, itemId, text: finalText }
6895 : null;
68966862 }
68976863 }
68986864
......@@ -8587,7 +8553,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
85878553 streamBlocks: new Map(),
85888554 nextBlockIndex: new Map(),
85898555 snapshotBlockIndex: new Map(),
8590 lastThinking: null,
85918556 snapshots: new Set(),
85928557 blocks: new Map(),
85938558 },