Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 19 additions & 13 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,27 +259,27 @@ 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.
// Anthropic extended thinking signs reasoning blocks and rejects any
// modification to the assistant message that contains them — including
// mutations to adjacent text parts that shift content-block indices.
// An empty text part ("") can appear as a structural separator between
// reasoning groups (e.g. step-start, reasoning(sig), text(""),
// step-start, reasoning(sig)). Previously this was rewritten to a
// single space (" ") to survive AI-SDK filtering, but that mutation
// itself causes Anthropic to reject the signed reasoning blocks as
// "modified". Dropping the empty separator is safe because step-start
// parts already delimit reasoning groups, and the absence of the text
// part does not change the signed blocks themselves.
const hasSignedReasoning = msg.parts.some((part) => {
if (part.type !== "reasoning") return false
return part.metadata?.anthropic?.signature != null
})
for (const part of msg.parts) {
if (part.type === "text") {
const text = part.text === "" && hasSignedReasoning ? " " : part.text
if (part.text === "" && hasSignedReasoning) continue
assistantMessage.parts.push({
type: "text",
text,
text: part.text,
...(differentModel ? {} : { providerMetadata: part.metadata }),
})
}
Expand Down Expand Up @@ -361,6 +361,12 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
}
if (part.type === "reasoning") {
if (differentModel) {
// When replaying to a different model, signed reasoning blocks
// cannot be preserved verbatim (the signature is Anthropic-specific).
// Downgrade readable thinking to plain text and silently drop
// redacted thinking (which has no readable content). This is a
// one-way transformation for cross-provider replay only; same-model
// replay preserves the signed block below.
if (part.text.trim().length > 0)
assistantMessage.parts.push({
type: "text",
Expand Down
66 changes: 62 additions & 4 deletions packages/opencode/test/session/message-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1259,8 +1259,14 @@ 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)]
test("drops empty text between signed reasoning blocks to preserve signature validity", async () => {
// Anthropic signs reasoning blocks and rejects any modification to the
// assistant message that contains them. An empty text part ("") can
// appear as a structural separator between reasoning groups. Previously
// it was rewritten to " " to survive AI-SDK filtering, but that mutation
// itself causes Anthropic to reject the signed blocks as "modified".
// Dropping the empty separator is safe because step-start parts already
// delimit reasoning groups.
const assistantID = "m-assistant"
const input: SessionV1.WithParts[] = [
{
Expand Down Expand Up @@ -1288,12 +1294,64 @@ describe("session.message-v2.toModelMessage", () => {

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(" ")
// First reasoning group: no text part at all (empty separator dropped)
expect((result[0].content as any[]).find((p) => p.type === "text")).toBeUndefined()
const reasoning0 = (result[0].content as any[]).find((p) => p.type === "reasoning")
expect(reasoning0).toBeDefined()
expect(reasoning0.providerOptions?.anthropic?.signature).toBe("sig1")
// Second reasoning group: non-empty text preserved
const reasoning1 = (result[1].content as any[]).find((p) => p.type === "reasoning")
expect(reasoning1).toBeDefined()
expect(reasoning1.providerOptions?.anthropic?.signature).toBe("sig2")
expect((result[1].content as any[]).find((p) => p.type === "text").text).toBe("the answer")
})

test("preserves redacted thinking blocks with empty text during replay", async () => {
// Redacted thinking (text: "" with anthropic.redactedData) must survive
// toModelMessages without mutation. The empty text separator between
// redacted reasoning and a normal text part must be dropped (not mutated
// to " ") so the signed redactedData block is not invalidated.
const assistantID = "m-assistant-redacted"
const input: SessionV1.WithParts[] = [
{
info: assistantInfo(assistantID, "m-parent"),
parts: [
{ ...basePart(assistantID, "p1"), type: "step-start" },
{
...basePart(assistantID, "p2"),
type: "reasoning",
text: "",
metadata: { anthropic: { redactedData: "encrypted-blob" } },
},
{ ...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: "response" },
] as SessionV1.Part[],
},
]

const result = await MessageV2.toModelMessages(input, model)

expect(result).toHaveLength(2)
// First group: redacted reasoning present, no text separator
const reasoning0 = (result[0].content as any[]).find((p) => p.type === "reasoning")
expect(reasoning0).toBeDefined()
expect(reasoning0.providerOptions?.anthropic?.redactedData).toBe("encrypted-blob")
expect((result[0].content as any[]).find((p) => p.type === "text")).toBeUndefined()
// Second group: signed reasoning + normal text
const reasoning1 = (result[1].content as any[]).find((p) => p.type === "reasoning")
expect(reasoning1).toBeDefined()
expect(reasoning1.providerOptions?.anthropic?.signature).toBe("sig2")
expect((result[1].content as any[]).find((p) => p.type === "text").text).toBe("response")
})

test("leaves empty text alone when reasoning signature is under 'bedrock' namespace", async () => {
// Bedrock signed reasoning is preserved as reasoning metadata, but unlike the
// direct Anthropic path we do not preserve empty text separators for Bedrock.
Expand Down
Loading