From af6c0799c930c4b07d30ea85f0dc5b07576ecadf Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 23 Apr 2026 02:31:07 +0800 Subject: [PATCH 1/3] fix(opencode): sniff media attachments --- packages/opencode/src/session/message-v2.ts | 6 +- packages/opencode/src/tool/read.ts | 90 +++++++++++------ packages/opencode/src/tool/webfetch.ts | 3 +- packages/opencode/src/util/media.ts | 49 ++++++++++ .../opencode/test/session/message-v2.test.ts | 62 ++++++++++++ packages/opencode/test/tool/read.test.ts | 96 ++++++++++++++++++- 6 files changed, 268 insertions(+), 38 deletions(-) create mode 100644 packages/opencode/src/util/media.ts diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 39277b074..af264efaa 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -16,6 +16,8 @@ import type { Provider } from "@/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Effect } from "effect" import { EffectLogger } from "@/effect" +import { isMedia } from "@/util/media" +export { isMedia } from "@/util/media" /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ interface FetchDecompressionError extends Error { @@ -26,10 +28,6 @@ interface FetchDecompressionError extends Error { export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:" -export function isMedia(mime: string) { - return mime.startsWith("image/") || mime === "application/pdf" -} - export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() })) export const StructuredOutputError = NamedError.create( diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 2a89e4afd..024c63e04 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -1,7 +1,6 @@ import z from "zod" -import { Effect, Scope } from "effect" +import { Effect, Option, Scope } from "effect" import { createReadStream } from "fs" -import { open } from "fs/promises" import * as path from "path" import { createInterface } from "readline" import { Tool } from "./tool" @@ -11,12 +10,16 @@ import DESCRIPTION from "./read.txt" import { Instance } from "../project/instance" import { assertExternalDirectoryEffect } from "./external-directory" import { Instruction } from "../session/instruction" +import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "../util/media" 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` +const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024 +const MAX_ATTACHMENT_BYTES_LABEL = `${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB` +const SAMPLE_BYTES = 4096 const parameters = z.object({ filePath: z.string().describe("The absolute path to the file or directory to read"), @@ -77,6 +80,18 @@ export const ReadTool = Tool.define( yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope)) }) + const readSample = Effect.fn("ReadTool.sample")(function* (filepath: string, fileSize: number) { + if (fileSize === 0) return new Uint8Array() + + return yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fs.open(filepath, { flag: "r" }) + const bytes = yield* file.readAlloc(Math.min(SAMPLE_BYTES, fileSize)) + return Option.getOrElse(bytes, () => new Uint8Array()) + }), + ) + }) + const run = Effect.fn("ReadTool.execute")(function* (params: z.infer, ctx: Tool.Context) { if (params.offset !== undefined && params.offset < 1) { return yield* Effect.fail(new Error("offset must be greater than or equal to 1")) @@ -142,10 +157,23 @@ export const ReadTool = Tool.define( const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID) - const mime = AppFileSystem.mimeType(filepath) - const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet" - const isPdf = mime === "application/pdf" + if (isBinaryByExt(filepath) && !shouldSniffBeforeBinaryExt(filepath)) { + return yield* Effect.fail( + new Error(`Cannot read binary file (extension: ${path.extname(filepath).toLowerCase()}): ${filepath}`), + ) + } + + const sample = yield* readSample(filepath, Number(stat.size)) + const mime = sniffAttachmentMime(sample, AppFileSystem.mimeType(filepath)) + const isImage = isImageAttachment(mime) + const isPdf = isPdfAttachment(mime) if (isImage || isPdf) { + if (Number(stat.size) > MAX_ATTACHMENT_BYTES) { + return yield* Effect.fail( + new Error(`Cannot read attachment larger than ${MAX_ATTACHMENT_BYTES_LABEL}: ${filepath}`), + ) + } + const msg = `${isImage ? "Image" : "PDF"} read successfully` return { title, @@ -165,8 +193,8 @@ export const ReadTool = Tool.define( } } - if (yield* Effect.promise(() => isBinaryFile(filepath, Number(stat.size)))) { - return yield* Effect.fail(new Error(`Cannot read binary file: ${filepath}`)) + if (isBinaryFile(filepath, sample)) { + return yield* Effect.fail(new Error(`Cannot read binary file (content inspection): ${filepath}`)) } const file = yield* Effect.promise(() => @@ -262,7 +290,29 @@ async function lines(filepath: string, opts: { limit: number; offset: number }) return { raw, count, cut, more, offset: opts.offset } } -async function isBinaryFile(filepath: string, fileSize: number): Promise { +function isBinaryFile(filepath: string, sample: Uint8Array): boolean { + if (isBinaryByExt(filepath)) return true + + if (sample.byteLength === 0) return false + + let nonPrintableCount = 0 + for (let i = 0; i < sample.byteLength; i++) { + if (sample[i] === 0) return true + if (sample[i] < 9 || (sample[i] > 13 && sample[i] < 32)) { + nonPrintableCount++ + } + } + // If >30% non-printable characters, consider it binary + return nonPrintableCount / sample.byteLength > 0.3 +} + +function shouldSniffBeforeBinaryExt(filepath: string): boolean { + const ext = path.extname(filepath).toLowerCase() + // These common generic binary extensions may still contain renamed image/PDF attachments. + return ext === ".bin" || ext === ".dat" +} + +function isBinaryByExt(filepath: string): boolean { const ext = path.extname(filepath).toLowerCase() // binary check for common non-text extensions switch (ext) { @@ -296,28 +346,6 @@ async function isBinaryFile(filepath: string, fileSize: number): Promise 13 && bytes[i] < 32)) { - nonPrintableCount++ - } - } - // If >30% non-printable characters, consider it binary - return nonPrintableCount / result.bytesRead > 0.3 - } finally { - await fh.close() + return false } } diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index 6498b871f..5c554d7e5 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -4,6 +4,7 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http" import * as Tool from "./tool" import TurndownService from "turndown" import DESCRIPTION from "./webfetch.txt" +import { isImageAttachment } from "../util/media" const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds @@ -105,7 +106,7 @@ export const WebFetchTool = Tool.define( const title = `${params.url} (${contentType})` // Check if response is an image - const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet" + const isImage = isImageAttachment(mime) if (isImage) { const base64Content = Buffer.from(arrayBuffer).toString("base64") diff --git a/packages/opencode/src/util/media.ts b/packages/opencode/src/util/media.ts new file mode 100644 index 000000000..054e9c527 --- /dev/null +++ b/packages/opencode/src/util/media.ts @@ -0,0 +1,49 @@ +const startsWith = (bytes: Uint8Array, prefix: number[]) => + bytes.length >= prefix.length && prefix.every((value, index) => bytes[index] === value) +const startsWithAt = (bytes: Uint8Array, offset: number, prefix: number[]) => + bytes.length >= offset + prefix.length && prefix.every((value, index) => bytes[offset + index] === value) + +const ascii = (value: string) => [...value].map((char) => char.charCodeAt(0)) +const brand = (bytes: Uint8Array, offset: number) => String.fromCharCode(...bytes.slice(offset, offset + 4)) +const u32be = (bytes: Uint8Array, offset: number) => + bytes.length >= offset + 4 + ? ((bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]) >>> 0 + : 0 + +export function isPdfAttachment(mime: string) { + return mime === "application/pdf" +} + +export function isMedia(mime: string) { + return mime.startsWith("image/") || isPdfAttachment(mime) +} + +export function isImageAttachment(mime: string) { + return mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet" +} + +export function sniffAttachmentMime(bytes: Uint8Array, fallback: string) { + if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png" + if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg" + if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif" + if (startsWith(bytes, [0x42, 0x4d])) return "image/bmp" + if (startsWith(bytes, [0x49, 0x49, 0x2a, 0x00]) || startsWith(bytes, [0x4d, 0x4d, 0x00, 0x2a])) return "image/tiff" + if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf" + if ( + startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && + bytes.length >= 12 && + startsWith(bytes.slice(8, 12), [0x57, 0x45, 0x42, 0x50]) + ) + return "image/webp" + if (startsWithAt(bytes, 4, ascii("ftyp"))) { + const boxSize = u32be(bytes, 0) + const limit = Math.min(boxSize > 0 ? boxSize : bytes.length, bytes.length) + const brands = [] + for (let offset = 8; offset + 4 <= limit; offset += 4) { + brands.push(brand(bytes, offset)) + } + if (brands.some((item) => item === "avif" || item === "avis")) return "image/avif" + if (brands.some((item) => ["heic", "heix", "hevc", "hevx", "mif1", "msf1"].includes(item))) return "image/heic" + } + return fallback +} diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 6d4e994a8..8518d4112 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -359,6 +359,68 @@ describe("session.message-v2.toModelMessage", () => { ]) }) + test("converts pdf tool attachments into media tool results", async () => { + const userID = "m-user" + const assistantID = "m-assistant" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "read pdf", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "tool", + callID: "call-1", + tool: "read", + state: { + status: "completed", + input: { filePath: "report.pdf" }, + output: "PDF read successfully", + title: "Read", + metadata: {}, + time: { start: 0, end: 1 }, + attachments: [ + { + ...basePart(assistantID, "file-1"), + type: "file", + mime: "application/pdf", + filename: "report.pdf", + url: "data:application/pdf;base64,JVBERi0=", + }, + ], + }, + }, + ] as MessageV2.Part[], + }, + ] + + const [, , tool] = await MessageV2.toModelMessages(input, model) + expect(tool).toMatchObject({ + role: "tool", + content: [ + { + output: { + type: "content", + value: [ + { type: "text", text: "PDF read successfully" }, + { type: "media", mediaType: "application/pdf", data: "JVBERi0=" }, + ], + }, + }, + ], + }) + }) + test("omits provider metadata when assistant model differs", async () => { const userID = "m-user" const assistantID = "m-assistant" diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 3a9c0e8e3..d3b17dedb 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -17,6 +17,12 @@ import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") +const ftyp = (...brands: string[]) => { + const content = Buffer.concat([Buffer.from("ftyp"), ...brands.map((item) => Buffer.from(item))]) + const size = Buffer.alloc(4) + size.writeUInt32BE(size.length + content.length) + return Buffer.concat([size, content]) +} afterEach(async () => { await Instance.disposeAll() @@ -421,6 +427,92 @@ describe("tool.read truncation", () => { }), ) + it.live("detects attachment media from file contents", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xdb, 0x00, 0x43, 0x00]) + yield* put(path.join(dir, "image.bin"), jpeg) + + const result = yield* exec(dir, { filePath: path.join(dir, "image.bin") }) + + expect(result.output).toContain("Image read successfully") + expect(result.attachments?.[0]?.mime).toBe("image/jpeg") + expect(result.attachments?.[0]?.url).toStartWith("data:image/jpeg;base64,") + }), + ) + + it.live("detects pdf attachment media from file contents", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + yield* put(path.join(dir, "report.bin"), Buffer.from("%PDF-1.4\n")) + + const result = yield* exec(dir, { filePath: path.join(dir, "report.bin") }) + + expect(result.output).toContain("PDF read successfully") + expect(result.attachments?.[0]?.mime).toBe("application/pdf") + expect(result.attachments?.[0]?.url).toStartWith("data:application/pdf;base64,") + }), + ) + + it.live("detects modern image attachment media from file contents", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + yield* put(path.join(dir, "photo.bin"), Buffer.from([0x49, 0x49, 0x2a, 0x00, 0x00, 0x08, 0x00, 0x00])) + yield* put(path.join(dir, "phone.dat"), ftyp("heic", "\0\0\0\0")) + yield* put(path.join(dir, "screen.bin"), ftyp("avif", "\0\0\0\0")) + + const tiff = yield* exec(dir, { filePath: path.join(dir, "photo.bin") }) + const heic = yield* exec(dir, { filePath: path.join(dir, "phone.dat") }) + const avif = yield* exec(dir, { filePath: path.join(dir, "screen.bin") }) + + expect(tiff.attachments?.[0]?.mime).toBe("image/tiff") + expect(heic.attachments?.[0]?.mime).toBe("image/heic") + expect(avif.attachments?.[0]?.mime).toBe("image/avif") + }), + ) + + it.live("rejects oversized sniffed attachments before base64 encoding", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const largeJpeg = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff]), Buffer.alloc(5 * 1024 * 1024)]) + yield* put(path.join(dir, "large.bin"), largeJpeg) + + const err = yield* fail(dir, { filePath: path.join(dir, "large.bin") }) + + expect(err.message).toContain("Cannot read attachment larger than 5 MB") + }), + ) + + it.live("detects ftyp compatible-brand image media from file contents", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + yield* put(path.join(dir, "phone.bin"), ftyp("isom", "\0\0\0\0", "heix")) + yield* put(path.join(dir, "motion.bin"), ftyp("isom", "\0\0\0\0", "hevx")) + yield* put(path.join(dir, "screen.dat"), ftyp("isom", "\0\0\0\0", "avis")) + + const heix = yield* exec(dir, { filePath: path.join(dir, "phone.bin") }) + const hevx = yield* exec(dir, { filePath: path.join(dir, "motion.bin") }) + const avis = yield* exec(dir, { filePath: path.join(dir, "screen.dat") }) + + expect(heix.attachments?.[0]?.mime).toBe("image/heic") + expect(hevx.attachments?.[0]?.mime).toBe("image/heic") + expect(avis.attachments?.[0]?.mime).toBe("image/avif") + }), + ) + + it.live("reads svg files as text instead of attachments", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + yield* put(path.join(dir, "icon.svg"), `hello`) + + const result = yield* exec(dir, { filePath: path.join(dir, "icon.svg") }) + + expect(result.attachments).toBeUndefined() + expect(result.output).toContain("") + expect(result.output).toContain("hello") + }), + ) + it.live(".fbs files (FlatBuffers schema) are read as text, not images", () => Effect.gen(function* () { const dir = yield* tmpdirScoped() @@ -468,7 +560,7 @@ describe("tool.read binary detection", () => { yield* put(path.join(dir, "null-byte.txt"), bytes) const err = yield* fail(dir, { filePath: path.join(dir, "null-byte.txt") }) - expect(err.message).toContain("Cannot read binary file") + expect(err.message).toContain("Cannot read binary file (content inspection)") }), ) @@ -478,7 +570,7 @@ describe("tool.read binary detection", () => { yield* put(path.join(dir, "module.wasm"), "not really wasm") const err = yield* fail(dir, { filePath: path.join(dir, "module.wasm") }) - expect(err.message).toContain("Cannot read binary file") + expect(err.message).toContain("Cannot read binary file (extension: .wasm)") }), ) }) From 15483dbf481f05cd43a68873b2f08ffb412fa1e9 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 23 Apr 2026 02:31:25 +0800 Subject: [PATCH 2/3] fix(opencode): sync plugin compatibility tail --- packages/opencode/src/control-plane/types.ts | 3 +- .../opencode/src/control-plane/workspace.ts | 22 ++- .../src/plugin/github-copilot/copilot.ts | 10 +- packages/opencode/src/tool/skill.ts | 2 +- .../test/plugin/github-copilot-models.test.ts | 135 ++++++++++++++++++ .../test/plugin/workspace-adaptor.test.ts | 18 ++- packages/plugin/src/index.ts | 9 +- 7 files changed, 188 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index dd17c56d9..f0e222de7 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -26,7 +26,8 @@ export type Target = export type Adaptor = { configure(input: WorkspaceInfo): WorkspaceInfo | Promise - create(config: WorkspaceInfo, from?: WorkspaceInfo): Promise + // from is reserved for future workspace copy flows; core does not pass it today. + create(config: WorkspaceInfo, env?: Record, from?: WorkspaceInfo): Promise remove(config: WorkspaceInfo): Promise target(config: WorkspaceInfo): Target | Promise } diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index c3fec8233..44a5ad247 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -11,6 +11,8 @@ import { Filesystem } from "@/util/filesystem" import { ProjectID } from "@/project/schema" import { Instance } from "@/project/instance" import { Plugin } from "@/plugin" +import { Auth } from "@/auth" +import { AppRuntime } from "@/effect/app-runtime" import { WorkspaceTable } from "./workspace.sql" import { getAdaptor, getBuiltinAdaptor, ownerKey } from "./adaptors" import { WorkspaceInfo } from "./types" @@ -92,8 +94,8 @@ export namespace Workspace { const candidates = [ ...new Set( - [input.hint, input.owner, projectWorktree, ...project.sandboxes].filter( - (value): value is string => Boolean(value), + [input.hint, input.owner, projectWorktree, ...project.sandboxes].filter((value): value is string => + Boolean(value), ), ), ] @@ -137,7 +139,9 @@ export namespace Workspace { throw lastError } - export async function resolveAdaptor(input: Pick & { hint?: string | null }) { + export async function resolveAdaptor( + input: Pick & { hint?: string | null }, + ) { const hint = input.hint ?? (() => { @@ -200,7 +204,17 @@ export namespace Workspace { .run() }) - await adaptor.create(config) + const env = Object.fromEntries( + Object.entries({ + OPENCODE_AUTH_CONTENT: JSON.stringify(await AppRuntime.runPromise(Auth.Service.use((auth) => auth.all()))), + OPENCODE_WORKSPACE_ID: info.id, + OPENCODE_EXPERIMENTAL_WORKSPACES: "true", + OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS, + OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, + }).filter(([, value]) => value !== undefined), + ) as Record + await adaptor.create(config, env) startSync({ space: info }) diff --git a/packages/opencode/src/plugin/github-copilot/copilot.ts b/packages/opencode/src/plugin/github-copilot/copilot.ts index ac685f74d..efa43dda7 100644 --- a/packages/opencode/src/plugin/github-copilot/copilot.ts +++ b/packages/opencode/src/plugin/github-copilot/copilot.ts @@ -10,6 +10,7 @@ import { MessageV2 } from "@/session/message-v2" const log = Log.create({ service: "plugin.copilot" }) const CLIENT_ID = "Ov23li8tweQw6odWQebz" +const COPILOT_ANTHROPIC_NPM = "@ai-sdk/anthropic" // Add a small safety buffer when polling to avoid hitting the server // slightly too early due to clock skew / timer drift. const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 // 3 seconds @@ -329,16 +330,23 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { }, "chat.params": async (incoming, output) => { if (!incoming.model.providerID.includes("github-copilot")) return + const isCopilotAnthropic = incoming.model.api.npm === COPILOT_ANTHROPIC_NPM // Match github copilot cli, omit maxOutputTokens for gpt models if (incoming.model.api.id.includes("gpt")) { output.maxOutputTokens = undefined } + + // Copilot's /v1/messages shim rejects the eager_input_streaming field. + if (isCopilotAnthropic) { + output.options.toolStreaming = false + } }, "chat.headers": async (incoming, output) => { if (!incoming.model.providerID.includes("github-copilot")) return + const isCopilotAnthropic = incoming.model.api.npm === COPILOT_ANTHROPIC_NPM - if (incoming.model.api.npm === "@ai-sdk/anthropic") { + if (isCopilotAnthropic) { output.headers["anthropic-beta"] = "interleaved-thinking-2025-05-14" } diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 1582a90f2..59ec8ed1f 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -41,7 +41,7 @@ export const SkillTool = Tool.define( const base = pathToFileURL(dir).href const limit = 10 - const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe( + const files = yield* rg.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe( Stream.filter((file) => !file.includes("SKILL.md")), Stream.map((file) => path.resolve(dir, file)), Stream.take(limit), diff --git a/packages/opencode/test/plugin/github-copilot-models.test.ts b/packages/opencode/test/plugin/github-copilot-models.test.ts index 33ddef5dd..c183d8e40 100644 --- a/packages/opencode/test/plugin/github-copilot-models.test.ts +++ b/packages/opencode/test/plugin/github-copilot-models.test.ts @@ -161,3 +161,138 @@ test("remaps fallback oauth model urls to the enterprise host", async () => { expect(models.claude.api.url).toBe("https://copilot-api.ghe.example.com") expect(models.claude.api.npm).toBe("@ai-sdk/github-copilot") }) + +test("disables anthropic tool streaming for github copilot chat params", async () => { + const hooks = await CopilotAuthPlugin({ + client: {} as never, + project: {} as never, + directory: "", + worktree: "", + experimental_workspace: { + register() {}, + }, + serverUrl: new URL("https://example.com"), + $: {} as never, + }) + + const output = { temperature: 0, topP: 1, topK: 0, options: {} as Record } + await hooks["chat.params"]?.( + { + model: { + providerID: "github-copilot", + id: "claude", + api: { id: "claude-sonnet-4.5", npm: "@ai-sdk/anthropic" }, + }, + } as never, + output as never, + ) + + expect(output.options).toMatchObject({ toolStreaming: false }) +}) + +test("keeps tool streaming untouched outside github copilot anthropic chat params", async () => { + const hooks = await CopilotAuthPlugin({ + client: {} as never, + project: {} as never, + directory: "", + worktree: "", + experimental_workspace: { + register() {}, + }, + serverUrl: new URL("https://example.com"), + $: {} as never, + }) + + const copilotOpenAI = { temperature: 0, topP: 1, topK: 0, options: {} as Record } + await hooks["chat.params"]?.( + { + model: { + providerID: "github-copilot", + id: "gpt", + api: { id: "gpt-5", npm: "@ai-sdk/openai" }, + }, + } as never, + copilotOpenAI as never, + ) + + const anthropic = { temperature: 0, topP: 1, topK: 0, options: {} as Record } + await hooks["chat.params"]?.( + { + model: { + providerID: "anthropic", + id: "claude", + api: { id: "claude-sonnet-4.5", npm: "@ai-sdk/anthropic" }, + }, + } as never, + anthropic as never, + ) + + expect(copilotOpenAI.options).not.toHaveProperty("toolStreaming") + expect(anthropic.options).not.toHaveProperty("toolStreaming") +}) + +test("sets anthropic beta header only for github copilot anthropic chat headers", async () => { + const hooks = await CopilotAuthPlugin({ + client: { + session: { + message: async () => { + throw new Error("skip") + }, + get: async () => { + throw new Error("skip") + }, + }, + } as never, + project: {} as never, + directory: "", + worktree: "", + experimental_workspace: { + register() {}, + }, + serverUrl: new URL("https://example.com"), + $: {} as never, + }) + + const copilotAnthropic = { headers: {} as Record } + await hooks["chat.headers"]?.( + { + model: { + providerID: "github-copilot", + id: "claude", + api: { id: "claude-sonnet-4.5", npm: "@ai-sdk/anthropic" }, + }, + message: { sessionID: "s", id: "m" }, + } as never, + copilotAnthropic as never, + ) + + const copilotOpenAI = { headers: {} as Record } + await hooks["chat.headers"]?.( + { + model: { + providerID: "github-copilot", + id: "gpt", + api: { id: "gpt-5", npm: "@ai-sdk/openai" }, + }, + message: { sessionID: "s", id: "m" }, + } as never, + copilotOpenAI as never, + ) + + const anthropic = { headers: {} as Record } + await hooks["chat.headers"]?.( + { + model: { + providerID: "anthropic", + id: "claude", + api: { id: "claude-sonnet-4.5", npm: "@ai-sdk/anthropic" }, + }, + message: { sessionID: "s", id: "m" }, + } as never, + anthropic as never, + ) + + expect(copilotAnthropic.headers["anthropic-beta"]).toBe("interleaved-thinking-2025-05-14") + expect(copilotOpenAI.headers).not.toHaveProperty("anthropic-beta") + expect(anthropic.headers).not.toHaveProperty("anthropic-beta") +}) diff --git a/packages/opencode/test/plugin/workspace-adaptor.test.ts b/packages/opencode/test/plugin/workspace-adaptor.test.ts index 5c074fc55..7f0706de6 100644 --- a/packages/opencode/test/plugin/workspace-adaptor.test.ts +++ b/packages/opencode/test/plugin/workspace-adaptor.test.ts @@ -85,8 +85,8 @@ async function pluginProject() { " configure(input) {", ` return { ...input, name: "plug", branch: "plug/main", directory: ${JSON.stringify(space)} }`, " },", - " async create(input) {", - ` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(input))`, + " async create(input, env) {", + ` await Bun.write(${JSON.stringify(mark)}, JSON.stringify({ input, env }))`, " },", " async remove() {},", " target(input) {", @@ -141,13 +141,25 @@ describe("plugin.workspace", () => { expect(info.branch).toBe("plug/main") expect(info.directory).toBe(tmp.extra.space) expect(info.extra).toEqual({ key: "value" }) - expect(JSON.parse(await Bun.file(tmp.extra.mark).text())).toMatchObject({ + const created = JSON.parse(await Bun.file(tmp.extra.mark).text()) + expect(created.input).toMatchObject({ type: tmp.extra.type, name: "plug", branch: "plug/main", directory: tmp.extra.space, extra: { key: "value" }, }) + expect(created.env.OPENCODE_WORKSPACE_ID).toBe(info.id) + expect(created.env.OPENCODE_EXPERIMENTAL_WORKSPACES).toBe("true") + const otelKeys = ["OTEL_EXPORTER_OTLP_HEADERS", "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_RESOURCE_ATTRIBUTES"] as const + for (const key of otelKeys) { + const expected = process.env[key] + if (expected === undefined) expect(created.env).not.toHaveProperty(key) + else expect(created.env[key]).toBe(expected) + } + const auth = JSON.parse(created.env.OPENCODE_AUTH_CONTENT) + expect(typeof auth).toBe("object") + expect(auth).not.toBeNull() await waitFor(() => { const status = Workspace.status().find((item) => item.workspaceID === info.id) return status !== undefined && status.status !== "connecting" diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 799c3fd54..a7dac5124 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -49,7 +49,14 @@ export type WorkspaceAdaptor = { name: string description: string configure(config: WorkspaceInfo): WorkspaceInfo | Promise - create(config: WorkspaceInfo, from?: WorkspaceInfo): Promise + /** + * Environment variables to pass to spawned workspace processes. + * + * This can include OPENCODE_AUTH_CONTENT, a serialized provider auth blob. + * Treat values as secrets and avoid persisting or logging them. + * The from parameter is reserved for future workspace copy flows; core does not pass it today. + */ + create(config: WorkspaceInfo, env?: Record, from?: WorkspaceInfo): Promise remove(config: WorkspaceInfo): Promise target(config: WorkspaceInfo): WorkspaceTarget | Promise } From 3443d08cba7c5eb146152167be7540c509bea0d5 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 23 Apr 2026 02:31:54 +0800 Subject: [PATCH 3/3] fix(ui): sync upstream diff primitives --- .../src/components/message-part-stale.test.ts | 7 +++++ packages/ui/src/components/message-part.tsx | 4 +-- .../session-review-diff-render.test.ts | 21 ++++++++++++++ packages/ui/src/components/session-review.tsx | 29 ++++++++++++++----- .../components/session-turn-parent.test.ts | 14 +++++++++ packages/ui/src/components/session-turn.tsx | 17 ++++------- 6 files changed, 71 insertions(+), 21 deletions(-) create mode 100644 packages/ui/src/components/session-review-diff-render.test.ts create mode 100644 packages/ui/src/components/session-turn-parent.test.ts diff --git a/packages/ui/src/components/message-part-stale.test.ts b/packages/ui/src/components/message-part-stale.test.ts index 8bdbfc4e5..5f223eb5f 100644 --- a/packages/ui/src/components/message-part-stale.test.ts +++ b/packages/ui/src/components/message-part-stale.test.ts @@ -10,3 +10,10 @@ test("assistant part renderers capture item values before passing them to Part", expect(source).not.toMatch(/defaultOpen=\{partDefaultOpen\(item\(\)!?/) expect(source).not.toMatch(/message=\{message\(\)!?\}/) }) + +test("tool file accordions account for tool content gap in sticky offset", () => { + const source = readFileSync(new URL("./message-part.tsx", import.meta.url), "utf8") + + expect(source).toContain('style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}') + expect(source).not.toContain('style={{ "--sticky-accordion-offset": "40px" }}') +}) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 01af085be..8d8ebb61b 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1279,7 +1279,7 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre @@ -2071,7 +2071,7 @@ ToolRegistry.register({ setExpanded(Array.isArray(value) ? value : value ? [value] : [])} > diff --git a/packages/ui/src/components/session-review-diff-render.test.ts b/packages/ui/src/components/session-review-diff-render.test.ts new file mode 100644 index 000000000..e0b029ee8 --- /dev/null +++ b/packages/ui/src/components/session-review-diff-render.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test" +import { readFileSync } from "node:fs" + +test("session review keeps metadata-only diff rows non-renderable but visible", () => { + const source = readFileSync(new URL("./session-review.tsx", import.meta.url), "utf8") + + expect(source).toContain("!!mediaKindFromPath(diff.file) || diff.additions !== 0 || diff.deletions !== 0") + expect(source).toContain("const renderableFiles = createMemo(() =>") + expect(source).toContain(".filter(canRenderDiff)") + expect(source).toContain(".map((diff) => diff.file)") + expect(source).toContain("const hasOpenRenderableFiles = createMemo(() =>") + expect(source).toContain("renderableFiles().some((file) => open().includes(file))") + expect(source).toContain("const next = hasOpenRenderableFiles() ? [] : renderableFiles()") + expect(source).toContain("") + expect(source).toContain("const expanded = createMemo(() => diffCanRender() && open().includes(file))") + expect(source).toContain("const diffCanRender = () => canRenderDiff(diff)") + expect(source).toContain("value={file}") + expect(source).toContain("disabled={!diffCanRender()}") + expect(source).not.toContain('class="cursor-default"') + expect(source).toContain("") +}) diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 711350397..d92f54b61 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -85,6 +85,8 @@ function list(value: unknown): ReviewDiff[] { return Object.values(value).filter(diff) } +const canRenderDiff = (diff: ViewDiff) => !!mediaKindFromPath(diff.file) || diff.additions !== 0 || diff.deletions !== 0 + export interface SessionReviewProps { title?: JSX.Element empty?: JSX.Element @@ -261,8 +263,16 @@ export const SessionReview = (props: SessionReviewProps) => { queue() } + const renderableFiles = createMemo(() => + items() + .filter(canRenderDiff) + .map((diff) => diff.file), + ) + + const hasOpenRenderableFiles = createMemo(() => renderableFiles().some((file) => open().includes(file))) + const handleExpandOrCollapseAll = () => { - const next = open().length > 0 ? [] : files() + const next = hasOpenRenderableFiles() ? [] : renderableFiles() handleChange(next) } @@ -358,7 +368,7 @@ export const SessionReview = (props: SessionReviewProps) => { onClick={handleExpandOrCollapseAll} > - 0}>{i18n.t("ui.sessionReview.collapseAll")} + {i18n.t("ui.sessionReview.collapseAll")} {i18n.t("ui.sessionReview.expandAll")} @@ -387,7 +397,8 @@ export const SessionReview = (props: SessionReviewProps) => { {(diff) => { const file = diff.file - const expanded = createMemo(() => open().includes(file)) + const diffCanRender = () => canRenderDiff(diff) + const expanded = createMemo(() => diffCanRender() && open().includes(file)) const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file))) const force = () => !!store.force[file] @@ -502,7 +513,7 @@ export const SessionReview = (props: SessionReviewProps) => { data-selected={props.focusedFile === file ? "" : undefined} > - +
@@ -511,7 +522,7 @@ export const SessionReview = (props: SessionReviewProps) => { {`\u202A${getDirectory(file)}\u202C`} {getFilename(file)} - +
diff --git a/packages/ui/src/components/session-turn-parent.test.ts b/packages/ui/src/components/session-turn-parent.test.ts new file mode 100644 index 000000000..68b0b0459 --- /dev/null +++ b/packages/ui/src/components/session-turn-parent.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test" +import { readFileSync } from "node:fs" + +test("session turn collects assistant messages by parent id across the full message list", () => { + const source = readFileSync(new URL("./session-turn.tsx", import.meta.url), "utf8") + + expect(source).toContain("messages") + expect(source).toContain(".slice(messageIndex() + 1)") + expect(source).toContain(".filter") + expect(source).toContain("if (messageIndex() < 0) return emptyAssistant") + expect(source).toContain('item.role === "assistant"') + expect(source).toContain("item.parentID === msg.id") + expect(source).not.toContain('if (item.role === "user") break') +}) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 9ab409bdf..c214f5cc3 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -267,17 +267,12 @@ export function SessionTurn( if (!msg) return emptyAssistant const messages = allMessages() ?? emptyMessages - const index = messageIndex() - if (index < 0) return emptyAssistant - - const result: AssistantMessage[] = [] - for (let i = index + 1; i < messages.length; i++) { - const item = messages[i] - if (!item) continue - if (item.role === "user") break - if (item.role === "assistant" && item.parentID === msg.id) result.push(item as AssistantMessage) - } - return result + if (messageIndex() < 0) return emptyAssistant + + // Parent-linked assistant messages can outlive the old "stop at next user" boundary. + return messages + .slice(messageIndex() + 1) + .filter((item): item is AssistantMessage => item.role === "assistant" && item.parentID === msg.id) }, emptyAssistant, { equals: same },