Merge remote-tracking branch 'origin/dev' into move-models-dev-to-core

This commit is contained in:
Dax Raad
2026-05-13 11:13:10 -04:00
33 changed files with 3411 additions and 1491 deletions

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-xZyIgqow1wVh0Kfpb5GLUUHsE3jyfqJfrZ9Qykml008=",
"aarch64-linux": "sha256-tbbne63KImq4EQrPi45l9YG1dY/SO7b1ZKkLjDfZhWg=",
"aarch64-darwin": "sha256-PYsiSMkASbcZxqMXb7UfbkRTiQae6xzseMNhDP+/y5g=",
"x86_64-darwin": "sha256-Qnj9FAgXWyiB6U5NyIsRw7aNVNexAagETr07Jwde908="
"x86_64-linux": "sha256-cRhvzZoW6gBbE0sQm1+e+6/WgajuA6MSIL5iroFsfqs=",
"aarch64-linux": "sha256-0knZfxBULqkt5u6sXFx+a/vqw2rc6IC1+LeAd4TNFhM=",
"aarch64-darwin": "sha256-jL4tO+EHSmUF+gQGEaLzAbTxxjkL8OyhTk13vsbomgM=",
"x86_64-darwin": "sha256-bsa7IpS3GaxagcigTa0yqZTkf4e/nbcTQ9aZeb+5eHQ="
}
}

View File

@@ -24,7 +24,11 @@ export const AzurePlugin = PluginV2.define({
"aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/azure") return
if (evt.model.providerID === ProviderV2.ID.azure) {
if (!evt.options.resourceName && !evt.options.baseURL && (evt.model.endpoint.type !== "aisdk" || !evt.model.endpoint.url)) {
if (
!evt.options.resourceName &&
!evt.options.baseURL &&
(evt.model.endpoint.type !== "aisdk" || !evt.model.endpoint.url)
) {
throw new Error(
"AZURE_RESOURCE_NAME is missing, set it using env var or reconnecting the azure provider and setting it",
)
@@ -35,11 +39,7 @@ export const AzurePlugin = PluginV2.define({
}),
"aisdk.language": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.azure) return
evt.language = selectLanguage(
evt.sdk,
evt.model.apiID,
Boolean(evt.options.useCompletionUrls),
)
evt.language = selectLanguage(evt.sdk, evt.model.apiID, Boolean(evt.options.useCompletionUrls))
}),
}
}),
@@ -52,15 +52,12 @@ export const AzureCognitiveServicesPlugin = PluginV2.define({
"provider.update": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("azure-cognitive-services")) return
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
if (resourceName) evt.provider.options.aisdk.provider.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai`
if (resourceName)
evt.provider.options.aisdk.provider.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai`
}),
"aisdk.language": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return
evt.language = selectLanguage(
evt.sdk,
evt.model.apiID,
Boolean(evt.options.useCompletionUrls),
)
evt.language = selectLanguage(evt.sdk, evt.model.apiID, Boolean(evt.options.useCompletionUrls))
}),
}
}),

View File

