diff --git a/packages/opencode/src/server/error.ts b/packages/opencode/src/server/error.ts index cc5fa9618..e9b6e2a39 100644 --- a/packages/opencode/src/server/error.ts +++ b/packages/opencode/src/server/error.ts @@ -31,6 +31,16 @@ export const ERRORS = { }, } as const -export function errors(...codes: number[]) { - return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]])) +export function errors(...codes: (keyof typeof ERRORS)[]) { + return Object.fromEntries( + codes.map((code) => { + const entry = ERRORS[code] + // Fail loudly instead of silently dropping the response: JSON.stringify + // omits undefined, so an unregistered code used to vanish from the spec + // (e.g. errors(409) became a no-op). Routes with bespoke error bodies + // must declare them inline rather than route them through this helper. + if (!entry) throw new Error(`errors(): no response schema registered for status ${code}`) + return [code, entry] + }), + ) } diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index 0e8d04f52..fc89be625 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -35,6 +35,16 @@ import { Env } from "@/env" const log = Log.create({ service: "server" }) const AbortSource = z.string().regex(/^[A-Za-z0-9._-]{1,80}$/) + +// tool/respond returns route-local failure bodies ({ error } for not-found / +// already-resolved, plus optional decoder { details } on 422) rather than the +// shared NotFoundError/BadRequest envelopes, so it declares them inline. The +// schema is left un-refed (no .meta ref) so it inlines directly into each +// response instead of a shared component. +const ToolRespondFailure = z.object({ + error: z.string(), + details: z.unknown().optional(), +}) const e2eSessionRoutesEnabled = () => Env.get("OPENCODE_E2E_ENABLED") === "true" && !!Env.get("OPENCODE_E2E_LLM_URL") function publishTurnChangeFiles(display: TurnChangeDisplay, mode: "undo" | "redo", mutatedPaths?: string[]) { @@ -487,7 +497,19 @@ export const SessionRoutes = lazy(() => description: "Resolved", content: { "application/json": { schema: resolver(z.object({ status: z.literal("ok") })) } }, }, - ...errors(400, 404, 409, 422), + ...errors(400), + 404: { + description: "No pending tool call for the given (session, message, call)", + content: { "application/json": { schema: resolver(ToolRespondFailure) } }, + }, + 409: { + description: "The pending tool call was already resolved", + content: { "application/json": { schema: resolver(ToolRespondFailure) } }, + }, + 422: { + description: "The submitted payload failed the tool-owned decoder", + content: { "application/json": { schema: resolver(ToolRespondFailure) } }, + }, }, }), validator("param", z.object({ sessionID: SessionID.zod })), diff --git a/packages/opencode/test/server/error.test.ts b/packages/opencode/test/server/error.test.ts new file mode 100644 index 000000000..5ee071d77 --- /dev/null +++ b/packages/opencode/test/server/error.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test" +import { errors } from "../../src/server/error" + +describe("errors() response helper", () => { + test("declares the registered status codes", () => { + expect(Object.keys(errors(400, 404))).toEqual(["400", "404"]) + }) + + test("throws on a status without a registered schema instead of silently dropping it", () => { + // JSON.stringify omits undefined, so an unregistered code used to vanish + // from the spec (errors(409) became a no-op). The helper must fail loudly. + // The signature rejects unknown codes at compile time; cast to exercise the + // runtime guard for callers that bypass the types. + const loose = errors as (...codes: number[]) => unknown + expect(() => loose(409)).toThrow(/no response schema registered for status 409/) + }) +}) diff --git a/packages/opencode/test/server/tool-respond-route.test.ts b/packages/opencode/test/server/tool-respond-route.test.ts index 1721e181f..ce015429e 100644 --- a/packages/opencode/test/server/tool-respond-route.test.ts +++ b/packages/opencode/test/server/tool-respond-route.test.ts @@ -280,4 +280,22 @@ describe("POST /session/:sessionID/tool/respond", () => { }, }) }) + + test("declares its route-local failure bodies in OpenAPI", async () => { + const spec = await Server.openapi() + const responses = spec.paths?.["/session/{sessionID}/tool/respond"]?.post?.responses + + // 404 / 409 / 422 carry the inline route-local { error, details? } body, + // not the shared NotFoundError envelope. Asserting the inline schema keeps + // this robust against component-ref registration order across the suite. + for (const status of ["404", "409", "422"] as const) { + const response = responses?.[status] + if (!response || "$ref" in response) throw new Error(`expected inline ${status} response`) + expect(response.content?.["application/json"]?.schema, status).toMatchObject({ + type: "object", + properties: { error: { type: "string" } }, + required: ["error"], + }) + } + }) })