import { afterEach, describe, expect } from "bun:test" import { Deferred, Effect, Fiber, Layer } from "effect" import { eq } from "drizzle-orm" import { GlobalBus, type GlobalEvent } from "@/bus/global" import { Server } from "../../src/server/server" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" import { Session } from "@/session/session" import { SessionTable } from "@/session/session.sql" import { Database } from "@/storage/db" import * as Log from "@opencode-ai/core/util/log" import { Worktree } from "../../src/worktree" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" void Log.init({ print: false }) const it = testEffect(Layer.mergeAll(Session.defaultLayer)) const testWorktreeMutations = process.platform === "win32" ? it.instance.skip : it.instance function app() { return Server.Default().app } function request(path: string, directory: string, init: RequestInit = {}) { return Effect.promise(() => { const headers = new Headers(init.headers) headers.set("x-opencode-directory", directory) return Promise.resolve(app().request(path, { ...init, headers })) }) } function createSession(input?: Session.CreateInput) { return Session.use.create(input) } function json(response: Response) { return Effect.promise(() => response.json() as Promise) } function waitReady(input: { directory?: string; name?: string }) { return Effect.gen(function* () { const ready = yield* Deferred.make() const on = (event: GlobalEvent) => { if (event.payload.type !== Worktree.Event.Ready.type) return if (input.directory && event.directory !== input.directory) return if (input.name && event.payload.properties.name !== input.name) return Deferred.doneUnsafe(ready, Effect.void) } GlobalBus.on("event", on) yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on))) return yield* Deferred.await(ready).pipe( Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.fail(new Error("timed out waiting for worktree.ready")), }), ) }) } function insertAccount() { return Effect.acquireRelease( Effect.sync(() => { Database.Client() .$client.prepare( "INSERT INTO account (id, email, url, access_token, refresh_token, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?)", ) .run( "account-test", "test@example.com", "https://console.example.com", "access", "refresh", Date.now(), Date.now(), ) return "account-test" }), (id) => Effect.sync(() => { Database.Client().$client.prepare("DELETE FROM account WHERE id = ?").run(id) }), ) } function setSessionUpdated(session: Session.Info, updated: number) { return Effect.sync(() => { Database.use((db) => db.update(SessionTable).set({ time_updated: updated }).where(eq(SessionTable.id, session.id)).run(), ) }) } function withCreatedWorktree(directory: string, use: (info: Worktree.Info) => Effect.Effect) { const name = "api-test" const headers = { "content-type": "application/json" } return Effect.acquireUseRelease( Effect.gen(function* () { const ready = yield* waitReady({ name }).pipe(Effect.forkScoped) const created = yield* request(ExperimentalPaths.worktree, directory, { method: "POST", headers, body: JSON.stringify({ name }), }) expect(created.status).toBe(200) const info = yield* json(created) expect(info).toMatchObject({ name, branch: "opencode/api-test" }) yield* Fiber.join(ready) return info }), use, (info) => Effect.gen(function* () { const removed = yield* request(ExperimentalPaths.worktree, directory, { method: "DELETE", headers, body: JSON.stringify({ directory: info.directory }), }) if (removed.status !== 200) return yield* Effect.fail(new Error(`failed to remove worktree: ${removed.status}`)) const ok = yield* json(removed) if (!ok) return yield* Effect.fail(new Error(`failed to remove worktree ${info.directory}`)) }), ) } afterEach(async () => { await disposeAllInstances() await resetDatabase() }) describe("experimental HttpApi", () => { it.instance( "serves read-only experimental endpoints through the default server app", () => Effect.gen(function* () { const tmp = yield* TestInstance const directory = tmp.directory const [consoleState, consoleOrgs, toolList, toolIDs, worktrees, resources] = yield* Effect.all( [ request(ExperimentalPaths.console, directory), request(ExperimentalPaths.consoleOrgs, directory), request(`${ExperimentalPaths.tool}?provider=opencode&model=gpt-5`, directory), request(ExperimentalPaths.toolIDs, directory), request(ExperimentalPaths.worktree, directory), request(ExperimentalPaths.resource, directory), ], { concurrency: "unbounded" }, ) expect(consoleState.status).toBe(200) expect(yield* json(consoleState)).toEqual({ consoleManagedProviders: [], switchableOrgCount: 0, }) expect(consoleOrgs.status).toBe(200) expect(yield* json(consoleOrgs)).toEqual({ orgs: [] }) expect(toolList.status).toBe(200) expect(yield* json(toolList)).toContainEqual( expect.objectContaining({ id: "bash", description: expect.any(String), parameters: expect.any(Object), }), ) expect(toolIDs.status).toBe(200) expect(yield* json(toolIDs)).toContain("bash") expect(worktrees.status).toBe(200) expect(yield* json(worktrees)).toEqual([]) expect(resources.status).toBe(200) expect(yield* json(resources)).toEqual({}) }), { config: { formatter: false, lsp: false, mcp: { demo: { type: "local", command: ["echo", "demo"], enabled: false, }, }, }, }, ) it.instance("returns declared worktree errors", () => Effect.gen(function* () { const tmp = yield* TestInstance const response = yield* request(ExperimentalPaths.worktree, tmp.directory, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}), }) expect(response.status).toBe(400) expect(yield* json(response)).toEqual({ name: "WorktreeNotGitError", data: { message: "Worktrees are only supported for git projects" }, }) }), ) it.instance( "serves Console org switch through the default server app", () => Effect.gen(function* () { const tmp = yield* TestInstance const accountID = yield* insertAccount() const switched = yield* request(ExperimentalPaths.consoleSwitch, tmp.directory, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ accountID, orgID: "org-test" }), }) expect(switched.status).toBe(200) expect(yield* json(switched)).toBe(true) }), { config: { formatter: false, lsp: false } }, ) it.instance( "serves global session list through the default server app", () => Effect.gen(function* () { const tmp = yield* TestInstance const first = yield* createSession({ title: "page-one" }) const second = yield* createSession({ title: "page-two" }) yield* setSessionUpdated(first, 1) yield* setSessionUpdated(second, 2) const page = yield* request( `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.directory, limit: "1" })}`, tmp.directory, ) expect(page.status).toBe(200) expect(page.headers.get("x-next-cursor")).toBeTruthy() const body = yield* json(page) expect(body.map((session) => session.id)).toEqual([second.id]) expect(body[0].project?.id).toBe(second.projectID) const next = yield* request( `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.directory, limit: "10", cursor: body[0].time.updated.toString(), })}`, tmp.directory, ) expect(next.status).toBe(200) expect((yield* json(next)).map((session) => session.id)).toContain(first.id) }), { git: true, config: { formatter: false, lsp: false } }, ) testWorktreeMutations( "serves worktree mutations through the default server app", () => Effect.gen(function* () { const tmp = yield* TestInstance yield* withCreatedWorktree(tmp.directory, (info) => Effect.gen(function* () { const listed = yield* request(ExperimentalPaths.worktree, tmp.directory) expect(listed.status).toBe(200) expect(yield* json(listed)).toContain(info.directory) const reset = yield* request(ExperimentalPaths.worktreeReset, tmp.directory, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ directory: info.directory }), }) expect(reset.status).toBe(200) expect(yield* json(reset)).toBe(true) }), ) const afterRemove = yield* request(ExperimentalPaths.worktree, tmp.directory) expect(afterRemove.status).toBe(200) expect(yield* json(afterRemove)).toEqual([]) }), { git: true, config: { formatter: false, lsp: false } }, ) })