@@ -32,7 +32,8 @@ export const GitLabPlugin = PluginV2.define({
}),
"aisdk.language": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.gitlab) return
const featureFlags = typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {}
const featureFlags =
typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {}
if (evt.model.apiID.startsWith("duo-workflow-")) {
const gitlab = yield* Effect.promise(() => import("gitlab-ai-provider")).pipe(Effect.orDie)
const workflowRef =

View File

@@ -15,7 +15,13 @@ function resolveProject(options: Record<string, any>) {
}
function resolveLocation(options: Record<string, any>) {
return options.location ?? process.env.GOOGLE_VERTEX_LOCATION ?? process.env.GOOGLE_CLOUD_LOCATION ?? process.env.VERTEX_LOCATION ?? "us-central1"
return (
options.location ??
process.env.GOOGLE_VERTEX_LOCATION ??
process.env.GOOGLE_CLOUD_LOCATION ??
process.env.VERTEX_LOCATION ??
"us-central1"
)
}
function vertexEndpoint(location: string) {
@@ -60,7 +66,10 @@ export const GoogleVertexPlugin = PluginV2.define({
if (evt.provider.endpoint.type === "aisdk" && evt.provider.endpoint.url) {
evt.provider.endpoint.url = replaceVertexVars(evt.provider.endpoint.url, project, location)
}
if (evt.provider.endpoint.type === "aisdk" && evt.provider.endpoint.package.includes("@ai-sdk/openai-compatible")) {
if (
evt.provider.endpoint.type === "aisdk" &&
evt.provider.endpoint.package.includes("@ai-sdk/openai-compatible")
) {
evt.provider.options.aisdk.provider.fetch = authFetch(evt.provider.options.aisdk.provider.fetch)
}
}),
@@ -95,8 +104,16 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({
return {
"provider.update": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("google-vertex-anthropic")) return
const project = evt.provider.options.aisdk.provider.project ?? process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCP_PROJECT ?? process.env.GCLOUD_PROJECT
const location = evt.provider.options.aisdk.provider.location ?? process.env.GOOGLE_CLOUD_LOCATION ?? process.env.VERTEX_LOCATION ?? "global"
const project =
evt.provider.options.aisdk.provider.project ??
process.env.GOOGLE_CLOUD_PROJECT ??
process.env.GCP_PROJECT ??
process.env.GCLOUD_PROJECT
const location =
evt.provider.options.aisdk.provider.location ??
process.env.GOOGLE_CLOUD_LOCATION ??
process.env.VERTEX_LOCATION ??
"global"
if (project) evt.provider.options.aisdk.provider.project = project
evt.provider.options.aisdk.provider.location = location
}),

View File

@@ -10,7 +10,7 @@ export const OpencodePlugin = PluginV2.define({
"provider.update": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.opencode) return
hasKey = Boolean(
process.env.OPENCODE_API_KEY ||
process.env.OPENCODE_API_KEY ||
evt.provider.env.some((item) => process.env[item]) ||
evt.provider.options.aisdk.provider.apiKey ||
(evt.provider.enabled && evt.provider.enabled.via === "auth"),

View File

@@ -29,7 +29,11 @@ export const SapAICorePlugin = PluginV2.define({
const match = Object.keys(mod).find((name) => name.startsWith("create"))
if (!match) throw new Error(`Package ${evt.package} has no provider factory export`)
evt.sdk = mod[match](serviceKey ? { deploymentId: process.env.AICORE_DEPLOYMENT_ID, resourceGroup: process.env.AICORE_RESOURCE_GROUP } : {})
evt.sdk = mod[match](
serviceKey
? { deploymentId: process.env.AICORE_DEPLOYMENT_ID, resourceGroup: process.env.AICORE_RESOURCE_GROUP }
: {},
)
}),
"aisdk.language": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return

View File

@@ -16,6 +16,7 @@ export interface RunOptions {
readonly maxErrorBytes?: number
readonly signal?: AbortSignal
readonly timeout?: Duration.Input
readonly stdin?: string | Uint8Array | Stream.Stream<Uint8Array, PlatformError>
}
export interface RunStreamOptions {
@@ -96,6 +97,15 @@ const waitForAbort = (signal: AbortSignal) =>
return Effect.sync(() => signal.removeEventListener("abort", onabort))
})
const normalizeStdin = (
input: string | Uint8Array | Stream.Stream<Uint8Array, PlatformError>,
): Stream.Stream<Uint8Array, PlatformError> =>
typeof input === "string"
? Stream.make(new TextEncoder().encode(input))
: input instanceof Uint8Array
? Stream.make(input)
: input
const collectStream = (stream: Stream.Stream<Uint8Array, PlatformError>, maxOutputBytes: number | undefined) =>
Stream.runFold(
stream,
@@ -119,7 +129,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const run = Effect.fn("AppProcess.run")(function* (command: ChildProcess.Command, options?: RunOptions) {
const runCommand = (command: ChildProcess.Command, options?: RunOptions) => {
const description = describeCommand(command)
const collect = Effect.scoped(
Effect.gen(function* () {
@@ -154,7 +164,22 @@ export const layer = Layer.effect(
),
)
: timed
return yield* aborted.pipe(Effect.catch((cause) => Effect.fail(wrapError(description, cause))))
return aborted.pipe(Effect.catch((cause) => Effect.fail(wrapError(description, cause))))
}
const run = Effect.fn("AppProcess.run")(function* (command: ChildProcess.Command, options?: RunOptions) {
if (options?.stdin === undefined) return yield* runCommand(command, options)
if (command._tag !== "StandardCommand") {
return yield* new AppProcessError({
command: describeCommand(command),
cause: new Error("stdin option only supports StandardCommand; received PipedCommand"),
})
}
const next = ChildProcess.make(command.command, command.args, {
...command.options,
stdin: normalizeStdin(options.stdin),
})
return yield* runCommand(next, options)
})
const runStream = (

View File

@@ -11,8 +11,9 @@ function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") {
function bedrockFetch(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") {
const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID)
return (language as { config: { fetch: (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response> } }).config
.fetch
return (
language as { config: { fetch: (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response> } }
).config.fetch
}
describe("AmazonBedrockPlugin", () => {

View File

@@ -1,4 +1,6 @@
import { describe, expect } from "bun:test"
import { realpathSync } from "node:fs"
import { tmpdir } from "node:os"
import { Effect, Exit, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { AppProcess } from "@opencode-ai/core/process"
@@ -123,6 +125,82 @@ describe("AppProcess", () => {
)
})
describe("run with stdin option", () => {
const echoStdin = "process.stdin.on('data', c => process.stdout.write(c))"
it.effect(
"feeds a string to stdin and returns it on stdout",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc.run(cmd("-e", echoStdin), { stdin: "hello" })
expect(result.exitCode).toBe(0)
expect(result.stdout.toString("utf8")).toBe("hello")
}),
)
it.effect(
"feeds a Uint8Array to stdin",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const bytes = new TextEncoder().encode("bytes")
const result = yield* svc.run(cmd("-e", echoStdin), { stdin: bytes })
expect(result.exitCode).toBe(0)
expect(result.stdout.toString("utf8")).toBe("bytes")
}),
)
it.effect(
"feeds a Stream of Uint8Array chunks to stdin",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const enc = new TextEncoder()
const stream = Stream.fromIterable([enc.encode("one"), enc.encode("-two"), enc.encode("-three")])
const result = yield* svc.run(cmd("-e", echoStdin), { stdin: stream })
expect(result.exitCode).toBe(0)
expect(result.stdout.toString("utf8")).toBe("one-two-three")
}),
)
it.effect(
"completes correctly with empty input",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc.run(cmd("-e", echoStdin), { stdin: "" })
expect(result.exitCode).toBe(0)
expect(result.stdout.toString("utf8")).toBe("")
}),
)
it.effect(
"carries existing Command options like env",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const script =
"process.stdout.write(process.env.FEED + ':'); process.stdin.on('data', c => process.stdout.write(c))"
const command = ChildProcess.make(NODE, ["-e", script], { env: { FEED: "envset" }, extendEnv: true })
const result = yield* svc.run(command, { stdin: "payload" })
expect(result.exitCode).toBe(0)
expect(result.stdout.toString("utf8")).toBe("envset:payload")
}),
)
it.effect(
"carries existing Command options like cwd",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const dir = realpathSync(tmpdir())
const script =
"process.stdout.write(process.cwd() + '|'); process.stdin.on('data', c => process.stdout.write(c))"
const command = ChildProcess.make(NODE, ["-e", script], { cwd: dir })
const result = yield* svc.run(command, { stdin: "ok" })
expect(result.exitCode).toBe(0)
const [cwd, stdin] = result.stdout.toString("utf8").split("|")
expect(realpathSync(cwd)).toBe(dir)
expect(stdin).toBe("ok")
}),
)
})
describe("runStream", () => {
it.live(
"emits lines incrementally and ends cleanly on exit 0",
@@ -136,11 +214,17 @@ describe("AppProcess", () => {
)
it.live(
"fails with AppProcessError when exit not in okExitCodes",
"okExitCodes determines whether a non-zero exit fails the stream",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const allowed = yield* svc
.runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
.pipe(Stream.runCollect)
expect(Array.from(allowed)).toEqual(["only"])
const exit = yield* Effect.exit(
svc.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0] }).pipe(Stream.runCollect),
svc
.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0, 1] })
.pipe(Stream.runCollect),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
@@ -152,17 +236,6 @@ describe("AppProcess", () => {
}),
)
it.live(
"okExitCodes allowlist treats non-zero as success",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc
.runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
.pipe(Stream.runCollect)
expect(Array.from(result)).toEqual(["only"])
}),
)
it.live(
"without okExitCodes, never fails on exit code",
Effect.gen(function* () {
@@ -177,12 +250,10 @@ describe("AppProcess", () => {
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const controller = new AbortController()
setTimeout(() => controller.abort(), 50)
controller.abort()
const exit = yield* Effect.exit(
svc
.runStream(cmd("-e", "setInterval(() => console.log('tick'), 100); setTimeout(() => {}, 60_000)"), {
signal: controller.signal,
})
.runStream(cmd("-e", "setInterval(() => {}, 60_000)"), { signal: controller.signal })
.pipe(Stream.runCollect),
)
expect(Exit.isFailure(exit)).toBe(true)

View File

@@ -3,9 +3,21 @@ import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os"
import path from "path"
import fs from "fs/promises"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { AppProcess } from "@opencode-ai/core/process"
import * as Filesystem from "../../../../util/filesystem"
import * as Process from "../../../../util/process"
const writeWithStdin = (cmd: string[], text: string): Promise<void> =>
Effect.runPromise(
AppProcess.Service.use((svc) => svc.run(ChildProcess.make(cmd[0]!, cmd.slice(1)), { stdin: text })).pipe(
Effect.provide(AppProcess.defaultLayer),
Effect.catch(() => Effect.void),
Effect.asVoid,
),
).catch(() => undefined)
// Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup
const getWhich = lazy(async () => {
const { which } = await import("../../../../util/which")
@@ -125,49 +137,23 @@ const getCopyMethod = lazy(async () => {
if (os === "linux") {
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
console.log("clipboard: using wl-copy")
return async (text: string) => {
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
return (text: string) => writeWithStdin(["wl-copy"], text)
}
if (which("xclip")) {
console.log("clipboard: using xclip")
return async (text: string) => {
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
return (text: string) => writeWithStdin(["xclip", "-selection", "clipboard"], text)
}
if (which("xsel")) {
console.log("clipboard: using xsel")
return async (text: string) => {
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
return (text: string) => writeWithStdin(["xsel", "--clipboard", "--input"], text)
}
}
if (os === "win32") {
console.log("clipboard: using powershell")
return async (text: string) => {
return (text: string) =>
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
const proc = Process.spawn(
writeWithStdin(
[
"powershell.exe",
"-NonInteractive",
@@ -175,18 +161,8 @@ const getCopyMethod = lazy(async () => {
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
],
{
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
},
text,
)
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
console.log("clipboard: no native support")

View File

@@ -10,8 +10,8 @@ import { GlobalBus } from "@/bus/global"
import { Auth } from "@/auth"
import { SyncEvent } from "@/sync"
import { EventSequenceTable, EventTable } from "@/sync/event.sql"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as Log from "@opencode-ai/core/util/log"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema"
import { Slug } from "@opencode-ai/core/util/slug"
@@ -175,6 +175,7 @@ export const layer = Layer.effect(
const http = yield* HttpClient.HttpClient
const sync = yield* SyncEvent.Service
const vcs = yield* Vcs.Service
const flags = yield* RuntimeFlags.Service
const connections = new Map<WorkspaceID, ConnectionStatus>()
const syncFibers = yield* FiberMap.make<WorkspaceID, void, SyncLoopError>()
@@ -482,7 +483,7 @@ export const layer = Layer.effect(
})
const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) {
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
if (!flags.experimentalWorkspaces) return
const adapter = getAdapter(space.projectID, space.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(space)).pipe(
@@ -1040,6 +1041,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(Project.defaultLayer),
Layer.provide(Vcs.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(RuntimeFlags.defaultLayer),
)
const TIMEOUT = 5000

View File

@@ -23,6 +23,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
}) {}

View File

@@ -4,7 +4,6 @@ import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Decimal } from "decimal.js"
import { type ProviderMetadata, type LanguageModelUsage } from "ai"
import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Database } from "@/storage/db"
@@ -38,6 +37,7 @@ import { Permission } from "@/permission"
import { Global } from "@opencode-ai/core/global"
import { Effect, Layer, Option, Context, Schema, Types } from "effect"
import { NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
import { RuntimeFlags } from "@/effect/runtime-flags"
const log = Log.create({ service: "session" })
@@ -507,12 +507,17 @@ export type Patch = Types.DeepMutable<SyncEvent.Event<typeof Event.Updated>["dat
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn))
export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service | SyncEvent.Service> = Layer.effect(
export const layer: Layer.Layer<
Service,
never,
Bus.Service | Storage.Service | SyncEvent.Service | RuntimeFlags.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const storage = yield* Storage.Service
const sync = yield* SyncEvent.Service
const flags = yield* RuntimeFlags.Service
const createNext = Effect.fn("Session.createNext")(function* (input: {
id?: SessionID
@@ -550,7 +555,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
yield* sync.run(Event.Created, { sessionID: result.id, info: result })
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
if (!flags.experimentalWorkspaces) {
// This only exist for backwards compatibility. We should not be
// manually publishing this event; it is a sync event now
yield* bus.publish(Event.Updated, {
@@ -570,7 +575,9 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
const list = Effect.fn("Session.list")(function* (input?: ListInput) {
const ctx = yield* InstanceState.context
return Array.from(listByProject({ projectID: ctx.project.id, ...input }))
return Array.from(
listByProject({ projectID: ctx.project.id, experimentalWorkspaces: flags.experimentalWorkspaces, ...input }),
)
})
const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
@@ -860,11 +867,13 @@ export const defaultLayer = layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(Storage.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
)
function* listByProject(
input: ListInput & {
projectID: ProjectID
experimentalWorkspaces: boolean
},
) {
const conditions = [eq(SessionTable.project_id, input.projectID)]
@@ -882,7 +891,7 @@ function* listByProject(
: or(...conds)!,
)
}
} else if (input.scope !== "project" && !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
} else if (input.scope !== "project" && !input.experimentalWorkspaces) {
if (input.directory) {
conditions.push(eq(SessionTable.directory, input.directory))
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,11 +7,11 @@ import type { InstanceContext } from "@/project/instance"
import { EventSequenceTable, EventTable } from "./event.sql"
import type { WorkspaceID } from "@/control-plane/schema"
import { EventID } from "./schema"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Context, Effect, Layer, Schema as EffectSchema } from "effect"
import type { DeepMutable } from "@opencode-ai/core/schema"
import { serviceUse } from "@/effect/service-use"
import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
// Keep `Event["data"]` mutable because projectors mutate the persisted shape
// when writing to the database. Bus payloads (`Properties`) stay readonly —
@@ -69,6 +69,8 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Sy
export const layer = Layer.effect(Service)(
Effect.gen(function* () {
const flags = yield* RuntimeFlags.Service
const replay: Interface["replay"] = Effect.fn("SyncEvent.replay")(function* (event, options) {
const def = registry.get(event.type)
if (!def) {
@@ -104,7 +106,12 @@ export const layer = Layer.effect(Service)(
workspace: yield* InstanceState.workspaceID,
}
: undefined
process(def, event, { publish, context, ownerID: options?.ownerID })
process(def, event, {
publish,
context,
ownerID: options?.ownerID,
experimentalWorkspaces: flags.experimentalWorkspaces,
})
})
const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) {
@@ -160,7 +167,7 @@ export const layer = Layer.effect(Service)(
const seq = row?.seq != null ? row.seq + 1 : 0
const event = { id, seq, aggregateID: agg, data }
process(def, event, { publish, context })
process(def, event, { publish, context, experimentalWorkspaces: flags.experimentalWorkspaces })
},
{
behavior: "immediate",
@@ -197,7 +204,7 @@ export const layer = Layer.effect(Service)(
}),
)
export const defaultLayer = layer
export const defaultLayer = layer.pipe(Layer.provide(RuntimeFlags.defaultLayer))
export const use = serviceUse(Service)
@@ -279,7 +286,7 @@ export function project<Def extends Definition>(
function process<Def extends Definition>(
def: Def,
event: Event<Def>,
options: { publish: boolean; context?: PublishContext; ownerID?: string },
options: { publish: boolean; context?: PublishContext; ownerID?: string; experimentalWorkspaces: boolean },
) {
if (projectors == null) {
throw new Error("No projectors available. Call `SyncEvent.init` to install projectors")
@@ -293,7 +300,7 @@ function process<Def extends Definition>(
Database.transaction((tx) => {
projector(tx, event.data, event)
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
if (options.experimentalWorkspaces) {
tx.insert(EventSequenceTable)
.values({
aggregate_id: event.aggregateID,

View File

@@ -1,98 +0,0 @@
const locks = new Map<
string,
{
readers: number
writer: boolean
waitingReaders: (() => void)[]
waitingWriters: (() => void)[]
}
>()
function get(key: string) {
if (!locks.has(key)) {
locks.set(key, {
readers: 0,
writer: false,
waitingReaders: [],
waitingWriters: [],
})
}
return locks.get(key)!
}
function process(key: string) {
const lock = locks.get(key)
if (!lock || lock.writer || lock.readers > 0) return
// Prioritize writers to prevent starvation
if (lock.waitingWriters.length > 0) {
const nextWriter = lock.waitingWriters.shift()!
nextWriter()
return
}
// Wake up all waiting readers
while (lock.waitingReaders.length > 0) {
const nextReader = lock.waitingReaders.shift()!
nextReader()
}
// Clean up empty locks
if (lock.readers === 0 && !lock.writer && lock.waitingReaders.length === 0 && lock.waitingWriters.length === 0) {
locks.delete(key)
}
}
export async function read(key: string): Promise<Disposable> {
const lock = get(key)
return new Promise((resolve) => {
if (!lock.writer && lock.waitingWriters.length === 0) {
lock.readers++
resolve({
[Symbol.dispose]: () => {
lock.readers--
process(key)
},
})
} else {
lock.waitingReaders.push(() => {
lock.readers++
resolve({
[Symbol.dispose]: () => {
lock.readers--
process(key)
},
})
})
}
})
}
export async function write(key: string): Promise<Disposable> {
const lock = get(key)
return new Promise((resolve) => {
if (!lock.writer && lock.readers === 0) {
lock.writer = true
resolve({
[Symbol.dispose]: () => {
lock.writer = false
process(key)
},
})
} else {
lock.waitingWriters.push(() => {
lock.writer = true
resolve({
[Symbol.dispose]: () => {
lock.writer = false
process(key)
},
})
})
}
})
}
export * as Lock from "./lock"

View File

@@ -6,7 +6,7 @@ import path from "node:path"
import { setTimeout as delay } from "node:timers/promises"
import { NodeHttpServer } from "@effect/platform-node"
import { Effect, Layer, Schema } from "effect"
import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { FetchHttpClient, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { eq } from "drizzle-orm"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -16,13 +16,14 @@ import { ProjectID } from "@/project/schema"
import { ProjectTable } from "@/project/project.sql"
import { Instance } from "@/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { InstanceRef } from "@/effect/instance-ref"
import { Session as SessionNs } from "@/session/session"
import { SessionID } from "@/session/schema"
import { SessionTable } from "@/session/session.sql"
import { SyncEvent } from "@/sync"
import { EventSequenceTable } from "@/sync/event.sql"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
import { disposeAllInstances, provideTmpdirInstance, TestInstance, tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { registerAdapter } from "../../src/control-plane/adapters"
import { WorkspaceID } from "../../src/control-plane/schema"
@@ -32,24 +33,43 @@ import * as Workspace from "../../src/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceStore } from "@/project/instance-store"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Auth } from "@/auth"
import { SessionPrompt } from "@/session/prompt"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
import { RuntimeFlags } from "@/effect/runtime-flags"
void Log.init({ print: false })
const testServerLayer = Layer.mergeAll(
NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }),
Workspace.defaultLayer.pipe(Layer.provide(InstanceStore.defaultLayer), Layer.provide(InstanceBootstrap.defaultLayer)),
SessionNs.defaultLayer,
)
const it = testEffect(testServerLayer)
const originalWorkspacesFlag = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const originalEnv = {
OPENCODE_AUTH_CONTENT: process.env.OPENCODE_AUTH_CONTENT,
OPENCODE_EXPERIMENTAL_WORKSPACES: process.env.OPENCODE_EXPERIMENTAL_WORKSPACES,
OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS,
OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES,
}
const workspaceLayer = (experimentalWorkspaces: boolean) =>
Workspace.layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(SessionNs.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(SessionPrompt.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(Vcs.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })),
Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))),
)
const testServerLayer = Layer.mergeAll(
NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }),
workspaceLayer(true),
SessionNs.defaultLayer,
)
const it = testEffect(testServerLayer)
type RecordedCreate = {
info: WorkspaceInfo
env: Record<string, string | undefined>
@@ -93,6 +113,7 @@ beforeEach(() => {
Database.close()
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
restoreEnv()
process.env.OPENCODE_EXPERIMENTAL_WORKSPACES = "true"
})
afterEach(async () => {
@@ -105,7 +126,7 @@ afterEach(async () => {
async function withInstance<T>(fn: (dir: string) => T | Promise<T>) {
await using tmp = await tmpdir({ git: true })
return WithInstance.provide({
return await WithInstance.provide({
directory: tmp.path,
fn: () => fn(tmp.path),
})
@@ -140,6 +161,12 @@ const isWorkspaceSyncing = (id: WorkspaceID) =>
const startWorkspaceSyncing = (projectID: ProjectID) => {
void runWorkspace(Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID)))
}
const startWorkspaceSyncingWithFlag = (projectID: ProjectID, experimentalWorkspaces: boolean) =>
Effect.runPromise(
Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID)).pipe(
Effect.provide(workspaceLayer(experimentalWorkspaces)),
),
)
const waitForWorkspaceSync = (workspaceID: WorkspaceID, state: Record<string, number>, signal?: AbortSignal) =>
runWorkspace(Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)))
@@ -979,7 +1006,6 @@ describe("workspace CRUD", () => {
describe("workspace sync state", () => {
test("startWorkspaceSyncing is disabled by the experimental workspace flag", async () => {
await withInstance(async (dir) => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
const type = unique("flag-disabled")
const info = workspaceInfo(Instance.project.id, type)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
@@ -987,38 +1013,50 @@ describe("workspace sync state", () => {
insertWorkspace(info)
registerAdapter(Instance.project.id, type, localAdapter(path.join(dir, "flag-disabled")).adapter)
startWorkspaceSyncing(Instance.project.id)
await startWorkspaceSyncingWithFlag(Instance.project.id, false)
await delay(25)
expect((await workspaceStatus()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined()
})
})
test("startWorkspaceSyncing starts all workspaces", async () => {
await withInstance(async (dir) => {
const firstType = unique("first")
const secondType = unique("second")
const first = workspaceInfo(Instance.project.id, firstType)
const second = workspaceInfo(Instance.project.id, secondType)
await fs.mkdir(path.join(dir, "first"), { recursive: true })
await fs.mkdir(path.join(dir, "second"), { recursive: true })
insertWorkspace(first)
insertWorkspace(second)
registerAdapter(Instance.project.id, firstType, localAdapter(path.join(dir, "first")).adapter)
registerAdapter(Instance.project.id, secondType, localAdapter(path.join(dir, "second")).adapter)
it.instance(
"startWorkspaceSyncing starts all workspaces",
() =>
Effect.gen(function* () {
const { directory: dir } = yield* TestInstance
const instance = yield* InstanceRef
if (!instance) return yield* Effect.die(new Error("missing test instance"))
const workspace = yield* Workspace.Service
const projectID = instance.project.id
const firstType = unique("first")
const secondType = unique("second")
const first = workspaceInfo(projectID, firstType)
const second = workspaceInfo(projectID, secondType)
yield* Effect.promise(() => fs.mkdir(path.join(dir, "first"), { recursive: true }))
yield* Effect.promise(() => fs.mkdir(path.join(dir, "second"), { recursive: true }))
yield* Effect.sync(() => {
insertWorkspace(first)
insertWorkspace(second)
registerAdapter(projectID, firstType, localAdapter(path.join(dir, "first")).adapter)
registerAdapter(projectID, secondType, localAdapter(path.join(dir, "second")).adapter)
})
yield* Effect.addFinalizer(() =>
Effect.all([workspace.remove(first.id), workspace.remove(second.id)], { discard: true }).pipe(Effect.ignore),
)
startWorkspaceSyncing(Instance.project.id)
yield* workspace.startWorkspaceSyncing(projectID)
await eventually(() =>
workspaceStatus().then((status) => {
expect(status.find((item) => item.workspaceID === first.id)?.status).toBe("connected")
expect(status.find((item) => item.workspaceID === second.id)?.status).toBe("connected")
}),
)
await removeWorkspace(first.id)
await removeWorkspace(second.id)
})
})
yield* eventuallyEffect(
Effect.gen(function* () {
const status = yield* workspace.status()
expect(status.find((item) => item.workspaceID === first.id)?.status).toBe("connected")
expect(status.find((item) => item.workspaceID === second.id)?.status).toBe("connected")
}),
)
}),
{ git: true },
)
test("local start reports error when the target directory is missing", async () => {
await withInstance(async (dir) => {

View File

@@ -34,6 +34,7 @@ describe("RuntimeFlags", () => {
expect(flags.experimentalLspTool).toBe(true)
expect(flags.experimentalPlanMode).toBe(true)
expect(flags.experimentalEventSystem).toBe(true)
expect(flags.experimentalWorkspaces).toBe(true)
expect(flags.client).toBe("desktop")
}),
)

View File

@@ -1,15 +1,13 @@
import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test"
import fs from "fs/promises"
import { describe, expect } from "bun:test"
import path from "path"
import { ConfigProvider, Deferred, Effect, Layer, ManagedRuntime, Option } from "effect"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { Bus } from "../../src/bus"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { ConfigProvider, Deferred, Effect, Layer, Option } from "effect"
import { TestInstance, provideInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
import { Config } from "@/config/config"
import { FileWatcher } from "../../src/file/watcher"
import { Git } from "../../src/git"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
@@ -25,43 +23,43 @@ const watcherConfigLayer = ConfigProvider.layer(
}),
)
const watcherLayer = FileWatcher.layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(watcherConfigLayer),
)
const it = testEffect(Layer.mergeAll(AppFileSystem.defaultLayer, Git.defaultLayer))
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
/** Run `body` with a live FileWatcher service. */
function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
return WithInstance.provide({
directory,
fn: async () => {
const layer: Layer.Layer<FileWatcher.Service, never, never> = FileWatcher.layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(watcherConfigLayer),
)
const rt = ManagedRuntime.make(layer)
try {
await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
await Effect.runPromise(ready(directory))
await Effect.runPromise(body)
} finally {
await rt.dispose()
}
},
})
function withWatcher<A, E, R>(directory: string, body: Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
const watcher = yield* FileWatcher.Service
yield* watcher.init()
yield* ready(directory)
return yield* body
}).pipe(Effect.provide(watcherLayer), provideInstance(directory), Effect.scoped)
}
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
let done = false
const unsub = Bus.subscribe(FileWatcher.Event.Updated, (evt) => {
const on = (evt: GlobalEvent) => {
if (done) return
if (!check(evt.properties)) return
hit(evt.properties)
})
if (evt.directory !== directory) return
if (evt.payload.type !== FileWatcher.Event.Updated.type) return
if (!check(evt.payload.properties)) return
hit(evt.payload.properties)
}
GlobalBus.on("event", on)
return () => {
if (done) return
done = true
unsub()
GlobalBus.off("event", on)
}
}
@@ -72,7 +70,7 @@ function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
let off = () => {}
off = listen(directory, check, (evt) => {
off()
Deferred.doneUnsafe(deferred, Effect.succeed(evt))
Effect.runFork(Deferred.succeed(deferred, evt))
})
return off
})
@@ -86,7 +84,12 @@ function nextUpdate<E>(directory: string, check: (evt: WatcherEvent) => boolean,
({ deferred }) =>
Effect.gen(function* () {
yield* trigger
return yield* Deferred.await(deferred).pipe(Effect.timeout("5 seconds"))
return yield* Deferred.await(deferred).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () => Effect.fail(new Error("timed out waiting for file watcher update")),
}),
)
}),
({ cleanup }) => Effect.sync(cleanup),
)
@@ -104,7 +107,11 @@ function noUpdate<E>(
({ deferred }) =>
Effect.gen(function* () {
yield* trigger
expect(yield* Deferred.await(deferred).pipe(Effect.timeoutOption(`${ms} millis`))).toEqual(Option.none())
const result = yield* Deferred.await(deferred).pipe(
Effect.map((evt) => Option.some(evt)),
Effect.timeoutOrElse({ duration: `${ms} millis`, orElse: () => Effect.succeed(Option.none()) }),
)
expect(result).toEqual(Option.none())
}),
({ cleanup }) => Effect.sync(cleanup),
)
@@ -115,29 +122,25 @@ function ready(directory: string) {
const head = path.join(directory, ".git", "HEAD")
return Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
yield* nextUpdate(
directory,
(evt) => evt.file === file && evt.event === "add",
Effect.promise(() => fs.writeFile(file, "ready")),
).pipe(Effect.ensuring(Effect.promise(() => fs.rm(file, { force: true }).catch(() => undefined))), Effect.asVoid)
fs.writeFileString(file, "ready"),
).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
const git = yield* Effect.promise(() =>
fs
.stat(head)
.then(() => true)
.catch(() => false),
)
if (!git) return
if (!(yield* fs.existsSafe(head))) return
const branch = `watch-${Math.random().toString(36).slice(2)}`
const hash = yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(directory).quiet().text())
const hash = (yield* git.run(["rev-parse", "HEAD"], { cwd: directory })).text()
yield* nextUpdate(
directory,
(evt) => evt.file === head && evt.event !== "unlink",
Effect.promise(async () => {
await fs.writeFile(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n")
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
}),
fs
.writeFileString(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n")
.pipe(Effect.andThen(fs.writeFileString(head, `ref: refs/heads/${branch}\n`))),
).pipe(Effect.asVoid)
})
}
@@ -147,104 +150,114 @@ function ready(directory: string) {
// ---------------------------------------------------------------------------
describeWatcher("FileWatcher", () => {
afterEach(async () => {
await disposeAllInstances()
})
it.instance(
"publishes root create, update, and delete events",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const fs = yield* AppFileSystem.Service
const file = path.join(test.directory, "watch.txt")
const cases = [
{ event: "add" as const, trigger: fs.writeFileString(file, "a") },
{ event: "change" as const, trigger: fs.writeFileString(file, "b") },
{ event: "unlink" as const, trigger: fs.remove(file) },
]
test("publishes root create, update, and delete events", async () => {
await using tmp = await tmpdir({ git: true })
const file = path.join(tmp.path, "watch.txt")
const dir = tmp.path
const cases = [
{ event: "add" as const, trigger: Effect.promise(() => fs.writeFile(file, "a")) },
{ event: "change" as const, trigger: Effect.promise(() => fs.writeFile(file, "b")) },
{ event: "unlink" as const, trigger: Effect.promise(() => fs.unlink(file)) },
]
await withWatcher(
dir,
Effect.forEach(cases, ({ event, trigger }) =>
nextUpdate(dir, (evt) => evt.file === file && evt.event === event, trigger).pipe(
Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event }))),
),
),
)
})
test("watches non-git roots", async () => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "plain.txt")
const dir = tmp.path
await withWatcher(
dir,
nextUpdate(
dir,
(e) => e.file === file && e.event === "add",
Effect.promise(() => fs.writeFile(file, "plain")),
).pipe(Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event: "add" })))),
)
})
test("cleanup stops publishing events", async () => {
await using tmp = await tmpdir({ git: true })
const file = path.join(tmp.path, "after-dispose.txt")
// Start and immediately stop the watcher (withWatcher disposes on exit)
await withWatcher(tmp.path, Effect.void)
// Now write a file — no watcher should be listening
await WithInstance.provide({
directory: tmp.path,
fn: () =>
Effect.runPromise(
noUpdate(
tmp.path,
(e) => e.file === file,
Effect.promise(() => fs.writeFile(file, "gone")),
yield* withWatcher(
test.directory,
Effect.forEach(cases, ({ event, trigger }) =>
nextUpdate(test.directory, (evt) => evt.file === file && evt.event === event, trigger).pipe(
Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event }))),
),
),
)
}),
{ git: true },
)
it.instance("watches non-git roots", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const fs = yield* AppFileSystem.Service
const file = path.join(test.directory, "plain.txt")
yield* withWatcher(
test.directory,
nextUpdate(test.directory, (e) => e.file === file && e.event === "add", fs.writeFileString(file, "plain")).pipe(
Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event: "add" }))),
),
})
})
)
}),
)
test("ignores .git/index changes", async () => {
await using tmp = await tmpdir({ git: true })
const gitIndex = path.join(tmp.path, ".git", "index")
const edit = path.join(tmp.path, "tracked.txt")
it.instance(
"cleanup stops publishing events",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const fs = yield* AppFileSystem.Service
const file = path.join(test.directory, "after-dispose.txt")
await withWatcher(
tmp.path,
noUpdate(
tmp.path,
(e) => e.file === gitIndex,
Effect.promise(async () => {
await fs.writeFile(edit, "a")
await $`git add .`.cwd(tmp.path).quiet().nothrow()
}),
),
)
})
// Start and immediately stop the watcher (withWatcher disposes on exit).
yield* withWatcher(test.directory, Effect.void)
test("publishes .git/HEAD events", async () => {
await using tmp = await tmpdir({ git: true })
const head = path.join(tmp.path, ".git", "HEAD")
const branch = `watch-${Math.random().toString(36).slice(2)}`
await $`git branch ${branch}`.cwd(tmp.path).quiet()
// Now write a file - no watcher should be listening.
yield* noUpdate(test.directory, (e) => e.file === file, fs.writeFileString(file, "gone")).pipe(
provideInstance(test.directory),
)
}),
{ git: true },
)
await withWatcher(
tmp.path,
nextUpdate(
tmp.path,
(evt) => evt.file === head && evt.event !== "unlink",
Effect.promise(() => fs.writeFile(head, `ref: refs/heads/${branch}\n`)),
).pipe(
Effect.tap((evt) =>
Effect.sync(() => {
expect(evt.file).toBe(head)
expect(["add", "change"]).toContain(evt.event)
}),
),
),
)
})
it.instance(
"ignores .git/index changes",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const gitIndex = path.join(test.directory, ".git", "index")
const edit = path.join(test.directory, "tracked.txt")
yield* withWatcher(
test.directory,
noUpdate(
test.directory,
(e) => e.file === gitIndex,
fs.writeFileString(edit, "a").pipe(Effect.andThen(git.run(["add", "."], { cwd: test.directory }))),
),
)
}),
{ git: true },
)
it.instance(
"publishes .git/HEAD events",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const head = path.join(test.directory, ".git", "HEAD")
const branch = `watch-${Math.random().toString(36).slice(2)}`
yield* git.run(["branch", branch], { cwd: test.directory })
yield* withWatcher(
test.directory,
nextUpdate(
test.directory,
(evt) => evt.file === head && evt.event !== "unlink",
fs.writeFileString(head, `ref: refs/heads/${branch}\n`),
).pipe(
Effect.tap((evt) =>
Effect.sync(() => {
expect(evt.file).toBe(head)
expect(["add", "change"]).toContain(evt.event)
}),
),
),
)
}),
{ git: true },
)
})

