Compare commits

..
Author SHA1 Message Date
opencode 782a2bf06a release: v1.14.45 2026-05-10 00:07:14 +00:00
6849f96825 refactor(provider): share model status schema (#26595)
Co-authored-by: Developer <temp@example.com>
2026-05-09 19:20:31 -04:00
00c3248295 fix(config): allow active provider model status (#26592)
Co-authored-by: Developer <temp@example.com>
2026-05-09 18:59:05 -04:00
opencode-agent[bot] 818b56dbd0 chore: generate 2026-05-09 22:47:54 +00:00
Kit LangtonandGitHub 29b5b24787 fix(tui): aggregate bootstrap request failures
TUI bootstrap now reports all parallel fetch failures together instead of losing sibling failures after the first rejection.
2026-05-09 18:46:52 -04:00
Kit LangtonandGitHub 11363170ca fix(sdk): wrap thrown error bodies in Error
SDK throwOnError paths now convert structured response bodies into real Error instances while preserving the original body and status in cause.
2026-05-09 18:46:43 -04:00
Kit LangtonandGitHub ba9e4b67ed fix(tool/read): match permission patterns against worktree-relative path
Read permission checks now use the same worktree-relative path basis as edit/write/apply_patch, so configured patterns apply consistently.
2026-05-09 18:46:29 -04:00
Kit LangtonandGitHub bd1029b19f test(server): cover HttpApi context inheritance
Adds regression coverage for request context inheritance in promptAsync and explicit context provisioning in stream bodies.
2026-05-09 18:46:10 -04:00
43 changed files with 609 additions and 261 deletions
+17 -17
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.44",
"version": "1.14.45",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.44",
"version": "1.14.45",
"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.44",
"version": "1.14.45",
"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.44",
"version": "1.14.45",
"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.44",
"version": "1.14.45",
"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.44",
"version": "1.14.45",
"bin": {
"opencode": "./bin/opencode",
},
@@ -229,7 +229,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.44",
"version": "1.14.45",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -283,7 +283,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.44",
"version": "1.14.45",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -313,7 +313,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.44",
"version": "1.14.45",
"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.44",
"version": "1.14.45",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -342,7 +342,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.14.44",
"version": "1.14.45",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -360,7 +360,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.44",
"version": "1.14.45",
"bin": {
"opencode": "./bin/opencode",
},
@@ -495,7 +495,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.44",
"version": "1.14.45",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -533,7 +533,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.44",
"version": "1.14.45",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -548,7 +548,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.44",
"version": "1.14.45",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -583,7 +583,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.44",
"version": "1.14.45",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -632,7 +632,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.44",
"version": "1.14.45",
"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.44",
"version": "1.14.45",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.44",
"version": "1.14.45",
"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.44",
"version": "1.14.45",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.44",
"version": "1.14.45",
"$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.44",
"version": "1.14.45",
"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.44",
"version": "1.14.45",
"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.44",
"version": "1.14.45",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.44",
"version": "1.14.45",
"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.44"
version = "1.14.45"
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.44/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/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.44/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/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.44/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/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.44/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.44",
"version": "1.14.45",
"$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.44",
"version": "1.14.45",
"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.44",
"version": "1.14.45",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.44",
"version": "1.14.45",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -0,0 +1,34 @@
/**
* Aggregate Promise.allSettled results into a single Error that names every
* failed endpoint, or return null when all fulfilled. Used at TUI bootstrap
* boundaries so a single 4xx doesn't drown its parallel siblings as
* unhandled rejections — every failure surfaces in one labeled message.
*/
export type LabeledSettled = {
name: string
result: PromiseSettledResult<unknown>
}
export function aggregateFailures(labeled: LabeledSettled[]): Error | null {
const failed = labeled.filter(
(x): x is { name: string; result: PromiseRejectedResult } => x.result.status === "rejected",
)
if (failed.length === 0) return null
const reasons = failed.map((f) => `${f.name}: ${reasonMessage(f.result.reason)}`).join("; ")
const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}`
const err = new Error(summary)
err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) }
return err
}
function reasonMessage(reason: unknown): string {
if (reason instanceof Error) return reason.message
if (typeof reason === "string") return reason
if (reason && typeof reason === "object") {
const obj = reason as { message?: unknown; name?: unknown }
if (typeof obj.message === "string") return obj.message
if (typeof obj.name === "string") return obj.name
}
return String(reason)
}
@@ -15,17 +15,13 @@ export function useEvent() {
// Special hack for truly global events
if (event.directory === "global") {
handler(event.payload)
return
}
// Workspace-scoped events match on workspace identity. Events without a
// workspace label fall through to the directory check — a session with
// no workspaceID can be live in the same directory as the TUI even when
// the TUI itself is attached to a workspace (#26671).
if (event.workspace !== undefined) {
if (project.workspace.current()) {
if (event.workspace === project.workspace.current()) {
handler(event.payload)
}
return
}
@@ -32,6 +32,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
import path from "path"
import { useKV } from "./kv"
import { aggregateFailures } from "./aggregate-failures"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@@ -391,16 +392,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
const blockingRequests: Promise<unknown>[] = [
providersPromise,
providerListPromise,
agentsPromise,
configPromise,
projectPromise,
...(args.continue ? [sessionListPromise] : []),
const blockingRequests: { name: string; promise: Promise<unknown> }[] = [
{ name: "config.providers", promise: providersPromise },
{ name: "provider.list", promise: providerListPromise },
{ name: "app.agents", promise: agentsPromise },
{ name: "config.get", promise: configPromise },
{ name: "project.sync", promise: projectPromise },
...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []),
]
await Promise.all(blockingRequests)
await Promise.allSettled(blockingRequests.map((r) => r.promise))
.then((settled) => {
// Surface every failed endpoint in one labeled message instead of
// letting the first rejection drown its siblings as unhandled
// rejections.
const failure = aggregateFailures(blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] })))
if (failure) throw failure
})
.then(async () => {
const providersResponse = providersPromise.then((x) => x.data!)
const providerListResponse = providerListPromise.then((x) => x.data!)
+2 -1
View File
@@ -1,6 +1,7 @@
import { Schema } from "effect"
import { zod } from "@opencode-ai/core/effect-zod"
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
import { ModelStatus } from "@/provider/model-status"
export const Model = Schema.Struct({
id: Schema.optional(Schema.String),
@@ -49,7 +50,7 @@ export const Model = Schema.Struct({
}),
),
experimental: Schema.optional(Schema.Boolean),
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
status: Schema.optional(ModelStatus),
provider: Schema.optional(
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
),
@@ -0,0 +1,9 @@
import { Schema } from "effect"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"])
export type ModelStatus = typeof ModelStatus.Type
export * as ProviderModelStatus from "./model-status"
+2 -1
View File
@@ -8,6 +8,7 @@ import { Flock } from "@opencode-ai/core/util/flock"
import { Hash } from "@opencode-ai/core/util/hash"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { CatalogModelStatus } from "./model-status"
const Cost = Schema.Struct({
input: Schema.Finite,
@@ -71,7 +72,7 @@ export const Model = Schema.Struct({
),
}),
),
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
status: Schema.optional(CatalogModelStatus),
provider: Schema.optional(
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
),
+2 -1
View File
@@ -28,6 +28,7 @@ import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema"
import * as ProviderTransform from "./transform"
import { ModelID, ProviderID } from "./schema"
import { ModelStatus } from "./model-status"
const log = Log.create({ service: "provider" })
@@ -897,7 +898,7 @@ export const Model = Schema.Struct({
capabilities: ProviderCapabilities,
cost: ProviderCost,
limit: ProviderLimit,
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
status: ModelStatus,
options: Schema.Record(Schema.String, Schema.Any),
headers: Schema.Record(Schema.String, Schema.String),
release_date: Schema.String,
+1 -1
View File
@@ -178,7 +178,7 @@ export const ReadTool = Tool.define(
yield* ctx.ask({
permission: "read",
patterns: [filepath],
patterns: [path.relative(instance.worktree, filepath)],
always: ["*"],
metadata: {},
})
+2 -1
View File
@@ -1,4 +1,5 @@
import { withStatics } from "@opencode-ai/core/schema"
import { ModelStatus } from "@/provider/model-status"
import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect"
import { DateTimeUtcFromMillis } from "effect/Schema"
@@ -114,7 +115,7 @@ export class Info extends Schema.Class<Info>("Model.Info")({
released: DateTimeUtcFromMillis,
}),
cost: Cost.pipe(Schema.Array),
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
status: ModelStatus,
limit: Schema.Struct({
context: Schema.Int,
input: Schema.Int.pipe(Schema.optional),
@@ -0,0 +1,55 @@
/**
* Regression test for the TUI bootstrap aggregation helper. Replaces the
* pre-fix Promise.all behavior where the first rejection drowned every
* sibling endpoint's failure as an unhandled rejection.
*/
import { describe, expect, test } from "bun:test"
import { aggregateFailures } from "@/cli/cmd/tui/context/aggregate-failures"
describe("aggregateFailures", () => {
test("returns null when every result is fulfilled", () => {
expect(
aggregateFailures([
{ name: "config", result: { status: "fulfilled", value: 1 } },
{ name: "providers", result: { status: "fulfilled", value: 2 } },
]),
).toBeNull()
})
test("names the failed endpoint when one rejects", () => {
const err = aggregateFailures([
{ name: "config", result: { status: "fulfilled", value: 1 } },
{
name: "providers",
result: { status: "rejected", reason: new Error("Service unavailable") },
},
])
expect(err).toBeInstanceOf(Error)
expect(err!.message).toContain("1 of 2")
expect(err!.message).toContain("providers: Service unavailable")
})
test("names every failed endpoint when multiple reject", () => {
const err = aggregateFailures([
{ name: "config", result: { status: "rejected", reason: new Error("400 Bad Request") } },
{ name: "providers", result: { status: "fulfilled", value: 1 } },
{ name: "agents", result: { status: "rejected", reason: { message: "boom" } } },
])
expect(err).toBeInstanceOf(Error)
expect(err!.message).toContain("2 of 3")
expect(err!.message).toContain("config: 400 Bad Request")
expect(err!.message).toContain("agents: boom")
})
test("attaches structured failure list under .cause", () => {
const reason = new Error("nope")
const err = aggregateFailures([{ name: "providers", result: { status: "rejected", reason } }])
const cause = err!.cause as { failures: Array<{ name: string; reason: unknown }> }
expect(cause.failures).toEqual([{ name: "providers", reason }])
})
test("falls back to String() for opaque reasons", () => {
const err = aggregateFailures([{ name: "x", result: { status: "rejected", reason: 42 } }])
expect(err!.message).toContain("x: 42")
})
})
@@ -0,0 +1,61 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { ConfigProvider } from "@/config/provider"
import { CatalogModelStatus, ModelStatus } from "@/provider/model-status"
import { ModelsDev } from "@/provider/models"
import { Provider } from "@/provider/provider"
describe("provider model status schemas", () => {
test("keeps catalog status separate from normalized provider status", () => {
expect(Schema.decodeUnknownSync(CatalogModelStatus)("deprecated")).toBe("deprecated")
expect(() => Schema.decodeUnknownSync(CatalogModelStatus)("active")).toThrow()
expect(Schema.decodeUnknownSync(ModelStatus)("active")).toBe("active")
})
test("accepts active status across public provider schemas", () => {
expect(Schema.decodeUnknownSync(ConfigProvider.Model)({ status: "active" }).status).toBe("active")
expect(
Schema.decodeUnknownSync(ModelsDev.Model)({
id: "test-model",
name: "Test Model",
release_date: "2026-01-01",
attachment: false,
reasoning: false,
temperature: true,
tool_call: true,
limit: { context: 128000, output: 8192 },
}).status,
).toBeUndefined()
expect(
Schema.decodeUnknownSync(Provider.Model)({
id: "test-model",
providerID: "test-provider",
api: {
id: "test-model",
url: "",
npm: "@ai-sdk/openai-compatible",
},
name: "Test Model",
capabilities: {
temperature: true,
reasoning: false,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
limit: { context: 128000, output: 8192 },
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
}).status,
).toBe("active")
})
})
@@ -47,4 +47,41 @@ describe("config HttpApi", () => {
lsp: false,
})
})
test("serves config with active provider model status", async () => {
await using tmp = await tmpdir({
config: {
formatter: false,
lsp: false,
provider: {
omniroute: {
models: {
"gpt-4o": {
status: "active",
},
},
},
},
},
})
const response = await app().request("/config", {
headers: {
"x-opencode-directory": tmp.path,
},
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
provider: {
omniroute: {
models: {
"gpt-4o": {
status: "active",
},
},
},
},
})
})
})
@@ -0,0 +1,189 @@
// Regression coverage for issue #26526's claim that promptAsync's
// Effect.forkIn loses the request's InstanceRef/WorkspaceRef. It does not —
// forkIn preserves Context.Reference values via standard fiber inheritance.
//
// The companion claim that the streaming prompt handler "captures and
// provides" those services is true and load-bearing: Stream.fromEffect's
// body runs detached from the request fiber's context, so the explicit
// Effect.provideService calls there are required, not defensive duplication.
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Layer, Scope } from "effect"
import * as Stream from "effect/Stream"
import { HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { mkdir } from "node:fs/promises"
import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { InstanceLayer } from "../../src/project/instance-layer"
import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project"
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
yield* Effect.promise(() => resetDatabase())
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await disposeAllInstances()
await resetDatabase()
}),
)
}),
)
const workspaceLayer = Workspace.defaultLayer.pipe(
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
)
const it = testEffect(
Layer.mergeAll(
testStateLayer,
NodeHttpServer.layerTest,
NodeServices.layer,
InstanceLayer.layer,
Project.defaultLayer,
workspaceLayer,
),
)
const instanceContextTestLayer = instanceRouterMiddleware
.combine(workspaceRouterMiddleware)
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
const localAdapter = (directory: string): WorkspaceAdapter => ({
name: "Local Test",
description: "Create a local test workspace",
configure: (info) => ({ ...info, name: "local-test", directory }),
create: async () => {
await mkdir(directory, { recursive: true })
},
async remove() {},
target: () => ({ type: "local" as const, directory }),
})
const setupWorkspace = (kind: string) =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
yield* Project.use.fromDirectory(dir)
const projectID = yield* Project.Service.use((svc) => svc.fromDirectory(dir).pipe(Effect.map((p) => p.project.id)))
registerAdapter(projectID, kind, localAdapter(dir))
const workspace = yield* Workspace.Service.use((svc) =>
svc.create({ type: kind, branch: null, extra: null, projectID }),
)
return { dir, workspace }
})
type Capture = { directory?: string; workspaceID?: string }
const captureInstance = Effect.gen(function* () {
const instance = yield* InstanceRef
const workspaceID = yield* WorkspaceRef
return { directory: instance?.directory, workspaceID } satisfies Capture
})
describe("HttpApi handler context inheritance", () => {
// Mirrors handlers/session.ts:281 promptAsync. The forked fiber inherits
// the request's Context — including InstanceRef and WorkspaceRef provided
// by InstanceContextMiddleware — without any explicit re-provide.
it.live("Effect.forkIn preserves InstanceRef/WorkspaceRef across the fork", () =>
Effect.gen(function* () {
const { dir, workspace } = yield* setupWorkspace("local-fork")
const capture = yield* Deferred.make<Capture>()
yield* HttpRouter.add(
"POST",
"/fork-probe",
Effect.gen(function* () {
const scope = yield* Scope.Scope
yield* Effect.gen(function* () {
yield* Deferred.succeed(capture, yield* captureInstance)
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
return HttpServerResponse.empty({ status: 204 })
}),
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
const response = yield* HttpClient.post(
`/fork-probe?directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`,
)
expect(response.status).toBe(204)
const observed = yield* Deferred.await(capture).pipe(Effect.timeout("2 seconds"))
expect(observed.directory).toBe(dir)
expect(observed.workspaceID).toBe(workspace.id)
}),
)
// Mirrors handlers/session.ts:255 prompt — the streaming handler reads
// InstanceRef/WorkspaceRef in the request fiber and re-provides them to
// the Stream.fromEffect body. This test locks in why the explicit
// provides are required: without them the stream body sees undefined.
it.live("Stream.fromEffect body needs explicit provides — inheritance does not carry through", () =>
Effect.gen(function* () {
const { dir, workspace } = yield* setupWorkspace("local-stream")
const withoutCapture = yield* Deferred.make<Capture>()
const withCapture = yield* Deferred.make<Capture>()
yield* HttpRouter.add(
"POST",
"/stream-probe-without",
Effect.gen(function* () {
return HttpServerResponse.stream(
Stream.fromEffect(
Effect.gen(function* () {
yield* Deferred.succeed(withoutCapture, yield* captureInstance)
return ""
}),
).pipe(Stream.encodeText),
{ contentType: "application/json" },
)
}),
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
yield* HttpRouter.add(
"POST",
"/stream-probe-with",
Effect.gen(function* () {
const instance = yield* InstanceRef
const workspaceID = yield* WorkspaceRef
return HttpServerResponse.stream(
Stream.fromEffect(
Effect.gen(function* () {
yield* Deferred.succeed(withCapture, yield* captureInstance)
return ""
}).pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspaceID)),
).pipe(Stream.encodeText),
{ contentType: "application/json" },
)
}),
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
const queryString = `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`
const responseWithout = yield* HttpClient.post(`/stream-probe-without?${queryString}`)
yield* responseWithout.text
const responseWith = yield* HttpClient.post(`/stream-probe-with?${queryString}`)
yield* responseWith.text
const without = yield* Deferred.await(withoutCapture).pipe(Effect.timeout("2 seconds"))
expect(without.directory).toBeUndefined()
expect(without.workspaceID).toBeUndefined()
const withProvide = yield* Deferred.await(withCapture).pipe(Effect.timeout("2 seconds"))
expect(withProvide.directory).toBe(dir)
expect(withProvide.workspaceID).toBe(workspace.id)
}),
)
})
@@ -1,176 +0,0 @@
/*
* Regression coverage for issue #26671 — TUI does not live-render messages when
* an external HTTP client POSTs to /session/{id}/prompt_async on the same
* server, even though the web UI renders them correctly.
*
* The hypothesis under test: prompt_async forks via Effect.forkIn(scope), and
* the forked work emits GlobalBus events (the same bus the SSE /event endpoint
* forwards). Per #26586, Effect.forkIn preserves InstanceRef/WorkspaceRef
* across the fork, so events emitted inside the fork should carry the
* request's directory and workspace.
*
* Test 1 (live request): mount the in-process server, create a session in a
* tmp directory (no workspace), POST /session/{id}/prompt_async, and assert
* that some GlobalBus event scoped to the session fires with
* { directory: <tmp.path>, workspace: undefined }. If this fails, the bug is
* in event publishing. If it passes, publishing is correct and the bug lives
* in the TUI's client-side filter.
*
* Test 2 (synthetic filter): replicate the TUI useEvent filter shape from
* packages/opencode/src/cli/cmd/tui/context/event.ts and lock in the fixed
* behaviour: events without a workspace label fall through to a directory
* comparison even when the TUI itself is attached to a workspace, so a
* directory-mode session driven by an external POST is forwarded correctly.
*/
import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as Log from "@opencode-ai/core/util/log"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { Session } from "@/session/session"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { waitGlobalBusEventPromise } from "./global-bus"
void Log.init({ print: false })
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
function app() {
return Server.Default().app
}
function pathFor(path: string, params: Record<string, string>) {
return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path)
}
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
}
function createSession(directory: string, input?: Session.CreateInput) {
return WithInstance.provide({
directory,
fn: () => runSession(Session.Service.use((svc) => svc.create(input))),
})
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await disposeAllInstances()
await resetDatabase()
})
describe("session prompt_async events (issue #26671)", () => {
test("forks publish GlobalBus events with the request's directory/workspace", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const session = await createSession(tmp.path, { title: "external prompt" })
// Subscribe BEFORE posting so we don't miss the first emit. The
// GlobalBus.on inside Effect.callback registers synchronously when the
// outer Promise is created, so kicking the request after this line is
// safe.
const eventPromise = waitGlobalBusEventPromise({
timeout: 8_000,
message: "no GlobalBus event observed for prompt_async",
// Match the first non-housekeeping event scoped to this session. The
// exact event type depends on how far the prompt gets before failing
// (no provider configured): the user-message persistence emits sync
// events, and any subsequent failure emits Session.Event.Error.
predicate: (event) => {
if (event.payload.type === "server.heartbeat") return false
if (event.payload.type === "server.connected") return false
// Attached payload always serialises the sessionID for session events.
return JSON.stringify(event.payload).includes(session.id)
},
})
const promptAsyncPath = pathFor(SessionPaths.promptAsync, { sessionID: session.id })
const response = await app().request(promptAsyncPath, {
method: "POST",
headers: {
"x-opencode-directory": tmp.path,
"content-type": "application/json",
},
body: JSON.stringify({
parts: [{ type: "text", text: "hello from external POST" }],
}),
})
// prompt_async returns 204 immediately; the work continues in the fork.
expect(response.status).toBe(204)
const event = await eventPromise
expect(event.directory).toBe(tmp.path)
expect(event.workspace).toBeUndefined()
})
test("TUI useEvent filter forwards directory-scoped events even when the TUI has an active workspace", () => {
// Mirrors the (fixed) filter at packages/opencode/src/cli/cmd/tui/context/event.ts.
// If someone reverts the fix back to an early-return in the workspace
// branch, case (c) below catches it.
type IncomingEvent = {
directory: string | undefined
workspace: string | undefined
payload: { type: string }
}
function tuiFilter(input: {
event: IncomingEvent
activeWorkspace: string | undefined
activeDirectory: string
}): boolean {
const { event, activeWorkspace, activeDirectory } = input
if (event.payload.type === "sync") return false
if (event.directory === "global") return true
if (event.workspace !== undefined) {
return event.workspace === activeWorkspace
}
return event.directory === activeDirectory
}
// (a) Directory mode: matching directory is forwarded.
expect(
tuiFilter({
event: { directory: "/proj", workspace: undefined, payload: { type: "session.next.message.created" } },
activeWorkspace: undefined,
activeDirectory: "/proj",
}),
).toBe(true)
// (b) Workspace mode: matching workspace is forwarded.
expect(
tuiFilter({
event: { directory: "/proj", workspace: "W1", payload: { type: "session.next.message.created" } },
activeWorkspace: "W1",
activeDirectory: "/proj",
}),
).toBe(true)
// (c) THE FIX FOR #26671:
// TUI is in workspace mode, the inbound event has workspace=undefined
// (the session has no workspaceID and the external POST didn't carry
// workspace context), and the directory matches. The fixed filter
// recognises that an event without a workspace label is directory-
// scoped and consults the directory comparator, so this is forwarded.
expect(
tuiFilter({
event: { directory: "/proj", workspace: undefined, payload: { type: "session.next.message.created" } },
activeWorkspace: "W1",
activeDirectory: "/proj",
}),
).toBe(true)
// (d) Cross-workspace events stay dropped.
expect(
tuiFilter({
event: { directory: "/proj", workspace: "W2", payload: { type: "session.next.message.created" } },
activeWorkspace: "W1",
activeDirectory: "/proj",
}),
).toBe(false)
})
})
@@ -394,8 +394,16 @@ describe("HttpApi SDK", () => {
const missing = yield* capture(() => sdk.session.get({ sessionID }))
const thrown = yield* captureThrown(() => sdk.session.get({ sessionID }, { throwOnError: true }))
// Result-tuple path: error body is preserved as-is so existing
// consumers reading `result.error.name` / `JSON.stringify(error)`
// keep working byte-for-byte.
expect(missing.error).toEqual(expected)
expect(thrown).toEqual(expected)
// throwOnError path: SDK wraps the body in a real Error with the
// server's message, with the original parsed body preserved under
// `.cause.body`.
expect(thrown).toBeInstanceOf(Error)
expect((thrown as Error).message).toBe(expected.data.message)
expect(((thrown as Error).cause as { body: unknown }).body).toEqual(expected)
return {
status: missing.status,
error: missing.error,
@@ -0,0 +1,74 @@
/**
* Regression tests for the SDK error shape — the v2 SDK's `throwOnError: true`
* path used to throw raw values (empty strings or POJOs from JSON-decoded
* error bodies). The TUI catches those and `e.message`/`e.stack` are
* undefined, so users see `[object Object]` or a blank crash.
*
* Both cases must throw a real `Error` instance with a non-empty `.message`
* extracted from the response body, plus `.status` and `.body` attached.
*/
import { afterEach, describe, expect, test } from "bun:test"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { resetDatabase } from "../fixture/db"
void Log.init({ print: false })
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
function client(directory: string) {
return createOpencodeClient({
baseUrl: "http://test",
directory,
fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch,
})
}
describe("v2 SDK error shape", () => {
test("404 with NamedError body throws a real Error carrying the server message", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const sdk = client(tmp.path)
let caught: unknown
try {
await sdk.session.get({ sessionID: "ses_no_such" }, { throwOnError: true })
} catch (e) {
caught = e
}
expect(caught).toBeInstanceOf(Error)
const err = caught as Error
const cause = err.cause as { body?: any; status?: number }
expect(err.message).toContain("Session not found")
expect(cause.status).toBe(404)
expect(cause.body).toMatchObject({
name: "NotFoundError",
data: { message: expect.stringContaining("Session not found") },
})
})
test("400 with empty body throws a real Error naming the status", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const sdk = client(tmp.path)
let caught: unknown
try {
// POST /sync/history with `aggregate: -1` triggers schema validation
// that returns an empty 400 body (verified via plan-mode probe).
await sdk.sync.history.list({ aggregate: -1 } as any, { throwOnError: true })
} catch (e) {
caught = e
}
expect(caught).toBeInstanceOf(Error)
const err = caught as Error
const cause = err.cause as { status?: number }
expect(err.message.length).toBeGreaterThan(0)
expect(cause.status).toBe(400)
})
})
+14 -1
View File
@@ -155,11 +155,24 @@ describe("tool.read external_directory permission", () => {
yield* exec(dir, { filePath: alt }, next)
const read = items.find((item) => item.permission === "read")
expect(read).toBeDefined()
expect(read!.patterns).toEqual([full(target)])
expect(read!.patterns).toEqual([path.relative(dir, full(target))])
}),
)
}
it.live("uses worktree-relative path for read permission so user rules match like edit/write", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
yield* put(path.join(dir, "src", "secret.ts"), "shh")
const { items, next } = asks()
yield* exec(dir, { filePath: path.join(dir, "src", "secret.ts") }, next)
const read = items.find((item) => item.permission === "read")
expect(read).toBeDefined()
expect(read!.patterns).toEqual([path.join("src", "secret.ts")])
}),
)
it.live("asks for directory-scoped external_directory permission when reading external directory", () =>
Effect.gen(function* () {
const outer = yield* tmpdirScoped()
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.14.44",
"version": "1.14.45",
"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.44",
"version": "1.14.45",
"type": "module",
"license": "MIT",
"scripts": {
+2
View File
@@ -3,6 +3,7 @@ export * from "./gen/types.gen.js"
import { createClient } from "./gen/client/client.gen.js"
import { type Config } from "./gen/client/types.gen.js"
import { OpencodeClient } from "./gen/sdk.gen.js"
import { wrapClientError } from "./error-interceptor.js"
export { type Config as OpencodeClientConfig, OpencodeClient }
function pick(value: string | null, fallback?: string) {
@@ -51,5 +52,6 @@ export function createOpencodeClient(config?: Config & { directory?: string }) {
const client = createClient(config)
client.interceptors.request.use((request) => rewrite(request, config?.directory))
client.interceptors.error.use(wrapClientError)
return new OpencodeClient({ client })
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Wrap whatever the generated client decoded from a non-2xx error body
* into a real `Error` so downstream formatters (TUI, plugins) get a
* useful `.message` instead of `[object Object]` or blank. The original
* parsed body and status live under `.cause` for callers that need
* structured fields.
*
* Only fires when the caller used `{ throwOnError: true }`. Callers that
* read `result.error` directly (the result-tuple path) get the parsed
* body unchanged so existing field-level reads (`.error.name`,
* `JSON.stringify(error)`, etc.) are byte-for-byte identical to before.
*/
export function wrapClientError(
error: unknown,
response: Response | undefined,
request: Request | undefined,
opts: { throwOnError?: boolean } | undefined,
): unknown {
if (!opts?.throwOnError) return error
if (error instanceof Error) return error
// NamedError-shaped responses (the common case for opencode 4xx) come
// through as POJOs — extract a useful message first, then wrap.
if (typeof error === "object" && error !== null && Object.keys(error).length > 0) {
const obj = error as { data?: { message?: unknown }; message?: unknown; name?: unknown }
const message =
(typeof obj.data?.message === "string" && obj.data.message) ||
(typeof obj.message === "string" && obj.message) ||
(typeof obj.name === "string" && obj.name) ||
describe(request, response)
return new Error(message, { cause: { body: error, status: response?.status } })
}
if (typeof error === "string" && error.length > 0) {
return new Error(error, { cause: { body: error, status: response?.status } })
}
// Empty body / network failure / undefined / null / empty object.
const reason = response ? "(empty response body)" : "network error (no response)"
return new Error(`opencode server ${describe(request, response)}: ${reason}`, {
cause: { body: error, status: response?.status },
})
}
function describe(request: Request | undefined, response: Response | undefined) {
const method = request?.method ?? "?"
const url = request?.url ?? "?"
const status = response?.status
const statusText = response?.statusText
return `${method} ${url}${status ? " → " + status : ""}${statusText ? " " + statusText : ""}`
}
+2 -2
View File
@@ -1065,7 +1065,7 @@ export type ProviderConfig = {
output: Array<"text" | "audio" | "image" | "video" | "pdf">
}
experimental?: boolean
status?: "alpha" | "beta" | "deprecated"
status?: "alpha" | "beta" | "deprecated" | "active"
options?: {
[key: string]: unknown
}
@@ -3012,7 +3012,7 @@ export type ProviderListResponses = {
output: Array<"text" | "audio" | "image" | "video" | "pdf">
}
experimental?: boolean
status?: "alpha" | "beta" | "deprecated"
status?: "alpha" | "beta" | "deprecated" | "active"
options: {
[key: string]: unknown
}
+2 -19
View File
@@ -3,6 +3,7 @@ export * from "./gen/types.gen.js"
import { createClient } from "./gen/client/client.gen.js"
import { type Config } from "./gen/client/types.gen.js"
import { OpencodeClient } from "./gen/sdk.gen.js"
import { wrapClientError } from "../error-interceptor.js"
export { type Config as OpencodeClientConfig, OpencodeClient }
function pick(value: string | null, fallback?: string, encode?: (value: string) => string) {
@@ -84,24 +85,6 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp
return response
})
// The generated client falls back to throwing a literal `{}` when the server
// responds with an empty / unparseable error body, which surfaces as a bare
// `{}` in TUI / CLI error output. Wrap ONLY that case in a real Error so
// downstream formatters get a useful message — but pass through any parsed
// JSON error body unchanged so existing consumers can still inspect fields.
client.interceptors.error.use((error, response, request) => {
const isEmpty =
error === undefined ||
error === null ||
error === "" ||
(typeof error === "object" && !(error instanceof Error) && Object.keys(error).length === 0)
if (!isEmpty) return error
const method = request?.method ?? "?"
const url = request?.url ?? "?"
if (!response) return new Error(`opencode server ${method} ${url}: network error (no response)`)
const status = response.status
const statusText = response.statusText ? " " + response.statusText : ""
return new Error(`opencode server ${method} ${url}${status}${statusText}: (empty response body)`)
})
client.interceptors.error.use(wrapClientError)
return new OpencodeClient({ client })
}
+1 -1
View File
@@ -1060,7 +1060,7 @@ export type ProviderConfig = {
output: Array<"text" | "audio" | "image" | "video" | "pdf">
}
experimental?: boolean
status?: "alpha" | "beta" | "deprecated"
status?: "alpha" | "beta" | "deprecated" | "active"
provider?: {
npm?: string
api?: string
+1 -1
View File
@@ -11725,7 +11725,7 @@
},
"status": {
"type": "string",
"enum": ["alpha", "beta", "deprecated"]
"enum": ["alpha", "beta", "deprecated", "active"]
},
"provider": {
"type": "object",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.14.44",
"version": "1.14.45",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.14.44",
"version": "1.14.45",
"type": "module",
"license": "MIT",
"exports": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.14.44",
"version": "1.14.45",
"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.44",
"version": "1.14.45",
"publisher": "sst-dev",
"repository": {
"type": "git",