From 3d85c39da9412d91608c825db6ca32cf7af1a082 Mon Sep 17 00:00:00 2001 From: chan1103 Date: Thu, 16 Apr 2026 14:23:51 +0900 Subject: [PATCH] fix: replace empty text in reasoning messages to preserve thinking block positions normalizeMessages removes empty text parts, which shifts thinking block positions and invalidates signatures. Simple preservation does not work because the AI SDK has a second filter and the API rejects empty text. In assistant messages with signed reasoning, replace empty text with a placeholder instead of removing it. This preserves array positions through all filtering layers. --- packages/opencode/src/provider/transform.ts | 28 ++++-- .../opencode/test/provider/transform.test.ts | 97 +++++++++++++++++++ 2 files changed, 118 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index c940b31c8c9a..8afd5afe2d49 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -60,14 +60,28 @@ function normalizeMessages( return msg } if (!Array.isArray(msg.content)) return msg - const filtered = msg.content.filter((part) => { - if (part.type === "text" || part.type === "reasoning") { - return part.text !== "" + const hasReasoning = msg.role === "assistant" && msg.content.some((p) => p.type === "reasoning" && p.providerOptions !== undefined) + const filtered = msg.content + .filter((part) => { + if (part.type === "reasoning") { + return part.text !== "" || part.providerOptions !== undefined + } + if (part.type === "text" && !hasReasoning) { + return part.text !== "" + } + return true + }) + .map((part) => { + if (hasReasoning && part.type === "text" && part.text === "") { + return { ...part, text: "..." } as typeof part + } + return part + }) + if (filtered.length === 0) return undefined + if (hasReasoning && filtered.length > 0 && filtered[filtered.length - 1].type === "reasoning") { + filtered.push({ type: "text", text: "..." } as (typeof filtered)[number]) } - return true - }) - if (filtered.length === 0) return undefined - return { ...msg, content: filtered } + return { ...msg, content: filtered } }) .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "") } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 0666d0f641d1..f19a7dbeae75 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1212,6 +1212,103 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[0].content[1]).toEqual({ type: "text", text: "Result" }) }) + test("replaces empty text with placeholder in assistant messages with reasoning", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "thinking...", providerOptions: { anthropic: { signature: "sig_abc" } } }, + { type: "text", text: "" }, + { type: "reasoning", text: "more thinking", providerOptions: { anthropic: { signature: "sig_xyz" } } }, + { type: "text", text: "Answer" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(4) + expect(result[0].content[0]).toEqual({ type: "reasoning", text: "thinking...", providerOptions: { anthropic: { signature: "sig_abc" } } }) + expect(result[0].content[1]).toEqual({ type: "text", text: "..." }) + expect(result[0].content[2]).toEqual({ type: "reasoning", text: "more thinking", providerOptions: { anthropic: { signature: "sig_xyz" } } }) + expect(result[0].content[3]).toEqual({ type: "text", text: "Answer" }) + }) + + test("replaces empty text and appends fallback when only reasoning remains", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "thinking...", providerOptions: { anthropic: { signature: "sig_abc" } } }, + { type: "text", text: "" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(2) + expect((result[0].content as any[])[0].type).toBe("reasoning") + expect(result[0].content[1]).toEqual({ type: "text", text: "..." }) + }) + + test("appends fallback text when assistant has only reasoning with signature", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "deep thought", providerOptions: { anthropic: { signature: "sig_xyz" } } }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(2) + expect((result[0].content as any[])[0].type).toBe("reasoning") + expect(result[0].content[1]).toEqual({ type: "text", text: "..." }) + }) + + test("does not replace text in assistant messages without reasoning", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "text", text: "" }, + { type: "text", text: "Hello" }, + { type: "text", text: "" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(1) + expect(result[0].content[0]).toEqual({ type: "text", text: "Hello" }) + }) + + test("does not replace empty text in user messages with reasoning", () => { + const msgs = [ + { + role: "user", + content: [ + { type: "reasoning", text: "user reasoning", providerOptions: { anthropic: { signature: "sig_abc" } } }, + { type: "text", text: "" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(1) + expect((result[0].content as any[])[0].type).toBe("reasoning") + }) + test("filters empty content for bedrock provider", () => { const bedrockModel = { ...anthropicModel,