View File

@@ -1,9 +1,10 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Effect, Layer, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { Installation } from "../../src/installation"
import { InstallationChannel } from "@opencode-ai/core/installation/version"
import { testEffect } from "../lib/effect"
const encoder = new TextEncoder()
@@ -51,86 +52,84 @@ function testLayer(
describe("installation", () => {
describe("latest", () => {
test("reads release version from GitHub releases", async () => {
const layer = testLayer(() => jsonResponse({ tag_name: "v1.2.3" }))
testEffect(testLayer(() => jsonResponse({ tag_name: "v1.2.3" }))).effect(
"reads release version from GitHub releases",
() =>
Effect.gen(function* () {
const result = yield* Installation.Service.use((svc) => svc.latest("unknown"))
expect(result).toBe("1.2.3")
}),
)
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("unknown")).pipe(Effect.provide(layer)),
)
expect(result).toBe("1.2.3")
})
testEffect(testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" }))).effect(
"strips v prefix from GitHub release tag",
() =>
Effect.gen(function* () {
const result = yield* Installation.Service.use((svc) => svc.latest("curl"))
expect(result).toBe("4.0.0-beta.1")
}),
)
test("strips v prefix from GitHub release tag", async () => {
const layer = testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" }))
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("curl")).pipe(Effect.provide(layer)),
)
expect(result).toBe("4.0.0-beta.1")
})
test("reads npm versions via registry", async () => {
const calls: string[] = []
const layer = testLayer((request) => {
calls.push(request.url)
const npmCalls: string[] = []
testEffect(
testLayer((request) => {
npmCalls.push(request.url)
return jsonResponse({ version: "1.5.0" })
})
}),
).effect("reads npm versions via registry", () =>
Effect.gen(function* () {
const result = yield* Installation.Service.use((svc) => svc.latest("npm"))
expect(result).toBe("1.5.0")
expect(npmCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
}),
)
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)),
)
expect(result).toBe("1.5.0")
expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
})
test("reads bun versions via registry", async () => {
const calls: string[] = []
const layer = testLayer((request) => {
calls.push(request.url)
const bunCalls: string[] = []
testEffect(
testLayer((request) => {
bunCalls.push(request.url)
return jsonResponse({ version: "1.6.0" })
})
}),
).effect("reads bun versions via registry", () =>
Effect.gen(function* () {
const result = yield* Installation.Service.use((svc) => svc.latest("bun"))
expect(result).toBe("1.6.0")
expect(bunCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
}),
)
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)),
)
expect(result).toBe("1.6.0")
expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
})
test("reads pnpm versions via registry", async () => {
const calls: string[] = []
const layer = testLayer((request) => {
calls.push(request.url)
const pnpmCalls: string[] = []
testEffect(
testLayer((request) => {
pnpmCalls.push(request.url)
return jsonResponse({ version: "1.7.0" })
})
}),
).effect("reads pnpm versions via registry", () =>
Effect.gen(function* () {
const result = yield* Installation.Service.use((svc) => svc.latest("pnpm"))
expect(result).toBe("1.7.0")
expect(pnpmCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
}),
)
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("pnpm")).pipe(Effect.provide(layer)),
)
expect(result).toBe("1.7.0")
expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
})
testEffect(testLayer(() => jsonResponse({ version: "2.3.4" }))).effect("reads scoop manifest versions", () =>
Effect.gen(function* () {
const result = yield* Installation.Service.use((svc) => svc.latest("scoop"))
expect(result).toBe("2.3.4")
}),
)
test("reads scoop manifest versions", async () => {
const layer = testLayer(() => jsonResponse({ version: "2.3.4" }))
testEffect(testLayer(() => jsonResponse({ d: { results: [{ Version: "3.4.5" }] } }))).effect(
"reads chocolatey feed versions",
() =>
Effect.gen(function* () {
const result = yield* Installation.Service.use((svc) => svc.latest("choco"))
expect(result).toBe("3.4.5")
}),
)
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("scoop")).pipe(Effect.provide(layer)),
)
expect(result).toBe("2.3.4")
})
test("reads chocolatey feed versions", async () => {
const layer = testLayer(() => jsonResponse({ d: { results: [{ Version: "3.4.5" }] } }))
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("choco")).pipe(Effect.provide(layer)),
)
expect(result).toBe("3.4.5")
})
test("reads brew formulae API versions", async () => {
const layer = testLayer(
testEffect(
testLayer(
() => jsonResponse({ versions: { stable: "2.0.0" } }),
(cmd, args) => {
// getBrewFormula: return core formula (no tap)
@@ -138,31 +137,31 @@ describe("installation", () => {
if (cmd === "brew" && args.includes("--formula") && args.includes("opencode")) return "opencode"
return ""
},
)
),
).effect("reads brew formulae API versions", () =>
Effect.gen(function* () {
const result = yield* Installation.Service.use((svc) => svc.latest("brew"))
expect(result).toBe("2.0.0")
}),
)
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)),
)
expect(result).toBe("2.0.0")
const brewInfoJson = JSON.stringify({
formulae: [{ versions: { stable: "2.1.0" } }],
})
test("reads brew tap info JSON via CLI", async () => {
const brewInfoJson = JSON.stringify({
formulae: [{ versions: { stable: "2.1.0" } }],
})
const layer = testLayer(
testEffect(
testLayer(
() => jsonResponse({}), // HTTP not used for tap formula
(cmd, args) => {
if (cmd === "brew" && args.includes("anomalyco/tap/opencode") && args.includes("--formula")) return "opencode"
if (cmd === "brew" && args.includes("--json=v2")) return brewInfoJson
return ""
},
)
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)),
)
expect(result).toBe("2.1.0")
})
),
).effect("reads brew tap info JSON via CLI", () =>
Effect.gen(function* () {
const result = yield* Installation.Service.use((svc) => svc.latest("brew"))
expect(result).toBe("2.1.0")
}),
)
})
})

