| author | |
| committer | |
| log | 3f70c831c65f339e71c549a2e1395aaffa9ce17e |
| tree | ee451d5067c669d61b6c6117c509a0267f04435b |
| parent | c0c495e4dc12bbb0bb44387657a5682661cdd05c |
| signature | Signed by PGP key B5690EEEBB952194 |
2 files changed, 105 insertions(+), 1 deletions(-)
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+69| ... | ... | @@ -2806,6 +2806,75 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2806 | 2806 | ), |
| 2807 | 2807 | ); |
| 2808 | 2808 | |
| 2809 | it.effect.each([ | |
| 2810 | { model: "claude-opus-5-5", expected: "assistant_message" }, | |
| 2811 | { model: "claude-fable-5-1", expected: "assistant_message" }, | |
| 2812 | { model: "claude-opus-5", expected: "reasoning" }, | |
| 2813 | { model: "claude-sonnet-4-6", expected: "reasoning" }, | |
| 2814 | ] as const)( | |
| 2815 | "shows $model thinking right before a tool call as $expected", | |
| 2816 | ({ model, expected }) => | |
| 2817 | Effect.gen(function* () { | |
| 2818 | const harness = yield* makeWakeHarness; | |
| 2819 | yield* harness.runtime.startTurn( | |
| 2820 | makeClaudeTestTurnInput({ | |
| 2821 | threadId: harness.threadId, | |
| 2822 | providerThread: harness.providerThread, | |
| 2823 | now: yield* DateTime.now, | |
| 2824 | attemptId: RunAttemptId.make("thinking-before-tool-attempt"), | |
| 2825 | text: "Check the memory file", | |
| 2826 | attachments: [], | |
| 2827 | }), | |
| 2828 | ); | |
| 2829 | const snapshot = (uuid: string, content: ReadonlyArray<unknown>) => | |
| 2830 | claudeSdkFrame({ | |
| 2831 | type: "assistant", | |
| 2832 | uuid, | |
| 2833 | session_id: WAKE_NATIVE_SESSION, | |
| 2834 | parent_tool_use_id: null, | |
| 2835 | message: { id: "reply-message", model, content }, | |
| 2836 | }); | |
| 2837 | const frames = [ | |
| 2838 | snapshot("reasoning-snapshot", [ | |
| 2839 | { type: "thinking", thinking: "Planning the check", signature: "sig" }, | |
| 2840 | ]), | |
| 2841 | snapshot("reply-snapshot", [ | |
| 2842 | { | |
| 2843 | type: "thinking", | |
| 2844 | thinking: "Found it: the memory file is the cause.", | |
| 2845 | signature: "sig", | |
| 2846 | }, | |
| 2847 | ]), | |
| 2848 | snapshot("tool-snapshot", [ | |
| 2849 | { type: "tool_use", id: "tool-1", name: "Bash", input: { command: "ls" } }, | |
| 2850 | ]), | |
| 2851 | makeResultFrame({ uuid: "thinking-before-tool-result", result: "" }), | |
| 2852 | ]; | |
| 2853 | for (const frame of frames) yield* Queue.offer(harness.sdkMessages, frame); | |
| 2854 | yield* Queue.take(harness.terminalReceipts); | |
| 2855 | const latest = new Map( | |
| 2856 | harness.events.flatMap((event) => | |
| 2857 | event.type === "turn_item.updated" && | |
| 2858 | (event.turnItem.type === "reasoning" || event.turnItem.type === "assistant_message") | |
| 2859 | ? [[event.turnItem.id, event.turnItem] as const] | |
| 2860 | : [], | |
| 2861 | ), | |
| 2862 | ); | |
| 2863 | assert.deepEqual( | |
| 2864 | [...latest.values()].map((item) => [item.type, item.text]), | |
| 2865 | [ | |
| 2866 | ["reasoning", "Planning the check"], | |
| 2867 | [expected, "Found it: the memory file is the cause."], | |
| 2868 | ], | |
| 2869 | ); | |
| 2870 | }).pipe( | |
| 2871 | Effect.scoped, | |
| 2872 | Effect.provide( | |
| 2873 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 2874 | ), | |
| 2875 | ), | |
| 2876 | ); | |
| 2877 | ||
| 2809 | 2878 | it.effect.each(["cancelled", "denied", "permission_denied", undefined])( |
| 2810 | 2879 | "preserves native tool non-execution metadata %s without inferring a denial from text", |
| 2811 | 2880 | (kind) => |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+36-1| ... | ... | @@ -1165,6 +1165,15 @@ function textFromClaudeContent(content: SDKAssistantMessage["message"]["content" |
| 1165 | 1165 | return content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join(""); |
| 1166 | 1166 | } |
| 1167 | 1167 | |
| 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. | |
| 1170 | function 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 | ||
| 1168 | 1177 | // In SDK mode Claude reports `/goal` only through the transcript (its |
| 1169 | 1178 | // `active_goal` event is remote-only): synthetic command output names the |
| 1170 | 1179 | // goal, and each unmet evaluator check returns as Stop hook feedback. |
| ... | ... | @@ -2906,6 +2915,12 @@ interface ActiveClaudeTurnContext { |
| 2906 | 2915 | readonly nextBlockIndex: Map<string, number>; |
| 2907 | 2916 | readonly snapshotBlockIndex: Map<string, number>; |
| 2908 | 2917 | 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; | |
| 2909 | 2924 | readonly blocks: Map< |
| 2910 | 2925 | string, |
| 2911 | 2926 | { readonly startedAt: DateTime.Utc; readonly ordinal: number; text: string } |
| ... | ... | @@ -6752,7 +6767,23 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 6752 | 6767 | // The SDK emits one assistant snapshot per completed content block. |
| 6753 | 6768 | // Count thinking blocks separately so snapshots share the streamed ID. |
| 6754 | 6769 | for (const block of message.message.content) { |
| 6755 | if (block.type !== "thinking") continue; | |
| 6770 | if (block.type !== "thinking") { | |
| 6771 | const update = context.reasoning.lastThinking; | |
| 6772 | context.reasoning.lastThinking = null; | |
| 6773 | if ( | |
| 6774 | block.type === "tool_use" && | |
| 6775 | update?.messageId === message.message.id && | |
| 6776 | claudeWritesUpdatesAsThinking(message.message.model) | |
| 6777 | ) { | |
| 6778 | // Same item ID, so the update replaces the Thought in place. | |
| 6779 | yield* emitAssistantTextArtifacts({ | |
| 6780 | context, | |
| 6781 | nativeItemId: update.itemId, | |
| 6782 | text: update.text, | |
| 6783 | }); | |
| 6784 | } | |
| 6785 | continue; | |
| 6786 | } | |
| 6756 | 6787 | const index = context.reasoning.snapshotBlockIndex.get(message.message.id) ?? 0; |
| 6757 | 6788 | context.reasoning.snapshotBlockIndex.set(message.message.id, index + 1); |
| 6758 | 6789 | const itemId = `${message.message.id}:thinking:${index}`; |
| ... | ... | @@ -6764,6 +6795,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 6764 | 6795 | ...(finalText ? { finalText } : {}), |
| 6765 | 6796 | emitEmpty: false, |
| 6766 | 6797 | }); |
| 6798 | context.reasoning.lastThinking = finalText | |
| 6799 | ? { messageId: message.message.id, itemId, text: finalText } | |
| 6800 | : null; | |
| 6767 | 6801 | } |
| 6768 | 6802 | } |
| 6769 | 6803 | |
| ... | ... | @@ -8441,6 +8475,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 8441 | 8475 | streamBlocks: new Map(), |
| 8442 | 8476 | nextBlockIndex: new Map(), |
| 8443 | 8477 | snapshotBlockIndex: new Map(), |
| 8478 | lastThinking: null, | |
| 8444 | 8479 | snapshots: new Set(), |
| 8445 | 8480 | blocks: new Map(), |
| 8446 | 8481 | }, |