From 64937161aaa653faa6a77d7c5328a398c5ff2a80 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 20:14:46 -0400 Subject: [PATCH] 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 = {