diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 210c574d4f..3f52f6a2aa 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -135,9 +135,16 @@ function normalizeMessages( } if (!Array.isArray(msg.content)) return msg const filtered = msg.content.filter((part) => { - if (part.type === "text" || part.type === "reasoning") { + if (part.type === "text") { return part.text !== "" } + if (part.type === "reasoning") { + return ( + part.text.trim().length > 0 || + part.providerOptions?.anthropic?.signature != null || + part.providerOptions?.anthropic?.redactedData != null + ) + } return true }) if (filtered.length === 0) return undefined @@ -156,9 +163,16 @@ function normalizeMessages( } if (!Array.isArray(msg.content)) return msg const filtered = msg.content.filter((part) => { - if (part.type === "text" || part.type === "reasoning") { + if (part.type === "text") { return part.text !== "" } + if (part.type === "reasoning") { + return ( + part.text.trim().length > 0 || + part.providerOptions?.bedrock?.signature != null || + part.providerOptions?.bedrock?.redactedData != null + ) + } return true }) if (filtered.length === 0) return undefined diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index ed09262d0e..2930dbaeb3 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -35,7 +35,7 @@ interface FetchDecompressionError extends Error { path: string } -export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:" +export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:" export { isMedia } export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}) @@ -734,25 +734,25 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( const result: UIMessage[] = [] const toolNames = new Set() // Track media from tool results that need to be injected as user messages - // for providers that don't support media in tool results. + // for providers that don't support that media type in tool results. // // OpenAI-compatible APIs only support string content in tool results, so we need - // to extract media and inject as user messages. Other SDKs (anthropic, google, - // bedrock) handle type: "content" with media parts natively. + // to extract media and inject as user messages. Some SDKs only support a subset + // of media in tool results; e.g. Bedrock supports images but not PDFs there. // - // Only apply this workaround if the model actually supports image input - - // otherwise there's no point extracting images. - const supportsMediaInToolResults = (() => { + // Only apply this workaround if the model actually supports that media input - + // otherwise unsupportedParts() will turn it into a user-visible error. + const supportsMediaInToolResult = (attachment: { mime: string }) => { if (model.api.npm === "@ai-sdk/anthropic") return true if (model.api.npm === "@ai-sdk/openai") return true - if (model.api.npm === "@ai-sdk/amazon-bedrock") return true + if (model.api.npm === "@ai-sdk/amazon-bedrock") return attachment.mime.startsWith("image/") if (model.api.npm === "@ai-sdk/google-vertex/anthropic") return true if (model.api.npm === "@ai-sdk/google") { const id = model.api.id.toLowerCase() return id.includes("gemini-3") && !id.includes("gemini-2") } return false - })() + } const toModelOutput = (options: { toolCallId: string; input: unknown; output: unknown }) => { const output = options.output @@ -797,9 +797,9 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( role: "user", parts: [], } - result.push(userMessage) for (const part of msg.parts) { - if (part.type === "text" && !part.ignored) + // User message parts should never be empty + if (part.type === "text" && !part.ignored && part.text !== "") userMessage.parts.push({ type: "text", text: part.text, @@ -834,11 +834,12 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( }) } } + if (userMessage.parts.length > 0) result.push(userMessage) } if (msg.info.role === "assistant") { const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` - const media: Array<{ mime: string; url: string }> = [] + const media: Array<{ mime: string; url: string; filename?: string }> = [] if ( msg.info.error && @@ -864,11 +865,10 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( // 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. + // the neighboring signed reasoning blocks. const hasSignedReasoning = msg.parts.some((part) => { if (part.type !== "reasoning") return false - return part.metadata?.anthropic?.signature != null || part.metadata?.bedrock?.signature != null + return part.metadata?.anthropic?.signature != null }) for (const part of msg.parts) { if (part.type === "text") { @@ -894,11 +894,11 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( // For providers that don't support media in tool results, extract media files // (images, PDFs) to be sent as a separate user message const mediaAttachments = attachments.filter((a) => isMedia(a.mime)) - const nonMediaAttachments = attachments.filter((a) => !isMedia(a.mime)) - if (!supportsMediaInToolResults && mediaAttachments.length > 0) { - media.push(...mediaAttachments) + const extractedMedia = mediaAttachments.filter((a) => !supportsMediaInToolResult(a)) + if (extractedMedia.length > 0) { + media.push(...extractedMedia) } - const finalAttachments = supportsMediaInToolResults ? attachments : nonMediaAttachments + const finalAttachments = attachments.filter((a) => !isMedia(a.mime) || supportsMediaInToolResult(a)) const output = finalAttachments.length > 0 @@ -988,6 +988,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( type: "file" as const, url: attachment.url, mediaType: attachment.mime, + filename: attachment.filename, })), ], }) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 999b61b48e..d9c71f8c07 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -155,6 +155,54 @@ describe("session.message-v2.toModelMessage", () => { expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([]) }) + test("filters out user messages with only empty text parts", async () => { + const messageID = "m-user" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(messageID), + parts: [ + { + ...basePart(messageID, "p1"), + type: "text", + text: "", + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([]) + }) + + test("filters empty user text parts while keeping non-empty parts", async () => { + const messageID = "m-user" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(messageID), + parts: [ + { + ...basePart(messageID, "p1"), + type: "text", + text: "", + }, + { + ...basePart(messageID, "p2"), + type: "text", + text: "hello", + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "hello" }], + }, + ]) + }) + test("includes synthetic text parts", async () => { const messageID = "m-user" @@ -443,6 +491,108 @@ describe("session.message-v2.toModelMessage", () => { }) }) + test("moves bedrock pdf tool-result media into a separate user message", async () => { + const bedrockModel: Provider.Model = { + ...model, + id: ModelID.make("amazon-bedrock/anthropic.claude-sonnet-4-6"), + providerID: ProviderID.make("amazon-bedrock"), + api: { + id: "anthropic.claude-sonnet-4-6", + url: "https://bedrock-runtime.us-east-1.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + capabilities: { + ...model.capabilities, + attachment: true, + input: { + ...model.capabilities.input, + image: true, + pdf: true, + }, + }, + } + const pdf = Buffer.from("%PDF-1.4\n").toString("base64") + const userID = "m-user-bedrock-pdf" + const assistantID = "m-assistant-bedrock-pdf" + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1-bedrock-pdf"), + type: "text", + text: "run tool", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1-bedrock-pdf"), + type: "tool", + callID: "call-bedrock-pdf-1", + tool: "read", + state: { + status: "completed", + input: { filePath: "/tmp/example.pdf" }, + output: "PDF read successfully", + title: "Read", + metadata: {}, + time: { start: 0, end: 1 }, + attachments: [ + { + ...basePart(assistantID, "file-bedrock-pdf-1"), + type: "file", + mime: "application/pdf", + filename: "example.pdf", + url: `data:application/pdf;base64,${pdf}`, + }, + ], + }, + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, bedrockModel)).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "run tool" }], + }, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-bedrock-pdf-1", + toolName: "read", + input: { filePath: "/tmp/example.pdf" }, + providerExecuted: undefined, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-bedrock-pdf-1", + toolName: "read", + output: { type: "text", value: "PDF read successfully" }, + }, + ], + }, + { + role: "user", + content: [ + { type: "text", text: "Attached media from tool result:" }, + { type: "file", mediaType: "application/pdf", filename: "example.pdf", data: `data:application/pdf;base64,${pdf}` }, + ], + }, + ]) + }) + test("omits provider metadata when assistant model differs", async () => { const userID = "m-user" const assistantID = "m-assistant" @@ -1134,8 +1284,9 @@ describe("session.message-v2.toModelMessage", () => { 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 + 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. const assistantID = "m-assistant-bedrock" const input: MessageV2.WithParts[] = [ { @@ -1157,7 +1308,7 @@ describe("session.message-v2.toModelMessage", () => { expect(result).toHaveLength(1) const texts = (result[0].content as any[]).filter((p) => p.type === "text") - expect(texts.map((t) => t.text)).toStrictEqual([" ", "answer"]) + expect(texts.map((t) => t.text)).toStrictEqual(["", "answer"]) }) test("leaves empty text alone when reasoning has no Anthropic signature", async () => {