From 495cecdfeb68c62b9f0c1a0fa1e50d46e0acaebe Mon Sep 17 00:00:00 2001 From: Jacopo Binosi Date: Fri, 17 Apr 2026 19:26:08 +0200 Subject: [PATCH 1/5] fix(opencode): preserve reasoning providerMetadata across model switches --- packages/opencode/src/provider/transform.ts | 26 +++++++---- .../opencode/test/session/message-v2.test.ts | 43 +++++++++++++++++++ 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index cd29e40822da..5a2abc4ef881 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -156,8 +156,11 @@ function normalizeMessages( } if (!Array.isArray(msg.content)) return msg const filtered = msg.content.filter((part) => { - if (part.type === "text" || part.type === "reasoning") { - return part.text !== "" + if (part.type === "text") return part.text !== "" + if (part.type === "reasoning") { + // Keep reasoning parts that carry providerOptions (e.g. redacted_thinking + // blocks which have empty text but contain redactedData that must be preserved) + return part.text !== "" || part.providerOptions != null } return true }) @@ -213,6 +216,7 @@ function normalizeMessages( const parts = msg.content const first = parts.findIndex((part) => part.type === "tool-call") if (first === -1) return [msg] + if (parts.some((part) => part.type === "reasoning")) return [msg] if (!parts.slice(first).some((part) => part.type !== "tool-call")) return [msg] return [ { ...msg, content: parts.filter((part) => part.type !== "tool-call") }, @@ -358,13 +362,17 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0 if (shouldUseContentOptions) { - const lastContent = msg.content[msg.content.length - 1] - if ( - lastContent && - typeof lastContent === "object" && - lastContent.type !== "tool-approval-request" && - lastContent.type !== "tool-approval-response" - ) { + const lastContent = [...msg.content] + .reverse() + .find( + (part) => + part && + typeof part === "object" && + part.type !== "tool-approval-request" && + part.type !== "tool-approval-response" && + part.type !== "reasoning", + ) as any + if (lastContent) { lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions) continue } diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 999b61b48e56..444107782789 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -526,6 +526,49 @@ describe("session.message-v2.toModelMessage", () => { ]) }) + test("preserves reasoning providerMetadata even when assistant model differs", async () => { + const userID = "m-user" + const assistantID = "m-assistant" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "think about this", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID, undefined, { providerID: "other", modelID: "other" }), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "reasoning", + text: "reasoning trace", + time: { start: 0 }, + metadata: { anthropic: { signature: "sig-abc" } }, + }, + ] as MessageV2.Part[], + } as MessageV2.WithParts, + ] + + expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "think about this" }], + }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "reasoning trace", providerOptions: { anthropic: { signature: "sig-abc" } } }, + ], + }, + ]) + }) + test("replaces compacted tool output with placeholder", async () => { const userID = "m-user" const assistantID = "m-assistant" From 8b408cfb8c0274bd31c03b69386d5f9cd09ced5c Mon Sep 17 00:00:00 2001 From: bainos Date: Thu, 30 Apr 2026 16:55:32 +0200 Subject: [PATCH 2/5] fix(session): strip reasoning parts from compaction message history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bedrock rejects compaction requests when thinking/redacted_thinking blocks are present with missing or corrupted signatures. The compaction model only needs conversation content to produce a summary — reasoning blocks are not required and cause API errors at scale. Add stripReasoning option to toModelMessagesEffect and enable it in the compaction path. --- packages/opencode/src/session/compaction.ts | 1 + packages/opencode/src/session/message-v2.ts | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 067d43da2e25..a124229855dd 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -407,6 +407,7 @@ export const layer: Layer.Layer< yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true, + stripReasoning: true, toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, }) const ctx = yield* InstanceState.context diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index ed09262d0efe..335e51c36f3d 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -729,7 +729,7 @@ function providerMeta(metadata: Record | undefined) { export const toModelMessagesEffect = Effect.fnUntraced(function* ( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean; toolOutputMaxChars?: number }, + options?: { stripMedia?: boolean; stripReasoning?: boolean; toolOutputMaxChars?: number }, ) { const result: UIMessage[] = [] const toolNames = new Set() @@ -956,6 +956,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( }) } if (part.type === "reasoning") { + if (options?.stripReasoning) continue if (differentModel) { if (part.text.trim().length > 0) assistantMessage.parts.push({ @@ -1012,7 +1013,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( export function toModelMessages( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean; toolOutputMaxChars?: number }, + options?: { stripMedia?: boolean; stripReasoning?: boolean; toolOutputMaxChars?: number }, ): Promise { return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer))) } From 009eeecd9746d3d45487149fae34575121ce18b9 Mon Sep 17 00:00:00 2001 From: bainos Date: Thu, 30 Apr 2026 18:48:19 +0200 Subject: [PATCH 3/5] fix(session): resolve typecheck errors from stripReasoning option --- packages/opencode/src/session/compaction.ts | 2 +- packages/opencode/src/session/message-v2.ts | 2 +- packages/opencode/src/session/prompt.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index a124229855dd..58490ec8740f 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -450,7 +450,7 @@ export const layer: Layer.Layer< tools: {}, system: [], messages: [ - ...modelMessages, + ...(modelMessages ?? []), { role: "user", content: [{ type: "text", text: nextPrompt }], diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 335e51c36f3d..8a36a414ea65 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1015,7 +1015,7 @@ export function toModelMessages( model: Provider.Model, options?: { stripMedia?: boolean; stripReasoning?: boolean; toolOutputMaxChars?: number }, ): Promise { - return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer))) + return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer), Effect.map((msgs) => msgs ?? []))) } export function page(input: { sessionID: SessionID; limit: number; before?: string }) { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fef8c438366c..470a04189376 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -208,7 +208,7 @@ export const layer = Layer.effect( model: mdl, sessionID: input.session.id, retries: 2, - messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs], + messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...(msgs ?? [])], }) .pipe( Stream.filter((e): e is Extract => e.type === "text-delta"), From adb9b5ca5890359ba8cf5c01ed1e4c5086281ab8 Mon Sep 17 00:00:00 2001 From: bainos Date: Thu, 30 Apr 2026 18:49:00 +0200 Subject: [PATCH 4/5] fix(session): guard spread of possibly-undefined modelMsgs in prompt --- packages/opencode/src/session/prompt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 470a04189376..78d3b374162f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1581,7 +1581,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the sessionID, parentSessionID: session.parentID, system, - messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])], + messages: [...(modelMsgs ?? []), ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])], tools, model, toolChoice: format.type === "json_schema" ? "required" : undefined, From bc2e4cf148c5e61eb165879281552c14c61c7130 Mon Sep 17 00:00:00 2001 From: bainos Date: Fri, 8 May 2026 07:49:25 +0200 Subject: [PATCH 5/5] test(session): update reasoning test to reflect upstream differentModel behavior --- packages/opencode/test/session/message-v2.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 444107782789..3fd0fc4e3b36 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -526,7 +526,7 @@ describe("session.message-v2.toModelMessage", () => { ]) }) - test("preserves reasoning providerMetadata even when assistant model differs", async () => { + test("converts reasoning to text when assistant model differs", async () => { const userID = "m-user" const assistantID = "m-assistant" @@ -563,7 +563,7 @@ describe("session.message-v2.toModelMessage", () => { { role: "assistant", content: [ - { type: "reasoning", text: "reasoning trace", providerOptions: { anthropic: { signature: "sig-abc" } } }, + { type: "text", text: "reasoning trace" }, ], }, ])