Compare commits

..
Author SHA1 Message Date
Developer ff6f032faa refactor(mcp): use Effect-native catch + tryPromise instead of try/catch
Per CLAUDE.md style guide ("Avoid try/catch where possible") and the
opencode Effect rules ("Use Effect.tryPromise for promise-based APIs",
"Use Effect.fnUntraced for internal helpers"), replace the plain
async function + try/catch in listTools with an Effect-native
listToolsTolerant that composes via Effect.tryPromise + Effect.catch.

Also drops the no-op `Effect.map((tools) => tools)` identity in defs(),
and extracts a tiny `wrapAsError` helper to remove the duplicated
"err instanceof Error ? ... : new Error(String(err))" expression.

No behavior change. 20/20 tests still green.
2026-05-09 19:15:36 -04:00
Developer d3a69ad910 fix(mcp): tolerate invalid tool output schemas
Closes #26529.

When an MCP server returns a tool whose \`outputSchema\` contains a
broken \`$ref\` (e.g. Google Stitch's \`#/$defs/ScreenInstance\`), the
SDK's typed \`listTools()\` validator throws and opencode marks the
ENTIRE server as failed — losing every other valid tool the server
exposes.

Catch the schema-reference errors and retry with a tolerant schema
(\`looseObject\` + \`outputSchema: z.unknown().optional()\`) via the raw
\`request\` path so the bad tool's schema is accepted as opaque while
the others load normally.

Equivalent fix shape to #26530 (nicolascancino) — kept his approach
since it's correct. Bundles our reproducer test from
\`kit/issue-reproducers\` so the regression is locked in.

Verified red → green → red → green:
- pre-fix: server marked \`failed\`
- post-fix: server stays \`connected\`, valid tool present
2026-05-09 19:00:34 -04:00
Kit LangtonandDeveloper 1c3950111a test(mcp): reproducer for #26529 — outputSchema unresolved refs fail whole server 2026-05-09 18:58:33 -04:00
19 changed files with 323 additions and 395 deletions
@@ -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 -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) }),
),
+45 -5
View File
@@ -36,6 +36,24 @@ import { withStatics } from "@opencode-ai/core/schema"
const log = Log.create({ service: "mcp" })
const DEFAULT_TIMEOUT = 30_000
const TolerantToolSchema = z.looseObject({
name: z.string(),
description: z.string().optional(),
inputSchema: z
.object({
type: z.literal("object"),
properties: z.record(z.string(), z.unknown()).optional(),
required: z.array(z.string()).optional(),
})
.catchall(z.unknown()),
outputSchema: z.unknown().optional(),
})
const TolerantListToolsResultSchema = z.looseObject({
tools: z.array(TolerantToolSchema),
nextCursor: z.string().optional(),
})
export const Resource = Schema.Struct({
name: Schema.String,
uri: Schema.String,
@@ -119,6 +137,32 @@ function remoteURL(key: string, value: string) {
log.warn("invalid remote mcp url", { key })
}
function isSchemaReferenceError(err: unknown) {
return err instanceof Error && /can't resolve reference|schema.*reference|reference.*schema/i.test(err.message)
}
const wrapAsError = (err: unknown) => (err instanceof Error ? err : new Error(String(err)))
function listToolsTolerant(key: string, client: MCPClient, timeout: number) {
return Effect.tryPromise({
try: () => client.listTools(undefined, { timeout }),
catch: wrapAsError,
}).pipe(
Effect.map((result) => result.tools),
Effect.catch((err) => {
if (!isSchemaReferenceError(err)) return Effect.fail(err)
log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", {
key,
error: err,
})
return Effect.tryPromise({
try: () => client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { timeout }),
catch: wrapAsError,
}).pipe(Effect.map((result) => result.tools as MCPToolDef[]))
}),
)
}
// Convert MCP tool definition to AI SDK Tool type
function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool {
const inputSchema = mcpTool.inputSchema
@@ -151,11 +195,7 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number
}
function defs(key: string, client: MCPClient, timeout?: number) {
return Effect.tryPromise({
try: () => withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT),
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
}).pipe(
Effect.map((result) => result.tools),
return listToolsTolerant(key, client, timeout ?? DEFAULT_TIMEOUT).pipe(
Effect.catch((err) => {
log.error("failed to get tools from client", { key, error: err })
return Effect.succeed(undefined)
@@ -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,
+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")
},
})
})
@@ -0,0 +1,241 @@
// Reproducer for opencode issue #26529
//
// When an MCP server's `tools/list` response contains a tool whose
// `outputSchema` has an unresolved `$ref` (e.g. `#/$defs/ScreenInstance`),
// the MCP SDK's response validation throws on the entire `listTools()`
// call. opencode currently treats this as a fatal error and marks the
// whole server as `failed`, even though the server has other valid tools
// that should still be usable.
//
// Expected behavior: opencode should skip tools with malformed schemas
// and keep the server connected with its remaining valid tools.
import { test, expect, mock, beforeEach } from "bun:test"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { Effect } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
// --- Mock infrastructure (mirrors lifecycle.test.ts patterns) ---
interface MockClientState {
tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }>
listToolsShouldFail: boolean
listToolsError: string
notificationHandlers: Map<unknown, (...args: any[]) => any>
closed: boolean
}
const clientStates = new Map<string, MockClientState>()
let lastCreatedClientName: string | undefined
function getOrCreateClientState(name?: string): MockClientState {
const key = name ?? "default"
let state = clientStates.get(key)
if (!state) {
state = {
tools: [],
listToolsShouldFail: false,
listToolsError: "listTools failed",
notificationHandlers: new Map(),
closed: false,
}
clientStates.set(key, state)
}
return state
}
class MockStdioTransport {
stderr: null = null
pid = 12345
// oxlint-disable-next-line no-useless-constructor
constructor(_opts: any) {}
async start() {}
async close() {}
}
class MockStreamableHTTP {
// oxlint-disable-next-line no-useless-constructor
constructor(_url: URL, _opts?: any) {}
async start() {}
async close() {}
async finishAuth() {}
}
class MockSSE {
// oxlint-disable-next-line no-useless-constructor
constructor(_url: URL, _opts?: any) {}
async start() {}
async close() {}
}
void mock.module("@modelcontextprotocol/sdk/client/stdio.js", () => ({
StdioClientTransport: MockStdioTransport,
}))
void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
StreamableHTTPClientTransport: MockStreamableHTTP,
}))
void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
SSEClientTransport: MockSSE,
}))
void mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({
UnauthorizedError: class extends Error {
constructor() {
super("Unauthorized")
}
},
}))
void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: class MockClient {
_state!: MockClientState
transport: any
// oxlint-disable-next-line no-useless-constructor
constructor(_opts: any) {}
async connect(transport: { start: () => Promise<void> }) {
this.transport = transport
await transport.start()
this._state = getOrCreateClientState(lastCreatedClientName)
}
setNotificationHandler(schema: unknown, handler: (...args: any[]) => any) {
this._state?.notificationHandlers.set(schema, handler)
}
async listTools() {
if (this._state?.listToolsShouldFail) {
throw new Error(this._state.listToolsError)
}
return { tools: this._state?.tools ?? [] }
}
async request(req: { method: string }) {
// The fix retries via raw `request("tools/list", ...)` with a
// tolerant schema after the typed listTools() rejects on a bad
// outputSchema reference. The retry path bypasses the SDK's
// strict validator, so the mock returns the same tools list
// without the validation that originally threw.
if (req.method === "tools/list") return { tools: this._state?.tools ?? [] }
throw new Error(`unsupported request: ${req.method}`)
}
async listPrompts() {
return { prompts: [] }
}
async listResources() {
return { resources: [] }
}
async close() {
if (this._state) this._state.closed = true
}
},
}))
beforeEach(() => {
clientStates.clear()
lastCreatedClientName = undefined
})
const { MCP } = await import("../../src/mcp/index")
const { Instance } = await import("../../src/project/instance")
const { WithInstance } = await import("../../src/project/with-instance")
const { tmpdir } = await import("../fixture/fixture")
function withInstance(
config: Record<string, unknown>,
fn: (mcp: MCPNS.Interface) => Effect.Effect<void, unknown, never>,
) {
return async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
`${dir}/opencode.json`,
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: config,
}),
)
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer)))
await InstanceRuntime.disposeInstance(Instance.current)
},
})
}
}
// ========================================================================
// Reproducer: outputSchema with unresolved $ref fails the whole server
// ========================================================================
//
// In the real bug, the MCP SDK's response-validation layer attempts to
// resolve `$ref`s inside a tool's `outputSchema`. When a referenced
// definition is missing (e.g. `#/$defs/ScreenInstance`), validation
// throws something like:
//
// can't resolve reference #/$defs/ScreenInstance from id #
//
// `client.listTools()` therefore rejects, opencode's `defs()` catches
// the error and returns `undefined`, and `create()` then marks the whole
// MCP server as `failed` -- losing access to all other valid tools the
// server exposes.
//
// This test simulates the same failure path by making `listTools()`
// throw the same error, and asserts the server stays connected with its
// valid tool exposed.
test(
"tool with unresolved $ref in outputSchema does not fail the whole server",
withInstance(
{
"screen-server": {
type: "local",
command: ["echo", "test"],
},
},
(mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "screen-server"
const serverState = getOrCreateClientState("screen-server")
// Simulate the SDK's validation throwing on the bad outputSchema.
// This is exactly what happens in the wild when one tool in
// tools/list has an `outputSchema` like:
// { $ref: "#/$defs/ScreenInstance" }
// with no `$defs` block to resolve against.
serverState.tools = [
{
name: "good_tool",
description: "valid tool that should still load",
inputSchema: { type: "object", properties: {} },
},
{
name: "bad_tool",
description: "tool with unresolved outputSchema $ref",
inputSchema: { type: "object", properties: {} },
outputSchema: { $ref: "#/$defs/ScreenInstance" },
},
]
serverState.listToolsShouldFail = true
serverState.listToolsError = "can't resolve reference #/$defs/ScreenInstance from id #"
yield* mcp.add("screen-server", {
type: "local",
command: ["echo", "test"],
})
const status = yield* mcp.status()
// Expected: the server should remain connected because at least
// one tool (`good_tool`) has a valid schema.
expect(status["screen-server"]?.status).toBe("connected")
// Expected: the valid tool should be available even though one
// of the server's tools had a bad outputSchema.
const tools = yield* mcp.tools()
expect(Object.keys(tools).some((k) => k.includes("good_tool"))).toBe(true)
}),
),
)
@@ -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,6 +1,5 @@
import { ConfigProvider, Effect, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { parse } from "./assertions"
import { runtime, type Runtime } from "./runtime"
import type { ActiveScenario, Backend, BackendApp, CallResult, CaptureMode, SeededContext } from "./types"
@@ -18,47 +17,9 @@ export function call(
ctx: SeededContext<unknown>,
options: CallOptions = {},
) {
return Effect.promise(async () => {
const handler = app(await runtime(), backend, options)
if (scenario.sdkCall) return callViaSdk(handler, scenario, ctx)
return capture(await handler.request(toRequest(scenario, ctx)), scenario.capture)
})
}
/**
* Run the scenario through a real `createOpencodeClient` wired to the
* in-process exerciser router. The SDK applies its real request transforms
* (auto-injected `?directory=...` / `?workspace=...` on GETs, header
* rewrites, etc.), so any drift between what the SDK sends and what the
* server's typed query schemas accept fails the scenario at write time.
*/
async function callViaSdk(handler: BackendApp, scenario: ActiveScenario, ctx: SeededContext<unknown>) {
const sdk = createOpencodeClient({
baseUrl: "http://localhost",
directory: ctx.directory,
fetch: ((input: Request | URL | string, init?: RequestInit) => handler.request(input, init)) as unknown as typeof fetch,
})
let result: unknown
let thrown: unknown
try {
result = await scenario.sdkCall!(sdk, ctx)
} catch (err) {
thrown = err
}
return normalizeSdkResult(result, thrown)
}
function normalizeSdkResult(result: unknown, thrown: unknown): CallResult {
// SDK returns either { data, error, response } when not throwing, or
// throws an Error with `.cause = { body, status }` when throwOnError: true.
const tuple = result as { data?: unknown; error?: unknown; response?: Response } | undefined
const cause = (thrown as { cause?: { status?: number; body?: unknown } } | undefined)?.cause
const response = tuple?.response
const status = response?.status ?? cause?.status ?? (thrown ? 0 : 200)
const contentType = response?.headers.get("content-type") ?? "application/json"
const body = tuple?.data ?? tuple?.error ?? cause?.body ?? thrown
const text = typeof body === "string" ? body : JSON.stringify(body ?? null)
return { status, contentType, body, text, timedOut: false }
return Effect.promise(async () =>
capture(await app(await runtime(), backend, options).request(toRequest(scenario, ctx)), scenario.capture),
)
}
export function callAuthProbe(
@@ -10,7 +10,6 @@ import type {
ProjectOptions,
RequestSpec,
ScenarioContext,
Sdk,
SeededContext,
TodoScenario,
} from "./types"
@@ -51,22 +50,6 @@ class ScenarioBuilder<S = undefined> {
return this.clone({ request })
}
/**
* Run the scenario through the real SDK client wired to the in-process
* exerciser router. The SDK applies its real request transforms (for example,
* auto-injecting `?directory=...` on GETs when a directory is set) so route
* tests catch the SDK-vs-server-shape drift class at write time instead of
* regression time. Existing `.at(...)` scenarios are unchanged.
*
* The callback may return either an SDK result tuple (`{ data, error,
* response }`) or throw (use `{ throwOnError: true }`); the runner
* normalizes both into the same `CallResult` shape that `.json()` /
* `.status()` / `.ok()` already understand.
*/
viaSdk(sdkCall: (sdk: Sdk, ctx: SeededContext<S>) => Promise<unknown>) {
return this.clone({ sdkCall })
}
probe(authProbe: RequestSpec) {
return this.clone({ authProbe })
}
@@ -184,8 +167,6 @@ class ScenarioBuilder<S = undefined> {
mutates: state.mutates,
reset: state.reset,
auth: state.auth,
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired sdkCall/state type inside the builder.
sdkCall: state.sdkCall as ActiveScenario["sdkCall"],
}
}
}
@@ -123,19 +123,6 @@ const scenarios: Scenario[] = [
http.protected.get("/command", "command.list").json(200, array, "status"),
http.protected.get("/agent", "app.agents").json(200, array, "status"),
http.protected.get("/skill", "app.skills").json(200, array, "status"),
// Same /agent route exercised through the real SDK client. Catches the
// SDK-injection class of regressions (`?directory=...` auto-added on GETs)
// that the direct-Request path is structurally blind to. See #26569.
http.protected
.get("/agent", "app.agents.via_sdk")
.viaSdk((sdk) => sdk.app.agents({}, { throwOnError: true }))
.json(200, array, "status"),
// Same /command route via SDK — second proof that the directory injection
// works for any GET under workspace routing.
http.protected
.get("/command", "command.list.via_sdk")
.viaSdk((sdk) => sdk.command.list({}, { throwOnError: true }))
.json(200, array, "status"),
http.protected.get("/lsp", "lsp.status").json(200, array),
http.protected.get("/formatter", "formatter.status").json(200, array),
http.protected.get("/config", "config.get").json(200, undefined, "status"),
@@ -1,20 +1,10 @@
import type { Duration, Effect } from "effect"
import type { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { Config } from "../../../src/config/config"
import type { Project } from "../../../src/project/project"
import type { Worktree } from "../../../src/worktree"
import type { MessageV2 } from "../../../src/session/message-v2"
import type { SessionID } from "../../../src/session/schema"
/**
* The real generated SDK client used by every consumer (TUI, Desktop, plugins).
* Scenarios that opt into `.viaSdk(...)` get one of these wired to the in-process
* exerciser router so SDK request transforms (auto-injected `?directory=...`,
* header rewrites, etc.) are exercised against real handlers that's what
* catches the #26569 family.
*/
export type Sdk = ReturnType<typeof createOpencodeClient>
export const OpenApiMethods = ["get", "post", "put", "delete", "patch"] as const
export const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const
@@ -98,7 +88,6 @@ export type ActiveScenario = {
mutates: boolean
reset: boolean
auth: AuthPolicy
sdkCall?: (sdk: Sdk, ctx: SeededContext<unknown>) => Promise<unknown>
}
export type BuilderState<S> = {
@@ -113,7 +102,6 @@ export type BuilderState<S> = {
mutates: boolean
reset: boolean
auth: AuthPolicy
sdkCall?: (sdk: Sdk, ctx: SeededContext<S>) => Promise<unknown>
}
export type TodoScenario = {
+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
}
+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",