From 337fdd00a9591c1b75b52e2cd07a7e04604172c8 Mon Sep 17 00:00:00 2001 From: rekram1-node Date: Sat, 29 Aug 2026 15:07:12 +0000 Subject: [PATCH] fix(opencode): preserve V1 tool attachment filenames --- packages/opencode/src/session/message-v2.ts | 24 ++-- .../opencode/test/session/message-v2.test.ts | 120 +++++++++++++++++- 2 files changed, 131 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 9b3f2c46f405..04532c7618ed 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -29,7 +29,6 @@ import { lt } from "drizzle-orm" import { or } from "drizzle-orm" import { MessageTable, PartTable, SessionTable } from "@opencode-ai/core/session/sql" import { ProviderError } from "@/provider/error" -import { iife } from "@/util/iife" import { errorMessage } from "@/util/error" import { isMedia } from "@/util/media" import type { SystemError } from "bun" @@ -167,7 +166,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( if (typeof output === "object") { const outputObject = output as { text: string - attachments?: Array<{ mime: string; url: string }> + attachments?: Array<{ mime: string; url: string; filename?: string }> } const attachments = (outputObject.attachments ?? []).filter((attachment) => { return attachment.url.startsWith("data:") && attachment.url.includes(",") @@ -177,14 +176,19 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( type: "content", value: [ ...(outputObject.text ? [{ type: "text", text: outputObject.text }] : []), - ...attachments.map((attachment) => ({ - type: "media", - mediaType: attachment.mime, - data: iife(() => { - const commaIndex = attachment.url.indexOf(",") - return commaIndex === -1 ? attachment.url : attachment.url.slice(commaIndex + 1) - }), - })), + ...attachments.map((attachment) => { + const data = attachment.url.slice(attachment.url.indexOf(",") + 1) + if (attachment.mime.startsWith("image/")) { + return { type: "image-data", data, mediaType: attachment.mime } + } + return { + type: "file-data", + data, + mediaType: attachment.mime, + // Responses requires a file extension, including for old tool results without a filename. + filename: attachment.filename ?? (attachment.mime === "application/pdf" ? "attachment.pdf" : undefined), + } + }), ], } } diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 734a30e42454..fe33aa21df99 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { APICallError } from "ai" +import { APICallError, generateText } from "ai" +import { createBedrockMantle } from "@ai-sdk/amazon-bedrock/mantle" +import { createOpenAI } from "@ai-sdk/openai" import { MessageV2 } from "../../src/session/message-v2" import { ProviderTransform } from "@/provider/transform" import type { Provider } from "@/provider/provider" @@ -111,6 +113,118 @@ function basePart(messageID: string, id: string) { } } +describe.each([ + { + npm: "@ai-sdk/amazon-bedrock/mantle", + sdk: (fetch: typeof globalThis.fetch) => + createBedrockMantle({ region: "us-east-1", apiKey: "test-key", fetch }).responses("openai.gpt-5.5"), + }, + { + npm: "@ai-sdk/openai", + sdk: (fetch: typeof globalThis.fetch) => createOpenAI({ apiKey: "test-key", fetch }).responses("gpt-5.5"), + }, +])("$npm tool attachment serialization", ({ npm, sdk }) => { + test.each([ + { name: "plain text", mime: undefined, filename: undefined, replay: false }, + { name: "image", mime: "image/png", filename: "image.png", replay: false }, + { name: "named PDF", mime: "application/pdf", filename: "report.pdf", replay: false }, + { name: "unnamed PDF", mime: "application/pdf", filename: undefined, replay: false }, + { name: "PDF replayed before glob", mime: "application/pdf", filename: "report.pdf", replay: true }, + ])("preserves $name", async ({ mime, filename, replay }) => { + const selected = { ...model, api: { ...model.api, npm } } + const data = Buffer.from(mime === "image/png" ? "image fixture" : "%PDF-1.4\n").toString("base64") + const input: SessionV1.WithParts[] = [ + { + info: assistantInfo("msg_assistant", "msg_user"), + parts: [ + { + ...basePart("msg_assistant", "prt_read"), + type: "tool", + tool: "read", + callID: "call_read", + state: { + status: "completed", + input: { filePath: "/fixture/file" }, + output: "File read successfully", + title: "Read", + metadata: {}, + time: { start: 0, end: 1 }, + attachments: mime + ? [ + { + ...basePart("msg_assistant", "prt_file"), + type: "file", + mime, + filename, + url: `data:${mime};base64,${data}`, + }, + ] + : [], + }, + }, + ], + }, + ] + if (replay) + input.push({ + info: assistantInfo("msg_glob", "msg_user"), + parts: [ + { + ...basePart("msg_glob", "prt_glob"), + type: "tool", + tool: "glob", + callID: "call_glob", + state: { + status: "completed", + input: { pattern: "*.ts" }, + output: "/fixture/main.ts", + title: "Find Files", + metadata: {}, + time: { start: 2, end: 3 }, + }, + }, + ], + }) + const requests: string[] = [] + const capture = Object.assign( + async (...args: Parameters) => { + requests.push(String(args[1]?.body)) + throw new Error("request captured") + }, + { preconnect: () => undefined }, + ) + await expect( + generateText({ + model: sdk(capture), + messages: await MessageV2.toModelMessages(input, selected), + maxRetries: 0, + }), + ).rejects.toThrow("request captured") + expect(requests).toHaveLength(1) + expect(JSON.parse(requests[0])).toMatchObject({ + input: expect.arrayContaining([ + { + type: "function_call_output", + call_id: "call_read", + output: mime + ? [ + { type: "input_text", text: "File read successfully" }, + mime === "image/png" + ? { type: "input_image", image_url: `data:${mime};base64,${data}` } + : { + type: "input_file", + filename: filename ?? "attachment.pdf", + file_data: `data:${mime};base64,${data}`, + }, + ] + : "File read successfully", + }, + ...(replay ? [{ type: "function_call_output", call_id: "call_glob", output: "/fixture/main.ts" }] : []), + ]), + }) + }) +}) + describe("session.message-v2.toModelMessage", () => { test("filters out messages with no parts", async () => { const input: SessionV1.WithParts[] = [ @@ -401,7 +515,7 @@ describe("session.message-v2.toModelMessage", () => { type: "content", value: [ { type: "text", text: "ok" }, - { type: "media", mediaType: "image/png", data: "Zm9v" }, + { type: "image-data", mediaType: "image/png", data: "Zm9v" }, ], }, providerOptions: { openai: { tool: "meta" } }, @@ -488,7 +602,7 @@ describe("session.message-v2.toModelMessage", () => { type: "content", value: [ { type: "text", text: "Image read successfully" }, - { type: "media", mediaType: "image/jpeg", data: jpeg }, + { type: "image-data", mediaType: "image/jpeg", data: jpeg }, ], }, })