authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-14 17:45:39+00:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 17:45:39+00:00
logcae6c6627a323d1d33a340842b0eb384807daedb
tree21480c1119d6e3675e0525b5c75b02e4155593f3
parent7ac952c630ef8a60d14ca8f6a0c7adce33db4bae
signature Signed by PGP key B5690EEEBB952194

fix(server): only Claude progress updates before a tool call show as text (#18923)

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

2 files changed, 83 insertions(+), 21 deletions(-)

apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+35-19
......@@ -2863,10 +2863,29 @@ describe("ClaudeAdapterV2 background wake turns", () => {
28632863 ),
28642864 );
28652865
2866 // Summarized thinking can't tell a progress update from reasoning, so both stay Thoughts.
2867 it.effect.each(["claude-opus-5-5", "claude-fable-5-1", "claude-opus-5", "claude-sonnet-4-6"])(
2868 "keeps %s thinking right before a tool call as reasoning",
2869 (model) =>
2866 // Summarized display can't mark a progress update, so only the second of two
2867 // thinking blocks before a tool call becomes text; a lone block is reasoning.
2868 const plan = "Planning the check";
2869 const update = "Found it: the memory file is the cause.";
2870 it.effect.each([
2871 {
2872 model: "claude-opus-5-5",
2873 texts: [plan, update],
2874 expected: ["reasoning", "assistant_message"],
2875 },
2876 {
2877 model: "claude-fable-5-1",
2878 texts: [plan, update],
2879 expected: ["reasoning", "assistant_message"],
2880 },
2881 { model: "claude-opus-5", texts: [plan, update], expected: ["reasoning", "reasoning"] },
2882 { model: "claude-sonnet-4-6", texts: [plan, update], expected: ["reasoning", "reasoning"] },
2883 { model: "claude-opus-5-5", texts: [plan], expected: ["reasoning"] },
2884 { model: "claude-opus-5-5", texts: [plan, " \n "], expected: ["reasoning", "reasoning"] },
2885 { model: "claude-opus-5-5", texts: [" \n ", update], expected: ["reasoning", "reasoning"] },
2886 ] as const)(
2887 "shows $model thinking $texts before a tool call as $expected",
2888 ({ model, texts, expected }) =>
28702889 Effect.gen(function* () {
28712890 const harness = yield* makeWakeHarness;
28722891 yield* harness.runtime.startTurn(
......@@ -2888,16 +2907,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {
28882907 message: { id: "reply-message", model, content },
28892908 });
28902909 const frames = [
2891 snapshot("reasoning-snapshot", [
2892 { type: "thinking", thinking: "Planning the check", signature: "sig" },
2893 ]),
2894 snapshot("reply-snapshot", [
2895 {
2896 type: "thinking",
2897 thinking: "Found it: the memory file is the cause.",
2898 signature: "sig",
2899 },
2900 ]),
2910 ...texts.map((thinking, index) =>
2911 snapshot(`thinking-snapshot-${index}`, [
2912 { type: "thinking", thinking, signature: "sig" },
2913 ]),
2914 ),
29012915 snapshot("tool-snapshot", [
29022916 { type: "tool_use", id: "tool-1", name: "Bash", input: { command: "ls" } },
29032917 ]),
......@@ -2913,12 +2927,14 @@ describe("ClaudeAdapterV2 background wake turns", () => {
29132927 : [],
29142928 ),
29152929 );
2930 const items = [...latest.values()];
29162931 assert.deepEqual(
2917 [...latest.values()].map((item) => [item.type, item.text]),
2918 [
2919 ["reasoning", "Planning the check"],
2920 ["reasoning", "Found it: the memory file is the cause."],
2921 ],
2932 items.map((item) => item.type),
2933 [...expected],
2934 );
2935 assert.deepEqual(
2936 items.map((item) => item.text),
2937 [...texts],
29222938 );
29232939 }).pipe(
29242940 Effect.scoped,
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+48-2
......@@ -1275,6 +1275,17 @@ function textFromClaudeContent(content: SDKAssistantMessage["message"]["content"
12751275 return content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
12761276}
12771277
1278// Claude 5.1 and later return the text written ahead of a tool call as a
1279// thinking block, at most one per call, instead of a text block. Summarized
1280// display makes it look like reasoning, so only the second of two thinking
1281// blocks right before a tool call is treated as that text.
1282function claudeWritesUpdatesAsThinking(model: string): boolean {
1283 const match = /claude-[a-z]+-(\d+)(?:-(\d{1,2}))?(?!\d)/u.exec(model);
1284 if (match === null) return false;
1285 const major = Number(match[1]);
1286 return major > 5 || (major === 5 && Number(match[2] ?? 0) >= 1);
1287}
1288
12781289// In SDK mode Claude reports `/goal` only through the transcript (its
12791290// `active_goal` event is remote-only): synthetic command output names the
12801291// goal, and each unmet evaluator check returns as Stop hook feedback.
......@@ -3016,6 +3027,13 @@ interface ActiveClaudeTurnContext {
30163027 readonly nextBlockIndex: Map<string, number>;
30173028 readonly snapshotBlockIndex: Map<string, number>;
30183029 readonly snapshots: Set<string>;
3030 // The latest snapshot block, when it was non-empty thinking.
3031 lastThinking: {
3032 readonly messageId: string;
3033 readonly itemId: string;
3034 readonly text: string;
3035 readonly followsThinking: boolean;
3036 } | null;
30193037 readonly blocks: Map<
30203038 string,
30213039 { readonly startedAt: DateTime.Utc; readonly ordinal: number; text: string }
......@@ -6962,18 +6980,45 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
69626980 // The SDK emits one assistant snapshot per completed content block.
69636981 // Count thinking blocks separately so snapshots share the streamed ID.
69646982 for (const block of message.message.content) {
6965 if (block.type !== "thinking") continue;
6983 if (block.type !== "thinking") {
6984 const update = context.reasoning.lastThinking;
6985 context.reasoning.lastThinking = null;
6986 if (
6987 block.type === "tool_use" &&
6988 update !== null &&
6989 update.messageId === message.message.id &&
6990 update.followsThinking &&
6991 claudeWritesUpdatesAsThinking(message.message.model)
6992 ) {
6993 // Same item ID, so the update replaces the Thought in place.
6994 yield* emitAssistantTextArtifacts({
6995 context,
6996 nativeItemId: update.itemId,
6997 text: update.text,
6998 });
6999 }
7000 continue;
7001 }
69667002 const index = context.reasoning.snapshotBlockIndex.get(message.message.id) ?? 0;
69677003 context.reasoning.snapshotBlockIndex.set(message.message.id, index + 1);
69687004 const itemId = `${message.message.id}:thinking:${index}`;
69697005 yield* ensureReasoningBlock(context, itemId);
69707006 const finalText = block.thinking || context.reasoning.blocks.get(itemId)?.text;
6971 yield* reasoningDeltas.complete({
7007 const completedText = yield* reasoningDeltas.complete({
69727008 turnId: context.nativeTurnId,
69737009 itemId,
69747010 ...(finalText ? { finalText } : {}),
69757011 emitEmpty: false,
69767012 });
7013 const previous = context.reasoning.lastThinking;
7014 context.reasoning.lastThinking = completedText.trim()
7015 ? {
7016 messageId: message.message.id,
7017 itemId,
7018 text: completedText,
7019 followsThinking: previous !== null && previous.messageId === message.message.id,
7020 }
7021 : null;
69777022 }
69787023 }
69797024
......@@ -8699,6 +8744,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
86998744 streamBlocks: new Map(),
87008745 nextBlockIndex: new Map(),
87018746 snapshotBlockIndex: new Map(),
8747 lastThinking: null,
87028748 snapshots: new Set(),
87038749 blocks: new Map(),
87048750 },