From 64937161aaa653faa6a77d7c5328a398c5ff2a80 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 20:14:46 -0400 Subject: [PATCH 1/4] feat(httpapi-exercise): add .viaSdk() to drive scenarios through real SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exerciser harness builds requests directly as Request objects, which means it never exercises the SDK client's auto-injection of ?directory= / ?workspace= on GETs. That's structurally how the entire #26569 / #26581 family of regressions slipped through — the SDK was sending requests the typed query schemas didn't accept, but the harness was sending requests directly. Add an opt-in `.viaSdk((sdk, ctx) => sdk.X.Y(...))` builder method that runs the scenario through a real `createOpencodeClient` wired to the in-process exerciser router. The SDK applies its real request transforms so route tests catch the SDK-vs-server-shape drift class at write time. The runner normalizes the SDK's `{data, error, response}` (or thrown Error with `.cause = {body, status}`) back into the existing `CallResult` shape so all the existing assertions (`.json()`, `.status()`, `.ok()`, etc.) continue to work unchanged. Existing `.at(...)` scenarios are not touched. Convert two scenarios as proof: `app.agents.via_sdk` and `command.list.via_sdk` both pass alongside the 139 existing scenarios (141/141 PASS). Subsequent PRs will migrate the standalone `httpapi-query-schema-drift` scenarios into the exerciser using `.viaSdk(...)` and delete that file as the bug class becomes a structural guarantee inside the harness. --- packages/opencode/config.json | 3 ++ .../test/server/httpapi-exercise/backend.ts | 45 +++++++++++++++++-- .../test/server/httpapi-exercise/dsl.ts | 19 ++++++++ .../test/server/httpapi-exercise/index.ts | 13 ++++++ .../test/server/httpapi-exercise/types.ts | 12 +++++ 5 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/config.json diff --git a/packages/opencode/config.json b/packages/opencode/config.json new file mode 100644 index 0000000000..c3eb6a56fa --- /dev/null +++ b/packages/opencode/config.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://opencode.ai/config.json" +} \ No newline at end of file diff --git a/packages/opencode/test/server/httpapi-exercise/backend.ts b/packages/opencode/test/server/httpapi-exercise/backend.ts index fac5f699c3..4711b6968c 100644 --- a/packages/opencode/test/server/httpapi-exercise/backend.ts +++ b/packages/opencode/test/server/httpapi-exercise/backend.ts @@ -1,5 +1,6 @@ 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" @@ -17,9 +18,47 @@ export function call( ctx: SeededContext, options: CallOptions = {}, ) { - return Effect.promise(async () => - capture(await app(await runtime(), backend, options).request(toRequest(scenario, ctx)), scenario.capture), - ) + 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) { + 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 } } export function callAuthProbe( diff --git a/packages/opencode/test/server/httpapi-exercise/dsl.ts b/packages/opencode/test/server/httpapi-exercise/dsl.ts index 60d41576f0..13994b3339 100644 --- a/packages/opencode/test/server/httpapi-exercise/dsl.ts +++ b/packages/opencode/test/server/httpapi-exercise/dsl.ts @@ -10,6 +10,7 @@ import type { ProjectOptions, RequestSpec, ScenarioContext, + Sdk, SeededContext, TodoScenario, } from "./types" @@ -50,6 +51,22 @@ class ScenarioBuilder { 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) => Promise) { + return this.clone({ sdkCall }) + } + probe(authProbe: RequestSpec) { return this.clone({ authProbe }) } @@ -167,6 +184,8 @@ class ScenarioBuilder { 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"], } } } diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 4560973abe..8cacb47132 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -123,6 +123,19 @@ 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"), diff --git a/packages/opencode/test/server/httpapi-exercise/types.ts b/packages/opencode/test/server/httpapi-exercise/types.ts index a0466d7b70..3e65218290 100644 --- a/packages/opencode/test/server/httpapi-exercise/types.ts +++ b/packages/opencode/test/server/httpapi-exercise/types.ts @@ -1,10 +1,20 @@ 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 + export const OpenApiMethods = ["get", "post", "put", "delete", "patch"] as const export const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const @@ -88,6 +98,7 @@ export type ActiveScenario = { mutates: boolean reset: boolean auth: AuthPolicy + sdkCall?: (sdk: Sdk, ctx: SeededContext) => Promise } export type BuilderState = { @@ -102,6 +113,7 @@ export type BuilderState = { mutates: boolean reset: boolean auth: AuthPolicy + sdkCall?: (sdk: Sdk, ctx: SeededContext) => Promise } export type TodoScenario = { From a4ead918f533ffdcf0e58d55c538656e04c672fe Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 20:15:17 -0400 Subject: [PATCH 2/4] chore: drop accidentally committed test artifact --- packages/opencode/config.json | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 packages/opencode/config.json diff --git a/packages/opencode/config.json b/packages/opencode/config.json deleted file mode 100644 index c3eb6a56fa..0000000000 --- a/packages/opencode/config.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json" -} \ No newline at end of file From f4cb3170d4bac622358bbcf7e00d696fc8c2c84b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 20:27:14 -0400 Subject: [PATCH 3/4] refactor(httpapi-exercise): migrate query-schema-drift scenarios into harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the 8 standalone scenarios from packages/opencode/test/server/httpapi-query-schema-drift.test.ts into the route-coverage exerciser as `.viaSdk(...)` scenarios. Each one exercises a routing-aware GET through the real SDK client wired to the in-process router, so the SDK's auto-injected `?directory=...` / `?workspace=...` query params hit the same typed schemas as in production. Routes now covered by `.viaSdk(...)` scenarios in the exerciser: /session (session.list.via_sdk) /session/{sessionID}/message (session.messages.via_sdk) /find/file (find.files.via_sdk) /find (find.text.via_sdk) /file/content (file.read.via_sdk) /experimental/session (experimental.session.list.via_sdk) /experimental/tool (experimental.tool.list.via_sdk) /vcs/diff (vcs.diff.via_sdk) Standalone httpapi-query-schema-drift.test.ts deleted — its coverage now lives in the harness alongside every other route test, single source of truth. 149/149 scenarios pass after migration (139 existing + 2 from proof-of-concept #26604 + 8 migrated here). --- packages/opencode/config.json | 3 + .../test/server/httpapi-exercise/index.ts | 41 ++++++ .../server/httpapi-query-schema-drift.test.ts | 134 ------------------ 3 files changed, 44 insertions(+), 134 deletions(-) create mode 100644 packages/opencode/config.json delete mode 100644 packages/opencode/test/server/httpapi-query-schema-drift.test.ts diff --git a/packages/opencode/config.json b/packages/opencode/config.json new file mode 100644 index 0000000000..c3eb6a56fa --- /dev/null +++ b/packages/opencode/config.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://opencode.ai/config.json" +} \ No newline at end of file diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 8cacb47132..2911521b2d 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -1260,6 +1260,47 @@ const scenarios: Scenario[] = [ .probe({ path: "/global/upgrade", body: { target: 1 } }) .at(() => ({ path: "/global/upgrade", body: { target: 1 } })) .status(400), + + // ─── SDK routing-params drift coverage (replaces httpapi-query-schema-drift.test.ts) ─── + // Each routing-aware GET endpoint runs through the real SDK client, which + // auto-injects `?directory=...&workspace=...`. Pre-#26581 these all 4xx'd + // because the typed query schemas didn't accept the SDK's injected fields. + // Any future endpoint added under WorkspaceRoutingMiddleware that forgets + // WorkspaceRoutingQueryFields will fail its `.viaSdk(...)` scenario on + // first run. + http.protected + .get("/session", "session.list.via_sdk") + .viaSdk((sdk) => sdk.session.list({ roots: true }, { throwOnError: true })) + .json(200, array, "status"), + http.protected + .get("/session/{sessionID}/message", "session.messages.via_sdk") + .at((ctx) => ({ path: route("/session/{sessionID}/message", { sessionID: "ses_via_sdk_drift" }), headers: ctx.headers() })) + .viaSdk((sdk) => sdk.session.messages({ sessionID: "ses_via_sdk_drift", limit: 80 })) + .status(404, undefined, "status"), + http.protected + .get("/find/file", "find.files.via_sdk") + .viaSdk((sdk) => sdk.find.files({ query: "foo" }, { throwOnError: true })) + .json(200, array, "status"), + http.protected + .get("/find", "find.text.via_sdk") + .viaSdk((sdk) => sdk.find.text({ pattern: "foo" }, { throwOnError: true })) + .json(200, array, "status"), + http.protected + .get("/file/content", "file.read.via_sdk") + .viaSdk((sdk) => sdk.file.read({ path: "foo" }, { throwOnError: true })) + .json(200, undefined, "status"), + http.protected + .get("/experimental/session", "experimental.session.list.via_sdk") + .viaSdk((sdk) => sdk.experimental.session.list({}, { throwOnError: true })) + .json(200, array, "status"), + http.protected + .get("/experimental/tool", "experimental.tool.list.via_sdk") + .viaSdk((sdk) => sdk.tool.list({ provider: "anthropic", model: "claude" }, { throwOnError: true })) + .json(200, array, "status"), + http.protected + .get("/vcs/diff", "vcs.diff.via_sdk") + .viaSdk((sdk) => sdk.vcs.diff({ mode: "git" }, { throwOnError: true })) + .json(200, array, "status"), ] const llmScenarios = new Set([ diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts deleted file mode 100644 index 68daeca1e9..0000000000 --- a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { afterEach, describe, expect } from "bun:test" -import { Effect } from "effect" -import { Flag } from "@opencode-ai/core/flag/flag" -import { Server } from "../../src/server/server" -import { SessionID } from "../../src/session/schema" -import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" -import { it } from "../lib/effect" - -const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES - -function app() { - return Server.Default().app -} - -function request(url: string, init?: RequestInit) { - return Effect.promise(async () => app().request(url, init)) -} - -function withTmp( - options: Parameters[0], - fn: (tmp: Awaited>) => Effect.Effect, -) { - return Effect.acquireRelease( - Effect.promise(() => tmpdir(options)), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe(Effect.flatMap(fn)) -} - -afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces - await disposeAllInstances() - await resetDatabase() -}) - -// Regression for the "OpenAPI advertises ?directory&workspace, runtime -// rejects them" drift class. Each affected route must accept both params -// without 400. -describe("httpapi query schema drift", () => { - const routingParams = (dir: string) => - `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent("ws_test")}` - - const expectNotSchemaRejection = (status: number, url: string) => { - expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400) - } - - it.live( - "session list accepts directory and workspace", - withTmp({ config: { formatter: false, lsp: false } }, (tmp) => - Effect.gen(function* () { - const url = `/session?${routingParams(tmp.path)}` - const response = yield* request(url) - expectNotSchemaRejection(response.status, url) - }), - ), - ) - - it.live( - "session messages accepts directory and workspace", - withTmp({ config: { formatter: false, lsp: false } }, (tmp) => - Effect.gen(function* () { - const url = `/session/${SessionID.descending()}/message?limit=80&${routingParams(tmp.path)}` - const response = yield* request(url) - expectNotSchemaRejection(response.status, url) - }), - ), - ) - - it.live( - "file find/file accepts directory and workspace", - withTmp({ config: { formatter: false, lsp: false } }, (tmp) => - Effect.gen(function* () { - const url = `/find/file?query=foo&${routingParams(tmp.path)}` - const response = yield* request(url) - expectNotSchemaRejection(response.status, url) - }), - ), - ) - - it.live( - "file find/text accepts directory and workspace", - withTmp({ config: { formatter: false, lsp: false } }, (tmp) => - Effect.gen(function* () { - const url = `/find?pattern=foo&${routingParams(tmp.path)}` - const response = yield* request(url) - expectNotSchemaRejection(response.status, url) - }), - ), - ) - - it.live( - "file read accepts directory and workspace", - withTmp({ config: { formatter: false, lsp: false } }, (tmp) => - Effect.gen(function* () { - const url = `/file?path=foo&${routingParams(tmp.path)}` - const response = yield* request(url) - expectNotSchemaRejection(response.status, url) - }), - ), - ) - - it.live( - "experimental session list accepts directory and workspace", - withTmp({ config: { formatter: false, lsp: false } }, (tmp) => - Effect.gen(function* () { - const url = `/experimental/session?${routingParams(tmp.path)}` - const response = yield* request(url) - expectNotSchemaRejection(response.status, url) - }), - ), - ) - - it.live( - "experimental tool list accepts directory and workspace", - withTmp({ config: { formatter: false, lsp: false } }, (tmp) => - Effect.gen(function* () { - const url = `/experimental/tool?provider=anthropic&model=claude&${routingParams(tmp.path)}` - const response = yield* request(url) - expectNotSchemaRejection(response.status, url) - }), - ), - ) - - it.live( - "vcs diff accepts directory and workspace", - withTmp({ config: { formatter: false, lsp: false } }, (tmp) => - Effect.gen(function* () { - const url = `/vcs/diff?mode=working&${routingParams(tmp.path)}` - const response = yield* request(url) - expectNotSchemaRejection(response.status, url) - }), - ), - ) -}) From 4598e81fd733ab430365226054d2aeb0be6ed88d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 20:27:27 -0400 Subject: [PATCH 4/4] chore: gitignore config.json artifact created by httpapi-exercise --- .gitignore | 1 + packages/opencode/config.json | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 packages/opencode/config.json diff --git a/.gitignore b/.gitignore index 19198a7a59..498aca111f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ UPCOMING_CHANGELOG.md logs/ *.bun-build tsconfig.tsbuildinfo +/packages/opencode/config.json diff --git a/packages/opencode/config.json b/packages/opencode/config.json deleted file mode 100644 index c3eb6a56fa..0000000000 --- a/packages/opencode/config.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json" -} \ No newline at end of file