Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton fd45c7bb0f effect(config): extract ConfigPaths.Service from config/paths 2026-05-13 19:40:54 -04:00
108 changed files with 765 additions and 72956 deletions
-2
View File
@@ -5,7 +5,6 @@ import { produce, type Draft } from "immer"
import { ModelV2 } from "./model"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
import { Instance } from "./instance"
type ProviderRecord = {
provider: ProviderV2.Info
@@ -57,7 +56,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
yield* Instance.Service
let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
const plugin = yield* PluginV2.Service
-12
View File
@@ -1,12 +0,0 @@
import { Layer, LayerMap } from "effect"
import { Instance } from "./instance"
import { Catalog } from "./catalog"
import { PluginBoot } from "./plugin/boot"
export class InstanceServiceMap extends LayerMap.Service<InstanceServiceMap>()("@opencode/example/InstanceServiceMap", {
lookup: (ref: Instance.Ref) => {
const instance = Layer.succeed(Instance.Service, Instance.Service.of(ref))
return Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(Layer.provide(instance))
},
idleTimeToLive: "5 minutes",
}) {}
-10
View File
@@ -1,10 +0,0 @@
import { Context } from "effect"
export * as Instance from "./instance"
export type Ref = {
readonly directory: string
readonly workspaceID?: string
}
export class Service extends Context.Service<Service, Ref>()("@opencode/Instance") {}
-2
View File
@@ -1,2 +0,0 @@
// Auto-generated by build.ts - do not edit
export declare const snapshot: Record<string, unknown>
File diff suppressed because it is too large Load Diff
-71
View File
@@ -1,71 +0,0 @@
export * as PluginBoot from "./boot"
import { Context, Deferred, Effect, Layer } from "effect"
import { AuthV2 } from "../auth"
import { Catalog } from "../catalog"
import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { AuthPlugin } from "./auth"
import { EnvPlugin } from "./env"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
type Plugin = {
id: PluginV2.ID
effect: Effect.Effect<PluginV2.HookFunctions | void, never, Catalog.Service | AuthV2.Service | Npm.Service>
}
export interface Interface {
readonly wait: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PluginBoot") {}
export const layer: Layer.Layer<Service, never, Catalog.Service | PluginV2.Service | AuthV2.Service | Npm.Service> =
Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service
const npm = yield* Npm.Service
const done = yield* Deferred.make<void>()
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
yield* plugin.add({
id: input.id,
effect: input.effect.pipe(
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(AuthV2.Service, auth),
Effect.provideService(Npm.Service, npm),
),
})
})
const boot = Effect.gen(function* () {
yield* add(EnvPlugin)
yield* add(AuthPlugin)
for (const item of ProviderPlugins) {
yield* add(item)
}
yield* add(ModelsDevPlugin)
}).pipe(Effect.withSpan("PluginBoot.boot"))
yield* boot.pipe(
Effect.exit,
Effect.flatMap((exit) => Deferred.done(done, exit)),
Effect.forkScoped,
)
return Service.of({
wait: () => Deferred.await(done),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Catalog.defaultLayer),
Layer.provide(PluginV2.defaultLayer),
Layer.provide(Layer.orDie(AuthV2.defaultLayer)),
Layer.provide(Npm.defaultLayer),
)
+2 -4
View File
@@ -31,8 +31,7 @@ export interface RunResult {
readonly exitCode: number
readonly stdout: Buffer
readonly stderr: Buffer
readonly stdoutTruncated: boolean
readonly stderrTruncated: boolean
readonly truncated: boolean
}
export type Interface = ChildProcessSpawner["Service"] & {
@@ -148,8 +147,7 @@ export const layer = Layer.effect(
exitCode,
stdout: stdout.buffer,
stderr: stderr.buffer,
stdoutTruncated: stdout.truncated,
stderrTruncated: stderr.truncated,
truncated: stdout.truncated,
} satisfies RunResult
}),
)
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
import { fakeSelectorSdk, it, model } from "./provider-helper"
import { fakeSelectorSdk, it, model } from "../v2/plugin/provider-helper"
describe("GithubCopilotPlugin", () => {
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
+3 -18
View File
@@ -20,8 +20,7 @@ describe("AppProcess", () => {
const result = yield* svc.run(cmd("-e", "process.stdout.write('hi\\n')"))
expect(result.exitCode).toBe(0)
expect(result.stdout.toString("utf8")).toBe("hi\n")
expect(result.stdoutTruncated).toBe(false)
expect(result.stderrTruncated).toBe(false)
expect(result.truncated).toBe(false)
}),
)
@@ -85,31 +84,17 @@ describe("AppProcess", () => {
)
it.effect(
"truncates stdout when maxOutputBytes is set",
"truncates output when maxOutputBytes is set",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc.run(cmd("-e", "process.stdout.write('0123456789')"), { maxOutputBytes: 5 })
expect(result.exitCode).toBe(0)
expect(result.stdoutTruncated).toBe(true)
expect(result.stderrTruncated).toBe(false)
expect(result.truncated).toBe(true)
expect(result.stdout.length).toBe(5)
expect(result.stdout.toString("utf8")).toBe("01234")
}),
)
it.effect(
"truncates stderr when maxErrorBytes is set",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc.run(cmd("-e", "process.stderr.write('0123456789')"), { maxErrorBytes: 5 })
expect(result.exitCode).toBe(0)
expect(result.stdoutTruncated).toBe(false)
expect(result.stderrTruncated).toBe(true)
expect(result.stderr.length).toBe(5)
expect(result.stderr.toString("utf8")).toBe("01234")
}),
)
it.effect(
"result includes command description",
Effect.gen(function* () {
@@ -1,14 +1,12 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Instance } from "@opencode-ai/core/instance"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "./lib/effect"
import { testEffect } from "../lib/effect"
const instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" }))
const it = testEffect(Catalog.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer), Layer.provide(instanceLayer)))
const it = testEffect(Catalog.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer)))
describe("CatalogV2", () => {
it.effect("normalizes provider baseURL into endpoint url", () =>
@@ -4,7 +4,7 @@ import { AuthV2 } from "@opencode-ai/core/auth"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
import { testEffect } from "../lib/effect"
import { testEffect } from "../../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer))
@@ -5,7 +5,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { testEffect } from "../lib/effect"
import { testEffect } from "../../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer))
@@ -3,7 +3,7 @@ import { Effect, Layer } from "effect"
import { AISDK } from "@opencode-ai/core/aisdk"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra"
import { testEffect } from "../lib/effect"
import { testEffect } from "../../lib/effect"
import { it, model } from "./provider-helper"
const itAISDK = testEffect(Layer.provideMerge(AISDK.layer, PluginV2.defaultLayer))
@@ -9,7 +9,7 @@ import { AISDK } from "@opencode-ai/core/aisdk"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic"
import { testEffect } from "../lib/effect"
import { testEffect } from "../../lib/effect"
import { fixtureProvider, it, model, npmLayer } from "./provider-helper"
const fixtureProviderPath = fileURLToPath(fixtureProvider)
@@ -4,7 +4,7 @@ import { AuthV2 } from "@opencode-ai/core/auth"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
import { testEffect } from "../lib/effect"
import { testEffect } from "../../lib/effect"
import { it, model, npmLayer, provider, withEnv } from "./provider-helper"
const gitlabSDKOptions: Record<string, unknown>[] = []
@@ -4,7 +4,7 @@ import { AISDK } from "@opencode-ai/core/aisdk"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google"
import { testEffect } from "../lib/effect"
import { testEffect } from "../../lib/effect"
import { it, model } from "./provider-helper"
const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer)))
@@ -6,7 +6,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq"
import { it, model } from "./provider-helper"
import { testEffect } from "../lib/effect"
import { testEffect } from "../../lib/effect"
const aisdkIt = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer)))
@@ -5,7 +5,7 @@ import { Effect, Layer, Option } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { testEffect } from "../../lib/effect"
export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
@@ -1,7 +1,6 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Option } from "effect"
import { DateTime, Effect, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Instance } from "@opencode-ai/core/instance"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
@@ -9,7 +8,6 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { it, model, provider, withEnv } from "./provider-helper"
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
const instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" }))
describe("OpencodePlugin", () => {
it.effect("uses a public key and cancels paid models without credentials", () =>
@@ -192,6 +190,6 @@ describe("OpencodePlugin", () => {
const selected = yield* catalog.model.small(providerID)
expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
}).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(instanceLayer)))),
}).pipe(Effect.provide(Catalog.defaultLayer)),
)
})
@@ -4,7 +4,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { testEffect } from "../../lib/effect"
import { fakeSelectorSdk } from "./provider-helper"
const it = testEffect(PluginV2.defaultLayer)
-1
View File
@@ -198,6 +198,5 @@ export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
*/
export class ToolFailure extends Schema.TaggedErrorClass<ToolFailure>()("LLM.ToolFailure", {
message: Schema.String,
error: Schema.optional(Schema.Defect),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
-1
View File
@@ -171,7 +171,6 @@ export const ToolError = Schema.Struct({
id: ToolCallID,
name: Schema.String,
message: Schema.String,
error: Schema.optional(Schema.Defect),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolError" })
export type ToolError = Schema.Schema.Type<typeof ToolError>
+10 -26
View File
@@ -112,29 +112,17 @@ export const stream = <T extends Tools>(options: StreamOptions<T>): Stream.Strea
const dispatched = yield* Effect.forEach(
state.toolCalls,
(call) =>
dispatch(tools, call).pipe(Effect.map((result) => [call, result.result, result.error] as const)),
(call) => dispatch(tools, call).pipe(Effect.map((result) => [call, result] as const)),
{ concurrency },
)
const resultStream = Stream.fromIterable(
dispatched.flatMap(([call, result, error]) => emitEvents(call, result, error)),
)
const resultStream = Stream.fromIterable(dispatched.flatMap(([call, result]) => emitEvents(call, result)))
if (!options.stopWhen) return resultStream.pipe(Stream.concat(finishStream))
if (options.stopWhen({ step, request })) return resultStream.pipe(Stream.concat(finishStream))
return resultStream.pipe(
Stream.concat(
loop(
followUpRequest(
request,
state,
dispatched.map(([call, result]) => [call, result] as const),
),
step + 1,
totalUsage,
totalProviderMetadata,
),
loop(followUpRequest(request, state, dispatched), step + 1, totalUsage, totalProviderMetadata),
),
)
}),
@@ -227,7 +215,7 @@ const addUsage = (left: Usage | undefined, right: Usage | undefined) => {
| "reasoningTokens"
| "totalTokens"
const sum = (key: UsageKey) =>
left[key] === undefined && right[key] === undefined ? undefined : (left[key] ?? 0) + (right[key] ?? 0)
left[key] === undefined && right[key] === undefined ? undefined : Number(left[key] ?? 0) + Number(right[key] ?? 0)
return new Usage({
inputTokens: sum("inputTokens"),
@@ -276,20 +264,16 @@ const appendStreamingText = (
state.assistantContent.push({ type, text, providerMetadata })
}
const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<{ result: ToolResultValue; error?: unknown }> => {
const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<ToolResultValue> => {
const tool = tools[call.name]
if (!tool) return Effect.succeed({ result: { type: "error" as const, value: `Unknown tool: ${call.name}` } })
if (!tool) return Effect.succeed({ type: "error" as const, value: `Unknown tool: ${call.name}` })
if (!tool.execute)
return Effect.succeed({ result: { type: "error" as const, value: `Tool has no execute handler: ${call.name}` } })
return Effect.succeed({ type: "error" as const, value: `Tool has no execute handler: ${call.name}` })
return decodeAndExecute(tool, call).pipe(
Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.succeed({
result: { type: "error" as const, value: failure.message } satisfies ToolResultValue,
error: failure.error,
}),
Effect.succeed({ type: "error" as const, value: failure.message } satisfies ToolResultValue),
),
Effect.map((result) => ("result" in result ? result : { result })),
)
}
@@ -310,10 +294,10 @@ const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect<Tool
Effect.map((encoded): ToolResultValue => ({ type: "json", value: encoded })),
)
const emitEvents = (call: ToolCallPart, result: ToolResultValue, error: unknown): ReadonlyArray<LLMEvent> =>
const emitEvents = (call: ToolCallPart, result: ToolResultValue): ReadonlyArray<LLMEvent> =>
result.type === "error"
? [
LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value), error }),
LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value) }),
LLMEvent.toolResult({ id: call.id, name: call.name, result }),
]
: [LLMEvent.toolResult({ id: call.id, name: call.name, result })]
+13 -20
View File
@@ -25,7 +25,6 @@ const baseRequest = LLM.request({
model,
prompt: "Use the tool.",
})
const weatherFailureCause = new Error("weather lookup denied")
const get_weather = tool({
description: "Get current weather for a city.",
@@ -33,8 +32,7 @@ const get_weather = tool({
success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }),
execute: ({ city }) =>
Effect.gen(function* () {
if (city === "FAIL")
return yield* new ToolFailure({ message: `Weather lookup failed for ${city}`, error: weatherFailureCause })
if (city === "FAIL") return yield* new ToolFailure({ message: `Weather lookup failed for ${city}` })
return { temperature: 22, condition: "sunny" }
}),
})
@@ -87,27 +85,23 @@ describe("LLMClient tools", () => {
tools: { get_weather },
}).pipe(Stream.runCollect, Effect.provide(layer))
const second = bodies[1]
if (!second || typeof second !== "object") throw new Error("Expected second request body")
const messages = Reflect.get(second, "messages")
const tools = Reflect.get(second, "tools")
const second = bodies[1] as {
readonly messages?: ReadonlyArray<Record<string, unknown>>
readonly tools?: ReadonlyArray<unknown>
readonly tool_choice?: unknown
readonly max_tokens?: unknown
}
expect(Reflect.get(second, "max_tokens")).toBe(50)
expect(Reflect.get(second, "tool_choice")).toBe("auto")
expect(tools).toHaveLength(1)
expect(
Array.isArray(messages)
? messages.map((message) =>
message && typeof message === "object" ? Reflect.get(message, "role") : undefined,
)
: undefined,
).toEqual(["user", "assistant", "tool"])
expect(Array.isArray(messages) ? messages[1] : undefined).toMatchObject({
expect(second.max_tokens).toBe(50)
expect(second.tool_choice).toBe("auto")
expect(second.tools).toHaveLength(1)
expect(second.messages?.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
expect(second.messages?.[1]).toMatchObject({
role: "assistant",
content: null,
tool_calls: [{ id: "call_1", type: "function", function: { name: "get_weather" } }],
})
expect(Array.isArray(messages) ? messages[2] : undefined).toMatchObject({
expect(second.messages?.[2]).toMatchObject({
role: "tool",
tool_call_id: "call_1",
content: '{"temperature":22,"condition":"sunny"}',
@@ -333,7 +327,6 @@ describe("LLMClient tools", () => {
const toolError = events.find(LLMEvent.is.toolError)
expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" })
expect(toolError?.message).toBe("Weather lookup failed for FAIL")
expect(toolError?.error).toBe(weatherFailureCause)
}),
)
+2 -2
View File
@@ -13,11 +13,11 @@ const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
await Bun.write(
path.join(dir, "../core/src/models-snapshot.js"),
path.join(dir, "src/provider/models-snapshot.js"),
`// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
)
await Bun.write(
path.join(dir, "../core/src/models-snapshot.d.ts"),
path.join(dir, "src/provider/models-snapshot.d.ts"),
`// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
)
console.log("Generated models-snapshot.js")
+4 -2
View File
@@ -67,11 +67,11 @@ Most exported tools are already on the intended Effect-native shape. The remaini
Current spot cleanups worth tracking:
- [x] `read.ts` — streams through `AppFileSystem.Service.stream` with `Stream.splitLines`; the legacy Node stream / `readline` helper is gone
- [ ] `read.ts` — still bridges to Node stream / `readline` helpers and Promise-based binary detection
- [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up
- [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction
- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and file-search routes
- [x] `patch/index.ts` — apply path now returns `Effect` over `AppFileSystem.Service`; the parser and chunk replacer stay pure
- [ ] `patch/index.ts` — adjacent to tool migration; still has raw fs usage behind patch application
Notable items that are already effectively on the target path and do not need separate migration bullets right now:
@@ -85,4 +85,6 @@ Notable items that are already effectively on the target path and do not need se
Current raw fs users that still appear relevant here:
- `tool/read.ts``fs.createReadStream`, `readline`
- `file/ripgrep.ts``fs/promises`
- `patch/index.ts``fs`, `fs/promises`
+10 -16
View File
@@ -1,23 +1,22 @@
import { EOL } from "os"
import { Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { InstanceServiceMap } from "@opencode-ai/core/instance-layer"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { effectCmd } from "../../effect-cmd"
import { PluginBoot } from "@/v2/plugin-boot"
const Runtime = Layer.mergeAll(InstanceServiceMap.layer)
const layer = Catalog.defaultLayer.pipe(Layer.provide(PluginBoot.defaultLayer))
export const V2Command = effectCmd({
command: "v2",
describe: "debug v2 catalog and built-in plugins",
instance: false,
handler: Effect.fn("Cli.debug.v2")(
function* () {
yield* PluginBoot.Service.use((service) => service.wait())
handler: Effect.fn("Cli.debug.v2")(function* () {
const result = yield* Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providers = (yield* catalog.provider.available()).sort((a, b) => a.id.localeCompare(b.id))
const all = (yield* catalog.provider.all()).sort((a, b) => a.id.localeCompare(b.id))
const result = {
return {
providers,
default: catalog.model
.default()
@@ -34,13 +33,8 @@ export const V2Command = effectCmd({
),
),
}
process.stdout.write(JSON.stringify(result, null, 2) + EOL)
},
Effect.provide(
InstanceServiceMap.get({
directory: process.cwd(),
}),
),
Effect.provide(Runtime),
),
}).pipe(Effect.provide(layer), Effect.orDie)
process.stdout.write(JSON.stringify(result, null, 2) + EOL)
}),
})
+1 -1
View File
@@ -19,7 +19,7 @@ import type {
import { UI } from "../ui"
import { cmd } from "./cmd"
import { effectCmd } from "../effect-cmd"
import { ModelsDev } from "@opencode-ai/core/models"
import { ModelsDev } from "@/provider/models"
import { InstanceRef } from "@/effect/instance-ref"
import { SessionShare } from "@/share/session"
import { Session } from "@/session/session"
+4 -5
View File
@@ -7,7 +7,7 @@ import { SessionTable, MessageTable, PartTable } from "../../session/session.sql
import { InstanceRef } from "@/effect/instance-ref"
import { ShareNext } from "@/share/share-next"
import { EOL } from "os"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Filesystem } from "@/util/filesystem"
import { Effect, Schema } from "effect"
const decodeMessageInfo = Schema.decodeUnknownSync(MessageV2.Info)
@@ -95,7 +95,6 @@ export const ImportCommand = effectCmd({
const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectID: string) {
const share = yield* ShareNext.Service
const fs = yield* AppFileSystem.Service
let exportData: ExportData | undefined
@@ -150,9 +149,9 @@ const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectI
exportData = transformed
} else {
exportData = (yield* fs.readJson(file).pipe(Effect.orElseSucceed(() => undefined))) as
| NonNullable<typeof exportData>
| undefined
exportData = yield* Effect.promise(() =>
Filesystem.readJson<NonNullable<typeof exportData>>(file).catch(() => undefined),
)
if (!exportData) {
process.stdout.write(`File not found: ${file}`)
process.stdout.write(EOL)
+1 -1
View File
@@ -2,7 +2,7 @@ import { EOL } from "os"
import { Effect } from "effect"
import { Provider } from "@/provider/provider"
import { ProviderID } from "../../provider/schema"
import { ModelsDev } from "@opencode-ai/core/models"
import { ModelsDev } from "@/provider/models"
import { effectCmd, fail } from "../effect-cmd"
import { UI } from "../ui"
+1 -1
View File
@@ -3,7 +3,7 @@ import { cmd } from "./cmd"
import { CliError, effectCmd, fail } from "../effect-cmd"
import { UI } from "../ui"
import * as Prompt from "../effect/prompt"
import { ModelsDev } from "@opencode-ai/core/models"
import { ModelsDev } from "@/provider/models"
import { map, pipe, sortBy, values } from "remeda"
import path from "path"
@@ -7,7 +7,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import * as ConfigPaths from "@/config/paths"
import { ConfigPaths } from "@/config/paths"
const log = Log.create({ service: "tui.migrate" })
@@ -5,7 +5,7 @@ import { createBindingLookup } from "@opentui/keymap/extras"
import { mergeDeep, unique } from "remeda"
import { Cause, Context, Effect, Fiber, Layer, Schema } from "effect"
import { ConfigParse } from "@/config/parse"
import * as ConfigPaths from "@/config/paths"
import { ConfigPaths } from "@/config/paths"
import { migrateTuiConfig } from "./tui-migrate"
import { KeymapLeaderTimeoutDefault, resolveAttentionSoundPaths, TuiInfo } from "./tui-schema"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -99,6 +99,7 @@ function dropUnknownKeybinds(input: Record<string, unknown>, configFilepath: str
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
const afs = yield* AppFileSystem.Service
let appliedOrder = 0
const paths = yield* ConfigPaths.Service
const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect<Info> =>
Effect.gen(function* () {
@@ -191,10 +192,10 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
// Every config dir we may read from: global config dir, any `.opencode`
// folders between cwd and home, and OPENCODE_CONFIG_DIR.
const directories = yield* ConfigPaths.directories(ctx.directory)
const directories = yield* paths.directories(ctx.directory)
yield* Effect.promise(() => migrateTuiConfig({ directories, cwd: ctx.directory }))
const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : yield* ConfigPaths.files("tui", ctx.directory)
const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : yield* paths.projectFiles("tui", ctx.directory)
const acc: Acc = {
result: {},
@@ -295,7 +296,11 @@ export const layer = Layer.effect(
}).pipe(Effect.withSpan("TuiConfig.layer")),
)
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
export const defaultLayer = layer.pipe(
Layer.provide(Npm.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(ConfigPaths.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
+4 -2
View File
@@ -370,6 +370,7 @@ export const layer = Layer.effect(
const accountSvc = yield* Account.Service
const env = yield* Env.Service
const npmSvc = yield* Npm.Service
const paths = yield* ConfigPaths.Service
const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
@@ -562,7 +563,7 @@ export const layer = Layer.effect(
}
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
for (const file of yield* paths.projectFiles("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
yield* merge(file, yield* loadFile(file), "local")
}
}
@@ -571,7 +572,7 @@ export const layer = Layer.effect(
result.mode = result.mode || {}
result.plugin = result.plugin || []
const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree)
const directories = yield* paths.directories(ctx.directory, ctx.worktree)
if (Flag.OPENCODE_CONFIG_DIR) {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
@@ -829,6 +830,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Account.defaultLayer),
Layer.provide(Npm.defaultLayer),
Layer.provide(ConfigPaths.defaultLayer),
)
export * as Config from "./config"
+65 -34
View File
@@ -4,42 +4,73 @@ import path from "path"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { unique } from "remeda"
import * as Effect from "effect/Effect"
import { Context, Effect, Layer } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
export const files = Effect.fn("ConfigPaths.projectFiles")(function* (
name: string,
directory: string,
worktree?: string,
) {
const afs = yield* AppFileSystem.Service
return (yield* afs.up({
targets: [`${name}.jsonc`, `${name}.json`],
start: directory,
stop: worktree,
})).toReversed()
})
export const directories = Effect.fn("ConfigPaths.directories")(function* (directory: string, worktree?: string) {
const afs = yield* AppFileSystem.Service
return unique([
Global.Path.config,
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? yield* afs.up({
targets: [".opencode"],
start: directory,
stop: worktree,
})
: []),
...(yield* afs.up({
targets: [".opencode"],
start: Global.Path.home,
stop: Global.Path.home,
})),
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
])
})
import { ConfigParse } from "./parse"
export function fileInDirectory(dir: string, name: string) {
return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
}
export interface Interface {
readonly projectFiles: (
name: string,
directory: string,
worktree?: string,
) => Effect.Effect<string[], AppFileSystem.Error>
readonly directories: (directory: string, worktree?: string) => Effect.Effect<string[], AppFileSystem.Error>
readonly readFile: (filepath: string) => Effect.Effect<string | undefined, AppFileSystem.Error>
readonly parseText: (text: string, filepath: string) => Effect.Effect<unknown>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ConfigPaths") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const afs = yield* AppFileSystem.Service
const projectFiles = Effect.fn("ConfigPaths.projectFiles")(function* (
name: string,
directory: string,
worktree?: string,
) {
return (yield* afs.up({
targets: [`${name}.jsonc`, `${name}.json`],
start: directory,
stop: worktree,
})).toReversed()
})
const directories = Effect.fn("ConfigPaths.directories")(function* (directory: string, worktree?: string) {
return unique([
Global.Path.config,
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? yield* afs.up({
targets: [".opencode"],
start: directory,
stop: worktree,
})
: []),
...(yield* afs.up({
targets: [".opencode"],
start: Global.Path.home,
stop: Global.Path.home,
})),
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
])
})
const readFile = Effect.fn("ConfigPaths.readFile")(function* (filepath: string) {
return yield* afs.readFileStringSafe(filepath)
})
const parseText = Effect.fn("ConfigPaths.parseText")(function* (text: string, filepath: string) {
return ConfigParse.jsonc(text, filepath)
})
return Service.of({ projectFiles, directories, readFile, parseText })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
@@ -10,9 +10,9 @@ import { GlobalBus } from "@/bus/global"
import { Auth } from "@/auth"
import { SyncEvent } from "@/sync"
import { EventSequenceTable, EventTable } from "@/sync/event.sql"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
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"
import { WorkspaceTable } from "./workspace.sql"
@@ -176,7 +176,6 @@ export const layer = Layer.effect(
const sync = yield* SyncEvent.Service
const vcs = yield* Vcs.Service
const flags = yield* RuntimeFlags.Service
const fs = yield* AppFileSystem.Service
const connections = new Map<WorkspaceID, ConnectionStatus>()
const syncFibers = yield* FiberMap.make<WorkspaceID, void, SyncLoopError>()
@@ -502,7 +501,7 @@ export const layer = Layer.effect(
if (!target) return
if (target.type === "local") {
setStatus(space.id, (yield* fs.existsSafe(target.directory)) ? "connected" : "error")
setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error")
return
}
@@ -1041,7 +1040,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(SessionPrompt.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(Vcs.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(RuntimeFlags.defaultLayer),
)
+1 -1
View File
@@ -14,7 +14,7 @@ import { FileWatcher } from "@/file/watcher"
import { Storage } from "@/storage/storage"
import { Snapshot } from "@/snapshot"
import { Plugin } from "@/plugin"
import { ModelsDev } from "@opencode-ai/core/models"
import { ModelsDev } from "@/provider/models"
import { Provider } from "@/provider/provider"
import { ProviderAuth } from "@/provider/auth"
import { Agent } from "@/agent/agent"
+1 -2
View File
@@ -5,7 +5,6 @@ import { InstanceState } from "@/effect/instance-state"
import path from "path"
import { mergeDeep } from "remeda"
import { Config } from "@/config/config"
import { errorMessage } from "@/util/error"
import * as Log from "@opencode-ai/core/util/log"
import * as Formatter from "./formatter"
@@ -101,7 +100,7 @@ export const layer = Layer.effect(
command: cmd,
...item.environment,
file: filepath,
cause: errorMessage(error.cause ?? error),
cause: error.message,
})
return undefined
}),
+1 -1
View File
@@ -124,7 +124,7 @@ export const layer = Layer.effect(
text: () => result.stdout.toString("utf8"),
stdout: result.stdout,
stderr: result.stderr,
truncated: result.stdoutTruncated || result.stderrTruncated,
truncated: result.truncated,
} satisfies Result
},
Effect.catch((err) => Effect.succeed(fail(err))),
+1 -2
View File
@@ -1,7 +1,6 @@
import { Effect, Layer, Schema, Context, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { errorMessage } from "@/util/error"
import { ChildProcess } from "effect/unstable/process"
import { AppProcess } from "@opencode-ai/core/process"
import path from "path"
@@ -125,7 +124,7 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | AppProce
stderr: result.stderr.toString("utf8"),
}
},
Effect.catch((err) => Effect.succeed({ code: 1, stdout: "", stderr: errorMessage(err) })),
Effect.catch(() => Effect.succeed({ code: 1, stdout: "", stderr: "" })),
)
const getBrewFormula = Effect.fnUntraced(function* () {
+71 -77
View File
@@ -1,6 +1,7 @@
import { Effect, Schema } from "effect"
import { Schema } from "effect"
import * as path from "path"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import * as fs from "fs/promises"
import { readFileSync } from "fs"
import * as Log from "@opencode-ai/core/util/log"
import * as Bom from "../util/bom"
@@ -307,12 +308,14 @@ interface ApplyPatchFileUpdate {
bom: boolean
}
export function deriveNewContentsFromChunks(
filePath: string,
chunks: UpdateFileChunk[],
originalText: string,
): ApplyPatchFileUpdate {
const originalContent = Bom.split(originalText)
export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate {
// Read original file content
let originalContent: ReturnType<typeof Bom.split>
try {
originalContent = Bom.split(readFileSync(filePath, "utf-8"))
} catch (error) {
throw new Error(`Failed to read file ${filePath}: ${error}`, { cause: error })
}
let originalLines = originalContent.text.split("\n")
@@ -420,11 +423,11 @@ function applyReplacements(lines: string[], replacements: Array<[number, number,
// Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode)
function normalizeUnicode(str: string): string {
return str
.replace(/[‘’‚‛]/g, "'") // single quotes
.replace(/[“”„‟]/g, '"') // double quotes
.replace(/[‐‑‒–—―]/g, "-") // dashes
.replace(//g, "...") // ellipsis
.replace(/ /g, " ") // non-breaking space
.replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes
.replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes
.replace(/\u2026/g, "...") // ellipsis
.replace(/\u00A0/g, " ") // non-breaking space
}
type Comparator = (a: string, b: string) => boolean
@@ -514,71 +517,77 @@ function generateUnifiedDiff(oldContent: string, newContent: string): string {
}
// Apply hunks to filesystem
export const applyHunksToFiles = Effect.fn("Patch.applyHunksToFiles")(function* (hunks: Hunk[]) {
export async function applyHunksToFiles(hunks: Hunk[]): Promise<AffectedPaths> {
if (hunks.length === 0) {
return yield* Effect.fail(new Error("No files were modified."))
throw new Error("No files were modified.")
}
const fs = yield* AppFileSystem.Service
const added: string[] = []
const modified: string[] = []
const deleted: string[] = []
for (const hunk of hunks) {
switch (hunk.type) {
case "add": {
yield* fs.writeWithDirs(hunk.path, hunk.contents)
case "add":
// Create parent directories
const addDir = path.dirname(hunk.path)
if (addDir !== "." && addDir !== "/") {
await fs.mkdir(addDir, { recursive: true })
}
await fs.writeFile(hunk.path, hunk.contents, "utf-8")
added.push(hunk.path)
log.info(`Added file: ${hunk.path}`)
break
}
case "delete": {
yield* fs.remove(hunk.path)
case "delete":
await fs.unlink(hunk.path)
deleted.push(hunk.path)
log.info(`Deleted file: ${hunk.path}`)
break
}
case "update": {
const originalText = yield* fs.readFileString(hunk.path)
const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks, originalText)
case "update":
const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks)
if (hunk.move_path) {
yield* fs.writeWithDirs(hunk.move_path, Bom.join(fileUpdate.content, fileUpdate.bom))
yield* fs.remove(hunk.path)
// Handle file move
const moveDir = path.dirname(hunk.move_path)
if (moveDir !== "." && moveDir !== "/") {
await fs.mkdir(moveDir, { recursive: true })
}
await fs.writeFile(hunk.move_path, Bom.join(fileUpdate.content, fileUpdate.bom), "utf-8")
await fs.unlink(hunk.path)
modified.push(hunk.move_path)
log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`)
} else {
yield* fs.writeWithDirs(hunk.path, Bom.join(fileUpdate.content, fileUpdate.bom))
// Regular update
await fs.writeFile(hunk.path, Bom.join(fileUpdate.content, fileUpdate.bom), "utf-8")
modified.push(hunk.path)
log.info(`Updated file: ${hunk.path}`)
}
break
}
}
}
return { added, modified, deleted } satisfies AffectedPaths
})
return { added, modified, deleted }
}
// Main patch application function
export const applyPatch = Effect.fn("Patch.applyPatch")(function* (patchText: string) {
export async function applyPatch(patchText: string): Promise<AffectedPaths> {
const { hunks } = parsePatch(patchText)
return yield* applyHunksToFiles(hunks)
})
return applyHunksToFiles(hunks)
}
type MaybeApplyPatchVerifiedResult =
// Async version of maybeParseApplyPatchVerified
export async function maybeParseApplyPatchVerified(
argv: string[],
cwd: string,
): Promise<
| { type: MaybeApplyPatchVerified.Body; action: ApplyPatchAction }
| { type: MaybeApplyPatchVerified.CorrectnessError; error: Error }
| { type: MaybeApplyPatchVerified.NotApplyPatch }
// Effectful verified-parse: needs AppFileSystem.Service to read existing files
export const maybeParseApplyPatchVerified = Effect.fn("Patch.maybeParseApplyPatchVerified")(function* (
argv: string[],
cwd: string,
) {
> {
// Detect implicit patch invocation (raw patch without apply_patch command)
if (argv.length === 1) {
try {
@@ -586,7 +595,7 @@ export const maybeParseApplyPatchVerified = Effect.fn("Patch.maybeParseApplyPatc
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: new Error(ApplyPatchError.ImplicitInvocation),
} satisfies MaybeApplyPatchVerifiedResult
}
} catch {
// Not a patch, continue
}
@@ -595,9 +604,8 @@ export const maybeParseApplyPatchVerified = Effect.fn("Patch.maybeParseApplyPatc
const result = maybeParseApplyPatch(argv)
switch (result.type) {
case MaybeApplyPatch.Body: {
const fs = yield* AppFileSystem.Service
const args = result.args
case MaybeApplyPatch.Body:
const { args } = result
const effectiveCwd = args.workdir ? path.resolve(cwd, args.workdir) : cwd
const changes = new Map<string, ApplyPatchFileChange>()
@@ -615,39 +623,27 @@ export const maybeParseApplyPatchVerified = Effect.fn("Patch.maybeParseApplyPatc
})
break
case "delete": {
case "delete":
// For delete, we need to read the current content
const deletePath = path.resolve(effectiveCwd, hunk.path)
const content = yield* fs.readFileString(deletePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (content === undefined) {
try {
const content = await fs.readFile(deletePath, "utf-8")
changes.set(resolvedPath, {
type: "delete",
content,
})
} catch {
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: new Error(`Failed to read file for deletion: ${deletePath}`),
} satisfies MaybeApplyPatchVerifiedResult
}
}
changes.set(resolvedPath, {
type: "delete",
content,
})
break
}
case "update": {
case "update":
const updatePath = path.resolve(effectiveCwd, hunk.path)
const originalText = yield* fs
.readFileString(updatePath)
.pipe(
Effect.catch((cause) =>
Effect.succeed(new Error(`Failed to read file ${updatePath}: ${cause}`, { cause })),
),
)
if (originalText instanceof Error) {
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: originalText,
} satisfies MaybeApplyPatchVerifiedResult
}
try {
const fileUpdate = deriveNewContentsFromChunks(updatePath, hunk.chunks, originalText)
const fileUpdate = deriveNewContentsFromChunks(updatePath, hunk.chunks)
changes.set(resolvedPath, {
type: "update",
unified_diff: fileUpdate.unified_diff,
@@ -658,10 +654,9 @@ export const maybeParseApplyPatchVerified = Effect.fn("Patch.maybeParseApplyPatc
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: error as Error,
} satisfies MaybeApplyPatchVerifiedResult
}
}
break
}
}
}
@@ -672,18 +667,17 @@ export const maybeParseApplyPatchVerified = Effect.fn("Patch.maybeParseApplyPatc
patch: args.patch,
cwd: effectiveCwd,
},
} satisfies MaybeApplyPatchVerifiedResult
}
}
case MaybeApplyPatch.PatchParseError:
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: result.error,
} satisfies MaybeApplyPatchVerifiedResult
}
case MaybeApplyPatch.NotApplyPatch:
return { type: MaybeApplyPatchVerified.NotApplyPatch } satisfies MaybeApplyPatchVerifiedResult
return { type: MaybeApplyPatchVerified.NotApplyPatch }
}
})
}
export * as Patch from "."
+1 -1
View File
@@ -7,7 +7,7 @@ import {
printParseErrorCode,
} from "jsonc-parser"
import * as ConfigPaths from "@/config/paths"
import { ConfigPaths } from "@/config/paths"
import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
import { Flock } from "@opencode-ai/core/util/flock"
@@ -1,6 +1,7 @@
import { Schema } from "effect"
export { CatalogModelStatus } from "@opencode-ai/core/models"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"])
export type ModelStatus = typeof ModelStatus.Type
@@ -1,17 +1,15 @@
import { Global } from "@opencode-ai/core/global"
import path from "path"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Global } from "./global"
import { Flag } from "./flag/flag"
import { Flock } from "./util/flock"
import { Hash } from "./util/hash"
import { AppFileSystem } from "./filesystem"
import { InstallationChannel, InstallationVersion } from "./installation/version"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}`
import { Installation } from "../installation"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Flock } from "@opencode-ai/core/util/flock"
import { Hash } from "@opencode-ai/core/util/hash"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { CatalogModelStatus } from "./model-status"
import { RuntimeFlags } from "@/effect/runtime-flags"
const CostTier = Schema.Struct({
input: Schema.Finite,
@@ -112,21 +110,14 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
type Requirements = AppFileSystem.Service | HttpClient.HttpClient
type Requirements = AppFileSystem.Service | HttpClient.HttpClient | RuntimeFlags.Service
export const layer: Layer.Layer<Service, never, Requirements> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const http = HttpClient.filterStatusOk(
(yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({
retryOn: "errors-and-responses",
times: 2,
schedule: Schedule.exponential(200).pipe(Schedule.jittered),
}),
),
)
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const flags = yield* RuntimeFlags.Service
const source = Flag.OPENCODE_MODELS_URL || "https://models.dev"
const filepath = path.join(
@@ -145,7 +136,7 @@ export const layer: Layer.Layer<Service, never, Requirements> = Layer.effect(
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
return yield* HttpClientRequest.get(`${source}/api.json`).pipe(
HttpClientRequest.setHeader("User-Agent", USER_AGENT),
HttpClientRequest.setHeader("User-Agent", Installation.userAgent(flags.client)),
http.execute,
Effect.flatMap((res) => res.text),
Effect.timeout("10 seconds"),
@@ -221,6 +212,7 @@ export const layer: Layer.Layer<Service, never, Requirements> = Layer.effect(
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
)
export * as ModelsDev from "./models"
+6 -8
View File
@@ -8,7 +8,7 @@ import { Npm } from "@opencode-ai/core/npm"
import { Hash } from "@opencode-ai/core/util/hash"
import { Plugin } from "../plugin"
import { type LanguageModelV3 } from "@ai-sdk/provider"
import * as ModelsDev from "@opencode-ai/core/models"
import * as ModelsDev from "./models"
import { Auth } from "../auth"
import { Env } from "../env"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
@@ -1707,9 +1707,7 @@ const layer = Layer.effect(
if (cfg.small_model) {
const parsed = parseModel(cfg.small_model)
return yield* getModel(parsed.providerID, parsed.modelID).pipe(
Effect.catchTag("ProviderModelNotFoundError", () => Effect.succeed(undefined)),
)
return yield* getModel(parsed.providerID, parsed.modelID).pipe(Effect.orDie)
}
const s = yield* InstanceState.get(state)
@@ -1737,22 +1735,22 @@ const layer = Layer.effect(
const candidates = Object.keys(provider.models).filter((m) => m.includes(item))
const globalMatch = candidates.find((m) => m.startsWith("global."))
if (globalMatch) return provider.models[globalMatch]
if (globalMatch) return yield* getModel(providerID, ModelID.make(globalMatch)).pipe(Effect.orDie)
const region = provider.options?.region
if (region) {
const regionPrefix = region.split("-")[0]
if (regionPrefix === "us" || regionPrefix === "eu") {
const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`))
if (regionalMatch) return provider.models[regionalMatch]
if (regionalMatch) return yield* getModel(providerID, ModelID.make(regionalMatch)).pipe(Effect.orDie)
}
}
const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p)))
if (unprefixed) return provider.models[unprefixed]
if (unprefixed) return yield* getModel(providerID, ModelID.make(unprefixed)).pipe(Effect.orDie)
} else {
for (const model of Object.keys(provider.models)) {
if (model.includes(item)) return provider.models[model]
if (model.includes(item)) return yield* getModel(providerID, ModelID.make(model)).pipe(Effect.orDie)
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import type { ModelMessage, ToolResultPart } from "ai"
import { mergeDeep, unique } from "remeda"
import type { JSONSchema7 } from "@ai-sdk/provider"
import type * as Provider from "./provider"
import type * as ModelsDev from "@opencode-ai/core/models"
import type * as ModelsDev from "./models"
import { iife } from "@/util/iife"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -40,37 +40,30 @@ function eventData(data: unknown): Sse.Event {
}
function eventResponse(bus: Bus.Interface) {
return Effect.gen(function* () {
const context = yield* Effect.context()
const events = bus.subscribeAll().pipe(Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type))
const heartbeat = Stream.tick("10 seconds").pipe(
Stream.drop(1),
Stream.map(() => ({ id: Bus.createID(), type: "server.heartbeat", properties: {} })),
)
const events = bus.subscribeAll().pipe(
Stream.provideContext(context),
Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type),
)
const heartbeat = Stream.tick("10 seconds").pipe(
Stream.drop(1),
Stream.map(() => ({ id: Bus.createID(), type: "server.heartbeat", properties: {} })),
)
log.info("event connected")
return HttpServerResponse.stream(
Stream.make({ id: Bus.createID(), type: "server.connected", properties: {} }).pipe(
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText,
Stream.ensuring(Effect.sync(() => log.info("event disconnected"))),
),
{
contentType: "text/event-stream",
headers: {
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"X-Content-Type-Options": "nosniff",
},
log.info("event connected")
return HttpServerResponse.stream(
Stream.make({ id: Bus.createID(), type: "server.connected", properties: {} }).pipe(
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText,
Stream.ensuring(Effect.sync(() => log.info("event disconnected"))),
),
{
contentType: "text/event-stream",
headers: {
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"X-Content-Type-Options": "nosniff",
},
)
})
},
)
}
export const eventHandlers = HttpApiBuilder.group(EventApi, "event", (handlers) =>
@@ -79,7 +72,7 @@ export const eventHandlers = HttpApiBuilder.group(EventApi, "event", (handlers)
return handlers.handleRaw(
"subscribe",
Effect.fn("EventHttpApi.subscribe")(function* () {
return yield* eventResponse(bus)
return eventResponse(bus)
}),
)
}),
@@ -1,59 +0,0 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { Instance } from "@opencode-ai/core/instance"
import { InstanceServiceMap } from "@opencode-ai/core/instance-layer"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Effect, Layer, Schema } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"
export const InstanceQuery = Schema.Struct({
instance: Schema.optional(
Schema.Struct({
directory: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
}),
),
}).annotate({ identifier: "V2InstanceQuery" })
export const instanceQueryOpenApi = OpenApi.annotations({
transform: (operation) => {
const parameters = operation.parameters
if (!Array.isArray(parameters)) return operation
return {
...operation,
parameters: parameters.map((parameter) =>
parameter?.name === "instance" && parameter?.in === "query"
? { ...parameter, style: "deepObject", explode: true }
: parameter,
),
}
},
})
export class V2InstanceMiddleware extends HttpApiMiddleware.Service<
V2InstanceMiddleware,
{
provides: Catalog.Service | PluginBoot.Service
}
>()("@opencode/ExperimentalHttpApiV2Instance") {}
function ref(request: HttpServerRequest.HttpServerRequest): Instance.Ref {
const query = new URL(request.url, "http://localhost").searchParams
return {
directory: query.get("instance[directory]") || request.headers["x-opencode-directory"] || process.cwd(),
workspaceID: query.get("instance[workspace]") || request.headers["x-opencode-workspace"],
}
}
export const layer = Layer.effect(
V2InstanceMiddleware,
Effect.gen(function* () {
const instances = yield* InstanceServiceMap
return V2InstanceMiddleware.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
return yield* effect.pipe(Effect.provide(instances.get(ref(request))))
}),
)
}),
).pipe(Layer.provide(InstanceServiceMap.layer))
@@ -2,22 +2,18 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../../middleware/authorization"
import { InstanceQuery, instanceQueryOpenApi, V2InstanceMiddleware } from "./instance"
export const ModelGroup = HttpApiGroup.make("v2.model")
.add(
HttpApiEndpoint.get("models", "/api/model", {
query: InstanceQuery,
success: Schema.Array(ModelV2.Info),
})
.annotateMerge(instanceQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.model.list",
summary: "List v2 models",
description: "Retrieve available v2 models ordered by release date.",
}),
),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.model.list",
summary: "List v2 models",
description: "Retrieve available v2 models ordered by release date.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
@@ -25,5 +21,4 @@ export const ModelGroup = HttpApiGroup.make("v2.model")
description: "Experimental v2 model routes.",
}),
)
.middleware(V2InstanceMiddleware)
.middleware(Authorization)
@@ -3,39 +3,31 @@ import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { ApiNotFoundError } from "../../errors"
import { Authorization } from "../../middleware/authorization"
import { InstanceQuery, instanceQueryOpenApi, V2InstanceMiddleware } from "./instance"
export const ProviderGroup = HttpApiGroup.make("v2.provider")
.add(
HttpApiEndpoint.get("providers", "/api/provider", {
query: InstanceQuery,
success: Schema.Array(ProviderV2.Info),
})
.annotateMerge(instanceQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.provider.list",
summary: "List v2 providers",
description: "Retrieve active v2 AI providers so clients can show provider availability and configuration.",
}),
),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.provider.list",
summary: "List v2 providers",
description: "Retrieve active v2 AI providers so clients can show provider availability and configuration.",
}),
),
)
.add(
HttpApiEndpoint.get("provider", "/api/provider/:providerID", {
params: { providerID: ProviderV2.ID },
query: InstanceQuery,
success: ProviderV2.Info,
error: ApiNotFoundError,
})
.annotateMerge(instanceQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.provider.get",
summary: "Get v2 provider",
description:
"Retrieve a single v2 AI provider so clients can inspect its availability and endpoint settings.",
}),
),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.provider.get",
summary: "Get v2 provider",
description: "Retrieve a single v2 AI provider so clients can inspect its availability and endpoint settings.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
@@ -43,5 +35,4 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider")
description: "Experimental v2 provider routes.",
}),
)
.middleware(V2InstanceMiddleware)
.middleware(Authorization)
@@ -38,9 +38,7 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance"
})
const getVcs = Effect.fn("InstanceHttpApi.vcs")(function* () {
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], {
concurrency: "unbounded",
})
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 })
return { branch, default_branch }
})
@@ -1,6 +1,6 @@
import { ProviderAuth } from "@/provider/auth"
import { Config } from "@/config/config"
import { ModelsDev } from "@opencode-ai/core/models"
import { ModelsDev } from "@/provider/models"
import { Provider } from "@/provider/provider"
import { ProviderID } from "@/provider/schema"
import { mapValues } from "remeda"
@@ -36,12 +36,6 @@ import {
} from "../groups/session"
import * as SessionError from "./session-errors"
const tryParseJson = (text: string) =>
Effect.try({
try: () => JSON.parse(text) as unknown,
catch: () => new HttpApiError.BadRequest({}),
})
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -166,7 +160,10 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
const body = yield* Effect.orDie(ctx.request.text)
if (body.trim().length === 0) return yield* create({})
const json = yield* tryParseJson(body)
const json = yield* Effect.try({
try: () => JSON.parse(body) as unknown,
catch: () => new HttpApiError.BadRequest({}),
})
const payload = yield* Schema.decodeUnknownEffect(Session.CreateInput)(json).pipe(
Effect.mapError(() => new HttpApiError.BadRequest({})),
)
@@ -214,7 +211,10 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
const body = yield* Effect.orDie(ctx.request.text)
if (body.trim().length === 0) return yield* fork({ params: ctx.params })
const json = yield* tryParseJson(body)
const json = yield* Effect.try({
try: () => JSON.parse(body) as unknown,
catch: () => new HttpApiError.BadRequest({}),
})
const payload = yield* Schema.decodeUnknownEffect(ForkPayload)(json).pipe(
Effect.mapError(() => new HttpApiError.BadRequest({})),
)
@@ -11,7 +11,7 @@ import { lte } from "drizzle-orm"
import { not } from "drizzle-orm"
import { or } from "drizzle-orm"
import { Effect, Scope } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { HistoryPayload, ReplayPayload, SessionPayload } from "../groups/sync"
import * as Log from "@opencode-ai/core/util/log"
@@ -59,7 +59,7 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
const steal = Effect.fn("SyncHttpApi.steal")(function* (ctx: { payload: typeof SessionPayload.Type }) {
const workspaceID = yield* InstanceState.workspaceID
if (!workspaceID) return yield* new HttpApiError.BadRequest({})
if (!workspaceID) throw new Error("Cannot steal session without workspace context")
yield* sync.run(Session.Event.Updated, {
sessionID: ctx.payload.sessionID,
@@ -1,12 +1,12 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { SessionV2 } from "@/v2/session"
import { Layer } from "effect"
import { layer as v2InstanceLayer } from "../groups/v2/instance"
import { messageHandlers } from "./v2/message"
import { modelHandlers } from "./v2/model"
import { providerHandlers } from "./v2/provider"
import { sessionHandlers } from "./v2/session"
export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers, modelHandlers, providerHandlers).pipe(
Layer.provide(v2InstanceLayer),
Layer.provide(Catalog.defaultLayer),
Layer.provide(SessionV2.defaultLayer),
)
@@ -1,19 +1,12 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
export const modelHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.model", (handlers) =>
Effect.gen(function* () {
return handlers.handle(
"models",
Effect.fn(function* () {
const catalog = yield* Catalog.Service
const pluginBoot = yield* PluginBoot.Service
yield* pluginBoot.wait()
return yield* catalog.model.available()
}),
)
const catalog = yield* Catalog.Service
return handlers.handle("models", () => catalog.model.available())
}),
)
@@ -1,5 +1,4 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
@@ -7,22 +6,13 @@ import { notFound } from "../../errors"
export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provider", (handlers) =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
return handlers
.handle(
"providers",
Effect.fn(function* () {
const catalog = yield* Catalog.Service
const pluginBoot = yield* PluginBoot.Service
yield* pluginBoot.wait()
return yield* catalog.provider.available()
}),
)
.handle("providers", () => catalog.provider.available())
.handle(
"provider",
Effect.fn(function* (ctx) {
const catalog = yield* Catalog.Service
const pluginBoot = yield* PluginBoot.Service
yield* pluginBoot.wait()
return yield* catalog.provider
.get(ctx.params.providerID)
.pipe(Effect.catchTag("CatalogV2.ProviderNotFound", () => Effect.fail(notFound("Provider not found"))))
@@ -29,7 +29,7 @@ import { InstanceLayer } from "@/project/instance-layer"
import { Plugin } from "@/plugin"
import { Project } from "@/project/project"
import { ProviderAuth } from "@/provider/auth"
import { ModelsDev } from "@opencode-ai/core/models"
import { ModelsDev } from "@/provider/models"
import { Provider } from "@/provider/provider"
import { Pty } from "@/pty"
import { PtyTicket } from "@/pty/ticket"
+1 -5
View File
@@ -119,11 +119,7 @@ export const ApplyPatchTool = Tool.define(
// Apply the update chunks to get new content
try {
const fileUpdate = Patch.deriveNewContentsFromChunks(
filePath,
hunk.chunks,
Bom.join(source.text, source.bom),
)
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
newContent = fileUpdate.content
bom = fileUpdate.bom
} catch (error) {
+50 -45
View File
@@ -1,6 +1,8 @@
import { Effect, Option, Schema, Scope, Stream } from "effect"
import { Effect, Option, Schema, Scope } from "effect"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { createReadStream } from "fs"
import * as path from "path"
import { createInterface } from "readline"
import * as Tool from "./tool"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { LSP } from "@/lsp/lsp"
@@ -103,49 +105,6 @@ export const ReadTool = Tool.define(
)
})
const lines = Effect.fn("ReadTool.lines")(function* (filepath: string, opts: { limit: number; offset: number }) {
const start = opts.offset - 1
const raw: string[] = []
const flags = { bytes: 0, count: 0, cut: false, more: false, done: false }
// Note: prefer manual TextDecoder over Stream.decodeText — when the source stream
// ends without flushing, decodeText drops the final unterminated line. We also
// avoid Stream.runForEachWhile (it currently swallows the final unterminated
// line of the upstream splitLines pipeline) and instead toggle a `done` flag
// and ignore subsequent lines.
const decoder = new TextDecoder("utf-8")
yield* fs.stream(filepath).pipe(
Stream.map((bytes) => decoder.decode(bytes, { stream: true })),
Stream.splitLines,
Stream.runForEach((text) =>
Effect.sync(() => {
if (flags.done) return
flags.count += 1
if (flags.count <= start) return
if (raw.length >= opts.limit) {
flags.more = true
return
}
const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
if (flags.bytes + size > MAX_BYTES) {
flags.cut = true
flags.more = true
flags.done = true
return
}
raw.push(line)
flags.bytes += size
}),
),
)
return { raw, count: flags.count, cut: flags.cut, more: flags.more, offset: opts.offset }
})
const isBinaryFile = (filepath: string, bytes: Uint8Array) => {
const ext = path.extname(filepath).toLowerCase()
switch (ext) {
@@ -288,7 +247,9 @@ export const ReadTool = Tool.define(
return yield* Effect.fail(new Error(`Cannot read binary file: ${filepath}`))
}
const file = yield* lines(filepath, { limit: params.limit ?? DEFAULT_READ_LIMIT, offset: params.offset || 1 })
const file = yield* Effect.promise(() =>
lines(filepath, { limit: params.limit ?? DEFAULT_READ_LIMIT, offset: params.offset || 1 }),
)
if (file.count < file.offset && !(file.count === 0 && file.offset === 1)) {
return yield* Effect.fail(
new Error(`Offset ${file.offset} is out of range for this file (${file.count} lines)`),
@@ -335,3 +296,47 @@ export const ReadTool = Tool.define(
}
}),
)
async function lines(filepath: string, opts: { limit: number; offset: number }) {
const stream = createReadStream(filepath, { encoding: "utf8" })
const rl = createInterface({
input: stream,
// Note: we use the crlfDelay option to recognize all instances of CR LF
// ('\r\n') in file as a single line break.
crlfDelay: Infinity,
})
const start = opts.offset - 1
const raw: string[] = []
let bytes = 0
let count = 0
let cut = false
let more = false
try {
for await (const text of rl) {
count += 1
if (count <= start) continue
if (raw.length >= opts.limit) {
more = true
continue
}
const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
if (bytes + size > MAX_BYTES) {
cut = true
more = true
break
}
raw.push(line)
bytes += size
}
} finally {
rl.close()
stream.destroy()
}
return { raw, count, cut, more, offset: opts.offset }
}
+50
View File
@@ -0,0 +1,50 @@
export * as PluginBoot from "./plugin-boot"
import { Npm } from "@opencode-ai/core/npm"
import { Effect, Layer } from "effect"
import { AuthV2 } from "@opencode-ai/core/auth"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
import { EnvPlugin } from "@opencode-ai/core/plugin/env"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { ModelsDevPlugin } from "./plugin/models-dev"
type Plugin = {
id: PluginV2.ID
effect: Effect.Effect<PluginV2.HookFunctions | void, never, Catalog.Service | AuthV2.Service | Npm.Service>
}
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service
const npm = yield* Npm.Service
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
yield* plugin.add({
id: input.id,
effect: input.effect.pipe(
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(AuthV2.Service, auth),
Effect.provideService(Npm.Service, npm),
),
})
})
yield* add(EnvPlugin)
yield* add(AuthPlugin)
for (const item of ProviderPlugins) {
yield* add(item)
}
yield* add(ModelsDevPlugin)
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Catalog.defaultLayer),
Layer.provide(PluginV2.defaultLayer),
Layer.provide(Layer.orDie(AuthV2.defaultLayer)),
Layer.provide(Npm.defaultLayer),
)
@@ -1,9 +1,9 @@
import { DateTime, Effect } from "effect"
import { Catalog } from "../catalog"
import { ModelV2 } from "../model"
import { ModelsDev } from "../models"
import { PluginV2 } from "../plugin"
import { ProviderV2 } from "../provider"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelsDev } from "@/provider/models"
import { PluginV2 } from "@opencode-ai/core/plugin"
function released(date: string) {
const time = Date.parse(date)
@@ -6,6 +6,7 @@ import { pathToFileURL } from "url"
import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { Config } from "../../src/config/config"
import { ConfigPaths } from "../../src/config/paths"
import { Env } from "../../src/env"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Plugin } from "../../src/plugin"
@@ -29,6 +30,7 @@ const configLayer = Config.layer.pipe(
Layer.provide(AuthTest.empty),
Layer.provide(AccountTest.empty),
Layer.provide(NpmTest.noop),
Layer.provide(ConfigPaths.defaultLayer),
)
const pluginLayer = Plugin.layer.pipe(
Layer.provide(Bus.layer),
@@ -3,6 +3,7 @@ import { Effect, Layer, Option } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Config } from "@/config/config"
import { ConfigManaged } from "@/config/managed"
import { ConfigPaths } from "@/config/paths"
import { ConfigParse } from "../../src/config/parse"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
@@ -57,6 +58,7 @@ const layer = Config.layer.pipe(
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
Layer.provide(noopNpm),
Layer.provide(ConfigPaths.defaultLayer),
)
const it = testEffect(layer)
@@ -548,6 +550,7 @@ test("resolves env templates in account config with account token", async () =>
Layer.provide(fakeAccount),
Layer.provideMerge(infra),
Layer.provide(noopNpm),
Layer.provide(ConfigPaths.defaultLayer),
)
try {
@@ -1059,6 +1062,7 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
Layer.provide(noopNpm),
Layer.provide(ConfigPaths.defaultLayer),
)
try {
@@ -1944,6 +1948,7 @@ test("project config overrides remote well-known config", async () => {
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
Layer.provide(noopNpm),
Layer.provide(ConfigPaths.defaultLayer),
)
try {
@@ -2002,6 +2007,7 @@ test("wellknown URL with trailing slash is normalized", async () => {
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
Layer.provide(noopNpm),
Layer.provide(ConfigPaths.defaultLayer),
)
try {
@@ -2074,6 +2080,7 @@ test("wellknown remote_config supports templated env vars in headers", async ()
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
Layer.provide(noopNpm),
Layer.provide(ConfigPaths.defaultLayer),
)
try {
@@ -8,7 +8,6 @@ import { NodeHttpServer } from "@effect/platform-node"
import { Effect, Layer, Schema } from "effect"
import { FetchHttpClient, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { eq } from "drizzle-orm"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus, type GlobalEvent } from "@/bus/global"
@@ -60,7 +59,6 @@ const workspaceLayer = (experimentalWorkspaces: boolean) =>
Layer.provide(Project.defaultLayer),
Layer.provide(Vcs.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })),
Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))),
)
@@ -1042,29 +1040,21 @@ describe("workspace CRUD", () => {
})
describe("workspace sync state", () => {
it.instance(
"startWorkspaceSyncing is disabled by the experimental workspace flag",
() =>
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 sessionSvc = yield* SessionNs.Service
const type = unique("flag-disabled")
const info = workspaceInfo(instance.project.id, type)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, info.id)
insertWorkspace(info)
registerAdapter(instance.project.id, type, localAdapter(path.join(dir, "flag-disabled")).adapter)
test("startWorkspaceSyncing is disabled by the experimental workspace flag", async () => {
await withInstance(async (dir) => {
const type = unique("flag-disabled")
const info = workspaceInfo(Instance.project.id, type)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(session.id, info.id)
insertWorkspace(info)
registerAdapter(Instance.project.id, type, localAdapter(path.join(dir, "flag-disabled")).adapter)
yield* Effect.promise(() => startWorkspaceSyncingWithFlag(instance.project.id, false))
yield* Effect.sleep("25 millis")
await startWorkspaceSyncingWithFlag(Instance.project.id, false)
await delay(25)
expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined()
}),
{ git: true },
)
expect((await workspaceStatus()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined()
})
})
it.instance(
"startWorkspaceSyncing starts all workspaces",
@@ -1104,76 +1094,67 @@ describe("workspace sync state", () => {
{ git: true },
)
it.instance(
"local start reports error when the target directory is missing",
() =>
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 sessionSvc = yield* SessionNs.Service
const type = unique("missing-local")
const info = workspaceInfo(instance.project.id, type)
insertWorkspace(info)
registerAdapter(
instance.project.id,
type,
localAdapter(path.join(dir, "missing-target"), { createDir: false }).adapter,
)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
test("local start reports error when the target directory is missing", async () => {
await withInstance(async (dir) => {
const type = unique("missing-local")
const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info)
registerAdapter(
Instance.project.id,
type,
localAdapter(path.join(dir, "missing-target"), { createDir: false }).adapter,
)
attachSessionToWorkspace(
(await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id,
info.id,
)
yield* workspace.startWorkspaceSyncing(instance.project.id)
startWorkspaceSyncing(Instance.project.id)
yield* eventuallyEffect(
Effect.gen(function* () {
const status = yield* workspace.status()
expect(status.find((item) => item.workspaceID === info.id)?.status).toBe("error")
}),
)
expect(yield* workspace.isSyncing(info.id)).toBe(false)
yield* workspace.remove(info.id)
}),
{ git: true },
)
await eventually(() =>
workspaceStatus().then((status) =>
expect(status.find((item) => item.workspaceID === info.id)?.status).toBe("error"),
),
)
expect(await isWorkspaceSyncing(info.id)).toBe(false)
await removeWorkspace(info.id)
})
})
it.instance(
"duplicate local status updates are suppressed",
() =>
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 sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
yield* Effect.addFinalizer(() => Effect.sync(() => captured.dispose()))
test("duplicate local status updates are suppressed", async () => {
await withInstance(async (dir) => {
const captured = captureGlobalEvents()
try {
const type = unique("dedupe-local")
const info = workspaceInfo(instance.project.id, type)
const info = workspaceInfo(Instance.project.id, type)
const target = path.join(dir, "dedupe-local")
yield* Effect.promise(() => fs.mkdir(target, { recursive: true }))
await fs.mkdir(target, { recursive: true })
insertWorkspace(info)
registerAdapter(instance.project.id, type, localAdapter(target).adapter)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
registerAdapter(Instance.project.id, type, localAdapter(target).adapter)
attachSessionToWorkspace(
(await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id,
info.id,
)
yield* workspace.startWorkspaceSyncing(instance.project.id)
yield* workspace.startWorkspaceSyncing(instance.project.id)
startWorkspaceSyncing(Instance.project.id)
startWorkspaceSyncing(Instance.project.id)
yield* eventuallyEffect(
Effect.gen(function* () {
const status = yield* workspace.status()
expect(status.find((item) => item.workspaceID === info.id)?.status).toBe("connected")
}),
await eventually(() =>
workspaceStatus().then((status) =>
expect(status.find((item) => item.workspaceID === info.id)?.status).toBe("connected"),
),
)
expect(
captured.events.filter(
(event) => event.workspace === info.id && event.payload.type === Workspace.Event.Status.type,
),
).toHaveLength(1)
yield* workspace.remove(info.id)
}),
{ git: true },
)
await removeWorkspace(info.id)
} finally {
captured.dispose()
}
})
})
it.live("remote start emits disconnected, connecting, and connected then refuses duplicate listeners", () => {
const calls: FetchCall[] = []
@@ -159,7 +159,7 @@ it.live("InstanceState preserves directory across async boundaries", () =>
return Test.of({
get: Effect.fn("Test.get")(function* () {
yield* Effect.sleep(Duration.millis(1))
yield* Effect.promise(() => Bun.sleep(1))
yield* Effect.sleep(Duration.millis(1))
for (let i = 0; i < 100; i++) {
yield* Effect.yieldNow
@@ -168,7 +168,7 @@ it.live("InstanceState preserves directory across async boundaries", () =>
yield* Effect.promise(() => Promise.resolve())
}
yield* Effect.sleep(Duration.millis(2))
yield* Effect.sleep(Duration.millis(1))
yield* Effect.promise(() => Bun.sleep(1))
return yield* InstanceState.get(state)
}),
})
@@ -212,7 +212,7 @@ it.live("InstanceState survives high-contention concurrent access", () =>
return Test.of({
get: Effect.fn("Test.get")(function* () {
for (let i = 0; i < 10; i++) {
yield* Effect.sleep(Duration.millis(Math.random() * 3))
yield* Effect.promise(() => Bun.sleep(Math.random() * 3))
yield* Effect.yieldNow
yield* Effect.promise(() => Promise.resolve())
}
@@ -248,8 +248,8 @@ it.live("InstanceState correct after interleaved init and dispose", () =>
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) =>
Effect.gen(function* () {
yield* Effect.sleep(Duration.millis(5))
Effect.promise(async () => {
await Bun.sleep(5)
return ctx.directory
}),
)
@@ -305,9 +305,9 @@ it.live("InstanceState dedupes concurrent lookups", () =>
const dir = yield* tmpdirScoped()
let n = 0
const state = yield* InstanceState.make(() =>
Effect.gen(function* () {
Effect.promise(async () => {
n += 1
yield* Effect.sleep(Duration.millis(10))
await Bun.sleep(10)
return { n }
}),
)
+24 -25
View File
@@ -5,6 +5,7 @@ import { Cause, Effect, Exit, Layer } from "effect"
import path from "path"
import fs from "fs/promises"
import { File } from "../../src/file"
import { Filesystem } from "@/util/filesystem"
import { disposeAllInstances, TestInstance, withTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -160,7 +161,7 @@ describe("file/index Filesystem patterns", () => {
const filepath = path.join(test.directory, "test.json")
yield* Effect.promise(() => fs.writeFile(filepath, '{"key": "value"}', "utf-8"))
expect(AppFileSystem.mimeType(filepath)).toContain("application/json")
expect(yield* Effect.promise(() => Filesystem.mimeType(filepath))).toContain("application/json")
const result = yield* read("test.json")
expect(result.type).toBe("text")
@@ -180,7 +181,7 @@ describe("file/index Filesystem patterns", () => {
for (const testCase of testCases) {
const filepath = path.join(test.directory, `test.${testCase.ext}`)
yield* Effect.promise(() => fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00])))
expect(AppFileSystem.mimeType(filepath)).toContain(testCase.mime)
expect(yield* Effect.promise(() => Filesystem.mimeType(filepath))).toContain(testCase.mime)
}
}),
)
@@ -188,16 +189,15 @@ describe("file/index Filesystem patterns", () => {
describe("list() - Filesystem.exists() and readText()", () => {
it.instance(
"reads .gitignore via AppFileSystem.existsSafe() and readFileString()",
"reads .gitignore via Filesystem.exists() and readText()",
() =>
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const test = yield* TestInstance
const gitignorePath = path.join(test.directory, ".gitignore")
yield* fsys.writeFileString(gitignorePath, "node_modules\ndist\n")
yield* Effect.promise(() => fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8"))
expect(yield* fsys.existsSafe(gitignorePath)).toBe(true)
expect(yield* fsys.readFileString(gitignorePath)).toContain("node_modules")
expect(yield* Effect.promise(() => Filesystem.exists(gitignorePath))).toBe(true)
expect(yield* Effect.promise(() => Filesystem.readText(gitignorePath))).toContain("node_modules")
}),
{ git: true },
)
@@ -206,13 +206,12 @@ describe("file/index Filesystem patterns", () => {
"reads .ignore file similarly",
() =>
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const test = yield* TestInstance
const ignorePath = path.join(test.directory, ".ignore")
yield* fsys.writeFileString(ignorePath, "*.log\n.env\n")
yield* Effect.promise(() => fs.writeFile(ignorePath, "*.log\n.env\n", "utf-8"))
expect(yield* fsys.existsSafe(ignorePath)).toBe(true)
expect(yield* fsys.readFileString(ignorePath)).toContain("*.log")
expect(yield* Effect.promise(() => Filesystem.exists(ignorePath))).toBe(true)
expect(yield* Effect.promise(() => Filesystem.readText(ignorePath))).toContain("*.log")
}),
{ git: true },
)
@@ -221,10 +220,9 @@ describe("file/index Filesystem patterns", () => {
"handles missing .gitignore gracefully",
() =>
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const test = yield* TestInstance
const gitignorePath = path.join(test.directory, ".gitignore")
expect(yield* fsys.existsSafe(gitignorePath)).toBe(false)
expect(yield* Effect.promise(() => Filesystem.exists(gitignorePath))).toBe(false)
const nodes = yield* list()
expect(Array.isArray(nodes)).toBe(true)
@@ -233,17 +231,16 @@ describe("file/index Filesystem patterns", () => {
)
})
describe("File.changed() - AppFileSystem.readFileString() for untracked files", () => {
describe("File.changed() - Filesystem.readText() for untracked files", () => {
it.instance(
"reads untracked files via AppFileSystem.readFileString()",
"reads untracked files via Filesystem.readText()",
() =>
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const test = yield* TestInstance
const untrackedPath = path.join(test.directory, "untracked.txt")
yield* fsys.writeFileString(untrackedPath, "new content\nwith multiple lines")
yield* Effect.promise(() => fs.writeFile(untrackedPath, "new content\nwith multiple lines", "utf-8"))
const content = yield* fsys.readFileString(untrackedPath)
const content = yield* Effect.promise(() => Filesystem.readText(untrackedPath))
expect(content.split("\n").length).toBe(2)
}),
{ git: true },
@@ -251,26 +248,28 @@ describe("file/index Filesystem patterns", () => {
})
describe("Error handling", () => {
it.instance("handles errors gracefully in AppFileSystem.readFileString()", () =>
it.instance("handles errors gracefully in Filesystem.readText()", () =>
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const test = yield* TestInstance
yield* fsys.writeFileString(path.join(test.directory, "readonly.txt"), "content")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "readonly.txt"), "content", "utf-8"))
const nonExistentPath = path.join(test.directory, "does-not-exist.txt")
expect(Exit.isFailure(yield* fsys.readFileString(nonExistentPath).pipe(Effect.exit))).toBe(true)
expect(
Exit.isFailure(yield* Effect.promise(() => Filesystem.readText(nonExistentPath)).pipe(Effect.exit)),
).toBe(true)
const result = yield* read("does-not-exist.txt")
expect(result.content).toBe("")
}),
)
it.instance("handles errors in AppFileSystem.readFile()", () =>
it.instance("handles errors in Filesystem.readArrayBuffer()", () =>
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const test = yield* TestInstance
const nonExistentPath = path.join(test.directory, "does-not-exist.bin")
const buffer = yield* fsys.readFile(nonExistentPath).pipe(Effect.orElseSucceed(() => new Uint8Array(0)))
const buffer = yield* Effect.promise(() =>
Filesystem.readArrayBuffer(nonExistentPath).catch(() => new ArrayBuffer(0)),
)
expect(buffer.byteLength).toBe(0)
}),
)
+2 -2
View File
@@ -186,14 +186,14 @@ describe("Format", () => {
Formatter.gofmt.enabled = async () => {
active++
max = Math.max(max, active)
await Promise.resolve()
await Bun.sleep(20)
active--
return ["sh", "-c", "true"]
}
Formatter.mix.enabled = async () => {
active++
max = Math.max(max, active)
await Promise.resolve()
await Bun.sleep(20)
active--
return ["sh", "-c", "true"]
}
+110 -145
View File
@@ -1,13 +1,8 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { Effect } from "effect"
import { Patch } from "../../src/patch"
import * as fs from "fs/promises"
import * as path from "path"
import { tmpdir } from "os"
import { Patch } from "../../src/patch"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { testEffect } from "../lib/effect"
const it = testEffect(AppFileSystem.defaultLayer)
describe("Patch namespace", () => {
let tempDir: string
@@ -139,53 +134,46 @@ PATCH`
})
describe("applyPatch", () => {
it.live("should add a new file", () =>
Effect.gen(function* () {
const patchText = `*** Begin Patch
test("should add a new file", async () => {
const patchText = `*** Begin Patch
*** Add File: ${tempDir}/new-file.txt
+Hello World
+This is a new file
*** End Patch`
const result = yield* Patch.applyPatch(patchText)
expect(result.added).toHaveLength(1)
expect(result.modified).toHaveLength(0)
expect(result.deleted).toHaveLength(0)
const result = await Patch.applyPatch(patchText)
expect(result.added).toHaveLength(1)
expect(result.modified).toHaveLength(0)
expect(result.deleted).toHaveLength(0)
const content = yield* Effect.promise(() => fs.readFile(result.added[0], "utf-8"))
expect(content).toBe("Hello World\nThis is a new file")
}),
)
const content = await fs.readFile(result.added[0], "utf-8")
expect(content).toBe("Hello World\nThis is a new file")
})
it.live("should delete an existing file", () =>
Effect.gen(function* () {
const filePath = path.join(tempDir, "to-delete.txt")
yield* Effect.promise(() => fs.writeFile(filePath, "This file will be deleted"))
test("should delete an existing file", async () => {
const filePath = path.join(tempDir, "to-delete.txt")
await fs.writeFile(filePath, "This file will be deleted")
const patchText = `*** Begin Patch
const patchText = `*** Begin Patch
*** Delete File: ${filePath}
*** End Patch`
const result = yield* Patch.applyPatch(patchText)
expect(result.deleted).toHaveLength(1)
expect(result.deleted[0]).toBe(filePath)
const result = await Patch.applyPatch(patchText)
expect(result.deleted).toHaveLength(1)
expect(result.deleted[0]).toBe(filePath)
const exists = yield* Effect.promise(() =>
fs
.access(filePath)
.then(() => true)
.catch(() => false),
)
expect(exists).toBe(false)
}),
)
const exists = await fs
.access(filePath)
.then(() => true)
.catch(() => false)
expect(exists).toBe(false)
})
it.live("should update an existing file", () =>
Effect.gen(function* () {
const filePath = path.join(tempDir, "to-update.txt")
yield* Effect.promise(() => fs.writeFile(filePath, "line 1\nline 2\nline 3\n"))
test("should update an existing file", async () => {
const filePath = path.join(tempDir, "to-update.txt")
await fs.writeFile(filePath, "line 1\nline 2\nline 3\n")
const patchText = `*** Begin Patch
const patchText = `*** Begin Patch
*** Update File: ${filePath}
@@
line 1
@@ -194,22 +182,20 @@ PATCH`
line 3
*** End Patch`
const result = yield* Patch.applyPatch(patchText)
expect(result.modified).toHaveLength(1)
expect(result.modified[0]).toBe(filePath)
const result = await Patch.applyPatch(patchText)
expect(result.modified).toHaveLength(1)
expect(result.modified[0]).toBe(filePath)
const content = yield* Effect.promise(() => fs.readFile(filePath, "utf-8"))
expect(content).toBe("line 1\nline 2 updated\nline 3\n")
}),
)
const content = await fs.readFile(filePath, "utf-8")
expect(content).toBe("line 1\nline 2 updated\nline 3\n")
})
it.live("should move and update a file", () =>
Effect.gen(function* () {
const oldPath = path.join(tempDir, "old-name.txt")
const newPath = path.join(tempDir, "new-name.txt")
yield* Effect.promise(() => fs.writeFile(oldPath, "old content\n"))
test("should move and update a file", async () => {
const oldPath = path.join(tempDir, "old-name.txt")
const newPath = path.join(tempDir, "new-name.txt")
await fs.writeFile(oldPath, "old content\n")
const patchText = `*** Begin Patch
const patchText = `*** Begin Patch
*** Update File: ${oldPath}
*** Move to: ${newPath}
@@
@@ -217,33 +203,29 @@ PATCH`
+new content
*** End Patch`
const result = yield* Patch.applyPatch(patchText)
expect(result.modified).toHaveLength(1)
expect(result.modified[0]).toBe(newPath)
const result = await Patch.applyPatch(patchText)
expect(result.modified).toHaveLength(1)
expect(result.modified[0]).toBe(newPath)
const oldExists = yield* Effect.promise(() =>
fs
.access(oldPath)
.then(() => true)
.catch(() => false),
)
expect(oldExists).toBe(false)
const oldExists = await fs
.access(oldPath)
.then(() => true)
.catch(() => false)
expect(oldExists).toBe(false)
const newContent = yield* Effect.promise(() => fs.readFile(newPath, "utf-8"))
expect(newContent).toBe("new content\n")
}),
)
const newContent = await fs.readFile(newPath, "utf-8")
expect(newContent).toBe("new content\n")
})
it.live("should handle multiple operations in one patch", () =>
Effect.gen(function* () {
const file1 = path.join(tempDir, "file1.txt")
const file2 = path.join(tempDir, "file2.txt")
const file3 = path.join(tempDir, "file3.txt")
test("should handle multiple operations in one patch", async () => {
const file1 = path.join(tempDir, "file1.txt")
const file2 = path.join(tempDir, "file2.txt")
const file3 = path.join(tempDir, "file3.txt")
yield* Effect.promise(() => fs.writeFile(file1, "content 1"))
yield* Effect.promise(() => fs.writeFile(file2, "content 2"))
await fs.writeFile(file1, "content 1")
await fs.writeFile(file2, "content 2")
const patchText = `*** Begin Patch
const patchText = `*** Begin Patch
*** Add File: ${file3}
+new file content
*** Update File: ${file1}
@@ -253,114 +235,98 @@ PATCH`
*** Delete File: ${file2}
*** End Patch`
const result = yield* Patch.applyPatch(patchText)
expect(result.added).toHaveLength(1)
expect(result.modified).toHaveLength(1)
expect(result.deleted).toHaveLength(1)
}),
)
const result = await Patch.applyPatch(patchText)
expect(result.added).toHaveLength(1)
expect(result.modified).toHaveLength(1)
expect(result.deleted).toHaveLength(1)
})
it.live("should create parent directories when adding files", () =>
Effect.gen(function* () {
const nestedPath = path.join(tempDir, "deep", "nested", "file.txt")
test("should create parent directories when adding files", async () => {
const nestedPath = path.join(tempDir, "deep", "nested", "file.txt")
const patchText = `*** Begin Patch
const patchText = `*** Begin Patch
*** Add File: ${nestedPath}
+Deep nested content
*** End Patch`
const result = yield* Patch.applyPatch(patchText)
expect(result.added).toHaveLength(1)
expect(result.added[0]).toBe(nestedPath)
const result = await Patch.applyPatch(patchText)
expect(result.added).toHaveLength(1)
expect(result.added[0]).toBe(nestedPath)
const exists = yield* Effect.promise(() =>
fs
.access(nestedPath)
.then(() => true)
.catch(() => false),
)
expect(exists).toBe(true)
}),
)
const exists = await fs
.access(nestedPath)
.then(() => true)
.catch(() => false)
expect(exists).toBe(true)
})
})
describe("error handling", () => {
it.live("should fail when updating non-existent file", () =>
Effect.gen(function* () {
const nonExistent = path.join(tempDir, "does-not-exist.txt")
test("should throw error when updating non-existent file", async () => {
const nonExistent = path.join(tempDir, "does-not-exist.txt")
const patchText = `*** Begin Patch
const patchText = `*** Begin Patch
*** Update File: ${nonExistent}
@@
-old line
+new line
*** End Patch`
const exit = yield* Effect.exit(Patch.applyPatch(patchText))
expect(exit._tag).toBe("Failure")
}),
)
await expect(Patch.applyPatch(patchText)).rejects.toThrow()
})
it.live("should fail when deleting non-existent file", () =>
Effect.gen(function* () {
const nonExistent = path.join(tempDir, "does-not-exist.txt")
test("should throw error when deleting non-existent file", async () => {
const nonExistent = path.join(tempDir, "does-not-exist.txt")
const patchText = `*** Begin Patch
const patchText = `*** Begin Patch
*** Delete File: ${nonExistent}
*** End Patch`
const exit = yield* Effect.exit(Patch.applyPatch(patchText))
expect(exit._tag).toBe("Failure")
}),
)
await expect(Patch.applyPatch(patchText)).rejects.toThrow()
})
})
describe("edge cases", () => {
it.live("should handle empty files", () =>
Effect.gen(function* () {
const emptyFile = path.join(tempDir, "empty.txt")
yield* Effect.promise(() => fs.writeFile(emptyFile, ""))
test("should handle empty files", async () => {
const emptyFile = path.join(tempDir, "empty.txt")
await fs.writeFile(emptyFile, "")
const patchText = `*** Begin Patch
const patchText = `*** Begin Patch
*** Update File: ${emptyFile}
@@
+First line
*** End Patch`
const result = yield* Patch.applyPatch(patchText)
expect(result.modified).toHaveLength(1)
const result = await Patch.applyPatch(patchText)
expect(result.modified).toHaveLength(1)
const content = yield* Effect.promise(() => fs.readFile(emptyFile, "utf-8"))
expect(content).toBe("First line\n")
}),
)
const content = await fs.readFile(emptyFile, "utf-8")
expect(content).toBe("First line\n")
})
it.live("should handle files with no trailing newline", () =>
Effect.gen(function* () {
const filePath = path.join(tempDir, "no-newline.txt")
yield* Effect.promise(() => fs.writeFile(filePath, "no newline"))
test("should handle files with no trailing newline", async () => {
const filePath = path.join(tempDir, "no-newline.txt")
await fs.writeFile(filePath, "no newline")
const patchText = `*** Begin Patch
const patchText = `*** Begin Patch
*** Update File: ${filePath}
@@
-no newline
+has newline now
*** End Patch`
const result = yield* Patch.applyPatch(patchText)
expect(result.modified).toHaveLength(1)
const result = await Patch.applyPatch(patchText)
expect(result.modified).toHaveLength(1)
const content = yield* Effect.promise(() => fs.readFile(filePath, "utf-8"))
expect(content).toBe("has newline now\n")
}),
)
const content = await fs.readFile(filePath, "utf-8")
expect(content).toBe("has newline now\n")
})
it.live("should handle multiple update chunks in single file", () =>
Effect.gen(function* () {
const filePath = path.join(tempDir, "multi-chunk.txt")
yield* Effect.promise(() => fs.writeFile(filePath, "line 1\nline 2\nline 3\nline 4\n"))
test("should handle multiple update chunks in single file", async () => {
const filePath = path.join(tempDir, "multi-chunk.txt")
await fs.writeFile(filePath, "line 1\nline 2\nline 3\nline 4\n")
const patchText = `*** Begin Patch
const patchText = `*** Begin Patch
*** Update File: ${filePath}
@@
line 1
@@ -372,12 +338,11 @@ PATCH`
+LINE 4
*** End Patch`
const result = yield* Patch.applyPatch(patchText)
expect(result.modified).toHaveLength(1)
const result = await Patch.applyPatch(patchText)
expect(result.modified).toHaveLength(1)
const content = yield* Effect.promise(() => fs.readFile(filePath, "utf-8"))
expect(content).toBe("line 1\nLINE 2\nline 3\nLINE 4\n")
}),
)
const content = await fs.readFile(filePath, "utf-8")
expect(content).toBe("line 1\nLINE 2\nline 3\nLINE 4\n")
})
})
})
@@ -4,9 +4,9 @@ import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { Filesystem } from "@/util/filesystem"
const { Plugin } = await import("../../src/plugin/index")
const { PluginLoader } = await import("../../src/plugin/loader")
@@ -20,7 +20,7 @@ afterEach(async () => {
await disposeAllInstances()
})
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer))
const it = testEffect(CrossSpawnSpawner.defaultLayer)
function withTmp<T, A, E, R>(
init: (dir: string) => Promise<T>,
@@ -837,7 +837,7 @@ describe("plugin.loader.shared", () => {
Effect.gen(function* () {
yield* load(tmp.path)
expect(
(yield* (yield* AppFileSystem.Service).readJson(tmp.extra.mark)) as { source: string; enabled: boolean },
yield* Effect.promise(() => Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark)),
).toEqual({
source: "tuple",
enabled: true,
@@ -960,8 +960,7 @@ export default {
(tmp) =>
Effect.gen(function* () {
const file = path.join(tmp.extra.mod, "package.json")
const fsys = yield* AppFileSystem.Service
const json = (yield* fsys.readJson(file)) as Record<string, unknown>
const json = yield* Effect.promise(() => Filesystem.readJson<Record<string, unknown>>(file))
const list = readPackageThemes("acme-plugin", {
dir: tmp.extra.mod,
pkg: file,
@@ -969,8 +968,8 @@ export default {
})
expect(list).toEqual([
AppFileSystem.resolve(path.join(tmp.extra.mod, "themes", "one.json")),
AppFileSystem.resolve(path.join(tmp.extra.mod, "themes", "two.json")),
Filesystem.resolve(path.join(tmp.extra.mod, "themes", "one.json")),
Filesystem.resolve(path.join(tmp.extra.mod, "themes", "two.json")),
])
}),
),
@@ -1034,7 +1033,7 @@ export default {
{
spec: "acme-plugin@1.0.0",
target: tmp.extra.mod,
themes: [AppFileSystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
},
])
expect(missing).toHaveLength(0)
@@ -1097,7 +1096,7 @@ export default {
expect(loaded).toEqual([
{
spec: "acme-plugin@1.0.0",
themes: [AppFileSystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
},
])
} finally {
@@ -1118,8 +1117,7 @@ export default {
},
(tmp) =>
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const json = (yield* fsys.readJson(tmp.extra.file)) as Record<string, unknown>
const json = yield* Effect.promise(() => Filesystem.readJson<Record<string, unknown>>(tmp.extra.file))
expect(() =>
readPackageThemes("acme", {
dir: tmp.extra.mod,
@@ -56,7 +56,6 @@ const workspaceLayer = Workspace.layer.pipe(
Layer.provide(Project.defaultLayer),
Layer.provide(Vcs.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrapLayer))),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: true })),
)
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { ConfigProvider } from "@/config/provider"
import { CatalogModelStatus, ModelStatus } from "@/provider/model-status"
import { ModelsDev } from "@opencode-ai/core/models"
import { ModelsDev } from "@/provider/models"
import { Provider } from "@/provider/provider"
describe("provider model status schemas", () => {
@@ -4,10 +4,11 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { ModelsDev } from "@opencode-ai/core/models"
import { it } from "./lib/effect"
import { ModelsDev } from "../../src/provider/models"
import { it } from "../lib/effect"
import { rm, writeFile, utimes, mkdir } from "fs/promises"
import path from "path"
import { RuntimeFlags } from "@/effect/runtime-flags"
// test/preload.ts pins OPENCODE_MODELS_PATH to a fixture so other tests can
// resolve providers without network. These tests need to drive the on-disk
@@ -92,6 +93,7 @@ const buildLayer = (state: Ref.Ref<MockState>) =>
Layer.fresh(ModelsDev.layer).pipe(
Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(RuntimeFlags.layer({ client: "test-client" })),
)
const writeCache = (data: object, mtimeMs?: number) =>
@@ -136,14 +138,14 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() returns bundled snapshot when disk empty and fetch disabled", () =>
it.live("get() returns {} when disk empty and fetch disabled", () =>
Effect.gen(function* () {
const state = yield* Ref.make(initialState)
const result = yield* provided(
state,
ModelsDev.Service.use((s) => s.get()),
)
expect(Object.keys(result).length).toBeGreaterThan(0)
expect(result).toEqual({})
const final = yield* Ref.get(state)
expect(final.calls).toEqual([])
}),
@@ -205,7 +207,7 @@ describe("ModelsDev Service", () => {
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(final.calls[0].url).toContain("/api.json")
expect(final.calls[0].userAgent).toContain("/cli")
expect(final.calls[0].userAgent).toContain("/test-client")
}),
)
@@ -255,7 +257,7 @@ describe("ModelsDev Service", () => {
}),
)
expect(result).toEqual(fixture)
// retryTransient retries 5xx, so calls may be > 1.
// withTransientReadRetry retries 5xx, so calls may be > 1.
const final = yield* Ref.get(state)
expect(final.calls.length).toBeGreaterThanOrEqual(1)
}),
@@ -7,7 +7,7 @@ import { Global } from "@opencode-ai/core/global"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Plugin } from "../../src/plugin/index"
import { ModelsDev } from "@opencode-ai/core/models"
import { ModelsDev } from "@/provider/models"
import { Provider } from "@/provider/provider"
import { ProviderID, ModelID } from "../../src/provider/schema"
import { Filesystem } from "@/util/filesystem"
@@ -1025,27 +1025,6 @@ test("getSmallModel respects config small_model override", async () => {
})
})
test("getSmallModel ignores invalid config small_model", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
small_model: "anthropic/not-a-real-model",
}),
)
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
expect(await getSmallModel(ProviderID.anthropic)).toBeUndefined()
},
})
})
test("provider.sort prioritizes preferred models", () => {
const models = [
{ id: "random-model", name: "Random" },
@@ -1,13 +1,10 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Bus } from "../../src/bus"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { EventPaths } from "../../src/server/routes/instance/httpapi/event"
import { Event as ServerEvent } from "../../src/server/event"
import * as Log from "@opencode-ai/core/util/log"
import { Schema } from "effect"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, reloadTestInstance, tmpdir } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
@@ -15,53 +12,22 @@ function app() {
return Server.Default().app
}
const EventData = Schema.Struct({
id: Schema.optional(Schema.String),
type: Schema.String,
properties: Schema.Record(Schema.String, Schema.Any),
})
async function readChunk(reader: ReadableStreamDefaultReader<Uint8Array>) {
let timeout: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
reader.read(),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error("timed out waiting for event")), 5_000)
}),
])
} finally {
if (timeout) clearTimeout(timeout)
}
async function readFirstChunk(response: Response) {
if (!response.body) throw new Error("missing response body")
const reader = response.body.getReader()
const result = await Promise.race([
reader.read(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("timed out waiting for event")), 5_000)),
])
await reader.cancel()
return new TextDecoder().decode(result.value)
}
async function readFirstEvent(response: Response) {
if (!response.body) throw new Error("missing response body")
const reader = response.body.getReader()
try {
return await readEvent(reader)
} finally {
await reader.cancel()
}
}
async function readEvent(reader: ReadableStreamDefaultReader<Uint8Array>) {
const result = await readChunk(reader)
if (result.done || !result.value) throw new Error("event stream closed")
return Schema.decodeUnknownSync(EventData)(JSON.parse(new TextDecoder().decode(result.value).replace(/^data: /, "")))
}
async function readStatusWithin(reader: ReadableStreamDefaultReader<Uint8Array>, delay: number) {
let timeout: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
reader.read().then((result) => (result.done ? "closed" : "event")),
new Promise<"open">((resolve) => {
timeout = setTimeout(() => resolve("open"), delay)
}),
])
} finally {
if (timeout) clearTimeout(timeout)
return JSON.parse((await readFirstChunk(response)).replace(/^data: /, "")) as {
id?: string
type: string
properties: Record<string, unknown>
}
}
@@ -83,36 +49,11 @@ describe("event HttpApi", () => {
expect(await readFirstEvent(response)).toMatchObject({ type: "server.connected", properties: {} })
})
test("keeps the event stream open after the initial event", async () => {
test("serves the initial server connected event", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
if (!response.body) throw new Error("missing response body")
const headers = { "x-opencode-directory": tmp.path }
const response = await app().request(EventPaths.event, { headers })
const reader = response.body.getReader()
try {
expect(await readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
expect(await readStatusWithin(reader, 250)).toBe("open")
} finally {
await reader.cancel()
}
})
test("delivers instance bus events after the initial event", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
if (!response.body) throw new Error("missing response body")
const reader = response.body.getReader()
try {
expect(await readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
const next = readEvent(reader)
const ctx = await reloadTestInstance({ directory: tmp.path })
await Instance.restore(ctx, () => Bus.publish(ServerEvent.Connected, {}))
expect(await next).toMatchObject({ type: "server.connected", properties: {} })
} finally {
await reader.cancel()
}
expect(await readFirstEvent(response)).toMatchObject({ type: "server.connected", properties: {} })
})
})
+1 -1
View File
@@ -9,7 +9,7 @@ import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform"
import { ModelsDev } from "@opencode-ai/core/models"
import { ModelsDev } from "@/provider/models"
import { ProviderID, ModelID } from "../../src/provider/schema"
import { Filesystem } from "@/util/filesystem"
import { tmpdir } from "../fixture/fixture"

Some files were not shown because too many files have changed in this diff Show More