Compare commits

...
11 changed files with 361 additions and 434 deletions
+28 -33
View File
@@ -177,7 +177,7 @@ const layer = Layer.effect(
if (!dotgit) return undefined
const cwd = path.dirname(dotgit)
const result = yield* run(cwd, proc)(["rev-parse", "--git-dir", "--git-common-dir", "--show-toplevel"])
const result = yield* run(cwd, proc, ["rev-parse", "--git-dir", "--git-common-dir", "--show-toplevel"])
const [gitDir, commonDir, topLevel] = result.text.split(/\r?\n/)
if (!gitDir || !commonDir) return undefined
@@ -189,13 +189,13 @@ const layer = Layer.effect(
})
const remote = Effect.fn("Git.remote.get")(function* (repository: Repository, name = "origin") {
const result = yield* run(repository.worktree, proc)(["remote", "get-url", name])
const result = yield* run(repository.worktree, proc, ["remote", "get-url", name])
if (result.exitCode !== 0) return undefined
return result.text.trim() || undefined
})
const roots = Effect.fn("Git.history.rootCommits")(function* (repository: Repository) {
const result = yield* run(repository.worktree, proc)(["rev-list", "--max-parents=0", "HEAD"])
const result = yield* run(repository.worktree, proc, ["rev-list", "--max-parents=0", "HEAD"])
if (result.exitCode !== 0) return []
return result.text
.split("\n")
@@ -205,13 +205,13 @@ const layer = Layer.effect(
})
const head = Effect.fn("Git.history.head")(function* (repository: Repository) {
const result = yield* run(repository.worktree, proc)(["rev-parse", "HEAD"])
const result = yield* run(repository.worktree, proc, ["rev-parse", "HEAD"])
if (result.exitCode !== 0) return undefined
return result.text.trim() || undefined
})
const branch = Effect.fn("Git.history.branch")(function* (repository: Repository) {
const result = yield* run(repository.worktree, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"])
const result = yield* run(repository.worktree, proc, ["symbolic-ref", "--quiet", "--short", "HEAD"])
if (result.exitCode !== 0) return undefined
return result.text.trim() || undefined
})
@@ -220,7 +220,7 @@ const layer = Layer.effect(
repository: Repository,
remoteName = "origin",
) {
const result = yield* run(repository.worktree, proc)(["symbolic-ref", `refs/remotes/${remoteName}/HEAD`])
const result = yield* run(repository.worktree, proc, ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`])
if (result.exitCode !== 0) return undefined
return result.text.trim().replace(new RegExp(`^refs/remotes/${remoteName}/`), "") || undefined
})
@@ -230,10 +230,7 @@ const layer = Layer.effect(
directory: AbsolutePath,
args: string[],
) {
const result = yield* execute(
directory,
proc,
)(args).pipe(
const result = yield* execute(directory, proc, args).pipe(
Effect.mapError((cause) => new OperationError({ operation, directory, message: cause.message, cause })),
)
if (result.exitCode === 0) return
@@ -711,31 +708,29 @@ interface Result {
readonly stderr: string
}
function run(cwd: string, proc: AppProcess.Interface) {
return (args: string[]) =>
execute(cwd, proc)(args).pipe(Effect.orElseSucceed(() => ({ exitCode: 1, text: "", stderr: "" })))
function run(cwd: string, proc: AppProcess.Interface, args: string[]) {
return execute(cwd, proc, args).pipe(Effect.orElseSucceed(() => ({ exitCode: 1, text: "", stderr: "" })))
}
function execute(cwd: string, proc: AppProcess.Interface) {
return (args: string[]) =>
proc
.run(
ChildProcess.make("git", args, {
cwd,
extendEnv: true,
stdin: "ignore",
}),
)
.pipe(
Effect.map(
(result) =>
({
exitCode: result.exitCode,
text: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
}) satisfies Result,
),
)
function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
return proc
.run(
ChildProcess.make("git", args, {
cwd,
extendEnv: true,
stdin: "ignore",
}),
)
.pipe(
Effect.map(
(result) =>
({
exitCode: result.exitCode,
text: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
}) satisfies Result,
),
)
}
function resolvePath(cwd: string, value: string) {
+20 -18
View File
@@ -7,22 +7,24 @@ type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
const layer = Layer.mergeAll(TestConsole.layer, TestClock.layer())
const effect = <A, E>(name: string, body: Body<A, E, Scope>, options?: Parameters<typeof test>[2]) =>
test(
name,
() =>
Effect.gen(function* () {
const exit = yield* Effect.suspend(() => (typeof body === "function" ? body() : body)).pipe(
Effect.scoped,
Effect.provide(layer),
Effect.exit,
)
if (Exit.isFailure(exit)) {
yield* Effect.forEach(Cause.prettyErrors(exit.cause), Effect.logError, { discard: true })
}
return yield* exit
}).pipe(Effect.runPromise),
options,
)
const make =
<R>(testLayer: Layer.Layer<R>) =>
<A, E>(name: string, body: Body<A, E, Scope>, options?: Parameters<typeof test>[2]) =>
test(
name,
() =>
Effect.gen(function* () {
const exit = yield* Effect.suspend(() => (typeof body === "function" ? body() : body)).pipe(
Effect.scoped,
Effect.provide(testLayer),
Effect.exit,
)
if (Exit.isFailure(exit)) {
yield* Effect.forEach(Cause.prettyErrors(exit.cause), Effect.logError, { discard: true })
}
return yield* exit
}).pipe(Effect.runPromise),
options,
)
export const it = { effect }
export const it = { effect: make(layer), live: make(TestConsole.layer) }
+134 -180
View File
@@ -20,6 +20,7 @@ import {
emitPromise,
generate,
GenerationError,
type Output,
} from "../src"
import { it } from "./effect"
import { Api as FixtureApi, Missing } from "./fixture"
@@ -32,6 +33,21 @@ function compile<Id extends string, Groups extends HttpApiGroup.Constraint>(sour
return emitEffect(compileContract(source))
}
async function emittedModule(output: Output) {
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
const dispose = () => rm(directory, { recursive: true, force: true })
try {
// Finish each write before cleanup can run, even when a later write fails.
await Array.fromAsync(output.files, (file) => Bun.write(join(directory, file.path), file.content))
const module = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
return { module, [Symbol.asyncDispose]: dispose }
} catch (cause) {
await dispose()
throw cause
}
}
describe("HttpApiCodegen.generate", () => {
test("compiles one contract for Promise and Effect emitters", () => {
const contract = compileContract(
@@ -352,27 +368,21 @@ describe("HttpApiCodegen.generate", () => {
),
)
const output = emitPromise(compileContract(source))
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
const methods: Array<string> = []
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (_input: RequestInfo | URL, init?: RequestInit) => {
methods.push(init?.method ?? "GET")
return Response.json("ok")
},
})
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (_input: RequestInfo | URL, init?: RequestInit) => {
methods.push(init?.method ?? "GET")
return Response.json("ok")
},
})
expect(await client.session.instructions.list()).toBe("ok")
expect(await client.session.instructions.put()).toBe("ok")
expect(await client.session.instructions.remove()).toBe("ok")
expect(methods).toEqual(["GET", "PUT", "DELETE"])
} finally {
await rm(directory, { recursive: true, force: true })
}
expect(await client.session.instructions.list()).toBe("ok")
expect(await client.session.instructions.put()).toBe("ok")
expect(await client.session.instructions.remove()).toBe("ok")
expect(methods).toEqual(["GET", "PUT", "DELETE"])
})
test("rejects duplicate and leaf-namespace endpoint paths", () => {
@@ -825,26 +835,19 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
let request: Request | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
request = input instanceof Request ? input : new Request(input)
return Response.json({ data: "hello" })
},
})
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
request = input instanceof Request ? input : new Request(input)
return Response.json({ data: "hello" })
},
})
expect(await client.session.get({ sessionID: "a/b" })).toBe("hello")
expect(request?.method).toBe("GET")
expect(request?.url).toBe("https://example.com/session/a%2Fb")
} finally {
await rm(directory, { recursive: true, force: true })
}
expect(await client.session.get({ sessionID: "a/b" })).toBe("hello")
expect(request?.method).toBe("GET")
expect(request?.url).toBe("https://example.com/session/a%2Fb")
})
test("maps an emitted no-content response to undefined", async () => {
@@ -858,20 +861,13 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => new Response(null, { status: 204 }),
})
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => new Response(null, { status: 204 }),
})
expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined()
} finally {
await rm(directory, { recursive: true, force: true })
}
expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined()
})
test("executes an emitted binary wildcard GET through fetch", async () => {
@@ -885,28 +881,21 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
let request: Request | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
request = input instanceof Request ? input : new Request(input)
return new Response(new Uint8Array([1, 2, 3]))
},
})
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
request = input instanceof Request ? input : new Request(input)
return new Response(new Uint8Array([1, 2, 3]))
},
})
const result = await client.session.read({ path: "src/a b#c.ts", token: "x/y" })
expect(result).toBeInstanceOf(Uint8Array)
expect(Array.from(result)).toEqual([1, 2, 3])
expect(request?.method).toBe("GET")
expect(request?.url).toBe("https://example.com/file/src/a%20b%23c.ts?token=x%2Fy")
} finally {
await rm(directory, { recursive: true, force: true })
}
const result = await client.session.read({ path: "src/a b#c.ts", token: "x/y" })
expect(result).toBeInstanceOf(Uint8Array)
expect(Array.from(result)).toEqual([1, 2, 3])
expect(request?.method).toBe("GET")
expect(request?.url).toBe("https://example.com/file/src/a%20b%23c.ts?token=x%2Fy")
})
test("serializes flattened query, header, and JSON payload inputs", async () => {
@@ -923,29 +912,22 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
let request: Request | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: "admitted" })
},
})
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: "admitted" })
},
})
expect(
await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" }),
).toBe("admitted")
expect(request?.url).toBe("https://example.com/session/session?resume=true")
expect(request?.headers.get("traceID")).toBe("trace")
expect(await request?.json()).toEqual({ prompt: "hello" })
} finally {
await rm(directory, { recursive: true, force: true })
}
expect(await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" })).toBe(
"admitted",
)
expect(request?.url).toBe("https://example.com/session/session?resume=true")
expect(request?.headers.get("traceID")).toBe("trace")
expect(await request?.json()).toEqual({ prompt: "hello" })
})
test("serializes an opaque union payload as the direct JSON body", async () => {
@@ -962,26 +944,19 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
let request: Request | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return new Response(null, { status: 204 })
},
})
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return new Response(null, { status: 204 })
},
})
await client.session.configure({ payload: { type: "local", command: ["opencode"] } })
await client.session.configure({ payload: { type: "local", command: ["opencode"] } })
expect(await request?.json()).toEqual({ type: "local", command: ["opencode"] })
} finally {
await rm(directory, { recursive: true, force: true })
}
expect(await request?.json()).toEqual({ type: "local", command: ["opencode"] })
})
test("serializes explicit null query values", async () => {
@@ -995,26 +970,19 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
let request: Request | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: [] })
},
})
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: [] })
},
})
await client.session.list({ parentID: null })
await client.session.list({ parentID: null })
expect(request?.url).toBe("https://example.com/session?parentID=null")
} finally {
await rm(directory, { recursive: true, force: true })
}
expect(request?.url).toBe("https://example.com/session?parentID=null")
})
test("rejects with declared tagged errors and exports a type guard", async () => {
@@ -1029,22 +997,15 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }),
})
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }),
})
const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause)
expect(error).toEqual({ _tag: "Missing", message: "gone" })
expect(generated.isMissing(error)).toBeTrue()
} finally {
await rm(directory, { recursive: true, force: true })
}
const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause)
expect(error).toEqual({ _tag: "Missing", message: "gone" })
expect(emitted.module.isMissing(error)).toBeTrue()
})
test("iterates an emitted SSE stream lazily without reconnecting", async () => {
@@ -1060,42 +1021,35 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
let requests = 0
let url: string | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
requests++
url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
const encoder = new TextEncoder()
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('data: {"type":"ready","count":"1"}\r'))
controller.enqueue(encoder.encode("\n\r\n"))
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
})
const events = client.session.subscribe({ after: 2 })
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let requests = 0
let url: string | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
requests++
url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
const encoder = new TextEncoder()
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('data: {"type":"ready","count":"1"}\r'))
controller.enqueue(encoder.encode("\n\r\n"))
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
})
const events = client.session.subscribe({ after: 2 })
expect(requests).toBe(0)
const received = []
for await (const event of events) received.push(event)
expect(received).toEqual([{ type: "ready", count: "1" }])
expect(requests).toBe(1)
expect(url).toBe("https://example.com/event?after=2")
} finally {
await rm(directory, { recursive: true, force: true })
}
expect(requests).toBe(0)
const received = []
for await (const event of events) received.push(event)
expect(received).toEqual([{ type: "ready", count: "1" }])
expect(requests).toBe(1)
expect(url).toBe("https://example.com/event?after=2")
})
test("preserves public group and endpoint identifiers exactly", () => {
@@ -1138,7 +1092,7 @@ describe("HttpApiCodegen.generate", () => {
for (const file of output.files) expect(() => transpiler.transformSync(file.content)).not.toThrow()
})
it.effect("keeps the strict generated-consumer fixture current", () =>
it.live("keeps the strict generated-consumer fixture current", () =>
Effect.gen(function* () {
const output = compile(FixtureApi)
const actual = yield* Effect.promise(() =>
+48 -63
View File
@@ -3,75 +3,60 @@ import path from "node:path"
import { expect } from "bun:test"
import { Config } from "@opencode-ai/schema/config"
import { Effect, Schema } from "effect"
import { HttpServer } from "effect/unstable/http"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
import { startServer } from "./fixture/server"
import { AbsolutePath } from "@opencode-ai/schema/schema"
it.live("returns ordered config entries for the requested directory", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir("opencode-config-endpoint-")),
(tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const config = path.join(project, "opencode.json")
yield* Effect.promise(() =>
Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]),
)
yield* Effect.promise(() =>
fs.writeFile(
config,
JSON.stringify({
permissions: [
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
],
mcp: { servers: { docs: { type: "remote", url: "https://example.com/mcp" } } },
}),
),
)
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
config: { directory: global },
fs: { filewatcher: false },
})
const url = new URL("/api/config", HttpServer.formatAddress(server.address))
url.searchParams.set("location[directory]", project)
const response = yield* Effect.promise(() =>
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
)
const body: unknown = yield* Effect.promise(() => response.json())
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(body)
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-config-endpoint-")))
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const config = path.join(project, "opencode.json")
yield* Effect.promise(() =>
Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]),
)
yield* Effect.promise(() =>
fs.writeFile(
config,
JSON.stringify({
permissions: [
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
],
mcp: { servers: { docs: { type: "remote", url: "https://example.com/mcp" } } },
}),
),
)
const server = yield* startServer(global)
const url = new URL("/api/config", server.base)
url.searchParams.set("location[directory]", project)
const response = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
const body: unknown = yield* Effect.promise(() => response.json())
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(body)
expect(response.status).toBe(200)
expect(Array.isArray(entries)).toBe(true)
const document = entries.find(
(entry): entry is Config.Document => entry.type === "document" && entry.path === config,
)
expect(document?.info.permissions).toEqual([
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
])
expect(document?.path).toBe(AbsolutePath.make(config))
if (!Array.isArray(body)) throw new Error("Expected a config entry array")
const raw = body.find((entry) => isRecord(entry) && entry["type"] === "document" && entry["path"] === config)
if (!isRecord(raw) || !isRecord(raw["info"])) throw new Error("Expected a config document")
expect(raw["info"]).not.toHaveProperty("default_agent")
expect(raw["info"]).not.toHaveProperty("model")
const mcp = raw["info"]["mcp"]
if (!isRecord(mcp) || !isRecord(mcp["servers"]) || !isRecord(mcp["servers"]["docs"]))
throw new Error("Expected an MCP server config")
expect(mcp["servers"]["docs"]).not.toHaveProperty("headers")
expect(mcp["servers"]["docs"]).not.toHaveProperty("oauth")
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
expect(response.status).toBe(200)
expect(Array.isArray(entries)).toBe(true)
const document = entries.find(
(entry): entry is Config.Document => entry.type === "document" && entry.path === config,
)
expect(document?.info.permissions).toEqual([
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
])
expect(document?.path).toBe(AbsolutePath.make(config))
if (!Array.isArray(body)) throw new Error("Expected a config entry array")
const raw = body.find((entry) => isRecord(entry) && entry["type"] === "document" && entry["path"] === config)
if (!isRecord(raw) || !isRecord(raw["info"])) throw new Error("Expected a config document")
expect(raw["info"]).not.toHaveProperty("default_agent")
expect(raw["info"]).not.toHaveProperty("model")
const mcp = raw["info"]["mcp"]
if (!isRecord(mcp) || !isRecord(mcp["servers"]) || !isRecord(mcp["servers"]["docs"]))
throw new Error("Expected an MCP server config")
expect(mcp["servers"]["docs"]).not.toHaveProperty("headers")
expect(mcp["servers"]["docs"]).not.toHaveProperty("oauth")
}),
)
function isRecord(value: unknown): value is Record<string, unknown> {
+10 -10
View File
@@ -61,7 +61,7 @@ it.live("serves the HttpApi and enforces Basic auth like the Node server", () =>
const body: unknown = yield* Effect.promise(() => response.json())
if (typeof body !== "object" || body === null) throw new Error("Expected a health response object")
expect((body as Record<string, unknown>)["healthy"]).toBe(true)
}).pipe(Effect.scoped),
}),
)
it.live("activates credentials through the HttpApi", () =>
@@ -71,7 +71,7 @@ it.live("activates credentials through the HttpApi", () =>
handler(new Request("http://opencode.local/api/credential/cred_missing/activate", { method: "POST" })),
)
expect(response.status).toBe(204)
}).pipe(Effect.scoped),
}),
)
it.live("serves unauthenticated and answers CORS preflight when no password is configured", () =>
@@ -93,7 +93,7 @@ it.live("serves unauthenticated and answers CORS preflight when no password is c
),
)
expect(preflight.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
}).pipe(Effect.scoped),
}),
)
it.live("cancels a stale OpenAI OAuth callback server before falling back", () =>
@@ -113,7 +113,7 @@ it.live("cancels a stale OpenAI OAuth callback server before falling back", () =
expect(requests).toContain("/cancel")
const body = (yield* Effect.promise(() => response.json())) as { data: { url: string } }
expect(new URL(body.data.url).searchParams.get("redirect_uri")).toBe("http://localhost:1455/auth/callback")
}).pipe(Effect.scoped),
}),
)
it.live("falls back to port 1457 when OpenAI OAuth port 1455 remains busy", () =>
@@ -133,7 +133,7 @@ it.live("falls back to port 1457 when OpenAI OAuth port 1455 remains busy", () =
expect(requests).toContain("/cancel")
const body = (yield* Effect.promise(() => response.json())) as { data: { url: string } }
expect(new URL(body.data.url).searchParams.get("redirect_uri")).toBe("http://localhost:1457/auth/callback")
}).pipe(Effect.scoped),
}),
)
it.live("explains how to recover when both OpenAI OAuth callback ports are busy", () =>
@@ -155,7 +155,7 @@ it.live("explains how to recover when both OpenAI OAuth callback ports are busy"
"OpenAI browser login needs local port 1455 or 1457, but both are already in use. Stop the processes using those ports or choose ChatGPT Pro/Plus (headless), then try again.",
kind: "integration_authorization",
})
}).pipe(Effect.scoped),
}),
)
it.live("treats destroying a missing workspace as success", () =>
@@ -171,7 +171,7 @@ it.live("treats destroying a missing workspace as success", () =>
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toEqual({ destroyed: false })
}).pipe(Effect.scoped),
}),
)
it.live("creates idempotent caller-identified workspaces through the HttpApi", () =>
@@ -213,7 +213,7 @@ it.live("creates idempotent caller-identified workspaces through the HttpApi", (
const minted = yield* create({ provider: "fake" })
expect(minted.status).toBe(200)
expect(yield* Effect.promise(() => minted.json())).toMatchObject({ data: expect.stringMatching(/^wrk_/) })
}).pipe(Effect.scoped),
}),
)
it.live("serves the session view operation and missing-session error", () =>
@@ -260,7 +260,7 @@ it.live("serves the session view operation and missing-session error", () =>
),
)
expect(missing.status).toBe(404)
}).pipe(Effect.scoped),
}),
)
// Pins the eager-boot guarantee: the application layer is built before the handler returns, so
@@ -283,5 +283,5 @@ it.live("stays serviceable when the first request aborts", () =>
const second = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/health")))
expect(second.status).toBe(200)
}).pipe(Effect.scoped),
}),
)
+19
View File
@@ -0,0 +1,19 @@
import { Effect } from "effect"
import { HttpServer } from "effect/unstable/http"
import { ServerProcess } from "../../src/process"
export const startServer = Effect.fnUntraced(function* (directory: string) {
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
config: { directory },
fs: { filewatcher: false },
})
return {
base: HttpServer.formatAddress(server.address),
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
}
})
+29 -33
View File
@@ -31,41 +31,37 @@ const generate = makeLocationNode({
})
it.live("uses base configuration without depending on process.cwd()", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir("opencode-generate-endpoint-")),
(tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
yield* Effect.promise(() => Promise.all([fs.mkdir(global), fs.mkdir(project)]))
yield* Effect.promise(() =>
Promise.all([
fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ model: "base/default" })),
fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ model: "project/default" })),
]),
)
const handler = yield* ServerFetch.make(
{
database: { path: ":memory:" },
config: { directory: global },
fs: { filewatcher: false },
},
{ overrides: [[Generate.node, generate]] },
)
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-generate-endpoint-")))
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
yield* Effect.promise(() => Promise.all([fs.mkdir(global), fs.mkdir(project)]))
yield* Effect.promise(() =>
Promise.all([
fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ model: "base/default" })),
fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ model: "project/default" })),
]),
)
const handler = yield* ServerFetch.make(
{
database: { path: ":memory:" },
config: { directory: global },
fs: { filewatcher: false },
},
{ overrides: [[Generate.node, generate]] },
)
expect(global).not.toBe(process.cwd())
expect(yield* request(handler, new URL("http://opencode.local/api/generate"))).toEqual({
model: { providerID: "base", model: "default" },
})
expect(global).not.toBe(process.cwd())
expect(yield* request(handler, new URL("http://opencode.local/api/generate"))).toEqual({
model: { providerID: "base", model: "default" },
})
const legacy = new URL("http://opencode.local/api/generate")
legacy.searchParams.set("location[directory]", project)
expect(yield* request(handler, legacy)).toEqual({
model: { providerID: "base", model: "default" },
})
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
const legacy = new URL("http://opencode.local/api/generate")
legacy.searchParams.set("location[directory]", project)
expect(yield* request(handler, legacy)).toEqual({
model: { providerID: "base", model: "default" },
})
}),
)
function request(handler: (request: Request) => Promise<Response>, url: URL) {
+28 -43
View File
@@ -2,54 +2,39 @@ import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Effect } from "effect"
import { HttpServer } from "effect/unstable/http"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
import { startServer } from "./fixture/server"
it.live("waits for plugin initialization before listing models", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir("opencode-model-endpoint-")),
(tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai-compatible",
settings: { apiKey: "secret" },
models: { chat: {} },
},
},
}),
),
)
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
config: { directory: tmp.path },
fs: { filewatcher: false },
})
const url = new URL("/api/model", HttpServer.formatAddress(server.address))
url.searchParams.set("location[directory]", tmp.path)
const response = yield* Effect.promise(() =>
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
)
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-model-endpoint-")))
yield* Effect.promise(() =>
fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai-compatible",
settings: { apiKey: "secret" },
models: { chat: {} },
},
},
}),
),
)
const server = yield* startServer(tmp.path)
const url = new URL("/api/model", server.base)
url.searchParams.set("location[directory]", tmp.path)
const response = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
expect(response.status).toBe(200)
const body: unknown = yield* Effect.promise(() => response.json())
if (!isRecord(body) || !Array.isArray(body["data"])) throw new Error("Expected a model list response")
expect(
body["data"].some((model) => isRecord(model) && model["providerID"] === "custom" && model["id"] === "chat"),
).toBeTrue()
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
expect(response.status).toBe(200)
const body: unknown = yield* Effect.promise(() => response.json())
if (!isRecord(body) || !Array.isArray(body["data"])) throw new Error("Expected a model list response")
expect(
body["data"].some((model) => isRecord(model) && model["providerID"] === "custom" && model["id"] === "chat"),
).toBeTrue()
}),
)
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -141,5 +141,5 @@ it.live("updates completed assistant message content through the session HTTP AP
_tag: "ConflictError",
resource: state.assistant,
})
}).pipe(Effect.scoped),
}),
)
+1 -1
View File
@@ -29,5 +29,5 @@ it.live("boots the workerd profile over durable object storage", () =>
const body: unknown = yield* Effect.promise(() => health.json())
expect(body).toMatchObject({ healthy: true, version: "workerd-test" })
}).pipe(Effect.scoped),
}),
)
+43 -52
View File
@@ -3,67 +3,58 @@ import path from "node:path"
import { $ } from "bun"
import { expect } from "bun:test"
import { Effect } from "effect"
import { HttpServer } from "effect/unstable/http"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
import { startServer } from "./fixture/server"
it.live("lists, creates, and removes worktrees by project ID", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir("opencode-worktree-endpoint-")),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
const destination = path.join(tmp.path, "worktrees")
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
yield* Effect.promise(() => $`git init`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.email test@opencode.test`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.name Test`.cwd(project).quiet())
yield* Effect.promise(() => $`git commit --allow-empty -m root`.cwd(project).quiet())
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
config: { directory: path.join(tmp.path, "config") },
fs: { filewatcher: false },
})
const base = HttpServer.formatAddress(server.address)
const headers = { authorization: `Basic ${btoa("opencode:secret")}` }
const location = new URL("/api/location", base)
location.searchParams.set("location[directory]", project)
const resolved = yield* Effect.promise(() => fetch(location, { headers }).then((response) => response.json()))
if (!isRecord(resolved) || !isRecord(resolved.project) || typeof resolved.project.id !== "string")
throw new Error("Expected resolved project")
const url = new URL(`/api/worktree/${resolved.project.id}`, base)
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-endpoint-")))
const project = path.join(tmp.path, "project")
const destination = path.join(tmp.path, "worktrees")
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
yield* Effect.promise(() => $`git init`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.email test@opencode.test`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.name Test`.cwd(project).quiet())
yield* Effect.promise(() => $`git commit --allow-empty -m root`.cwd(project).quiet())
const server = yield* startServer(path.join(tmp.path, "config"))
const location = new URL("/api/location", server.base)
location.searchParams.set("location[directory]", project)
const resolved = yield* Effect.promise(() =>
fetch(location, { headers: server.headers }).then((response) => response.json()),
)
if (!isRecord(resolved) || !isRecord(resolved.project) || typeof resolved.project.id !== "string")
throw new Error("Expected resolved project")
const url = new URL(`/api/worktree/${resolved.project.id}`, server.base)
const initial = yield* Effect.promise(() => fetch(url, { headers }).then((response) => response.json()))
expect(initial).toEqual([{ directory: project }])
const initial = yield* Effect.promise(() =>
fetch(url, { headers: server.headers }).then((response) => response.json()),
)
expect(initial).toEqual([{ directory: project }])
const created = yield* Effect.promise(() =>
fetch(url, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ strategy: "git", directory: destination, name: "api" }),
}).then((response) => response.json()),
)
expect(created).toEqual({ directory: path.join(destination, "api") })
const created = yield* Effect.promise(() =>
fetch(url, {
method: "POST",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ strategy: "git", directory: destination, name: "api" }),
}).then((response) => response.json()),
)
expect(created).toEqual({ directory: path.join(destination, "api") })
const listed = yield* Effect.promise(() => fetch(url, { headers }).then((response) => response.json()))
expect(listed).toContainEqual({ directory: path.join(destination, "api"), strategy: "git" })
const listed = yield* Effect.promise(() =>
fetch(url, { headers: server.headers }).then((response) => response.json()),
)
expect(listed).toContainEqual({ directory: path.join(destination, "api"), strategy: "git" })
const removed = yield* Effect.promise(() =>
fetch(url, {
method: "DELETE",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ directory: path.join(destination, "api"), force: false }),
}),
)
expect(removed.status).toBe(204)
const removed = yield* Effect.promise(() =>
fetch(url, {
method: "DELETE",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ directory: path.join(destination, "api"), force: false }),
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
expect(removed.status).toBe(204)
}),
)
function isRecord(value: unknown): value is Record<string, unknown> {