diff --git a/packages/app/src/utils/server.test.ts b/packages/app/src/utils/server.test.ts new file mode 100644 index 00000000000..3e8610c6051 --- /dev/null +++ b/packages/app/src/utils/server.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { base64Encode } from "@opencode-ai/util/encode" +import { normalizeDirectory } from "./server" + +describe("normalizeDirectory", () => { + test("keeps absolute posix directories unchanged", () => { + expect(normalizeDirectory("/tmp/demo")).toBe("/tmp/demo") + }) + + test("decodes posix route slugs into absolute directories", () => { + expect(normalizeDirectory(base64Encode("/tmp/demo"))).toBe("/tmp/demo") + }) + + test("decodes windows route slugs into absolute directories", () => { + expect(normalizeDirectory(base64Encode("C:\\Users\\demo\\repo"))).toBe("C:\\Users\\demo\\repo") + }) + + test("does not rewrite plain relative values", () => { + expect(normalizeDirectory("workspace")).toBe("workspace") + expect(normalizeDirectory(base64Encode("workspace"))).toBe(base64Encode("workspace")) + }) +}) diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts index 17f4a3adcec..c83a0195705 100644 --- a/packages/app/src/utils/server.ts +++ b/packages/app/src/utils/server.ts @@ -1,5 +1,17 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import type { ServerConnection } from "@/context/server" +import { decode64 } from "./base64" + +function absolute(dir: string) { + return dir.startsWith("/") || /^[A-Za-z]:[\\/]/.test(dir) || dir.startsWith("\\\\") +} + +export function normalizeDirectory(dir?: string) { + if (!dir || absolute(dir)) return dir + const next = decode64(dir) + if (!next || !absolute(next)) return dir + return next +} export function createSdkForServer({ server, @@ -16,6 +28,7 @@ export function createSdkForServer({ return createOpencodeClient({ ...config, + directory: normalizeDirectory(config.directory), headers: { ...config.headers, ...auth }, baseUrl: server.url, }) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f3d0d0b7ad3..31705c8bdc9 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -330,7 +330,25 @@ export namespace Config { } } + async function shared(dir: string) { + if (!Installation.isLocal()) return false + + const roots = [Instance.directory, Instance.worktree].filter(Boolean) + for (const root of roots) { + const rel = path.relative(root, dir) + if (path.isAbsolute(rel) || rel.startsWith("..")) continue + if (await Filesystem.exists(path.join(root, "node_modules", "@opencode-ai", "plugin"))) return true + } + + return false + } + export async function needsInstall(dir: string) { + if (await shared(dir)) { + log.debug("config dir can use shared local dependencies, skipping dependency install", { dir }) + return false + } + // Some config dirs may be read-only. // Installing deps there will fail; skip installation in that case. const writable = await isWritable(dir) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 03ccb44c1ad..95c51861f16 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -22,6 +22,26 @@ export namespace MessageV2 { return mime.startsWith("image/") || mime === "application/pdf" } + function modality(mime: string) { + if (mime.startsWith("image/")) return "image" as const + if (mime === "application/pdf") return "pdf" as const + } + + function supported(model: Provider.Model, mime: string) { + const kind = modality(mime) + if (!kind) return true + return model.capabilities.input[kind] + } + + function notice(mime: string) { + const kind = modality(mime) + if (!kind) return "The previous tool produced a file that was not attached." + if (kind === "image") { + return "The previous tool produced an image, but the current model cannot accept image input. The file was not attached." + } + return "The previous tool produced a PDF, but the current model cannot accept pdf input. The file was not attached." + } + export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() })) export const StructuredOutputError = NamedError.create( @@ -635,17 +655,22 @@ export namespace MessageV2 { if (part.type === "tool") { toolNames.add(part.tool) if (part.state.status === "completed") { - const outputText = part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output + let outputText = part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? []) // 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 keep = mediaAttachments.filter((item) => supported(model, item.mime)) + const drop = mediaAttachments.filter((item) => !supported(model, item.mime)) + if (drop.length > 0) { + outputText += `\n\n${drop.map((item) => notice(item.mime)).join("\n")}` + } + if (!supportsMediaInToolResults && keep.length > 0) { + media.push(...keep) } - const finalAttachments = supportsMediaInToolResults ? attachments : nonMediaAttachments + const finalAttachments = supportsMediaInToolResults ? [...nonMediaAttachments, ...keep] : nonMediaAttachments const output = finalAttachments.length > 0 diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index c981ac16e43..ca7d90553b4 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -11,6 +11,7 @@ import { Instance } from "../project/instance" import { assertExternalDirectory } from "./external-directory" import { InstructionPrompt } from "../session/instruction" import { Filesystem } from "../util/filesystem" +import type { Provider } from "../provider/provider" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -18,6 +19,24 @@ const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` const MAX_BYTES = 50 * 1024 const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB` +function modality(mime: string) { + if (mime.startsWith("image/")) return "image" as const + if (mime === "application/pdf") return "pdf" as const +} + +function blocked(model: Provider.Model | undefined, mime: string) { + const kind = modality(mime) + if (!kind) return false + if (!model) return false + return !model.capabilities.input[kind] +} + +function notice(mime: string) { + const kind = modality(mime) + if (!kind) return "The file was not attached to the conversation." + return `This model does not support ${kind} input. The file was not attached to the conversation. Switch to a model that supports ${kind} input to inspect this file.` +} + export const ReadTool = Tool.define("read", { description: DESCRIPTION, parameters: z.object({ @@ -122,6 +141,19 @@ export const ReadTool = Tool.define("read", { const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet" const isPdf = mime === "application/pdf" if (isImage || isPdf) { + const model = ctx.extra?.["model"] as Provider.Model | undefined + if (blocked(model, mime)) { + const msg = `${isImage ? "Image" : "PDF"} read blocked. ${notice(mime)}` + return { + title, + output: msg, + metadata: { + preview: msg, + truncated: false, + loaded: instructions.map((i) => i.filepath), + }, + } + } const msg = `${isImage ? "Image" : "PDF"} read successfully` return { title, diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 90727cf8a08..01a3be98f6b 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -763,6 +763,37 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { } }) +test("skips installs for project .opencode when local deps are available upstream", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, "node_modules", "@opencode-ai", "plugin"), { recursive: true }) + await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + expect(await Config.needsInstall(path.join(tmp.path, ".opencode"))).toBe(false) + }, + }) +}) + +test("still installs for project .opencode when shared local deps are missing", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + expect(await Config.needsInstall(path.join(tmp.path, ".opencode"))).toBe(true) + }, + }) +}) + test("resolves scoped npm plugins in config", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index e9c6cb729bb..4f22e82e32f 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -269,6 +269,16 @@ describe("session.message-v2.toModelMessage", () => { test("converts assistant tool completion into tool-call + tool-result messages with attachments", () => { const userID = "m-user" const assistantID = "m-assistant" + const vision: Provider.Model = { + ...model, + capabilities: { + ...model.capabilities, + input: { + ...model.capabilities.input, + image: true, + }, + }, + } const input: MessageV2.WithParts[] = [ { @@ -318,7 +328,7 @@ describe("session.message-v2.toModelMessage", () => { }, ] - expect(MessageV2.toModelMessages(input, model)).toStrictEqual([ + expect(MessageV2.toModelMessages(input, vision)).toStrictEqual([ { role: "user", content: [{ type: "text", text: "run tool" }], @@ -569,6 +579,191 @@ describe("session.message-v2.toModelMessage", () => { ]) }) + test("does not inject unsupported tool result media for text-only models", () => { + const userID = "m-user" + const assistantID = "m-assistant" + const textOnly: Provider.Model = { + ...model, + api: { + ...model.api, + npm: "@ai-sdk/openai-compatible", + }, + } + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "read image", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "tool", + callID: "call-1", + tool: "read", + state: { + status: "completed", + input: { filePath: "/tmp/image.png" }, + output: "Image read successfully", + title: "Read", + metadata: {}, + attachments: [ + { + ...basePart(assistantID, "f1"), + type: "file", + mime: "image/png", + url: "data:image/png;base64,AAECAw==", + }, + ], + time: { start: 0, end: 1 }, + }, + }, + ] as MessageV2.Part[], + }, + ] + + expect(MessageV2.toModelMessages(input, textOnly)).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "read image" }], + }, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "read", + input: { filePath: "/tmp/image.png" }, + providerExecuted: undefined, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "read", + output: { + type: "text", + value: + "Image read successfully\n\nThe previous tool produced an image, but the current model cannot accept image input. The file was not attached.", + }, + }, + ], + }, + ]) + }) + + test("keeps supported tool result media for vision models on openai-compatible providers", () => { + const userID = "m-user" + const assistantID = "m-assistant" + const vision: Provider.Model = { + ...model, + api: { + ...model.api, + npm: "@ai-sdk/openai-compatible", + }, + capabilities: { + ...model.capabilities, + input: { + ...model.capabilities.input, + image: true, + }, + }, + } + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "read image", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "tool", + callID: "call-1", + tool: "read", + state: { + status: "completed", + input: { filePath: "/tmp/image.png" }, + output: "Image read successfully", + title: "Read", + metadata: {}, + attachments: [ + { + ...basePart(assistantID, "f1"), + type: "file", + mime: "image/png", + url: "data:image/png;base64,AAECAw==", + }, + ], + time: { start: 0, end: 1 }, + }, + }, + ] as MessageV2.Part[], + }, + ] + + expect(MessageV2.toModelMessages(input, vision)).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "read image" }], + }, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "read", + input: { filePath: "/tmp/image.png" }, + providerExecuted: undefined, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "read", + output: { type: "text", value: "Image read successfully" }, + }, + ], + }, + { + role: "user", + content: [ + { type: "text", text: "Attached image(s) from tool result:" }, + { + type: "file", + mediaType: "image/png", + filename: undefined, + data: "data:image/png;base64,AAECAw==", + }, + ], + }, + ]) + }) + test("filters assistant messages with non-abort errors", () => { const assistantID = "m-assistant" diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 7659d690c3a..879a9ec5ccc 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -414,6 +414,45 @@ describe("tool.read truncation", () => { }) }) + test("image files are not attached for text-only models", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==", + "base64", + ) + await Bun.write(path.join(dir, "image.png"), png) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute( + { filePath: path.join(tmp.path, "image.png") }, + { + ...ctx, + extra: { + model: { + capabilities: { + input: { + image: false, + pdf: false, + }, + }, + }, + }, + } as typeof ctx, + ) + expect(result.metadata.truncated).toBe(false) + expect(result.attachments).toBeUndefined() + expect(result.output).toContain("Image read blocked") + expect(result.output).toContain("does not support image input") + expect(result.output).toContain("not attached to the conversation") + }, + }) + }) + test(".fbs files (FlatBuffers schema) are read as text, not images", async () => { await using tmp = await tmpdir({ init: async (dir) => {