View File

@@ -1,15 +1,19 @@
import { test, expect, mock, beforeEach } from "bun:test"
import { expect, mock, beforeEach } from "bun:test"
import { EventEmitter } from "events"
import { Effect } from "effect"
import { Deferred, Effect, Layer, Option } from "effect"
import type { Duration } from "effect"
import { testEffect } from "../lib/effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
// Track open() calls and control failure behavior
let openShouldFail = false
let openCalledWith: string | undefined
let openDeferred: Deferred.Deferred<string> | undefined
void mock.module("open", () => ({
default: async (url: string) => {
openCalledWith = url
if (openDeferred) Effect.runSync(Deferred.succeed(openDeferred, url).pipe(Effect.ignore))
// Return a mock subprocess that emits an error if openShouldFail is true
const subprocess = new EventEmitter()
@@ -97,173 +101,135 @@ void mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({
beforeEach(() => {
openShouldFail = false
openCalledWith = undefined
openDeferred = undefined
transportCalls.length = 0
})
// Import modules after mocking
const { MCP } = await import("../../src/mcp/index")
const { AppRuntime } = await import("../../src/effect/app-runtime")
const { Bus } = await import("../../src/bus")
const { Config } = await import("../../src/config/config")
const { McpAuth } = await import("../../src/mcp/auth")
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
const { Instance } = await import("../../src/project/instance")
const { WithInstance } = await import("../../src/project/with-instance")
const { tmpdir } = await import("../fixture/fixture")
const { AppFileSystem } = await import("@opencode-ai/core/filesystem")
const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner")
const mcpTest = testEffect(
MCP.layer.pipe(
Layer.provide(McpAuth.defaultLayer),
Layer.provideMerge(Bus.layer),
Layer.provide(Config.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
),
)
const service = MCP.Service as unknown as Effect.Effect<MCPNS.Interface, never, never>
test("BrowserOpenFailed event is published when open() throws", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
`${dir}/opencode.json`,
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
"test-oauth-server": {
type: "remote",
url: "https://example.com/mcp",
},
},
}),
)
const config = (name: string) => ({
mcp: {
[name]: {
type: "remote" as const,
url: "https://example.com/mcp",
},
},
})
const withCallbackStop = Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
const awaitWithTimeout = <A, E, R>(
self: Effect.Effect<A, E, R>,
message: string,
duration: Duration.Input = "5 seconds",
) =>
self.pipe(
Effect.timeoutOrElse({
duration,
orElse: () => Effect.fail(new Error(message)),
}),
)
const trackBrowserOpen = Effect.gen(function* () {
const opened = yield* Deferred.make<string>()
openDeferred = opened
yield* Effect.addFinalizer(() => Effect.sync(() => (openDeferred = undefined)))
return opened
})
const trackBrowserOpenFailed = Effect.gen(function* () {
const bus = yield* Bus.Service
const event = yield* Deferred.make<{ mcpName: string; url: string }>()
const unsubscribe = yield* bus.subscribeCallback(MCP.BrowserOpenFailed, (evt) => {
Effect.runSync(Deferred.succeed(event, evt.properties).pipe(Effect.ignore))
})
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
return event
})
const authenticateScoped = (name: string) =>
Effect.gen(function* () {
const mcp = yield* service
yield* mcp.authenticate(name).pipe(
Effect.ignore,
Effect.catchCause(() => Effect.void),
Effect.forkScoped,
)
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
mcpTest.instance(
"BrowserOpenFailed event is published when open() throws",
() =>
Effect.gen(function* () {
yield* withCallbackStop
openShouldFail = true
const events: Array<{ mcpName: string; url: string }> = []
const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => {
events.push(evt.properties)
})
const event = yield* trackBrowserOpenFailed
yield* authenticateScoped("test-oauth-server")
// Run authenticate with a timeout to avoid waiting forever for the callback
// Attach a handler immediately so callback shutdown rejections
// don't show up as unhandled between tests.
const authPromise = AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* service
return yield* mcp.authenticate("test-oauth-server")
}),
).catch(() => undefined)
const failure = yield* awaitWithTimeout(Deferred.await(event), "Timed out waiting for BrowserOpenFailed event")
// Config.get() can be slow in tests, so give it plenty of time.
await new Promise((resolve) => setTimeout(resolve, 2_000))
expect(failure.mcpName).toBe("test-oauth-server")
expect(failure.url).toContain("https://")
}),
{ config: config("test-oauth-server") },
)
// Stop the callback server and cancel any pending auth
await McpOAuthCallback.stop()
await authPromise
unsubscribe()
// Verify the BrowserOpenFailed event was published
expect(events.length).toBe(1)
expect(events[0].mcpName).toBe("test-oauth-server")
expect(events[0].url).toContain("https://")
},
})
})
test("BrowserOpenFailed event is NOT published when open() succeeds", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
`${dir}/opencode.json`,
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
"test-oauth-server-2": {
type: "remote",
url: "https://example.com/mcp",
},
},
}),
)
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
mcpTest.instance(
"BrowserOpenFailed event is NOT published when open() succeeds",
() =>
Effect.gen(function* () {
yield* withCallbackStop
openShouldFail = false
const events: Array<{ mcpName: string; url: string }> = []
const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => {
events.push(evt.properties)
})
const opened = yield* trackBrowserOpen
const event = yield* trackBrowserOpenFailed
yield* authenticateScoped("test-oauth-server-2")
// Run authenticate with a timeout to avoid waiting forever for the callback
const authPromise = AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* service
return yield* mcp.authenticate("test-oauth-server-2")
}),
).catch(() => undefined)
yield* awaitWithTimeout(Deferred.await(opened), "Timed out waiting for open()")
const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis"))
// Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
await new Promise((resolve) => setTimeout(resolve, 2_000))
// Stop the callback server and cancel any pending auth
await McpOAuthCallback.stop()
await authPromise
unsubscribe()
// Verify NO BrowserOpenFailed event was published
expect(events.length).toBe(0)
// Verify open() was still called
expect(failure).toEqual(Option.none())
expect(openCalledWith).toBeDefined()
},
})
})
}),
{ config: config("test-oauth-server-2") },
)
test("open() is called with the authorization URL", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
`${dir}/opencode.json`,
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
"test-oauth-server-3": {
type: "remote",
url: "https://example.com/mcp",
},
},
}),
)
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
mcpTest.instance(
"open() is called with the authorization URL",
() =>
Effect.gen(function* () {
yield* withCallbackStop
openShouldFail = false
openCalledWith = undefined
// Run authenticate with a timeout to avoid waiting forever for the callback
const authPromise = AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* service
return yield* mcp.authenticate("test-oauth-server-3")
}),
).catch(() => undefined)
const opened = yield* trackBrowserOpen
const event = yield* trackBrowserOpenFailed
yield* authenticateScoped("test-oauth-server-3")
// Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
await new Promise((resolve) => setTimeout(resolve, 2_000))
const url = yield* awaitWithTimeout(Deferred.await(opened), "Timed out waiting for open()")
const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis"))
// Stop the callback server and cancel any pending auth
await McpOAuthCallback.stop()
await authPromise
// Verify open was called with a URL
expect(openCalledWith).toBeDefined()
expect(typeof openCalledWith).toBe("string")
expect(openCalledWith!).toContain("https://")
},
})
})
expect(failure).toEqual(Option.none())
expect(typeof url).toBe("string")
expect(url).toContain("https://")
}),
{ config: config("test-oauth-server-3") },
)

