Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions packages/opencode/src/server/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}),
)
}
24 changes: 23 additions & 1 deletion packages/opencode/src/server/instance/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]) {
Expand Down Expand Up @@ -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 })),
Expand Down
17 changes: 17 additions & 0 deletions packages/opencode/test/server/error.test.ts
Original file line number Diff line number Diff line change
@@ -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/)
})
})
18 changes: 18 additions & 0 deletions packages/opencode/test/server/tool-respond-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
})
}
})
})
Loading