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", () => {...@@ -2809,14 +2809,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2809 ),2809 ),
2810 );2810 );
28112811
2812 it.effect.each([2812 // Summarized thinking can't tell a progress update from reasoning, so both stay Thoughts.
2813 { model: "claude-opus-5-5", expected: "assistant_message" },2813 it.effect.each(["claude-opus-5-5", "claude-fable-5-1", "claude-opus-5", "claude-sonnet-4-6"])(
2814 { model: "claude-fable-5-1", expected: "assistant_message" },2814 "keeps %s thinking right before a tool call as reasoning",
2815 { model: "claude-opus-5", expected: "reasoning" },2815 (model) =>
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 }) =>
2820 Effect.gen(function* () {2816 Effect.gen(function* () {
2821 const harness = yield* makeWakeHarness;2817 const harness = yield* makeWakeHarness;
2822 yield* harness.runtime.startTurn(2818 yield* harness.runtime.startTurn(
...@@ -2867,7 +2863,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2867,7 +2863,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2867 [...latest.values()].map((item) => [item.type, item.text]),2863 [...latest.values()].map((item) => [item.type, item.text]),
2868 [2864 [
2869 ["reasoning", "Planning the check"],2865 ["reasoning", "Planning the check"],
2870 [expected, "Found it: the memory file is the cause."],2866 ["reasoning", "Found it: the memory file is the cause."],
2871 ],2867 ],
2872 );2868 );
2873 }).pipe(2869 }).pipe(
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+1-36
...@@ -1165,15 +1165,6 @@ function textFromClaudeContent(content: SDKAssistantMessage["message"]["content"...@@ -1165,15 +1165,6 @@ 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
1177// In SDK mode Claude reports `/goal` only through the transcript (its1168// In SDK mode Claude reports `/goal` only through the transcript (its
1178// `active_goal` event is remote-only): synthetic command output names the1169// `active_goal` event is remote-only): synthetic command output names the
1179// goal, and each unmet evaluator check returns as Stop hook feedback.1170// goal, and each unmet evaluator check returns as Stop hook feedback.
...@@ -2915,12 +2906,6 @@ interface ActiveClaudeTurnContext {...@@ -2915,12 +2906,6 @@ interface ActiveClaudeTurnContext {
2915 readonly nextBlockIndex: Map<string, number>;2906 readonly nextBlockIndex: Map<string, number>;
2916 readonly snapshotBlockIndex: Map<string, number>;2907 readonly snapshotBlockIndex: Map<string, number>;
2917 readonly snapshots: Set<string>;2908 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;
2924 readonly blocks: Map<2909 readonly blocks: Map<
2925 string,2910 string,
2926 { readonly startedAt: DateTime.Utc; readonly ordinal: number; text: string }2911 { readonly startedAt: DateTime.Utc; readonly ordinal: number; text: string }
...@@ -6862,23 +6847,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -6862,23 +6847,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
6862 // The SDK emits one assistant snapshot per completed content block.6847 // The SDK emits one assistant snapshot per completed content block.
6863 // Count thinking blocks separately so snapshots share the streamed ID.6848 // Count thinking blocks separately so snapshots share the streamed ID.
6864 for (const block of message.message.content) {6849 for (const block of message.message.content) {
6865 if (block.type !== "thinking") {6850 if (block.type !== "thinking") continue;
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 }
6882 const index = context.reasoning.snapshotBlockIndex.get(message.message.id) ?? 0;6851 const index = context.reasoning.snapshotBlockIndex.get(message.message.id) ?? 0;
6883 context.reasoning.snapshotBlockIndex.set(message.message.id, index + 1);6852 context.reasoning.snapshotBlockIndex.set(message.message.id, index + 1);
6884 const itemId = `${message.message.id}:thinking:${index}`;6853 const itemId = `${message.message.id}:thinking:${index}`;
...@@ -6890,9 +6859,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -6890,9 +6859,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
6890 ...(finalText ? { finalText } : {}),6859 ...(finalText ? { finalText } : {}),
6891 emitEmpty: false,6860 emitEmpty: false,
6892 });6861 });
6893 context.reasoning.lastThinking = finalText
6894 ? { messageId: message.message.id, itemId, text: finalText }
6895 : null;
6896 }6862 }
6897 }6863 }
68986864
...@@ -8587,7 +8553,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -8587,7 +8553,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
8587 streamBlocks: new Map(),8553 streamBlocks: new Map(),
8588 nextBlockIndex: new Map(),8554 nextBlockIndex: new Map(),
8589 snapshotBlockIndex: new Map(),8555 snapshotBlockIndex: new Map(),
8590 lastThinking: null,
8591 snapshots: new Set(),8556 snapshots: new Set(),
8592 blocks: new Map(),8557 blocks: new Map(),
8593 },8558 },