mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 03:19:59 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 23c6b69abf |
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.43",
|
||||
"version": "1.14.42",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.43",
|
||||
"version": "1.14.42",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,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,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,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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.43",
|
||||
"version": "1.14.42",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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,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,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,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,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.43",
|
||||
"version": "1.14.42",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.14.43",
|
||||
"version": "1.14.42",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.14.43",
|
||||
"version": "1.14.42",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user