Compare commits

..
Author SHA1 Message Date
Kit Langton 3369c08ffa docs(plugin): update current API examples 2026-08-27 15:00:32 -04:00
13 changed files with 472 additions and 373 deletions
+33 -28
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,7 +230,10 @@ 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
@@ -708,29 +711,31 @@ interface Result {
readonly stderr: string
}
function run(cwd: string, proc: AppProcess.Interface, args: string[]) {
return execute(cwd, proc, args).pipe(Effect.orElseSucceed(() => ({ exitCode: 1, text: "", stderr: "" })))
function run(cwd: string, proc: AppProcess.Interface) {
return (args: string[]) =>
execute(cwd, proc)(args).pipe(Effect.orElseSucceed(() => ({ exitCode: 1, text: "", stderr: "" })))
}
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 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 resolvePath(cwd: string, value: string) {
+18 -20
View File
@@ -7,24 +7,22 @@ type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
const layer = Layer.mergeAll(TestConsole.layer, TestClock.layer())
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,
)
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,
)
export const it = { effect: make(layer), live: make(TestConsole.layer) }
export const it = { effect }
+180 -134
View File
@@ -20,7 +20,6 @@ import {
emitPromise,
generate,
GenerationError,
type Output,
} from "../src"
import { it } from "./effect"
import { Api as FixtureApi, Missing } from "./fixture"
@@ -33,21 +32,6 @@ 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(
@@ -368,21 +352,27 @@ describe("HttpApiCodegen.generate", () => {
),
)
const output = emitPromise(compileContract(source))
await using emitted = await emittedModule(output)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
const methods: Array<string> = []
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")
},
})
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")
},
})
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"])
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 })
}
})
test("rejects duplicate and leaf-namespace endpoint paths", () => {
@@ -835,19 +825,26 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
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" })
},
})
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
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")
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 })
}
})
test("maps an emitted no-content response to undefined", async () => {
@@ -861,13 +858,20 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
await using emitted = await emittedModule(output)
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => new Response(null, { status: 204 }),
})
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined()
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 })
}
})
test("executes an emitted binary wildcard GET through fetch", async () => {
@@ -881,21 +885,28 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
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]))
},
})
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
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")
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 })
}
})
test("serializes flattened query, header, and JSON payload inputs", async () => {
@@ -912,22 +923,29 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
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" })
},
})
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
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" })
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 })
}
})
test("serializes an opaque union payload as the direct JSON body", async () => {
@@ -944,19 +962,26 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
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 })
},
})
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await client.session.configure({ payload: { type: "local", command: ["opencode"] } })
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 })
},
})
expect(await request?.json()).toEqual({ 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 })
}
})
test("serializes explicit null query values", async () => {
@@ -970,19 +995,26 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
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: [] })
},
})
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await client.session.list({ parentID: null })
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: [] })
},
})
expect(request?.url).toBe("https://example.com/session?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 })
}
})
test("rejects with declared tagged errors and exports a type guard", async () => {
@@ -997,15 +1029,22 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
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 }),
})
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
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()
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 })
}
})
test("iterates an emitted SSE stream lazily without reconnecting", async () => {
@@ -1021,35 +1060,42 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
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 })
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
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")
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 })
}
})
test("preserves public group and endpoint identifiers exactly", () => {
@@ -1092,7 +1138,7 @@ describe("HttpApiCodegen.generate", () => {
for (const file of output.files) expect(() => transpiler.transformSync(file.content)).not.toThrow()
})
it.live("keeps the strict generated-consumer fixture current", () =>
it.effect("keeps the strict generated-consumer fixture current", () =>
Effect.gen(function* () {
const output = compile(FixtureApi)
const actual = yield* Effect.promise(() =>
+21 -7
View File
@@ -5,7 +5,9 @@ The Promise plugin API at `@opencode-ai/plugin` is the async/await equivalent of
- `hook` installs behavior at an OpenCode extension point.
- `reload` reruns every transform hook for a stateful domain.
The only difference from the Effect API is the async boundary: hook callbacks, hook registration, `reload`, and `Registration.dispose` use Promises instead of Effects.
The Promise API uses Promises instead of Effects for setup, runtime hook
callbacks, hook registration, `reload`, and `Registration.dispose`. Transform
draft callbacks remain synchronous.
## Defining A Plugin
@@ -46,12 +48,15 @@ await registration.dispose()
## Transform Hooks
Transform hooks contribute to stateful domains. The draft editor is synchronous; the callback may be `async` when it needs to await other work:
Transform hooks contribute to stateful domains. The draft editor is synchronous,
so load asynchronous data before registering a transform or reloading its domain:
```ts
const description = await loadReviewerDescription()
await ctx.agent.transform((agent) => {
agent.update("reviewer", (item) => {
item.description = "Reviews code for regressions"
item.description = description
item.mode = "subagent"
})
})
@@ -64,8 +69,12 @@ ctx.agent.transform
ctx.catalog.transform
ctx.command.transform
ctx.integration.transform
ctx.mcp.transform
ctx.reference.transform
ctx.skill.transform
ctx.tool.transform
ctx.vcs.transform
ctx.websearch.transform
```
## Runtime Hooks
@@ -81,7 +90,7 @@ await ctx.aisdk.hook("sdk", async (event) => {
await ctx.aisdk.hook("language", (event) => {
if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.api.id)
event.language = event.sdk.responses(event.model.modelID)
})
```
@@ -94,14 +103,15 @@ await ctx.session.hook("context", (event) => {
})
```
Promise tools use executable tool values with async executors. Registration
supplies the tool's name and options separately:
Promise tools use complete executable tool values with async executors:
```ts
import { Schema } from "effect"
await ctx.tool.transform((tools) => {
tools.add("echo", {
tools.add({
name: "echo",
options: { codemode: false },
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
@@ -132,6 +142,10 @@ ctx.agent.reload()
ctx.catalog.reload()
ctx.command.reload()
ctx.integration.reload()
ctx.mcp.reload()
ctx.reference.reload()
ctx.skill.reload()
ctx.tool.reload()
ctx.vcs.reload()
ctx.websearch.reload()
```
+17 -5
View File
@@ -31,7 +31,9 @@ Registrations are owned by the plugin scope. Closing the scope removes them auto
## Transform Hooks
Transform hooks contribute to stateful domains:
Transform hooks contribute to stateful domains. Their draft callbacks are
synchronous, so load effectful data before registering a transform or reloading
its domain:
```ts
yield *
@@ -52,8 +54,12 @@ ctx.agent.transform
ctx.catalog.transform
ctx.command.transform
ctx.integration.transform
ctx.mcp.transform
ctx.reference.transform
ctx.skill.transform
ctx.tool.transform
ctx.vcs.transform
ctx.websearch.transform
```
## Runtime Hooks
@@ -72,10 +78,12 @@ yield *
)
yield *
ctx.aisdk.hook("language", (event) => {
if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.api.id)
})
ctx.aisdk.hook("language", (event) =>
Effect.sync(() => {
if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.modelID)
}),
)
```
Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks.
@@ -117,6 +125,10 @@ ctx.agent.reload()
ctx.catalog.reload()
ctx.command.reload()
ctx.integration.reload()
ctx.mcp.reload()
ctx.reference.reload()
ctx.skill.reload()
ctx.tool.reload()
ctx.vcs.reload()
ctx.websearch.reload()
```
+63 -48
View File
@@ -3,60 +3,75 @@ 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 { startServer } from "./fixture/server"
import { ServerProcess } from "../src/process"
import { AbsolutePath } from "@opencode-ai/schema/schema"
it.live("returns ordered config entries for the requested directory", () =>
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)
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)
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")
}),
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]()),
),
)
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
@@ -1,19 +0,0 @@
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")}` },
}
})
+33 -29
View File
@@ -31,37 +31,41 @@ const generate = makeLocationNode({
})
it.live("uses base configuration without depending on process.cwd()", () =>
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]] },
)
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]] },
)
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" },
})
}),
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]()),
),
)
function request(handler: (request: Request) => Promise<Response>, url: URL) {
+43 -28
View File
@@ -2,39 +2,54 @@ 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 { startServer } from "./fixture/server"
import { ServerProcess } from "../src/process"
it.live("waits for plugin initialization before listing models", () =>
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 }))
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")}` } }),
)
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()
}),
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]()),
),
)
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),
)
+52 -43
View File
@@ -3,58 +3,67 @@ 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 { startServer } from "./fixture/server"
import { ServerProcess } from "../src/process"
it.live("lists, creates, and removes worktrees by project ID", () =>
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)
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)
const initial = yield* Effect.promise(() =>
fetch(url, { headers: server.headers }).then((response) => response.json()),
)
expect(initial).toEqual([{ directory: project }])
const initial = yield* Effect.promise(() => fetch(url, { headers }).then((response) => response.json()))
expect(initial).toEqual([{ directory: project }])
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 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 listed = yield* Effect.promise(() =>
fetch(url, { headers: server.headers }).then((response) => response.json()),
)
expect(listed).toContainEqual({ directory: path.join(destination, "api"), strategy: "git" })
const listed = yield* Effect.promise(() => fetch(url, { 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: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ directory: path.join(destination, "api"), force: false }),
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)
}),
)
expect(removed.status).toBe(204)
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
function isRecord(value: unknown): value is Record<string, unknown> {