diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index e269d4cbd..1befacee9 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -63,6 +63,7 @@ ### Removed - Removed legacy local request-cost estimation machinery and database schemas previously used for OpenCode Go estimates. +- Fixed Anthropic-compatible streams dropping thinking bytes supplied by `content_block_start`, which invalidated signed-thinking replay ([#8319](https://github.com/can1357/oh-my-pi/pull/8319) by [@max12525k](https://github.com/max12525k)). ## [17.2.15] - 2026-08-12 diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index ef427bc3f..fd9fcac01 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -2416,6 +2416,14 @@ const streamAnthropicOnce = ( contentIndex, partial: output, }); + if (block.thinking) { + stream.push({ + type: "thinking_delta", + contentIndex, + delta: block.thinking, + partial: output, + }); + } } else if (event.content_block.type === "redacted_thinking") { streamedReplayUnsafeContent = true; const block: Block = { diff --git a/packages/ai/test/anthropic-stream-envelope.test.ts b/packages/ai/test/anthropic-stream-envelope.test.ts index 257aa4451..f6c2f9364 100644 --- a/packages/ai/test/anthropic-stream-envelope.test.ts +++ b/packages/ai/test/anthropic-stream-envelope.test.ts @@ -902,31 +902,6 @@ describe("anthropic stream envelope handling", () => { ]); }); - it("preserves signed thinking content from content_block_start", async () => { - const events = createThinkingSuccessEvents(" summary tail"); - events[1] = { - type: "content_block_start", - index: 0, - content_block: { type: "thinking", thinking: "Summary prefix" }, - }; - vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => createMockRequest(events) 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.content).toHaveLength(1); - const block = result.content[0]; - expect(block?.type).toBe("thinking"); - if (block?.type !== "thinking") { - throw new Error("Expected thinking content from content_block_start"); - } - expect(block.thinking).toBe("Summary prefix summary tail"); - expect(block.thinkingSignature).toBe("sig_thinking"); - }); - it("drops replayed closed blocks after a duplicate message_start instead of duplicating content", async () => { const events: MockAnthropicEvent[] = [ { diff --git a/packages/ai/test/leaked-thinking-stream.test.ts b/packages/ai/test/leaked-thinking-stream.test.ts index bd0564d22..104432e93 100644 --- a/packages/ai/test/leaked-thinking-stream.test.ts +++ b/packages/ai/test/leaked-thinking-stream.test.ts @@ -659,6 +659,43 @@ describe("leaked thinking healing through stream()", () => { return Object.assign(fn, { preconnect: fetch.preconnect }); } + function anthropicThinkingFetch(): FetchImpl { + const body = [ + sseFrame("message_start", { + type: "message_start", + message: { id: "msg_thinking_prefix", usage: { input_tokens: 5, output_tokens: 0 } }, + }), + sseFrame("content_block_start", { + type: "content_block_start", + index: 0, + content_block: { type: "thinking", thinking: "Summary prefix" }, + }), + sseFrame("content_block_delta", { + type: "content_block_delta", + index: 0, + delta: { type: "thinking_delta", thinking: " summary tail" }, + }), + sseFrame("content_block_delta", { + type: "content_block_delta", + index: 0, + delta: { type: "signature_delta", signature: "sig_thinking" }, + }), + sseFrame("content_block_stop", { type: "content_block_stop", index: 0 }), + sseFrame("message_delta", { + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { input_tokens: 5, output_tokens: 4 }, + }), + sseFrame("message_stop", { type: "message_stop" }), + ].join(""); + const fn = async (_input: string | URL | Request, _init?: RequestInit): Promise => + new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream", "request-id": "req_thinking_prefix" }, + }); + return Object.assign(fn, { preconnect: fetch.preconnect }); + } + function anthropicModel(overrides: Partial> = {}): Model<"anthropic-messages"> { return buildModel({ id: "claude-sonnet-4-5", @@ -711,6 +748,25 @@ describe("leaked thinking healing through stream()", () => { expect(texts(result).join("").trim()).toBe("Final answer."); }); + it("preserves thinking bytes from content_block_start through a non-official endpoint", async () => { + const result = await stream( + anthropicModel({ provider: "zai", baseUrl: "https://api.z.ai/api/anthropic" }), + context, + { + apiKey: "test", + fetch: anthropicThinkingFetch(), + }, + ).result(); + + expect(thinks(result)).toEqual([ + { + type: "thinking", + thinking: "Summary prefix summary tail", + thinkingSignature: "sig_thinking", + }, + ]); + }); + it("replays native web-search history on a custom Anthropic continuation", async () => { const searchResult = { type: "web_search_tool_result",