Procházet zdrojové kódy

feat(httpapi-exercise): add .viaSdk() to drive scenarios through real SDK

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.
Kit Langton před 3 měsíci
rodič
revize
8a5d19c376

+ 3 - 0
packages/opencode/config.json

@@ -0,0 +1,3 @@
+{
+  "$schema": "https://opencode.ai/config.json"
+}

+ 42 - 3
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<unknown>,
   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<unknown>) {
+  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(

+ 19 - 0
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<S = undefined> {
     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<S>) => Promise<unknown>) {
+    return this.clone({ sdkCall })
+  }
+
   probe(authProbe: RequestSpec) {
     return this.clone({ authProbe })
   }
@@ -167,6 +184,8 @@ class ScenarioBuilder<S = undefined> {
       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"],
     }
   }
 }

+ 13 - 0
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"),

+ 12 - 0
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<typeof createOpencodeClient>
+
 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<unknown>) => Promise<unknown>
 }
 
 export type BuilderState<S> = {
@@ -102,6 +113,7 @@ export type BuilderState<S> = {
   mutates: boolean
   reset: boolean
   auth: AuthPolicy
+  sdkCall?: (sdk: Sdk, ctx: SeededContext<S>) => Promise<unknown>
 }
 
 export type TodoScenario = {