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", () => {...@@ -2863,10 +2863,29 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2863 ),2863 ),
2864 );2864 );
28652865
2866 // Summarized thinking can't tell a progress update from reasoning, so both stay Thoughts.2866 // Summarized display can't mark a progress update, so only the second of two
2867 it.effect.each(["claude-opus-5-5", "claude-fable-5-1", "claude-opus-5", "claude-sonnet-4-6"])(2867 // thinking blocks before a tool call becomes text; a lone block is reasoning.
2868 "keeps %s thinking right before a tool call as reasoning",2868 const plan = "Planning the check";
2869 (model) =>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 }) =>
2870 Effect.gen(function* () {2889 Effect.gen(function* () {
2871 const harness = yield* makeWakeHarness;2890 const harness = yield* makeWakeHarness;
2872 yield* harness.runtime.startTurn(2891 yield* harness.runtime.startTurn(
...@@ -2888,16 +2907,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2888,16 +2907,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2888 message: { id: "reply-message", model, content },2907 message: { id: "reply-message", model, content },
2889 });2908 });
2890 const frames = [2909 const frames = [
2891 snapshot("reasoning-snapshot", [2910 ...texts.map((thinking, index) =>
2892 { type: "thinking", thinking: "Planning the check", signature: "sig" },2911 snapshot(`thinking-snapshot-${index}`, [
2893 ]),2912 { type: "thinking", thinking, signature: "sig" },
2894 snapshot("reply-snapshot", [2913 ]),
2895 {2914 ),
2896 type: "thinking",
2897 thinking: "Found it: the memory file is the cause.",
2898 signature: "sig",
2899 },
2900 ]),
2901 snapshot("tool-snapshot", [2915 snapshot("tool-snapshot", [
2902 { type: "tool_use", id: "tool-1", name: "Bash", input: { command: "ls" } },2916 { type: "tool_use", id: "tool-1", name: "Bash", input: { command: "ls" } },
2903 ]),2917 ]),
...@@ -2913,12 +2927,14 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2913,12 +2927,14 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2913 : [],2927 : [],
2914 ),2928 ),
2915 );2929 );
2930 const items = [...latest.values()];
2916 assert.deepEqual(2931 assert.deepEqual(
2917 [...latest.values()].map((item) => [item.type, item.text]),2932 items.map((item) => item.type),
2918 [2933 [...expected],
2919 ["reasoning", "Planning the check"],2934 );
2920 ["reasoning", "Found it: the memory file is the cause."],2935 assert.deepEqual(
2921 ],2936 items.map((item) => item.text),
2937 [...texts],
2922 );2938 );
2923 }).pipe(2939 }).pipe(
2924 Effect.scoped,2940 Effect.scoped,
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+48-2
...@@ -1275,6 +1275,17 @@ function textFromClaudeContent(content: SDKAssistantMessage["message"]["content"...@@ -1275,6 +1275,17 @@ function textFromClaudeContent(content: SDKAssistantMessage["message"]["content"
1275 return content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");1275 return content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
1276}1276}
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
1278// In SDK mode Claude reports `/goal` only through the transcript (its1289// In SDK mode Claude reports `/goal` only through the transcript (its
1279// `active_goal` event is remote-only): synthetic command output names the1290// `active_goal` event is remote-only): synthetic command output names the
1280// goal, and each unmet evaluator check returns as Stop hook feedback.1291// goal, and each unmet evaluator check returns as Stop hook feedback.
...@@ -3016,6 +3027,13 @@ interface ActiveClaudeTurnContext {...@@ -3016,6 +3027,13 @@ interface ActiveClaudeTurnContext {
3016 readonly nextBlockIndex: Map<string, number>;3027 readonly nextBlockIndex: Map<string, number>;
3017 readonly snapshotBlockIndex: Map<string, number>;3028 readonly snapshotBlockIndex: Map<string, number>;
3018 readonly snapshots: Set<string>;3029 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;
3019 readonly blocks: Map<3037 readonly blocks: Map<
3020 string,3038 string,
3021 { readonly startedAt: DateTime.Utc; readonly ordinal: number; text: string }3039 { readonly startedAt: DateTime.Utc; readonly ordinal: number; text: string }
...@@ -6962,18 +6980,45 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -6962,18 +6980,45 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
6962 // The SDK emits one assistant snapshot per completed content block.6980 // The SDK emits one assistant snapshot per completed content block.
6963 // Count thinking blocks separately so snapshots share the streamed ID.6981 // Count thinking blocks separately so snapshots share the streamed ID.
6964 for (const block of message.message.content) {6982 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 }
6966 const index = context.reasoning.snapshotBlockIndex.get(message.message.id) ?? 0;7002 const index = context.reasoning.snapshotBlockIndex.get(message.message.id) ?? 0;
6967 context.reasoning.snapshotBlockIndex.set(message.message.id, index + 1);7003 context.reasoning.snapshotBlockIndex.set(message.message.id, index + 1);
6968 const itemId = `${message.message.id}:thinking:${index}`;7004 const itemId = `${message.message.id}:thinking:${index}`;
6969 yield* ensureReasoningBlock(context, itemId);7005 yield* ensureReasoningBlock(context, itemId);
6970 const finalText = block.thinking || context.reasoning.blocks.get(itemId)?.text;7006 const finalText = block.thinking || context.reasoning.blocks.get(itemId)?.text;
6971 yield* reasoningDeltas.complete({7007 const completedText = yield* reasoningDeltas.complete({
6972 turnId: context.nativeTurnId,7008 turnId: context.nativeTurnId,
6973 itemId,7009 itemId,
6974 ...(finalText ? { finalText } : {}),7010 ...(finalText ? { finalText } : {}),
6975 emitEmpty: false,7011 emitEmpty: false,
6976 });7012 });
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;
6977 }7022 }
6978 }7023 }
69797024
...@@ -8699,6 +8744,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -8699,6 +8744,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
8699 streamBlocks: new Map(),8744 streamBlocks: new Map(),
8700 nextBlockIndex: new Map(),8745 nextBlockIndex: new Map(),
8701 snapshotBlockIndex: new Map(),8746 snapshotBlockIndex: new Map(),
8747 lastThinking: null,
8702 snapshots: new Set(),8748 snapshots: new Set(),
8703 blocks: new Map(),8749 blocks: new Map(),
8704 },8750 },