From 3c24d22d42b2791c967b571db2c6e77e68ab38c5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 22:38:32 -0400 Subject: [PATCH] fix(httpapi): omit absent optional response fields (#25214) --- packages/opencode/src/project/project.ts | 20 ++--- packages/opencode/src/provider/auth.ts | 40 ++++----- packages/opencode/src/provider/provider.ts | 12 +-- .../test/server/httpapi-json-parity.test.ts | 81 +++++++++++++++++++ 4 files changed, 118 insertions(+), 35 deletions(-) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 86208a60cd..f30d2e90c7 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -16,7 +16,7 @@ import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { zod } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema" import { serviceUse } from "@/effect/service-use" const log = Log.create({ service: "project" }) @@ -24,13 +24,13 @@ const log = Log.create({ service: "project" }) const ProjectVcs = Schema.Literal("git") const ProjectIcon = Schema.Struct({ - url: Schema.optional(Schema.String), - override: Schema.optional(Schema.String), - color: Schema.optional(Schema.String), + url: optionalOmitUndefined(Schema.String), + override: optionalOmitUndefined(Schema.String), + color: optionalOmitUndefined(Schema.String), }) const ProjectCommands = Schema.Struct({ - start: Schema.optional( + start: optionalOmitUndefined( Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }), ), }) @@ -38,16 +38,16 @@ const ProjectCommands = Schema.Struct({ const ProjectTime = Schema.Struct({ created: NonNegativeInt, updated: NonNegativeInt, - initialized: Schema.optional(NonNegativeInt), + initialized: optionalOmitUndefined(NonNegativeInt), }) export const Info = Schema.Struct({ id: ProjectID, worktree: Schema.String, - vcs: Schema.optional(ProjectVcs), - name: Schema.optional(Schema.String), - icon: Schema.optional(ProjectIcon), - commands: Schema.optional(ProjectCommands), + vcs: optionalOmitUndefined(ProjectVcs), + name: optionalOmitUndefined(Schema.String), + icon: optionalOmitUndefined(ProjectIcon), + commands: optionalOmitUndefined(ProjectCommands), time: ProjectTime, sandboxes: Schema.Array(Schema.String), }) diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 6cbfcf1be2..9b2ca33c31 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -3,7 +3,7 @@ import { Auth } from "@/auth" import { InstanceState } from "@/effect/instance-state" import { zod } from "@/util/effect-zod" import { namedSchemaError } from "@/util/named-schema-error" -import { withStatics } from "@/util/schema" +import { optionalOmitUndefined, withStatics } from "@/util/schema" import { Plugin } from "../plugin" import { ProviderID } from "./schema" import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect" @@ -18,14 +18,14 @@ const TextPrompt = Schema.Struct({ type: Schema.Literal("text"), key: Schema.String, message: Schema.String, - placeholder: Schema.optional(Schema.String), - when: Schema.optional(When), + placeholder: optionalOmitUndefined(Schema.String), + when: optionalOmitUndefined(When), }) const SelectOption = Schema.Struct({ label: Schema.String, value: Schema.String, - hint: Schema.optional(Schema.String), + hint: optionalOmitUndefined(Schema.String), }) const SelectPrompt = Schema.Struct({ @@ -33,7 +33,7 @@ const SelectPrompt = Schema.Struct({ key: Schema.String, message: Schema.String, options: Schema.Array(SelectOption), - when: Schema.optional(When), + when: optionalOmitUndefined(When), }) const Prompt = Schema.Union([TextPrompt, SelectPrompt]) @@ -41,7 +41,7 @@ const Prompt = Schema.Union([TextPrompt, SelectPrompt]) export class Method extends Schema.Class("ProviderAuthMethod")({ type: Schema.Literals(["oauth", "api"]), label: Schema.String, - prompts: Schema.optional(Schema.Array(Prompt)), + prompts: optionalOmitUndefined(Schema.Array(Prompt)), }) { static readonly zod = zod(this) } @@ -135,23 +135,25 @@ export const layer: Layer.Layer = item.methods.map((method) => ({ type: method.type, label: method.label, - prompts: method.prompts?.map((prompt) => { - if (prompt.type === "select") { + ...(method.prompts && { + prompts: method.prompts.map((prompt) => { + if (prompt.type === "select") { + return { + type: "select" as const, + key: prompt.key, + message: prompt.message, + options: prompt.options, + ...(prompt.when && { when: prompt.when }), + } + } return { - type: "select" as const, + type: "text" as const, key: prompt.key, message: prompt.message, - options: prompt.options, - when: prompt.when, + ...(prompt.placeholder && { placeholder: prompt.placeholder }), + ...(prompt.when && { when: prompt.when }), } - } - return { - type: "text" as const, - key: prompt.key, - message: prompt.message, - placeholder: prompt.placeholder, - when: prompt.when, - } + }), }), })), ), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 24b599db08..7d9806d139 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -24,7 +24,7 @@ import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { isRecord } from "@/util/record" -import { withStatics } from "@/util/schema" +import { optionalOmitUndefined, withStatics } from "@/util/schema" import * as ProviderTransform from "./transform" import { ModelID, ProviderID } from "./schema" @@ -875,7 +875,7 @@ const ProviderCost = Schema.Struct({ input: Schema.Finite, output: Schema.Finite, cache: ProviderCacheCost, - experimentalOver200K: Schema.optional( + experimentalOver200K: optionalOmitUndefined( Schema.Struct({ input: Schema.Finite, output: Schema.Finite, @@ -886,7 +886,7 @@ const ProviderCost = Schema.Struct({ const ProviderLimit = Schema.Struct({ context: Schema.Finite, - input: Schema.optional(Schema.Finite), + input: optionalOmitUndefined(Schema.Finite), output: Schema.Finite, }) @@ -895,7 +895,7 @@ export const Model = Schema.Struct({ providerID: ProviderID, api: ProviderApiInfo, name: Schema.String, - family: Schema.optional(Schema.String), + family: optionalOmitUndefined(Schema.String), capabilities: ProviderCapabilities, cost: ProviderCost, limit: ProviderLimit, @@ -903,7 +903,7 @@ export const Model = Schema.Struct({ options: Schema.Record(Schema.String, Schema.Any), headers: Schema.Record(Schema.String, Schema.String), release_date: Schema.String, - variants: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))), + variants: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))), }) .annotate({ identifier: "Model" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) @@ -914,7 +914,7 @@ export const Info = Schema.Struct({ name: Schema.String, source: Schema.Literals(["env", "config", "custom", "api"]), env: Schema.Array(Schema.String), - key: Schema.optional(Schema.String), + key: optionalOmitUndefined(Schema.String), options: Schema.Record(Schema.String, Schema.Any), models: Schema.Record(Schema.String, Model), }) diff --git a/packages/opencode/test/server/httpapi-json-parity.test.ts b/packages/opencode/test/server/httpapi-json-parity.test.ts index b88a032f5d..645e924c60 100644 --- a/packages/opencode/test/server/httpapi-json-parity.test.ts +++ b/packages/opencode/test/server/httpapi-json-parity.test.ts @@ -5,6 +5,10 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" +import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" +import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global" +import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" +import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { MessageID, PartID } from "../../src/session/schema" import { Session } from "@/session/session" @@ -89,6 +93,83 @@ afterEach(async () => { }) describe("HttpApi JSON parity", () => { + it.live( + "matches legacy JSON shape for safe GET endpoints", + withTmp( + { + git: true, + config: { + formatter: false, + lsp: false, + mcp: { + demo: { + type: "local", + command: ["echo", "demo"], + enabled: false, + }, + }, + }, + }, + (tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => Bun.write(`${tmp.path}/hello.txt`, "hello\n")) + + const headers = { "x-opencode-directory": tmp.path } + const legacy = app(false) + const httpapi = app(true) + + yield* Effect.forEach( + [ + { label: "global.health", path: GlobalPaths.health, headers: {} }, + { label: "instance.path", path: InstancePaths.path, headers }, + { label: "instance.vcs", path: InstancePaths.vcs, headers }, + { label: "instance.vcsDiff", path: `${InstancePaths.vcsDiff}?mode=git`, headers }, + { label: "instance.command", path: InstancePaths.command, headers }, + { label: "instance.agent", path: InstancePaths.agent, headers }, + { label: "instance.skill", path: InstancePaths.skill, headers }, + { label: "instance.lsp", path: InstancePaths.lsp, headers }, + { label: "instance.formatter", path: InstancePaths.formatter, headers }, + { label: "config.get", path: "/config", headers }, + { label: "config.providers", path: "/config/providers", headers }, + { label: "project.list", path: "/project", headers }, + { label: "project.current", path: "/project/current", headers }, + { label: "provider.list", path: "/provider", headers }, + { label: "provider.auth", path: "/provider/auth", headers }, + { label: "mcp.status", path: McpPaths.status, headers }, + { label: "file.list", path: `${FilePaths.list}?${new URLSearchParams({ path: "." })}`, headers }, + { + label: "file.content", + path: `${FilePaths.content}?${new URLSearchParams({ path: "hello.txt" })}`, + headers, + }, + { label: "file.status", path: FilePaths.status, headers }, + { + label: "find.file", + path: `${FilePaths.findFile}?${new URLSearchParams({ query: "hello", dirs: "false" })}`, + headers, + }, + { + label: "find.text", + path: `${FilePaths.findText}?${new URLSearchParams({ pattern: "hello" })}`, + headers, + }, + { + label: "find.symbol", + path: `${FilePaths.findSymbol}?${new URLSearchParams({ query: "hello" })}`, + headers, + }, + { label: "experimental.console", path: ExperimentalPaths.console, headers }, + { label: "experimental.consoleOrgs", path: ExperimentalPaths.consoleOrgs, headers }, + { label: "experimental.toolIDs", path: ExperimentalPaths.toolIDs, headers }, + { label: "experimental.worktree", path: ExperimentalPaths.worktree, headers }, + ], + (input) => expectJsonParity({ ...input, legacy, httpapi }), + { concurrency: 1 }, + ) + }), + ), + ) + it.live( "matches legacy JSON shape for session read endpoints", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>