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/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 067d43da2e25..58490ec8740f 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 @@ -449,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 ed09262d0efe..8a36a414ea65 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,9 +1013,9 @@ 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))) + 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..78d3b374162f 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"), @@ -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, diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 999b61b48e56..3fd0fc4e3b36 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("converts reasoning to text 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: "text", text: "reasoning trace" }, + ], + }, + ]) + }) + test("replaces compacted tool output with placeholder", async () => { const userID = "m-user" const assistantID = "m-assistant"