Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/app/src/utils/server.test.ts
Original file line number Diff line number Diff line change
@@ -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"))
})
})
13 changes: 13 additions & 0 deletions packages/app/src/utils/server.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -16,6 +28,7 @@ export function createSdkForServer({

return createOpencodeClient({
...config,
directory: normalizeDirectory(config.directory),
headers: { ...config.headers, ...auth },
baseUrl: server.url,
})
Expand Down
18 changes: 18 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 29 additions & 4 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions packages/opencode/src/tool/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,32 @@ 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
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({
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading
Loading