feat(aircoding): AirCoding V2 baseline — deterministic multi-agent architecture
Forked from OpenCode v1.17.4 with multi-agent system: - 5 agents: aircoding, scheduler, worker, architect, reviewer - Deterministic DAG scheduling engine (coordinator_tick) - Tool whitelists as hard enforcement - AirCoding validation plugin - V1 requirements: C4 docs, ADR, AGENTS.md, debug-log.md - Design documents in docs/
This commit is contained in:
330
packages/opencode/test/server/httpapi-query-schema-drift.test.ts
Normal file
330
packages/opencode/test/server/httpapi-query-schema-drift.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
|
||||
import {
|
||||
FilePaths,
|
||||
FileQuery,
|
||||
FindFileQuery,
|
||||
FindTextQuery,
|
||||
} from "../../src/server/routes/instance/httpapi/groups/file"
|
||||
import {
|
||||
ExperimentalPaths,
|
||||
SessionListQuery as ExperimentalSessionListQuery,
|
||||
ToolListQuery,
|
||||
} from "../../src/server/routes/instance/httpapi/groups/experimental"
|
||||
import { InstancePaths, VcsDiffQuery } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||
import {
|
||||
ListQuery as SessionListQuery,
|
||||
MessagesQuery,
|
||||
SessionPaths,
|
||||
} from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
||||
import { SessionMessagesQuery } from "@opencode-ai/server/groups/message"
|
||||
import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
|
||||
type Method = "get" | "post" | "put" | "delete" | "patch"
|
||||
type QuerySchema = { readonly fields: Record<string, unknown> }
|
||||
type OpenApiSchema = {
|
||||
readonly anyOf?: readonly OpenApiSchema[]
|
||||
readonly enum?: readonly string[]
|
||||
readonly maximum?: number
|
||||
readonly minimum?: number
|
||||
readonly pattern?: string
|
||||
readonly type?: string
|
||||
}
|
||||
type OpenApiParameter = { readonly name: string; readonly in: string; readonly schema?: OpenApiSchema }
|
||||
type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] }
|
||||
|
||||
const openApiDriftRoutes = [
|
||||
{ method: "get", path: SessionPaths.list, query: SessionListQuery },
|
||||
{ method: "get", path: SessionPaths.messages, query: MessagesQuery },
|
||||
{ method: "get", path: FilePaths.findFile, query: FindFileQuery },
|
||||
{ method: "get", path: FilePaths.findText, query: FindTextQuery },
|
||||
{ method: "get", path: FilePaths.list, query: FileQuery },
|
||||
{ method: "get", path: ExperimentalPaths.session, query: ExperimentalSessionListQuery },
|
||||
{ method: "get", path: ExperimentalPaths.tool, query: ToolListQuery },
|
||||
{ method: "get", path: InstancePaths.vcsDiff, query: VcsDiffQuery },
|
||||
{ method: "get", path: "/api/session/:sessionID/message", query: SessionMessagesQuery },
|
||||
] satisfies Array<{ method: Method; path: string; query: QuerySchema }>
|
||||
|
||||
const numericSdkQueryParams = [
|
||||
{ method: "get", path: ExperimentalPaths.session, name: "start", schema: { type: "number" } },
|
||||
{ method: "get", path: ExperimentalPaths.session, name: "cursor", schema: { type: "number" } },
|
||||
{ method: "get", path: ExperimentalPaths.session, name: "limit", schema: { type: "number" } },
|
||||
{ method: "get", path: FilePaths.findFile, name: "limit", schema: { type: "integer", minimum: 1, maximum: 200 } },
|
||||
{ method: "get", path: SessionPaths.list, name: "start", schema: { type: "number" } },
|
||||
{ method: "get", path: SessionPaths.list, name: "limit", schema: { type: "number" } },
|
||||
{
|
||||
method: "get",
|
||||
path: SessionPaths.messages,
|
||||
name: "limit",
|
||||
schema: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
|
||||
},
|
||||
{ method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } },
|
||||
] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }>
|
||||
|
||||
const booleanSdkQueryParams = [
|
||||
{ method: "get", path: ExperimentalPaths.session, name: "roots" },
|
||||
{ method: "get", path: ExperimentalPaths.session, name: "archived" },
|
||||
{ method: "get", path: SessionPaths.list, name: "roots" },
|
||||
] satisfies Array<{ method: Method; path: string; name: string }>
|
||||
|
||||
const queryParamPatterns = [
|
||||
{ method: "get", path: SessionPaths.diff, name: "messageID", pattern: "^msg" },
|
||||
] satisfies Array<{ method: Method; path: string; name: string; pattern: string }>
|
||||
|
||||
const pathParamPatterns = [
|
||||
{ method: "get", path: SessionPaths.get, name: "sessionID", pattern: "^ses" },
|
||||
{ method: "get", path: SessionPaths.message, name: "messageID", pattern: "^msg" },
|
||||
{ method: "patch", path: SessionPaths.updatePart, name: "partID", pattern: "^prt" },
|
||||
{ method: "post", path: SessionPaths.permissions, name: "permissionID", pattern: "^per" },
|
||||
{ method: "post", path: "/permission/:requestID/reply", name: "requestID", pattern: "^per" },
|
||||
{ method: "post", path: "/question/:requestID/reply", name: "requestID", pattern: "^que" },
|
||||
{ method: "put", path: PtyPaths.update, name: "ptyID", pattern: "^pty" },
|
||||
{ method: "delete", path: WorkspacePaths.remove, name: "id", pattern: "^wrk" },
|
||||
] satisfies Array<{ method: Method; path: string; name: string; pattern: string }>
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
function openApiPath(path: string) {
|
||||
return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}")
|
||||
}
|
||||
|
||||
function queryParameters(operation: OpenApiOperation | undefined) {
|
||||
return (operation?.parameters ?? []).filter((param) => param.in === "query").map((param) => param.name)
|
||||
}
|
||||
|
||||
function queryParameter(operation: OpenApiOperation | undefined, name: string) {
|
||||
return (operation?.parameters ?? []).find((param) => param.in === "query" && param.name === name)
|
||||
}
|
||||
|
||||
function pathParameter(operation: OpenApiOperation | undefined, name: string) {
|
||||
return (operation?.parameters ?? []).find((param) => param.in === "path" && param.name === name)
|
||||
}
|
||||
|
||||
function assertAdvertisedQueryParamsAreRuntimeFields(input: {
|
||||
readonly method: Method
|
||||
readonly operation: OpenApiOperation | undefined
|
||||
readonly path: string
|
||||
readonly query: QuerySchema
|
||||
}) {
|
||||
const runtimeFields = new Set(Object.keys(input.query.fields))
|
||||
const advertisedOnly = queryParameters(input.operation).filter((name) => !runtimeFields.has(name))
|
||||
|
||||
expect(
|
||||
advertisedOnly,
|
||||
`${input.method.toUpperCase()} ${input.path} advertises query params not accepted by runtime schema`,
|
||||
).toEqual([])
|
||||
}
|
||||
|
||||
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.effect(
|
||||
"boolean query schema accepts only true and false strings",
|
||||
Effect.sync(() => {
|
||||
const decode = Schema.decodeUnknownSync(QueryBoolean)
|
||||
const encode = Schema.encodeUnknownSync(QueryBoolean)
|
||||
|
||||
expect(decode("true")).toBe(true)
|
||||
expect(decode("false")).toBe(false)
|
||||
expect(encode(true)).toBe("true")
|
||||
expect(encode(false)).toBe("false")
|
||||
|
||||
for (const input of ["1", "yes", "True", "", true, false]) {
|
||||
expect(() => decode(input)).toThrow()
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"OpenAPI query params are declared by runtime query schemas",
|
||||
Effect.sync(() => {
|
||||
const spec = OpenApi.fromApi(PublicApi)
|
||||
for (const route of openApiDriftRoutes) {
|
||||
assertAdvertisedQueryParamsAreRuntimeFields({
|
||||
...route,
|
||||
operation: spec.paths[openApiPath(route.path)]?.[route.method],
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"OpenAPI query and path schemas preserve compatibility metadata",
|
||||
Effect.sync(() => {
|
||||
const spec = OpenApi.fromApi(PublicApi)
|
||||
for (const expected of numericSdkQueryParams) {
|
||||
expect(
|
||||
queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema,
|
||||
`${expected.method.toUpperCase()} ${expected.path} ${expected.name}`,
|
||||
).toEqual(expected.schema)
|
||||
}
|
||||
for (const expected of booleanSdkQueryParams) {
|
||||
expect(
|
||||
queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema,
|
||||
`${expected.method.toUpperCase()} ${expected.path} ${expected.name}`,
|
||||
).toEqual(QueryBooleanOpenApi)
|
||||
}
|
||||
for (const expected of queryParamPatterns) {
|
||||
expect(
|
||||
queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema,
|
||||
`${expected.method.toUpperCase()} ${expected.path} ${expected.name}`,
|
||||
).toEqual({ type: "string", pattern: expected.pattern })
|
||||
}
|
||||
for (const expected of pathParamPatterns) {
|
||||
expect(
|
||||
pathParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema,
|
||||
`${expected.method.toUpperCase()} ${expected.path} ${expected.name}`,
|
||||
).toEqual({ type: "string", pattern: expected.pattern })
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"drift assertion catches spec-only workspace query params",
|
||||
Effect.sync(() => {
|
||||
expect(() =>
|
||||
assertAdvertisedQueryParamsAreRuntimeFields({
|
||||
method: "get",
|
||||
operation: {
|
||||
parameters: [
|
||||
{ name: "directory", in: "query" },
|
||||
{ name: "workspace", in: "query" },
|
||||
],
|
||||
},
|
||||
path: "/fixture",
|
||||
query: Schema.Struct({}),
|
||||
}),
|
||||
).toThrow("advertises query params not accepted by runtime schema")
|
||||
}),
|
||||
)
|
||||
|
||||
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)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user