mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-11 13:21:03 +00:00
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>
This commit is contained in:
parent
2dffdfff4a
commit
233fc5b910
2 changed files with 122 additions and 2 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue