Compare commits

..
Author SHA1 Message Date
Claude 7aa35180ab fix(tui): forward directory-scoped events when TUI is in workspace mode (#26671)
The TUI's useEvent filter returned early in the workspace branch when the
inbound event's workspace did not match the TUI's active workspace —
including the common case where event.workspace was undefined because the
session has no workspaceID. The early return meant directory-scoped
events from external POSTs (web UI, SDK, curl) targeting a session
running in the same directory were silently dropped, even though the
server published them correctly with the right directory.

Treat events without a workspace label as directory-scoped and fall
through to the directory comparator so the TUI re-renders them. Also
adds an explicit return after the global-event handler so a global event
isn't forwarded twice.

Test cases (a)-(c) in httpapi-promptasync-events.test.ts now all forward;
(d) keeps cross-workspace events dropped.

https://claude.ai/code/session_011VwDFG5W32dnu1qnT3SGr1
2026-05-10 10:58:29 +00:00
Claude 4fdeb5aac2 test(server): isolate #26671 to TUI client-side event filter
prompt_async forks publish GlobalBus events with the correct
directory/workspace (proving Effect.forkIn preserves InstanceRef per
#26586). The TUI's useEvent filter, however, returns early in the
workspace branch when event.workspace is undefined, never falling through
to directory matching. Two tests document the boundary: the live request
asserts the publishing path is correct, the synthetic filter test pins
the dropped-event shape so the fix can flip a single branch.

https://claude.ai/code/session_011VwDFG5W32dnu1qnT3SGr1
2026-05-10 10:34:37 +00:00
48 changed files with 321 additions and 929 deletions
+17 -17
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.45",
"version": "1.14.44",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.45",
"version": "1.14.44",
"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.45",
"version": "1.14.44",
"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.45",
"version": "1.14.44",
"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.45",
"version": "1.14.44",
"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.45",
"version": "1.14.44",
"bin": {
"opencode": "./bin/opencode",
},
@@ -229,7 +229,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.45",
"version": "1.14.44",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -283,7 +283,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.45",
"version": "1.14.44",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -313,7 +313,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.45",
"version": "1.14.44",
"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.45",
"version": "1.14.44",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -342,7 +342,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.14.45",
"version": "1.14.44",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -360,7 +360,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.45",
"version": "1.14.44",
"bin": {
"opencode": "./bin/opencode",
},
@@ -495,7 +495,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.45",
"version": "1.14.44",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -533,7 +533,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.45",
"version": "1.14.44",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -548,7 +548,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.45",
"version": "1.14.44",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -583,7 +583,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.45",
"version": "1.14.44",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -632,7 +632,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.45",
"version": "1.14.44",
"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.45",
"version": "1.14.44",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.45",
"version": "1.14.44",
"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.45",
"version": "1.14.44",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.45",
"version": "1.14.44",
"$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.45",
"version": "1.14.44",
"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.45",
"version": "1.14.44",
"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.45",
"version": "1.14.44",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.45",
"version": "1.14.44",
"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.45"
version = "1.14.44"
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.45/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/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.45/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/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.45/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/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.45/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.45",
"version": "1.14.44",
"$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.45",
"version": "1.14.44",
"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.45",
"version": "1.14.44",
"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.45",
"version": "1.14.44",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -1,33 +0,0 @@
import type { Permission } from "../permission"
import type { Agent } from "./agent"
/**
* Build the `permission` ruleset for a subagent's session when it's spawned
* via the task tool. Combines:
*
* 1. The parent **agent's** deny rules — Plan Mode and other agent-level
* restrictions live on the agent ruleset, not on the session, so a
* subagent that only inherited the parent SESSION's permission would
* silently bypass them. (#26514)
* 2. The parent **session's** deny rules and external_directory rules —
* same forwarding the original code already did.
* 3. Default `todowrite` and `task` denies if the subagent's own ruleset
* doesn't already permit them.
*/
export function deriveSubagentSessionPermission(input: {
parentSessionPermission: Permission.Ruleset
parentAgent: Agent.Info | undefined
subagent: Agent.Info
}): Permission.Ruleset {
const canTask = input.subagent.permission.some((rule) => rule.permission === "task")
const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite")
const parentAgentDenies = input.parentAgent?.permission.filter((rule) => rule.action === "deny") ?? []
return [
...parentAgentDenies,
...input.parentSessionPermission.filter(
(rule) => rule.permission === "external_directory" || rule.action === "deny",
),
...(canTodo ? [] : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]),
...(canTask ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]),
]
}
@@ -1,34 +0,0 @@
/**
* 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,13 +15,17 @@ export function useEvent() {
// Special hack for truly global events
if (event.directory === "global") {
handler(event.payload)
return
}
if (project.workspace.current()) {
// 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 (event.workspace === project.workspace.current()) {
handler(event.payload)
}
return
}
@@ -32,7 +32,6 @@ 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",
@@ -392,23 +391,16 @@ 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: { 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 }] : []),
const blockingRequests: Promise<unknown>[] = [
providersPromise,
providerListPromise,
agentsPromise,
configPromise,
projectPromise,
...(args.continue ? [sessionListPromise] : []),
]
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
})
await Promise.all(blockingRequests)
.then(async () => {
const providersResponse = providersPromise.then((x) => x.data!)
const providerListResponse = providerListPromise.then((x) => x.data!)
+22 -61
View File
@@ -2,13 +2,10 @@ export * as ConfigParse from "./parse"
import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser"
import { Cause, Exit, Schema as EffectSchema, SchemaIssue } from "effect"
import * as Log from "@opencode-ai/core/util/log"
import z from "zod"
import type { DeepMutable } from "@opencode-ai/core/schema"
import { InvalidError, JsonError } from "./error"
const log = Log.create({ service: "config.parse" })
type ZodSchema<T> = z.ZodType<T>
export function jsonc(text: string, filepath: string): unknown {
@@ -53,70 +50,34 @@ export function effectSchema<S extends EffectSchema.Decoder<unknown, never>>(
data: unknown,
source: string,
): DeepMutable<S["Type"]> {
// The user's config lives on disk and may legitimately be stale, hand-edited,
// or carry leftover keys from older versions. Crashing the whole load on a
// single bad field would make opencode unstartable for those users (see Ben
// Matthews / Discord, v1.14.45). Strip the malformed top-level fields and
// keep going — log every drop so users can see what was ignored and fix it.
const cleaned = stripUnknownTopLevelKeys(schema, data, source)
return decodeWithFieldTolerance(schema, cleaned, source)
}
function stripUnknownTopLevelKeys(schema: EffectSchema.Top, data: unknown, source: string): unknown {
if (typeof data !== "object" || data === null || Array.isArray(data)) return data
const extra = topLevelExtraKeys(schema, data)
if (extra.length === 0) return data
log.warn("ignoring unrecognized config keys", { source, keys: extra })
const obj = data as Record<string, unknown>
return Object.fromEntries(Object.entries(obj).filter(([key]) => !extra.includes(key)))
}
if (extra.length) {
throw new InvalidError({
path: source,
issues: [
{
code: "unrecognized_keys",
keys: extra,
path: [],
message: `Unrecognized key${extra.length === 1 ? "" : "s"}: ${extra.join(", ")}`,
} as z.core.$ZodIssue,
],
})
}
function decodeWithFieldTolerance<S extends EffectSchema.Decoder<unknown, never>>(
schema: S,
data: unknown,
source: string,
): DeepMutable<S["Type"]> {
// Try a clean decode first. If it succeeds we're done — common path.
const decoded = EffectSchema.decodeUnknownExit(schema)(data, { errors: "all", propertyOrder: "original" })
if (Exit.isSuccess(decoded)) return decoded.value as DeepMutable<S["Type"]>
const error = Cause.squash(decoded.cause)
const issues = EffectSchema.isSchemaError(error)
? (SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues as z.core.$ZodIssue[])
: ([{ code: "custom", message: String(error), path: [] }] as z.core.$ZodIssue[])
// Identify malformed top-level fields. Anything with a non-empty path is a
// field-scoped issue we can drop and retry. Issues with an empty path are
// root-level (e.g. data is not an object at all) and can't be field-recovered.
const badFields = collectTopLevelFieldNames(issues)
if (badFields.size === 0 || typeof data !== "object" || data === null || Array.isArray(data)) {
throw new InvalidError({ path: source, issues }, { cause: error })
}
log.warn("ignoring invalid config fields", {
source,
fields: [...badFields],
summary: issues
.filter((issue) => issue.path && issue.path.length > 0)
.map((issue) => `${issue.path?.join(".")}: ${issue.message}`)
.slice(0, 8),
})
const obj = data as Record<string, unknown>
const cleaned = Object.fromEntries(Object.entries(obj).filter(([key]) => !badFields.has(key)))
// Retry without the bad fields. If THIS fails, we're past field-tolerance —
// fall back to the original strict error so the user sees the real cause.
const retry = EffectSchema.decodeUnknownExit(schema)(cleaned, { errors: "all", propertyOrder: "original" })
if (Exit.isSuccess(retry)) return retry.value as DeepMutable<S["Type"]>
throw new InvalidError({ path: source, issues }, { cause: error })
}
function collectTopLevelFieldNames(issues: z.core.$ZodIssue[]): Set<string> {
const names = new Set<string>()
for (const issue of issues) {
const head = issue.path?.[0]
if (typeof head === "string") names.add(head)
}
return names
throw new InvalidError(
{
path: source,
issues: EffectSchema.isSchemaError(error)
? (SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues as z.core.$ZodIssue[])
: ([{ code: "custom", message: String(error), path: [] }] as z.core.$ZodIssue[]),
},
{ cause: error },
)
}
function topLevelExtraKeys(schema: EffectSchema.Top, data: unknown) {
+1 -2
View File
@@ -1,7 +1,6 @@
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),
@@ -50,7 +49,7 @@ export const Model = Schema.Struct({
}),
),
experimental: Schema.optional(Schema.Boolean),
status: Schema.optional(ModelStatus),
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
provider: Schema.optional(
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
),
@@ -1,9 +0,0 @@
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"
+1 -2
View File
@@ -8,7 +8,6 @@ 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,
@@ -72,7 +71,7 @@ export const Model = Schema.Struct({
),
}),
),
status: Schema.optional(CatalogModelStatus),
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
provider: Schema.optional(
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
),
+1 -2
View File
@@ -28,7 +28,6 @@ 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" })
@@ -898,7 +897,7 @@ export const Model = Schema.Struct({
capabilities: ProviderCapabilities,
cost: ProviderCost,
limit: ProviderLimit,
status: ModelStatus,
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
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: [path.relative(instance.worktree, filepath)],
patterns: [filepath],
always: ["*"],
metadata: {},
})
+26 -11
View File
@@ -4,7 +4,6 @@ import { Session } from "@/session/session"
import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2"
import { Agent } from "../agent/agent"
import { deriveSubagentSessionPermission } from "../agent/subagent-permissions"
import type { SessionPrompt } from "../session/prompt"
import { Config } from "@/config/config"
import { Effect, Exit, Schema } from "effect"
@@ -59,25 +58,41 @@ export const TaskTool = Tool.define(
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
}
const canTask = next.permission.some((rule) => rule.permission === id)
const canTodo = next.permission.some((rule) => rule.permission === "todowrite")
const taskID = params.task_id
const session = taskID
? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
: undefined
const parent = yield* sessions.get(ctx.sessionID)
const parentAgent = parent.agent
? yield* agent.get(parent.agent).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
: undefined
const nextSession =
session ??
(yield* sessions.create({
parentID: ctx.sessionID,
title: params.description + ` (@${next.name} subagent)`,
permission: [
...deriveSubagentSessionPermission({
parentSessionPermission: parent.permission ?? [],
parentAgent,
subagent: next,
}),
...(parent.permission ?? []).filter(
(rule) => rule.permission === "external_directory" || rule.action === "deny",
),
...(canTodo
? []
: [
{
permission: "todowrite" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(canTask
? []
: [
{
permission: id,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(cfg.experimental?.primary_tools?.map((item) => ({
pattern: "*",
action: "allow" as const,
@@ -129,8 +144,8 @@ export const TaskTool = Tool.define(
},
agent: next.name,
tools: {
...(next.permission.some((rule) => rule.permission === "todowrite") ? {} : { todowrite: false }),
...(next.permission.some((rule) => rule.permission === id) ? {} : { task: false }),
...(canTodo ? {} : { todowrite: false }),
...(canTask ? {} : { task: false }),
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
},
parts,
+1 -2
View File
@@ -1,5 +1,4 @@
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"
@@ -115,7 +114,7 @@ export class Info extends Schema.Class<Info>("Model.Info")({
released: DateTimeUtcFromMillis,
}),
cost: Cost.pipe(Schema.Array),
status: ModelStatus,
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
limit: Schema.Struct({
context: Schema.Int,
input: Schema.Int.pipe(Schema.optional),
@@ -1,141 +0,0 @@
/**
* Reproducer for opencode issue #26514:
*
* In Plan Mode (the `plan` agent), the main agent's edit/write tools are
* blocked by the plan agent's permission ruleset (`edit: { "*": "deny" }`).
* However, when the plan agent spawns a subagent via the `task` tool, the
* subagent retains full file modification capabilities a security bypass.
*
* This test replicates the permission ruleset that would govern a
* `general` subagent when launched from a `plan` parent session, mirroring
* the logic in `src/tool/task.ts` (filtered parent permissions ++ runtime
* subagent agent permissions, evaluated as in `session/prompt.ts`).
*
* The expected (secure) behavior is that the subagent inherits the plan
* mode read-only restriction and `edit`/`write` resolve to `deny`. On
* origin/dev this assertion fails because the parent **agent** permissions
* are not propagated to the subagent only the parent **session**
* permissions are passed through, and Plan Mode's restrictions live on the
* agent, not the session.
*/
import { test, expect, afterEach } from "bun:test"
import { Effect } from "effect"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
import { WithInstance } from "../../src/project/with-instance"
import { Agent } from "../../src/agent/agent"
import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions"
import { Permission } from "../../src/permission"
afterEach(async () => {
await disposeAllInstances()
})
function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
}
// `deriveSubagentSessionPermission` is imported from production. The test
// exercises the actual helper that task.ts uses to build the subagent's
// session permission, so any regression in that helper trips this test.
test("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
const generalAgent = await load(tmp.path, (svc) => svc.get("general"))
expect(planAgent).toBeDefined()
expect(generalAgent).toBeDefined()
// Sanity: the plan agent itself blocks edit. (Note: `write` and
// `apply_patch` route through the `edit` permission at the runtime
// tool layer — see Permission.disabled / EDIT_TOOLS.)
expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny")
// Simulate the plan-mode parent session: in real flow the plan
// session's `permission` field is empty (Plan Mode lives on the agent
// ruleset, not the session). So we pass [] through as the parent
// session permission, exactly like the actual code path.
const parentSessionPermission: Permission.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,
subagent: generalAgent!,
})
// Mirror the runtime evaluation in session/prompt.ts (~line 410, 639):
// ruleset: Permission.merge(agent.permission, session.permission ?? [])
const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission)
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny")
},
})
})
test("[#26514] explore subagent launched from plan mode also stays read-only", async () => {
// Sibling check: even though `explore` is intrinsically read-only, the
// bug surface is the same. Including this case to document that the fix
// should propagate the parent **agent** permissions, not just deny edit
// when the subagent happens to already deny it.
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
const explore = await load(tmp.path, (svc) => svc.get("explore"))
expect(planAgent).toBeDefined()
expect(explore).toBeDefined()
const parentSessionPermission: Permission.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,
subagent: explore!,
})
const effective = Permission.merge(explore!.permission, subagentSessionPermission)
// Already deny — sanity check.
expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny")
},
})
})
test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only", async () => {
// The most damaging case: a user-defined subagent with default
// permissions (allow-by-default, like `general`). The subagent must NOT
// be able to edit when the parent agent is `plan`.
await using tmp = await tmpdir({
config: {
agent: {
my_subagent: {
description: "A user-defined subagent",
mode: "subagent",
},
},
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
const my = await load(tmp.path, (svc) => svc.get("my_subagent"))
expect(planAgent).toBeDefined()
expect(my).toBeDefined()
const parentSessionPermission: Permission.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,
subagent: my!,
})
const effective = Permission.merge(my!.permission, subagentSessionPermission)
// BUG: on origin/dev edit resolves to "allow" because the plan
// agent's `edit: deny *` rule never reaches the subagent.
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
},
})
})
@@ -1,55 +0,0 @@
/**
* 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")
})
})
+12 -74
View File
@@ -558,22 +558,20 @@ test("handles file inclusion with replacement tokens", async () => {
})
})
test("config loader is tolerant: drops unknown fields, keeps the rest", async () => {
test("validates config schema and throws on invalid fields", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
username: "kept",
invalid_field: "should be dropped, not crash the app",
invalid_field: "should cause error",
})
},
})
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(config.username).toBe("kept")
expect((config as Record<string, unknown>).invalid_field).toBeUndefined()
// Strict schema should throw an error for invalid fields
await expect(load()).rejects.toThrow()
},
})
})
@@ -1683,70 +1681,7 @@ test("permission config preserves user key order", async () => {
})
})
// Discord bug report (Ben Matthews, v1.14.45): a malformed `skills:` field
// (array instead of object) made the WHOLE config fail to load, the server
// returned 500, and the desktop app couldn't start. Per Kit:
// "for all of these things that we load from the user's computer, they
// should be kind of tolerant. ... It shouldn't break opencode."
// The contract: drop the malformed top-level field, log a warning, keep
// the rest of the config so the app starts.
test("config parser is tolerant: drops malformed top-level fields, keeps the rest", () => {
const config = ConfigParse.effectSchema(
Config.Info,
{
$schema: "https://opencode.ai/config.json",
username: "ben",
// Wrong shape — schema expects { paths?, urls? }, user has an array
// (looks like the LOADED skills list got pasted into the config).
skills: [
{ name: "scss-layout-accessibility", path: ".opencode/skills/scss-layout-accessibility.md" },
{ name: "testing", path: ".opencode/skills/testing.md" },
],
},
"test",
)
// Pre-fix this throws ConfigInvalidError and the user can't start opencode.
// Post-fix the bad field is dropped and the rest of the config loads.
expect(config.username).toBe("ben")
expect(config.skills).toBeUndefined()
})
test("config parser is tolerant: drops unrecognized top-level keys instead of throwing", () => {
const config = ConfigParse.effectSchema(
Config.Info,
{
$schema: "https://opencode.ai/config.json",
username: "ben",
// Typo or stale key — pre-fix this threw `unrecognized_keys`.
autoshrare: true,
},
"test",
)
expect(config.username).toBe("ben")
expect((config as Record<string, unknown>).autoshrare).toBeUndefined()
})
test("config parser is tolerant: drops multiple bad fields in one pass", () => {
const config = ConfigParse.effectSchema(
Config.Info,
{
$schema: "https://opencode.ai/config.json",
username: "ben",
skills: ["wrong shape"],
autoshare: 42, // wrong type — schema wants string literal | undefined
not_a_real_key: "ignore me",
},
"test",
)
expect(config.username).toBe("ben")
expect(config.skills).toBeUndefined()
expect(config.autoshare).toBeUndefined()
})
test("Effect config parser preserves permission order while dropping unknown top-level keys", () => {
test("Effect config parser preserves permission order while rejecting unknown top-level keys", () => {
const config = ConfigParse.effectSchema(
Config.Info,
{
@@ -1760,10 +1695,13 @@ test("Effect config parser preserves permission order while dropping unknown top
)
expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"])
// Tolerant parser: unknown keys are stripped (with a warning log) instead
// of failing the entire config load.
const stripped = ConfigParse.effectSchema(Config.Info, { invalid_field: true }, "test")
expect((stripped as Record<string, unknown>).invalid_field).toBeUndefined()
try {
ConfigParse.effectSchema(Config.Info, { invalid_field: true }, "test")
throw new Error("expected config parse to fail")
} catch (err) {
const error = err as { data?: { issues?: Array<{ code?: string; keys?: string[]; path?: string[] }> } }
expect(error.data?.issues?.[0]).toMatchObject({ code: "unrecognized_keys", keys: ["invalid_field"], path: [] })
}
})
// MCP config merging tests
@@ -1,61 +0,0 @@
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,41 +47,4 @@ 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",
},
},
},
},
})
})
})
@@ -1,189 +0,0 @@
// 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)
}),
)
})
@@ -0,0 +1,176 @@
/*
* 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,16 +394,8 @@ 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)
// 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)
expect(thrown).toEqual(expected)
return {
status: missing.status,
error: missing.error,
@@ -1,74 +0,0 @@
/**
* 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)
})
})
+1 -14
View File
@@ -155,24 +155,11 @@ 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([path.relative(dir, full(target))])
expect(read!.patterns).toEqual([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.45",
"version": "1.14.44",
"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.45",
"version": "1.14.44",
"type": "module",
"license": "MIT",
"scripts": {
-2
View File
@@ -3,7 +3,6 @@ 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) {
@@ -52,6 +51,5 @@ 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
@@ -1,51 +0,0 @@
/**
* 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" | "active"
status?: "alpha" | "beta" | "deprecated"
options?: {
[key: string]: unknown
}
@@ -3012,7 +3012,7 @@ export type ProviderListResponses = {
output: Array<"text" | "audio" | "image" | "video" | "pdf">
}
experimental?: boolean
status?: "alpha" | "beta" | "deprecated" | "active"
status?: "alpha" | "beta" | "deprecated"
options: {
[key: string]: unknown
}
+19 -2
View File
@@ -3,7 +3,6 @@ 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) {
@@ -85,6 +84,24 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp
return response
})
client.interceptors.error.use(wrapClientError)
// 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)`)
})
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" | "active"
status?: "alpha" | "beta" | "deprecated"
provider?: {
npm?: string
api?: string
+1 -1
View File
@@ -11725,7 +11725,7 @@
},
"status": {
"type": "string",
"enum": ["alpha", "beta", "deprecated", "active"]
"enum": ["alpha", "beta", "deprecated"]
},
"provider": {
"type": "object",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.14.45",
"version": "1.14.44",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.14.45",
"version": "1.14.44",
"type": "module",
"license": "MIT",
"exports": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.14.45",
"version": "1.14.44",
"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.45",
"version": "1.14.44",
"publisher": "sst-dev",
"repository": {
"type": "git",