Compare commits

...

11 Commits

Author SHA1 Message Date
Kit Langton ea27e2457e test(skill): migrate skill tests to effect fixtures (#29046) 2026-05-24 20:12:48 -04:00
Kit Langton f00a681fc5 test(lsp): migrate client tests to effect fixtures (#29045) 2026-05-24 20:12:19 -04:00
Kit Langton 9d2e2af865 test(project): use effect service fixtures (#29044) 2026-05-24 11:19:35 -04:00
Kit Langton 47560358de test(opencode): migrate file fixtures to app filesystem (#29043) 2026-05-24 11:17:56 -04:00
Kit Langton 84eff93bfa test(opencode): migrate tool registry fixtures (#29042) 2026-05-24 11:15:39 -04:00
Kit Langton e2fd4413c8 test(opencode): migrate skill fixtures to app filesystem (#29040) 2026-05-24 11:12:32 -04:00
Kit Langton ed95ba73f4 test(opencode): restore config coverage parity 2026-05-23 19:39:03 -04:00
Kit Langton 8890121da0 refactor(core): centralize service use helper 2026-05-23 19:32:04 -04:00
Kit Langton 2fc0793f1f test(opencode): use filesystem service accessors 2026-05-23 15:01:01 -04:00
Kit Langton 22f79f05cf fix(desktop): declare scheduled primitive dependency 2026-05-23 14:55:11 -04:00
Kit Langton 4655dc84c7 test(opencode): simplify config effect tests 2026-05-23 14:54:00 -04:00
33 changed files with 1349 additions and 1775 deletions
@@ -15,6 +15,7 @@ type ServiceUse<Identifier, Shape> = {
} }
export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, Shape>) => { export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, Shape>) => {
const cache = new Map<string, (...args: unknown[]) => Effect.Effect<unknown, unknown, unknown>>()
// This is the only dynamic boundary: TypeScript knows the accessor shape, // This is the only dynamic boundary: TypeScript knows the accessor shape,
// but Proxy property names are runtime values. // but Proxy property names are runtime values.
const access = new Proxy( const access = new Proxy(
@@ -22,7 +23,9 @@ export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, S
{ {
get: (_, key) => { get: (_, key) => {
if (typeof key !== "string") return undefined if (typeof key !== "string") return undefined
return (...args: unknown[]) => const cached = cache.get(key)
if (cached) return cached
const accessor = (...args: unknown[]) =>
tag.use((service) => { tag.use((service) => {
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy keys are checked at runtime. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy keys are checked at runtime.
const method = service[key as keyof Shape] const method = service[key as keyof Shape]
@@ -30,6 +33,8 @@ export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, S
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- ServiceUse exposes only Effect-returning methods. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- ServiceUse exposes only Effect-returning methods.
return (method as (...args: unknown[]) => Effect.Effect<unknown, unknown, unknown>)(...args) return (method as (...args: unknown[]) => Effect.Effect<unknown, unknown, unknown>)(...args)
}) })
cache.set(key, accessor)
return accessor
}, },
}, },
) )
+4 -1
View File
@@ -3,9 +3,10 @@ import { dirname, join, relative, resolve as pathResolve } from "path"
import { realpathSync } from "fs" import { realpathSync } from "fs"
import * as NFS from "fs/promises" import * as NFS from "fs/promises"
import { lookup } from "mime-types" import { lookup } from "mime-types"
import { Effect, FileSystem, Layer, Schema, Context } from "effect" import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import type { PlatformError } from "effect/PlatformError" import type { PlatformError } from "effect/PlatformError"
import { Glob } from "./util/glob" import { Glob } from "./util/glob"
import { serviceUse } from "./effect/service-use"
export namespace AppFileSystem { export namespace AppFileSystem {
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", { export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
@@ -39,6 +40,8 @@ export namespace AppFileSystem {
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {} export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
export const use = serviceUse(Service)
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
+1 -1
View File
@@ -1,5 +1,5 @@
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect" import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { import {
FetchHttpClient, FetchHttpClient,
HttpClient, HttpClient,
+1 -1
View File
@@ -1,5 +1,5 @@
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Effect, Layer, Option, Schema, Context } from "effect" import { Effect, Layer, Option, Schema, Context } from "effect"
import { Database } from "@/storage/db" import { Database } from "@/storage/db"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../provider/schema" import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai" import { generateObject, streamObject, type ModelMessage } from "ai"
+1 -1
View File
@@ -5,7 +5,7 @@ import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global" import { GlobalBus } from "./global"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Identifier } from "@/id/id" import { Identifier } from "@/id/id"
import type { InstanceContext } from "@/project/instance-context" import type { InstanceContext } from "@/project/instance-context"
import { InstanceRef } from "@/effect/instance-ref" import { InstanceRef } from "@/effect/instance-ref"
+1 -1
View File
@@ -1,5 +1,5 @@
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import os from "os" import os from "os"
@@ -1,5 +1,5 @@
import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect" import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http" import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { Database } from "@/storage/db" import { Database } from "@/storage/db"
import { asc } from "drizzle-orm" import { asc } from "drizzle-orm"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
type State = Record<string, string | undefined> type State = Record<string, string | undefined>
+1 -1
View File
@@ -1,5 +1,5 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
+1 -1
View File
@@ -1,5 +1,5 @@
import path from "path" import path from "path"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect" import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect"
import type { PlatformError } from "effect/PlatformError" import type { PlatformError } from "effect/PlatformError"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect, Layer, Context, Schema } from "effect" import { Effect, Layer, Context, Schema } from "effect"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import { AppProcess } from "@opencode-ai/core/process" import { AppProcess } from "@opencode-ai/core/process"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect, Layer, Schema, Context, Stream } from "effect" import { Effect, Layer, Schema, Context, Stream } from "effect"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client" import { withTransientReadRetry } from "@/util/effect-http-client"
import { errorMessage } from "@/util/error" import { errorMessage } from "@/util/error"
+1 -1
View File
@@ -1,5 +1,5 @@
import path from "path" import path from "path"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Effect, Layer, Context, Option, Schema } from "effect" import { Effect, Layer, Context, Option, Schema } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
+1 -1
View File
@@ -1,5 +1,5 @@
import { dynamicTool, type Tool, jsonSchema, type JSONSchema7 } from "ai" import { dynamicTool, type Tool, jsonSchema, type JSONSchema7 } from "ai"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
@@ -1,5 +1,5 @@
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceRef } from "@/effect/instance-ref" import { InstanceRef } from "@/effect/instance-ref"
import { disposeInstance as runDisposers } from "@/effect/instance-registry" import { disposeInstance as runDisposers } from "@/effect/instance-registry"
+1 -1
View File
@@ -20,7 +20,7 @@ import { AppProcess } from "@opencode-ai/core/process"
import { Project as ProjectV2 } from "@opencode-ai/core/project" import { Project as ProjectV2 } from "@opencode-ai/core/project"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema" import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
const log = Log.create({ service: "project" }) const log = Log.create({ service: "project" })
+1 -1
View File
@@ -1,5 +1,5 @@
import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin" import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Auth } from "@/auth" import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { optionalOmitUndefined } from "@opencode-ai/core/schema"
+1 -1
View File
@@ -7,7 +7,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
import { Hash } from "@opencode-ai/core/util/hash" import { Hash } from "@opencode-ai/core/util/hash"
import { Plugin } from "../plugin" import { Plugin } from "../plugin"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { type LanguageModelV3 } from "@ai-sdk/provider" import { type LanguageModelV3 } from "@ai-sdk/provider"
import * as ModelsDev from "@opencode-ai/core/models-dev" import * as ModelsDev from "@opencode-ai/core/models-dev"
import { Auth } from "../auth" import { Auth } from "../auth"
+1 -1
View File
@@ -16,7 +16,7 @@ import { Effect, Layer, Context, Schema } from "effect"
import * as DateTime from "effect/DateTime" import * as DateTime from "effect/DateTime"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { isOverflow as overflow, usable } from "./overflow" import { isOverflow as overflow, usable } from "./overflow"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { SessionEvent } from "@opencode-ai/core/session-event" import { SessionEvent } from "@opencode-ai/core/session-event"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Slug } from "@opencode-ai/core/util/slug" import { Slug } from "@opencode-ai/core/util/slug"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import path from "path" import path from "path"
import { BackgroundJob } from "@/background/job" import { BackgroundJob } from "@/background/job"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
+1 -1
View File
@@ -1,5 +1,5 @@
import type * as SDK from "@opencode-ai/sdk/v2" import type * as SDK from "@opencode-ai/sdk/v2"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect" import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Account } from "@/account/account" import { Account } from "@/account/account"
+3
View File
@@ -16,6 +16,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { Discovery } from "./discovery" import { Discovery } from "./discovery"
import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" } import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" }
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
const log = Log.create({ service: "skill" }) const log = Log.create({ service: "skill" })
const CLAUDE_EXTERNAL_DIR = ".claude" const CLAUDE_EXTERNAL_DIR = ".claude"
@@ -243,6 +244,8 @@ const loadSkills = Effect.fnUntraced(function* (state: State, discovered: Discov
export class Service extends Context.Service<Service, Interface>()("@opencode/Skill") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Skill") {}
export const use = serviceUse(Service)
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
+1 -1
View File
@@ -12,7 +12,7 @@ import { EventID } from "./schema"
import { Context, Effect, Layer, Schema as EffectSchema } from "effect" import { Context, Effect, Layer, Schema as EffectSchema } from "effect"
import type { DeepMutable } from "@opencode-ai/core/schema" import type { DeepMutable } from "@opencode-ai/core/schema"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import { EffectBridge } from "@/effect/bridge" import { EffectBridge } from "@/effect/bridge"
+3
View File
@@ -55,6 +55,7 @@ import { Reference } from "@/reference/reference"
import { BackgroundJob } from "@/background/job" import { BackgroundJob } from "@/background/job"
import { SessionStatus } from "@/session/status" import { SessionStatus } from "@/session/status"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
const log = Log.create({ service: "tool.registry" }) const log = Log.create({ service: "tool.registry" })
@@ -81,6 +82,8 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ToolRegistry") {} export class Service extends Context.Service<Service, Interface>()("@opencode/ToolRegistry") {}
export const use = serviceUse(Service)
export const layer: Layer.Layer< export const layer: Layer.Layer<
Service, Service,
never, never,
File diff suppressed because it is too large Load Diff
+63 -75
View File
@@ -3,7 +3,6 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { $ } from "bun" import { $ } from "bun"
import { Cause, Effect, Exit, Layer } from "effect" import { Cause, Effect, Exit, Layer } from "effect"
import path from "path" import path from "path"
import fs from "fs/promises"
import { File } from "../../src/file" import { File } from "../../src/file"
import { disposeAllInstances, TestInstance, withTmpdirInstance } from "../fixture/fixture" import { disposeAllInstances, TestInstance, withTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@@ -48,6 +47,11 @@ const gitAddAll = (directory: string) => Effect.promise(() => $`git add .`.cwd(d
const gitCommit = (directory: string, message: string) => const gitCommit = (directory: string, message: string) =>
Effect.promise(() => $`git commit -m ${message}`.cwd(directory).quiet()) Effect.promise(() => $`git commit -m ${message}`.cwd(directory).quiet())
const writeFixtureFile = (directory: string, file: string, content: string | Uint8Array) =>
AppFileSystem.use.writeWithDirs(path.join(directory, file), content)
const removeFixtureFile = (directory: string, file: string) => AppFileSystem.use.remove(path.join(directory, file))
const failureMessage = <A, E, R>(self: Effect.Effect<A, E, R>) => const failureMessage = <A, E, R>(self: Effect.Effect<A, E, R>) =>
Effect.gen(function* () { Effect.gen(function* () {
const exit = yield* self.pipe(Effect.exit) const exit = yield* self.pipe(Effect.exit)
@@ -72,7 +76,7 @@ describe("file/index Filesystem patterns", () => {
it.instance("reads text file via Filesystem.readText()", () => it.instance("reads text file via Filesystem.readText()", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.txt"), "Hello World", "utf-8")) yield* writeFixtureFile(test.directory, "test.txt", "Hello World")
const result = yield* read("test.txt") const result = yield* read("test.txt")
expect(result.type).toBe("text") expect(result.type).toBe("text")
@@ -91,9 +95,7 @@ describe("file/index Filesystem patterns", () => {
it.instance("trims whitespace from text content", () => it.instance("trims whitespace from text content", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
yield* Effect.promise(() => yield* writeFixtureFile(test.directory, "test.txt", " content with spaces \n\n")
fs.writeFile(path.join(test.directory, "test.txt"), " content with spaces \n\n", "utf-8"),
)
const result = yield* read("test.txt") const result = yield* read("test.txt")
expect(result.content).toBe("content with spaces") expect(result.content).toBe("content with spaces")
@@ -103,7 +105,7 @@ describe("file/index Filesystem patterns", () => {
it.instance("handles empty text file", () => it.instance("handles empty text file", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "empty.txt"), "", "utf-8")) yield* writeFixtureFile(test.directory, "empty.txt", "")
const result = yield* read("empty.txt") const result = yield* read("empty.txt")
expect(result.type).toBe("text") expect(result.type).toBe("text")
@@ -114,9 +116,7 @@ describe("file/index Filesystem patterns", () => {
it.instance("handles multi-line text files", () => it.instance("handles multi-line text files", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
yield* Effect.promise(() => yield* writeFixtureFile(test.directory, "multiline.txt", "line1\nline2\nline3")
fs.writeFile(path.join(test.directory, "multiline.txt"), "line1\nline2\nline3", "utf-8"),
)
const result = yield* read("multiline.txt") const result = yield* read("multiline.txt")
expect(result.content).toBe("line1\nline2\nline3") expect(result.content).toBe("line1\nline2\nline3")
@@ -129,7 +129,7 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "image.png"), binaryContent)) yield* writeFixtureFile(test.directory, "image.png", binaryContent)
const result = yield* read("image.png") const result = yield* read("image.png")
expect(result.type).toBe("text") expect(result.type).toBe("text")
@@ -142,9 +142,7 @@ describe("file/index Filesystem patterns", () => {
it.instance("returns empty for binary non-image files", () => it.instance("returns empty for binary non-image files", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
yield* Effect.promise(() => yield* writeFixtureFile(test.directory, "binary.so", Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
fs.writeFile(path.join(test.directory, "binary.so"), Buffer.from([0x7f, 0x45, 0x4c, 0x46])),
)
const result = yield* read("binary.so") const result = yield* read("binary.so")
expect(result.type).toBe("binary") expect(result.type).toBe("binary")
@@ -158,7 +156,7 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
const filepath = path.join(test.directory, "test.json") const filepath = path.join(test.directory, "test.json")
yield* Effect.promise(() => fs.writeFile(filepath, '{"key": "value"}', "utf-8")) yield* AppFileSystem.use.writeWithDirs(filepath, '{"key": "value"}')
expect(AppFileSystem.mimeType(filepath)).toContain("application/json") expect(AppFileSystem.mimeType(filepath)).toContain("application/json")
@@ -179,7 +177,7 @@ describe("file/index Filesystem patterns", () => {
for (const testCase of testCases) { for (const testCase of testCases) {
const filepath = path.join(test.directory, `test.${testCase.ext}`) const filepath = path.join(test.directory, `test.${testCase.ext}`)
yield* Effect.promise(() => fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]))) yield* AppFileSystem.use.writeWithDirs(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]))
expect(AppFileSystem.mimeType(filepath)).toContain(testCase.mime) expect(AppFileSystem.mimeType(filepath)).toContain(testCase.mime)
} }
}), }),
@@ -288,9 +286,7 @@ describe("file/index Filesystem patterns", () => {
it.instance("treats .ts files as text", () => it.instance("treats .ts files as text", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
yield* Effect.promise(() => yield* writeFixtureFile(test.directory, "test.ts", "export const value = 1")
fs.writeFile(path.join(test.directory, "test.ts"), "export const value = 1", "utf-8"),
)
const result = yield* read("test.ts") const result = yield* read("test.ts")
expect(result.type).toBe("text") expect(result.type).toBe("text")
@@ -301,9 +297,7 @@ describe("file/index Filesystem patterns", () => {
it.instance("treats .mts files as text", () => it.instance("treats .mts files as text", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
yield* Effect.promise(() => yield* writeFixtureFile(test.directory, "test.mts", "export const value = 1")
fs.writeFile(path.join(test.directory, "test.mts"), "export const value = 1", "utf-8"),
)
const result = yield* read("test.mts") const result = yield* read("test.mts")
expect(result.type).toBe("text") expect(result.type).toBe("text")
@@ -314,9 +308,7 @@ describe("file/index Filesystem patterns", () => {
it.instance("treats .sh files as text", () => it.instance("treats .sh files as text", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
yield* Effect.promise(() => yield* writeFixtureFile(test.directory, "test.sh", "#!/usr/bin/env bash\necho hello")
fs.writeFile(path.join(test.directory, "test.sh"), "#!/usr/bin/env bash\necho hello", "utf-8"),
)
const result = yield* read("test.sh") const result = yield* read("test.sh")
expect(result.type).toBe("text") expect(result.type).toBe("text")
@@ -327,7 +319,7 @@ describe("file/index Filesystem patterns", () => {
it.instance("treats Dockerfile as text", () => it.instance("treats Dockerfile as text", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "Dockerfile"), "FROM alpine:3.20", "utf-8")) yield* writeFixtureFile(test.directory, "Dockerfile", "FROM alpine:3.20")
const result = yield* read("Dockerfile") const result = yield* read("Dockerfile")
expect(result.type).toBe("text") expect(result.type).toBe("text")
@@ -338,7 +330,7 @@ describe("file/index Filesystem patterns", () => {
it.instance("returns encoding info for text files", () => it.instance("returns encoding info for text files", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.txt"), "simple text", "utf-8")) yield* writeFixtureFile(test.directory, "test.txt", "simple text")
const result = yield* read("test.txt") const result = yield* read("test.txt")
expect(result.encoding).toBeUndefined() expect(result.encoding).toBeUndefined()
@@ -349,9 +341,7 @@ describe("file/index Filesystem patterns", () => {
it.instance("returns base64 encoding for images", () => it.instance("returns base64 encoding for images", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
yield* Effect.promise(() => yield* writeFixtureFile(test.directory, "test.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xe0]))
fs.writeFile(path.join(test.directory, "test.jpg"), Buffer.from([0xff, 0xd8, 0xff, 0xe0])),
)
const result = yield* read("test.jpg") const result = yield* read("test.jpg")
expect(result.encoding).toBe("base64") expect(result.encoding).toBe("base64")
@@ -381,10 +371,10 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
const filepath = path.join(test.directory, "file.txt") const filepath = path.join(test.directory, "file.txt")
yield* Effect.promise(() => fs.writeFile(filepath, "original\n", "utf-8")) yield* AppFileSystem.use.writeWithDirs(filepath, "original\n")
yield* gitAddAll(test.directory) yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file") yield* gitCommit(test.directory, "add file")
yield* Effect.promise(() => fs.writeFile(filepath, "modified\nextra line\n", "utf-8")) yield* AppFileSystem.use.writeWithDirs(filepath, "modified\nextra line\n")
const result = yield* status() const result = yield* status()
const entry = result.find((file) => file.path === "file.txt") const entry = result.find((file) => file.path === "file.txt")
@@ -401,9 +391,7 @@ describe("file/index Filesystem patterns", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
yield* Effect.promise(() => yield* writeFixtureFile(test.directory, "new.txt", "line1\nline2\nline3\n")
fs.writeFile(path.join(test.directory, "new.txt"), "line1\nline2\nline3\n", "utf-8"),
)
const result = yield* status() const result = yield* status()
const entry = result.find((file) => file.path === "new.txt") const entry = result.find((file) => file.path === "new.txt")
@@ -421,10 +409,10 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
const filepath = path.join(test.directory, "gone.txt") const filepath = path.join(test.directory, "gone.txt")
yield* Effect.promise(() => fs.writeFile(filepath, "content\n", "utf-8")) yield* AppFileSystem.use.writeWithDirs(filepath, "content\n")
yield* gitAddAll(test.directory) yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file") yield* gitCommit(test.directory, "add file")
yield* Effect.promise(() => fs.rm(filepath)) yield* AppFileSystem.use.remove(filepath)
const result = yield* status() const result = yield* status()
const entries = result.filter((file) => file.path === "gone.txt") const entries = result.filter((file) => file.path === "gone.txt")
@@ -438,14 +426,14 @@ describe("file/index Filesystem patterns", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "keep.txt"), "keep\n", "utf-8")) yield* writeFixtureFile(test.directory, "keep.txt", "keep\n")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "remove.txt"), "remove\n", "utf-8")) yield* writeFixtureFile(test.directory, "remove.txt", "remove\n")
yield* gitAddAll(test.directory) yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "initial") yield* gitCommit(test.directory, "initial")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "keep.txt"), "changed\n", "utf-8")) yield* writeFixtureFile(test.directory, "keep.txt", "changed\n")
yield* Effect.promise(() => fs.rm(path.join(test.directory, "remove.txt"))) yield* removeFixtureFile(test.directory, "remove.txt")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "brand-new.txt"), "hello\n", "utf-8")) yield* writeFixtureFile(test.directory, "brand-new.txt", "hello\n")
const result = yield* status() const result = yield* status()
expect(result.some((file) => file.path === "keep.txt" && file.status === "modified")).toBe(true) expect(result.some((file) => file.path === "keep.txt" && file.status === "modified")).toBe(true)
@@ -476,13 +464,15 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
const filepath = path.join(test.directory, "data.bin") const filepath = path.join(test.directory, "data.bin")
yield* Effect.promise(() => yield* AppFileSystem.use.writeWithDirs(
fs.writeFile(filepath, Buffer.from(Array.from({ length: 256 }, (_, index) => index))), filepath,
Buffer.from(Array.from({ length: 256 }, (_, index) => index)),
) )
yield* gitAddAll(test.directory) yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add binary") yield* gitCommit(test.directory, "add binary")
yield* Effect.promise(() => yield* AppFileSystem.use.writeWithDirs(
fs.writeFile(filepath, Buffer.from(Array.from({ length: 512 }, (_, index) => index % 256))), filepath,
Buffer.from(Array.from({ length: 512 }, (_, index) => index % 256)),
) )
const result = yield* status() const result = yield* status()
@@ -502,11 +492,9 @@ describe("file/index Filesystem patterns", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "subdir"))) yield* AppFileSystem.use.ensureDir(path.join(test.directory, "subdir"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "file.txt"), "content", "utf-8")) yield* writeFixtureFile(test.directory, "file.txt", "content")
yield* Effect.promise(() => yield* writeFixtureFile(test.directory, path.join("subdir", "nested.txt"), "nested")
fs.writeFile(path.join(test.directory, "subdir", "nested.txt"), "nested", "utf-8"),
)
const nodes = yield* list() const nodes = yield* list()
expect(nodes.length).toBeGreaterThanOrEqual(2) expect(nodes.length).toBeGreaterThanOrEqual(2)
@@ -527,10 +515,10 @@ describe("file/index Filesystem patterns", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "beta"))) yield* AppFileSystem.use.ensureDir(path.join(test.directory, "beta"))
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "alpha"))) yield* AppFileSystem.use.ensureDir(path.join(test.directory, "alpha"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "zz.txt"), "", "utf-8")) yield* writeFixtureFile(test.directory, "zz.txt", "")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "aa.txt"), "", "utf-8")) yield* writeFixtureFile(test.directory, "aa.txt", "")
const nodes = yield* list() const nodes = yield* list()
const dirs = nodes.filter((node) => node.type === "directory") const dirs = nodes.filter((node) => node.type === "directory")
@@ -551,8 +539,8 @@ describe("file/index Filesystem patterns", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, ".DS_Store"), "", "utf-8")) yield* writeFixtureFile(test.directory, ".DS_Store", "")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "visible.txt"), "", "utf-8")) yield* writeFixtureFile(test.directory, "visible.txt", "")
const names = (yield* list()).map((node) => node.name) const names = (yield* list()).map((node) => node.name)
expect(names).not.toContain(".git") expect(names).not.toContain(".git")
@@ -567,10 +555,10 @@ describe("file/index Filesystem patterns", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, ".gitignore"), "*.log\nbuild/\n", "utf-8")) yield* writeFixtureFile(test.directory, ".gitignore", "*.log\nbuild/\n")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "app.log"), "log data", "utf-8")) yield* writeFixtureFile(test.directory, "app.log", "log data")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "main.ts"), "code", "utf-8")) yield* writeFixtureFile(test.directory, "main.ts", "code")
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "build"))) yield* AppFileSystem.use.ensureDir(path.join(test.directory, "build"))
const nodes = yield* list() const nodes = yield* list()
expect(nodes.find((node) => node.name === "app.log")?.ignored).toBe(true) expect(nodes.find((node) => node.name === "app.log")?.ignored).toBe(true)
@@ -585,9 +573,9 @@ describe("file/index Filesystem patterns", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "sub"))) yield* AppFileSystem.use.ensureDir(path.join(test.directory, "sub"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "sub", "a.txt"), "", "utf-8")) yield* writeFixtureFile(test.directory, path.join("sub", "a.txt"), "")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "sub", "b.txt"), "", "utf-8")) yield* writeFixtureFile(test.directory, path.join("sub", "b.txt"), "")
const nodes = yield* list("sub") const nodes = yield* list("sub")
expect(nodes.length).toBe(2) expect(nodes.length).toBe(2)
@@ -609,7 +597,7 @@ describe("file/index Filesystem patterns", () => {
it.instance("works without git", () => it.instance("works without git", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "file.txt"), "hi", "utf-8")) yield* writeFixtureFile(test.directory, "file.txt", "hi")
const nodes = yield* list() const nodes = yield* list()
expect(nodes.length).toBeGreaterThanOrEqual(1) expect(nodes.length).toBeGreaterThanOrEqual(1)
@@ -755,7 +743,7 @@ describe("file/index Filesystem patterns", () => {
yield* init() yield* init()
expect(yield* search({ query: "fresh", type: "file" })).toEqual([]) expect(yield* search({ query: "fresh", type: "file" })).toEqual([])
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "fresh.ts"), "fresh", "utf-8")) yield* writeFixtureFile(test.directory, "fresh.ts", "fresh")
expect(yield* search({ query: "fresh", type: "file" })).toContain("fresh.ts") expect(yield* search({ query: "fresh", type: "file" })).toContain("fresh.ts")
}), }),
@@ -770,10 +758,10 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
const filepath = path.join(test.directory, "file.txt") const filepath = path.join(test.directory, "file.txt")
yield* Effect.promise(() => fs.writeFile(filepath, "original content\n", "utf-8")) yield* AppFileSystem.use.writeWithDirs(filepath, "original content\n")
yield* gitAddAll(test.directory) yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file") yield* gitCommit(test.directory, "add file")
yield* Effect.promise(() => fs.writeFile(filepath, "modified content\n", "utf-8")) yield* AppFileSystem.use.writeWithDirs(filepath, "modified content\n")
const result = yield* read("file.txt") const result = yield* read("file.txt")
expect(result.type).toBe("text") expect(result.type).toBe("text")
@@ -793,10 +781,10 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
const filepath = path.join(test.directory, "staged.txt") const filepath = path.join(test.directory, "staged.txt")
yield* Effect.promise(() => fs.writeFile(filepath, "before\n", "utf-8")) yield* AppFileSystem.use.writeWithDirs(filepath, "before\n")
yield* gitAddAll(test.directory) yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file") yield* gitCommit(test.directory, "add file")
yield* Effect.promise(() => fs.writeFile(filepath, "after\n", "utf-8")) yield* AppFileSystem.use.writeWithDirs(filepath, "after\n")
yield* gitAddAll(test.directory) yield* gitAddAll(test.directory)
const result = yield* read("staged.txt") const result = yield* read("staged.txt")
@@ -812,7 +800,7 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
const filepath = path.join(test.directory, "clean.txt") const filepath = path.join(test.directory, "clean.txt")
yield* Effect.promise(() => fs.writeFile(filepath, "unchanged\n", "utf-8")) yield* AppFileSystem.use.writeWithDirs(filepath, "unchanged\n")
yield* gitAddAll(test.directory) yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file") yield* gitCommit(test.directory, "add file")
@@ -832,14 +820,14 @@ describe("file/index Filesystem patterns", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const one = yield* TestInstance const one = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(one.directory, "a.ts"), "one", "utf-8")) yield* writeFixtureFile(one.directory, "a.ts", "one")
yield* init() yield* init()
expect(yield* search({ query: "a.ts", type: "file" })).toContain("a.ts") expect(yield* search({ query: "a.ts", type: "file" })).toContain("a.ts")
expect(yield* search({ query: "b.ts", type: "file" })).not.toContain("b.ts") expect(yield* search({ query: "b.ts", type: "file" })).not.toContain("b.ts")
yield* Effect.gen(function* () { yield* Effect.gen(function* () {
const two = yield* TestInstance const two = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(two.directory, "b.ts"), "two", "utf-8")) yield* writeFixtureFile(two.directory, "b.ts", "two")
yield* init() yield* init()
expect(yield* search({ query: "b.ts", type: "file" })).toContain("b.ts") expect(yield* search({ query: "b.ts", type: "file" })).toContain("b.ts")
expect(yield* search({ query: "a.ts", type: "file" })).not.toContain("a.ts") expect(yield* search({ query: "a.ts", type: "file" })).not.toContain("a.ts")
@@ -853,14 +841,14 @@ describe("file/index Filesystem patterns", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "before.ts"), "before", "utf-8")) yield* writeFixtureFile(test.directory, "before.ts", "before")
yield* init() yield* init()
expect(yield* search({ query: "before", type: "file" })).toContain("before.ts") expect(yield* search({ query: "before", type: "file" })).toContain("before.ts")
yield* Effect.promise(() => disposeAllInstances()) yield* Effect.promise(() => disposeAllInstances())
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "after.ts"), "after", "utf-8")) yield* writeFixtureFile(test.directory, "after.ts", "after")
yield* Effect.promise(() => fs.rm(path.join(test.directory, "before.ts"))) yield* removeFixtureFile(test.directory, "before.ts")
yield* init() yield* init()
expect(yield* search({ query: "after", type: "file" })).toContain("after.ts") expect(yield* search({ query: "after", type: "file" })).toContain("after.ts")
+236 -320
View File
@@ -1,11 +1,16 @@
import { beforeEach, describe, expect, test } from "bun:test" import { beforeEach, describe, expect } from "bun:test"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { tmpdir, withTestInstance } from "../fixture/fixture" import { Effect } from "effect"
import { pollWithTimeout, testEffect } from "../lib/effect"
import { requireInstance, TestInstance } from "../fixture/fixture"
import { LSPClient } from "@/lsp/client" import { LSPClient } from "@/lsp/client"
import * as LSPServer from "@/lsp/server" import * as LSPServer from "@/lsp/server"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
const it = testEffect(AppFileSystem.defaultLayer)
function spawnFakeServer() { function spawnFakeServer() {
const { spawn } = require("child_process") const { spawn } = require("child_process")
const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js")
@@ -16,202 +21,164 @@ function spawnFakeServer() {
} }
} }
const createClient = (handle: LSPServer.Handle, initialization?: LSPServer.Handle["initialization"]) =>
Effect.gen(function* () {
const test = yield* TestInstance
const instance = yield* requireInstance
return yield* Effect.promise(() =>
LSPClient.create({
serverID: "fake",
server: initialization ? { ...handle, initialization } : handle,
root: test.directory,
directory: test.directory,
instance,
}),
)
})
const createScopedClient = (handle: LSPServer.Handle, initialization?: LSPServer.Handle["initialization"]) =>
Effect.gen(function* () {
const client = yield* createClient(handle, initialization)
yield* Effect.addFinalizer(() => Effect.promise(() => client.shutdown()).pipe(Effect.ignore))
return client
})
const writeFile = (file: string, content: string) => AppFileSystem.use.writeWithDirs(file, content)
describe("LSPClient interop", () => { describe("LSPClient interop", () => {
beforeEach(async () => { beforeEach(async () => {
await Log.init({ print: true }) await Log.init({ print: true })
}) })
test("handles workspace/workspaceFolders request", async () => { it.instance("handles workspace/workspaceFolders request", () =>
const handle = spawnFakeServer() as any Effect.gen(function* () {
const client = yield* createScopedClient(spawnFakeServer())
const client = await withTestInstance({ yield* Effect.promise(() =>
directory: process.cwd(), client.connection.sendNotification("test/trigger", {
fn: (ctx) => method: "workspace/workspaceFolders",
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}), }),
}) )
await client.connection.sendNotification("test/trigger", { yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
method: "workspace/workspaceFolders", expect(client.connection).toBeDefined()
}) }),
)
await new Promise((resolve) => setTimeout(resolve, 100)) it.instance("handles client/registerCapability request", () =>
expect(client.connection).toBeDefined() Effect.gen(function* () {
await client.shutdown() const client = yield* createScopedClient(spawnFakeServer())
})
test("handles client/registerCapability request", async () => { yield* Effect.promise(() =>
const handle = spawnFakeServer() as any client.connection.sendNotification("test/trigger", {
method: "client/registerCapability",
const client = await withTestInstance({
directory: process.cwd(),
fn: (ctx) =>
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}), }),
}) )
await client.connection.sendNotification("test/trigger", { yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
method: "client/registerCapability", expect(client.connection).toBeDefined()
}) }),
)
await new Promise((resolve) => setTimeout(resolve, 100)) it.instance("handles client/unregisterCapability request", () =>
expect(client.connection).toBeDefined() Effect.gen(function* () {
await client.shutdown() const client = yield* createScopedClient(spawnFakeServer())
})
test("handles client/unregisterCapability request", async () => { yield* Effect.promise(() =>
const handle = spawnFakeServer() as any client.connection.sendNotification("test/trigger", {
method: "client/unregisterCapability",
const client = await withTestInstance({
directory: process.cwd(),
fn: (ctx) =>
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}), }),
}) )
await client.connection.sendNotification("test/trigger", { yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
method: "client/unregisterCapability", expect(client.connection).toBeDefined()
}) }),
)
await new Promise((resolve) => setTimeout(resolve, 100)) it.instance("initialize does not overclaim unsupported diagnostics capabilities", () =>
expect(client.connection).toBeDefined() Effect.gen(function* () {
await client.shutdown() const client = yield* createScopedClient(spawnFakeServer())
})
test("initialize does not overclaim unsupported diagnostics capabilities", async () => { const params = yield* Effect.promise(() =>
const handle = spawnFakeServer() as any client.connection.sendRequest<{
capabilities: {
workspace: { diagnostics: { refreshSupport: boolean } }
textDocument: { publishDiagnostics: { versionSupport: boolean } }
}
}>("test/get-initialize-params", {}),
)
expect(params.capabilities.workspace.diagnostics.refreshSupport).toBe(false)
expect(params.capabilities.textDocument.publishDiagnostics.versionSupport).toBe(false)
}),
)
const client = await withTestInstance({ it.instance("workspace/configuration returns one result per requested item", () =>
directory: process.cwd(), Effect.gen(function* () {
fn: (ctx) => const initialization = {
LSPClient.create({ alpha: {
serverID: "fake", beta: 1,
server: handle as unknown as LSPServer.Handle, },
root: process.cwd(), gamma: true,
directory: process.cwd(), }
instance: ctx,
const client = yield* createScopedClient(spawnFakeServer(), initialization)
const response = yield* Effect.promise(() =>
client.connection.sendRequest<unknown[]>("test/request-configuration", {
items: [{ section: "alpha" }, { section: "alpha.beta" }, { section: "missing" }, {}],
}), }),
}) )
const params = await client.connection.sendRequest<any>("test/get-initialize-params", {}) expect(response).toEqual([{ beta: 1 }, 1, null, initialization])
expect(params.capabilities.workspace.diagnostics.refreshSupport).toBe(false) }),
expect(params.capabilities.textDocument.publishDiagnostics.versionSupport).toBe(false) )
await client.shutdown() it.instance("sends ranged didChange for incremental sync servers", () =>
}) Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "client.ts")
yield* writeFile(file, "first\n")
test("workspace/configuration returns one result per requested item", async () => { const client = yield* createScopedClient(spawnFakeServer())
const handle = spawnFakeServer() as any
const initialization = {
alpha: {
beta: 1,
},
gamma: true,
}
const client = await withTestInstance({ yield* Effect.promise(() => client.notify.open({ path: file }))
directory: process.cwd(), yield* writeFile(file, "second\nthird\n")
fn: (ctx) => yield* Effect.promise(() => client.notify.open({ path: file }))
LSPClient.create({
serverID: "fake",
server: {
...(handle as unknown as LSPServer.Handle),
initialization,
},
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}),
})
const response = await client.connection.sendRequest<any[]>("test/request-configuration", { const change = yield* Effect.promise(() =>
items: [{ section: "alpha" }, { section: "alpha.beta" }, { section: "missing" }, {}], client.connection.sendRequest<{
})
expect(response).toEqual([{ beta: 1 }, 1, null, initialization])
await client.shutdown()
})
test("sends ranged didChange for incremental sync servers", async () => {
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.ts")
await Bun.write(file, "first\n")
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.notify.open({ path: file })
await Bun.write(file, "second\nthird\n")
await client.notify.open({ path: file })
const change = await client.connection.sendRequest<{
textDocument: { version: number } textDocument: { version: number }
contentChanges: { contentChanges: {
range?: { start: { line: number; character: number }; end: { line: number; character: number } } range?: { start: { line: number; character: number }; end: { line: number; character: number } }
text: string text: string
}[] }[]
}>("test/get-last-change", {}) }>("test/get-last-change", {}),
expect(change.textDocument.version).toBe(1) )
expect(change.contentChanges).toEqual([ expect(change.textDocument.version).toBe(1)
{ expect(change.contentChanges).toEqual([
range: { {
start: { line: 0, character: 0 }, range: {
end: { line: 1, character: 0 }, start: { line: 0, character: 0 },
}, end: { line: 1, character: 0 },
text: "second\nthird\n",
}, },
]) text: "second\nthird\n",
},
])
}),
)
await client.shutdown() it.instance("document mode falls back to push diagnostics", () =>
}, Effect.gen(function* () {
}) const test = yield* TestInstance
}) const file = path.join(test.directory, "client.ts")
yield* writeFile(file, "const x = 1\n")
test("document mode falls back to push diagnostics", async () => { const client = yield* createScopedClient(spawnFakeServer())
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.ts")
await Bun.write(file, "const x = 1\n")
await withTestInstance({ const version = yield* Effect.promise(() => client.notify.open({ path: file }))
directory: tmp.path, const wait = client.waitForDiagnostics({ path: file, version, mode: "document" })
fn: async (ctx) => { yield* Effect.promise(() =>
const client = await LSPClient.create({ client.connection.sendNotification("test/publish-diagnostics", {
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
const version = await client.notify.open({ path: file })
const wait = client.waitForDiagnostics({ path: file, version, mode: "document" })
await client.connection.sendNotification("test/publish-diagnostics", {
uri: pathToFileURL(file).href, uri: pathToFileURL(file).href,
version, version,
diagnostics: [ diagnostics: [
@@ -224,40 +191,30 @@ describe("LSPClient interop", () => {
severity: 1, severity: 1,
}, },
], ],
}) }),
await wait )
yield* Effect.promise(() => wait)
const diagnostics = client.diagnostics.get(file) ?? [] const diagnostics = client.diagnostics.get(file) ?? []
expect(diagnostics).toHaveLength(1) expect(diagnostics).toHaveLength(1)
expect(diagnostics[0]?.message).toBe("push diagnostic") expect(diagnostics[0]?.message).toBe("push diagnostic")
const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {}) const count = yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
expect(count).toBe(0) expect(count).toBe(0)
}),
)
await client.shutdown() it.instance("document mode accepts matching push diagnostics published before waiting", () =>
}, Effect.gen(function* () {
}) const test = yield* TestInstance
}) const file = path.join(test.directory, "client.ts")
yield* writeFile(file, "const x = 1\n")
test("document mode accepts matching push diagnostics published before waiting", async () => { const client = yield* createScopedClient(spawnFakeServer())
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.ts")
await Bun.write(file, "const x = 1\n")
await withTestInstance({ const version = yield* Effect.promise(() => client.notify.open({ path: file }))
directory: tmp.path, yield* Effect.promise(() =>
fn: async (ctx) => { client.connection.sendNotification("test/publish-diagnostics", {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
const version = await client.notify.open({ path: file })
await client.connection.sendNotification("test/publish-diagnostics", {
uri: pathToFileURL(file).href, uri: pathToFileURL(file).href,
version, version,
diagnostics: [ diagnostics: [
@@ -270,41 +227,31 @@ describe("LSPClient interop", () => {
severity: 1, severity: 1,
}, },
], ],
}) }),
)
for (let i = 0; i < 20 && (client.diagnostics.get(file)?.length ?? 0) === 0; i++) { const diagnostic = yield* pollWithTimeout(
await new Promise((resolve) => setTimeout(resolve, 25)) Effect.sync(() => client.diagnostics.get(file)?.[0]),
} "push diagnostic was not published",
)
expect(diagnostic.message).toBe("push diagnostic")
expect(client.diagnostics.get(file)?.[0]?.message).toBe("push diagnostic") const started = Date.now()
yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "document" }))
expect(Date.now() - started).toBeLessThan(1_000)
}),
)
const started = Date.now() it.instance("document mode waits for pull diagnostics", () =>
await client.waitForDiagnostics({ path: file, version, mode: "document" }) Effect.gen(function* () {
expect(Date.now() - started).toBeLessThan(1_000) const test = yield* TestInstance
const file = path.join(test.directory, "client.cs")
yield* writeFile(file, "class C {}\n")
await client.shutdown() const client = yield* createScopedClient(spawnFakeServer())
},
})
})
test("document mode waits for pull diagnostics", async () => { yield* Effect.promise(() =>
const handle = spawnFakeServer() as any client.connection.sendRequest("test/configure-pull-diagnostics", {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.cs")
await Bun.write(file, "class C {}\n")
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
registerOn: "didOpen", registerOn: "didOpen",
registrations: [{ identifier: "DocumentCompilerSemantic" }], registrations: [{ identifier: "DocumentCompilerSemantic" }],
documentDiagnosticsByIdentifier: { documentDiagnosticsByIdentifier: {
@@ -319,41 +266,31 @@ describe("LSPClient interop", () => {
}, },
], ],
}, },
}) }),
)
const version = await client.notify.open({ path: file }) const version = yield* Effect.promise(() => client.notify.open({ path: file }))
await client.waitForDiagnostics({ path: file, version, mode: "document" }) yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "document" }))
const diagnostics = client.diagnostics.get(file) ?? [] const diagnostics = client.diagnostics.get(file) ?? []
expect(diagnostics).toHaveLength(1) expect(diagnostics).toHaveLength(1)
expect(diagnostics[0]?.message).toBe("pull diagnostic") expect(diagnostics[0]?.message).toBe("pull diagnostic")
const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {}) const count = yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
expect(count).toBeGreaterThan(0) expect(count).toBeGreaterThan(0)
}),
)
await client.shutdown() it.instance("document mode does not wait for the slowest pull identifier after current-file diagnostics arrive", () =>
}, Effect.gen(function* () {
}) const test = yield* TestInstance
}) const file = path.join(test.directory, "client.cs")
yield* writeFile(file, "class C {}\n")
test("document mode does not wait for the slowest pull identifier after current-file diagnostics arrive", async () => { const client = yield* createScopedClient(spawnFakeServer())
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.cs")
await Bun.write(file, "class C {}\n")
await withTestInstance({ yield* Effect.promise(() =>
directory: tmp.path, client.connection.sendRequest("test/configure-pull-diagnostics", {
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
registrations: [{ identifier: "fast" }, { identifier: "slow" }], registrations: [{ identifier: "fast" }, { identifier: "slow" }],
documentDiagnosticsByIdentifier: { documentDiagnosticsByIdentifier: {
fast: [ fast: [
@@ -371,43 +308,34 @@ describe("LSPClient interop", () => {
documentDelayMsByIdentifier: { documentDelayMsByIdentifier: {
slow: 2_500, slow: 2_500,
}, },
}) }),
)
const version = await client.notify.open({ path: file }) const version = yield* Effect.promise(() => client.notify.open({ path: file }))
await client.connection.sendRequest("test/register-configured-pull-diagnostics", {}) yield* Effect.promise(() => client.connection.sendRequest("test/register-configured-pull-diagnostics", {}))
await new Promise((resolve) => setTimeout(resolve, 100)) const started = Date.now()
const started = Date.now() yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "document" }))
await client.waitForDiagnostics({ path: file, version, mode: "document" })
expect(Date.now() - started).toBeLessThan(1_000) expect(Date.now() - started).toBeLessThan(1_000)
expect(client.diagnostics.get(file)?.[0]?.message).toBe("fast diagnostic") expect(client.diagnostics.get(file)?.[0]?.message).toBe("fast diagnostic")
expect(await client.connection.sendRequest("test/get-diagnostic-request-count", {})).toBeGreaterThan(1) expect(
yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {})),
).toBeGreaterThan(1)
}),
)
await client.shutdown() it.instance("full mode includes workspace pull diagnostics", () =>
}, Effect.gen(function* () {
}) const test = yield* TestInstance
}) const file = path.join(test.directory, "client.cs")
const related = path.join(test.directory, "other.cs")
yield* writeFile(file, "class C {}\n")
yield* writeFile(related, "class D {}\n")
test("full mode includes workspace pull diagnostics", async () => { const client = yield* createScopedClient(spawnFakeServer())
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.cs")
const related = path.join(tmp.path, "other.cs")
await Bun.write(file, "class C {}\n")
await Bun.write(related, "class D {}\n")
await withTestInstance({ yield* Effect.promise(() =>
directory: tmp.path, client.connection.sendRequest("test/configure-pull-diagnostics", {
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
registerOn: "didOpen", registerOn: "didOpen",
registrations: [ registrations: [
{ identifier: "DocumentCompilerSemantic" }, { identifier: "DocumentCompilerSemantic" },
@@ -442,52 +370,40 @@ describe("LSPClient interop", () => {
}, },
], ],
}, },
}) }),
)
const version = await client.notify.open({ path: file }) const version = yield* Effect.promise(() => client.notify.open({ path: file }))
await client.waitForDiagnostics({ path: file, version, mode: "full" }) yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "full" }))
expect(client.diagnostics.get(file)?.[0]?.message).toBe("current file") expect(client.diagnostics.get(file)?.[0]?.message).toBe("current file")
expect(client.diagnostics.get(related)?.[0]?.message).toBe("workspace file") expect(client.diagnostics.get(related)?.[0]?.message).toBe("workspace file")
}),
)
await client.shutdown() it.instance("full mode treats an empty workspace pull response as handled", () =>
}, Effect.gen(function* () {
}) const test = yield* TestInstance
}) const file = path.join(test.directory, "client.cs")
yield* writeFile(file, "class C {}\n")
test("full mode treats an empty workspace pull response as handled", async () => { const client = yield* createScopedClient(spawnFakeServer())
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.cs")
await Bun.write(file, "class C {}\n")
await withTestInstance({ yield* Effect.promise(() =>
directory: tmp.path, client.connection.sendRequest("test/configure-pull-diagnostics", {
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
registerOn: "didOpen", registerOn: "didOpen",
registrations: [{ identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true }], registrations: [{ identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true }],
workspaceDiagnosticsByIdentifier: { workspaceDiagnosticsByIdentifier: {
WorkspaceDocumentsAndProject: [], WorkspaceDocumentsAndProject: [],
}, },
}) }),
)
const version = await client.notify.open({ path: file }) const version = yield* Effect.promise(() => client.notify.open({ path: file }))
const started = Date.now() const started = Date.now()
await client.waitForDiagnostics({ path: file, version, mode: "full" }) yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "full" }))
expect(Date.now() - started).toBeLessThan(1_000) expect(Date.now() - started).toBeLessThan(1_000)
}),
await client.shutdown() )
},
})
})
}) })
+106 -120
View File
@@ -30,16 +30,9 @@ void Log.init({ print: false })
const encoder = new TextEncoder() const encoder = new TextEncoder()
const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer) const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer)
const it = testEffect(layer) const it = testEffect(layer)
function run<A, E>(fn: (svc: Project.Interface) => Effect.Effect<A, E>) {
return Effect.gen(function* () {
const svc = yield* Project.Service
return yield* fn(svc)
})
}
function remoteProjectID(remote: string) { function remoteProjectID(remote: string) {
return ProjectID.make(Hash.fast(`git-remote:${remote}`)) return ProjectID.make(Hash.fast(`git-remote:${remote}`))
} }
@@ -103,10 +96,18 @@ function projectLayerWithRuntimeFlags(flags: Parameters<typeof RuntimeFlags.laye
} }
const failureIt = (failArg: string) => const failureIt = (failArg: string) =>
testEffect(Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer)) testEffect(
Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer),
)
const iconDiscoveryIt = testEffect( const iconDiscoveryIt = testEffect(
Layer.provideMerge(projectLayerWithRuntimeFlags({ experimentalIconDiscovery: true }), CrossSpawnSpawner.defaultLayer), Layer.mergeAll(
Layer.provideMerge(
projectLayerWithRuntimeFlags({ experimentalIconDiscovery: true }),
CrossSpawnSpawner.defaultLayer,
),
AppFileSystem.defaultLayer,
),
) )
function waitForProjectIcon(id: ProjectID, attempts = 50): Effect.Effect<Project.Info> { function waitForProjectIcon(id: ProjectID, attempts = 50): Effect.Effect<Project.Info> {
@@ -125,7 +126,7 @@ describe("Project.fromDirectory", () => {
const tmp = yield* tmpdirScoped() const tmp = yield* tmpdirScoped()
yield* Effect.promise(() => $`git init`.cwd(tmp).quiet()) yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const { project } = yield* Project.use.fromDirectory(tmp)
expect(project).toBeDefined() expect(project).toBeDefined()
expect(project.id).toBe(ProjectID.global) expect(project.id).toBe(ProjectID.global)
@@ -133,7 +134,7 @@ describe("Project.fromDirectory", () => {
expect(project.worktree).toBe(tmp) expect(project.worktree).toBe(tmp)
const opencodeFile = path.join(tmp, ".git", "opencode") const opencodeFile = path.join(tmp, ".git", "opencode")
expect(yield* Effect.promise(() => Bun.file(opencodeFile).exists())).toBe(false) expect(yield* AppFileSystem.use.existsSafe(opencodeFile)).toBe(false)
}), }),
) )
@@ -141,7 +142,7 @@ describe("Project.fromDirectory", () => {
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const { project } = yield* Project.use.fromDirectory(tmp)
expect(project).toBeDefined() expect(project).toBeDefined()
expect(project.id).not.toBe(ProjectID.global) expect(project.id).not.toBe(ProjectID.global)
@@ -153,7 +154,7 @@ describe("Project.fromDirectory", () => {
it.live("returns global for non-git directory", () => it.live("returns global for non-git directory", () =>
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped() const tmp = yield* tmpdirScoped()
const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const { project } = yield* Project.use.fromDirectory(tmp)
expect(project.id).toBe(ProjectID.global) expect(project.id).toBe(ProjectID.global)
}), }),
) )
@@ -161,8 +162,8 @@ describe("Project.fromDirectory", () => {
it.live("derives stable project ID from root commit", () => it.live("derives stable project ID from root commit", () =>
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const { project: a } = yield* run((svc) => svc.fromDirectory(tmp)) const { project: a } = yield* Project.use.fromDirectory(tmp)
const { project: b } = yield* run((svc) => svc.fromDirectory(tmp)) const { project: b } = yield* Project.use.fromDirectory(tmp)
expect(b.id).toBe(a.id) expect(b.id).toBe(a.id)
}), }),
) )
@@ -172,7 +173,7 @@ describe("Project.fromDirectory", () => {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
yield* Effect.promise(() => $`git remote add origin git@github.com:Test-Org/Test-Repo.git`.cwd(tmp).quiet()) yield* Effect.promise(() => $`git remote add origin git@github.com:Test-Org/Test-Repo.git`.cwd(tmp).quiet())
const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const { project } = yield* Project.use.fromDirectory(tmp)
expect(project.id).toBe(remoteProjectID("github.com/Test-Org/Test-Repo")) expect(project.id).toBe(remoteProjectID("github.com/Test-Org/Test-Repo"))
}), }),
@@ -185,8 +186,8 @@ describe("Project.fromDirectory", () => {
yield* Effect.promise(() => $`git remote add origin git@github.com:owner/repo.git`.cwd(ssh).quiet()) yield* Effect.promise(() => $`git remote add origin git@github.com:owner/repo.git`.cwd(ssh).quiet())
yield* Effect.promise(() => $`git remote add origin https://github.com/owner/repo.git`.cwd(https).quiet()) yield* Effect.promise(() => $`git remote add origin https://github.com/owner/repo.git`.cwd(https).quiet())
const { project: a } = yield* run((svc) => svc.fromDirectory(ssh)) const { project: a } = yield* Project.use.fromDirectory(ssh)
const { project: b } = yield* run((svc) => svc.fromDirectory(https)) const { project: b } = yield* Project.use.fromDirectory(https)
expect(a.id).toBe(remoteProjectID("github.com/owner/repo")) expect(a.id).toBe(remoteProjectID("github.com/owner/repo"))
expect(b.id).toBe(a.id) expect(b.id).toBe(a.id)
@@ -196,8 +197,7 @@ describe("Project.fromDirectory", () => {
it.live("migrates cached root project data when origin becomes available", () => it.live("migrates cached root project data when origin becomes available", () =>
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const projects = yield* Project.Service const { project: rootProject } = yield* Project.use.fromDirectory(tmp)
const { project: rootProject } = yield* projects.fromDirectory(tmp)
const remoteID = remoteProjectID("github.com/acme/app") const remoteID = remoteProjectID("github.com/acme/app")
const sessionID = crypto.randomUUID() as SessionID const sessionID = crypto.randomUUID() as SessionID
const workspaceID = WorkspaceID.ascending() const workspaceID = WorkspaceID.ascending()
@@ -236,7 +236,7 @@ describe("Project.fromDirectory", () => {
}) })
yield* Effect.promise(() => $`git remote add origin git@github.com:acme/app.git`.cwd(tmp).quiet()) yield* Effect.promise(() => $`git remote add origin git@github.com:acme/app.git`.cwd(tmp).quiet())
const { project } = yield* projects.fromDirectory(tmp) const { project } = yield* Project.use.fromDirectory(tmp)
expect(project.id).toBe(remoteID) expect(project.id).toBe(remoteID)
expect( expect(
@@ -263,7 +263,7 @@ describe("Project.fromDirectory git failure paths", () => {
yield* Effect.promise(() => $`git init`.cwd(tmp).quiet()) yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
// rev-list fails because HEAD doesn't exist yet: this is the natural scenario. // rev-list fails because HEAD doesn't exist yet: this is the natural scenario.
const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const { project } = yield* Project.use.fromDirectory(tmp)
expect(project.vcs).toBe("git") expect(project.vcs).toBe("git")
expect(project.id).toBe(ProjectID.global) expect(project.id).toBe(ProjectID.global)
expect(project.worktree).toBe(tmp) expect(project.worktree).toBe(tmp)
@@ -274,7 +274,7 @@ describe("Project.fromDirectory git failure paths", () => {
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp)) const { project, sandbox } = yield* Project.use.fromDirectory(tmp)
expect(project.worktree).toBe(tmp) expect(project.worktree).toBe(tmp)
expect(sandbox).toBe(tmp) expect(sandbox).toBe(tmp)
}), }),
@@ -284,7 +284,7 @@ describe("Project.fromDirectory git failure paths", () => {
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp)) const { project, sandbox } = yield* Project.use.fromDirectory(tmp)
expect(project.worktree).toBe(tmp) expect(project.worktree).toBe(tmp)
expect(sandbox).toBe(tmp) expect(sandbox).toBe(tmp)
}), }),
@@ -296,7 +296,7 @@ describe("Project.fromDirectory with worktrees", () => {
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp)) const { project, sandbox } = yield* Project.use.fromDirectory(tmp)
expect(project.worktree).toBe(tmp) expect(project.worktree).toBe(tmp)
expect(sandbox).toBe(tmp) expect(sandbox).toBe(tmp)
@@ -319,7 +319,7 @@ describe("Project.fromDirectory with worktrees", () => {
) )
yield* Effect.promise(() => $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp).quiet()) yield* Effect.promise(() => $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp).quiet())
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(worktreePath)) const { project, sandbox } = yield* Project.use.fromDirectory(worktreePath)
expect(project.worktree).toBe(worktreePath) expect(project.worktree).toBe(worktreePath)
expect(sandbox).toBe(worktreePath) expect(sandbox).toBe(worktreePath)
@@ -332,7 +332,7 @@ describe("Project.fromDirectory with worktrees", () => {
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const { project: main } = yield* run((svc) => svc.fromDirectory(tmp)) const { project: main } = yield* Project.use.fromDirectory(tmp)
const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-wt-shared") const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-wt-shared")
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>
@@ -345,12 +345,12 @@ describe("Project.fromDirectory with worktrees", () => {
) )
yield* Effect.promise(() => $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp).quiet()) yield* Effect.promise(() => $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp).quiet())
const { project: wt } = yield* run((svc) => svc.fromDirectory(worktreePath)) const { project: wt } = yield* Project.use.fromDirectory(worktreePath)
expect(wt.id).toBe(main.id) expect(wt.id).toBe(main.id)
const cache = path.join(tmp, ".git", "opencode") const cache = path.join(tmp, ".git", "opencode")
const exists = yield* Effect.promise(() => Bun.file(cache).exists()) const exists = yield* AppFileSystem.use.existsSafe(cache)
expect(exists).toBe(true) expect(exists).toBe(true)
}), }),
) )
@@ -368,8 +368,8 @@ describe("Project.fromDirectory with worktrees", () => {
yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet()) yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet())
yield* Effect.promise(() => $`git clone ${bare} ${clone}`.quiet()) yield* Effect.promise(() => $`git clone ${bare} ${clone}`.quiet())
const { project: a } = yield* run((svc) => svc.fromDirectory(tmp)) const { project: a } = yield* Project.use.fromDirectory(tmp)
const { project: b } = yield* run((svc) => svc.fromDirectory(clone)) const { project: b } = yield* Project.use.fromDirectory(clone)
expect(b.id).toBe(a.id) expect(b.id).toBe(a.id)
}), }),
@@ -400,8 +400,8 @@ describe("Project.fromDirectory with worktrees", () => {
yield* Effect.promise(() => $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp).quiet()) yield* Effect.promise(() => $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp).quiet())
yield* Effect.promise(() => $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp).quiet()) yield* Effect.promise(() => $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp).quiet())
yield* run((svc) => svc.fromDirectory(worktree1)) yield* Project.use.fromDirectory(worktree1)
const { project } = yield* run((svc) => svc.fromDirectory(worktree2)) const { project } = yield* Project.use.fromDirectory(worktree2)
expect(project.worktree).toBe(worktree1) expect(project.worktree).toBe(worktree1)
expect(project.sandboxes).toContain(worktree2) expect(project.sandboxes).toContain(worktree2)
@@ -415,9 +415,9 @@ describe("Project.discover", () => {
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData)) yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.png"), pngData)
const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const { project } = yield* Project.use.fromDirectory(tmp)
const updated = yield* waitForProjectIcon(project.id) const updated = yield* waitForProjectIcon(project.id)
expect(updated.icon?.url).toStartWith("data:") expect(updated.icon?.url).toStartWith("data:")
@@ -428,12 +428,12 @@ describe("Project.discover", () => {
it.live("should discover favicon.png in root", () => it.live("should discover favicon.png in root", () =>
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const { project } = yield* Project.use.fromDirectory(tmp)
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData)) yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.png"), pngData)
yield* run((svc) => svc.discover(project)) yield* Project.use.discover(project)
const updated = Project.get(project.id) const updated = Project.get(project.id)
expect(updated).toBeDefined() expect(updated).toBeDefined()
@@ -447,11 +447,11 @@ describe("Project.discover", () => {
it.live("should not discover non-image files", () => it.live("should not discover non-image files", () =>
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const { project } = yield* Project.use.fromDirectory(tmp)
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.txt"), "not an image")) yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.txt"), "not an image")
yield* run((svc) => svc.discover(project)) yield* Project.use.discover(project)
const updated = Project.get(project.id) const updated = Project.get(project.id)
expect(updated).toBeDefined() expect(updated).toBeDefined()
@@ -462,22 +462,20 @@ describe("Project.discover", () => {
it.live("should not discover favicon when override is set", () => it.live("should not discover favicon when override is set", () =>
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const { project } = yield* Project.use.fromDirectory(tmp)
yield* run((svc) => yield* Project.use.update({
svc.update({ projectID: project.id,
projectID: project.id, icon: { override: "data:image/png;base64,override" },
icon: { override: "data:image/png;base64,override" }, })
}),
)
const updatedProject = yield* run((svc) => svc.get(project.id)) const updatedProject = yield* Project.use.get(project.id)
if (!updatedProject) throw new Error("Project not found") if (!updatedProject) throw new Error("Project not found")
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData)) yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.png"), pngData)
yield* run((svc) => svc.discover(updatedProject)) yield* Project.use.discover(updatedProject)
const updated = Project.get(project.id) const updated = Project.get(project.id)
expect(updated).toBeDefined() expect(updated).toBeDefined()
@@ -491,14 +489,12 @@ describe("Project.update", () => {
it.live("should update name", () => it.live("should update name", () =>
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const { project } = yield* Project.use.fromDirectory(tmp)
const updated = yield* run((svc) => const updated = yield* Project.use.update({
svc.update({ projectID: project.id,
projectID: project.id, name: "New Project Name",
name: "New Project Name", })
}),
)
expect(updated.name).toBe("New Project Name") expect(updated.name).toBe("New Project Name")
@@ -510,14 +506,12 @@ describe("Project.update", () => {
it.live("should update icon url", () => it.live("should update icon url", () =>
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const { project } = yield* Project.use.fromDirectory(tmp)
const updated = yield* run((svc) => const updated = yield* Project.use.update({
svc.update({ projectID: project.id,
projectID: project.id, icon: { url: "https://example.com/icon.png" },
icon: { url: "https://example.com/icon.png" }, })
}),
)
expect(updated.icon?.url).toBe("https://example.com/icon.png") expect(updated.icon?.url).toBe("https://example.com/icon.png")
@@ -529,14 +523,12 @@ describe("Project.update", () => {
it.live("should update icon color", () => it.live("should update icon color", () =>
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const { project } = yield* Project.use.fromDirectory(tmp)
const updated = yield* run((svc) => const updated = yield* Project.use.update({
svc.update({ projectID: project.id,
projectID: project.id, icon: { color: "#ff0000" },
icon: { color: "#ff0000" }, })
}),
)
expect(updated.icon?.color).toBe("#ff0000") expect(updated.icon?.color).toBe("#ff0000")
@@ -548,14 +540,12 @@ describe("Project.update", () => {
it.live("should update icon override", () => it.live("should update icon override", () =>
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const { project } = yield* Project.use.fromDirectory(tmp)
const updated = yield* run((svc) => const updated = yield* Project.use.update({
svc.update({ projectID: project.id,
projectID: project.id, icon: { override: "data:image/png;base64,abc123" },
icon: { override: "data:image/png;base64,abc123" }, })
}),
)
expect(updated.icon?.override).toBe("data:image/png;base64,abc123") expect(updated.icon?.override).toBe("data:image/png;base64,abc123")
@@ -567,14 +557,12 @@ describe("Project.update", () => {
it.live("should update commands", () => it.live("should update commands", () =>
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const { project } = yield* Project.use.fromDirectory(tmp)
const updated = yield* run((svc) => const updated = yield* Project.use.update({
svc.update({ projectID: project.id,
projectID: project.id, commands: { start: "npm run dev" },
commands: { start: "npm run dev" }, })
}),
)
expect(updated.commands?.start).toBe("npm run dev") expect(updated.commands?.start).toBe("npm run dev")
@@ -585,12 +573,12 @@ describe("Project.update", () => {
it.live("should fail when project not found", () => it.live("should fail when project not found", () =>
Effect.gen(function* () { Effect.gen(function* () {
const exit = yield* run((svc) => const exit = yield* Project.use
svc.update({ .update({
projectID: ProjectID.make("nonexistent-project-id"), projectID: ProjectID.make("nonexistent-project-id"),
name: "Should Fail", name: "Should Fail",
}), })
).pipe(Effect.exit) .pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true) expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) { if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause) const error = Cause.squash(exit.cause)
@@ -602,7 +590,7 @@ describe("Project.update", () => {
it.live("should emit GlobalBus event on update", () => it.live("should emit GlobalBus event on update", () =>
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const { project } = yield* Project.use.fromDirectory(tmp)
let eventPayload: any = null let eventPayload: any = null
const on = (data: any) => { const on = (data: any) => {
@@ -611,7 +599,7 @@ describe("Project.update", () => {
GlobalBus.on("event", on) GlobalBus.on("event", on)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on))) yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
yield* run((svc) => svc.update({ projectID: project.id, name: "Updated Name" })) yield* Project.use.update({ projectID: project.id, name: "Updated Name" })
expect(eventPayload).not.toBeNull() expect(eventPayload).not.toBeNull()
expect(eventPayload.payload.type).toBe("project.updated") expect(eventPayload.payload.type).toBe("project.updated")
@@ -622,16 +610,14 @@ describe("Project.update", () => {
it.live("should update multiple fields at once", () => it.live("should update multiple fields at once", () =>
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const { project } = yield* Project.use.fromDirectory(tmp)
const updated = yield* run((svc) => const updated = yield* Project.use.update({
svc.update({ projectID: project.id,
projectID: project.id, name: "Multi Update",
name: "Multi Update", icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" },
icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" }, commands: { start: "make start" },
commands: { start: "make start" }, })
}),
)
expect(updated.name).toBe("Multi Update") expect(updated.name).toBe("Multi Update")
expect(updated.icon?.url).toBe("https://example.com/favicon.ico") expect(updated.icon?.url).toBe("https://example.com/favicon.ico")
@@ -646,7 +632,7 @@ describe("Project.list and Project.get", () => {
it.live("list returns all projects", () => it.live("list returns all projects", () =>
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const { project } = yield* Project.use.fromDirectory(tmp)
const all = Project.list() const all = Project.list()
expect(all.length).toBeGreaterThan(0) expect(all.length).toBeGreaterThan(0)
@@ -657,7 +643,7 @@ describe("Project.list and Project.get", () => {
it.live("get returns project by id", () => it.live("get returns project by id", () =>
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const { project } = yield* Project.use.fromDirectory(tmp)
const found = Project.get(project.id) const found = Project.get(project.id)
expect(found).toBeDefined() expect(found).toBeDefined()
@@ -675,7 +661,7 @@ describe("Project.setInitialized", () => {
it.live("sets time_initialized on project", () => it.live("sets time_initialized on project", () =>
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const { project } = yield* Project.use.fromDirectory(tmp)
expect(project.time.initialized).toBeUndefined() expect(project.time.initialized).toBeUndefined()
@@ -691,15 +677,15 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
it.live("addSandbox adds directory and removeSandbox removes it", () => it.live("addSandbox adds directory and removeSandbox removes it", () =>
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const { project } = yield* Project.use.fromDirectory(tmp)
const sandboxDir = path.join(tmp, "sandbox-test") const sandboxDir = path.join(tmp, "sandbox-test")
yield* run((svc) => svc.addSandbox(project.id, sandboxDir)) yield* Project.use.addSandbox(project.id, sandboxDir)
let found = Project.get(project.id) let found = Project.get(project.id)
expect(found?.sandboxes).toContain(sandboxDir) expect(found?.sandboxes).toContain(sandboxDir)
yield* run((svc) => svc.removeSandbox(project.id, sandboxDir)) yield* Project.use.removeSandbox(project.id, sandboxDir)
found = Project.get(project.id) found = Project.get(project.id)
expect(found?.sandboxes).not.toContain(sandboxDir) expect(found?.sandboxes).not.toContain(sandboxDir)
@@ -709,7 +695,7 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
it.live("addSandbox emits GlobalBus event", () => it.live("addSandbox emits GlobalBus event", () =>
Effect.gen(function* () { Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true }) const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const { project } = yield* Project.use.fromDirectory(tmp)
const sandboxDir = path.join(tmp, "sandbox-event") const sandboxDir = path.join(tmp, "sandbox-event")
const events: any[] = [] const events: any[] = []
@@ -717,7 +703,7 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
GlobalBus.on("event", on) GlobalBus.on("event", on)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on))) yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
yield* run((svc) => svc.addSandbox(project.id, sandboxDir)) yield* Project.use.addSandbox(project.id, sandboxDir)
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true) expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
}), }),
@@ -739,7 +725,7 @@ describe("Project.fromDirectory with bare repos", () => {
yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet()) yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet())
yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet()) yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet())
const { project } = yield* run((svc) => svc.fromDirectory(worktreePath)) const { project } = yield* Project.use.fromDirectory(worktreePath)
expect(project.id).not.toBe(ProjectID.global) expect(project.id).not.toBe(ProjectID.global)
expect(project.worktree).toBe(worktreePath) expect(project.worktree).toBe(worktreePath)
@@ -747,8 +733,8 @@ describe("Project.fromDirectory with bare repos", () => {
const correctCache = path.join(barePath, "opencode") const correctCache = path.join(barePath, "opencode")
const wrongCache = path.join(parentDir, ".git", "opencode") const wrongCache = path.join(parentDir, ".git", "opencode")
expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true) expect(yield* AppFileSystem.use.existsSafe(correctCache)).toBe(true)
expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false) expect(yield* AppFileSystem.use.existsSafe(wrongCache)).toBe(false)
}), }),
) )
@@ -773,8 +759,8 @@ describe("Project.fromDirectory with bare repos", () => {
yield* Effect.promise(() => $`git worktree add ${worktreeA} HEAD`.cwd(bareA).quiet()) yield* Effect.promise(() => $`git worktree add ${worktreeA} HEAD`.cwd(bareA).quiet())
yield* Effect.promise(() => $`git worktree add ${worktreeB} HEAD`.cwd(bareB).quiet()) yield* Effect.promise(() => $`git worktree add ${worktreeB} HEAD`.cwd(bareB).quiet())
const { project: projA } = yield* run((svc) => svc.fromDirectory(worktreeA)) const { project: projA } = yield* Project.use.fromDirectory(worktreeA)
const { project: projB } = yield* run((svc) => svc.fromDirectory(worktreeB)) const { project: projB } = yield* Project.use.fromDirectory(worktreeB)
expect(projA.id).not.toBe(projB.id) expect(projA.id).not.toBe(projB.id)
@@ -782,9 +768,9 @@ describe("Project.fromDirectory with bare repos", () => {
const cacheB = path.join(bareB, "opencode") const cacheB = path.join(bareB, "opencode")
const wrongCache = path.join(parentDir, ".git", "opencode") const wrongCache = path.join(parentDir, ".git", "opencode")
expect(yield* Effect.promise(() => Bun.file(cacheA).exists())).toBe(true) expect(yield* AppFileSystem.use.existsSafe(cacheA)).toBe(true)
expect(yield* Effect.promise(() => Bun.file(cacheB).exists())).toBe(true) expect(yield* AppFileSystem.use.existsSafe(cacheB)).toBe(true)
expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false) expect(yield* AppFileSystem.use.existsSafe(wrongCache)).toBe(false)
}), }),
) )
@@ -802,13 +788,13 @@ describe("Project.fromDirectory with bare repos", () => {
yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet()) yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet())
yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet()) yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet())
const { project } = yield* run((svc) => svc.fromDirectory(worktreePath)) const { project } = yield* Project.use.fromDirectory(worktreePath)
expect(project.id).not.toBe(ProjectID.global) expect(project.id).not.toBe(ProjectID.global)
expect(project.worktree).toBe(worktreePath) expect(project.worktree).toBe(worktreePath)
const correctCache = path.join(barePath, "opencode") const correctCache = path.join(barePath, "opencode")
expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true) expect(yield* AppFileSystem.use.existsSafe(correctCache)).toBe(true)
}), }),
) )
}) })
+282 -312
View File
@@ -8,14 +8,13 @@ import { Config } from "../../src/config/config"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { provideInstance, provideTmpdirInstance, tmpdir } from "../fixture/fixture" import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import path from "path" import path from "path"
import fs from "fs/promises"
const node = CrossSpawnSpawner.defaultLayer const node = CrossSpawnSpawner.defaultLayer
const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node)) const it = testEffect(Layer.mergeAll(Skill.defaultLayer, AppFileSystem.defaultLayer, node))
const itWithoutClaudeCodeSkills = testEffect( const itWithoutClaudeCodeSkills = testEffect(
Layer.mergeAll( Layer.mergeAll(
Skill.layer.pipe( Skill.layer.pipe(
@@ -26,6 +25,7 @@ const itWithoutClaudeCodeSkills = testEffect(
Layer.provide(Global.layer), Layer.provide(Global.layer),
Layer.provide(RuntimeFlags.layer({ disableClaudeCodeSkills: true })), Layer.provide(RuntimeFlags.layer({ disableClaudeCodeSkills: true })),
), ),
AppFileSystem.defaultLayer,
node, node,
), ),
) )
@@ -39,15 +39,18 @@ const itWithoutExternalSkills = testEffect(
Layer.provide(Global.layer), Layer.provide(Global.layer),
Layer.provide(RuntimeFlags.layer({ disableExternalSkills: true })), Layer.provide(RuntimeFlags.layer({ disableExternalSkills: true })),
), ),
AppFileSystem.defaultLayer,
node, node,
), ),
) )
async function createGlobalSkill(homeDir: string) { const writeSkill = (dir: string, parts: string[], content: string) =>
const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill") AppFileSystem.use.writeWithDirs(path.join(dir, ...parts, "SKILL.md"), content)
await fs.mkdir(skillDir, { recursive: true })
await Bun.write( const createGlobalSkill = (homeDir: string) =>
path.join(skillDir, "SKILL.md"), writeSkill(
homeDir,
[".claude", "skills", "global-test-skill"],
`--- `---
name: global-test-skill name: global-test-skill
description: A global skill from ~/.claude/skills for testing. description: A global skill from ~/.claude/skills for testing.
@@ -58,7 +61,6 @@ description: A global skill from ~/.claude/skills for testing.
This skill is loaded from the global home directory. This skill is loaded from the global home directory.
`, `,
) )
}
const withHome = <A, E, R>(home: string, self: Effect.Effect<A, E, R>) => const withHome = <A, E, R>(home: string, self: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease( Effect.acquireUseRelease(
@@ -75,14 +77,14 @@ const withHome = <A, E, R>(home: string, self: Effect.Effect<A, E, R>) =>
) )
describe("skill", () => { describe("skill", () => {
it.live("discovers skills from .opencode/skill/ directory", () => it.instance(
provideTmpdirInstance( "discovers skills from .opencode/skill/ directory",
(dir) => Effect.gen(function* () {
Effect.gen(function* () { const test = yield* TestInstance
yield* Effect.promise(() => yield* writeSkill(
Bun.write( test.directory,
path.join(dir, ".opencode", "skill", "test-skill", "SKILL.md"), [".opencode", "skill", "test-skill"],
`--- `---
name: test-skill name: test-skill
description: A test skill for verification. description: A test skill for verification.
--- ---
@@ -91,118 +93,111 @@ description: A test skill for verification.
Instructions here. Instructions here.
`, `,
), )
)
const skill = yield* Skill.Service const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>") expect(list.length).toBe(1)
expect(list.length).toBe(1) const item = list.find((x) => x.name === "test-skill")
const item = list.find((x) => x.name === "test-skill") expect(item).toBeDefined()
expect(item).toBeDefined() expect(item!.description).toBe("A test skill for verification.")
expect(item!.description).toBe("A test skill for verification.") expect(item!.location).toContain(path.join("skill", "test-skill", "SKILL.md"))
expect(item!.location).toContain(path.join("skill", "test-skill", "SKILL.md")) }),
}), { git: true },
{ git: true },
),
) )
it.live("returns skill directories from Skill.dirs", () => it.instance(
provideTmpdirInstance( "returns skill directories from Skill.dirs",
(dir) => Effect.gen(function* () {
withHome( const test = yield* TestInstance
dir, yield* withHome(
Effect.gen(function* () { test.directory,
yield* Effect.promise(() => Effect.gen(function* () {
Bun.write( yield* writeSkill(
path.join(dir, ".opencode", "skill", "dir-skill", "SKILL.md"), test.directory,
`--- [".opencode", "skill", "dir-skill"],
`---
name: dir-skill name: dir-skill
description: Skill for dirs test. description: Skill for dirs test.
--- ---
# Dir Skill # Dir Skill
`, `,
), )
)
const skill = yield* Skill.Service const dirs = yield* Skill.use.dirs()
const dirs = yield* skill.dirs() expect(dirs).toContain(path.join(test.directory, ".opencode", "skill", "dir-skill"))
expect(dirs).toContain(path.join(dir, ".opencode", "skill", "dir-skill")) expect(dirs.length).toBe(1)
expect(dirs.length).toBe(1) }),
}), )
), }),
{ git: true }, { git: true },
),
) )
it.live("discovers multiple skills from .opencode/skill/ directory", () => it.instance(
provideTmpdirInstance( "discovers multiple skills from .opencode/skill/ directory",
(dir) => Effect.gen(function* () {
Effect.gen(function* () { const test = yield* TestInstance
yield* Effect.promise(() => yield* Effect.all(
Promise.all([ [
Bun.write( writeSkill(
path.join(dir, ".opencode", "skill", "skill-one", "SKILL.md"), test.directory,
`--- [".opencode", "skill", "skill-one"],
`---
name: skill-one name: skill-one
description: First test skill. description: First test skill.
--- ---
# Skill One # Skill One
`, `,
), ),
Bun.write( writeSkill(
path.join(dir, ".opencode", "skill", "skill-two", "SKILL.md"), test.directory,
`--- [".opencode", "skill", "skill-two"],
`---
name: skill-two name: skill-two
description: Second test skill. description: Second test skill.
--- ---
# Skill Two # Skill Two
`, `,
), ),
]), ],
) )
const skill = yield* Skill.Service const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>") expect(list.length).toBe(2)
expect(list.length).toBe(2) expect(list.find((x) => x.name === "skill-one")).toBeDefined()
expect(list.find((x) => x.name === "skill-one")).toBeDefined() expect(list.find((x) => x.name === "skill-two")).toBeDefined()
expect(list.find((x) => x.name === "skill-two")).toBeDefined() }),
}), { git: true },
{ git: true },
),
) )
it.live("skips skills with missing frontmatter", () => it.instance(
provideTmpdirInstance( "skips skills with missing frontmatter",
(dir) => Effect.gen(function* () {
Effect.gen(function* () { const test = yield* TestInstance
yield* Effect.promise(() => yield* writeSkill(
Bun.write( test.directory,
path.join(dir, ".opencode", "skill", "no-frontmatter", "SKILL.md"), [".opencode", "skill", "no-frontmatter"],
`# No Frontmatter `# No Frontmatter
Just some content without YAML frontmatter. Just some content without YAML frontmatter.
`, `,
), )
)
const skill = yield* Skill.Service expect((yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")).toEqual([])
expect((yield* skill.all()).filter((s) => s.location !== "<built-in>")).toEqual([]) }),
}), { git: true },
{ git: true },
),
) )
it.live("discovers skills without descriptions", () => it.instance(
provideTmpdirInstance( "discovers skills without descriptions",
(dir) => Effect.gen(function* () {
Effect.gen(function* () { const test = yield* TestInstance
yield* Effect.promise(() => yield* writeSkill(
Bun.write( test.directory,
path.join(dir, ".opencode", "skill", "manual-skill", "SKILL.md"), [".opencode", "skill", "manual-skill"],
`--- `---
name: manual-skill name: manual-skill
--- ---
@@ -210,98 +205,81 @@ name: manual-skill
Instructions here. Instructions here.
`, `,
), )
)
const skill = yield* Skill.Service const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>") expect(list.length).toBe(1)
expect(list.length).toBe(1) const item = list.find((x) => x.name === "manual-skill")
const item = list.find((x) => x.name === "manual-skill") expect(item).toBeDefined()
expect(item).toBeDefined() expect(item!.description).toBeUndefined()
expect(item!.description).toBeUndefined() expect(Skill.fmt(list, { verbose: false })).toBe("No skills are currently available.")
expect(Skill.fmt(list, { verbose: false })).toBe("No skills are currently available.") expect(Skill.fmt(list, { verbose: true })).toBe("No skills are currently available.")
expect(Skill.fmt(list, { verbose: true })).toBe("No skills are currently available.") }),
}), { git: true },
{ git: true },
),
) )
it.live("discovers skills from .claude/skills/ directory", () => it.instance(
provideTmpdirInstance( "discovers skills from .claude/skills/ directory",
(dir) => Effect.gen(function* () {
Effect.gen(function* () { const test = yield* TestInstance
yield* Effect.promise(() => yield* writeSkill(
Bun.write( test.directory,
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"), [".claude", "skills", "claude-skill"],
`--- `---
name: claude-skill name: claude-skill
description: A skill in the .claude/skills directory. description: A skill in the .claude/skills directory.
--- ---
# Claude Skill # Claude Skill
`, `,
),
)
const skill = yield* Skill.Service
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(1)
const item = list.find((x) => x.name === "claude-skill")
expect(item).toBeDefined()
expect(item!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md"))
}),
{ git: true },
),
)
it.live("discovers global skills from ~/.claude/skills/ directory", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir({ git: true })),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
) )
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(1)
const item = list.find((x) => x.name === "claude-skill")
expect(item).toBeDefined()
expect(item!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md"))
}),
{ git: true },
)
it.instance(
"discovers global skills from ~/.claude/skills/ directory",
Effect.gen(function* () {
const test = yield* TestInstance
yield* withHome( yield* withHome(
tmp.path, test.directory,
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.promise(() => createGlobalSkill(tmp.path)) yield* createGlobalSkill(test.directory)
yield* Effect.gen(function* () { const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
const skill = yield* Skill.Service expect(list.length).toBe(1)
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>") expect(list[0].name).toBe("global-test-skill")
expect(list.length).toBe(1) expect(list[0].description).toBe("A global skill from ~/.claude/skills for testing.")
expect(list[0].name).toBe("global-test-skill") expect(list[0].location).toContain(path.join(".claude", "skills", "global-test-skill", "SKILL.md"))
expect(list[0].description).toBe("A global skill from ~/.claude/skills for testing.")
expect(list[0].location).toContain(path.join(".claude", "skills", "global-test-skill", "SKILL.md"))
}).pipe(provideInstance(tmp.path))
}), }),
) )
}), }),
{ git: true },
) )
it.live("returns empty array when no skills exist", () => it.instance(
provideTmpdirInstance( "returns empty array when no skills exist",
() => Effect.gen(function* () {
Effect.gen(function* () { expect((yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")).toEqual([])
const skill = yield* Skill.Service }),
expect((yield* skill.all()).filter((s) => s.location !== "<built-in>")).toEqual([]) { git: true },
}),
{ git: true },
),
) )
it.live("fails with typed error when requiring a missing skill", () => it.instance(
provideTmpdirInstance( "fails with typed error when requiring a missing skill",
() => Effect.gen(function* () {
Effect.gen(function* () { const error = yield* Effect.flip(Skill.use.require("missing-skill"))
const skill = yield* Skill.Service expect(error).toBeInstanceOf(Skill.NotFoundError)
const error = yield* Effect.flip(skill.require("missing-skill")) expect(error._tag).toBe("Skill.NotFoundError")
expect(error).toBeInstanceOf(Skill.NotFoundError) expect(error.name).toBe("missing-skill")
expect(error._tag).toBe("Skill.NotFoundError") expect(error.message).toContain('Skill "missing-skill" not found.')
expect(error.name).toBe("missing-skill") }),
expect(error.message).toContain('Skill "missing-skill" not found.') { git: true },
}),
{ git: true },
),
) )
it.effect("exposes tagged expected skill failure classes", () => it.effect("exposes tagged expected skill failure classes", () =>
@@ -320,50 +298,42 @@ description: A skill in the .claude/skills directory.
}), }),
) )
it.live("discovers skills from .agents/skills/ directory", () => it.instance(
provideTmpdirInstance( "discovers skills from .agents/skills/ directory",
(dir) => Effect.gen(function* () {
Effect.gen(function* () { const test = yield* TestInstance
yield* Effect.promise(() => yield* writeSkill(
Bun.write( test.directory,
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"), [".agents", "skills", "agent-skill"],
`--- `---
name: agent-skill name: agent-skill
description: A skill in the .agents/skills directory. description: A skill in the .agents/skills directory.
--- ---
# Agent Skill # Agent Skill
`, `,
),
)
const skill = yield* Skill.Service
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(1)
const item = list.find((x) => x.name === "agent-skill")
expect(item).toBeDefined()
expect(item!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md"))
}),
{ git: true },
),
)
it.live("discovers global skills from ~/.agents/skills/ directory", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir({ git: true })),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
) )
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(1)
const item = list.find((x) => x.name === "agent-skill")
expect(item).toBeDefined()
expect(item!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md"))
}),
{ git: true },
)
it.instance(
"discovers global skills from ~/.agents/skills/ directory",
Effect.gen(function* () {
const test = yield* TestInstance
yield* withHome( yield* withHome(
tmp.path, test.directory,
Effect.gen(function* () { Effect.gen(function* () {
const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill") yield* writeSkill(
yield* Effect.promise(() => fs.mkdir(skillDir, { recursive: true })) test.directory,
yield* Effect.promise(() => [".agents", "skills", "global-agent-skill"],
Bun.write( `---
path.join(skillDir, "SKILL.md"),
`---
name: global-agent-skill name: global-agent-skill
description: A global skill from ~/.agents/skills for testing. description: A global skill from ~/.agents/skills for testing.
--- ---
@@ -372,198 +342,198 @@ description: A global skill from ~/.agents/skills for testing.
This skill is loaded from the global home directory. This skill is loaded from the global home directory.
`, `,
),
) )
yield* Effect.gen(function* () { const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
const skill = yield* Skill.Service expect(list.length).toBe(1)
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>") expect(list[0].name).toBe("global-agent-skill")
expect(list.length).toBe(1) expect(list[0].description).toBe("A global skill from ~/.agents/skills for testing.")
expect(list[0].name).toBe("global-agent-skill") expect(list[0].location).toContain(path.join(".agents", "skills", "global-agent-skill", "SKILL.md"))
expect(list[0].description).toBe("A global skill from ~/.agents/skills for testing.")
expect(list[0].location).toContain(path.join(".agents", "skills", "global-agent-skill", "SKILL.md"))
}).pipe(provideInstance(tmp.path))
}), }),
) )
}), }),
{ git: true },
) )
it.live("discovers skills from both .claude/skills/ and .agents/skills/", () => it.instance(
provideTmpdirInstance( "discovers skills from both .claude/skills/ and .agents/skills/",
(dir) => Effect.gen(function* () {
Effect.gen(function* () { const test = yield* TestInstance
yield* Effect.promise(() => yield* Effect.all(
Promise.all([ [
Bun.write( writeSkill(
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"), test.directory,
`--- [".claude", "skills", "claude-skill"],
`---
name: claude-skill name: claude-skill
description: A skill in the .claude/skills directory. description: A skill in the .claude/skills directory.
--- ---
# Claude Skill # Claude Skill
`, `,
), ),
Bun.write( writeSkill(
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"), test.directory,
`--- [".agents", "skills", "agent-skill"],
`---
name: agent-skill name: agent-skill
description: A skill in the .agents/skills directory. description: A skill in the .agents/skills directory.
--- ---
# Agent Skill # Agent Skill
`, `,
), ),
]), ],
) )
const skill = yield* Skill.Service const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>") expect(list.length).toBe(2)
expect(list.length).toBe(2) expect(list.find((x) => x.name === "claude-skill")).toBeDefined()
expect(list.find((x) => x.name === "claude-skill")).toBeDefined() expect(list.find((x) => x.name === "agent-skill")).toBeDefined()
expect(list.find((x) => x.name === "agent-skill")).toBeDefined() }),
}), { git: true },
{ git: true },
),
) )
itWithoutClaudeCodeSkills.live("skips Claude Code skills when disabled", () => itWithoutClaudeCodeSkills.instance(
provideTmpdirInstance( "skips Claude Code skills when disabled",
(dir) => Effect.gen(function* () {
Effect.gen(function* () { const test = yield* TestInstance
yield* Effect.promise(() => yield* Effect.all(
Promise.all([ [
Bun.write( writeSkill(
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"), test.directory,
`--- [".claude", "skills", "claude-skill"],
`---
name: claude-skill name: claude-skill
description: A skill in the .claude/skills directory. description: A skill in the .claude/skills directory.
--- ---
# Claude Skill # Claude Skill
`, `,
), ),
Bun.write( writeSkill(
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"), test.directory,
`--- [".agents", "skills", "agent-skill"],
`---
name: agent-skill name: agent-skill
description: A skill in the .agents/skills directory. description: A skill in the .agents/skills directory.
--- ---
# Agent Skill # Agent Skill
`, `,
), ),
]), ],
) )
const skill = yield* Skill.Service const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>") expect(list.map((s) => s.name)).toEqual(["agent-skill"])
expect(list.map((s) => s.name)).toEqual(["agent-skill"]) }),
}), { git: true },
{ git: true },
),
) )
itWithoutExternalSkills.live("skips external skill directories when disabled", () => itWithoutExternalSkills.instance(
provideTmpdirInstance( "skips external skill directories when disabled",
(dir) => Effect.gen(function* () {
Effect.gen(function* () { const test = yield* TestInstance
yield* Effect.promise(() => yield* Effect.all(
Promise.all([ [
Bun.write( writeSkill(
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"), test.directory,
`--- [".claude", "skills", "claude-skill"],
`---
name: claude-skill name: claude-skill
description: A skill in the .claude/skills directory. description: A skill in the .claude/skills directory.
--- ---
# Claude Skill # Claude Skill
`, `,
), ),
Bun.write( writeSkill(
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"), test.directory,
`--- [".agents", "skills", "agent-skill"],
`---
name: agent-skill name: agent-skill
description: A skill in the .agents/skills directory. description: A skill in the .agents/skills directory.
--- ---
# Agent Skill # Agent Skill
`, `,
), ),
Bun.write( writeSkill(
path.join(dir, ".opencode", "skill", "opencode-skill", "SKILL.md"), test.directory,
`--- [".opencode", "skill", "opencode-skill"],
`---
name: opencode-skill name: opencode-skill
description: A skill in the .opencode/skill directory. description: A skill in the .opencode/skill directory.
--- ---
# OpenCode Skill # OpenCode Skill
`, `,
), ),
]), ],
) )
const skill = yield* Skill.Service const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>") expect(list.map((s) => s.name)).toEqual(["opencode-skill"])
expect(list.map((s) => s.name)).toEqual(["opencode-skill"]) }),
}), { git: true },
{ git: true },
),
) )
it.live("properly resolves directories that skills live in", () => it.instance(
provideTmpdirInstance( "properly resolves directories that skills live in",
(dir) => Effect.gen(function* () {
Effect.gen(function* () { const test = yield* TestInstance
yield* Effect.promise(() => yield* Effect.all(
Promise.all([ [
Bun.write( writeSkill(
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"), test.directory,
`--- [".claude", "skills", "claude-skill"],
`---
name: claude-skill name: claude-skill
description: A skill in the .claude/skills directory. description: A skill in the .claude/skills directory.
--- ---
# Claude Skill # Claude Skill
`, `,
), ),
Bun.write( writeSkill(
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"), test.directory,
`--- [".agents", "skills", "agent-skill"],
`---
name: agent-skill name: agent-skill
description: A skill in the .agents/skills directory. description: A skill in the .agents/skills directory.
--- ---
# Agent Skill # Agent Skill
`, `,
), ),
Bun.write( writeSkill(
path.join(dir, ".opencode", "skill", "agent-skill", "SKILL.md"), test.directory,
`--- [".opencode", "skill", "agent-skill"],
`---
name: opencode-skill name: opencode-skill
description: A skill in the .opencode/skill directory. description: A skill in the .opencode/skill directory.
--- ---
# OpenCode Skill # OpenCode Skill
`, `,
), ),
Bun.write( writeSkill(
path.join(dir, ".opencode", "skills", "agent-skill", "SKILL.md"), test.directory,
`--- [".opencode", "skills", "agent-skill"],
`---
name: opencode-skill name: opencode-skill
description: A skill in the .opencode/skills directory. description: A skill in the .opencode/skills directory.
--- ---
# OpenCode Skill # OpenCode Skill
`, `,
), ),
]), ],
) )
const skill = yield* Skill.Service expect((yield* Skill.use.dirs()).length).toBe(4)
expect((yield* skill.dirs()).length).toBe(4) }),
}), { git: true },
{ git: true },
),
) )
}) })
+182 -237
View File
@@ -95,15 +95,25 @@ const brokenPluginLayer = Layer.succeed(
}), }),
) )
const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer)) const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer, AppFileSystem.defaultLayer))
const scout = testEffect( const scout = testEffect(
Layer.mergeAll(registryLayer({ flags: { experimentalScout: true } }), node, Agent.defaultLayer), Layer.mergeAll(
registryLayer({ flags: { experimentalScout: true } }),
node,
Agent.defaultLayer,
AppFileSystem.defaultLayer,
),
) )
const background = testEffect( const background = testEffect(
Layer.mergeAll(registryLayer({ flags: { experimentalBackgroundSubagents: true } }), node, Agent.defaultLayer), Layer.mergeAll(
registryLayer({ flags: { experimentalBackgroundSubagents: true } }),
node,
Agent.defaultLayer,
AppFileSystem.defaultLayer,
),
) )
const withBrokenPlugin = testEffect( const withBrokenPlugin = testEffect(
Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer), Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer, AppFileSystem.defaultLayer),
) )
afterEach(async () => { afterEach(async () => {
@@ -113,8 +123,7 @@ afterEach(async () => {
describe("tool.registry", () => { describe("tool.registry", () => {
it.instance("hides repo research tools unless experimental", () => it.instance("hides repo research tools unless experimental", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const ids = yield* ToolRegistry.use.ids()
const ids = yield* registry.ids()
expect(ids).not.toContain("repo_clone") expect(ids).not.toContain("repo_clone")
expect(ids).not.toContain("repo_overview") expect(ids).not.toContain("repo_overview")
@@ -123,8 +132,7 @@ describe("tool.registry", () => {
scout.instance("shows repo research tools when experimental scout is enabled", () => scout.instance("shows repo research tools when experimental scout is enabled", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const ids = yield* ToolRegistry.use.ids()
const ids = yield* registry.ids()
expect(ids).toContain("repo_clone") expect(ids).toContain("repo_clone")
expect(ids).toContain("repo_overview") expect(ids).toContain("repo_overview")
@@ -133,8 +141,7 @@ describe("tool.registry", () => {
it.instance("hides task_status unless experimental background subagents are enabled", () => it.instance("hides task_status unless experimental background subagents are enabled", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const ids = yield* ToolRegistry.use.ids()
const ids = yield* registry.ids()
expect(ids).not.toContain("task_status") expect(ids).not.toContain("task_status")
}), }),
@@ -142,11 +149,10 @@ describe("tool.registry", () => {
it.instance("hides task background parameter unless experimental background subagents are enabled", () => it.instance("hides task background parameter unless experimental background subagents are enabled", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const agent = yield* Agent.Service const agent = yield* Agent.Service
const build = yield* agent.get("build") const build = yield* agent.get("build")
if (!build) throw new Error("build agent not found") if (!build) throw new Error("build agent not found")
const task = (yield* registry.tools({ const task = (yield* ToolRegistry.use.tools({
providerID: ProviderID.opencode, providerID: ProviderID.opencode,
modelID: ModelID.make("test"), modelID: ModelID.make("test"),
agent: build, agent: build,
@@ -159,8 +165,7 @@ describe("tool.registry", () => {
background.instance("shows task_status when experimental background subagents are enabled", () => background.instance("shows task_status when experimental background subagents are enabled", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const ids = yield* ToolRegistry.use.ids()
const ids = yield* registry.ids()
expect(ids).toContain("task_status") expect(ids).toContain("task_status")
}), }),
@@ -169,26 +174,20 @@ describe("tool.registry", () => {
it.instance("loads tools from .opencode/tool (singular)", () => it.instance("loads tools from .opencode/tool (singular)", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode") yield* AppFileSystem.use.writeWithDirs(
const tool = path.join(opencode, "tool") path.join(test.directory, ".opencode", "tool", "hello.ts"),
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) [
yield* Effect.promise(() => "export default {",
Bun.write( " description: 'hello tool',",
path.join(tool, "hello.ts"), " args: {},",
[ " execute: async () => {",
"export default {", " return 'hello world'",
" description: 'hello tool',", " },",
" args: {},", "}",
" execute: async () => {", "",
" return 'hello world'", ].join("\n"),
" },",
"}",
"",
].join("\n"),
),
) )
const registry = yield* ToolRegistry.Service const ids = yield* ToolRegistry.use.ids()
const ids = yield* registry.ids()
expect(ids).toContain("hello") expect(ids).toContain("hello")
}), }),
) )
@@ -196,25 +195,20 @@ describe("tool.registry", () => {
it.instance("ignores non-tool exports in .opencode/tool files", () => it.instance("ignores non-tool exports in .opencode/tool files", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
const tool = path.join(test.directory, ".opencode", "tool") yield* AppFileSystem.use.writeWithDirs(
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) path.join(test.directory, ".opencode", "tool", "mixed.ts"),
yield* Effect.promise(() => [
Bun.write( "export const helper = 'not a tool'",
path.join(tool, "mixed.ts"), "export default {",
[ " description: 'mixed tool',",
"export const helper = 'not a tool'", " args: {},",
"export default {", " execute: async () => 'ok',",
" description: 'mixed tool',", "}",
" args: {},", "",
" execute: async () => 'ok',", ].join("\n"),
"}",
"",
].join("\n"),
),
) )
const registry = yield* ToolRegistry.Service const ids = yield* ToolRegistry.use.ids()
const ids = yield* registry.ids()
expect(ids).toContain("mixed") expect(ids).toContain("mixed")
expect(ids).not.toContain("mixed_helper") expect(ids).not.toContain("mixed_helper")
}), }),
@@ -229,28 +223,23 @@ describe("tool.registry", () => {
it.instance("tolerates a custom tool exporting null/undefined args (no-args fallback)", () => it.instance("tolerates a custom tool exporting null/undefined args (no-args fallback)", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
const tool = path.join(test.directory, ".opencode", "tool") yield* AppFileSystem.use.writeWithDirs(
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) path.join(test.directory, ".opencode", "tool", "noargs.ts"),
yield* Effect.promise(() => [
Bun.write( "export default {",
path.join(tool, "noargs.ts"), " description: 'tool with no args',",
[ " args: undefined,",
"export default {", " execute: async () => 'ok',",
" description: 'tool with no args',", "}",
" args: undefined,", "",
" execute: async () => 'ok',", ].join("\n"),
"}",
"",
].join("\n"),
),
) )
const registry = yield* ToolRegistry.Service const ids = yield* ToolRegistry.use.ids()
const ids = yield* registry.ids()
// Built-in tools must still load — a single malformed custom tool must // Built-in tools must still load — a single malformed custom tool must
// not poison the whole registry. // not poison the whole registry.
expect(ids).toContain("read") expect(ids).toContain("read")
const loaded = (yield* registry.all()).find((t) => t.id === "noargs") const loaded = (yield* ToolRegistry.use.all()).find((t) => t.id === "noargs")
if (!loaded) throw new Error("noargs tool was not loaded") if (!loaded) throw new Error("noargs tool was not loaded")
expect(loaded.jsonSchema).toMatchObject({ type: "object", properties: {} }) expect(loaded.jsonSchema).toMatchObject({ type: "object", properties: {} })
}), }),
@@ -264,8 +253,7 @@ describe("tool.registry", () => {
// protection. // protection.
withBrokenPlugin.instance("tolerates a plugin tool registered with null/undefined args", () => withBrokenPlugin.instance("tolerates a plugin tool registered with null/undefined args", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const ids = yield* ToolRegistry.use.ids()
const ids = yield* registry.ids()
expect(ids).toContain("read") expect(ids).toContain("read")
expect(ids).toContain("broken_plugin_tool") expect(ids).toContain("broken_plugin_tool")
}), }),
@@ -274,26 +262,20 @@ describe("tool.registry", () => {
it.instance("loads tools from .opencode/tools (plural)", () => it.instance("loads tools from .opencode/tools (plural)", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode") yield* AppFileSystem.use.writeWithDirs(
const tools = path.join(opencode, "tools") path.join(test.directory, ".opencode", "tools", "hello.ts"),
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) [
yield* Effect.promise(() => "export default {",
Bun.write( " description: 'hello tool',",
path.join(tools, "hello.ts"), " args: {},",
[ " execute: async () => {",
"export default {", " return 'hello world'",
" description: 'hello tool',", " },",
" args: {},", "}",
" execute: async () => {", "",
" return 'hello world'", ].join("\n"),
" },",
"}",
"",
].join("\n"),
),
) )
const registry = yield* ToolRegistry.Service const ids = yield* ToolRegistry.use.ids()
const ids = yield* registry.ids()
expect(ids).toContain("hello") expect(ids).toContain("hello")
}), }),
) )
@@ -301,26 +283,21 @@ describe("tool.registry", () => {
it.instance("loads Zod-schema custom tools with JSON Schema and validation", () => it.instance("loads Zod-schema custom tools with JSON Schema and validation", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
const customTools = path.join(test.directory, ".opencode", "tools")
const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true })) yield* AppFileSystem.use.writeWithDirs(
yield* Effect.promise(() => path.join(test.directory, ".opencode", "tools", "sql.ts"),
Bun.write( [
path.join(customTools, "sql.ts"), `import { tool } from ${JSON.stringify(pluginTool)}`,
[ "export default tool({",
`import { tool } from ${JSON.stringify(pluginTool)}`, " description: 'query database',",
"export default tool({", " args: { query: tool.schema.string().describe('SQL query to execute') },",
" description: 'query database',", " execute: async ({ query }) => query,",
" args: { query: tool.schema.string().describe('SQL query to execute') },", "})",
" execute: async ({ query }) => query,", "",
"})", ].join("\n"),
"",
].join("\n"),
),
) )
const registry = yield* ToolRegistry.Service const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "sql")
const loaded = (yield* registry.all()).find((tool) => tool.id === "sql")
if (!loaded) throw new Error("custom sql tool was not loaded") if (!loaded) throw new Error("custom sql tool was not loaded")
expect(loaded?.jsonSchema).toMatchObject({ expect(loaded?.jsonSchema).toMatchObject({
type: "object", type: "object",
@@ -333,7 +310,7 @@ describe("tool.registry", () => {
expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false) expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false)
const agents = yield* Agent.Service const agents = yield* Agent.Service
const promptTools = yield* registry.tools({ const promptTools = yield* ToolRegistry.use.tools({
providerID: ProviderID.opencode, providerID: ProviderID.opencode,
modelID: ModelID.make("test"), modelID: ModelID.make("test"),
agent: yield* agents.defaultInfo(), agent: yield* agents.defaultInfo(),
@@ -357,53 +334,44 @@ describe("tool.registry", () => {
const opencode = path.join(test.directory, ".opencode") const opencode = path.join(test.directory, ".opencode")
const customTools = path.join(opencode, "tools") const customTools = path.join(opencode, "tools")
const plugin = path.join(opencode, "node_modules", "@opencode-ai", "plugin") const plugin = path.join(opencode, "node_modules", "@opencode-ai", "plugin")
yield* Effect.promise(() => fs.mkdir(path.join(plugin, "dist"), { recursive: true }))
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
yield* Effect.promise(() => yield* Effect.promise(() =>
fs.cp(path.dirname(fileURLToPath(import.meta.resolve("zod"))), path.join(opencode, "node_modules", "zod"), { fs.cp(path.dirname(fileURLToPath(import.meta.resolve("zod"))), path.join(opencode, "node_modules", "zod"), {
dereference: true, dereference: true,
recursive: true, recursive: true,
}), }),
) )
yield* Effect.promise(() => yield* AppFileSystem.use.writeWithDirs(
Bun.write( path.join(plugin, "package.json"),
path.join(plugin, "package.json"), JSON.stringify({ name: "@opencode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }),
JSON.stringify({ name: "@opencode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }),
),
) )
yield* Effect.promise(() => yield* AppFileSystem.use.writeWithDirs(
Bun.write( path.join(plugin, "dist", "index.js"),
path.join(plugin, "dist", "index.js"), [
[ "import { z } from 'zod'",
"import { z } from 'zod'", "export function tool(input) {",
"export function tool(input) {", " return input",
" return input", "}",
"}", "tool.schema = z",
"tool.schema = z", "",
"", ].join("\n"),
].join("\n"),
),
) )
yield* Effect.promise(() => yield* AppFileSystem.use.writeWithDirs(
Bun.write( path.join(customTools, "addition.ts"),
path.join(customTools, "addition.ts"), [
[ 'import { tool } from "@opencode-ai/plugin"',
'import { tool } from "@opencode-ai/plugin"', "export default tool({",
"export default tool({", " description: 'Use this tool to add two numbers and return their sum.',",
" description: 'Use this tool to add two numbers and return their sum.',", " args: {",
" args: {", " left: tool.schema.number().describe('The first number to add'),",
" left: tool.schema.number().describe('The first number to add'),", " right: tool.schema.number().describe('The second number to add'),",
" right: tool.schema.number().describe('The second number to add'),", " },",
" },", " execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,",
" execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,", "})",
"})", "",
"", ].join("\n"),
].join("\n"),
),
) )
const registry = yield* ToolRegistry.Service const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "addition")
const loaded = (yield* registry.all()).find((tool) => tool.id === "addition")
if (!loaded) throw new Error("custom addition tool was not loaded") if (!loaded) throw new Error("custom addition tool was not loaded")
expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({ expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
@@ -419,29 +387,24 @@ describe("tool.registry", () => {
it.instance("preserves attachments from structured custom tool results", () => it.instance("preserves attachments from structured custom tool results", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
const customTools = path.join(test.directory, ".opencode", "tools")
const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true })) yield* AppFileSystem.use.writeWithDirs(
yield* Effect.promise(() => path.join(test.directory, ".opencode", "tools", "image.ts"),
Bun.write( [
path.join(customTools, "image.ts"), `import { tool } from ${JSON.stringify(pluginTool)}`,
[ "export default tool({",
`import { tool } from ${JSON.stringify(pluginTool)}`, " description: 'image tool',",
"export default tool({", " args: {},",
" description: 'image tool',", " execute: async () => ({",
" args: {},", " output: 'here is an image',",
" execute: async () => ({", " attachments: [{ type: 'file', mime: 'image/png', filename: 'picture.png', url: 'data:image/png;base64,AAAA' }],",
" output: 'here is an image',", " }),",
" attachments: [{ type: 'file', mime: 'image/png', filename: 'picture.png', url: 'data:image/png;base64,AAAA' }],", "})",
" }),", "",
"})", ].join("\n"),
"",
].join("\n"),
),
) )
const registry = yield* ToolRegistry.Service const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "image")
const loaded = (yield* registry.all()).find((tool) => tool.id === "image")
if (!loaded) throw new Error("custom image tool was not loaded") if (!loaded) throw new Error("custom image tool was not loaded")
const agents = yield* Agent.Service const agents = yield* Agent.Service
const result = yield* loaded.execute({}, { const result = yield* loaded.execute({}, {
@@ -464,24 +427,19 @@ describe("tool.registry", () => {
it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () => it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
const tools = path.join(test.directory, ".opencode", "tools") yield* AppFileSystem.use.writeWithDirs(
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) path.join(test.directory, ".opencode", "tools", "legacy.ts"),
yield* Effect.promise(() => [
Bun.write( "export default {",
path.join(tools, "legacy.ts"), " description: 'legacy schema tool',",
[ " args: { text: { type: 'string', description: 'Text to render' } },",
"export default {", " execute: async ({ text }) => text,",
" description: 'legacy schema tool',", "}",
" args: { text: { type: 'string', description: 'Text to render' } },", "",
" execute: async ({ text }) => text,", ].join("\n"),
"}",
"",
].join("\n"),
),
) )
const registry = yield* ToolRegistry.Service const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "legacy")
const loaded = (yield* registry.all()).find((tool) => tool.id === "legacy")
if (!loaded) throw new Error("legacy custom tool was not loaded") if (!loaded) throw new Error("legacy custom tool was not loaded")
expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({ expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
type: "object", type: "object",
@@ -498,73 +456,60 @@ describe("tool.registry", () => {
const test = yield* TestInstance const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode") const opencode = path.join(test.directory, ".opencode")
const tools = path.join(opencode, "tools") const tools = path.join(opencode, "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) yield* AppFileSystem.use.writeWithDirs(
yield* Effect.promise(() => path.join(opencode, "package.json"),
Bun.write( JSON.stringify({
path.join(opencode, "package.json"), name: "custom-tools",
JSON.stringify({ dependencies: {
name: "custom-tools", "@opencode-ai/plugin": "^0.0.0",
dependencies: { cowsay: "^1.6.0",
"@opencode-ai/plugin": "^0.0.0", },
cowsay: "^1.6.0", }),
},
}),
),
) )
yield* Effect.promise(() => yield* AppFileSystem.use.writeWithDirs(
Bun.write( path.join(opencode, "package-lock.json"),
path.join(opencode, "package-lock.json"), JSON.stringify({
JSON.stringify({ name: "custom-tools",
name: "custom-tools", lockfileVersion: 3,
lockfileVersion: 3, packages: {
packages: { "": {
"": { dependencies: {
dependencies: { "@opencode-ai/plugin": "^0.0.0",
"@opencode-ai/plugin": "^0.0.0", cowsay: "^1.6.0",
cowsay: "^1.6.0",
},
}, },
}, },
}), },
), }),
) )
const cowsay = path.join(opencode, "node_modules", "cowsay") const cowsay = path.join(opencode, "node_modules", "cowsay")
yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true })) yield* AppFileSystem.use.writeWithDirs(
yield* Effect.promise(() => path.join(cowsay, "package.json"),
Bun.write( JSON.stringify({
path.join(cowsay, "package.json"), name: "cowsay",
JSON.stringify({ type: "module",
name: "cowsay", exports: "./index.js",
type: "module", }),
exports: "./index.js",
}),
),
) )
yield* Effect.promise(() => yield* AppFileSystem.use.writeWithDirs(
Bun.write( path.join(cowsay, "index.js"),
path.join(cowsay, "index.js"), ["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
),
) )
yield* Effect.promise(() => yield* AppFileSystem.use.writeWithDirs(
Bun.write( path.join(tools, "cowsay.ts"),
path.join(tools, "cowsay.ts"), [
[ "import { say } from 'cowsay'",
"import { say } from 'cowsay'", "export default {",
"export default {", " description: 'tool that imports cowsay at top level',",
" description: 'tool that imports cowsay at top level',", " args: { text: { type: 'string' } },",
" args: { text: { type: 'string' } },", " execute: async ({ text }: { text: string }) => {",
" execute: async ({ text }: { text: string }) => {", " return say({ text })",
" return say({ text })", " },",
" },", "}",
"}", "",
"", ].join("\n"),
].join("\n"),
),
) )
const registry = yield* ToolRegistry.Service const ids = yield* ToolRegistry.use.ids()
const ids = yield* registry.ids()
expect(ids).toContain("cowsay") expect(ids).toContain("cowsay")
}), }),
) )
+11 -11
View File
@@ -1,4 +1,5 @@
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Cause, Effect, Exit, Layer } from "effect" import { Cause, Effect, Exit, Layer } from "effect"
import { afterEach, describe, expect } from "bun:test" import { afterEach, describe, expect } from "bun:test"
import path from "path" import path from "path"
@@ -7,6 +8,7 @@ import type { Permission } from "../../src/permission"
import type { Tool } from "@/tool/tool" import type { Tool } from "@/tool/tool"
import { SkillTool } from "../../src/tool/skill" import { SkillTool } from "../../src/tool/skill"
import { ToolRegistry } from "@/tool/registry" import { ToolRegistry } from "@/tool/registry"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@@ -27,17 +29,16 @@ afterEach(async () => {
const node = CrossSpawnSpawner.defaultLayer const node = CrossSpawnSpawner.defaultLayer
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node, AppFileSystem.defaultLayer))
describe("tool.skill", () => { describe("tool.skill", () => {
it.live("execute returns skill content block with files", () => it.live("execute returns skill content block with files", () =>
provideTmpdirInstance((dir) => provideTmpdirInstance((dir) =>
Effect.gen(function* () { Effect.gen(function* () {
const skill = path.join(dir, ".opencode", "skill", "tool-skill") const skill = path.join(dir, ".opencode", "skill", "tool-skill")
yield* Effect.promise(() => yield* AppFileSystem.use.writeWithDirs(
Bun.write( path.join(skill, "SKILL.md"),
path.join(skill, "SKILL.md"), `---
`---
name: tool-skill name: tool-skill
description: Skill for tool tests. description: Skill for tool tests.
--- ---
@@ -46,9 +47,8 @@ description: Skill for tool tests.
Use this skill. Use this skill.
`, `,
),
) )
yield* Effect.promise(() => Bun.write(path.join(skill, "scripts", "demo.txt"), "demo")) yield* AppFileSystem.use.writeWithDirs(path.join(skill, "scripts", "demo.txt"), "demo")
const home = process.env.OPENCODE_TEST_HOME const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = dir process.env.OPENCODE_TEST_HOME = dir
@@ -61,8 +61,8 @@ Use this skill.
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
const tool = (yield* registry.tools({ const tool = (yield* registry.tools({
providerID: "opencode" as any, providerID: ProviderID.opencode,
modelID: "gpt-5" as any, modelID: ModelID.make("gpt-5"),
agent, agent,
})).find((tool) => tool.id === SkillTool.id) })).find((tool) => tool.id === SkillTool.id)
if (!tool) throw new Error("Skill tool not found") if (!tool) throw new Error("Skill tool not found")
@@ -105,8 +105,8 @@ Use this skill.
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
const tool = (yield* registry.tools({ const tool = (yield* registry.tools({
providerID: "opencode" as any, providerID: ProviderID.opencode,
modelID: "gpt-5" as any, modelID: ModelID.make("gpt-5"),
agent, agent,
})).find((tool) => tool.id === SkillTool.id) })).find((tool) => tool.id === SkillTool.id)
if (!tool) throw new Error("Skill tool not found") if (!tool) throw new Error("Skill tool not found")