Skip to content

Commit ebcaa01

Browse files
committed
fix: preserve thinking block signatures across three independent corruption paths
1 parent ce19c05 commit ebcaa01

6 files changed

Lines changed: 139 additions & 3 deletions

File tree

packages/opencode/src/provider/transform.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@ export namespace ProviderTransform {
6262
}
6363
if (!Array.isArray(msg.content)) return msg
6464
const filtered = msg.content.filter((part) => {
65-
if (part.type === "text" || part.type === "reasoning") {
65+
if (part.type === "reasoning") {
66+
return part.text !== "" || part.providerOptions !== undefined
67+
}
68+
if (part.type === "text") {
6669
return part.text !== ""
6770
}
6871
return true

packages/opencode/src/session/message-v2.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -792,7 +792,7 @@ export namespace MessageV2 {
792792
assistantMessage.parts.push({
793793
type: "reasoning",
794794
text: part.text,
795-
...(differentModel ? {} : { providerMetadata: part.metadata }),
795+
providerMetadata: part.metadata,
796796
})
797797
}
798798
}

packages/opencode/src/session/processor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ export namespace SessionProcessor {
245245

246246
case "reasoning-end":
247247
if (!(value.id in ctx.reasoningMap)) return
248-
ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text.trimEnd()
248+
ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text
249249
ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() }
250250
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
251251
yield* session.updatePart(ctx.reasoningMap[value.id])

packages/opencode/test/provider/transform.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,6 +1099,47 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
10991099
expect(result[0].content[0]).toEqual({ type: "text", text: "Answer" })
11001100
})
11011101

1102+
test("preserves empty reasoning parts with providerOptions (redacted_thinking)", () => {
1103+
const msgs = [
1104+
{
1105+
role: "assistant",
1106+
content: [
1107+
{ type: "reasoning", text: "visible thinking", providerOptions: { anthropic: { signature: "sig_abc" } } },
1108+
{ type: "reasoning", text: "", providerOptions: { anthropic: { signature: "sig_xyz", redactedData: "encrypted" } } },
1109+
{ type: "text", text: "Answer" },
1110+
],
1111+
},
1112+
] as any[]
1113+
1114+
const result = ProviderTransform.message(msgs, anthropicModel, {})
1115+
1116+
expect(result).toHaveLength(1)
1117+
expect(result[0].content).toHaveLength(3)
1118+
expect(result[0].content[1]).toEqual({
1119+
type: "reasoning",
1120+
text: "",
1121+
providerOptions: { anthropic: { signature: "sig_xyz", redactedData: "encrypted" } },
1122+
})
1123+
})
1124+
1125+
test("removes empty reasoning parts without providerOptions", () => {
1126+
const msgs = [
1127+
{
1128+
role: "assistant",
1129+
content: [
1130+
{ type: "reasoning", text: "" },
1131+
{ type: "text", text: "Answer" },
1132+
],
1133+
},
1134+
] as any[]
1135+
1136+
const result = ProviderTransform.message(msgs, anthropicModel, {})
1137+
1138+
expect(result).toHaveLength(1)
1139+
expect(result[0].content).toHaveLength(1)
1140+
expect(result[0].content[0]).toEqual({ type: "text", text: "Answer" })
1141+
})
1142+
11021143
test("removes entire message when all parts are empty", () => {
11031144
const msgs = [
11041145
{ role: "user", content: "Hello" },

packages/opencode/test/session/message-v2.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,6 +1018,54 @@ describe("session.message-v2.fromError", () => {
10181018
expect((result as MessageV2.APIError).data.message).toInclude("decompression")
10191019
})
10201020

1021+
test("preserves reasoning providerMetadata when model differs (thinking signatures)", async () => {
1022+
const userID = "m-user"
1023+
const assistantID = "m-assistant"
1024+
const reasoningMeta = { anthropic: { signature: "sig_test_abc123" } }
1025+
1026+
const input: MessageV2.WithParts[] = [
1027+
{
1028+
info: userInfo(userID),
1029+
parts: [
1030+
{
1031+
...basePart(userID, "p1"),
1032+
type: "text",
1033+
text: "hello",
1034+
},
1035+
] as MessageV2.Part[],
1036+
},
1037+
{
1038+
info: assistantInfo(assistantID, userID, undefined, {
1039+
providerID: "anthropic",
1040+
modelID: "claude-opus-4-6",
1041+
}),
1042+
parts: [
1043+
{
1044+
...basePart(assistantID, "p2"),
1045+
type: "reasoning",
1046+
text: "thinking about the answer",
1047+
time: { start: 0 },
1048+
metadata: reasoningMeta,
1049+
},
1050+
{
1051+
...basePart(assistantID, "p3"),
1052+
type: "text",
1053+
text: "the answer",
1054+
},
1055+
] as MessageV2.Part[],
1056+
},
1057+
]
1058+
1059+
const result = await MessageV2.toModelMessages(input, model)
1060+
1061+
expect(result).toHaveLength(2)
1062+
const assistantMsg = result[1]
1063+
expect(assistantMsg.role).toBe("assistant")
1064+
const reasoningPart = (assistantMsg.content as any[]).find((p: any) => p.type === "reasoning")
1065+
expect(reasoningPart).toBeDefined()
1066+
expect(reasoningPart.providerOptions).toEqual(reasoningMeta)
1067+
})
1068+
10211069
test("classifies ZlibError as AbortedError when abort context is provided", () => {
10221070
const zlibError = new Error(
10231071
'ZlibError fetching "https://opencode.cloudflare.dev/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()',

packages/opencode/test/session/processor-effect.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,50 @@ it.live("session.processor effect tests capture reasoning from http mock", () =>
399399
),
400400
)
401401

402+
it.live("session.processor preserves reasoning text with trailing whitespace", () =>
403+
provideTmpdirServer(
404+
({ dir, llm }) =>
405+
Effect.gen(function* () {
406+
const { processors, session, provider } = yield* boot()
407+
408+
yield* llm.push(reply().reason("thinking with spaces ").text("done").stop())
409+
410+
const chat = yield* session.create({})
411+
const parent = yield* user(chat.id, "test trailing whitespace")
412+
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
413+
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
414+
const handle = yield* processors.create({
415+
assistantMessage: msg,
416+
sessionID: chat.id,
417+
model: mdl,
418+
})
419+
420+
yield* handle.process({
421+
user: {
422+
id: parent.id,
423+
sessionID: chat.id,
424+
role: "user",
425+
time: parent.time,
426+
agent: parent.agent,
427+
model: { providerID: ref.providerID, modelID: ref.modelID },
428+
} satisfies MessageV2.User,
429+
sessionID: chat.id,
430+
model: mdl,
431+
agent: agent(),
432+
system: [],
433+
messages: [{ role: "user", content: "test trailing whitespace" }],
434+
tools: {},
435+
})
436+
437+
const parts = MessageV2.parts(msg.id)
438+
const reasoning = parts.find((part): part is MessageV2.ReasoningPart => part.type === "reasoning")
439+
440+
expect(reasoning?.text).toBe("thinking with spaces ")
441+
}),
442+
{ git: true, config: (url) => providerCfg(url) },
443+
),
444+
)
445+
402446
it.live("session.processor effect tests reset reasoning state across retries", () =>
403447
provideTmpdirServer(
404448
({ dir, llm }) =>

0 commit comments

Comments
 (0)