mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-13 15:44:56 +00:00
Merge remote-tracking branch 'origin/dev' into move-models-dev-to-core
This commit is contained in:
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}),
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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") },
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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[]>()
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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]()
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
1161
specs/v2/api.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>>
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user