View File

@@ -1,16 +1,12 @@
import { afterEach, describe, test, expect } from "bun:test"
import { describe, test, expect } from "bun:test"
import { Effect } from "effect"
import { Permission } from "../src/permission"
import { Config } from "@/config/config"
import { Instance } from "../src/project/instance"
import { WithInstance } from "../src/project/with-instance"
import { disposeAllInstances, tmpdir } from "./fixture/fixture"
import { AppRuntime } from "../src/effect/app-runtime"
import { testEffect } from "./lib/effect"
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
const it = testEffect(Config.defaultLayer)
afterEach(async () => {
await disposeAllInstances()
})
const load = Config.Service.use((svc) => svc.get())
describe("Permission.evaluate for permission.task", () => {
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset =>
@@ -147,8 +143,18 @@ describe("Permission.disabled for task tool", () => {
// Integration tests that load permissions from real config files
describe("permission.task with real config files", () => {
test("loads task permissions from opencode.json config", async () => {
await using tmp = await tmpdir({
it.instance(
"loads task permissions from opencode.json config",
() =>
Effect.gen(function* () {
const config = yield* load
const ruleset = Permission.fromConfig(config.permission ?? {})
// general and orchestrator-fast should be allowed, code-reviewer denied
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
}),
{
git: true,
config: {
permission: {
@@ -158,22 +164,21 @@ describe("permission.task with real config files", () => {
},
},
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
const ruleset = Permission.fromConfig(config.permission ?? {})
// general and orchestrator-fast should be allowed, code-reviewer denied
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
},
})
})
},
)
test("loads task permissions with wildcard patterns from config", async () => {
await using tmp = await tmpdir({
it.instance(
"loads task permissions with wildcard patterns from config",
() =>
Effect.gen(function* () {
const config = yield* load
const ruleset = Permission.fromConfig(config.permission ?? {})
// general and code-reviewer should be ask, orchestrator-* denied
expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
}),
{
git: true,
config: {
permission: {
@@ -183,22 +188,21 @@ describe("permission.task with real config files", () => {
},
},
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
const ruleset = Permission.fromConfig(config.permission ?? {})
// general and code-reviewer should be ask, orchestrator-* denied
expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
},
})
})
},
)
test("evaluate respects task permission from config", async () => {
await using tmp = await tmpdir({
it.instance(
"evaluate respects task permission from config",
() =>
Effect.gen(function* () {
const config = yield* load
const ruleset = Permission.fromConfig(config.permission ?? {})
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
// Unspecified agents default to "ask"
expect(Permission.evaluate("task", "unknown-agent", ruleset).action).toBe("ask")
}),
{
git: true,
config: {
permission: {
@@ -208,38 +212,14 @@ describe("permission.task with real config files", () => {
},
},
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
const ruleset = Permission.fromConfig(config.permission ?? {})
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
// Unspecified agents default to "ask"
expect(Permission.evaluate("task", "unknown-agent", ruleset).action).toBe("ask")
},
})
})
},
)
test("mixed permission config with task and other tools", async () => {
await using tmp = await tmpdir({
git: true,
config: {
permission: {
bash: "allow",
edit: "ask",
task: {
"*": "deny",
general: "allow",
},
},
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
it.instance(
"mixed permission config with task and other tools",
() =>
Effect.gen(function* () {
const config = yield* load
const ruleset = Permission.fromConfig(config.permission ?? {})
// Verify task permissions
@@ -257,27 +237,27 @@ describe("permission.task with real config files", () => {
// task is NOT disabled because disabled() uses findLast, and the last rule
// matching "task" permission is {pattern: "general", action: "allow"}, not pattern: "*"
expect(disabled.has("task")).toBe(false)
},
})
})
test("task tool disabled when global deny comes last in config", async () => {
await using tmp = await tmpdir({
}),
{
git: true,
config: {
permission: {
bash: "allow",
edit: "ask",
task: {
general: "allow",
"code-reviewer": "allow",
"*": "deny",
general: "allow",
},
},
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
},
)
it.instance(
"task tool disabled when global deny comes last in config",
() =>
Effect.gen(function* () {
const config = yield* load
const ruleset = Permission.fromConfig(config.permission ?? {})
// Last matching rule wins - "*" deny is last, so all agents are denied
@@ -289,26 +269,26 @@ describe("permission.task with real config files", () => {
// and sees pattern: "*" with action: "deny", so task is disabled
const disabled = Permission.disabled(["task"], ruleset)
expect(disabled.has("task")).toBe(true)
},
})
})
test("task tool NOT disabled when specific allow comes last in config", async () => {
await using tmp = await tmpdir({
}),
{
git: true,
config: {
permission: {
task: {
"*": "deny",
general: "allow",
"code-reviewer": "allow",
"*": "deny",
},
},
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
},
)
it.instance(
"task tool NOT disabled when specific allow comes last in config",
() =>
Effect.gen(function* () {
const config = yield* load
const ruleset = Permission.fromConfig(config.permission ?? {})
// Evaluate uses findLast - "general" allow comes after "*" deny
@@ -321,7 +301,17 @@ describe("permission.task with real config files", () => {
// So the task tool is NOT disabled (even though most subagents are denied)
const disabled = Permission.disabled(["task"], ruleset)
expect(disabled.has("task")).toBe(false)
}),
{
git: true,
config: {
permission: {
task: {
"*": "deny",
general: "allow",
},
},
},
})
})
},
)
})

View File

@@ -1,5 +1,6 @@
import { afterAll, afterEach, describe, expect } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
@@ -17,6 +18,11 @@ import { Plugin } from "../../src/plugin/index"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project"
import { Vcs } from "../../src/project/vcs"
import { Session } from "../../src/session/session"
import { SessionPrompt } from "../../src/session/prompt"
import { SyncEvent } from "../../src/sync"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { NpmTest } from "../fake/npm"
@@ -42,8 +48,16 @@ const pluginLayer = Plugin.layer.pipe(
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })),
)
const noopBootstrapLayer = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
const workspaceLayer = Workspace.defaultLayer.pipe(
const workspaceLayer = Workspace.layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(SessionPrompt.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(Vcs.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrapLayer))),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: true })),
)
const it = testEffect(Layer.mergeAll(pluginLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer))

View File

@@ -35,6 +35,7 @@ process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")
process.env["XDG_STATE_HOME"] = path.join(dir, "state")
process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json")
process.env["OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"] = "true"
process.env["OPENCODE_EXPERIMENTAL_WORKSPACES"] = "true"
// Set test home directory to isolate tests from user's actual home directory
// This prevents tests from picking up real user configs/skills from ~/.claude/skills

View File

@@ -1,19 +1,28 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { Session as SessionNs } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture"
import { Flag } from "@opencode-ai/core/flag/flag"
import { mkdir } from "fs/promises"
import path from "path"
import { Database } from "@/storage/db"
import { SessionTable } from "@/session/session.sql"
import { eq } from "drizzle-orm"
import { testEffect } from "../lib/effect"
import { Bus } from "@/bus"
import { Storage } from "@/storage/storage"
import { SyncEvent } from "@/sync"
import { RuntimeFlags } from "@/effect/runtime-flags"
void Log.init({ print: false })
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const it = testEffect(SessionNs.defaultLayer)
const it = testEffect(
SessionNs.layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(Storage.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })),
),
)
const withSession = (input?: Parameters<SessionNs.Interface["create"]>[0]) =>
Effect.acquireRelease(
@@ -22,7 +31,6 @@ const withSession = (input?: Parameters<SessionNs.Interface["create"]>[0]) =>
)
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await disposeAllInstances()
})
@@ -31,7 +39,6 @@ describe("session.list", () => {
"does not filter by directory when directory is omitted",
() =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
const test = yield* TestInstance
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true }))
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
@@ -60,7 +67,6 @@ describe("session.list", () => {
"filters by directory when directory is provided",
() =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
const test = yield* TestInstance
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true }))
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
@@ -91,7 +97,6 @@ describe("session.list", () => {
"filters by path and ignores directory when path is provided",
() =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
const test = yield* TestInstance
yield* Effect.promise(() =>
mkdir(path.join(test.directory, "packages", "opencode", "src", "deep"), { recursive: true }),
@@ -129,7 +134,6 @@ describe("session.list", () => {
"falls back to directory when filtering legacy sessions without path",
() =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
const test = yield* TestInstance
yield* Effect.promise(() =>
mkdir(path.join(test.directory, "packages", "opencode", "src"), { recursive: true }),

View File

@@ -3,16 +3,29 @@ import { Deferred, Effect, Exit, Layer } from "effect"
import { Session as SessionNs } from "@/session/session"
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { Bus } from "@/bus"
import { Storage } from "@/storage/storage"
import { SyncEvent } from "@/sync"
import { RuntimeFlags } from "@/effect/runtime-flags"
void Log.init({ print: false })
const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer))
const it = testEffect(
Layer.mergeAll(
SessionNs.layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(Storage.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })),
),
CrossSpawnSpawner.defaultLayer,
),
)
const awaitDeferred = <T>(deferred: Deferred.Deferred<T>, message: string) =>
Effect.race(
@@ -56,8 +69,6 @@ describe("session.created event", () => {
it.instance("session.created event should be emitted before session.updated", () =>
Effect.gen(function* () {
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
const session = yield* SessionNs.Service
const events: string[] = []
const received = yield* Deferred.make<string[]>()

View File

@@ -1,4 +1,4 @@
import { describe, expect, beforeEach, afterEach, afterAll } from "bun:test"
import { describe, expect, beforeEach, afterAll } from "bun:test"
import { provideTmpdirInstance } from "../fixture/fixture"
import { Effect, Layer, Schema } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
@@ -7,21 +7,19 @@ import { SyncEvent } from "../../src/sync"
import { Database, eq } from "@/storage/db"
import { EventSequenceTable, EventTable } from "../../src/sync/event.sql"
import { MessageID } from "../../src/session/schema"
import { Flag } from "@opencode-ai/core/flag/flag"
import { initProjectors } from "../../src/server/projectors"
import { testEffect } from "../lib/effect"
import { RuntimeFlags } from "@/effect/runtime-flags"
const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const it = testEffect(Layer.mergeAll(SyncEvent.defaultLayer, CrossSpawnSpawner.defaultLayer))
const it = testEffect(
Layer.mergeAll(
SyncEvent.layer.pipe(Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: true }))),
CrossSpawnSpawner.defaultLayer,
),
)
beforeEach(() => {
Database.close()
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
})
afterEach(() => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original
})
describe("SyncEvent", () => {

View File

@@ -1,72 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Lock } from "@/util/lock"
function tick() {
return new Promise<void>((r) => queueMicrotask(r))
}
async function flush(n = 5) {
for (let i = 0; i < n; i++) await tick()
}
describe("util.lock", () => {
test("writer exclusivity: blocks reads and other writes while held", async () => {
const key = "lock:" + Math.random().toString(36).slice(2)
const state = {
writer2: false,
reader: false,
writers: 0,
}
// Acquire writer1
using writer1 = await Lock.write(key)
state.writers++
expect(state.writers).toBe(1)
// Start writer2 candidate (should block)
const writer2Task = (async () => {
const w = await Lock.write(key)
state.writers++
expect(state.writers).toBe(1)
state.writer2 = true
// Hold for a tick so reader cannot slip in
await tick()
return w
})()
// Start reader candidate (should block)
const readerTask = (async () => {
const r = await Lock.read(key)
state.reader = true
return r
})()
// Flush microtasks and assert neither acquired
await flush()
expect(state.writer2).toBe(false)
expect(state.reader).toBe(false)
// Release writer1
writer1[Symbol.dispose]()
state.writers--
// writer2 should acquire next
const writer2 = await writer2Task
expect(state.writer2).toBe(true)
// Reader still blocked while writer2 held
await flush()
expect(state.reader).toBe(false)
// Release writer2
writer2[Symbol.dispose]()
state.writers--
// Reader should now acquire
const reader = await readerTask
expect(state.reader).toBe(true)
reader[Symbol.dispose]()
})
})

View File

@@ -198,6 +198,9 @@ import type {
TuiShowToastResponses,
TuiSubmitPromptResponses,
V2ModelListResponses,
V2ProviderGetErrors,
V2ProviderGetResponses,
V2ProviderListResponses,
V2SessionCompactResponses,
V2SessionContextResponses,
V2SessionListErrors,
@@ -4382,26 +4385,41 @@ export class Model extends HeyApiClient {
*
* Retrieve available v2 models ordered by release date.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
public list<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).get<V2ModelListResponses, unknown, ThrowOnError>({
url: "/api/model",
...options,
})
}
}
export class Provider2 extends HeyApiClient {
/**
* List v2 providers
*
* Retrieve active v2 AI providers so clients can show provider availability and configuration.
*/
public list<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).get<V2ProviderListResponses, unknown, ThrowOnError>({
url: "/api/provider",
...options,
})
}
/**
* Get v2 provider
*
* Retrieve a single v2 AI provider so clients can inspect its availability and endpoint settings.
*/
public get<ThrowOnError extends boolean = false>(
parameters: {
providerID: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<V2ModelListResponses, unknown, ThrowOnError>({
url: "/api/model",
const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "providerID" }] }])
return (options?.client ?? this.client).get<V2ProviderGetResponses, V2ProviderGetErrors, ThrowOnError>({
url: "/api/provider/{providerID}",
...options,
...params,
})
@@ -4418,6 +4436,11 @@ export class V2 extends HeyApiClient {
get model(): Model {
return (this._model ??= new Model({ client: this.client }))
}
private _provider?: Provider2
get provider(): Provider2 {
return (this._provider ??= new Provider2({ client: this.client }))
}
}
export class Control extends HeyApiClient {

View File

@@ -3380,10 +3380,14 @@ export type SessionMessage =
export type ModelV2Info = {
id: string
apiID: string
providerID: string
family?: string
name: string
endpoint:
| {
type: "unknown"
}
| {
type: "openai/responses"
url: string
@@ -3404,6 +3408,11 @@ export type ModelV2Info = {
type: "anthropic/messages"
url: string
}
| {
type: "aisdk"
package: string
url?: string
}
capabilities: {
tools: boolean
input: Array<string>
@@ -3416,6 +3425,14 @@ export type ModelV2Info = {
body: {
[key: string]: unknown
}
aisdk: {
provider: {
[key: string]: unknown
}
request: {
[key: string]: unknown
}
}
variant?: string
}
variants: Array<{
@@ -3426,6 +3443,14 @@ export type ModelV2Info = {
body: {
[key: string]: unknown
}
aisdk: {
provider: {
[key: string]: unknown
}
request: {
[key: string]: unknown
}
}
}>
time: {
released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
@@ -3443,6 +3468,7 @@ export type ModelV2Info = {
}
}>
status: "alpha" | "beta" | "deprecated" | "active"
enabled: boolean
limit: {
context: number
input?: number
@@ -3450,6 +3476,73 @@ export type ModelV2Info = {
}
}
export type ProviderV2Info = {
id: string
name: string
enabled:
| false
| {
via: "env"
name: string
}
| {
via: "auth"
service: string
}
| {
via: "custom"
data: {
[key: string]: unknown
}
}
env: Array<string>
endpoint:
| {
type: "unknown"
}
| {
type: "openai/responses"
url: string
websocket?: boolean
}
| {
type: "openai/completions"
url: string
reasoning?:
| {
type: "reasoning_content"
}
| {
type: "reasoning_details"
}
}
| {
type: "anthropic/messages"
url: string
}
| {
type: "aisdk"
package: string
url?: string
}
options: {
headers: {
[key: string]: string
}
body: {
[key: string]: unknown
}
aisdk: {
provider: {
[key: string]: unknown
}
request: {
[key: string]: unknown
}
}
}
}
export type EventTuiToastShow1 = {
id: string
type: "tui.toast.show"
@@ -6580,10 +6673,7 @@ export type V2SessionMessagesResponse2 = V2SessionMessagesResponses[keyof V2Sess
export type V2ModelListData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
query?: never
url: "/api/model"
}
@@ -6596,6 +6686,49 @@ export type V2ModelListResponses = {
export type V2ModelListResponse = V2ModelListResponses[keyof V2ModelListResponses]
export type V2ProviderListData = {
body?: never
path?: never
query?: never
url: "/api/provider"
}
export type V2ProviderListResponses = {
/**
* Success
*/
200: Array<ProviderV2Info>
}
export type V2ProviderListResponse = V2ProviderListResponses[keyof V2ProviderListResponses]
export type V2ProviderGetData = {
body?: never
path: {
providerID: string
}
query?: never
url: "/api/provider/{providerID}"
}
export type V2ProviderGetErrors = {
/**
* NotFoundError
*/
404: NotFoundError
}
export type V2ProviderGetError = V2ProviderGetErrors[keyof V2ProviderGetErrors]
export type V2ProviderGetResponses = {
/**
* ProviderV2.Info
*/
200: ProviderV2Info
}
export type V2ProviderGetResponse = V2ProviderGetResponses[keyof V2ProviderGetResponses]
export type TuiAppendPromptData = {
body?: {
text: string

View File

@@ -7606,6 +7606,112 @@
]
}
},
"/api/model": {
"get": {
"tags": ["v2 models"],
"operationId": "v2.model.list",
"parameters": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ModelV2Info"
}
}
}
}
}
},
"description": "Retrieve available v2 models ordered by release date.",
"summary": "List v2 models",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.model.list({\n ...\n})"
}
]
}
},
"/api/provider": {
"get": {
"tags": ["v2 providers"],
"operationId": "v2.provider.list",
"parameters": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ProviderV2Info"
}
}
}
}
}
},
"description": "Retrieve active v2 AI providers so clients can show provider availability and configuration.",
"summary": "List v2 providers",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.provider.list({\n ...\n})"
}
]
}
},
"/api/provider/{providerID}": {
"get": {
"tags": ["v2 providers"],
"operationId": "v2.provider.get",
"parameters": [
{
"name": "providerID",
"in": "path",
"schema": {
"type": "string"
},
"required": true
}
],
"responses": {
"200": {
"description": "ProviderV2.Info",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProviderV2Info"
}
}
}
},
"404": {
"description": "NotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
}
}
}
}
},
"description": "Retrieve a single v2 AI provider so clients can inspect its availability and endpoint settings.",
"summary": "Get v2 provider",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.provider.get({\n ...\n})"
}
]
}
},
"/tui/append-prompt": {
"post": {
"tags": ["tui"],
@@ -18991,6 +19097,531 @@
}
]
},
"ModelV2Info": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"apiID": {
"type": "string"
},
"providerID": {
"type": "string"
},
"family": {
"type": "string"
},
"name": {
"type": "string"
},
"endpoint": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["unknown"]
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["openai/responses"]
},
"url": {
"type": "string"
},
"websocket": {
"type": "boolean"
}
},
"required": ["type", "url"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["openai/completions"]
},
"url": {
"type": "string"
},
"reasoning": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["reasoning_content"]
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["reasoning_details"]
}
},
"required": ["type"],
"additionalProperties": false
}
]
}
},
"required": ["type", "url"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["anthropic/messages"]
},
"url": {
"type": "string"
}
},
"required": ["type", "url"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["aisdk"]
},
"package": {
"type": "string"
},
"url": {
"type": "string"
}
},
"required": ["type", "package"],
"additionalProperties": false
}
]
},
"capabilities": {
"type": "object",
"properties": {
"tools": {
"type": "boolean"
},
"input": {
"type": "array",
"items": {
"type": "string"
}
},
"output": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["tools", "input", "output"],
"additionalProperties": false
},
"options": {
"type": "object",
"properties": {
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"body": {
"type": "object"
},
"aisdk": {
"type": "object",
"properties": {
"provider": {
"type": "object"
},
"request": {
"type": "object"
}
},
"required": ["provider", "request"],
"additionalProperties": false
},
"variant": {
"type": "string"
}
},
"required": ["headers", "body", "aisdk"],
"additionalProperties": false
},
"variants": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"body": {
"type": "object"
},
"aisdk": {
"type": "object",
"properties": {
"provider": {
"type": "object"
},
"request": {
"type": "object"
}
},
"required": ["provider", "request"],
"additionalProperties": false
}
},
"required": ["id", "headers", "body", "aisdk"],
"additionalProperties": false
}
},
"time": {
"type": "object",
"properties": {
"released": {
"anyOf": [
{
"type": "number"
},
{
"type": "string",
"enum": ["NaN"]
},
{
"type": "string",
"enum": ["Infinity"]
},
{
"type": "string",
"enum": ["-Infinity"]
},
{
"type": "string",
"enum": ["Infinity", "-Infinity", "NaN"]
}
]
}
},
"required": ["released"],
"additionalProperties": false
},
"cost": {
"type": "array",
"items": {
"type": "object",
"properties": {
"tier": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["context"]
},
"size": {
"type": "integer"
}
},
"required": ["type", "size"],
"additionalProperties": false
},
"input": {
"type": "number"
},
"output": {
"type": "number"
},
"cache": {
"type": "object",
"properties": {
"read": {
"type": "number"
},
"write": {
"type": "number"
}
},
"required": ["read", "write"],
"additionalProperties": false
}
},
"required": ["input", "output", "cache"],
"additionalProperties": false
}
},
"status": {
"type": "string",
"enum": ["alpha", "beta", "deprecated", "active"]
},
"enabled": {
"type": "boolean"
},
"limit": {
"type": "object",
"properties": {
"context": {
"type": "integer"
},
"input": {
"type": "integer"
},
"output": {
"type": "integer"
}
},
"required": ["context", "output"],
"additionalProperties": false
}
},
"required": [
"id",
"apiID",
"providerID",
"name",
"endpoint",
"capabilities",
"options",
"variants",
"time",
"cost",
"status",
"enabled",
"limit"
],
"additionalProperties": false
},
"ProviderV2Info": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"enabled": {
"anyOf": [
{
"type": "boolean",
"enum": [false]
},
{
"type": "object",
"properties": {
"via": {
"type": "string",
"enum": ["env"]
},
"name": {
"type": "string"
}
},
"required": ["via", "name"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"via": {
"type": "string",
"enum": ["auth"]
},
"service": {
"type": "string"
}
},
"required": ["via", "service"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"via": {
"type": "string",
"enum": ["custom"]
},
"data": {
"type": "object"
}
},
"required": ["via", "data"],
"additionalProperties": false
}
]
},
"env": {
"type": "array",
"items": {
"type": "string"
}
},
"endpoint": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["unknown"]
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["openai/responses"]
},
"url": {
"type": "string"
},
"websocket": {
"type": "boolean"
}
},
"required": ["type", "url"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["openai/completions"]
},
"url": {
"type": "string"
},
"reasoning": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["reasoning_content"]
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["reasoning_details"]
}
},
"required": ["type"],
"additionalProperties": false
}
]
}
},
"required": ["type", "url"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["anthropic/messages"]
},
"url": {
"type": "string"
}
},
"required": ["type", "url"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["aisdk"]
},
"package": {
"type": "string"
},
"url": {
"type": "string"
}
},
"required": ["type", "package"],
"additionalProperties": false
}
]
},
"options": {
"type": "object",
"properties": {
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"body": {
"type": "object"
},
"aisdk": {
"type": "object",
"properties": {
"provider": {
"type": "object"
},
"request": {
"type": "object"
}
},
"required": ["provider", "request"],
"additionalProperties": false
}
},
"required": ["headers", "body", "aisdk"],
"additionalProperties": false
}
},
"required": ["id", "name", "enabled", "env", "endpoint", "options"],
"additionalProperties": false
},
"EventTuiToastShow1": {
"type": "object",
"properties": {
@@ -19121,6 +19752,14 @@
"name": "v2 messages",
"description": "Experimental v2 message routes."
},
{
"name": "v2 models",
"description": "Experimental v2 model routes."
},
{
"name": "v2 providers",
"description": "Experimental v2 provider routes."
},
{
"name": "tui",
"description": "Experimental HttpApi TUI routes."

1161
specs/v2/api.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -55,9 +55,13 @@ const UnknownEndpoint = Schema.Struct({
type: Schema.Literal("unknown"),
})
export const Endpoint = Schema.Union([UnknownEndpoint, OpenAIResponses, OpenAICompletions, AnthropicMessages, AISDK]).pipe(
Schema.toTaggedUnion("type"),
)
export const Endpoint = Schema.Union([
UnknownEndpoint,
OpenAIResponses,
OpenAICompletions,
AnthropicMessages,
AISDK,
]).pipe(Schema.toTaggedUnion("type"))
export type Endpoint = typeof Endpoint.Type
export const Options = Schema.Struct({
@@ -198,7 +202,6 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
})
}
}
```
## Catalog Interface
@@ -253,23 +256,21 @@ const available = provider.enabled && model.status !== "deprecated"
## Plugin Interface
```ts
export type Definition<R = never> = Effect.Effect<{
readonly order: number
readonly hooks: HookFunctions
}, never, R>
export type Definition<R = never> = Effect.Effect<
{
readonly order: number
readonly hooks: HookFunctions
},
never,
R
>
export interface Interface {
readonly add: <R = never>(input: {
id: ID
definition: Definition<R>
}) => Effect.Effect<void, never, R>
readonly add: <R = never>(input: { id: ID; definition: Definition<R> }) => Effect.Effect<void, never, R>
readonly remove: (id: ID) => Effect.Effect<void>
readonly trigger: <Name extends keyof Hooks>(
name: Name,
input: HookInput<Name>,
) => Effect.Effect<HookInput<Name>>
readonly trigger: <Name extends keyof Hooks>(name: Name, input: HookInput<Name>) => Effect.Effect<HookInput<Name>>
}
```