diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index a15a97da0..f54d0e597 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -1467,10 +1467,12 @@ const THINKING_ENVELOPE_CLOSE = ""; function unwrapAnthropicThinkingEnvelope(text: string): string | undefined { let current = text.trim(); + let stripped = false; while (current.startsWith(THINKING_ENVELOPE_OPEN) && current.endsWith(THINKING_ENVELOPE_CLOSE)) { current = current.slice(THINKING_ENVELOPE_OPEN.length, current.length - THINKING_ENVELOPE_CLOSE.length).trim(); + stripped = true; } - return current === text ? undefined : current; + return stripped ? current : undefined; } function createEmptyUsage(premiumRequests?: number): Usage { diff --git a/packages/ai/test/anthropic-stream-envelope.test.ts b/packages/ai/test/anthropic-stream-envelope.test.ts index bced2b10c..486c35df9 100644 --- a/packages/ai/test/anthropic-stream-envelope.test.ts +++ b/packages/ai/test/anthropic-stream-envelope.test.ts @@ -349,6 +349,42 @@ describe("anthropic stream envelope handling", () => { { type: "text", text: "Check logs before accepting container health." }, ]); }); + it("preserves signed thinking bytes when no literal thinking envelope is present", async () => { + const signedThinking = "\nCheck logs before accepting container health.\n"; + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation( + () => createMockRequest(createThinkingSuccessEvents(signedThinking)) as never, + ); + + const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + for await (const _ of stream) { + // drain stream + } + const result = await stream.result(); + + expect(result.stopReason).toBe("stop"); + expect(result.content).toHaveLength(1); + const block = result.content[0]; + expect(block?.type).toBe("thinking"); + if (block?.type !== "thinking") { + throw new Error("Expected signed thinking content"); + } + expect(block.thinking).toBe(signedThinking); + expect(block.thinkingSignature).toBe("sig_thinking"); + + const replayParams = convertAnthropicMessages( + [ + { role: "user", content: "Say hi", timestamp: 1 }, + result, + { role: "user", content: "follow up", timestamp: 2 }, + ], + model, + false, + ); + const replayAssistant = replayParams.find(param => param.role === "assistant"); + expect(replayAssistant?.content).toEqual([ + { type: "thinking", thinking: signedThinking, signature: "sig_thinking" }, + ]); + }); it("drops replayed closed blocks after a duplicate message_start instead of duplicating content", async () => { const events: MockAnthropicEvent[] = [