Compare commits

..

1 Commits

Author SHA1 Message Date
opencode 23c6b69abf release: v1.14.42 2026-05-09 16:54:32 +00:00
33 changed files with 140 additions and 704 deletions
+17 -17
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.43",
"version": "1.14.42",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.43",
"version": "1.14.42",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -120,7 +120,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.14.43",
"version": "1.14.42",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -147,7 +147,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.14.43",
"version": "1.14.42",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -171,7 +171,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.14.43",
"version": "1.14.42",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -195,7 +195,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.14.43",
"version": "1.14.42",
"bin": {
"opencode": "./bin/opencode",
},
@@ -229,7 +229,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.43",
"version": "1.14.42",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -283,7 +283,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.43",
"version": "1.14.42",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -313,7 +313,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.43",
"version": "1.14.42",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -329,7 +329,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.14.43",
"version": "1.14.42",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -342,7 +342,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.14.43",
"version": "1.14.42",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -360,7 +360,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.43",
"version": "1.14.42",
"bin": {
"opencode": "./bin/opencode",
},
@@ -495,7 +495,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.43",
"version": "1.14.42",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -533,7 +533,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.43",
"version": "1.14.42",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -548,7 +548,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.43",
"version": "1.14.42",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -583,7 +583,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.43",
"version": "1.14.42",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -632,7 +632,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.43",
"version": "1.14.42",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.14.43",
"version": "1.14.42",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.43",
"version": "1.14.42",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.14.43",
"version": "1.14.42",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.43",
"version": "1.14.42",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.14.43",
"version": "1.14.42",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.43",
"version": "1.14.42",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.14.43",
"version": "1.14.42",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.43",
"version": "1.14.42",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.14.43"
version = "1.14.42"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.43",
"version": "1.14.42",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.43",
"version": "1.14.42",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.43",
"version": "1.14.42",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
@@ -1 +1 @@
ALTER TABLE `workspace` ADD `time_used` integer NOT NULL DEFAULT 0;
ALTER TABLE `workspace` ADD `time_used` integer NOT NULL;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.43",
"version": "1.14.42",
"name": "opencode",
"type": "module",
"license": "MIT",
+62 -68
View File
@@ -347,7 +347,33 @@ export class Agent implements ACPAgent {
this.toolStarts.delete(part.callID)
this.shellSnapshots.delete(part.callID)
const kind = toToolKind(part.tool)
const content = completedToolContent(part, kind)
const content: ToolCallContent[] = [
{
type: "content",
content: {
type: "text",
text: part.state.output,
},
},
]
if (kind === "edit") {
const input = part.state.input
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
const newText =
typeof input["newString"] === "string"
? input["newString"]
: typeof input["content"] === "string"
? input["content"]
: ""
content.push({
type: "diff",
path: filePath,
oldText,
newText,
})
}
if (part.tool === "todowrite") {
const parsedTodos = decodeTodos(part.state.output)
@@ -387,7 +413,10 @@ export class Agent implements ACPAgent {
content,
title: part.state.title,
rawInput: part.state.input,
rawOutput: completedToolRawOutput(part),
rawOutput: {
output: part.state.output,
metadata: part.state.metadata,
},
},
})
.catch((error) => {
@@ -831,7 +860,33 @@ export class Agent implements ACPAgent {
this.toolStarts.delete(part.callID)
this.shellSnapshots.delete(part.callID)
const kind = toToolKind(part.tool)
const content = completedToolContent(part, kind)
const content: ToolCallContent[] = [
{
type: "content",
content: {
type: "text",
text: part.state.output,
},
},
]
if (kind === "edit") {
const input = part.state.input
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
const newText =
typeof input["newString"] === "string"
? input["newString"]
: typeof input["content"] === "string"
? input["content"]
: ""
content.push({
type: "diff",
path: filePath,
oldText,
newText,
})
}
if (part.tool === "todowrite") {
const parsedTodos = decodeTodos(part.state.output)
@@ -871,7 +926,10 @@ export class Agent implements ACPAgent {
content,
title: part.state.title,
rawInput: part.state.input,
rawOutput: completedToolRawOutput(part),
rawOutput: {
output: part.state.output,
metadata: part.state.metadata,
},
},
})
.catch((err) => {
@@ -1597,70 +1655,6 @@ function toLocations(toolName: string, input: Record<string, any>): { path: stri
}
}
function completedToolContent(part: ToolPart, kind: ToolKind): ToolCallContent[] {
if (part.state.status !== "completed") return []
const content: ToolCallContent[] = [
{
type: "content",
content: {
type: "text",
text: part.state.output,
},
},
]
if (kind === "edit") {
const input = part.state.input
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
const newText =
typeof input["newString"] === "string"
? input["newString"]
: typeof input["content"] === "string"
? input["content"]
: ""
content.push({
type: "diff",
path: filePath,
oldText,
newText,
})
}
content.push(...imageContents(part.state.attachments ?? []))
return content
}
function completedToolRawOutput(part: ToolPart) {
if (part.state.status !== "completed") return {}
return {
output: part.state.output,
metadata: part.state.metadata,
...(part.state.attachments?.length ? { attachments: part.state.attachments } : {}),
}
}
function imageContents(attachments: Array<{ mime: string; url: string }>): ToolCallContent[] {
return attachments.flatMap((attachment): ToolCallContent[] => {
const match = attachment.url.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/)
const mime = match?.[1] ?? attachment.mime
if (!mime.startsWith("image/")) return []
const data = match?.[2]
if (data === undefined) return []
return [
{
type: "content" as const,
content: {
type: "image" as const,
mimeType: mime,
data,
},
},
]
})
}
async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> {
const sdk = config.sdk
const configured = config.defaultModel
@@ -22,7 +22,9 @@ function View(props: { api: TuiPluginApi }) {
</box>
<Show when={list().length <= 2 || open()}>
<Show when={list().length === 0}>
<text fg={theme().textMuted}>{off() ? "LSPs are disabled" : "LSPs will activate as files are read"}</text>
<text fg={theme().textMuted}>
{off() ? "LSPs are disabled" : "LSPs will activate as files are read"}
</text>
</Show>
<For each={list()}>
{(item) => (
+1 -11
View File
@@ -935,16 +935,6 @@ export const ConfigProvidersResult = Schema.Struct({
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type ConfigProvidersResult = Types.DeepMutable<Schema.Schema.Type<typeof ConfigProvidersResult>>
export function toPublicInfo(provider: Info): Info {
return JSON.parse(
JSON.stringify(provider, (_, value) => {
if (typeof value === "function" || typeof value === "symbol" || value === undefined) return undefined
if (typeof value === "bigint") return value.toString()
return value
}),
)
}
export function defaultModelIDs<T extends { models: Record<string, { id: string }> }>(providers: Record<string, T>) {
return mapValues(providers, (item) => sort(Object.values(item.models))[0].id)
}
@@ -1309,7 +1299,7 @@ const layer: Layer.Layer<
const options = yield* Effect.promise(() =>
plugin.auth!.loader!(
() => bridge.promise(auth.get(providerID).pipe(Effect.orDie)) as any,
toPublicInfo(database[plugin.auth!.provider]),
database[plugin.auth!.provider],
),
)
const opts = options ?? {}
@@ -1,7 +1,6 @@
import { Config } from "@/config/config"
import { Provider } from "@/provider/provider"
import * as InstanceState from "@/effect/instance-state"
import { toJsonSafe } from "@/util/json-safe"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
@@ -13,12 +12,7 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h
const configSvc = yield* Config.Service
const get = Effect.fn("ConfigHttpApi.get")(function* () {
// Plugin `config` hooks may attach runtime-only values (functions,
// bigints, etc.) under schema-allowed fields like provider.options
// (Schema.Any). Project a JSON-safe copy at the HTTP boundary so
// response encode succeeds and clients see the typed schema shape
// (mirrors Provider.toPublicInfo in #26550).
return toJsonSafe(yield* configSvc.get())
return yield* configSvc.get()
})
const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) {
@@ -30,7 +24,7 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h
const providers = Effect.fn("ConfigHttpApi.providers")(function* () {
const providers = yield* providerSvc.list()
return {
providers: Object.values(providers).map(Provider.toPublicInfo),
providers: Object.values(providers),
default: Provider.defaultModelIDs(providers),
}
})
@@ -30,7 +30,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
connected,
)
return {
all: Object.values(providers).map(Provider.toPublicInfo),
all: Object.values(providers),
default: Provider.defaultModelIDs(providers),
connected: Object.keys(connected),
}
@@ -6,13 +6,39 @@ import { NamedError } from "@opencode-ai/core/util/error"
import * as Log from "@opencode-ai/core/util/log"
import { Cause, Effect } from "effect"
import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http"
import { HttpApiError } from "effect/unstable/httpapi"
import { HttpApiSchemaError } from "effect/unstable/httpapi/HttpApiError"
const log = Log.create({ service: "server" })
function badRequestResponse() {
return HttpServerResponse.jsonUnsafe(
{
data: {},
errors: [],
success: false,
},
{ status: 400 },
)
}
function normalizeEmptyBadRequest(response: HttpServerResponse.HttpServerResponse) {
if (response.status !== 400 || response.body._tag !== "Empty") return response
return badRequestResponse()
}
// Keep typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s.
export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) =>
effect.pipe(
Effect.catch((error) => {
if (error instanceof HttpApiError.BadRequest) return Effect.succeed(badRequestResponse())
return Effect.fail(error)
}),
Effect.map(normalizeEmptyBadRequest),
Effect.catchCause((cause) => {
const schemaError = cause.reasons.filter(Cause.isDieReason).find((reason) => HttpApiSchemaError.is(reason.defect))
if (schemaError) return Effect.succeed(badRequestResponse())
const defect = cause.reasons.filter(Cause.isDieReason).find((reason) => {
if (HttpServerResponse.isHttpServerResponse(reason.defect)) return false
if (HttpServerError.isHttpServerError(reason.defect)) return false
-13
View File
@@ -1,13 +0,0 @@
export function toJsonSafe<T>(value: T): T {
const ancestors: object[] = []
const json = JSON.stringify(value, function (this: unknown, _key, v) {
if (typeof v === "function" || typeof v === "symbol" || v === undefined) return undefined
if (typeof v === "bigint") return v.toString()
if (v === null || typeof v !== "object") return v
while (ancestors.length > 0 && ancestors.at(-1) !== this) ancestors.pop()
if (ancestors.includes(v)) return undefined
ancestors.push(v)
return v
})
return json === undefined ? (undefined as T) : JSON.parse(json)
}
@@ -1,13 +1,8 @@
import { describe, expect, test } from "bun:test"
import { ACP } from "../../src/acp/agent"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import type {
Event,
EventMessagePartUpdated,
ToolStateCompleted,
ToolStatePending,
ToolStateRunning,
} from "@opencode-ai/sdk/v2"
import type { Event, EventMessagePartUpdated, ToolStatePending, ToolStateRunning } from "@opencode-ai/sdk/v2"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { tmpdir } from "../fixture/fixture"
@@ -41,14 +36,6 @@ function isToolCallUpdate(
return update.sessionUpdate === "tool_call_update"
}
function completedToolUpdate(sessionUpdates: SessionUpdateParams[], sessionId: string, callID: string) {
return sessionUpdates
.filter((u) => u.sessionId === sessionId)
.map((u) => u.update)
.filter(isToolCallUpdate)
.find((u) => u.toolCallId === callID && u.status === "completed")
}
function toolEvent(
sessionId: string,
cwd: string,
@@ -91,46 +78,6 @@ function toolEvent(
return { directory: cwd, payload }
}
function completedToolEvent(
sessionId: string,
cwd: string,
opts: {
callID: string
tool: string
input: Record<string, unknown>
output: string
attachments?: ToolStateCompleted["attachments"]
},
): GlobalEventEnvelope {
const state: ToolStateCompleted = {
status: "completed",
input: opts.input,
output: opts.output,
title: opts.tool,
metadata: {},
time: { start: Date.now() - 1, end: Date.now() },
...(opts.attachments && { attachments: opts.attachments }),
}
const payload: EventMessagePartUpdated = {
id: `evt_${opts.callID}`,
type: "message.part.updated",
properties: {
sessionID: sessionId,
time: Date.now(),
part: {
id: `part_${opts.callID}`,
sessionID: sessionId,
messageID: `msg_${opts.callID}`,
type: "tool",
callID: opts.callID,
tool: opts.tool,
state,
},
},
}
return { directory: cwd, payload }
}
function createEventStream() {
const queue: GlobalEventEnvelope[] = []
const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = []
@@ -669,130 +616,6 @@ describe("acp.agent event subscription", () => {
})
})
test("emits image attachments as ACP tool content blocks on live completed tool updates", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
const cwd = "/tmp/opencode-acp-test"
const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
const data = Buffer.from("image-data").toString("base64")
controller.push(
completedToolEvent(sessionId, cwd, {
callID: "call_image",
tool: "read",
input: { filePath: "/tmp/image.png" },
output: "Image read successfully",
attachments: [
{
id: "part_image",
sessionID: sessionId,
messageID: "msg_image",
type: "file",
mime: "image/png",
filename: "image.png",
url: `data:image/png;base64,${data}`,
},
{
id: "part_text",
sessionID: sessionId,
messageID: "msg_image",
type: "file",
mime: "text/plain",
filename: "note.txt",
url: "data:text/plain;base64,Zm9v",
},
],
}),
)
await new Promise((r) => setTimeout(r, 20))
const update = completedToolUpdate(sessionUpdates, sessionId, "call_image")
expect(update?.content).toContainEqual({
type: "content",
content: { type: "text", text: "Image read successfully" },
})
expect(update?.content).toContainEqual({
type: "content",
content: { type: "image", mimeType: "image/png", data },
})
expect(update?.content?.some((item) => item.type === "content" && item.content.type === "resource")).toBe(false)
expect((update?.rawOutput as { attachments?: unknown[] } | undefined)?.attachments?.length).toBe(2)
stop()
},
})
})
test("replays completed tool image attachments as ACP tool content blocks", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const { agent, sessionUpdates, stop, sdk } = createFakeAgent()
const cwd = "/tmp/opencode-acp-test"
const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
const data = Buffer.from("replay-image").toString("base64")
sdk.session.messages = async () => ({
data: [
{
info: {
role: "assistant",
sessionID: sessionId,
},
parts: [
{
id: "part_replay",
sessionID: sessionId,
messageID: "msg_replay",
type: "tool",
callID: "call_replay_image",
tool: "webfetch",
state: {
status: "completed",
input: { url: "https://example.com/image.png" },
output: "Image fetched successfully",
title: "webfetch",
metadata: {},
time: { start: Date.now() - 1, end: Date.now() },
attachments: [
{
id: "part_replay_image",
sessionID: sessionId,
messageID: "msg_replay",
type: "file",
mime: "image/jpeg",
filename: "image.jpg",
url: `data:image/jpeg;base64,${data}`,
},
],
},
},
],
},
],
})
await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any)
const update = completedToolUpdate(sessionUpdates, sessionId, "call_replay_image")
expect(update?.content).toContainEqual({
type: "content",
content: { type: "text", text: "Image fetched successfully" },
})
expect(update?.content).toContainEqual({
type: "content",
content: { type: "image", mimeType: "image/jpeg", data },
})
stop()
},
})
})
test("does not emit duplicate synthetic pending after replayed running tool", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
@@ -1,249 +0,0 @@
/**
* Reproducer tests for plugin-hook mutation bugs analogous to #26546
* (gemini-auth-plugin / `toPublicInfo`, fixed by #26550).
*
* Status:
* - Finding 4 (plugin.config): FIXED in this PR by projecting cfg
* through `toJsonSafe` at the HTTP boundary in
* `ConfigHttpApi.get`. The active reproducer below asserts the
* HTTP `/config` response, not Config.Service.get(); internal cfg
* is allowed to carry whatever plugins put there, the public API
* is the contract that matters.
* - Findings 1-3 (tool.definition, messages.transform, system.transform):
* reproducers SKIPPED. The `output`-mutation contract is documented
* public API (see packages/web/src/content/docs/plugins.mdx); blanket
* clone-and-return breaks plugins that mutate by reference, and
* blanket in-place scrub corrupts shared Effect Schema instances
* in `tool.parameters`. Per-hook design needed.
*/
import { afterAll, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import path from "path"
import { pathToFileURL } from "url"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
const { Plugin } = await import("../../src/plugin/index")
const { Config } = await import("../../src/config/config")
const { Server } = await import("../../src/server/server")
const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer))
afterAll(() => {
if (disableDefault === undefined) {
delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
return
}
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
})
function withProject<A, E, R>(
source: string,
self: Effect.Effect<A, E, R> | ((dir: string) => Effect.Effect<A, E, R>),
) {
return provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "plugin.ts")
yield* Effect.all(
[
Effect.promise(() => Bun.write(file, source)),
Effect.promise(() =>
Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{
$schema: "https://opencode.ai/config.json",
plugin: [pathToFileURL(file).href],
},
null,
2,
),
),
),
],
{ discard: true, concurrency: 2 },
)
return yield* typeof self === "function" ? self(dir) : self
}),
)
}
/** True if `value` contains only values that can cross JSON boundaries without loss. */
function isJsonSafe(value: unknown, seen = new WeakSet<object>()): boolean {
if (typeof value === "function" || typeof value === "symbol" || typeof value === "bigint" || value === undefined) {
return false
}
if (value === null || typeof value !== "object") return true
if (seen.has(value as object)) return true
seen.add(value as object)
if (Array.isArray(value)) return value.every((v) => isJsonSafe(v, seen))
for (const key of Object.keys(value as Record<string, unknown>)) {
if (!isJsonSafe((value as Record<string, unknown>)[key], seen)) return false
}
return true
}
describe("plugin hook mutation reproducers (analog of #26546)", () => {
// -------------------------------------------------------------------------
// Finding 1: tool.definition — packages/opencode/src/tool/registry.ts:325
// Plugin can attach function values to `output.parameters`; downstream LLM
// tool-use serialization drops them, producing malformed tool schemas.
// -------------------------------------------------------------------------
it.live.skip("tool.definition: output stays JSON-safe after plugin mutation", () =>
withProject(
[
"export default async () => ({",
' "tool.definition": (_input, output) => {',
" // Simulate a misbehaving plugin attaching a function value",
" // (e.g. trying to inject a custom validator, runtime hook, etc.)",
" output.parameters = {",
" ...((output.parameters as any) ?? {}),",
" __pluginFn: () => 'side effect',",
" }",
" },",
"})",
"",
].join("\n"),
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const output: { description: string; parameters: any } = {
description: "test tool",
parameters: { type: "object", properties: {} },
}
yield* plugin.trigger("tool.definition", { toolID: "test_tool" }, output)
// Future contract: function-valued mutations must not leak back
// to the tool definition pipeline without breaking output
// mutation semantics.
expect(isJsonSafe(output)).toBe(true)
}),
),
)
// -------------------------------------------------------------------------
// Finding 2: experimental.chat.messages.transform
// Call sites: session/prompt.ts:1566 and session/compaction.ts:407.
// The hook contract is the same; one test covers both.
// -------------------------------------------------------------------------
it.live.skip("experimental.chat.messages.transform: output stays JSON-safe after plugin mutation", () =>
withProject(
[
"export default async () => ({",
' "experimental.chat.messages.transform": (_input, output) => {',
" output.messages = [",
" ...output.messages,",
" // Plugin attaches a function in a message-shaped object;",
" // downstream MessageV2.toModelMessagesEffect / persistence",
" // will drop the field, corrupting message ordering or",
" // failing schema validation.",
" { id: 'plug', role: 'assistant', toolCall: () => 'oops' } as any,",
" ]",
" },",
"})",
"",
].join("\n"),
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const output = { messages: [] as any[] }
yield* plugin.trigger("experimental.chat.messages.transform", {}, output)
// Future contract: messages handed back to the prompt /
// compaction pipeline must be JSON-safe. The same hook fires from
// both session/prompt.ts:1566 and session/compaction.ts:407.
expect(isJsonSafe(output)).toBe(true)
}),
),
)
// -------------------------------------------------------------------------
// Finding 3: experimental.chat.system.transform — agent/agent.ts:452
// Plugin can attach function-valued entries to the system prompt array.
// -------------------------------------------------------------------------
it.live.skip("experimental.chat.system.transform: output stays JSON-safe after plugin mutation", () =>
withProject(
[
"export default async () => ({",
' "experimental.chat.system.transform": (_input, output) => {',
" // Plugin pushes a non-string value onto system. After JSON",
" // round-trip (e.g. provider request body), the function is",
" // dropped, leaving an empty / malformed prompt entry.",
" ;(output.system as any[]).push(() => 'dynamic prompt')",
" },",
"})",
"",
].join("\n"),
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const output: { system: unknown[] } = { system: ["initial"] }
yield* plugin.trigger(
"experimental.chat.system.transform",
{
model: {
providerID: ProviderID.anthropic,
modelID: ModelID.make("claude-sonnet-4-6"),
},
},
output,
)
// Future contract: the system prompt array opencode keeps
// forwarding to the LLM provider must contain only JSON-safe
// primitives.
expect(isJsonSafe(output)).toBe(true)
}),
),
)
// -------------------------------------------------------------------------
// Finding 4: plugin.config() — plugin/index.ts:235
// The plugin's `config` hook receives the LIVE Config.Info object and can
// mutate it. Internal cfg may keep runtime values; HTTP `/config` must
// project those values out before response encoding.
// -------------------------------------------------------------------------
it.live("plugin.config(): GET /config response stays JSON-safe after plugin mutation", () =>
withProject(
[
"export default async () => ({",
" config: (cfg) => {",
" // Misbehaving plugin attaches runtime-only values under a",
" // schema-allowed field. Internal state is allowed to carry",
" // them, but the HTTP API must project them to JSON-safe data.",
" ;(cfg as any).provider = {",
" ...((cfg as any).provider ?? {}),",
" plugin_runtime: {",
" options: {",
" fetch: async (input, init) => fetch(input, init),",
" pluginBigInt: BigInt(1),",
" },",
" },",
" }",
" },",
"})",
"",
].join("\n"),
(dir) =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
yield* plugin.init()
const config = yield* Config.Service
const cfg = yield* config.get()
expect(typeof cfg.provider?.plugin_runtime?.options?.fetch).toBe("function")
const headers = { "x-opencode-directory": dir }
const response = yield* Effect.promise(() =>
Promise.resolve(Server.Default().app.request("/config", { headers })),
)
expect(response.status).toBe(200)
const body = (yield* Effect.promise(() => response.json())) as Record<string, unknown>
// Post-fix contract: HTTP /config is the boundary. Whatever the
// live in-memory cfg looks like, the wire response must be
// JSON-safe and match the typed schema.
expect(isJsonSafe(body)).toBe(true)
const provider = body.provider as Record<string, { options?: Record<string, unknown> }> | undefined
expect(provider?.plugin_runtime?.options?.fetch).toBeUndefined()
expect(provider?.plugin_runtime?.options?.pluginBigInt).toBe("1")
}),
),
)
})
@@ -21,22 +21,6 @@ function app() {
return Server.Default().app
}
function providerListHasFetch(list: unknown) {
if (!Array.isArray(list)) return false
return list.some((item: unknown) => {
if (typeof item !== "object" || item === null || !("id" in item) || !("options" in item)) return false
if (item.id !== "google") return false
if (typeof item.options !== "object" || item.options === null) return false
return "fetch" in item.options
})
}
function hasProviderWithFetch(input: unknown, key: "all" | "providers") {
if (typeof input !== "object" || input === null) return false
if (key === "all") return "all" in input && providerListHasFetch(input.all)
return "providers" in input && providerListHasFetch(input.providers)
}
function requestAuthorize(input: {
app: ReturnType<typeof app>
providerID: string
@@ -92,39 +76,6 @@ function writeProviderAuthPlugin(dir: string) {
})
}
function writeFunctionOptionsPlugin(dir: string) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
yield* fs.makeDirectory(path.join(dir, ".opencode", "plugin"), { recursive: true })
yield* fs.writeFileString(
path.join(dir, ".opencode", "plugin", "provider-function-options.ts"),
[
"export default {",
' id: "test.provider-function-options",',
" server: async () => ({",
" auth: {",
' provider: "google",',
" loader: async (_getAuth, provider) => {",
" for (const model of Object.values(provider.models ?? {})) {",
" model.cost = { input: 0, output: 0 }",
" }",
" return {",
' apiKey: "",',
" fetch: async (input, init) => fetch(input, init),",
" }",
" },",
" methods: [{ type: 'api', label: 'API key' }],",
" },",
" }),",
"}",
"",
].join("\n"),
)
})
}
function withProviderProject<A, E, R>(self: (dir: string) => Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
@@ -185,43 +136,4 @@ describe("provider HttpApi", () => {
}),
),
)
it.live("serves provider lists when auth loaders add runtime fetch options", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" })
const previous = process.env.OPENCODE_AUTH_CONTENT
yield* fs.writeFileString(
path.join(dir, "opencode.json"),
JSON.stringify({ $schema: "https://opencode.ai/config.json", formatter: false, lsp: false }),
)
yield* writeFunctionOptionsPlugin(dir)
yield* Effect.sync(() => {
process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({
google: { type: "oauth", refresh: "dummy", access: "dummy", expires: 9999999999999 },
})
})
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
if (previous === undefined) delete process.env.OPENCODE_AUTH_CONTENT
if (previous !== undefined) process.env.OPENCODE_AUTH_CONTENT = previous
}),
)
const headers = { "x-opencode-directory": dir }
const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers })))
const configResponse = yield* Effect.promise(() =>
Promise.resolve(app().request("/config/providers", { headers })),
)
expect(providerResponse.status).toBe(200)
expect(configResponse.status).toBe(200)
const providerBody = yield* Effect.promise(() => providerResponse.json())
const configBody = yield* Effect.promise(() => configResponse.json())
expect(hasProviderWithFetch(providerBody, "all")).toBe(false)
expect(hasProviderWithFetch(configBody, "providers")).toBe(false)
}),
)
})
@@ -122,7 +122,7 @@ describe("sync HttpApi", () => {
}
})
test.todo("returns structured validation errors", async () => {
test("returns structured validation errors", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await ExperimentalHttpApiServer.webHandler().handler(
new Request(`http://localhost${SyncPaths.history}`, {
@@ -1,43 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Database } from "bun:sqlite"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
import { readFileSync, readdirSync } from "fs"
import path from "path"
const target = "20260507164347_add_workspace_time"
function migrations() {
return readdirSync(path.join(import.meta.dirname, "../../migration"), { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => ({
name: entry.name,
timestamp: Number(entry.name.split("_")[0]),
sql: readFileSync(path.join(import.meta.dirname, "../../migration", entry.name, "migration.sql"), "utf-8"),
}))
.sort((a, b) => a.timestamp - b.timestamp)
}
describe("workspace time migration", () => {
test("migrates existing workspace rows", () => {
const sqlite = new Database(":memory:")
const db = drizzle({ client: sqlite })
const entries = migrations()
const index = entries.findIndex((entry) => entry.name === target)
expect(index).toBeGreaterThan(0)
migrate(db, entries.slice(0, index))
sqlite.run(
"INSERT INTO project (id, worktree, vcs, name, time_created, time_updated, sandboxes) VALUES (?, ?, ?, ?, ?, ?, ?)",
["project_1", "/tmp/project", "git", "project", 1, 1, "[]"],
)
sqlite.run(
"INSERT INTO workspace (id, type, name, branch, directory, extra, project_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
["workspace_1", "local", "main", "main", "/tmp/project", null, "project_1"],
)
expect(() => migrate(db, entries.slice(index))).not.toThrow()
expect(sqlite.query("SELECT time_used FROM workspace WHERE id = ?").get("workspace_1")).toEqual({ time_used: 0 })
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.14.43",
"version": "1.14.42",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.14.43",
"version": "1.14.42",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.14.43",
"version": "1.14.42",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.14.43",
"version": "1.14.42",
"type": "module",
"license": "MIT",
"exports": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.14.43",
"version": "1.14.42",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.14.43",
"version": "1.14.42",
"publisher": "sst-dev",
"repository": {
"type": "git",