httpapi-exercise.ts 72 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014
  1. /**
  2. * End-to-end exerciser for the legacy Hono instance routes and the Effect HttpApi routes.
  3. *
  4. * The goal is not to be a normal unit test file. This is a route-coverage and parity
  5. * harness we can run while deleting Hono: every public route should eventually have a
  6. * small scenario that proves the Effect route decodes requests, uses the right instance
  7. * context, mutates storage when expected, and returns a compatible response shape.
  8. *
  9. * The script intentionally isolates `OPENCODE_DB` before importing modules that touch
  10. * storage. Scenarios may create/delete sessions and reset the database after each run,
  11. * so this must never point at a developer's real session database.
  12. *
  13. * DSL shape:
  14. * - `http.get/post/...` starts a scenario for one OpenAPI route key.
  15. * - `.seeded(...)` creates typed per-scenario state using Effect helpers on `ctx`.
  16. * - `.at(...)` builds the request from that typed state.
  17. * - `.json(...)` / `.jsonEffect(...)` assert response shape and optional side effects.
  18. * - `.mutating()` tells parity mode to run Effect and Hono in separate isolated contexts
  19. * so destructive routes compare equivalent fresh setups instead of sharing one DB.
  20. */
  21. import { Cause, ConfigProvider, Effect, Layer } from "effect"
  22. import { HttpRouter } from "effect/unstable/http"
  23. import { OpenApi } from "effect/unstable/httpapi"
  24. import { Flag } from "@opencode-ai/core/flag/flag"
  25. import { TestLLMServer } from "../test/lib/llm-server"
  26. import type { Config } from "../src/config/config"
  27. import { MessageID, PartID, type SessionID } from "../src/session/schema"
  28. import { ModelID, ProviderID } from "../src/provider/schema"
  29. import type { MessageV2 } from "../src/session/message-v2"
  30. import type { Worktree } from "../src/worktree"
  31. import type { Project } from "../src/project/project"
  32. import path from "path"
  33. const preserveExerciseGlobalRoot = !!process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL
  34. const exerciseGlobalRoot =
  35. process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL ??
  36. path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-global-${process.pid}`)
  37. process.env.XDG_DATA_HOME = path.join(exerciseGlobalRoot, "data")
  38. process.env.XDG_CONFIG_HOME = path.join(exerciseGlobalRoot, "config")
  39. process.env.XDG_STATE_HOME = path.join(exerciseGlobalRoot, "state")
  40. process.env.XDG_CACHE_HOME = path.join(exerciseGlobalRoot, "cache")
  41. process.env.OPENCODE_DISABLE_SHARE = "true"
  42. const exerciseConfigDirectory = path.join(exerciseGlobalRoot, "config", "opencode")
  43. const exerciseDataDirectory = path.join(exerciseGlobalRoot, "data", "opencode")
  44. const preserveExerciseDatabase = !!process.env.OPENCODE_HTTPAPI_EXERCISE_DB
  45. const exerciseDatabasePath =
  46. process.env.OPENCODE_HTTPAPI_EXERCISE_DB ??
  47. path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`)
  48. process.env.OPENCODE_DB = exerciseDatabasePath
  49. Flag.OPENCODE_DB = exerciseDatabasePath
  50. void (await import("@opencode-ai/core/util/log")).init({ print: false })
  51. const OpenApiMethods = ["get", "post", "put", "delete", "patch"] as const
  52. const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const
  53. const color = {
  54. dim: "\x1b[2m",
  55. green: "\x1b[32m",
  56. red: "\x1b[31m",
  57. yellow: "\x1b[33m",
  58. cyan: "\x1b[36m",
  59. reset: "\x1b[0m",
  60. }
  61. type Method = (typeof Methods)[number]
  62. type OpenApiMethod = (typeof OpenApiMethods)[number]
  63. type Mode = "effect" | "parity" | "coverage"
  64. type Backend = "effect" | "legacy"
  65. type Comparison = "none" | "status" | "json"
  66. type CaptureMode = "full" | "stream"
  67. type ProjectOptions = { git?: boolean; config?: Partial<Config.Info>; llm?: boolean }
  68. type OpenApiSpec = { paths?: Record<string, Partial<Record<OpenApiMethod, unknown>>> }
  69. type JsonObject = Record<string, unknown>
  70. type Options = {
  71. mode: Mode
  72. include: string | undefined
  73. failOnMissing: boolean
  74. failOnSkip: boolean
  75. }
  76. type RequestSpec = {
  77. path: string
  78. headers?: Record<string, string>
  79. body?: unknown
  80. }
  81. type CallResult = {
  82. status: number
  83. contentType: string
  84. body: unknown
  85. text: string
  86. }
  87. type BackendApp = {
  88. request(input: string | URL | Request, init?: RequestInit): Response | Promise<Response>
  89. }
  90. /** Effect-native helpers available while setting up and asserting a scenario. */
  91. type ScenarioContext = {
  92. directory: string | undefined
  93. headers: (extra?: Record<string, string>) => Record<string, string>
  94. file: (name: string, content: string) => Effect.Effect<void>
  95. session: (input?: { title?: string; parentID?: SessionID }) => Effect.Effect<SessionInfo>
  96. sessionGet: (sessionID: SessionID) => Effect.Effect<SessionInfo | undefined>
  97. project: () => Effect.Effect<Project.Info>
  98. message: (sessionID: SessionID, input?: { text?: string }) => Effect.Effect<MessageSeed>
  99. messages: (sessionID: SessionID) => Effect.Effect<MessageV2.WithParts[]>
  100. todos: (sessionID: SessionID, todos: TodoInfo[]) => Effect.Effect<void>
  101. worktree: (input?: { name?: string }) => Effect.Effect<Worktree.Info>
  102. worktreeRemove: (directory: string) => Effect.Effect<void>
  103. llmText: (value: string) => Effect.Effect<void>
  104. llmWait: (count: number) => Effect.Effect<void>
  105. tuiRequest: (request: { path: string; body: unknown }) => Effect.Effect<void>
  106. }
  107. /** Scenario context after `.seeded(...)`; `state` preserves the seed return type in the DSL. */
  108. type SeededContext<S> = ScenarioContext & {
  109. state: S
  110. }
  111. type Scenario = ActiveScenario | TodoScenario
  112. type ActiveScenario = {
  113. kind: "active"
  114. method: Method
  115. path: string
  116. name: string
  117. project: ProjectOptions | undefined
  118. seed: (ctx: ScenarioContext) => Effect.Effect<unknown>
  119. request: (ctx: ScenarioContext, state: unknown) => RequestSpec
  120. expect: (ctx: ScenarioContext, state: unknown, result: CallResult) => Effect.Effect<void>
  121. compare: Comparison
  122. capture: CaptureMode
  123. mutates: boolean
  124. reset: boolean
  125. }
  126. /** Internal builder state stays generic until `.json(...)` erases it into `ActiveScenario`. */
  127. type BuilderState<S> = {
  128. method: Method
  129. path: string
  130. name: string
  131. project: ProjectOptions | undefined
  132. seed: (ctx: ScenarioContext) => Effect.Effect<S>
  133. request: (ctx: SeededContext<S>) => RequestSpec
  134. capture: CaptureMode
  135. mutates: boolean
  136. reset: boolean
  137. }
  138. type TodoScenario = {
  139. kind: "todo"
  140. method: Method
  141. path: string
  142. name: string
  143. reason: string
  144. }
  145. type Result =
  146. | { status: "pass"; scenario: ActiveScenario }
  147. | { status: "fail"; scenario: ActiveScenario; message: string }
  148. | { status: "skip"; scenario: TodoScenario }
  149. type SessionInfo = { id: SessionID; title: string; parentID?: SessionID }
  150. type TodoInfo = { content: string; status: string; priority: string }
  151. type MessageSeed = { info: MessageV2.User; part: MessageV2.TextPart }
  152. const original = {
  153. OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
  154. OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
  155. OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
  156. }
  157. type Runtime = {
  158. PublicApi: (typeof import("../src/server/routes/instance/httpapi/public"))["PublicApi"]
  159. ExperimentalHttpApiServer: (typeof import("../src/server/routes/instance/httpapi/server"))["ExperimentalHttpApiServer"]
  160. Server: (typeof import("../src/server/server"))["Server"]
  161. AppLayer: (typeof import("../src/effect/app-runtime"))["AppLayer"]
  162. InstanceRef: (typeof import("../src/effect/instance-ref"))["InstanceRef"]
  163. Instance: (typeof import("../src/project/instance"))["Instance"]
  164. InstanceStore: (typeof import("../src/project/instance-store"))["InstanceStore"]
  165. Session: (typeof import("../src/session/session"))["Session"]
  166. Todo: (typeof import("../src/session/todo"))["Todo"]
  167. Worktree: (typeof import("../src/worktree"))["Worktree"]
  168. Project: (typeof import("../src/project/project"))["Project"]
  169. Tui: typeof import("../src/server/shared/tui-control")
  170. disposeAllInstances: (typeof import("../test/fixture/fixture"))["disposeAllInstances"]
  171. tmpdir: (typeof import("../test/fixture/fixture"))["tmpdir"]
  172. resetDatabase: (typeof import("../test/fixture/db"))["resetDatabase"]
  173. }
  174. let runtimePromise: Promise<Runtime> | undefined
  175. function runtime() {
  176. return (runtimePromise ??= (async () => {
  177. const publicApi = await import("../src/server/routes/instance/httpapi/public")
  178. const httpApiServer = await import("../src/server/routes/instance/httpapi/server")
  179. const server = await import("../src/server/server")
  180. const appRuntime = await import("../src/effect/app-runtime")
  181. const instanceRef = await import("../src/effect/instance-ref")
  182. const instance = await import("../src/project/instance")
  183. const instanceStore = await import("../src/project/instance-store")
  184. const session = await import("../src/session/session")
  185. const todo = await import("../src/session/todo")
  186. const worktree = await import("../src/worktree")
  187. const project = await import("../src/project/project")
  188. const tui = await import("../src/server/shared/tui-control")
  189. const fixture = await import("../test/fixture/fixture")
  190. const db = await import("../test/fixture/db")
  191. return {
  192. PublicApi: publicApi.PublicApi,
  193. ExperimentalHttpApiServer: httpApiServer.ExperimentalHttpApiServer,
  194. Server: server.Server,
  195. AppLayer: appRuntime.AppLayer,
  196. InstanceRef: instanceRef.InstanceRef,
  197. Instance: instance.Instance,
  198. InstanceStore: instanceStore.InstanceStore,
  199. Session: session.Session,
  200. Todo: todo.Todo,
  201. Worktree: worktree.Worktree,
  202. Project: project.Project,
  203. Tui: tui,
  204. disposeAllInstances: fixture.disposeAllInstances,
  205. tmpdir: fixture.tmpdir,
  206. resetDatabase: db.resetDatabase,
  207. }
  208. })())
  209. }
  210. class ScenarioBuilder<S = undefined> {
  211. private readonly state: BuilderState<S>
  212. constructor(method: Method, path: string, name: string) {
  213. this.state = {
  214. method,
  215. path,
  216. name,
  217. project: { git: true },
  218. seed: () => Effect.succeed(undefined as S),
  219. request: (ctx) => ({ path, headers: ctx.headers() }),
  220. capture: "full",
  221. mutates: false,
  222. reset: true,
  223. }
  224. }
  225. global() {
  226. return this.clone({ project: undefined, request: () => ({ path: this.state.path }) })
  227. }
  228. inProject(project: ProjectOptions = { git: true }) {
  229. return this.clone({ project })
  230. }
  231. withLlm() {
  232. return this.clone({ project: { ...(this.state.project ?? { git: true }), llm: true } })
  233. }
  234. at(request: BuilderState<S>["request"]) {
  235. return this.clone({ request })
  236. }
  237. mutating() {
  238. return this.clone({ mutates: true })
  239. }
  240. preserveDatabase() {
  241. return this.clone({ reset: false })
  242. }
  243. stream() {
  244. return this.clone({ capture: "stream" })
  245. }
  246. /** Assert a non-JSON or shape-only response. */
  247. ok(status = 200, compare: Comparison = "status") {
  248. return this.done(compare, (_ctx, result) =>
  249. Effect.sync(() => {
  250. if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`)
  251. }),
  252. )
  253. }
  254. status(
  255. status = 200,
  256. inspect?: (ctx: SeededContext<S>, result: CallResult) => Effect.Effect<void>,
  257. compare: Comparison = "status",
  258. ) {
  259. return this.done(compare, (ctx, result) =>
  260. Effect.gen(function* () {
  261. if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`)
  262. if (inspect) yield* inspect(ctx, result)
  263. }),
  264. )
  265. }
  266. /** Assert JSON status/content-type plus an optional synchronous body check. */
  267. json(status = 200, inspect?: (body: unknown, ctx: SeededContext<S>) => void, compare: Comparison = "json") {
  268. return this.jsonEffect(status, inspect ? (body, ctx) => Effect.sync(() => inspect(body, ctx)) : undefined, compare)
  269. }
  270. /** Assert JSON status/content-type plus optional Effect assertions, e.g. DB side effects. */
  271. jsonEffect(
  272. status = 200,
  273. inspect?: (body: unknown, ctx: SeededContext<S>) => Effect.Effect<void>,
  274. compare: Comparison = "json",
  275. ) {
  276. return this.done(compare, (ctx, result) =>
  277. Effect.gen(function* () {
  278. if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`)
  279. if (!looksJson(result))
  280. throw new Error(`expected JSON response, got ${result.contentType || "no content-type"}`)
  281. if (inspect) yield* inspect(result.body, ctx)
  282. }),
  283. )
  284. }
  285. private clone(next: Partial<BuilderState<S>>) {
  286. const builder = new ScenarioBuilder<S>(this.state.method, this.state.path, this.state.name)
  287. Object.assign(builder.state, this.state, next)
  288. return builder
  289. }
  290. /**
  291. * Seed typed state before the HTTP request. The returned value becomes `ctx.state`
  292. * for `.at(...)` and assertions, giving stateful route tests type-safe setup.
  293. */
  294. seeded<Next>(seed: (ctx: ScenarioContext) => Effect.Effect<Next>) {
  295. const builder = new ScenarioBuilder<Next>(this.state.method, this.state.path, this.state.name)
  296. Object.assign(builder.state, this.state, { seed })
  297. return builder
  298. }
  299. private done(
  300. compare: Comparison,
  301. expect: (ctx: SeededContext<S>, result: CallResult) => Effect.Effect<void>,
  302. ): ActiveScenario {
  303. const state = this.state
  304. return {
  305. kind: "active",
  306. method: state.method,
  307. path: state.path,
  308. name: state.name,
  309. project: state.project,
  310. seed: state.seed,
  311. request: (ctx, seeded) => state.request({ ...ctx, state: seeded as S }),
  312. expect: (ctx, seeded, result) => expect({ ...ctx, state: seeded as S }, result),
  313. compare,
  314. capture: state.capture,
  315. mutates: state.mutates,
  316. reset: state.reset,
  317. }
  318. }
  319. }
  320. const http = {
  321. get: (path: string, name: string) => new ScenarioBuilder("GET", path, name),
  322. post: (path: string, name: string) => new ScenarioBuilder("POST", path, name),
  323. put: (path: string, name: string) => new ScenarioBuilder("PUT", path, name),
  324. patch: (path: string, name: string) => new ScenarioBuilder("PATCH", path, name),
  325. delete: (path: string, name: string) => new ScenarioBuilder("DELETE", path, name),
  326. }
  327. const pending = (method: Method, path: string, name: string, reason: string): TodoScenario => ({
  328. kind: "todo",
  329. method,
  330. path,
  331. name,
  332. reason,
  333. })
  334. function route(template: string, params: Record<string, string>) {
  335. return Object.entries(params).reduce(
  336. (next, [key, value]) => next.replaceAll(`{${key}}`, value).replaceAll(`:${key}`, value),
  337. template,
  338. )
  339. }
  340. const scenarios: Scenario[] = [
  341. http
  342. .get("/global/health", "global.health")
  343. .global()
  344. .json(200, (body) => {
  345. object(body)
  346. check(body.healthy === true, "server should report healthy")
  347. }),
  348. http
  349. .get("/global/event", "global.event")
  350. .global()
  351. .stream()
  352. .status(
  353. 200,
  354. (_ctx, result) =>
  355. Effect.sync(() => {
  356. check(result.contentType.includes("text/event-stream"), "global event should be an SSE stream")
  357. check(result.text.includes("server.connected"), "global event should emit initial connection event")
  358. }),
  359. "status",
  360. ),
  361. http.get("/global/config", "global.config.get").global().json(),
  362. http
  363. .patch("/global/config", "global.config.update")
  364. .global()
  365. .seeded(() =>
  366. Effect.promise(() =>
  367. Bun.write(
  368. path.join(exerciseConfigDirectory, "opencode.jsonc"),
  369. JSON.stringify({ username: "httpapi-global" }, null, 2),
  370. ),
  371. ),
  372. )
  373. .at(() => ({ path: "/global/config", body: { username: "httpapi-global" } }))
  374. .jsonEffect(
  375. 200,
  376. (body) =>
  377. Effect.gen(function* () {
  378. object(body)
  379. check(body.username === "httpapi-global", "global config update should return patched config")
  380. const text = yield* Effect.promise(() =>
  381. Bun.file(path.join(exerciseConfigDirectory, "opencode.jsonc")).text(),
  382. )
  383. check(text.includes('"username": "httpapi-global"'), "global config update should write isolated config file")
  384. }),
  385. "status",
  386. ),
  387. http
  388. .post("/global/dispose", "global.dispose")
  389. .global()
  390. .mutating()
  391. .json(
  392. 200,
  393. (body) => {
  394. check(body === true, "global dispose should return true")
  395. },
  396. "status",
  397. ),
  398. http.get("/path", "path.get").json(200, (body, ctx) => {
  399. object(body)
  400. check(body.directory === ctx.directory, "directory should resolve from x-opencode-directory")
  401. check(body.worktree === ctx.directory, "worktree should resolve from x-opencode-directory")
  402. }),
  403. http.get("/vcs", "vcs.get").json(),
  404. http
  405. .get("/vcs/diff", "vcs.diff")
  406. .at((ctx) => ({ path: "/vcs/diff?mode=git", headers: ctx.headers() }))
  407. .json(200, array),
  408. http.get("/command", "command.list").json(200, array, "status"),
  409. http.get("/agent", "app.agents").json(200, array, "status"),
  410. http.get("/skill", "app.skills").json(200, array, "status"),
  411. http.get("/lsp", "lsp.status").json(200, array),
  412. http.get("/formatter", "formatter.status").json(200, array),
  413. http.get("/config", "config.get").json(200, undefined, "status"),
  414. http
  415. .patch("/config", "config.update")
  416. .mutating()
  417. .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: "httpapi-local" } }))
  418. .json(
  419. 200,
  420. (body) => {
  421. object(body)
  422. check(body.username === "httpapi-local", "local config update should return patched config")
  423. },
  424. "status",
  425. ),
  426. http
  427. .patch("/config", "config.update.invalid")
  428. .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: 1 } }))
  429. .status(400),
  430. http.get("/config/providers", "config.providers").json(),
  431. http.get("/project", "project.list").json(200, array, "status"),
  432. http.get("/project/current", "project.current").json(
  433. 200,
  434. (body, ctx) => {
  435. object(body)
  436. check(body.worktree === ctx.directory, "current project should resolve from scenario directory")
  437. },
  438. "status",
  439. ),
  440. http
  441. .patch("/project/{projectID}", "project.update")
  442. .mutating()
  443. .seeded((ctx) => ctx.project())
  444. .at((ctx) => ({
  445. path: route("/project/{projectID}", { projectID: ctx.state.id }),
  446. headers: ctx.headers(),
  447. body: { name: "HTTP API Project", commands: { start: "bun --version" } },
  448. }))
  449. .json(
  450. 200,
  451. (body) => {
  452. object(body)
  453. check(body.name === "HTTP API Project", "project update should return patched name")
  454. check(
  455. isRecord(body.commands) && body.commands.start === "bun --version",
  456. "project update should return patched command",
  457. )
  458. },
  459. "status",
  460. ),
  461. http
  462. .post("/project/git/init", "project.initGit")
  463. .mutating()
  464. .inProject({ git: false })
  465. .json(
  466. 200,
  467. (body, ctx) => {
  468. object(body)
  469. check(body.worktree === ctx.directory, "git init should return current project")
  470. check(body.vcs === "git", "git init should mark the project as git-backed")
  471. },
  472. "status",
  473. ),
  474. http.get("/provider", "provider.list").json(),
  475. http.get("/provider/auth", "provider.auth").json(),
  476. http
  477. .post("/provider/{providerID}/oauth/authorize", "provider.oauth.authorize")
  478. .at((ctx) => ({
  479. path: route("/provider/{providerID}/oauth/authorize", { providerID: "httpapi" }),
  480. headers: ctx.headers(),
  481. body: { method: "bad" },
  482. }))
  483. .status(400),
  484. http
  485. .post("/provider/{providerID}/oauth/callback", "provider.oauth.callback")
  486. .at((ctx) => ({
  487. path: route("/provider/{providerID}/oauth/callback", { providerID: "httpapi" }),
  488. headers: ctx.headers(),
  489. body: { method: "bad" },
  490. }))
  491. .status(400),
  492. http.get("/permission", "permission.list").json(200, array),
  493. http
  494. .post("/permission/{requestID}/reply", "permission.reply.invalid")
  495. .at((ctx) => ({
  496. path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }),
  497. headers: ctx.headers(),
  498. body: { reply: "bad" },
  499. }))
  500. .status(400),
  501. http
  502. .post("/permission/{requestID}/reply", "permission.reply")
  503. .at((ctx) => ({
  504. path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }),
  505. headers: ctx.headers(),
  506. body: { reply: "once" },
  507. }))
  508. .json(200, (body) => {
  509. check(body === true, "permission reply should return true even when request is no longer pending")
  510. }),
  511. http.get("/question", "question.list").json(200, array),
  512. http
  513. .post("/question/{requestID}/reply", "question.reply.invalid")
  514. .at((ctx) => ({
  515. path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }),
  516. headers: ctx.headers(),
  517. body: { answers: "Yes" },
  518. }))
  519. .status(400),
  520. http
  521. .post("/question/{requestID}/reply", "question.reply")
  522. .at((ctx) => ({
  523. path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }),
  524. headers: ctx.headers(),
  525. body: { answers: [["Yes"]] },
  526. }))
  527. .json(200, (body) => {
  528. check(body === true, "question reply should return true even when request is no longer pending")
  529. }),
  530. http
  531. .post("/question/{requestID}/reject", "question.reject")
  532. .at((ctx) => ({
  533. path: route("/question/{requestID}/reject", { requestID: "que_httpapi_reject" }),
  534. headers: ctx.headers(),
  535. }))
  536. .json(200, (body) => {
  537. check(body === true, "question reject should return true even when request is no longer pending")
  538. }),
  539. http
  540. .get("/file", "file.list")
  541. .seeded((ctx) => ctx.file("hello.txt", "hello\n"))
  542. .at((ctx) => ({ path: `/file?${new URLSearchParams({ path: "." })}`, headers: ctx.headers() }))
  543. .json(200, array),
  544. http
  545. .get("/file/content", "file.read")
  546. .seeded((ctx) => ctx.file("hello.txt", "hello\n"))
  547. .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "hello.txt" })}`, headers: ctx.headers() }))
  548. .json(200, (body) => {
  549. object(body)
  550. check(body.content === "hello", `content should match seeded file: ${JSON.stringify(body)}`)
  551. }),
  552. http
  553. .get("/file/content", "file.read.missing")
  554. .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "missing.txt" })}`, headers: ctx.headers() }))
  555. .json(200, (body) => {
  556. object(body)
  557. check(body.type === "text" && body.content === "", "missing file content should return an empty text result")
  558. }),
  559. http.get("/file/status", "file.status").json(200, array),
  560. http
  561. .get("/find", "find.text")
  562. .seeded((ctx) => ctx.file("hello.txt", "hello\n"))
  563. .at((ctx) => ({ path: `/find?${new URLSearchParams({ pattern: "hello" })}`, headers: ctx.headers() }))
  564. .json(200, array),
  565. http
  566. .get("/find/file", "find.files")
  567. .seeded((ctx) => ctx.file("hello.txt", "hello\n"))
  568. .at((ctx) => ({
  569. path: `/find/file?${new URLSearchParams({ query: "hello", dirs: "false" })}`,
  570. headers: ctx.headers(),
  571. }))
  572. .json(200, array),
  573. http
  574. .get("/find/symbol", "find.symbols")
  575. .seeded((ctx) => ctx.file("hello.ts", "export const hello = 1\n"))
  576. .at((ctx) => ({ path: `/find/symbol?${new URLSearchParams({ query: "hello" })}`, headers: ctx.headers() }))
  577. .json(200, array),
  578. http
  579. .get("/event", "event.stream")
  580. .stream()
  581. .status(
  582. 200,
  583. (_ctx, result) =>
  584. Effect.sync(() => {
  585. check(result.contentType.includes("text/event-stream"), "event should be an SSE stream")
  586. check(result.text.includes("server.connected"), "event should emit initial connection event")
  587. }),
  588. "status",
  589. ),
  590. http.get("/mcp", "mcp.status").json(),
  591. http
  592. .post("/mcp", "mcp.add")
  593. .mutating()
  594. .at((ctx) => ({
  595. path: "/mcp",
  596. headers: ctx.headers(),
  597. body: { name: "httpapi-disabled", config: { type: "local", command: ["bun", "--version"], enabled: false } },
  598. }))
  599. .json(
  600. 200,
  601. (body) => {
  602. object(body)
  603. object(body["httpapi-disabled"])
  604. check(body["httpapi-disabled"].status === "disabled", "disabled MCP server should be added without spawning")
  605. },
  606. "status",
  607. ),
  608. http
  609. .post("/mcp", "mcp.add.invalid")
  610. .at((ctx) => ({
  611. path: "/mcp",
  612. headers: ctx.headers(),
  613. body: { name: "httpapi-invalid", config: { type: "invalid" } },
  614. }))
  615. .status(400),
  616. http
  617. .post("/mcp/{name}/auth", "mcp.auth.start")
  618. .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() }))
  619. .json(
  620. 400,
  621. (body) => {
  622. object(body)
  623. check(typeof body.error === "string", "unsupported MCP OAuth response should include error")
  624. },
  625. "status",
  626. ),
  627. http
  628. .delete("/mcp/{name}/auth", "mcp.auth.remove")
  629. .mutating()
  630. .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() }))
  631. .json(200, (body) => {
  632. object(body)
  633. check(body.success === true, "MCP auth removal should return success")
  634. }),
  635. http
  636. .post("/mcp/{name}/auth/authenticate", "mcp.auth.authenticate")
  637. .at((ctx) => ({
  638. path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }),
  639. headers: ctx.headers(),
  640. }))
  641. .json(
  642. 400,
  643. (body) => {
  644. object(body)
  645. check(typeof body.error === "string", "unsupported MCP OAuth authenticate response should include error")
  646. },
  647. "status",
  648. ),
  649. http
  650. .post("/mcp/{name}/auth/callback", "mcp.auth.callback")
  651. .at((ctx) => ({
  652. path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }),
  653. headers: ctx.headers(),
  654. body: { code: 1 },
  655. }))
  656. .status(400),
  657. http
  658. .post("/mcp/{name}/connect", "mcp.connect")
  659. .mutating()
  660. .at((ctx) => ({ path: route("/mcp/{name}/connect", { name: "httpapi-missing" }), headers: ctx.headers() }))
  661. .json(200, (body) => {
  662. check(body === true, "missing MCP connect should remain a no-op success")
  663. }),
  664. http
  665. .post("/mcp/{name}/disconnect", "mcp.disconnect")
  666. .mutating()
  667. .at((ctx) => ({ path: route("/mcp/{name}/disconnect", { name: "httpapi-missing" }), headers: ctx.headers() }))
  668. .json(200, (body) => {
  669. check(body === true, "missing MCP disconnect should remain a no-op success")
  670. }),
  671. http.get("/pty/shells", "pty.shells").json(200, array),
  672. http.get("/pty", "pty.list").json(200, array),
  673. http
  674. .post("/pty", "pty.create")
  675. .mutating()
  676. .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: controlledPtyInput("HTTP API PTY") }))
  677. .json(
  678. 200,
  679. (body, ctx) => {
  680. object(body)
  681. check(body.title === "HTTP API PTY", "PTY create should return requested title")
  682. check(body.command === "/bin/sh", "PTY create should use controlled shell command")
  683. check(body.cwd === ctx.directory, "PTY create should default cwd to scenario directory")
  684. },
  685. "status",
  686. ),
  687. http
  688. .post("/pty", "pty.create.invalid")
  689. .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: { command: 1 } }))
  690. .status(400),
  691. http
  692. .get("/pty/{ptyID}", "pty.get")
  693. .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() }))
  694. .status(404),
  695. http
  696. .put("/pty/{ptyID}", "pty.update")
  697. .mutating()
  698. .at((ctx) => ({
  699. path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }),
  700. headers: ctx.headers(),
  701. body: { size: { rows: 0, cols: 0 } },
  702. }))
  703. .status(400),
  704. http
  705. .delete("/pty/{ptyID}", "pty.remove")
  706. .mutating()
  707. .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() }))
  708. .json(200, (body) => {
  709. check(body === true, "PTY remove should return true")
  710. }),
  711. http
  712. .get("/pty/{ptyID}/connect", "pty.connect")
  713. .at((ctx) => ({ path: route("/pty/{ptyID}/connect", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() }))
  714. .status(404, undefined, "none"),
  715. http.get("/experimental/console", "experimental.console.get").json(),
  716. http.get("/experimental/console/orgs", "experimental.console.listOrgs").json(),
  717. http
  718. .post("/experimental/console/switch", "experimental.console.switchOrg")
  719. .at((ctx) => ({
  720. path: "/experimental/console/switch",
  721. headers: ctx.headers(),
  722. body: { accountID: "httpapi-account", orgID: "httpapi-org" },
  723. }))
  724. .status(400, undefined, "none"),
  725. http.get("/experimental/workspace/adapter", "experimental.workspace.adapter.list").json(200, array),
  726. http.get("/experimental/workspace", "experimental.workspace.list").json(200, array),
  727. http.get("/experimental/workspace/status", "experimental.workspace.status").json(200, array),
  728. http
  729. .post("/experimental/workspace", "experimental.workspace.create")
  730. .at((ctx) => ({ path: "/experimental/workspace", headers: ctx.headers(), body: {} }))
  731. .status(400),
  732. http
  733. .delete("/experimental/workspace/{id}", "experimental.workspace.remove")
  734. .mutating()
  735. .at((ctx) => ({
  736. path: route("/experimental/workspace/{id}", { id: "wrk_httpapi_missing" }),
  737. headers: ctx.headers(),
  738. }))
  739. .status(200),
  740. http
  741. .post("/experimental/workspace/warp", "experimental.workspace.warp")
  742. .at((ctx) => ({
  743. path: "/experimental/workspace/warp",
  744. headers: ctx.headers(),
  745. body: {},
  746. }))
  747. .status(400),
  748. http
  749. .get("/experimental/tool", "tool.list")
  750. .at((ctx) => ({
  751. path: `/experimental/tool?${new URLSearchParams({ provider: "opencode", model: "test" })}`,
  752. headers: ctx.headers(),
  753. }))
  754. .json(200, array, "status"),
  755. http.get("/experimental/tool/ids", "tool.ids").json(200, array),
  756. http.get("/experimental/worktree", "worktree.list").json(200, array),
  757. http
  758. .post("/experimental/worktree", "worktree.create")
  759. .mutating()
  760. .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: "api-dsl" } }))
  761. .jsonEffect(
  762. 200,
  763. (body, ctx) =>
  764. Effect.gen(function* () {
  765. object(body)
  766. check(typeof body.directory === "string", "created worktree should include directory")
  767. yield* ctx.worktreeRemove(body.directory)
  768. }),
  769. "status",
  770. ),
  771. http
  772. .post("/experimental/worktree", "worktree.create.invalid")
  773. .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: 1 } }))
  774. .status(400),
  775. http
  776. .delete("/experimental/worktree", "worktree.remove")
  777. .mutating()
  778. .seeded((ctx) => ctx.worktree({ name: "api-remove" }))
  779. .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { directory: ctx.state.directory } }))
  780. .json(200, (body) => {
  781. check(body === true, "worktree remove should return true")
  782. }),
  783. http
  784. .post("/experimental/worktree/reset", "worktree.reset")
  785. .mutating()
  786. .seeded((ctx) => ctx.worktree({ name: "api-reset" }))
  787. .at((ctx) => ({
  788. path: "/experimental/worktree/reset",
  789. headers: ctx.headers(),
  790. body: { directory: ctx.state.directory },
  791. }))
  792. .jsonEffect(200, (body, ctx) =>
  793. Effect.gen(function* () {
  794. check(body === true, "worktree reset should return true")
  795. yield* ctx.worktreeRemove(ctx.state.directory)
  796. }),
  797. ),
  798. http.get("/experimental/session", "experimental.session.list").json(200, array),
  799. http.get("/experimental/resource", "experimental.resource.list").json(),
  800. http
  801. .post("/sync/history", "sync.history.list")
  802. .at((ctx) => ({ path: "/sync/history", headers: ctx.headers(), body: {} }))
  803. .json(200, array),
  804. http
  805. .post("/sync/replay", "sync.replay")
  806. .at((ctx) => ({ path: "/sync/replay", headers: ctx.headers(), body: { directory: ctx.directory, events: [] } }))
  807. .status(400),
  808. http
  809. .post("/sync/start", "sync.start")
  810. .mutating()
  811. .preserveDatabase()
  812. .json(200, (body) => {
  813. check(body === true, "sync start should return true when no workspace sessions exist")
  814. }),
  815. http
  816. .post("/instance/dispose", "instance.dispose")
  817. .mutating()
  818. .json(200, (body) => {
  819. check(body === true, "instance dispose should return true")
  820. }),
  821. http
  822. .post("/log", "app.log")
  823. .global()
  824. .at(() => ({ path: "/log", body: { service: "httpapi-exercise", level: "info", message: "route coverage" } }))
  825. .json(200, (body) => {
  826. check(body === true, "log route should return true")
  827. }),
  828. http
  829. .put("/auth/{providerID}", "auth.set")
  830. .global()
  831. .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }), body: { type: "api", key: "test-key" } }))
  832. .jsonEffect(200, (body) =>
  833. Effect.gen(function* () {
  834. check(body === true, "auth set should return true")
  835. const auth = yield* Effect.promise(() => Bun.file(path.join(exerciseDataDirectory, "auth.json")).json())
  836. object(auth)
  837. check(isRecord(auth.test) && auth.test.key === "test-key", "auth set should write isolated auth file")
  838. }),
  839. ),
  840. http
  841. .delete("/auth/{providerID}", "auth.remove")
  842. .global()
  843. .seeded(() =>
  844. Effect.promise(() =>
  845. Bun.write(
  846. path.join(exerciseDataDirectory, "auth.json"),
  847. JSON.stringify({ test: { type: "api", key: "remove-me" } }),
  848. ),
  849. ),
  850. )
  851. .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }) }))
  852. .jsonEffect(200, (body) =>
  853. Effect.gen(function* () {
  854. check(body === true, "auth remove should return true")
  855. const auth = yield* Effect.promise(() => Bun.file(path.join(exerciseDataDirectory, "auth.json")).json())
  856. object(auth)
  857. check(auth.test === undefined, "auth remove should delete provider from isolated auth file")
  858. }),
  859. ),
  860. http
  861. .get("/session", "session.list")
  862. .seeded((ctx) => ctx.session({ title: "List me" }))
  863. .at((ctx) => ({ path: "/session?roots=true", headers: ctx.headers() }))
  864. .json(200, (body, ctx) => {
  865. array(body)
  866. check(
  867. body.some((item) => isRecord(item) && item.id === ctx.state.id && item.title === "List me"),
  868. "seeded session should be listed",
  869. )
  870. }),
  871. http
  872. .get("/session/status", "session.status")
  873. .seeded((ctx) => ctx.session({ title: "Status session" }))
  874. .json(200, object),
  875. http
  876. .post("/session", "session.create")
  877. .mutating()
  878. .at((ctx) => ({ path: "/session", headers: ctx.headers(), body: { title: "Created session" } }))
  879. .json(
  880. 200,
  881. (body, ctx) => {
  882. object(body)
  883. check(body.title === "Created session", "created session should use requested title")
  884. check(body.directory === ctx.directory, "created session should use scenario directory")
  885. },
  886. "status",
  887. ),
  888. http
  889. .get("/session/{sessionID}", "session.get")
  890. .seeded((ctx) => ctx.session({ title: "Get me" }))
  891. .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() }))
  892. .json(200, (body, ctx) => {
  893. object(body)
  894. check(body.id === ctx.state.id, "should return requested session")
  895. check(body.title === "Get me", "should preserve seeded title")
  896. }),
  897. http
  898. .get("/session/{sessionID}", "session.get.missing")
  899. .at((ctx) => ({
  900. path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }),
  901. headers: ctx.headers(),
  902. }))
  903. .status(404),
  904. http
  905. .patch("/session/{sessionID}", "session.update")
  906. .mutating()
  907. .seeded((ctx) => ctx.session({ title: "Before rename" }))
  908. .at((ctx) => ({
  909. path: route("/session/{sessionID}", { sessionID: ctx.state.id }),
  910. headers: ctx.headers(),
  911. body: { title: "After rename" },
  912. }))
  913. .json(
  914. 200,
  915. (body) => {
  916. object(body)
  917. check(body.title === "After rename", "updated session should use new title")
  918. },
  919. "status",
  920. ),
  921. http
  922. .patch("/session/{sessionID}", "session.update.invalid")
  923. .mutating()
  924. .at((ctx) => ({
  925. path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }),
  926. headers: ctx.headers(),
  927. body: { title: 1 },
  928. }))
  929. .status(400),
  930. http
  931. .delete("/session/{sessionID}", "session.delete")
  932. .mutating()
  933. .seeded((ctx) => ctx.session({ title: "Delete me" }))
  934. .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() }))
  935. .jsonEffect(200, (body, ctx) =>
  936. Effect.gen(function* () {
  937. check(body === true, "delete should return true")
  938. check((yield* ctx.sessionGet(ctx.state.id)) === undefined, "deleted session should not remain in storage")
  939. }),
  940. ),
  941. http
  942. .get("/session/{sessionID}/children", "session.children")
  943. .seeded((ctx) =>
  944. Effect.gen(function* () {
  945. const parent = yield* ctx.session({ title: "Parent" })
  946. const child = yield* ctx.session({ title: "Child", parentID: parent.id })
  947. return { parent, child }
  948. }),
  949. )
  950. .at((ctx) => ({
  951. path: route("/session/{sessionID}/children", { sessionID: ctx.state.parent.id }),
  952. headers: ctx.headers(),
  953. }))
  954. .json(200, (body, ctx) => {
  955. array(body)
  956. check(
  957. body.some((item) => isRecord(item) && item.id === ctx.state.child.id && item.parentID === ctx.state.parent.id),
  958. "children should include seeded child",
  959. )
  960. }),
  961. http
  962. .get("/session/{sessionID}/todo", "session.todo")
  963. .seeded((ctx) =>
  964. Effect.gen(function* () {
  965. const session = yield* ctx.session({ title: "Todo session" })
  966. const todos = [{ content: "cover session todo", status: "pending", priority: "high" }]
  967. yield* ctx.todos(session.id, todos)
  968. return { session, todos }
  969. }),
  970. )
  971. .at((ctx) => ({
  972. path: route("/session/{sessionID}/todo", { sessionID: ctx.state.session.id }),
  973. headers: ctx.headers(),
  974. }))
  975. .json(200, (body, ctx) => {
  976. check(stable(body) === stable(ctx.state.todos), "todos should match seeded state")
  977. }),
  978. http
  979. .get("/session/{sessionID}/diff", "session.diff")
  980. .seeded((ctx) => ctx.session({ title: "Diff session" }))
  981. .at((ctx) => ({ path: route("/session/{sessionID}/diff", { sessionID: ctx.state.id }), headers: ctx.headers() }))
  982. .json(200, array),
  983. http
  984. .get("/session/{sessionID}/message", "session.messages")
  985. .seeded((ctx) => ctx.session({ title: "Messages session" }))
  986. .at((ctx) => ({ path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), headers: ctx.headers() }))
  987. .json(200, (body) => {
  988. array(body)
  989. check(body.length === 0, "new session should have no messages")
  990. }),
  991. http
  992. .get("/session/{sessionID}/message/{messageID}", "session.message")
  993. .seeded((ctx) =>
  994. Effect.gen(function* () {
  995. const session = yield* ctx.session({ title: "Message get session" })
  996. const message = yield* ctx.message(session.id, { text: "read me" })
  997. return { session, message }
  998. }),
  999. )
  1000. .at((ctx) => ({
  1001. path: route("/session/{sessionID}/message/{messageID}", {
  1002. sessionID: ctx.state.session.id,
  1003. messageID: ctx.state.message.info.id,
  1004. }),
  1005. headers: ctx.headers(),
  1006. }))
  1007. .json(200, (body, ctx) => {
  1008. object(body)
  1009. check(isRecord(body.info) && body.info.id === ctx.state.message.info.id, "should return requested message")
  1010. check(
  1011. Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.id === ctx.state.message.part.id),
  1012. "message should include seeded part",
  1013. )
  1014. }),
  1015. http
  1016. .patch("/session/{sessionID}/message/{messageID}/part/{partID}", "part.update")
  1017. .mutating()
  1018. .seeded((ctx) =>
  1019. Effect.gen(function* () {
  1020. const session = yield* ctx.session({ title: "Part update session" })
  1021. const message = yield* ctx.message(session.id, { text: "before" })
  1022. return { session, message }
  1023. }),
  1024. )
  1025. .at((ctx) => ({
  1026. path: route("/session/{sessionID}/message/{messageID}/part/{partID}", {
  1027. sessionID: ctx.state.session.id,
  1028. messageID: ctx.state.message.info.id,
  1029. partID: ctx.state.message.part.id,
  1030. }),
  1031. headers: ctx.headers(),
  1032. body: { ...ctx.state.message.part, text: "after" },
  1033. }))
  1034. .json(
  1035. 200,
  1036. (body) => {
  1037. object(body)
  1038. check(body.type === "text" && body.text === "after", "updated part should be returned")
  1039. },
  1040. "status",
  1041. ),
  1042. http
  1043. .delete("/session/{sessionID}/message/{messageID}/part/{partID}", "part.delete")
  1044. .mutating()
  1045. .seeded((ctx) =>
  1046. Effect.gen(function* () {
  1047. const session = yield* ctx.session({ title: "Part delete session" })
  1048. const message = yield* ctx.message(session.id, { text: "delete part" })
  1049. return { session, message }
  1050. }),
  1051. )
  1052. .at((ctx) => ({
  1053. path: route("/session/{sessionID}/message/{messageID}/part/{partID}", {
  1054. sessionID: ctx.state.session.id,
  1055. messageID: ctx.state.message.info.id,
  1056. partID: ctx.state.message.part.id,
  1057. }),
  1058. headers: ctx.headers(),
  1059. }))
  1060. .jsonEffect(200, (body, ctx) =>
  1061. Effect.gen(function* () {
  1062. check(body === true, "delete part should return true")
  1063. const messages = yield* ctx.messages(ctx.state.session.id)
  1064. check(messages[0]?.parts.length === 0, "deleted part should not remain on message")
  1065. }),
  1066. ),
  1067. http
  1068. .delete("/session/{sessionID}/message/{messageID}", "session.deleteMessage")
  1069. .mutating()
  1070. .seeded((ctx) =>
  1071. Effect.gen(function* () {
  1072. const session = yield* ctx.session({ title: "Message delete session" })
  1073. const message = yield* ctx.message(session.id, { text: "delete message" })
  1074. return { session, message }
  1075. }),
  1076. )
  1077. .at((ctx) => ({
  1078. path: route("/session/{sessionID}/message/{messageID}", {
  1079. sessionID: ctx.state.session.id,
  1080. messageID: ctx.state.message.info.id,
  1081. }),
  1082. headers: ctx.headers(),
  1083. }))
  1084. .jsonEffect(200, (body, ctx) =>
  1085. Effect.gen(function* () {
  1086. check(body === true, "delete message should return true")
  1087. check((yield* ctx.messages(ctx.state.session.id)).length === 0, "deleted message should not remain")
  1088. }),
  1089. ),
  1090. http
  1091. .post("/session/{sessionID}/fork", "session.fork")
  1092. .mutating()
  1093. .seeded((ctx) => ctx.session({ title: "Fork source" }))
  1094. .at((ctx) => ({
  1095. path: route("/session/{sessionID}/fork", { sessionID: ctx.state.id }),
  1096. headers: ctx.headers(),
  1097. body: {},
  1098. }))
  1099. .json(
  1100. 200,
  1101. (body) => {
  1102. object(body)
  1103. check(typeof body.id === "string", "fork should return a session")
  1104. },
  1105. "status",
  1106. ),
  1107. http
  1108. .post("/session/{sessionID}/abort", "session.abort")
  1109. .mutating()
  1110. .seeded((ctx) => ctx.session({ title: "Abort session" }))
  1111. .at((ctx) => ({ path: route("/session/{sessionID}/abort", { sessionID: ctx.state.id }), headers: ctx.headers() }))
  1112. .json(200, (body) => {
  1113. check(body === true, "abort should return true")
  1114. }),
  1115. http
  1116. .post("/session/{sessionID}/abort", "session.abort.missing")
  1117. .at((ctx) => ({
  1118. path: route("/session/{sessionID}/abort", { sessionID: "ses_httpapi_missing" }),
  1119. headers: ctx.headers(),
  1120. }))
  1121. .json(200, (body) => {
  1122. check(body === true, "missing session abort should remain a no-op success")
  1123. }),
  1124. http
  1125. .post("/session/{sessionID}/init", "session.init")
  1126. .preserveDatabase()
  1127. .withLlm()
  1128. .seeded((ctx) =>
  1129. Effect.gen(function* () {
  1130. const session = yield* ctx.session({ title: "Init session" })
  1131. const message = yield* ctx.message(session.id, { text: "initialize" })
  1132. yield* ctx.llmText("initialized")
  1133. yield* ctx.llmText("initialized")
  1134. return { session, message }
  1135. }),
  1136. )
  1137. .at((ctx) => ({
  1138. path: route("/session/{sessionID}/init", { sessionID: ctx.state.session.id }),
  1139. headers: ctx.headers(),
  1140. body: { providerID: "test", modelID: "test-model", messageID: ctx.state.message.info.id },
  1141. }))
  1142. .jsonEffect(200, (body, ctx) =>
  1143. Effect.gen(function* () {
  1144. check(body === true, "init should return true")
  1145. yield* ctx.llmWait(1)
  1146. }),
  1147. ),
  1148. http
  1149. .post("/session/{sessionID}/message", "session.prompt")
  1150. .preserveDatabase()
  1151. .withLlm()
  1152. .seeded((ctx) =>
  1153. Effect.gen(function* () {
  1154. const session = yield* ctx.session({ title: "LLM prompt session" })
  1155. yield* ctx.llmText("fake assistant")
  1156. yield* ctx.llmText("fake assistant")
  1157. return session
  1158. }),
  1159. )
  1160. .at((ctx) => ({
  1161. path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }),
  1162. headers: ctx.headers(),
  1163. body: {
  1164. agent: "build",
  1165. model: { providerID: "test", modelID: "test-model" },
  1166. parts: [{ type: "text", text: "hello llm" }],
  1167. },
  1168. }))
  1169. .jsonEffect(
  1170. 200,
  1171. (body, ctx) =>
  1172. Effect.gen(function* () {
  1173. object(body)
  1174. check(isRecord(body.info) && body.info.role === "assistant", "prompt should return assistant message")
  1175. check(
  1176. Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.text === "fake assistant"),
  1177. "assistant message should use fake LLM text",
  1178. )
  1179. yield* ctx.llmWait(1)
  1180. }),
  1181. "status",
  1182. ),
  1183. http
  1184. .post("/session/{sessionID}/prompt_async", "session.prompt_async")
  1185. .preserveDatabase()
  1186. .withLlm()
  1187. .seeded((ctx) =>
  1188. Effect.gen(function* () {
  1189. const session = yield* ctx.session({ title: "Async prompt session" })
  1190. yield* ctx.llmText("fake async assistant")
  1191. yield* ctx.llmText("fake async assistant")
  1192. return session
  1193. }),
  1194. )
  1195. .at((ctx) => ({
  1196. path: route("/session/{sessionID}/prompt_async", { sessionID: ctx.state.id }),
  1197. headers: ctx.headers(),
  1198. body: {
  1199. agent: "build",
  1200. model: { providerID: "test", modelID: "test-model" },
  1201. parts: [{ type: "text", text: "hello async" }],
  1202. },
  1203. }))
  1204. .status(204, (ctx) =>
  1205. Effect.gen(function* () {
  1206. yield* ctx.llmWait(1)
  1207. }),
  1208. ),
  1209. http
  1210. .post("/session/{sessionID}/command", "session.command")
  1211. .preserveDatabase()
  1212. .withLlm()
  1213. .seeded((ctx) =>
  1214. Effect.gen(function* () {
  1215. const session = yield* ctx.session({ title: "Command session" })
  1216. yield* ctx.llmText("command done")
  1217. yield* ctx.llmText("command done")
  1218. return session
  1219. }),
  1220. )
  1221. .at((ctx) => ({
  1222. path: route("/session/{sessionID}/command", { sessionID: ctx.state.id }),
  1223. headers: ctx.headers(),
  1224. body: { command: "init", arguments: "", model: "test/test-model" },
  1225. }))
  1226. .jsonEffect(
  1227. 200,
  1228. (body, ctx) =>
  1229. Effect.gen(function* () {
  1230. object(body)
  1231. check(isRecord(body.info) && body.info.role === "assistant", "command should return assistant message")
  1232. yield* ctx.llmWait(1)
  1233. }),
  1234. "status",
  1235. ),
  1236. http
  1237. .post("/session/{sessionID}/shell", "session.shell")
  1238. .preserveDatabase()
  1239. .mutating()
  1240. .seeded((ctx) => ctx.session({ title: "Shell session" }))
  1241. .at((ctx) => ({
  1242. path: route("/session/{sessionID}/shell", { sessionID: ctx.state.id }),
  1243. headers: ctx.headers(),
  1244. body: { agent: "build", model: { providerID: "test", modelID: "test-model" }, command: "printf shell-ok" },
  1245. }))
  1246. .json(
  1247. 200,
  1248. (body) => {
  1249. object(body)
  1250. check(isRecord(body.info) && body.info.role === "assistant", "shell should return assistant message")
  1251. check(
  1252. Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.type === "tool"),
  1253. "shell should return a tool part",
  1254. )
  1255. },
  1256. "status",
  1257. ),
  1258. http
  1259. .post("/session/{sessionID}/summarize", "session.summarize")
  1260. .preserveDatabase()
  1261. .withLlm()
  1262. .seeded((ctx) =>
  1263. Effect.gen(function* () {
  1264. const session = yield* ctx.session({ title: "Summarize session" })
  1265. yield* ctx.message(session.id, { text: "summarize this work" })
  1266. const summary = [
  1267. "## Goal",
  1268. "- Exercise session summarize.",
  1269. "",
  1270. "## Constraints & Preferences",
  1271. "- Use fake LLM.",
  1272. "",
  1273. "## Progress",
  1274. "### Done",
  1275. "- Summary generated.",
  1276. "",
  1277. "### In Progress",
  1278. "- (none)",
  1279. "",
  1280. "### Blocked",
  1281. "- (none)",
  1282. "",
  1283. "## Key Decisions",
  1284. "- Keep route local.",
  1285. "",
  1286. "## Next Steps",
  1287. "- (none)",
  1288. "",
  1289. "## Critical Context",
  1290. "- Test fixture.",
  1291. "",
  1292. "## Relevant Files",
  1293. "- script/httpapi-exercise.ts: scenario",
  1294. ].join("\n")
  1295. yield* ctx.llmText(summary)
  1296. yield* ctx.llmText(summary)
  1297. return session
  1298. }),
  1299. )
  1300. .at((ctx) => ({
  1301. path: route("/session/{sessionID}/summarize", { sessionID: ctx.state.id }),
  1302. headers: ctx.headers(),
  1303. body: { providerID: "test", modelID: "test-model", auto: false },
  1304. }))
  1305. .jsonEffect(
  1306. 200,
  1307. (body, ctx) =>
  1308. Effect.gen(function* () {
  1309. check(body === true, "summarize should return true")
  1310. const messages = yield* ctx.messages(ctx.state.id)
  1311. check(
  1312. messages.some((message) => message.info.role === "assistant" && message.info.summary === true),
  1313. "summarize should create a summary assistant message",
  1314. )
  1315. yield* ctx.llmWait(1)
  1316. }),
  1317. "status",
  1318. ),
  1319. http
  1320. .post("/session/{sessionID}/revert", "session.revert")
  1321. .mutating()
  1322. .seeded((ctx) =>
  1323. Effect.gen(function* () {
  1324. const session = yield* ctx.session({ title: "Revert session" })
  1325. const message = yield* ctx.message(session.id, { text: "revert me" })
  1326. return { session, message }
  1327. }),
  1328. )
  1329. .at((ctx) => ({
  1330. path: route("/session/{sessionID}/revert", { sessionID: ctx.state.session.id }),
  1331. headers: ctx.headers(),
  1332. body: { messageID: ctx.state.message.info.id },
  1333. }))
  1334. .json(
  1335. 200,
  1336. (body, ctx) => {
  1337. object(body)
  1338. check(body.id === ctx.state.session.id, "revert should return the session")
  1339. check(
  1340. isRecord(body.revert) && body.revert.messageID === ctx.state.message.info.id,
  1341. "revert should record reverted message",
  1342. )
  1343. },
  1344. "status",
  1345. ),
  1346. http
  1347. .post("/session/{sessionID}/unrevert", "session.unrevert")
  1348. .mutating()
  1349. .seeded((ctx) => ctx.session({ title: "Unrevert session" }))
  1350. .at((ctx) => ({
  1351. path: route("/session/{sessionID}/unrevert", { sessionID: ctx.state.id }),
  1352. headers: ctx.headers(),
  1353. }))
  1354. .json(
  1355. 200,
  1356. (body, ctx) => {
  1357. object(body)
  1358. check(body.id === ctx.state.id, "unrevert should return the session")
  1359. },
  1360. "status",
  1361. ),
  1362. http
  1363. .post("/session/{sessionID}/permissions/{permissionID}", "permission.respond")
  1364. .seeded((ctx) => ctx.session({ title: "Deprecated permission session" }))
  1365. .at((ctx) => ({
  1366. path: route("/session/{sessionID}/permissions/{permissionID}", {
  1367. sessionID: ctx.state.id,
  1368. permissionID: "per_httpapi_deprecated",
  1369. }),
  1370. headers: ctx.headers(),
  1371. body: { response: "once" },
  1372. }))
  1373. .json(200, (body) => {
  1374. check(body === true, "deprecated permission response should return true")
  1375. }),
  1376. http
  1377. .post("/session/{sessionID}/share", "session.share")
  1378. .mutating()
  1379. .seeded((ctx) => ctx.session({ title: "Share session" }))
  1380. .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() }))
  1381. .json(
  1382. 200,
  1383. (body, ctx) => {
  1384. object(body)
  1385. check(body.id === ctx.state.id, "share should return the session")
  1386. },
  1387. "status",
  1388. ),
  1389. http
  1390. .delete("/session/{sessionID}/share", "session.unshare")
  1391. .mutating()
  1392. .seeded((ctx) => ctx.session({ title: "Unshare session" }))
  1393. .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() }))
  1394. .json(
  1395. 200,
  1396. (body, ctx) => {
  1397. object(body)
  1398. check(body.id === ctx.state.id, "unshare should return the session")
  1399. },
  1400. "status",
  1401. ),
  1402. http
  1403. .post("/tui/append-prompt", "tui.appendPrompt")
  1404. .at((ctx) => ({ path: "/tui/append-prompt", headers: ctx.headers(), body: { text: "hello" } }))
  1405. .json(200, boolean, "status"),
  1406. http
  1407. .post("/tui/select-session", "tui.selectSession.invalid")
  1408. .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: "invalid" } }))
  1409. .status(400),
  1410. http.post("/tui/open-help", "tui.openHelp").json(200, boolean, "status"),
  1411. http.post("/tui/open-sessions", "tui.openSessions").json(200, boolean, "status"),
  1412. http.post("/tui/open-themes", "tui.openThemes").json(200, boolean, "status"),
  1413. http.post("/tui/open-models", "tui.openModels").json(200, boolean, "status"),
  1414. http.post("/tui/submit-prompt", "tui.submitPrompt").json(200, boolean, "status"),
  1415. http.post("/tui/clear-prompt", "tui.clearPrompt").json(200, boolean, "status"),
  1416. http
  1417. .post("/tui/execute-command", "tui.executeCommand")
  1418. .at((ctx) => ({ path: "/tui/execute-command", headers: ctx.headers(), body: { command: "agent_cycle" } }))
  1419. .json(200, boolean, "status"),
  1420. http
  1421. .post("/tui/show-toast", "tui.showToast")
  1422. .at((ctx) => ({
  1423. path: "/tui/show-toast",
  1424. headers: ctx.headers(),
  1425. body: { title: "Exercise", message: "covered", variant: "info", duration: 1000 },
  1426. }))
  1427. .json(200, boolean, "status"),
  1428. http
  1429. .post("/tui/publish", "tui.publish")
  1430. .at((ctx) => ({
  1431. path: "/tui/publish",
  1432. headers: ctx.headers(),
  1433. body: { type: "tui.prompt.append", properties: { text: "published" } },
  1434. }))
  1435. .json(200, boolean, "status"),
  1436. http
  1437. .post("/tui/select-session", "tui.selectSession")
  1438. .seeded((ctx) => ctx.session({ title: "TUI select" }))
  1439. .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: ctx.state.id } }))
  1440. .json(200, boolean, "status"),
  1441. http
  1442. .post("/tui/control/response", "tui.control.response")
  1443. .at((ctx) => ({ path: "/tui/control/response", headers: ctx.headers(), body: { ok: true } }))
  1444. .json(200, boolean, "status"),
  1445. http
  1446. .get("/tui/control/next", "tui.control.next")
  1447. .mutating()
  1448. .seeded((ctx) => ctx.tuiRequest({ path: "/tui/exercise", body: { text: "queued" } }))
  1449. .json(
  1450. 200,
  1451. (body) => {
  1452. object(body)
  1453. check(body.path === "/tui/exercise", "control next should return queued path")
  1454. object(body.body)
  1455. check(body.body.text === "queued", "control next should return queued body")
  1456. },
  1457. "status",
  1458. ),
  1459. http
  1460. .post("/global/upgrade", "global.upgrade")
  1461. .global()
  1462. .at(() => ({ path: "/global/upgrade", body: { target: 1 } }))
  1463. .status(400),
  1464. ]
  1465. const main = Effect.gen(function* () {
  1466. yield* Effect.addFinalizer(() => cleanupExercisePaths)
  1467. const options = parseOptions(Bun.argv.slice(2))
  1468. const modules = yield* Effect.promise(() => runtime())
  1469. const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi))
  1470. const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapiHono()))
  1471. const selected = scenarios.filter((scenario) => matches(options, scenario))
  1472. const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario)))
  1473. const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario)))
  1474. printHeader(options, effectRoutes, honoRoutes, selected, missing, extra)
  1475. const results =
  1476. options.mode === "coverage"
  1477. ? selected.map(coverageResult)
  1478. : yield* Effect.forEach(selected, runScenario(options), { concurrency: 1 })
  1479. printResults(results, missing, extra)
  1480. if (results.some((result) => result.status === "fail"))
  1481. return yield* Effect.fail(new Error("one or more scenarios failed"))
  1482. if (options.failOnSkip && results.some((result) => result.status === "skip"))
  1483. return yield* Effect.fail(new Error("one or more scenarios are skipped"))
  1484. if (options.failOnMissing && missing.length > 0)
  1485. return yield* Effect.fail(new Error("one or more routes have no scenario"))
  1486. })
  1487. function runScenario(options: Options) {
  1488. return (scenario: Scenario) => {
  1489. if (scenario.kind === "todo") return Effect.succeed({ status: "skip", scenario } as Result)
  1490. return runActive(options, scenario).pipe(
  1491. Effect.as({ status: "pass", scenario } as Result),
  1492. Effect.catchCause((cause) => Effect.succeed({ status: "fail" as const, scenario, message: Cause.pretty(cause) })),
  1493. Effect.scoped,
  1494. )
  1495. }
  1496. }
  1497. function runActive(options: Options, scenario: ActiveScenario) {
  1498. if (options.mode === "parity" && scenario.mutates && scenario.compare !== "none") {
  1499. return Effect.gen(function* () {
  1500. const effect = yield* runBackend("effect", scenario)
  1501. const legacy = yield* runBackend("legacy", scenario)
  1502. yield* compare(scenario, effect, legacy)
  1503. })
  1504. }
  1505. return withContext(scenario, (ctx) =>
  1506. Effect.gen(function* () {
  1507. const effect = yield* call("effect", scenario, ctx)
  1508. yield* scenario.expect(ctx, ctx.state, effect)
  1509. if (options.mode === "parity" && scenario.compare !== "none") {
  1510. const legacy = yield* call("legacy", scenario, ctx)
  1511. yield* scenario.expect(ctx, ctx.state, legacy)
  1512. yield* compare(scenario, effect, legacy)
  1513. }
  1514. }),
  1515. )
  1516. }
  1517. function runBackend(backend: "effect" | "legacy", scenario: ActiveScenario) {
  1518. return withContext(scenario, (ctx) =>
  1519. Effect.gen(function* () {
  1520. const result = yield* call(backend, scenario, ctx)
  1521. yield* scenario.expect(ctx, ctx.state, result)
  1522. return result
  1523. }),
  1524. )
  1525. }
  1526. function withContext<A, E>(scenario: ActiveScenario, use: (ctx: SeededContext<unknown>) => Effect.Effect<A, E>) {
  1527. return Effect.acquireRelease(
  1528. Effect.gen(function* () {
  1529. const llm = scenario.project?.llm ? yield* TestLLMServer : undefined
  1530. const project = scenario.project
  1531. const dir = project
  1532. ? yield* Effect.promise(async () => (await runtime()).tmpdir(projectOptions(project, llm?.url)))
  1533. : undefined
  1534. return { dir, llm }
  1535. }),
  1536. (ctx) => Effect.promise(async () => void (await ctx.dir?.[Symbol.asyncDispose]())).pipe(Effect.ignore),
  1537. ).pipe(
  1538. Effect.flatMap((context) =>
  1539. Effect.gen(function* () {
  1540. const modules = yield* Effect.promise(() => runtime())
  1541. const path = context.dir?.path
  1542. const instance = path
  1543. ? yield* modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe(
  1544. Effect.provide(modules.AppLayer),
  1545. Effect.catchCause((cause) =>
  1546. Effect.sleep("100 millis").pipe(
  1547. Effect.andThen(
  1548. modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe(
  1549. Effect.provide(modules.AppLayer),
  1550. ),
  1551. ),
  1552. Effect.catchCause(() => Effect.failCause(cause)),
  1553. ),
  1554. ),
  1555. )
  1556. : undefined
  1557. const run = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
  1558. effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(modules.AppLayer))
  1559. const directory = () => {
  1560. if (!context.dir?.path) throw new Error("scenario needs a project directory")
  1561. return context.dir.path
  1562. }
  1563. const llm = () => {
  1564. if (!context.llm) throw new Error("scenario needs fake LLM")
  1565. return context.llm
  1566. }
  1567. const base: ScenarioContext = {
  1568. directory: context.dir?.path,
  1569. headers: (extra) => ({
  1570. ...(context.dir?.path ? { "x-opencode-directory": context.dir.path } : {}),
  1571. ...extra,
  1572. }),
  1573. file: (name, content) =>
  1574. Effect.promise(() => {
  1575. return Bun.write(`${directory()}/${name}`, content)
  1576. }).pipe(Effect.asVoid),
  1577. session: (input) =>
  1578. run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID }))),
  1579. sessionGet: (sessionID) =>
  1580. run(modules.Session.Service.use((svc) => svc.get(sessionID))).pipe(
  1581. Effect.catchCause(() => Effect.succeed(undefined)),
  1582. ),
  1583. project: () =>
  1584. Effect.sync(() => {
  1585. if (!instance) throw new Error("scenario needs a project directory")
  1586. return instance.project
  1587. }),
  1588. message: (sessionID, input) =>
  1589. Effect.gen(function* () {
  1590. const info: MessageV2.User = {
  1591. id: MessageID.ascending(),
  1592. sessionID,
  1593. role: "user",
  1594. time: { created: Date.now() },
  1595. agent: "build",
  1596. model: {
  1597. providerID: ProviderID.opencode,
  1598. modelID: ModelID.make("test"),
  1599. },
  1600. }
  1601. const part: MessageV2.TextPart = {
  1602. id: PartID.ascending(),
  1603. sessionID,
  1604. messageID: info.id,
  1605. type: "text",
  1606. text: input?.text ?? "hello",
  1607. }
  1608. yield* run(
  1609. modules.Session.Service.use((svc) =>
  1610. Effect.gen(function* () {
  1611. yield* svc.updateMessage(info)
  1612. yield* svc.updatePart(part)
  1613. }),
  1614. ),
  1615. )
  1616. return { info, part }
  1617. }),
  1618. messages: (sessionID) => run(modules.Session.Service.use((svc) => svc.messages({ sessionID }))),
  1619. todos: (sessionID, todos) => run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))),
  1620. worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input))),
  1621. worktreeRemove: (directory) =>
  1622. run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)),
  1623. llmText: (value) => Effect.suspend(() => llm().text(value)),
  1624. llmWait: (count) => Effect.suspend(() => llm().wait(count)),
  1625. tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)),
  1626. }
  1627. const state = yield* scenario.seed(base)
  1628. return yield* use({ ...base, state })
  1629. }).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void)),
  1630. ),
  1631. Effect.ensuring(scenario.reset ? resetState : Effect.void),
  1632. )
  1633. }
  1634. function projectOptions(
  1635. project: ProjectOptions,
  1636. llmUrl: string | undefined,
  1637. ): { git?: boolean; config?: Partial<Config.Info> } {
  1638. if (!project.llm || !llmUrl) return { git: project.git, config: project.config }
  1639. const fake = fakeLlmConfig(llmUrl)
  1640. return {
  1641. git: project.git,
  1642. config: {
  1643. ...fake,
  1644. ...project.config,
  1645. provider: {
  1646. ...fake.provider,
  1647. ...project.config?.provider,
  1648. },
  1649. },
  1650. }
  1651. }
  1652. function fakeLlmConfig(url: string): Partial<Config.Info> {
  1653. return {
  1654. model: "test/test-model",
  1655. small_model: "test/test-model",
  1656. provider: {
  1657. test: {
  1658. name: "Test",
  1659. id: "test",
  1660. env: [],
  1661. npm: "@ai-sdk/openai-compatible",
  1662. models: {
  1663. "test-model": {
  1664. id: "test-model",
  1665. name: "Test Model",
  1666. attachment: false,
  1667. reasoning: false,
  1668. temperature: false,
  1669. tool_call: true,
  1670. release_date: "2025-01-01",
  1671. limit: { context: 100000, output: 10000 },
  1672. cost: { input: 0, output: 0 },
  1673. options: {},
  1674. },
  1675. },
  1676. options: {
  1677. apiKey: "test-key",
  1678. baseURL: url,
  1679. },
  1680. },
  1681. },
  1682. }
  1683. }
  1684. function controlledPtyInput(title: string | undefined) {
  1685. return {
  1686. command: "/bin/sh",
  1687. args: ["-c", "sleep 30"],
  1688. ...(title ? { title } : {}),
  1689. }
  1690. }
  1691. function call(backend: Backend, scenario: ActiveScenario, ctx: SeededContext<unknown>) {
  1692. return Effect.promise(async () =>
  1693. capture(await app(await runtime(), backend).request(toRequest(scenario, ctx)), scenario.capture),
  1694. )
  1695. }
  1696. const appCache: Partial<Record<Backend, BackendApp>> = {}
  1697. function app(modules: Runtime, backend: Backend) {
  1698. Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect"
  1699. Flag.OPENCODE_SERVER_PASSWORD = undefined
  1700. Flag.OPENCODE_SERVER_USERNAME = undefined
  1701. if (appCache[backend]) return appCache[backend]
  1702. if (backend === "legacy") {
  1703. const legacy = modules.Server.Legacy().app
  1704. return (appCache.legacy = {
  1705. request: (input, init) => legacy.request(input, init),
  1706. })
  1707. }
  1708. const handler = HttpRouter.toWebHandler(
  1709. modules.ExperimentalHttpApiServer.routes.pipe(
  1710. Layer.provide(
  1711. ConfigProvider.layer(
  1712. ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: undefined, OPENCODE_SERVER_USERNAME: undefined }),
  1713. ),
  1714. ),
  1715. ),
  1716. { disableLogger: true },
  1717. ).handler
  1718. return (appCache.effect = {
  1719. request(input: string | URL | Request, init?: RequestInit) {
  1720. return handler(
  1721. input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
  1722. modules.ExperimentalHttpApiServer.context,
  1723. )
  1724. },
  1725. })
  1726. }
  1727. function toRequest(scenario: ActiveScenario, ctx: SeededContext<unknown>) {
  1728. const spec = scenario.request(ctx, ctx.state)
  1729. return new Request(new URL(spec.path, "http://localhost"), {
  1730. method: scenario.method,
  1731. headers: spec.body === undefined ? spec.headers : { "content-type": "application/json", ...spec.headers },
  1732. body: spec.body === undefined ? undefined : JSON.stringify(spec.body),
  1733. })
  1734. }
  1735. async function capture(response: Response, mode: CaptureMode): Promise<CallResult> {
  1736. const text = mode === "stream" ? await captureStream(response) : await response.text()
  1737. return {
  1738. status: response.status,
  1739. contentType: response.headers.get("content-type") ?? "",
  1740. text,
  1741. body: parse(text),
  1742. }
  1743. }
  1744. async function captureStream(response: Response) {
  1745. if (!response.body) return ""
  1746. const reader = response.body.getReader()
  1747. const read = reader.read().then(
  1748. (result) => ({ result }),
  1749. (error: unknown) => ({ error }),
  1750. )
  1751. const winner = await Promise.race([read, Bun.sleep(1_000).then(() => ({ timeout: true }))])
  1752. if ("timeout" in winner) {
  1753. await reader.cancel("timed out waiting for stream chunk").catch(() => undefined)
  1754. throw new Error("timed out waiting for stream chunk")
  1755. }
  1756. if ("error" in winner) throw winner.error
  1757. await reader.cancel().catch(() => undefined)
  1758. if (winner.result.done) return ""
  1759. return new TextDecoder().decode(winner.result.value)
  1760. }
  1761. const cleanupExercisePaths = Effect.promise(async () => {
  1762. const fs = await import("fs/promises")
  1763. if (!preserveExerciseDatabase) {
  1764. await Promise.all(
  1765. [exerciseDatabasePath, `${exerciseDatabasePath}-wal`, `${exerciseDatabasePath}-shm`].map((file) =>
  1766. fs.rm(file, { force: true }).catch(() => undefined),
  1767. ),
  1768. )
  1769. }
  1770. if (!preserveExerciseGlobalRoot)
  1771. await fs.rm(exerciseGlobalRoot, { recursive: true, force: true }).catch(() => undefined)
  1772. })
  1773. function compare(scenario: ActiveScenario, effect: CallResult, legacy: CallResult) {
  1774. return Effect.sync(() => {
  1775. if (effect.status !== legacy.status)
  1776. throw new Error(`legacy returned ${legacy.status}, effect returned ${effect.status}`)
  1777. if (scenario.compare === "status") return
  1778. if (stable(effect.body) !== stable(legacy.body))
  1779. throw new Error(`JSON parity mismatch\nlegacy: ${stable(legacy.body)}\neffect: ${stable(effect.body)}`)
  1780. })
  1781. }
  1782. const resetState = Effect.promise(async () => {
  1783. const modules = await runtime()
  1784. Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
  1785. Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
  1786. Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
  1787. await modules.disposeAllInstances()
  1788. await modules.resetDatabase()
  1789. await Bun.sleep(25)
  1790. })
  1791. function routeKeys(spec: OpenApiSpec) {
  1792. return Object.entries(spec.paths ?? {})
  1793. .flatMap(([path, item]) =>
  1794. OpenApiMethods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`),
  1795. )
  1796. .sort()
  1797. }
  1798. function routeKey(scenario: Scenario) {
  1799. return `${scenario.method} ${scenario.path}`
  1800. }
  1801. function coverageResult(scenario: Scenario): Result {
  1802. if (scenario.kind === "todo") return { status: "skip", scenario }
  1803. return { status: "pass", scenario }
  1804. }
  1805. function parseOptions(args: string[]): Options {
  1806. const mode = option(args, "--mode") ?? "effect"
  1807. if (mode !== "effect" && mode !== "parity" && mode !== "coverage") throw new Error(`invalid --mode ${mode}`)
  1808. return {
  1809. mode,
  1810. include: option(args, "--include"),
  1811. failOnMissing: args.includes("--fail-on-missing"),
  1812. failOnSkip: args.includes("--fail-on-skip"),
  1813. }
  1814. }
  1815. function option(args: string[], name: string) {
  1816. const index = args.indexOf(name)
  1817. if (index === -1) return undefined
  1818. return args[index + 1]
  1819. }
  1820. function matches(options: Options, scenario: Scenario) {
  1821. if (!options.include) return true
  1822. return (
  1823. scenario.name.includes(options.include) ||
  1824. scenario.path.includes(options.include) ||
  1825. scenario.method.includes(options.include.toUpperCase())
  1826. )
  1827. }
  1828. function printHeader(
  1829. options: Options,
  1830. effectRoutes: string[],
  1831. honoRoutes: string[],
  1832. selected: Scenario[],
  1833. missing: string[],
  1834. extra: Scenario[],
  1835. ) {
  1836. console.log(`${color.cyan}HttpApi exerciser${color.reset}`)
  1837. console.log(`${color.dim}db=${exerciseDatabasePath}${color.reset}`)
  1838. console.log(`${color.dim}global=${exerciseGlobalRoot}${color.reset}`)
  1839. console.log(
  1840. `${color.dim}mode=${options.mode} selected=${selected.length} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length} onlyEffect=${effectRoutes.filter((route) => !honoRoutes.includes(route)).length} onlyHono=${honoRoutes.filter((route) => !effectRoutes.includes(route)).length}${color.reset}`,
  1841. )
  1842. console.log("")
  1843. }
  1844. function printResults(results: Result[], missing: string[], extra: Scenario[]) {
  1845. for (const result of results) {
  1846. if (result.status === "pass") {
  1847. console.log(
  1848. `${color.green}PASS${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`,
  1849. )
  1850. continue
  1851. }
  1852. if (result.status === "skip") {
  1853. console.log(
  1854. `${color.yellow}SKIP${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name} ${color.dim}${result.scenario.reason}${color.reset}`,
  1855. )
  1856. continue
  1857. }
  1858. console.log(
  1859. `${color.red}FAIL${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`,
  1860. )
  1861. console.log(`${color.red}${indent(result.message)}${color.reset}`)
  1862. }
  1863. if (missing.length > 0) {
  1864. console.log("\nMissing scenarios")
  1865. for (const route of missing) console.log(`${color.red}MISS${color.reset} ${route}`)
  1866. }
  1867. if (extra.length > 0) {
  1868. console.log("\nExtra scenarios")
  1869. for (const scenario of extra)
  1870. console.log(`${color.yellow}EXTRA${color.reset} ${routeKey(scenario)} ${scenario.name}`)
  1871. }
  1872. console.log(
  1873. `\n${color.dim}summary pass=${results.filter((result) => result.status === "pass").length} fail=${results.filter((result) => result.status === "fail").length} skip=${results.filter((result) => result.status === "skip").length} missing=${missing.length} extra=${extra.length}${color.reset}`,
  1874. )
  1875. }
  1876. function parse(text: string): unknown {
  1877. if (!text) return undefined
  1878. try {
  1879. return JSON.parse(text) as unknown
  1880. } catch {
  1881. return text
  1882. }
  1883. }
  1884. function looksJson(result: CallResult) {
  1885. return result.contentType.includes("application/json") || result.text.startsWith("{") || result.text.startsWith("[")
  1886. }
  1887. function stable(value: unknown): string {
  1888. return JSON.stringify(sort(value))
  1889. }
  1890. function sort(value: unknown): unknown {
  1891. if (Array.isArray(value)) return value.map(sort)
  1892. if (!value || typeof value !== "object") return value
  1893. return Object.fromEntries(
  1894. Object.entries(value)
  1895. .sort(([left], [right]) => left.localeCompare(right))
  1896. .map(([key, item]) => [key, sort(item)]),
  1897. )
  1898. }
  1899. function array(value: unknown): asserts value is unknown[] {
  1900. if (!Array.isArray(value)) throw new Error("expected array")
  1901. }
  1902. function object(value: unknown): asserts value is JsonObject {
  1903. if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected object")
  1904. }
  1905. function boolean(value: unknown): asserts value is boolean {
  1906. if (typeof value !== "boolean") throw new Error("expected boolean")
  1907. }
  1908. function isRecord(value: unknown): value is JsonObject {
  1909. return !!value && typeof value === "object" && !Array.isArray(value)
  1910. }
  1911. function check(value: boolean, message: string): asserts value {
  1912. if (!value) throw new Error(message)
  1913. }
  1914. function message(error: unknown) {
  1915. if (error instanceof Error) return error.message
  1916. return String(error)
  1917. }
  1918. function pad(value: string, size: number) {
  1919. return value.length >= size ? value : value + " ".repeat(size - value.length)
  1920. }
  1921. function indent(value: string) {
  1922. return value
  1923. .split("\n")
  1924. .map((line) => ` ${line}`)
  1925. .join("\n")
  1926. }
  1927. Effect.runPromise(main.pipe(Effect.provide(TestLLMServer.layer), Effect.scoped)).then(
  1928. () => process.exit(0),
  1929. (error: unknown) => {
  1930. console.error(`${color.red}${message(error)}${color.reset}`)
  1931. process.exit(1)
  1932. },
  1933. )