| author | |
| committer | |
| log | cae6c6627a323d1d33a340842b0eb384807daedb |
| tree | 21480c1119d6e3675e0525b5c75b02e4155593f3 |
| parent | 7ac952c630ef8a60d14ca8f6a0c7adce33db4bae |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 2863 | ), |
| 2864 | 2864 | ); |
| 2865 | 2865 | |
| 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 }) => | |
| 2870 | 2889 | Effect.gen(function* () { |
| 2871 | 2890 | const harness = yield* makeWakeHarness; |
| 2872 | 2891 | yield* harness.runtime.startTurn( |
| ... | ... | @@ -2888,16 +2907,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2888 | 2907 | message: { id: "reply-message", model, content }, |
| 2889 | 2908 | }); |
| 2890 | 2909 | 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 | ), | |
| 2901 | 2915 | snapshot("tool-snapshot", [ |
| 2902 | 2916 | { type: "tool_use", id: "tool-1", name: "Bash", input: { command: "ls" } }, |
| 2903 | 2917 | ]), |
| ... | ... | @@ -2913,12 +2927,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2913 | 2927 | : [], |
| 2914 | 2928 | ), |
| 2915 | 2929 | ); |
| 2930 | const items = [...latest.values()]; | |
| 2916 | 2931 | 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], | |
| 2922 | 2938 | ); |
| 2923 | 2939 | }).pipe( |
| 2924 | 2940 | Effect.scoped, |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+48-2| ... | ... | @@ -1275,6 +1275,17 @@ function textFromClaudeContent(content: SDKAssistantMessage["message"]["content" |
| 1275 | 1275 | return content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join(""); |
| 1276 | 1276 | } |
| 1277 | 1277 | |
| 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. | |
| 1282 | function 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 | 1289 | // In SDK mode Claude reports `/goal` only through the transcript (its |
| 1279 | 1290 | // `active_goal` event is remote-only): synthetic command output names the |
| 1280 | 1291 | // goal, and each unmet evaluator check returns as Stop hook feedback. |
| ... | ... | @@ -3016,6 +3027,13 @@ interface ActiveClaudeTurnContext { |
| 3016 | 3027 | readonly nextBlockIndex: Map<string, number>; |
| 3017 | 3028 | readonly snapshotBlockIndex: Map<string, number>; |
| 3018 | 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 | 3037 | readonly blocks: Map< |
| 3020 | 3038 | string, |
| 3021 | 3039 | { readonly startedAt: DateTime.Utc; readonly ordinal: number; text: string } |
| ... | ... | @@ -6962,18 +6980,45 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 6962 | 6980 | // The SDK emits one assistant snapshot per completed content block. |
| 6963 | 6981 | // Count thinking blocks separately so snapshots share the streamed ID. |
| 6964 | 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 | 7002 | const index = context.reasoning.snapshotBlockIndex.get(message.message.id) ?? 0; |
| 6967 | 7003 | context.reasoning.snapshotBlockIndex.set(message.message.id, index + 1); |
| 6968 | 7004 | const itemId = `${message.message.id}:thinking:${index}`; |
| 6969 | 7005 | yield* ensureReasoningBlock(context, itemId); |
| 6970 | 7006 | const finalText = block.thinking || context.reasoning.blocks.get(itemId)?.text; |
| 6971 | yield* reasoningDeltas.complete({ | |
| 7007 | const completedText = yield* reasoningDeltas.complete({ | |
| 6972 | 7008 | turnId: context.nativeTurnId, |
| 6973 | 7009 | itemId, |
| 6974 | 7010 | ...(finalText ? { finalText } : {}), |
| 6975 | 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 | } |
| 6979 | 7024 | |
| ... | ... | @@ -8699,6 +8744,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 8699 | 8744 | streamBlocks: new Map(), |
| 8700 | 8745 | nextBlockIndex: new Map(), |
| 8701 | 8746 | snapshotBlockIndex: new Map(), |
| 8747 | lastThinking: null, | |
| 8702 | 8748 | snapshots: new Set(), |
| 8703 | 8749 | blocks: new Map(), |
| 8704 | 8750 | }, |