From 082b50d2c23436450743cbc75c042f8301a9e11b Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 16 Sep 2026 18:53:41 +0000 Subject: [PATCH 1/4] fix(api): preserve provider reasoning across tool calls Amp-Thread-ID: https://ampcode.com/threads/T-01a0aac1-c3f8-7220-a896-ac2dafbf72e1 --- src/api/providers/__tests__/minimax.spec.ts | 51 +++++++++++++++++ src/api/providers/minimax.ts | 12 +++- .../__tests__/bedrock-converse-format.spec.ts | 55 +++++++++++++++++++ src/api/transform/bedrock-converse-format.ts | 23 +++++++- 4 files changed, 139 insertions(+), 2 deletions(-) diff --git a/src/api/providers/__tests__/minimax.spec.ts b/src/api/providers/__tests__/minimax.spec.ts index 01102b0457..f1ed9dae0e 100644 --- a/src/api/providers/__tests__/minimax.spec.ts +++ b/src/api/providers/__tests__/minimax.spec.ts @@ -342,6 +342,57 @@ describe("MiniMaxHandler", () => { expect(firstChunk.value).toEqual({ type: "reasoning", text: thinkingContent }) }) + it("captures thinking signatures for the next tool-loop request", async () => { + mockCreate.mockResolvedValueOnce( + asyncStreamFrom([ + { + type: "content_block_delta", + index: 0, + delta: { type: "thinking_delta", thinking: "Inspect the file." }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "signature_delta", signature: "signed-reasoning" }, + }, + ]), + ) + + const chunks = await collectStream(handler.createMessage("system prompt", [])) + + expect(chunks).toEqual([{ type: "reasoning", text: "Inspect the file." }]) + expect(handler.getThoughtSignature()).toBe("signed-reasoning") + }) + + it("filters legacy reasoning blocks while preserving signed thinking blocks", async () => { + mockCreate.mockResolvedValueOnce(asyncStreamFrom([])) + // The Anthropic SDK does not model Zoo Code's legacy internal reasoning block, + // which can still be present in persisted conversation history. + const messages = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "legacy unsigned reasoning", summary: [] }, + { type: "thinking", thinking: "signed reasoning", signature: "signature" }, + { type: "text", text: "I will inspect the file." }, + ], + }, + ] as unknown as Anthropic.Messages.MessageParam[] + + await collectStream(handler.createMessage("system prompt", messages)) + + const request = mockCreate.mock.calls[0][0] as Anthropic.Messages.MessageCreateParams + expect(request.messages).toEqual([ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "signed reasoning", signature: "signature" }, + { type: "text", text: "I will inspect the file." }, + ], + }, + ]) + }) + it("should handle tool calls in stream", async () => { mockCreate.mockResolvedValueOnce( asyncStreamFrom([ diff --git a/src/api/providers/minimax.ts b/src/api/providers/minimax.ts index e209add72d..3abd2c8686 100644 --- a/src/api/providers/minimax.ts +++ b/src/api/providers/minimax.ts @@ -10,6 +10,7 @@ import type { ApiHandlerOptions } from "../../shared/api" import { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { mergeEnvironmentDetailsForMiniMax } from "../transform/minimax-format" +import { filterNonAnthropicBlocks } from "../transform/anthropic-filter" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" @@ -53,6 +54,7 @@ function convertOpenAIToolChoice( export class MiniMaxHandler extends BaseProvider implements SingleCompletionHandler { private options: ApiHandlerOptions private client: Anthropic + private lastThoughtSignature?: string constructor(options: ApiHandlerOptions) { super() @@ -84,6 +86,7 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand ): ApiStream { const cacheControl: CacheControlEphemeral = { type: "ephemeral" } const { id: modelId, info, maxTokens, temperature } = this.getModel() + this.lastThoughtSignature = undefined // MiniMax M2 models support prompt caching const supportsPromptCache = info.supportsPromptCache ?? false @@ -92,7 +95,7 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand // into the tool_result content. This preserves reasoning continuity for // thinking models by preventing user messages from interrupting the // reasoning context after tool use (similar to r1-format's mergeToolResultText). - const processedMessages = mergeEnvironmentDetailsForMiniMax(messages) + const processedMessages = filterNonAnthropicBlocks(mergeEnvironmentDetailsForMiniMax(messages)) // Build the system blocks array const systemBlocks: Anthropic.Messages.TextBlockParam[] = [ @@ -194,6 +197,9 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand case "thinking_delta": yield { type: "reasoning", text: chunk.delta.thinking } break + case "signature_delta": + this.lastThoughtSignature = chunk.delta.signature + break case "text_delta": yield { type: "text", text: chunk.delta.text } break @@ -236,6 +242,10 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand } } + getThoughtSignature(): string | undefined { + return this.lastThoughtSignature + } + /** * Add cache control to the last two user messages for prompt caching */ diff --git a/src/api/transform/__tests__/bedrock-converse-format.spec.ts b/src/api/transform/__tests__/bedrock-converse-format.spec.ts index 27319c6562..77dced1351 100644 --- a/src/api/transform/__tests__/bedrock-converse-format.spec.ts +++ b/src/api/transform/__tests__/bedrock-converse-format.spec.ts @@ -26,6 +26,61 @@ describe("convertToBedrockConverseMessages", () => { ]) }) + it("converts internal reasoning blocks to Bedrock reasoning content", () => { + // The Anthropic SDK does not model Zoo Code's internal reasoning block, + // though this converter receives it from persisted conversation history. + const messages = [ + { + role: "assistant", + content: [{ type: "reasoning", text: "I should inspect the file first.", summary: [] }], + }, + ] as unknown as Anthropic.Messages.MessageParam[] + + expect(convertToBedrockConverseMessages(messages)).toEqual([ + { + role: "assistant", + content: [ + { + reasoningContent: { + reasoningText: { text: "I should inspect the file first." }, + }, + }, + ], + }, + ]) + }) + + it("converts signed thinking blocks to Bedrock reasoning content", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "I should inspect the file first.", + signature: "signed-reasoning", + }, + ], + }, + ] + + expect(convertToBedrockConverseMessages(messages)).toEqual([ + { + role: "assistant", + content: [ + { + reasoningContent: { + reasoningText: { + text: "I should inspect the file first.", + signature: "signed-reasoning", + }, + }, + }, + ], + }, + ]) + }) + it("converts messages with images correctly", () => { const messages: Anthropic.Messages.MessageParam[] = [ { diff --git a/src/api/transform/bedrock-converse-format.ts b/src/api/transform/bedrock-converse-format.ts index 2a49d72bce..fab2769a7e 100644 --- a/src/api/transform/bedrock-converse-format.ts +++ b/src/api/transform/bedrock-converse-format.ts @@ -3,8 +3,10 @@ import { ConversationRole, Message, ContentBlock } from "@aws-sdk/client-bedrock import { sanitizeOpenAiCallId } from "../../utils/tool-id" interface BedrockMessageContent { - type: "text" | "image" | "video" | "tool_use" | "tool_result" + type: "text" | "image" | "video" | "tool_use" | "tool_result" | "reasoning" | "thinking" text?: string + thinking?: string + signature?: string source?: { type: "base64" data: string | Uint8Array // string for Anthropic, Uint8Array for Bedrock @@ -58,6 +60,25 @@ export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Me } as ContentBlock } + if (messageBlock.type === "reasoning" && typeof messageBlock.text === "string") { + return { + reasoningContent: { + reasoningText: { text: messageBlock.text }, + }, + } as ContentBlock + } + + if (messageBlock.type === "thinking" && typeof messageBlock.thinking === "string") { + return { + reasoningContent: { + reasoningText: { + text: messageBlock.thinking, + ...(messageBlock.signature ? { signature: messageBlock.signature } : {}), + }, + }, + } as ContentBlock + } + if (messageBlock.type === "image" && messageBlock.source) { // Convert base64 string to byte array if needed let byteArray: Uint8Array From 8c366e7fa8fbd5d0b957937328287ac843ce9db3 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Fri, 18 Sep 2026 15:17:53 +0200 Subject: [PATCH 2/4] test(api): cover reasoning signature edge cases --- src/api/providers/__tests__/minimax.spec.ts | 28 +++++++++++++++++++ .../__tests__/bedrock-converse-format.spec.ts | 23 +++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/api/providers/__tests__/minimax.spec.ts b/src/api/providers/__tests__/minimax.spec.ts index f1ed9dae0e..051b3f5026 100644 --- a/src/api/providers/__tests__/minimax.spec.ts +++ b/src/api/providers/__tests__/minimax.spec.ts @@ -364,6 +364,34 @@ describe("MiniMaxHandler", () => { expect(handler.getThoughtSignature()).toBe("signed-reasoning") }) + it("clears stale thinking signatures before the next request", async () => { + mockCreate + .mockResolvedValueOnce( + asyncStreamFrom([ + { + type: "content_block_delta", + index: 0, + delta: { type: "signature_delta", signature: "signed-reasoning" }, + }, + ]), + ) + .mockResolvedValueOnce( + asyncStreamFrom([ + { + type: "content_block_delta", + index: 0, + delta: { type: "thinking_delta", thinking: "Continue without a signature." }, + }, + ]), + ) + + await collectStream(handler.createMessage("system prompt", [])) + expect(handler.getThoughtSignature()).toBe("signed-reasoning") + + await collectStream(handler.createMessage("system prompt", [])) + expect(handler.getThoughtSignature()).toBeUndefined() + }) + it("filters legacy reasoning blocks while preserving signed thinking blocks", async () => { mockCreate.mockResolvedValueOnce(asyncStreamFrom([])) // The Anthropic SDK does not model Zoo Code's legacy internal reasoning block, diff --git a/src/api/transform/__tests__/bedrock-converse-format.spec.ts b/src/api/transform/__tests__/bedrock-converse-format.spec.ts index 77dced1351..9d4a507e5c 100644 --- a/src/api/transform/__tests__/bedrock-converse-format.spec.ts +++ b/src/api/transform/__tests__/bedrock-converse-format.spec.ts @@ -81,6 +81,29 @@ describe("convertToBedrockConverseMessages", () => { ]) }) + it("converts unsigned thinking blocks without adding a signature", () => { + // Persisted provider output can omit a signature even though the Anthropic SDK requires one. + const messages = [ + { + role: "assistant", + content: [{ type: "thinking", thinking: "I should inspect the file first." }], + }, + ] as unknown as Anthropic.Messages.MessageParam[] + + expect(convertToBedrockConverseMessages(messages)).toStrictEqual([ + { + role: "assistant", + content: [ + { + reasoningContent: { + reasoningText: { text: "I should inspect the file first." }, + }, + }, + ], + }, + ]) + }) + it("converts messages with images correctly", () => { const messages: Anthropic.Messages.MessageParam[] = [ { From 6e471dc9e93863c7bbf508e40749da67cb3187a9 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Mon, 28 Sep 2026 19:03:34 +0200 Subject: [PATCH 3/4] fix(bedrock): drop signatures issued by other providers Bedrock never captures its own reasoning signatures, so any signature stored on a thinking block was issued by another provider (MiniMax) and would fail Bedrock's verification after a mid-task provider switch. Replay the reasoning text unsigned instead; `signature` is optional. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../__tests__/bedrock-converse-format.spec.ts | 10 ++++------ src/api/transform/bedrock-converse-format.ts | 9 ++++----- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/api/transform/__tests__/bedrock-converse-format.spec.ts b/src/api/transform/__tests__/bedrock-converse-format.spec.ts index 9d4a507e5c..86d6998cbd 100644 --- a/src/api/transform/__tests__/bedrock-converse-format.spec.ts +++ b/src/api/transform/__tests__/bedrock-converse-format.spec.ts @@ -50,7 +50,7 @@ describe("convertToBedrockConverseMessages", () => { ]) }) - it("converts signed thinking blocks to Bedrock reasoning content", () => { + it("drops signatures issued by another provider when converting thinking blocks", () => { const messages: Anthropic.Messages.MessageParam[] = [ { role: "assistant", @@ -64,16 +64,14 @@ describe("convertToBedrockConverseMessages", () => { }, ] - expect(convertToBedrockConverseMessages(messages)).toEqual([ + // Bedrock never records signatures, so a stored one would fail Bedrock's verification. + expect(convertToBedrockConverseMessages(messages)).toStrictEqual([ { role: "assistant", content: [ { reasoningContent: { - reasoningText: { - text: "I should inspect the file first.", - signature: "signed-reasoning", - }, + reasoningText: { text: "I should inspect the file first." }, }, }, ], diff --git a/src/api/transform/bedrock-converse-format.ts b/src/api/transform/bedrock-converse-format.ts index fab2769a7e..d8a20598f8 100644 --- a/src/api/transform/bedrock-converse-format.ts +++ b/src/api/transform/bedrock-converse-format.ts @@ -6,7 +6,6 @@ interface BedrockMessageContent { type: "text" | "image" | "video" | "tool_use" | "tool_result" | "reasoning" | "thinking" text?: string thinking?: string - signature?: string source?: { type: "base64" data: string | Uint8Array // string for Anthropic, Uint8Array for Bedrock @@ -69,12 +68,12 @@ export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Me } if (messageBlock.type === "thinking" && typeof messageBlock.thinking === "string") { + // Bedrock does not capture its own signatures, so any stored signature was issued by + // another provider (e.g. MiniMax) and would fail Bedrock's verification. Unsigned + // reasoning is accepted (`signature` is optional), so replay the text only. return { reasoningContent: { - reasoningText: { - text: messageBlock.thinking, - ...(messageBlock.signature ? { signature: messageBlock.signature } : {}), - }, + reasoningText: { text: messageBlock.thinking }, }, } as ContentBlock } From 5be91d1213f3c74849c3838c6f28acc23874a8b6 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Mon, 28 Sep 2026 19:06:44 +0200 Subject: [PATCH 4/4] fix(bedrock): only replay reasoning to models that preserve it Signed thinking blocks (from MiniMax) bypass Task's preserveReasoning filter, so after a provider switch they reached every Bedrock model, including Claude, as unsigned reasoningContent. Drop reasoning and thinking blocks unless the model sets preserveReasoning, matching how Task already strips plain reasoning blocks. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/api/providers/__tests__/bedrock.spec.ts | 26 +++++++++++++++++ src/api/providers/bedrock.ts | 4 ++- .../__tests__/bedrock-converse-format.spec.ts | 28 +++++++++++++++++-- src/api/transform/bedrock-converse-format.ts | 25 +++++++++++++++-- 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/src/api/providers/__tests__/bedrock.spec.ts b/src/api/providers/__tests__/bedrock.spec.ts index fd6da53a9b..9326034f1a 100644 --- a/src/api/providers/__tests__/bedrock.spec.ts +++ b/src/api/providers/__tests__/bedrock.spec.ts @@ -164,6 +164,32 @@ describe("AwsBedrockHandler", () => { } }) + it.each([ + ["anthropic.claude-sonnet-4-5-20250929-v1:0", false], + ["minimax.minimax-m2", true], + ] as const)("replays stored thinking to %s only when the model preserves reasoning", async (apiModelId, kept) => { + const provider = new AwsBedrockHandler({ apiModelId, awsRegion: "us-east-1" }) + provider["client"].send = vi.fn().mockResolvedValue({ stream: asyncStreamFrom([]) }) + // A MiniMax-signed thinking block survives a mid-task switch to Bedrock. + const history = [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "Signed by MiniMax.", signature: "minimax-signature" }, + { type: "text", text: "Done." }, + ], + }, + { role: "user", content: "next" }, + ] as Anthropic.Messages.MessageParam[] + await collectStream(provider.createMessage("system", history)) + const [{ messages }] = mockConverseStreamCommand.mock.lastCall! + expect(messages?.[1].content).toStrictEqual([ + ...(kept ? [{ reasoningContent: { reasoningText: { text: "Signed by MiniMax." } } }] : []), + { text: "Done." }, + ]) + }) + describe("getModel", () => { it("should return the correct model info for a standard model", () => { const modelInfo = handler.getModel() diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index 2e71ce0e12..080c8b038c 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -987,7 +987,9 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH conversationId?: string, // Optional conversation ID to track cache points across messages ): { system: SystemContentBlock[]; messages: Message[] } { // First convert messages using shared converter for proper image handling - const convertedMessages = sharedConverter(anthropicMessages as Anthropic.Messages.MessageParam[]) + const convertedMessages = sharedConverter(anthropicMessages as Anthropic.Messages.MessageParam[], { + preserveReasoning: modelInfo?.preserveReasoning === true, + }) // If prompt caching is disabled, return the converted messages directly if (!usePromptCache) { diff --git a/src/api/transform/__tests__/bedrock-converse-format.spec.ts b/src/api/transform/__tests__/bedrock-converse-format.spec.ts index 86d6998cbd..1551526e36 100644 --- a/src/api/transform/__tests__/bedrock-converse-format.spec.ts +++ b/src/api/transform/__tests__/bedrock-converse-format.spec.ts @@ -36,7 +36,7 @@ describe("convertToBedrockConverseMessages", () => { }, ] as unknown as Anthropic.Messages.MessageParam[] - expect(convertToBedrockConverseMessages(messages)).toEqual([ + expect(convertToBedrockConverseMessages(messages, { preserveReasoning: true })).toEqual([ { role: "assistant", content: [ @@ -50,6 +50,28 @@ describe("convertToBedrockConverseMessages", () => { ]) }) + it("drops reasoning and thinking blocks for models that do not preserve reasoning", () => { + // Signed thinking bypasses Task's reasoning filter, so the converter must not send it to e.g. Claude. + const messages = [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "Signed elsewhere.", signature: "minimax-signature" }, + { type: "text", text: "Reading the file." }, + ], + }, + { + role: "assistant", + content: [{ type: "reasoning", text: "Reasoning only.", summary: [] }], + }, + ] as unknown as Anthropic.Messages.MessageParam[] + + expect(convertToBedrockConverseMessages(messages)).toStrictEqual([ + { role: "assistant", content: [{ text: "Reading the file." }] }, + { role: "assistant", content: [{ text: "" }] }, + ]) + }) + it("drops signatures issued by another provider when converting thinking blocks", () => { const messages: Anthropic.Messages.MessageParam[] = [ { @@ -65,7 +87,7 @@ describe("convertToBedrockConverseMessages", () => { ] // Bedrock never records signatures, so a stored one would fail Bedrock's verification. - expect(convertToBedrockConverseMessages(messages)).toStrictEqual([ + expect(convertToBedrockConverseMessages(messages, { preserveReasoning: true })).toStrictEqual([ { role: "assistant", content: [ @@ -88,7 +110,7 @@ describe("convertToBedrockConverseMessages", () => { }, ] as unknown as Anthropic.Messages.MessageParam[] - expect(convertToBedrockConverseMessages(messages)).toStrictEqual([ + expect(convertToBedrockConverseMessages(messages, { preserveReasoning: true })).toStrictEqual([ { role: "assistant", content: [ diff --git a/src/api/transform/bedrock-converse-format.ts b/src/api/transform/bedrock-converse-format.ts index d8a20598f8..1f19b65e0e 100644 --- a/src/api/transform/bedrock-converse-format.ts +++ b/src/api/transform/bedrock-converse-format.ts @@ -27,8 +27,13 @@ interface BedrockMessageContent { /** * Convert Anthropic messages to Bedrock Converse format * @param anthropicMessages Messages in Anthropic format + * @param options.preserveReasoning Replay reasoning/thinking blocks as `reasoningContent`. + * Otherwise they are dropped, matching Task's stripping for models without `preserveReasoning`. */ -export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): Message[] { +export function convertToBedrockConverseMessages( + anthropicMessages: Anthropic.Messages.MessageParam[], + { preserveReasoning = false }: { preserveReasoning?: boolean } = {}, +): Message[] { return anthropicMessages.map((anthropicMessage) => { // Map Anthropic roles to Bedrock roles const role: ConversationRole = anthropicMessage.role === "assistant" ? "assistant" : "user" @@ -44,8 +49,18 @@ export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Me } } + // Signed thinking blocks (e.g. from MiniMax) bypass Task's reasoning filter, so they can reach + // any Bedrock model after a provider switch. Unsigned reasoning is only safe for models that + // opt in; Claude would receive thinking it cannot verify. + const blocks = preserveReasoning + ? anthropicMessage.content + : anthropicMessage.content.filter((block) => { + const type = (block as { type: string }).type + return type !== "reasoning" && type !== "thinking" + }) + // Process complex content types - const content = anthropicMessage.content.map((block) => { + const content = blocks.map((block) => { const messageBlock = block as BedrockMessageContent & { id?: string tool_use_id?: string @@ -223,7 +238,11 @@ export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Me return { role, - content, + // Bedrock rejects an empty content array; a reasoning-only turn becomes empty text. + content: + content.length === 0 && anthropicMessage.content.length > 0 + ? ([{ text: "" }] as ContentBlock[]) + : content, } }) }