mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-02 16:26:14 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd45c7bb0f |
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}) {}
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
)
|
||||
@@ -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", () =>
|
||||
|
||||
@@ -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", () =>
|
||||
+1
-1
@@ -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))
|
||||
+1
-1
@@ -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))
|
||||
+1
-1
@@ -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))
|
||||
+1
-1
@@ -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)
|
||||
+1
-1
@@ -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>[] = []
|
||||
+1
-1
@@ -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)))
|
||||
+1
-1
@@ -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)))
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-4
@@ -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)),
|
||||
)
|
||||
})
|
||||
+1
-1
@@ -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)
|
||||
@@ -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)),
|
||||
}) {}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })]
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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,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* () {
|
||||
|
||||
@@ -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 "."
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
+5
-5
@@ -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 }
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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: {} })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user