Compare commits
4 commits
dev
...
kit/migrat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4598e81fd7 | ||
|
|
f4cb3170d4 | ||
|
|
a4ead918f5 | ||
|
|
64937161aa |
6 changed files with 128 additions and 137 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -30,3 +30,4 @@ UPCOMING_CHANGELOG.md
|
||||||
logs/
|
logs/
|
||||||
*.bun-build
|
*.bun-build
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
|
/packages/opencode/config.json
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { ConfigProvider, Effect, Layer } from "effect"
|
import { ConfigProvider, Effect, Layer } from "effect"
|
||||||
import { HttpRouter } from "effect/unstable/http"
|
import { HttpRouter } from "effect/unstable/http"
|
||||||
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
import { parse } from "./assertions"
|
import { parse } from "./assertions"
|
||||||
import { runtime, type Runtime } from "./runtime"
|
import { runtime, type Runtime } from "./runtime"
|
||||||
import type { ActiveScenario, Backend, BackendApp, CallResult, CaptureMode, SeededContext } from "./types"
|
import type { ActiveScenario, Backend, BackendApp, CallResult, CaptureMode, SeededContext } from "./types"
|
||||||
|
|
@ -17,9 +18,47 @@ export function call(
|
||||||
ctx: SeededContext<unknown>,
|
ctx: SeededContext<unknown>,
|
||||||
options: CallOptions = {},
|
options: CallOptions = {},
|
||||||
) {
|
) {
|
||||||
return Effect.promise(async () =>
|
return Effect.promise(async () => {
|
||||||
capture(await app(await runtime(), backend, options).request(toRequest(scenario, ctx)), scenario.capture),
|
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(
|
export function callAuthProbe(
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type {
|
||||||
ProjectOptions,
|
ProjectOptions,
|
||||||
RequestSpec,
|
RequestSpec,
|
||||||
ScenarioContext,
|
ScenarioContext,
|
||||||
|
Sdk,
|
||||||
SeededContext,
|
SeededContext,
|
||||||
TodoScenario,
|
TodoScenario,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
@ -50,6 +51,22 @@ class ScenarioBuilder<S = undefined> {
|
||||||
return this.clone({ request })
|
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) {
|
probe(authProbe: RequestSpec) {
|
||||||
return this.clone({ authProbe })
|
return this.clone({ authProbe })
|
||||||
}
|
}
|
||||||
|
|
@ -167,6 +184,8 @@ class ScenarioBuilder<S = undefined> {
|
||||||
mutates: state.mutates,
|
mutates: state.mutates,
|
||||||
reset: state.reset,
|
reset: state.reset,
|
||||||
auth: state.auth,
|
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"],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,19 @@ const scenarios: Scenario[] = [
|
||||||
http.protected.get("/command", "command.list").json(200, array, "status"),
|
http.protected.get("/command", "command.list").json(200, array, "status"),
|
||||||
http.protected.get("/agent", "app.agents").json(200, array, "status"),
|
http.protected.get("/agent", "app.agents").json(200, array, "status"),
|
||||||
http.protected.get("/skill", "app.skills").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("/lsp", "lsp.status").json(200, array),
|
||||||
http.protected.get("/formatter", "formatter.status").json(200, array),
|
http.protected.get("/formatter", "formatter.status").json(200, array),
|
||||||
http.protected.get("/config", "config.get").json(200, undefined, "status"),
|
http.protected.get("/config", "config.get").json(200, undefined, "status"),
|
||||||
|
|
@ -1247,6 +1260,47 @@ const scenarios: Scenario[] = [
|
||||||
.probe({ path: "/global/upgrade", body: { target: 1 } })
|
.probe({ path: "/global/upgrade", body: { target: 1 } })
|
||||||
.at(() => ({ path: "/global/upgrade", body: { target: 1 } }))
|
.at(() => ({ path: "/global/upgrade", body: { target: 1 } }))
|
||||||
.status(400),
|
.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([
|
const llmScenarios = new Set([
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,20 @@
|
||||||
import type { Duration, Effect } from "effect"
|
import type { Duration, Effect } from "effect"
|
||||||
|
import type { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
import type { Config } from "../../../src/config/config"
|
import type { Config } from "../../../src/config/config"
|
||||||
import type { Project } from "../../../src/project/project"
|
import type { Project } from "../../../src/project/project"
|
||||||
import type { Worktree } from "../../../src/worktree"
|
import type { Worktree } from "../../../src/worktree"
|
||||||
import type { MessageV2 } from "../../../src/session/message-v2"
|
import type { MessageV2 } from "../../../src/session/message-v2"
|
||||||
import type { SessionID } from "../../../src/session/schema"
|
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 OpenApiMethods = ["get", "post", "put", "delete", "patch"] as const
|
||||||
export const Methods = ["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
|
mutates: boolean
|
||||||
reset: boolean
|
reset: boolean
|
||||||
auth: AuthPolicy
|
auth: AuthPolicy
|
||||||
|
sdkCall?: (sdk: Sdk, ctx: SeededContext<unknown>) => Promise<unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BuilderState<S> = {
|
export type BuilderState<S> = {
|
||||||
|
|
@ -102,6 +113,7 @@ export type BuilderState<S> = {
|
||||||
mutates: boolean
|
mutates: boolean
|
||||||
reset: boolean
|
reset: boolean
|
||||||
auth: AuthPolicy
|
auth: AuthPolicy
|
||||||
|
sdkCall?: (sdk: Sdk, ctx: SeededContext<S>) => Promise<unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TodoScenario = {
|
export type TodoScenario = {
|
||||||
|
|
|
||||||
|
|
@ -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<A, E, R>(
|
|
||||||
options: Parameters<typeof tmpdir>[0],
|
|
||||||
fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
|
|
||||||
) {
|
|
||||||
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)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue