From 0d7f9e818c0e06d1f1f62866168139c60b2b5654 Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Fri, 16 Jan 2026 14:13:43 -0700 Subject: [PATCH] fix: handle model switch to Claude with extended thinking enabled - Convert thinking blocks with signatures to wrapped text (preserves content) - Convert reasoning blocks to wrapped text for Claude - Track converted thinking messages to avoid incorrect removal - Only remove last assistant message if safe (no tool calls, no converted thinking) - Preserve tool_use/tool_result pairing Fixes the 'Invalid signature in thinking block' and 'Expected thinking or redacted_thinking but found text' errors when switching from GLM/MiniMax to Claude with extended thinking mid-session. --- packages/opencode/src/provider/transform.ts | 60 ++++- .../test/provider/thinking-blocks.test.ts | 222 ++++++++++++++++++ .../opencode/test/provider/transform.test.ts | 2 +- 3 files changed, 274 insertions(+), 10 deletions(-) create mode 100644 packages/opencode/test/provider/thinking-blocks.test.ts diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 4566fc1de2bf..450bfa039b7e 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -71,20 +71,62 @@ export namespace ProviderTransform { } if (model.api.id.includes("claude")) { - return msgs.map((msg) => { + const thinkingEnabled = (options as any)?.thinking?.type === "enabled" + const convertedThinkingMsgIndices = new Set() + + msgs = msgs.map((msg, msgIdx) => { if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) { - msg.content = msg.content.map((part) => { - if ((part.type === "tool-call" || part.type === "tool-result") && "toolCallId" in part) { - return { - ...part, - toolCallId: part.toolCallId.replace(/[^a-zA-Z0-9_-]/g, "_"), + let hadThinkingBlock = false + msg.content = msg.content + .map((part: any) => { + if (part.type === "thinking" && part.signature) { + const text = part.thinking || part.text || "" + hadThinkingBlock = true + if (!text) return null + return { + type: "text" as const, + text: `${text}`, + } } - } - return part - }) + if (part.type === "reasoning") { + const text = part.text || "" + if (!text) return null + return { + type: "text" as const, + text: `${text}`, + } + } + if ((part.type === "tool-call" || part.type === "tool-result") && "toolCallId" in part) { + return { + ...part, + toolCallId: part.toolCallId.replace(/[^a-zA-Z0-9_-]/g, "_"), + } + } + return part + }) + .filter((part: any) => part !== null) + if (hadThinkingBlock) convertedThinkingMsgIndices.add(msgIdx) } return msg }) + + if (thinkingEnabled) { + const lastAssistantIdx = msgs.findLastIndex((m) => m.role === "assistant") + if (lastAssistantIdx >= 0) { + const lastAssistant = msgs[lastAssistantIdx] + const hadConvertedThinking = convertedThinkingMsgIndices.has(lastAssistantIdx) + if (Array.isArray(lastAssistant.content) && lastAssistant.content.length > 0 && !hadConvertedThinking) { + const firstPart = lastAssistant.content[0] as any + const startsWithThinking = firstPart.type === "thinking" || firstPart.type === "redacted_thinking" + const hasToolCall = lastAssistant.content.some((p: any) => p.type === "tool-call") + if (!startsWithThinking && !hasToolCall) { + msgs = msgs.filter((_, i) => i !== lastAssistantIdx) + } + } + } + } + + return msgs } if (model.providerID === "mistral" || model.api.id.toLowerCase().includes("mistral")) { const result: ModelMessage[] = [] diff --git a/packages/opencode/test/provider/thinking-blocks.test.ts b/packages/opencode/test/provider/thinking-blocks.test.ts new file mode 100644 index 000000000000..1df754aea1e3 --- /dev/null +++ b/packages/opencode/test/provider/thinking-blocks.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test } from "bun:test" +import { ProviderTransform } from "../../src/provider/transform" + +const claudeModel = { + id: "anthropic/claude-sonnet-4", + providerID: "anthropic", + api: { + id: "claude-sonnet-4-20250514", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + name: "Claude Sonnet 4", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.003, output: 0.015, cache: { read: 0.0003, write: 0.00375 } }, + limit: { context: 200000, output: 8192 }, + status: "active", + options: {}, + headers: {}, +} as any + +describe("Thinking Block Structure", () => { + test("Claude thinking blocks have signature field", () => { + const claudeThinkingBlock = { + type: "thinking", + thinking: "Let me analyze this...", + signature: "ErUBCkYIAxgCIkDK8Y0dcPmz8BQ4K7W9vN...", + } + + expect(claudeThinkingBlock.type).toBe("thinking") + expect(claudeThinkingBlock.signature).toBeDefined() + }) + + test("GLM thinking blocks have different signature format", () => { + const glmThinkingBlock = { + type: "thinking", + thinking: "让我思考一下这个问题...", + signature: "glm_sig_abc123...", + } + + expect(glmThinkingBlock.signature).not.toMatch(/^ErUB/) + }) + + test("GLM reasoning blocks have no signature", () => { + const glmReasoningBlock = { + type: "reasoning", + text: "Let me think about this step by step...", + } + + expect(glmReasoningBlock.type).toBe("reasoning") + expect((glmReasoningBlock as any).signature).toBeUndefined() + }) +}) + +describe("Model Switch - Thinking Blocks", () => { + test("thinking blocks with signatures are converted to wrapped text", () => { + const messages = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "User is greeting me, I should respond warmly.", + signature: "glm_invalid_signature_12345", + }, + { type: "text", text: "Hello! How can I help you today?" }, + ], + }, + { role: "user", content: "What is 2+2?" }, + ] as any[] + + const claudeOptions = { thinking: { type: "enabled", budgetTokens: 16000 } } + const transformed = ProviderTransform.message(messages, claudeModel, claudeOptions) + + const assistantMsg = transformed.find((m) => m.role === "assistant") + expect(assistantMsg).toBeDefined() + + const thinkingPart = (assistantMsg?.content as any[])?.find((p: any) => p.type === "thinking") + expect(thinkingPart).toBeUndefined() + + const convertedText = (assistantMsg?.content as any[])?.find( + (p: any) => p.type === "text" && p.text.includes(""), + ) + expect(convertedText).toBeDefined() + expect(convertedText.text).toContain("User is greeting me") + }) + + test("last assistant without thinking is removed when thinking enabled (no tool calls)", () => { + const messages = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [{ type: "text", text: "Hello! How can I help you today?" }], + }, + { role: "user", content: "What is 2+2?" }, + ] as any[] + + const claudeOptions = { thinking: { type: "enabled", budgetTokens: 16000 } } + const transformed = ProviderTransform.message(messages, claudeModel, claudeOptions) + + const assistantMsgs = transformed.filter((m) => m.role === "assistant") + expect(assistantMsgs.length).toBe(0) + }) + + test("reasoning blocks are converted to wrapped text for Claude", () => { + const messages = [ + { role: "user", content: "Solve this: 2+2" }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "Let me calculate: 2 + 2 = 4" }, + { type: "text", text: "The answer is 4." }, + ], + }, + ] as any[] + + const transformed = ProviderTransform.message(messages, claudeModel, {}) + + const assistantMsg = transformed.find((m) => m.role === "assistant") + expect(assistantMsg).toBeDefined() + + const reasoningPart = (assistantMsg?.content as any[])?.find((p: any) => p.type === "reasoning") + expect(reasoningPart).toBeUndefined() + + const convertedText = (assistantMsg?.content as any[])?.find( + (p: any) => p.type === "text" && p.text.includes(""), + ) + expect(convertedText).toBeDefined() + expect(convertedText.text).toContain("Let me calculate") + }) +}) + +describe("Tool Pairing Preservation", () => { + test("thinking blocks converted but tool pairing preserved", () => { + const messages = [ + { role: "user", content: "Read the file test.txt" }, + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "I need to read this file...", + signature: "glm_invalid_sig", + }, + { + type: "tool-call", + toolCallId: "tool_123", + toolName: "read", + args: { path: "test.txt" }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "tool_123", + result: "File contents here", + }, + ], + }, + { role: "user", content: "Thanks! Now what?" }, + ] as any[] + + const claudeOptions = { thinking: { type: "enabled", budgetTokens: 16000 } } + const transformed = ProviderTransform.message(messages, claudeModel, claudeOptions) + + const assistantWithTool = transformed.find( + (m) => m.role === "assistant" && Array.isArray(m.content) && m.content.some((p: any) => p.type === "tool-call"), + ) + const toolResult = transformed.find( + (m) => m.role === "tool" && Array.isArray(m.content) && m.content.some((p: any) => p.type === "tool-result"), + ) + + expect(assistantWithTool).toBeDefined() + expect(toolResult).toBeDefined() + + const toolCall = (assistantWithTool!.content as any[]).find((p: any) => p.type === "tool-call") + const toolResultPart = (toolResult!.content as any[]).find((p: any) => p.type === "tool-result") + expect(toolCall.toolCallId).toBe(toolResultPart.toolCallId) + + const thinkingPart = (assistantWithTool!.content as any[]).find((p: any) => p.type === "thinking") + expect(thinkingPart).toBeUndefined() + + const convertedThinking = (assistantWithTool!.content as any[]).find( + (p: any) => p.type === "text" && p.text.includes(""), + ) + expect(convertedThinking).toBeDefined() + }) + + test("assistant with tool calls is NOT removed even without thinking", () => { + const messages = [ + { role: "user", content: "Read test.txt" }, + { + role: "assistant", + content: [ + { type: "text", text: "Reading the file..." }, + { type: "tool-call", toolCallId: "tool_456", toolName: "read", args: { path: "test.txt" } }, + ], + }, + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "tool_456", result: "File contents" }], + }, + ] as any[] + + const claudeOptions = { thinking: { type: "enabled", budgetTokens: 16000 } } + const transformed = ProviderTransform.message(messages, claudeModel, claudeOptions) + + const assistantMsg = transformed.find((m) => m.role === "assistant") + expect(assistantMsg).toBeDefined() + }) +}) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 33047b5bcb47..5043a36ee26b 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -592,7 +592,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result).toHaveLength(1) expect(result[0].content).toHaveLength(2) - expect(result[0].content[0]).toEqual({ type: "reasoning", text: "Thinking..." }) + expect(result[0].content[0]).toEqual({ type: "text", text: "Thinking..." }) expect(result[0].content[1]).toEqual({ type: "text", text: "Result" }) })