Преглед изворни кода

fix(provider): preserve assistant message content when reasoning blocks present (#21370)

Co-authored-by: Omer Koren <54630488+omer-koren@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
André Cruz пре 4 месеци
родитељ
комит
233fc5b910

+ 20 - 2
packages/opencode/src/session/message-v2.ts

@@ -854,13 +854,31 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
         role: "assistant",
         parts: [],
       }
+      // Anthropic adaptive thinking can persist assistant turns like:
+      // step-start, reasoning(signature), text(""), step-start,
+      // reasoning(signature). The empty text part is a structural separator,
+      // but it does not carry the signature metadata itself. Dropping it shifts
+      // signed thinking positions after step-start splitting/provider regrouping;
+      // keeping it as "" is filtered by the AI SDK and rejected by Anthropic.
+      // It is unclear whether this shape originates in our stream processing,
+      // a proxy, or a lower-level library, but preserving a non-empty separator
+      // here is the only safe replay point we have.
+      // Use a single space so the separator survives replay without changing
+      // the neighboring signed reasoning blocks. Bedrock-hosted Claude stores
+      // the same signature under the bedrock metadata namespace.
+      const hasSignedReasoning = msg.parts.some((part) => {
+        if (part.type !== "reasoning") return false
+        return part.metadata?.anthropic?.signature != null || part.metadata?.bedrock?.signature != null
+      })
       for (const part of msg.parts) {
-        if (part.type === "text")
+        if (part.type === "text") {
+          const text = part.text === "" && hasSignedReasoning ? " " : part.text
           assistantMessage.parts.push({
             type: "text",
-            text: part.text,
+            text,
             ...(differentModel ? {} : { providerMetadata: part.metadata }),
           })
+        }
         if (part.type === "step-start")
           assistantMessage.parts.push({
             type: "step-start",

+ 102 - 0
packages/opencode/test/session/message-v2.test.ts

@@ -1098,6 +1098,108 @@ describe("session.message-v2.toModelMessage", () => {
       },
     ])
   })
+
+  test("substitutes space for empty text between signed reasoning blocks", async () => {
+    // Reproduces the bug pattern: [reasoning(sig), text(""), reasoning(sig), text(full)]
+    const assistantID = "m-assistant"
+    const input: MessageV2.WithParts[] = [
+      {
+        info: assistantInfo(assistantID, "m-parent"),
+        parts: [
+          { ...basePart(assistantID, "p1"), type: "step-start" },
+          {
+            ...basePart(assistantID, "p2"),
+            type: "reasoning",
+            text: "thinking-one",
+            metadata: { anthropic: { signature: "sig1" } },
+          },
+          { ...basePart(assistantID, "p3"), type: "text", text: "" },
+          { ...basePart(assistantID, "p4"), type: "step-start" },
+          {
+            ...basePart(assistantID, "p5"),
+            type: "reasoning",
+            text: "thinking-two",
+            metadata: { anthropic: { signature: "sig2" } },
+          },
+          { ...basePart(assistantID, "p6"), type: "text", text: "the answer" },
+        ] as MessageV2.Part[],
+      },
+    ]
+
+    const result = await MessageV2.toModelMessages(input, model)
+
+    // step-start splits into two assistant messages; SDK's groupIntoBlocks merges them later
+    expect(result).toHaveLength(2)
+    expect((result[0].content as any[]).find((p) => p.type === "text").text).toBe(" ")
+    expect((result[1].content as any[]).find((p) => p.type === "text").text).toBe("the answer")
+  })
+
+  test("substitutes space for empty text when reasoning signature is under 'bedrock' namespace", async () => {
+    // AWS Bedrock hosts Anthropic Claude but stores signatures under metadata.bedrock
+    const assistantID = "m-assistant-bedrock"
+    const input: MessageV2.WithParts[] = [
+      {
+        info: assistantInfo(assistantID, "m-parent"),
+        parts: [
+          {
+            ...basePart(assistantID, "p1"),
+            type: "reasoning",
+            text: "thinking-bedrock",
+            metadata: { bedrock: { signature: "bedrock-sig" } },
+          },
+          { ...basePart(assistantID, "p2"), type: "text", text: "" },
+          { ...basePart(assistantID, "p3"), type: "text", text: "answer" },
+        ] as MessageV2.Part[],
+      },
+    ]
+
+    const result = await MessageV2.toModelMessages(input, model)
+
+    expect(result).toHaveLength(1)
+    const texts = (result[0].content as any[]).filter((p) => p.type === "text")
+    expect(texts.map((t) => t.text)).toStrictEqual([" ", "answer"])
+  })
+
+  test("leaves empty text alone when reasoning has no Anthropic signature", async () => {
+    // Non-Anthropic providers' reasoning doesn't position-validate, so empty text
+    // should be filtered normally rather than substituted.
+    const assistantID = "m-assistant-unsigned"
+    const input: MessageV2.WithParts[] = [
+      {
+        info: assistantInfo(assistantID, "m-parent"),
+        parts: [
+          { ...basePart(assistantID, "p1"), type: "reasoning", text: "thinking" },
+          { ...basePart(assistantID, "p2"), type: "text", text: "" },
+          { ...basePart(assistantID, "p3"), type: "text", text: "answer" },
+        ] as MessageV2.Part[],
+      },
+    ]
+
+    const result = await MessageV2.toModelMessages(input, model)
+
+    expect(result).toHaveLength(1)
+    const texts = (result[0].content as any[]).filter((p) => p.type === "text")
+    expect(texts.map((t) => t.text)).toStrictEqual(["", "answer"])
+  })
+
+  test("leaves empty text alone in assistant messages without reasoning", async () => {
+    const assistantID = "m-assistant-no-reasoning"
+    const input: MessageV2.WithParts[] = [
+      {
+        info: assistantInfo(assistantID, "m-parent"),
+        parts: [
+          { ...basePart(assistantID, "p1"), type: "text", text: "" },
+          { ...basePart(assistantID, "p2"), type: "text", text: "hello" },
+        ] as MessageV2.Part[],
+      },
+    ]
+
+    const result = await MessageV2.toModelMessages(input, model)
+
+    expect(result).toHaveLength(1)
+    const texts = (result[0].content as any[]).filter((p) => p.type === "text")
+    expect(texts.map((t) => t.text)).toStrictEqual(["", "hello"])
+  })
 })
 
 describe("session.message-v2.fromError", () => {