From ec0dcb3da9dc865ec012f281cd86102036e33713 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 31 Aug 2026 12:46:49 -0400 Subject: [PATCH 1/5] docs: improve build documentation discovery --- packages/core/src/plugin/skill/opencode.md | 15 ++ packages/www/src/docs/AGENTS.md | 1 + .../src/docs/content/build/client/effect.mdx | 2 +- packages/www/src/docs/content/build/index.mdx | 3 + .../src/docs/content/build/plugins/effect.mdx | 53 ----- .../docs/content/build/plugins/effect/rpc.mdx | 169 +++++++++++++++ .../src/docs/content/build/plugins/rpc.mdx | 196 ++++++++++++------ .../www/src/docs/content/build/sdk/index.mdx | 2 + packages/www/src/docs/lib/llms.ts | 25 +++ packages/www/src/docs/lib/navigation.ts | 16 +- packages/www/src/pages/llms.txt.ts | 9 + 11 files changed, 368 insertions(+), 123 deletions(-) create mode 100644 packages/www/src/docs/content/build/plugins/effect/rpc.mdx create mode 100644 packages/www/src/docs/lib/llms.ts create mode 100644 packages/www/src/pages/llms.txt.ts diff --git a/packages/core/src/plugin/skill/opencode.md b/packages/core/src/plugin/skill/opencode.md index 1ed6bb2ffc2..a5a7dcfa61f 100644 --- a/packages/core/src/plugin/skill/opencode.md +++ b/packages/core/src/plugin/skill/opencode.md @@ -11,6 +11,9 @@ truth. Follow links from that page when the question needs more detail. Fetch first when you need to discover the relevant documentation page. +A machine-readable documentation index is available at +. + ## Version policy Always answer for OpenCode V2 unless the user explicitly asks about V1, @@ -152,6 +155,8 @@ before answering. Refer to this guide when the user wants to build a plugin. It covers hooks, transforms, tools, plugin context capabilities, and package entrypoints. Plugins can also extend the TUI; for those, fetch the [CLI plugin guide](https://opencode.ai/v2/docs/build/plugins/cli). +For custom methods and events shared with other plugins or clients, fetch the +[RPC guide](https://opencode.ai/v2/docs/build/plugins/rpc). ## [Service](https://opencode.ai/v2/docs/troubleshooting#check-the-background-service) @@ -220,6 +225,16 @@ exposes typed Effects, Streams, and decoded OpenCode schema values. Its `Service` API can discover, start, stop, and authenticate with the local background service from a Node application. +## [SDK](https://opencode.ai/v2/docs/build/sdk) + +For questions about embedding OpenCode directly in an application, fetch the +full [SDK guide](https://opencode.ai/v2/docs/build/sdk) before answering. The SDK +hosts OpenCode in the application without opening an HTTP listener. + +Use the [Effect SDK guide](https://opencode.ai/v2/docs/build/sdk/effect) for +Effect applications. For Cloudflare Durable Objects, use the +[Cloudflare SDK guide](https://opencode.ai/v2/docs/build/sdk/cloudflare). + ## [Troubleshooting](https://opencode.ai/v2/docs/troubleshooting) OpenCode runs a client and a background server. Start by determining whether a diff --git a/packages/www/src/docs/AGENTS.md b/packages/www/src/docs/AGENTS.md index 05b9229213d..18c295262dd 100644 --- a/packages/www/src/docs/AGENTS.md +++ b/packages/www/src/docs/AGENTS.md @@ -14,6 +14,7 @@ - Keep prose sections brief and focused on one idea. Prefer one to three sentences over large paragraphs. - Interleave explanations with concrete code, configuration, command, or output examples so pages do not become walls of text. - Put the relevant example immediately after the text that introduces it, following `content/build/plugins/cli.mdx` as the reference pattern. +- Every subsection that explains syntax, fields, or an API concept must include its own minimal example. A larger example earlier on the page does not count. - Split long explanations with meaningful headings and examples rather than accumulating caveats in one paragraph. - Lead with the common task and working example; place edge cases and supporting details afterward. - Do not stack several prose paragraphs without a visual break. After introducing a concept, use an example, list, table, or task-oriented subheading before covering the next concern. diff --git a/packages/www/src/docs/content/build/client/effect.mdx b/packages/www/src/docs/content/build/client/effect.mdx index c20f2794a4f..0bfa93b223a 100644 --- a/packages/www/src/docs/content/build/client/effect.mdx +++ b/packages/www/src/docs/content/build/client/effect.mdx @@ -104,7 +104,7 @@ RPC error wrapper; reserved `rpc.*` types identify framework failures. RPC events are typed Streams, not callback-style `on` listeners. They receive the RPC's events from all locations, each with required `location` and a normal prefixed type such as `rpc.acme.updated`. This differs from server-plugin handles, -which are fixed to their own location. See [plugin RPC](/build/plugins/rpc) for +which are fixed to their own location. See [Effect plugin RPC](/build/plugins/effect/rpc) for definitions, schemas, registration, and live subscription semantics. ## Local background service diff --git a/packages/www/src/docs/content/build/index.mdx b/packages/www/src/docs/content/build/index.mdx index 8e1221ef756..3f71ce51ae2 100644 --- a/packages/www/src/docs/content/build/index.mdx +++ b/packages/www/src/docs/content/build/index.mdx @@ -6,6 +6,9 @@ OpenCode is used by millions every day. Build on top of it to create your own applications, integrations, and agent experiences without starting from scratch. +We also offer Effect APIs for [plugins](/build/plugins/effect), +[clients](/build/client/effect), and [embedded apps](/build/sdk/effect). + Build plugins that add tools, integrations, commands, agents, and custom behavior while keeping the rest of OpenCode diff --git a/packages/www/src/docs/content/build/plugins/effect.mdx b/packages/www/src/docs/content/build/plugins/effect.mdx index 6d3eee122a6..6b902c3fafd 100644 --- a/packages/www/src/docs/content/build/plugins/effect.mdx +++ b/packages/www/src/docs/content/build/plugins/effect.mdx @@ -631,59 +631,6 @@ interface Context { } ``` -### RPC - -Use the same execution-neutral [`Rpc.define` builder](/build/plugins/rpc). -Effect clients and plugins accept Effect Schema, Standard Schema, or plain JSON -Schema. Promise consumers accept only the portable Standard and JSON formats. - -```ts -import { Plugin } from "@opencode-ai/plugin/effect" -import { Effect } from "effect" -import { Acme } from "./rpc.js" - -export default Plugin.define({ - id: "acme-effect-plugin", - effect: (ctx) => - Effect.gen(function* () { - const registration = yield* ctx.rpc.register(Acme, { - search: ({ query }, context) => - findText(query).pipe( - Effect.flatMap((text) => - text - ? Effect.succeed({ text }) - : Effect.fail(context.error("not_found", "Result not found", { query })), - ), - ), - }) - yield* registration.events.emit("updated", { itemID: "item-1", text: "ready" }) - }).pipe(Effect.orDie), -}) -``` - -Effect handlers use normal interruption. Registrations belong to the plugin -scope; `yield* registration.dispose` removes one explicitly. Later registrations -override earlier ones at the same location, without changing in-flight handlers. - -`ctx.rpc(Acme)` returns a local typed subclient. Its methods return Effects and -`events.subscribe(name)` returns a Stream. Use scoped fibers when listening -during plugin lifetime: - -```ts -const acme = ctx.rpc(Acme) -yield * - acme.events.subscribe("updated").pipe( - Stream.runForEach((event) => Effect.logInfo(event.data.text)), - Effect.forkScoped, - ) -``` - -There is no Effect callback-style `on` API. Subscriptions are location-bound, -live-only, and close when Stream consumption stops. Events use the normal -ephemeral Bus path. Method `errors` maps become typed Effect error channels. Construct one -with `context.error(...)` and fail it with `Effect.fail`; unexpected failures and -transport errors remain separate from the declared method errors. - ### References Read references available at the current location. diff --git a/packages/www/src/docs/content/build/plugins/effect/rpc.mdx b/packages/www/src/docs/content/build/plugins/effect/rpc.mdx new file mode 100644 index 00000000000..d0c0d4e9141 --- /dev/null +++ b/packages/www/src/docs/content/build/plugins/effect/rpc.mdx @@ -0,0 +1,169 @@ +--- +title: "RPC" +--- + +Effect plugins can expose methods and events that return typed Effects and +Streams. + +## Define + +Use `Rpc.define` with Effect Schema to define the RPC. + +```ts title="src/rpc.ts" +import { Rpc } from "@opencode-ai/plugin/rpc" +import { Schema } from "effect" + +export const Acme = Rpc.define({ + id: "acme", + methods: { + search: { + input: Schema.Struct({ query: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + errors: { + not_found: Schema.Struct({ query: Schema.String }), + }, + }, + }, + events: { + updated: { + schema: Schema.Struct({ itemID: Schema.String, text: Schema.String }), + }, + }, +}) +``` + +### Validation + +Effect Schema validates values and infers their TypeScript types. + +```ts +input: Schema.Struct({ query: Schema.String }) +``` + +JSON Schema and Standard Schema are also supported. + +### Input and output + +Use `input` for the method argument and `output` for its return value. Leave +either one out when there is no value. + +```ts +search: { + input: Schema.Struct({ query: Schema.String }), + output: Schema.Struct({ text: Schema.String }), +} +``` + +### Errors + +Add expected failures to `errors`. Each key becomes the error's `type`. + +```ts +errors: { + not_found: Schema.Struct({ query: Schema.String }), +} +``` + +### Events + +Add events to the top-level `events` map. Event data must be an object. + +```ts +events: { + updated: { + schema: Schema.Struct({ itemID: Schema.String, text: Schema.String }), + }, +} +``` + +## Implement + +Register the implementation from the plugin Effect: + +```ts title="src/index.ts" +import { Plugin } from "@opencode-ai/plugin/effect" +import { Effect } from "effect" +import { Acme } from "./rpc.js" + +export default Plugin.define({ + id: "acme-effect-plugin", + effect: (ctx) => + Effect.gen(function* () { + const registration = yield* ctx.rpc.register(Acme, { + search: ({ query }, context) => + findText(query).pipe( + Effect.flatMap((text) => + text + ? Effect.succeed({ text }) + : Effect.fail(context.error("not_found", "Result not found", { query })), + ), + ), + }) + + const acme = ctx.rpc(Acme) + const result = yield* acme.search({ query: "hello" }) + + yield* registration.events.emit("updated", { itemID: "item-1", text: result.text }) + }).pipe(Effect.orDie), +}) +``` + +The error map becomes the error type of each method Effect. Use +`context.error(...)` to create a declared error. + +## Call + +Once the RPC is registered, it can be called over HTTP or from another plugin. + +### HTTP + +Create the Effect client, then create the RPC subclient: + +```ts +import { OpenCode } from "@opencode-ai/client/effect" +import { Effect } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import { Acme } from "opencode-acme-plugin/rpc" + +const program = Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:4096" }) + const acme = client.rpc(Acme) + return yield* acme.search({ query: "hello" }) +}) + +const result = await Effect.runPromise(program.pipe(Effect.provide(FetchHttpClient.layer))) +``` + +### Plugin + +Another Effect plugin can create a local subclient from its context: + +```ts +import { Effect } from "effect" + +effect: (ctx) => + Effect.gen(function* () { + const acme = ctx.rpc(Acme) + const result = yield* acme.search({ query: "hello" }) + yield* Effect.logInfo(result.text) + }).pipe(Effect.orDie) +``` + +### Subscribe + +RPC events are Streams. Subscribe by event name and run the Stream in a scoped +fiber: + +```ts +import { Effect, Stream } from "effect" + +const acme = ctx.rpc(Acme) + +yield * + acme.events.subscribe("updated").pipe( + Stream.runForEach((event) => Effect.logInfo(event.data.text)), + Effect.forkScoped, + ) +``` + +Subscriptions are live only and close when Stream consumption stops. diff --git a/packages/www/src/docs/content/build/plugins/rpc.mdx b/packages/www/src/docs/content/build/plugins/rpc.mdx index 4522877c9e6..0a7e01791fd 100644 --- a/packages/www/src/docs/content/build/plugins/rpc.mdx +++ b/packages/www/src/docs/content/build/plugins/rpc.mdx @@ -2,79 +2,129 @@ title: "RPC" --- -Expose typed methods and custom events through a shared RPC definition. Start by -putting the contract in a browser-safe module, separate from plugin setup and -server code. +Plugins can expose custom methods and events that run on the server and can be +called by other plugins or clients. -## Define the contract +## Define -Use `Rpc.define` to declare the RPC ID, methods, errors, and events. The builder -is synchronous and independent of Promise or Effect execution. +Use `Rpc.define` to list the RPC's methods, errors, and events. ```ts title="src/rpc.ts" import { Rpc } from "@opencode-ai/plugin/rpc" -import { z } from "zod" export const Acme = Rpc.define({ id: "acme", methods: { search: { - input: z.object({ query: z.string() }), - output: z.object({ text: z.string() }), + input: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + additionalProperties: false, + }, + output: { + type: "object", + properties: { text: { type: "string" } }, + required: ["text"], + additionalProperties: false, + }, errors: { - not_found: z.object({ query: z.string() }), + not_found: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + additionalProperties: false, + }, }, }, }, events: { updated: { - schema: z.object({ itemID: z.string(), text: z.string() }), + schema: { + type: "object", + properties: { + itemID: { type: "string" }, + text: { type: "string" }, + }, + required: ["itemID", "text"], + additionalProperties: false, + }, }, }, }) ``` -## Choose schemas +### Validation -Use Standard Schema, such as Zod, when Promise and Effect consumers share the -contract. It validates at runtime and infers TypeScript types. +An RPC definition describes the shapes of input and output of methods, events +and errors. It supports two schema formats: -Other schema formats have narrower tradeoffs: +- JSON Schema, simple and requires no dependencies. +- Any Standard Schema compliant validator + - Zod + - Valibot + - ArkType -- Plain JSON Schema validates at runtime, but its inferred TypeScript value is `unknown`. -- Plain JSON Schema uses Draft 2020-12 through Effect's JSON Schema importer and decoder. -- Use Standard Schema when you need another JSON Schema dialect or parser. -- Effect Schema is supported only by Effect plugins and clients. +### Input and output -Every method declares `input` and `output`. Omit either to represent no value. -An empty event payload is an object instead: +Each method can define an `input` schema for its argument and an `output` schema +for its return value. Leave either one out if the method does not accept or +return a value. ```ts -events: { - refreshed: { schema: z.object({}) }, +search: { + input: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + output: { + type: "object", + properties: { text: { type: "string" } }, + required: ["text"], + }, } ``` -Event schemas must produce JSON objects. Scalars, arrays, `null`, and `undefined` -are invalid event data. Plain JSON Schema events are checked when emitted even -though their payload type remains `unknown`. +JSON Schema values are `unknown` in TypeScript, so narrow them before use. +Standard Schema infers the input and output types. -## Declare errors +### Errors Add an `errors` map to a method for expected failures. Each key becomes the -literal error `type`, and its schema validates and transforms the error `data`. +error's `type`, and its schema defines the error's `data`. ```ts errors: { - not_found: z.object({ query: z.string() }), + not_found: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + additionalProperties: false, + }, } ``` -Names beginning with `rpc.` are reserved for framework failures. Contract schemas -own parsing, transformations, and Effect encoding; RPC does not perform another -generic JSON validation pass. +Error names beginning with `rpc.` are reserved by OpenCode. -## Register the implementation +### Events + +Add events to the top-level `events` map. Each event has a schema for the data +sent to subscribers. + +Event data must be an object. Use an empty object schema when there is no data: + +```ts +events: { + refreshed: { + schema: { type: "object", additionalProperties: false }, + }, +} +``` + +Scalars, arrays, `null`, and `undefined` are not valid event data. + +## Implement Register the implementation inside `setup`: @@ -86,42 +136,70 @@ export default Plugin.define({ id: "acme-plugin", async setup(ctx) { const registration = await ctx.rpc.register(Acme, { - search: async ({ query }, context) => { + search: async (input, context) => { + const { query } = input as { query: string } const text = await findText(query, { signal: context.signal }) if (!text) return context.error("not_found", "Result not found", { query }) return { text } }, }) + const acme = ctx.rpc(Acme) + const result = await acme.search({ query: "hello" }) + await registration.events.emit("updated", { itemID: "item-1", text: "ready" }) }, }) ``` -Promise handlers receive `signal` and a typed `error(type, message, data)` -constructor in their second argument. Return or throw a constructed error to -reject callers with `{ type, message, data? }`. +After registering the RPC, the same plugin can call it through `ctx.rpc(Acme)`. -Registration follows these rules: +The second argument includes `signal` for cancellation and `context.error(...)` +for declared errors. You can return or throw the error. -- RPC IDs are independent of plugin IDs, and one plugin can implement several RPCs. -- A later registration overrides an earlier registration at the same location. -- Disposal or plugin unload removes only that registration and reveals the previous one. -- In-flight calls retain the handler with which they started. +One plugin can register more than one RPC. Disposing the registration removes it. -## Call from another plugin +## Call -Other server plugins can obtain a handle without implementing the RPC: +Once the RPC is registered, it can be called over HTTP or from another plugin. + +### HTTP + +Create an OpenCode client, then use `client.rpc` to create a subclient for the +RPC: ```ts -const acme = ctx.rpc(Acme) +import { OpenCode } from "@opencode-ai/client" +import { Acme } from "opencode-acme-plugin/rpc" + +const client = OpenCode.make({ + baseUrl: "http://localhost:4096", +}) + +const acme = client.rpc(Acme) const result = await acme.search({ query: "hello" }) ``` -The handle is available immediately, and each call finds the current registration. -Calls stay within the server plugin's location and cannot override it. +### Plugin -## Subscribe to events +Plugins already have an OpenCode client. For example, a TUI plugin can create +the same RPC subclient from `context.client`: + +```ts title="src/tui.ts" +import { Plugin } from "@opencode-ai/plugin/tui" +import { Acme } from "opencode-acme-plugin/rpc" + +export default Plugin.define({ + id: "acme-tui", + async setup(context) { + const acme = context.client.rpc(Acme) + const result = await acme.search({ query: "hello" }) + console.log(result) + }, +}) +``` + +### Subscribe Use `events.on` for a callback and unsubscribe when the listener is no longer needed: @@ -142,20 +220,12 @@ for await (const event of acme.events.subscribe("updated")) { } ``` -Event subscriptions have these semantics: +Event subscriptions are live only, so disconnected subscribers miss events. -- Event keys use local names such as `updated` when subscribing. -- Delivered types are prefixed, such as `rpc.acme.updated`. -- Each event includes `id`, `created`, direct `data`, required `location`, and optional `metadata`. -- Events use the normal ephemeral Bus path and are live-only; disconnected subscribers miss events. -- Plugin unload closes its subscriptions. There is no plugin event log or replay API yet. -- The method name `events` is reserved for the subclient event API. +- Subscribe with the local name, such as `updated`. +- The received type is prefixed, such as `rpc.acme.updated`. +- Each event includes `data` and `location`. +- Plugin unload closes its subscriptions. -## Connect external clients - -External [clients](/build/client#plugin-rpc) use `client.rpc(Acme)` and receive -that RPC's events across all locations. The native `/api/event` stream and -typed subclients observe the same direct `rpc..` envelope. - -Importing the contract or constructing a handle does not load its server -implementation. Configure the plugin on the server separately. +External clients receive events from every location, so check `event.location` +when needed. The server plugin still needs to be configured and running. diff --git a/packages/www/src/docs/content/build/sdk/index.mdx b/packages/www/src/docs/content/build/sdk/index.mdx index 13b2d6beb82..6b39ecebcc6 100644 --- a/packages/www/src/docs/content/build/sdk/index.mdx +++ b/packages/www/src/docs/content/build/sdk/index.mdx @@ -7,6 +7,8 @@ title: "Overview" calls through its HTTP router in memory. It opens no HTTP listener and adds no network hop between the client and server. +For Cloudflare Durable Objects, see the [Cloudflare guide](/build/sdk/cloudflare). + The V2 SDK is beta. Install the current preview with `bun add @opencode-ai/sdk@dev`; its API may change before a stable release. diff --git a/packages/www/src/docs/lib/llms.ts b/packages/www/src/docs/lib/llms.ts new file mode 100644 index 00000000000..6d63e883439 --- /dev/null +++ b/packages/www/src/docs/lib/llms.ts @@ -0,0 +1,25 @@ +import { docsSections } from "./navigation" + +export function renderLlmsTxt(site: URL) { + const base = new URL(import.meta.env.BASE_URL, site) + const sections = docsSections.flatMap((section) => [ + `## ${section.title}`, + "", + ...section.groups.flatMap((group) => [ + ...(group.title && group.title !== section.title ? [`### ${group.title}`, ""] : []), + ...group.items.map((item) => { + const path = item.slug === "index" ? "" : `${item.slug.replace(/\/index$/, "")}/` + return `- [${item.title}](${new URL(`docs/${path}`, base)})` + }), + "", + ]), + ]) + + return [ + "# OpenCode V2 Documentation", + "", + "> Official documentation for using, configuring, and building with OpenCode V2.", + "", + ...sections, + ].join("\n") +} diff --git a/packages/www/src/docs/lib/navigation.ts b/packages/www/src/docs/lib/navigation.ts index b91704f1d34..7ccdfec9a76 100644 --- a/packages/www/src/docs/lib/navigation.ts +++ b/packages/www/src/docs/lib/navigation.ts @@ -95,25 +95,29 @@ export const docsSections: DocsSection[] = [ items: [ { title: "Overview", slug: "build/plugins" }, { title: "RPC", slug: "build/plugins/rpc" }, - { title: "Effect", slug: "build/plugins/effect" }, { title: "CLI", slug: "build/plugins/cli" }, ], }, { title: "Client", - items: [ - { title: "JavaScript", slug: "build/client" }, - { title: "Effect", slug: "build/client/effect" }, - ], + items: [{ title: "JavaScript", slug: "build/client" }], }, { title: "SDK", items: [ { title: "Overview", slug: "build/sdk" }, - { title: "Effect", slug: "build/sdk/effect" }, { title: "Cloudflare", slug: "build/sdk/cloudflare" }, ], }, + { + title: "Effect", + items: [ + { title: "Plugins", slug: "build/plugins/effect" }, + { title: "RPC", slug: "build/plugins/effect/rpc" }, + { title: "Client", slug: "build/client/effect" }, + { title: "SDK", slug: "build/sdk/effect" }, + ], + }, ], }, { diff --git a/packages/www/src/pages/llms.txt.ts b/packages/www/src/pages/llms.txt.ts new file mode 100644 index 00000000000..b16bc5c77a2 --- /dev/null +++ b/packages/www/src/pages/llms.txt.ts @@ -0,0 +1,9 @@ +import type { APIRoute } from "astro" +import { renderLlmsTxt } from "../docs/lib/llms" + +export const prerender = true + +export const GET: APIRoute = ({ site }) => + new Response(renderLlmsTxt(site ?? new URL("https://opencode.ai")), { + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }) From fcce2d7cc9cbd24a6bc37f41ba49be06a813e096 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:44:00 -0400 Subject: [PATCH 2/5] test(tui): await dialog text selection (#46143) Co-authored-by: kitlangton <7587245+kitlangton@users.noreply.github.com> --- packages/tui/test/cli/tui/dialog-select.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tui/test/cli/tui/dialog-select.test.tsx b/packages/tui/test/cli/tui/dialog-select.test.tsx index aa7f54ea681..0bc55037fd8 100644 --- a/packages/tui/test/cli/tui/dialog-select.test.tsx +++ b/packages/tui/test/cli/tui/dialog-select.test.tsx @@ -192,7 +192,7 @@ test("ctrl+c clears a dialog text selection before closing the dialog", async () const column = frame[row]!.indexOf("Alpha") + 1 await select.app.mockMouse.click(column, row) await select.app.mockMouse.click(column, row) - expect(select.app.renderer.getSelection()?.getSelectedText()).toBe("Alpha") + await select.app.waitFor(() => select.app.renderer.getSelection()?.getSelectedText() === "Alpha") select.app.mockInput.pressKey("c", { ctrl: true }) await select.app.waitFor(() => !select.app.renderer.getSelection()) From 197d28e033182b7042b68aec37922bc5f643a0c0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 31 Aug 2026 13:45:55 -0400 Subject: [PATCH 3/5] fix(tui): pin sidebar headings without scrollbar flashes (#46449) Keep the title and workspace label above scrollable sidebar details. Disable the unused horizontal scrollbar and place the automatic vertical scrollbar in the reserved gutter so tab changes do not flash or shift the sidebar. --- packages/tui/src/routes/session/sidebar.tsx | 57 +++++++++++---------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx index c1434b1fffd..533ef1dc5f1 100644 --- a/packages/tui/src/routes/session/sidebar.tsx +++ b/packages/tui/src/routes/session/sidebar.tsx @@ -29,16 +29,40 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { paddingRight={2} position={props.overlay ? "absolute" : "relative"} > + + + {withTimestampedFallback(session())} + + + {session().location.workspaceID} + + - queueMicrotask(() => { - if (!scroll.isDestroyed) scroll.verticalScrollBar.resetVisibilityControl() - }) - } flexGrow={1} + minHeight={0} scrollAcceleration={scrollAcceleration()} + // The sidebar only scrolls vertically; a horizontal bar steals a row during initial layout. + horizontalScrollbarOptions={{ visible: false }} verticalScrollbarOptions={{ - visible: false, + // Use the content's reserved right padding instead of changing its width when the bar toggles. + position: "absolute", + right: 0, + top: 0, + width: 1, + height: "100%", trackOptions: { backgroundColor: theme.background.default, foregroundColor: theme.scrollbar.default, @@ -46,27 +70,6 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { }} > - - - {withTimestampedFallback(session())} - - - {session().location.workspaceID} - - From 36ac35a7c86116733caac53e0c0134ca910e9222 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 31 Aug 2026 13:46:27 -0400 Subject: [PATCH 4/5] refactor(util): make layer graphs opaque and composable Replace exposed layer graph assembly with opaque declarations, checked substitutions, and lifetime-aware compilation. Preserve deep replacement, ordered startup, and Effect-owned resource lifetimes; migrate callers and verify source and published package contracts. --- packages/cli/src/index.ts | 11 +- packages/cli/src/server-process.ts | 11 +- packages/core/src/effect/app-node-builder.ts | 17 +- packages/core/src/instance.ts | 25 +- packages/core/src/persistent-pty/index.ts | 12 +- packages/core/test/agent.test.ts | 4 +- .../core/test/bus-session-routing.test.ts | 2 +- packages/core/test/bus.test.ts | 20 +- packages/core/test/catalog.test.ts | 8 +- packages/core/test/codemode.test.ts | 2 +- .../core/test/codemode/instructions.test.ts | 2 +- packages/core/test/config/command.test.ts | 21 +- packages/core/test/config/compaction.test.ts | 7 +- packages/core/test/config/config.test.ts | 23 +- packages/core/test/config/plugin.test.ts | 11 +- packages/core/test/config/skill.test.ts | 13 +- packages/core/test/config/snapshot.test.ts | 4 +- packages/core/test/config/tool-output.test.ts | 4 +- .../layer-node/layer-node-types.test.ts | 319 ++++++--- .../test/effect/layer-node/layer-node.test.ts | 671 ++++++++++++------ .../test/effect/layer-node/node-build.test.ts | 73 +- packages/core/test/file-mutation.test.ts | 4 +- packages/core/test/filesystem/search.test.ts | 34 +- packages/core/test/filesystem/watcher.test.ts | 14 +- .../core/test/fixture/effect-flock-worker.ts | 2 +- packages/core/test/fixture/prompt-location.ts | 6 +- packages/core/test/formatter.test.ts | 2 +- packages/core/test/instance-plugins.test.ts | 6 +- packages/core/test/instance-vanilla.test.ts | 11 +- .../core/test/instruction-discovery.test.ts | 13 +- packages/core/test/instruction-state.test.ts | 2 +- .../core/test/instructions/builtins.test.ts | 4 +- packages/core/test/integration.test.ts | 2 +- .../core/test/location-filesystem.test.ts | 15 +- packages/core/test/location-layer.test.ts | 6 +- packages/core/test/location-mutation.test.ts | 21 +- packages/core/test/location.test.ts | 2 +- packages/core/test/mcp-instructions.test.ts | 5 +- packages/core/test/mcp.test.ts | 26 +- packages/core/test/models.test.ts | 12 +- packages/core/test/npm.test.ts | 2 +- packages/core/test/permission.test.ts | 2 +- packages/core/test/plugin-hooks.test.ts | 6 +- packages/core/test/plugin/command.test.ts | 4 +- packages/core/test/plugin/fixture.ts | 18 +- packages/core/test/plugin/host.test.ts | 10 +- packages/core/test/plugin/models-dev.test.ts | 4 +- packages/core/test/plugin/variant.test.ts | 2 +- .../core/test/plugin/websearch-fixture.ts | 2 +- packages/core/test/pty/pty-session.test.ts | 6 +- packages/core/test/pty/ticket.test.ts | 4 +- .../core/test/reference-instructions.test.ts | 2 +- packages/core/test/reference.test.ts | 2 +- packages/core/test/repository-cache.test.ts | 4 +- packages/core/test/ripgrep.test.ts | 2 +- packages/core/test/rpc.test.ts | 16 +- packages/core/test/session-compact.test.ts | 6 +- packages/core/test/session-compaction.test.ts | 5 +- packages/core/test/session-create.test.ts | 23 +- packages/core/test/session-generate.test.ts | 22 +- .../core/test/session-instructions.test.ts | 14 +- packages/core/test/session-log.test.ts | 6 +- .../core/test/session-message-update.test.ts | 13 +- .../core/test/session-model-request.test.ts | 10 +- packages/core/test/session-move.test.ts | 22 +- packages/core/test/session-owned.test.ts | 7 +- packages/core/test/session-projector.test.ts | 4 +- .../core/test/session-prompt-hooks.test.ts | 10 +- packages/core/test/session-prompt.test.ts | 23 +- packages/core/test/session-remove.test.ts | 6 +- packages/core/test/session-revert.test.ts | 6 +- .../core/test/session-runner-recorded.test.ts | 62 +- .../test/session-runner-tool-events.test.ts | 2 +- packages/core/test/session-runner.test.ts | 34 +- packages/core/test/session-shell.test.ts | 4 +- packages/core/test/session-skill.test.ts | 6 +- packages/core/test/session-step.test.ts | 2 +- packages/core/test/session-store.test.ts | 2 +- packages/core/test/session-title.test.ts | 10 +- .../core/test/session-tool-progress.test.ts | 2 +- packages/core/test/session-view.test.ts | 10 +- packages/core/test/session-wait.test.ts | 5 +- packages/core/test/shell-cleanup.test.ts | 2 +- packages/core/test/skill-discovery.test.ts | 2 +- packages/core/test/skill/instructions.test.ts | 2 +- packages/core/test/snapshot.test.ts | 10 +- packages/core/test/tool-edit.test.ts | 11 +- packages/core/test/tool-output.test.ts | 2 +- packages/core/test/tool-patch.test.ts | 11 +- packages/core/test/tool-question.test.ts | 6 +- packages/core/test/tool-read.test.ts | 16 +- packages/core/test/tool-registry.test.ts | 2 +- packages/core/test/tool-search.test.ts | 10 +- packages/core/test/tool-shell.test.ts | 16 +- packages/core/test/tool-skill.test.ts | 6 +- packages/core/test/tool-subagent.test.ts | 19 +- packages/core/test/tool-webfetch.test.ts | 6 +- packages/core/test/tool-websearch.test.ts | 14 +- packages/core/test/tool-write.test.ts | 11 +- packages/core/test/util/effect-flock.test.ts | 2 +- packages/core/test/vcs-hg.test.ts | 21 +- packages/core/test/vcs.test.ts | 31 +- packages/core/test/workspace.test.ts | 2 +- packages/sdk/src/internal/host.ts | 2 +- packages/sdk/test/embedded.test.ts | 29 +- packages/server/src/routes.ts | 34 +- packages/server/src/workerd.ts | 16 +- packages/server/test/fetch.test.ts | 2 +- packages/server/test/generate.test.ts | 2 +- .../test/session-message-update.test.ts | 9 +- packages/server/test/vcs.test.ts | 5 +- packages/simulation/src/backend/index.ts | 62 +- .../test/simulated-provider.test.ts | 2 +- packages/util/docs/layer-node.md | 155 ++++ packages/util/package.json | 3 +- packages/util/src/effect/app-node.ts | 4 +- packages/util/src/effect/layer-node.ts | 535 +++++++------- packages/util/tsconfig.dist.json | 10 + 118 files changed, 1689 insertions(+), 1232 deletions(-) create mode 100644 packages/util/docs/layer-node.md create mode 100644 packages/util/tsconfig.dist.json diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index bbc4595b077..a24f56386db 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -98,12 +98,13 @@ Effect.gen(function* () { Effect.provide(Config.layer), Effect.provide(Updater.layer), Effect.provide( - LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), [ - [ - Global.node, - Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}), + LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), { + replacements: [ + Global.node.replace( + Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}), + ), ], - ]), + }), ), Effect.provide( Observability.layer({ diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index c81e4b44b0d..303046e1ec3 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -30,12 +30,13 @@ export const run = Effect.fnUntraced(function* (options: Options) { return yield* processEffect(options).pipe( Effect.provide(Updater.layer), Effect.provide( - LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), [ - [ - Global.node, - Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}), + LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), { + replacements: [ + Global.node.replace( + Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}), + ), ], - ]), + }), ), Effect.provide(NodeServices.layer), ) diff --git a/packages/core/src/effect/app-node-builder.ts b/packages/core/src/effect/app-node-builder.ts index b41e1f56d26..167131636e6 100644 --- a/packages/core/src/effect/app-node-builder.ts +++ b/packages/core/src/effect/app-node-builder.ts @@ -1,20 +1,11 @@ import { buildLocationServiceMap } from "../location-services.js" import { LocationServiceMap } from "../location-service-map.js" import { LayerNode } from "@opencode-ai/util/effect/layer-node" -import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" -export function build(root: LayerNode.Node, replacements: LayerNode.Replacements = []) { - // Only build the location service map if it's actually needed - if (!LayerNode.hasUnbound(root, LocationServiceMap.node) || hasReplacement(replacements, LocationServiceMap.node)) - return LayerNode.compile(root, replacements) - - const locationMap = buildLocationServiceMap(replacements) - const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] }) - return LayerNode.compile(root, replacements.concat([[LocationServiceMap.node, locationMapNode]])) -} - -function hasReplacement(replacements: LayerNode.Replacements, node: LayerNode.Node) { - return replacements.some(([source]) => source.name === node.name) +export function build(root: LayerNode.Graph, replacements: LayerNode.Replacements = []) { + return LayerNode.compile(root, { + replacements: [LocationServiceMap.node.replace(buildLocationServiceMap(replacements)), ...replacements], + }) } export * as AppNodeBuilder from "./app-node-builder.js" diff --git a/packages/core/src/instance.ts b/packages/core/src/instance.ts index dd553884c3b..98f6ebcfbea 100644 --- a/packages/core/src/instance.ts +++ b/packages/core/src/instance.ts @@ -110,9 +110,9 @@ const nodes = [ Vcs.node, // Start repository watches only after boot-critical filesystem and Git work. LocationWatcher.node, -] as const satisfies readonly Node.LocationNode[] +] as const satisfies readonly Node.LocationGraph[] -export const graph = LayerNode.group(nodes) +export const graph = LayerNode.group(nodes) export type Services = LayerNode.Output export type Error = LayerNode.Error @@ -141,29 +141,23 @@ export interface Options { // source still honors explicit plugin operations from wellknown and // host-injected config. const vanillaReplacements: LayerNode.Replacements = [ - [Config.node, Config.configured({ project: false, global: false })], - [InstructionDiscovery.node, InstructionDiscovery.configured({ project: false, global: false })], + Config.node.replace(Config.configured({ project: false, global: false })), + InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: false, global: false })), ] // One instance is one compiled, fresh copy of the graph standing on a directory. export function layer(ref: Location.Ref, options: Options = {}) { const startedAt = performance.now() // Ordered: vanilla defaults, then caller replacements (which win over the - // defaults), then bound pairs (which win over everything). - const allReplacements: LayerNode.Replacements = [ + // defaults), then instance bindings (which win over everything). + const replacements: LayerNode.Replacements = [ ...(options.discovery === false ? vanillaReplacements : []), ...(options.replacements ?? []), - [Location.node, Location.boundNode(ref, { discovery: options.discovery })], - [InstancePlugins.node, InstancePlugins.bound(options.plugins ?? [])], + Location.node.replace(Location.boundNode(ref, { discovery: options.discovery })), + InstancePlugins.node.replace(InstancePlugins.bound(options.plugins ?? [])), ] - // Apply replacements during hoist, not afterward: replacements can - // introduce new tagged dependencies (Location.boundNode depends on - // Project), and the hoist walk is the only pass that can still slice - // those back out. - const location = LayerNode.hoist(graph, Node.tags.values.global, allReplacements) - return LayerNode.compile(location.node).pipe( - Layer.fresh, + return LayerNode.compile(graph, { replacements, shared: Node.tags.values.global }).pipe( Layer.tap(() => Effect.logInfo("location services booted", { directory: ref.directory, @@ -171,6 +165,5 @@ export function layer(ref: Location.Ref, options: Options = {}) { durationMs: Math.round(performance.now() - startedAt), }), ), - Layer.provide(LayerNode.compile(location.hoisted)), ) } diff --git a/packages/core/src/persistent-pty/index.ts b/packages/core/src/persistent-pty/index.ts index 0f316b69cb7..b898c66f76b 100644 --- a/packages/core/src/persistent-pty/index.ts +++ b/packages/core/src/persistent-pty/index.ts @@ -112,7 +112,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/PersistentPty") {} -export const configured = (options: Options = {}) => +const makeLayer = (options: Options = {}) => Layer.effect( Service, Effect.gen(function* () { @@ -361,8 +361,14 @@ export const configured = (options: Options = {}) => }), ) -export const layer = configured() -export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Global.node] }) +export const layer = makeLayer() +export const configured = (options?: Options) => + makeGlobalNode({ + service: Service, + layer: options === undefined ? layer : makeLayer(options), + deps: [Bus.node, Global.node], + }) +export const node = configured() const request = (daemon: DaemonTransport, value: object, start = false) => daemon.request(value, start).pipe(Effect.mapError(unavailable)) diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index 2f287602116..1f18cbdc72a 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -22,8 +22,8 @@ const globalLayer = Layer.succeed(Global.Service, Global.Service.of(global)) const it = testEffect( AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, Location.node]), [ - [Global.node, globalLayer], - [Location.node, locationLayer], + Global.node.replace(globalLayer), + Location.node.replace(locationLayer), ]) as unknown as Layer.Layer, ) diff --git a/packages/core/test/bus-session-routing.test.ts b/packages/core/test/bus-session-routing.test.ts index 891cf2753c0..c3cf7b78c0e 100644 --- a/packages/core/test/bus-session-routing.test.ts +++ b/packages/core/test/bus-session-routing.test.ts @@ -21,7 +21,7 @@ import { testEffect } from "./lib/effect" const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [ - [Bus.node, Bus.configured({ persist: true })], + Bus.node.replace(Bus.configured({ persist: true })), ]), ) const a = Location.Ref.make({ directory: AbsolutePath.make("/a") }) diff --git a/packages/core/test/bus.test.ts b/packages/core/test/bus.test.ts index 4820b53887c..26f638986c4 100644 --- a/packages/core/test/bus.test.ts +++ b/packages/core/test/bus.test.ts @@ -100,12 +100,14 @@ const tail = (bus: Bus.Interface, input: { aggregateID: string; after?: number } const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, Location.node]), [ - [Location.node, locationLayer], - [Bus.node, Bus.configured({ persist: true })], + Location.node.replace(locationLayer), + Bus.node.replace(Bus.configured({ persist: true })), ]), ) const itWithoutLocation = testEffect( - AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [[Bus.node, Bus.configured({ persist: true })]]), + AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [ + Bus.node.replace(Bus.configured({ persist: true })), + ]), ) const itWithoutPersistence = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]))) @@ -631,8 +633,7 @@ describe("Bus", () => { const continueRead = yield* Deferred.make() let pause = true const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [ - [ - Bus.node, + Bus.node.replace( Bus.configured({ persist: true, beforeAggregateRead: () => @@ -640,7 +641,7 @@ describe("Bus", () => { ? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead))) : Effect.void, }), - ], + ), ]) yield* Effect.gen(function* () { @@ -1318,7 +1319,7 @@ describe("Bus", () => { it.effect("log replays across configured read pages", () => Effect.gen(function* () { const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [ - [Bus.node, Bus.configured({ persist: true, logReadPageSize: 2 })], + Bus.node.replace(Bus.configured({ persist: true, logReadPageSize: 2 })), ]) yield* Effect.gen(function* () { @@ -1351,8 +1352,7 @@ describe("Bus", () => { const releaseRead = yield* Deferred.make() const firstRead = yield* Ref.make(true) const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [ - [ - Bus.node, + Bus.node.replace( Bus.configured({ persist: true, beforeAggregateRead: () => @@ -1363,7 +1363,7 @@ describe("Bus", () => { }), ), }), - ], + ), ]) yield* Effect.gen(function* () { diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index ab670be22c9..ff6edcc4ac6 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -25,7 +25,7 @@ const locationLayer = Layer.succeed( ) const catalogLayer = AppNodeBuilder.build( LayerNode.group([Catalog.node, Bus.node, Credential.node, Integration.node]), - [[Location.node, locationLayer]], + [Location.node.replace(locationLayer)], ) const it = testEffect(catalogLayer) @@ -48,7 +48,7 @@ describe("Catalog", () => { it.effect("derives availability from active credentials without changing provider state", () => { const integrationID = Integration.ID.make("test") const localCatalogLayer = Layer.fresh( - AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node]), [[Location.node, locationLayer]]), + AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node]), [Location.node.replace(locationLayer)]), ) return Effect.gen(function* () { @@ -78,7 +78,7 @@ describe("Catalog", () => { const providerID = Provider.ID.make("remote") const localCatalogLayer = Layer.fresh( AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [ - [Location.node, locationLayer], + Location.node.replace(locationLayer), ]), ) @@ -108,7 +108,7 @@ describe("Catalog", () => { const providerID = Provider.ID.make("remote") const localCatalogLayer = Layer.fresh( AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [ - [Location.node, locationLayer], + Location.node.replace(locationLayer), ]), ) diff --git a/packages/core/test/codemode.test.ts b/packages/core/test/codemode.test.ts index 6525f17af1f..9a6d9fd9568 100644 --- a/packages/core/test/codemode.test.ts +++ b/packages/core/test/codemode.test.ts @@ -35,7 +35,7 @@ describe("CodeMode", () => { Effect.scoped, Effect.provide( AppNodeBuilder.build(Tool.node, [ - [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })), ]), ), ), diff --git a/packages/core/test/codemode/instructions.test.ts b/packages/core/test/codemode/instructions.test.ts index 29f1250223e..5d9e72150cc 100644 --- a/packages/core/test/codemode/instructions.test.ts +++ b/packages/core/test/codemode/instructions.test.ts @@ -85,7 +85,7 @@ describe("CodeModeInstructions", () => { execute: () => Effect.succeed({ output: "zeta" }), } const layer = AppNodeBuilder.build(Tool.node, [ - [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })), ]) return Effect.gen(function* () { diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index c2e9db41676..8bc526b45b8 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -43,10 +43,10 @@ const it = testEffect( AppNodeBuilder.build( LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, ShellSelect.node]), [ - [Mcp.node, emptyMcpLayer], - [Config.node, emptyConfigLayer], - [Location.node, testLocationLayer], - [ShellSelect.node, shellLayer], + Mcp.node.replace(emptyMcpLayer), + Config.node.replace(emptyConfigLayer), + Location.node.replace(testLocationLayer), + ShellSelect.node.replace(shellLayer), ], ), ) @@ -340,17 +340,16 @@ describeNative("ConfigCommandPlugin native watcher", () => { ShellSelect.node, ]), [ - [ - Location.node, + Location.node.replace( Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(path.join(tmp, "project")) })), ), - ], - [Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })], - [ShellSelect.node, shellLayer], - [Credential.node, emptyCredentialNode], - [WellKnown.node, emptyWellknownNode], + ), + Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })), + ShellSelect.node.replace(shellLayer), + Credential.node.replace(emptyCredentialNode), + WellKnown.node.replace(emptyWellknownNode), ], ), ), diff --git a/packages/core/test/config/compaction.test.ts b/packages/core/test/config/compaction.test.ts index 938ee7fe1a3..744c8cce88b 100644 --- a/packages/core/test/config/compaction.test.ts +++ b/packages/core/test/config/compaction.test.ts @@ -40,13 +40,12 @@ const it = testEffect( Layer.merge( config, AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, SessionModelRequest.node, Config.node, Bus.node]), [ - [ - llmClient, + llmClient.replace( Layer.mock(LLMClient.Service)({ stream: () => Stream.make(LLMEvent.textDelta({ id: "summary", text: "summary" })), }), - ], - [Config.node, config], + ), + Config.node.replace(config), ]), ), ) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 67344a94247..ff64d034fe4 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -55,12 +55,12 @@ function testLayer( ), ) const built = AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [ - [Config.node, Config.configured(options)], - [Location.node, locationLayer], - [Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })], - [Credential.node, credentialNode], - [WellKnown.node, wellknownNode], - [Watcher.node, watcher], + Config.node.replace(Config.configured(options)), + Location.node.replace(locationLayer), + Global.node.replace(Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })), + Credential.node.replace(credentialNode), + WellKnown.node.replace(wellknownNode), + Watcher.node.replace(watcher), ]) // Merge the watcher layer by reference so Watcher.Test resolves to the same // memoized instance the built graph uses. @@ -311,16 +311,15 @@ describe("Config", () => { }).pipe( Effect.provide( AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [ - [ - Location.node, + Location.node.replace( Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(project) })), ), - ], - [Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })], - [Credential.node, emptyCredentialNode], - [WellKnown.node, emptyWellknownNode], + ), + Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })), + Credential.node.replace(emptyCredentialNode), + WellKnown.node.replace(emptyWellknownNode), ]), ), ) diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index 2cf7a984799..6ed5ac8dfef 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -28,13 +28,13 @@ import { testEffect } from "../lib/effect" const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [ - [Global.node, tempGlobalLayer], + Global.node.replace(tempGlobalLayer), ]), ) const staticIt = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [ - [ConfigPluginSource.node, ConfigPluginSource.empty], - [Global.node, tempGlobalLayer], + ConfigPluginSource.node.replace(ConfigPluginSource.empty), + Global.node.replace(tempGlobalLayer), ]), ) const refreshNpm = makeGlobalNode({ @@ -65,10 +65,7 @@ const refreshNpm = makeGlobalNode({ const refreshIt = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]), - [ - [Global.node, tempGlobalLayer], - [Npm.node, refreshNpm], - ], + [Global.node.replace(tempGlobalLayer), Npm.node.replace(refreshNpm)], ), ) diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index e59eae8122c..4213e7d5894 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -86,14 +86,13 @@ const discover = (directory: string, global: string) => }).pipe( Effect.provide( AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [ - [ - Location.node, + Location.node.replace( Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), - ], - [Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })], - [Credential.node, emptyCredentialNode], - [WellKnown.node, emptyWellknownNode], - [Watcher.node, Watcher.testLayer], + ), + Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })), + Credential.node.replace(emptyCredentialNode), + WellKnown.node.replace(emptyWellknownNode), + Watcher.node.replace(Watcher.testLayer), ]), ), ) diff --git a/packages/core/test/config/snapshot.test.ts b/packages/core/test/config/snapshot.test.ts index bd54b29fed0..42e7489afa3 100644 --- a/packages/core/test/config/snapshot.test.ts +++ b/packages/core/test/config/snapshot.test.ts @@ -51,8 +51,8 @@ describe("ConfigSnapshotPlugin.Plugin", () => { }).pipe( Effect.provide( AppNodeBuilder.build(Snapshot.node, [ - [Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))], - [Global.node, Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })], + Location.node.replace(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))), + Global.node.replace(Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })), ]), ), ) diff --git a/packages/core/test/config/tool-output.test.ts b/packages/core/test/config/tool-output.test.ts index b6920a97766..a8cb7544130 100644 --- a/packages/core/test/config/tool-output.test.ts +++ b/packages/core/test/config/tool-output.test.ts @@ -44,7 +44,9 @@ describe("ConfigToolOutputPlugin.Plugin", () => { } yield* Effect.die(new Error("Timed out waiting for tool output config reload")) }).pipe( - Effect.provide(AppNodeBuilder.build(ToolOutput.node, [[Global.node, Global.layerWith({ data: tmp.path })]])), + Effect.provide( + AppNodeBuilder.build(ToolOutput.node, [Global.node.replace(Global.layerWith({ data: tmp.path }))]), + ), ), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ).pipe( diff --git a/packages/core/test/effect/layer-node/layer-node-types.test.ts b/packages/core/test/effect/layer-node/layer-node-types.test.ts index 73f662eb316..d1208b28baa 100644 --- a/packages/core/test/effect/layer-node/layer-node-types.test.ts +++ b/packages/core/test/effect/layer-node/layer-node-types.test.ts @@ -13,131 +13,218 @@ class OtherError { readonly _tag = "OtherError" } -const tags = LayerNode.tags({ app: [] }) -const make = tags.make("app") -const build = (root: LayerNode.Node) => LayerNode.compile(root) as Layer.Layer -const aLayer = Layer.succeed(A, A.of({})) -const bLayer = Layer.effect(B, Effect.as(A, B.of({}))) -const cLayer = Layer.effect( - C, - Effect.gen(function* () { - yield* A - yield* B - return C.of({}) - }), -) -const failingA = Layer.effect(A, Effect.fail(new LayerError())) -const a = make({ service: A, layer: aLayer, deps: [] }) -const b = make({ service: B, layer: bLayer, deps: [a] }) -const c = make({ service: C, layer: cLayer, deps: [a, b] }) -const failing = make({ service: A, layer: failingA, deps: [] }) -const dependent = make({ service: B, layer: bLayer, deps: [failing] }) -const inputA = LayerNode.unbound(A, tags.values.app) -const inputDependent = make({ service: B, layer: bLayer, deps: [inputA] }) +// Keep intentionally invalid expressions out of runtime execution. +const contracts = (tag: LayerNode.Tag<"app"> | LayerNode.Tag<"other">, flag: boolean) => { + const tags = LayerNode.tags({ app: [] }) + const make = tags.make("app") + const aLayer = Layer.succeed(A, A.of({})) + const bLayer = Layer.effect(B, Effect.as(A, B.of({}))) + const cLayer = Layer.effect( + C, + Effect.gen(function* () { + yield* A + yield* B + return C.of({}) + }), + ) + const a = make({ service: A, layer: aLayer, deps: [] }) + const b = make({ service: B, layer: bLayer, deps: [a] }) + const c = make({ service: C, layer: cLayer, deps: [a, b] }) + const ab = make({ name: "a-and-b", layer: Layer.mergeAll(aLayer, Layer.succeed(B, {})), deps: [] }) + const failing = make({ service: A, layer: Layer.effect(A, Effect.fail(new LayerError())), deps: [] }) + const dependent = make({ service: B, layer: bLayer, deps: [failing] }) + const inputA = LayerNode.unbound(A, tags.values.app) + const group = LayerNode.group([a, b]) -make({ name: "manual-a", layer: aLayer, deps: [] }) + make({ name: "manual-a", layer: aLayer, deps: [] }) + // @ts-expect-error A node must have a service or name + make({ layer: aLayer, deps: [] }) + // @ts-expect-error Service and name are mutually exclusive + make({ service: A, name: "a", layer: aLayer, deps: [] }) + // @ts-expect-error An explicit tagged contract requires a corresponding runtime tag + LayerNode.make({ service: A, layer: aLayer, deps: [] }) + // @ts-expect-error B requires A + make({ service: B, layer: bLayer, deps: [] }) + // @ts-expect-error C requires A and B + make({ service: C, layer: cLayer, deps: [a] }) + const erasedLayer: Layer.Any = bLayer + // @ts-expect-error Erasing a Layer's contract cannot hide its inputs and errors + make({ service: B, layer: erasedLayer, deps: [] }) -// @ts-expect-error A node must have a service or name -make({ layer: aLayer, deps: [] }) + LayerNode.compile(c) satisfies Layer.Layer + LayerNode.compile(dependent) satisfies Layer.Layer + LayerNode.compile(group) satisfies Layer.Layer + LayerNode.compile(LayerNode.group([])) satisfies Layer.Layer + // @ts-expect-error An empty graph cannot supply arbitrary services + LayerNode.compile(LayerNode.group([])) satisfies Layer.Layer + LayerNode.compile(inputA, { replacements: [inputA.replace(a)] }) satisfies Layer.Layer + // @ts-expect-error A is a private dependency, not a root output + LayerNode.compile(c) satisfies Layer.Layer + // @ts-expect-error Dependency failures are not erased + LayerNode.compile(dependent) satisfies Layer.Layer -// @ts-expect-error Service and name are mutually exclusive -make({ service: A, name: "a", layer: aLayer, deps: [] }) - -// @ts-expect-error B requires A -make({ service: B, layer: bLayer, deps: [] }) - -// @ts-expect-error C requires A and B -make({ service: C, layer: cLayer, deps: [a] }) - -const closed = build(LayerNode.group([c])) -const closedWithError = build(LayerNode.group([dependent])) -const checkClosed: Layer.Layer = closed -const checkError: Layer.Layer = closedWithError -void checkClosed -void checkError - -LayerNode.compile(a, [[a, Layer.succeed(A, A.of({}))]]) -LayerNode.compile(a, [[a, make({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] })]]) - -// @ts-expect-error Replacement must provide A -LayerNode.compile(a, [[a, Layer.succeed(B, B.of({}))]]) - -// @ts-expect-error Node replacement must provide A -const invalidNodeReplacement = () => LayerNode.compile(a, [[a, b]]) -void invalidNodeReplacement - -// @ts-expect-error Replacement cannot introduce a new error -LayerNode.compile(a, [[a, Layer.effect(A, Effect.fail(new OtherError()))]]) - -const invalidNodeErrorReplacement = () => + const replacements: LayerNode.Replacements = [a.replace(aLayer), a.replace(ab), failing.replace(a)] + const replacement: LayerNode.Replacement = a.replace(Layer.mergeAll(aLayer, Layer.succeed(B, {}))) + LayerNode.compile(a, { replacements: [...replacements, replacement] }) + inputA.replace(a) + a.replace(a) + // @ts-expect-error Closed layer replacements must provide every source output + ab.replace(aLayer) + // @ts-expect-error Node replacements must provide every source output + ab.replace(a) + // @ts-expect-error Replacement must provide A + a.replace(Layer.succeed(B, {})) + // @ts-expect-error Node replacement must provide A + a.replace(b) + // @ts-expect-error Raw layers with inputs are not closed + a.replace(Layer.effect(A, Effect.as(B, A.of({})))) + // @ts-expect-error Replacement cannot introduce a new error + a.replace(Layer.effect(A, Effect.fail(new OtherError()))) // @ts-expect-error Node replacement cannot introduce a new error - LayerNode.compile(a, [[a, make({ service: A, layer: Layer.effect(A, Effect.fail(new OtherError())), deps: [] })]]) -void invalidNodeErrorReplacement + a.replace(failing) + // @ts-expect-error Existing errors do not authorize unrelated replacement errors + failing.replace(Layer.effect(A, Effect.fail(new OtherError()))) + // @ts-expect-error Every alternative of a node replacement must supply A + a.replace(flag ? a : b) + // @ts-expect-error Every alternative of a raw-layer replacement must supply A + a.replace(flag ? aLayer : Layer.succeed(B, {})) + // @ts-expect-error A valid alternative cannot hide a new error in another alternative + a.replace(flag ? a : failing) + a.replace(flag ? a : ab) + failing.replace(flag ? a : failing) + // @ts-expect-error Storing replacements must not erase their validation + const invalidStored: LayerNode.Replacements = [a.replace(b)] + // @ts-expect-error Raw tuples cannot be stored as opaque replacements + const rawStored: LayerNode.Replacements = [[a, aLayer]] + // @ts-expect-error Raw tuples cannot be supplied to compile + LayerNode.compile(a, { replacements: [[a, aLayer]] }) + // @ts-expect-error Replacements are not structurally forgeable + const forged: LayerNode.Replacement = { source: a, target: a } + // @ts-expect-error Groups are not replaceable nodes + group.replace(a) + // @ts-expect-error Groups cannot be replacement targets + a.replace(group) + // @ts-expect-error Groups cannot be widened to nodes + const groupNode: LayerNode.Node = group + // @ts-expect-error Graphs are opaque + const forgedGraph: LayerNode.Graph = { name: "a" } -class TagA extends Context.Service()("test/TagA") {} -class TagB extends Context.Service()("test/TagB") {} -class TagC extends Context.Service()("test/TagC") {} + const aContract: LayerNode.Node = a + aContract.replace(aLayer) + // @ts-expect-error A method cannot be rebound to a declaration with a stronger contract + a.replace.call(ab, aLayer) + const detached = a.replace + // @ts-expect-error Replacement authority requires its checked receiver + detached(aLayer) + // @ts-expect-error Output narrowing cannot forget B before replacement + const narrowedOutput: LayerNode.Node = ab + // @ts-expect-error Output widening cannot add B before replacement + const widenedOutput: LayerNode.Node = a + // @ts-expect-error Error widening cannot authorize a new replacement error + const widenedError: LayerNode.Node = a + // @ts-expect-error Error narrowing cannot forget an existing failure + const narrowedError: LayerNode.Node = failing + // @ts-expect-error Tag widening cannot authorize replacement across tags + const widenedTag: LayerNode.Node = a + const unionTag = LayerNode.unbound(A, tag) + // @ts-expect-error Tag narrowing cannot forget a possible tag + const narrowedTag: LayerNode.Node = unionTag -const scopedTags = LayerNode.tags({ request: ["global"], global: [] }) -const request = scopedTags.make("request") -const global = scopedTags.make("global") -const globalA = global({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] }) -const requestA = request({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] }) -const requestB = request({ service: TagB, layer: Layer.succeed(TagB, TagB.of({})), deps: [] }) -const tagBLayer = Layer.effect(TagB, Effect.as(TagA, TagB.of({}))) -const tagCLayer = Layer.effect( - TagC, - Effect.gen(function* () { - yield* TagA - yield* TagB - return TagC.of({}) - }), -) + const outputProjection: LayerNode.Graph = group + // @ts-expect-error Graph output projection cannot invent a service + const widenedGraph: LayerNode.Graph = a + // @ts-expect-error A projected Graph has no replacement authority + outputProjection.replace(aLayer) -request({ service: TagB, layer: tagBLayer, deps: [globalA] }) -request({ service: TagC, layer: tagCLayer, deps: [globalA, requestB] }) -request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA, requestB])] }) + const choice = flag ? a : b + // @ts-expect-error Choosing one dependency does not provide both services + make({ service: C, layer: cLayer, deps: [choice] }) + // @ts-expect-error A conditional root promises only outputs present in every alternative + LayerNode.compile(LayerNode.group([choice])) satisfies Layer.Layer + const conditional = make({ name: "conditional", layer: flag ? aLayer : Layer.succeed(B, {}), deps: [] }) + LayerNode.compile(conditional) satisfies Layer.Layer + // @ts-expect-error A conditional implementation does not acquire both branches + LayerNode.compile(conditional) satisfies Layer.Layer + LayerNode.compile(LayerNode.group([flag ? a : ab])) satisfies Layer.Layer + const dynamic: Array = [] + // @ts-expect-error An unbounded array may contain no roots + LayerNode.compile(LayerNode.group(dynamic)) satisfies Layer.Layer -// @ts-expect-error Tag configuration can only reference declared tags -LayerNode.tags({ request: ["missing"], global: [] }) + const decorated = b.mapLayer((layer) => layer.pipe(Layer.tap(() => Effect.void))) + LayerNode.compile(decorated) satisfies Layer.Layer + b.replace(decorated) + // @ts-expect-error A layer mapper cannot be rebound to a weaker declaration + ab.mapLayer.call(a, (layer) => layer) + // @ts-expect-error mapLayer cannot add an input requirement + b.mapLayer((layer) => layer.pipe(Layer.tap(() => C))) + // @ts-expect-error mapLayer cannot grow the error channel + b.mapLayer((layer) => layer.pipe(Layer.tap(() => Effect.fail(new OtherError())))) + // @ts-expect-error mapLayer cannot drop an output + ab.mapLayer(() => aLayer) + // @ts-expect-error Unbound declarations have no implementation to map + inputA.mapLayer((layer: Layer.Layer) => layer) -// @ts-expect-error An unrelated dependency cannot satisfy TagA -request({ service: TagB, layer: tagBLayer, deps: [requestB] }) + const scopedTags = LayerNode.tags({ request: ["global"], global: [] }) + const request = scopedTags.make("request") + const global = scopedTags.make("global") + const globalA = global({ service: A, layer: aLayer, deps: [] }) + const requestA = request({ service: A, layer: aLayer, deps: [] }) + const requestB = request({ service: B, layer: Layer.succeed(B, {}), deps: [] }) + request({ service: B, layer: bLayer, deps: [globalA] }) + request({ service: C, layer: cLayer, deps: [globalA, requestB] }) + request({ service: C, layer: cLayer, deps: [LayerNode.group([globalA, requestB])] }) + LayerNode.compile(LayerNode.group([globalA, requestB]), { shared: scopedTags.values.global }) satisfies Layer.Layer< + A | B + > + // @ts-expect-error Tag configuration can only reference declared tags + LayerNode.tags({ request: ["missing"], global: [] }) + // @ts-expect-error Shared tags must be branded + LayerNode.compile(globalA, { shared: "global" }) + // @ts-expect-error Replacement targets must keep the source tag + globalA.replace(requestA) + // @ts-expect-error Replacement targets must keep the source tag in either direction + requestA.replace(globalA) + // @ts-expect-error Every alternative must keep the source tag + globalA.replace(flag ? globalA : requestA) + // @ts-expect-error Providing only A leaves B missing + request({ service: C, layer: cLayer, deps: [globalA] }) + // @ts-expect-error Providing only B leaves A missing + request({ service: C, layer: cLayer, deps: [requestB] }) + // @ts-expect-error Duplicate A providers still leave B missing + request({ service: C, layer: cLayer, deps: [globalA, requestA] }) + // @ts-expect-error A group with only A still leaves B missing + request({ service: C, layer: cLayer, deps: [LayerNode.group([globalA])] }) + // @ts-expect-error Global cannot depend on request + global({ service: B, layer: bLayer, deps: [requestA] }) + // @ts-expect-error Groups preserve their child tags + global({ service: B, layer: bLayer, deps: [LayerNode.group([requestA])] }) -// @ts-expect-error Providing only TagA leaves TagB missing -request({ service: TagC, layer: tagCLayer, deps: [globalA] }) + const globalScopedA = makeGlobalNode({ service: A, layer: aLayer, deps: [] }) + const locationScopedA = makeLocationNode({ service: A, layer: aLayer, deps: [] }) + makeGlobalNode({ service: B, layer: bLayer, deps: [globalScopedA] }) + makeLocationNode({ service: B, layer: bLayer, deps: [globalScopedA] }) + makeLocationNode({ service: B, layer: bLayer, deps: [locationScopedA] }) + // @ts-expect-error Global nodes cannot depend on location nodes + makeGlobalNode({ service: B, layer: bLayer, deps: [locationScopedA] }) + // @ts-expect-error B requires A + makeLocationNode({ service: B, layer: bLayer, deps: [] }) -// @ts-expect-error Providing only TagB leaves TagA missing -request({ service: TagC, layer: tagCLayer, deps: [requestB] }) + void [ + invalidStored, + rawStored, + forged, + groupNode, + forgedGraph, + narrowedOutput, + widenedOutput, + widenedError, + narrowedError, + widenedTag, + narrowedTag, + widenedGraph, + ] +} -// @ts-expect-error Duplicate TagA providers still leave TagB missing -request({ service: TagC, layer: tagCLayer, deps: [globalA, requestA] }) - -// @ts-expect-error A group with only TagA still leaves TagB missing -request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA])] }) - -// @ts-expect-error Global cannot depend on request -global({ service: TagB, layer: tagBLayer, deps: [requestA] }) - -// @ts-expect-error Groups preserve their child tags -global({ service: TagB, layer: tagBLayer, deps: [LayerNode.group([requestA])] }) - -class ScopedA extends Context.Service()("test/ScopedA") {} -class ScopedB extends Context.Service()("test/ScopedB") {} - -const scopedA = Layer.succeed(ScopedA, ScopedA.of({})) -const scopedB = Layer.effect(ScopedB, Effect.as(ScopedA, ScopedB.of({}))) -const globalScopedA = makeGlobalNode({ service: ScopedA, layer: scopedA, deps: [] }) -const locationScopedA = makeLocationNode({ service: ScopedA, layer: scopedA, deps: [] }) - -makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] }) -makeLocationNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] }) -makeLocationNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] }) - -// @ts-expect-error Global nodes cannot depend on location nodes -makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] }) - -// @ts-expect-error ScopedB requires ScopedA -makeLocationNode({ service: ScopedB, layer: scopedB, deps: [] }) - -test("type exploration compiles", () => {}) +test("layer node type contracts compile", () => { + void contracts +}) diff --git a/packages/core/test/effect/layer-node/layer-node.test.ts b/packages/core/test/effect/layer-node/layer-node.test.ts index 5c36f20255b..ec530319048 100644 --- a/packages/core/test/effect/layer-node/layer-node.test.ts +++ b/packages/core/test/effect/layer-node/layer-node.test.ts @@ -1,19 +1,21 @@ import { describe, expect, test } from "bun:test" -import { Context, Effect, Layer } from "effect" +import { Context, Deferred, Duration, Effect, Fiber, Layer, LayerMap, Option } from "effect" import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { testEffect } from "../../lib/effect" class Value extends Context.Service()("test/LayerNodeValue") {} class Greeting extends Context.Service()("test/LayerNodeGreeting") {} class Left extends Context.Service()("test/LayerNodeLeft") {} class Right extends Context.Service()("test/LayerNodeRight") {} -class Database extends Context.Service()("test/GraphDatabase") {} -class Users extends Context.Service }>()("test/GraphUsers") {} -class App extends Context.Service }>()("test/GraphApp") {} +class Memo extends Context.Service()("test/LayerNodeMemo") {} +class Support extends Context.Service()("test/LayerNodeSupport") {} +class Locations extends Context.Service>()( + "test/LayerNodeLocations", +) {} +const it = testEffect(Layer.empty) const tags = LayerNode.tags({ app: [] }) const make = tags.make("app") -const build = (root: LayerNode.Node, replacements?: readonly LayerNode.Replacement[]) => - LayerNode.compile(root, replacements) as Layer.Layer const valueLayer = Layer.succeed(Value, Value.of({ value: "production" })) const greetingLayer = Layer.effect( Greeting, @@ -23,240 +25,443 @@ const value = make({ service: Value, layer: valueLayer, deps: [] }) const greeting = make({ service: Greeting, layer: greetingLayer, deps: [value] }) describe("layer node", () => { - test("builds an untagged graph", async () => { - const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] }) - const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] }) - const program = Effect.map(Greeting, (item) => item.value).pipe( - Effect.provide(LayerNode.compile(LayerNode.group([greeting]))), + it.effect("builds an untagged graph", () => + Effect.gen(function* () { + const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] }) + const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] }) + const result = yield* Greeting.pipe(Effect.provide(LayerNode.compile(LayerNode.group([greeting])))) + expect(result.value).toBe("hello production") + }), + ) + + it.effect("exposes roots but hides transitive dependencies", () => + Effect.gen(function* () { + const context = yield* Layer.build(LayerNode.compile(LayerNode.group([greeting]))) + expect(Context.get(context, Greeting).value).toBe("hello production") + expect(Option.isNone(Context.getOption(context, Value))).toBe(true) + }), + ) + + it.effect("replaces exact declarations, not sibling names or native layer identities", () => + Effect.gen(function* () { + const sibling = make({ service: Value, layer: valueLayer, deps: [] }) + const target = make({ name: "different-name", layer: Layer.succeed(Value, { value: "replaced" }), deps: [] }) + const left = make({ + service: Left, + layer: Layer.effect( + Left, + Effect.map(Value, (item) => Left.of({ value: item.value })), + ), + deps: [value], + }) + const right = make({ + service: Right, + layer: Layer.effect( + Right, + Effect.map(Value, (item) => Right.of({ value: item.value })), + ), + deps: [sibling], + }) + const context = yield* Layer.build( + LayerNode.compile(LayerNode.group([left, right]), { replacements: [value.replace(target)] }), + ) + expect(Context.get(context, Left).value).toBe("replaced") + expect(Context.get(context, Right).value).toBe("production") + }), + ) + + it.effect("requires reachable unbound nodes to be replaced", () => + Effect.gen(function* () { + const unbound = LayerNode.unbound(Value, tags.values.app) + const root = make({ service: Greeting, layer: greetingLayer, deps: [unbound] }) + expect(() => LayerNode.compile(root)).toThrow("Unbound layer node: test/LayerNodeValue") + const result = yield* Greeting.pipe( + Effect.provide(LayerNode.compile(root, { replacements: [unbound.replace(value)] })), + ) + expect(result.value).toBe("hello production") + }), + ) + + it.effect("replaces every use of a declaration with a stored closed-layer replacement", () => + Effect.gen(function* () { + const replacements: LayerNode.Replacements = [value.replace(Layer.succeed(Value, { value: "replacement" }))] + const right = make({ + service: Right, + layer: Layer.effect( + Right, + Effect.map(Value, (item) => Right.of({ value: item.value })), + ), + deps: [value], + }) + const context = yield* Layer.build(LayerNode.compile(LayerNode.group([greeting, right]), { replacements })) + expect(Context.get(context, Greeting).value).toBe("hello replacement") + expect(Context.get(context, Right).value).toBe("replacement") + }), + ) + + it.effect("uses the last replacement and ignores unreachable unbound defaults and cycles", () => + Effect.gen(function* () { + const unbound = LayerNode.unbound(Value, tags.values.app) + const unused = make({ service: Value, layer: valueLayer, deps: [] }) + const result = yield* Greeting.pipe( + Effect.provide( + LayerNode.compile(greeting, { + replacements: [ + value.replace(unbound), + unbound.replace(unused), + unused.replace(unbound), + value.replace(Layer.succeed(Value, { value: "last" })), + ], + }), + ), + ) + expect(result.value).toBe("hello last") + }), + ) + + it.effect("resolves target chains independently of replacement order and treats self-replacement as identity", () => + Effect.gen(function* () { + const middle = make({ service: Value, layer: Layer.succeed(Value, { value: "middle" }), deps: [] }) + const target = make({ service: Value, layer: Layer.succeed(Value, { value: "target" }), deps: [] }) + const result = yield* Greeting.pipe( + Effect.provide( + LayerNode.compile(greeting, { + replacements: [target.replace(target), middle.replace(target), value.replace(middle)], + }), + ), + ) + expect(result.value).toBe("hello target") + }), + ) + + test("rejects reachable replacement and dependency cycles", () => { + const other = make({ service: Value, layer: valueLayer, deps: [] }) + expect(() => LayerNode.compile(greeting, { replacements: [value.replace(other), other.replace(value)] })).toThrow( + "Cycle detected in layer graph", ) - expect(await Effect.runPromise(program)).toBe("hello production") - }) - - test("builds a dependency graph", async () => { - const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(build(LayerNode.group([greeting])))) - expect(await Effect.runPromise(program)).toBe("hello production") - }) - - test("exposes roots but hides transitive dependencies", () => { - const layer = build(LayerNode.group([greeting])) - const check: Layer.Layer = layer - void check - }) - - test("preserves branch-specific implementations across roots", async () => { - const firstValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "first" })), deps: [] }) - const secondValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] }) - const leftLayer = Layer.effect( - Left, - Effect.map(Value, (item) => Left.of({ value: item.value })), - ) - const rightLayer = Layer.effect( - Right, - Effect.map(Value, (item) => Right.of({ value: item.value })), - ) - const left = make({ service: Left, layer: leftLayer, deps: [firstValue] }) - const right = make({ service: Right, layer: rightLayer, deps: [secondValue] }) - const layer = build(LayerNode.group([left, right])) - const program = Effect.gen(function* () { - return [(yield* Left).value, (yield* Right).value] - }).pipe(Effect.provide(layer)) - expect(await Effect.runPromise(program)).toEqual(["first", "second"]) - }) - - test("requires unbound nodes to be replaced before compilation", async () => { - const unbound = LayerNode.unbound(Value, tags.values.app) - const greeting = make({ service: Greeting, layer: greetingLayer, deps: [unbound] }) - const tree = LayerNode.group([greeting]) - expect(() => LayerNode.compile(tree)).toThrow("Unbound layer node: test/LayerNodeValue") - const layer = LayerNode.compile(tree, [[unbound, value]]) as Layer.Layer - const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(layer)) - expect(await Effect.runPromise(program)).toBe("hello production") - }) - - test("replaces a node with a closed layer", async () => { - const replacement = Layer.succeed(Value, Value.of({ value: "simulation" })) - const program = Effect.map(Greeting, (item) => item.value).pipe( - Effect.provide(build(LayerNode.group([greeting]), [[value, replacement]])), - ) - expect(await Effect.runPromise(program)).toBe("hello simulation") - }) - - test("replaces every use of the same layer", async () => { - const leftLayer = Layer.effect( - Left, - Effect.map(Value, (item) => Left.of({ value: item.value })), - ) - const rightLayer = Layer.effect( - Right, - Effect.map(Value, (item) => Right.of({ value: item.value })), - ) - const left = make({ service: Left, layer: leftLayer, deps: [value] }) - const right = make({ service: Right, layer: rightLayer, deps: [value] }) - const replacement = Layer.succeed(Value, Value.of({ value: "replaced" })) - const layer = build(LayerNode.group([left, right]), [[value, replacement]]) - const program = Effect.gen(function* () { - return [(yield* Left).value, (yield* Right).value] - }).pipe(Effect.provide(layer)) - expect(await Effect.runPromise(program)).toEqual(["replaced", "replaced"]) - }) - - test("does not acquire an unused replacement", async () => { - let acquisitions = 0 - const other = make({ service: Left, layer: Layer.succeed(Left, Left.of({ value: "other" })), deps: [] }) - const replacement = Layer.effect( - Left, - Effect.sync(() => { - acquisitions++ - return Left.of({ value: "replacement" }) - }), - ) - await Effect.runPromise( - Effect.map(Greeting, (item) => item.value).pipe( - Effect.provide(build(LayerNode.group([greeting]), [[other, replacement]])), - ), - ) - expect(acquisitions).toBe(0) - }) - - test("replaces a node without acquiring its dependencies", async () => { - let acquisitions = 0 - const dependencyLayer = Layer.effect( - Value, - Effect.sync(() => { - acquisitions++ - return Value.of({ value: "dependency" }) - }), - ) - const dependency = make({ service: Value, layer: dependencyLayer, deps: [] }) - const original = make({ service: Greeting, layer: greetingLayer, deps: [dependency] }) - const replacement = make({ - service: Greeting, - layer: Layer.succeed(Greeting, Greeting.of({ value: "replacement" })), - deps: [], - }) - - const program = Effect.map(Greeting, (item) => item.value).pipe( - Effect.provide(build(LayerNode.group([original]), [[original, replacement]])), - ) - - expect(await Effect.runPromise(program)).toBe("replacement") - expect(acquisitions).toBe(0) - }) - - test("applies later replacements inside earlier replacement nodes", async () => { - const original = make({ service: Greeting, layer: greetingLayer, deps: [value] }) - const replacement = make({ service: Greeting, layer: greetingLayer, deps: [value] }) - const program = Effect.map(Greeting, (item) => item.value).pipe( - Effect.provide( - build(LayerNode.group([original]), [ - [original, replacement], - [value, Layer.succeed(Value, Value.of({ value: "replacement dependency" }))], - ]), - ), - ) - - expect(await Effect.runPromise(program)).toBe("hello replacement dependency") - }) - - test("hoists and compiles tagged graphs", async () => { - const tags = LayerNode.tags({ location: ["global"], global: [] }) - const global = tags.make("global") - const location = tags.make("location") - const database = global({ - service: Database, - layer: Layer.succeed(Database, Database.of({ name: "Alice" })), - deps: [], - }) - const users = location({ - service: Users, + const dependent = make({ + service: Value, layer: Layer.effect( - Users, - Effect.gen(function* () { - const db = yield* Database - return Users.of({ list: Effect.succeed([db.name]) }) - }), + Value, + Effect.map(Greeting, (item) => Value.of({ value: item.value })), ), - deps: [database], + deps: [greeting], }) - const app = location({ - service: App, - layer: Layer.effect( - App, - Effect.gen(function* () { - const service = yield* Users - return App.of({ run: service.list }) - }), - ), - deps: [users], - }) - - const result = LayerNode.hoist(LayerNode.group([app]), tags.values.global) - expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({ - kind: "group", - dependencies: [], - }) - expect(result.hoisted.dependencies).toEqual([database]) - - const layer = LayerNode.compile(result.node).pipe( - Layer.provide(LayerNode.compile(result.hoisted)), - ) as unknown as Layer.Layer - const program = Effect.gen(function* () { - const app = yield* App - return yield* app.run - }).pipe(Effect.provide(layer)) - - expect(await Effect.runPromise(program)).toEqual(["Alice"]) - }) - - test("rejects conflicting hoisted implementations", () => { - const tags = LayerNode.tags({ location: ["global"], global: [] }) - const global = tags.make("global") - const location = tags.make("location") - const first = global({ - service: Database, - layer: Layer.succeed(Database, Database.of({ name: "first" })), - deps: [], - }) - const second = global({ - service: Database, - layer: Layer.succeed(Database, Database.of({ name: "second" })), - deps: [], - }) - const left = location({ - service: Users, - layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))), - deps: [first], - }) - const right = location({ - service: App, - layer: Layer.effect(App, Effect.as(Database, App.of({ run: Effect.succeed([]) }))), - deps: [second], - }) - - expect(() => LayerNode.hoist(LayerNode.group([left, right]), tags.values.global)).toThrow( - "Tag global has conflicting implementations for test/GraphDatabase", + expect(() => LayerNode.compile(greeting, { replacements: [value.replace(dependent)] })).toThrow( + "Cycle detected in layer graph", ) }) - test("treats dependency groups as transparent while hoisting", () => { - const tags = LayerNode.tags({ location: ["global"], global: [] }) - const global = tags.make("global") - const location = tags.make("location") - const database = global({ - service: Database, - layer: Layer.succeed(Database, Database.of({ name: "Alice" })), - deps: [], - }) - const users = location({ - service: Users, - layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))), - deps: [LayerNode.group([database])], - }) - const result = LayerNode.hoist(LayerNode.group([users]), tags.values.global) + it.effect("does not acquire replaced dependencies or unused replacement targets", () => + Effect.gen(function* () { + const acquired: string[] = [] + const dependency = make({ + service: Value, + layer: Layer.effect( + Value, + Effect.sync(() => { + acquired.push("old dependency") + return Value.of({ value: "dependency" }) + }), + ), + deps: [], + }) + const original = make({ service: Greeting, layer: greetingLayer, deps: [dependency] }) + const result = yield* Greeting.pipe( + Effect.provide( + LayerNode.compile(original, { + replacements: [ + original.replace(Layer.succeed(Greeting, { value: "replacement" })), + value.replace( + Layer.effect( + Value, + Effect.sync(() => { + acquired.push("unused target") + return Value.of({ value: "unused" }) + }), + ), + ), + ], + }), + ), + ) + expect(result.value).toBe("replacement") + expect(acquired).toEqual([]) + }), + ) - expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({ - kind: "group", - dependencies: [], - }) + it.effect("mapLayer preserves dependency wiring and replacement traversal", () => + Effect.gen(function* () { + const acquired: string[] = [] + const decorated = greeting.mapLayer((layer) => + layer.pipe( + Layer.tap((context) => + Effect.sync(() => { + acquired.push(Context.get(context, Greeting).value) + }), + ), + ), + ) + const result = yield* Greeting.pipe( + Effect.provide( + LayerNode.compile(greeting, { + replacements: [ + greeting.replace(decorated), + value.replace(Layer.succeed(Value, { value: "mapped dependency" })), + ], + }), + ), + ) + expect(result.value).toBe("hello mapped dependency") + expect(acquired).toEqual(["hello mapped dependency"]) + }), + ) + + it.effect("memoizes shared wiring instead of expanding a diamond into a tree", () => + Effect.gen(function* () { + const acquisitions: string[] = [] + const shared = value.mapLayer((layer) => + layer.pipe(Layer.tap(() => Effect.sync(() => acquisitions.push("shared")))), + ) + const left = make({ name: "left", layer: Layer.empty, deps: [shared] }) + const right = make({ name: "right", layer: Layer.empty, deps: [shared] }) + yield* Layer.build(LayerNode.compile(LayerNode.group([left, right]))) + expect(acquisitions).toEqual(["shared"]) + }), + ) + + it.effect("preserves declared memo-service outputs rather than filtering them as build metadata", () => + Effect.gen(function* () { + const supplied = yield* Layer.makeMemoMap + const memo = make({ + service: Layer.CurrentMemoMap, + layer: Layer.succeed(Layer.CurrentMemoMap, supplied), + deps: [], + }) + const observer = make({ service: Memo, layer: Layer.effect(Memo, Layer.CurrentMemoMap), deps: [memo] }) + expect(yield* Memo.pipe(Effect.provide(LayerNode.compile(observer)))).toBe(supplied) + }), + ) + + it.effect("rejects one implementation wired to different effective dependencies in either memo domain", () => + Effect.gen(function* () { + const other = make({ service: Value, layer: Layer.succeed(Value, { value: "other" }), deps: [] }) + const sibling = make({ service: Greeting, layer: greetingLayer, deps: [other] }) + const root = LayerNode.group([greeting, sibling]) + expect(() => LayerNode.compile(root)).toThrow("wired to different dependencies") + expect(() => LayerNode.compile(root, { shared: tags.values.app })).toThrow("wired to different dependencies") + const result = yield* Greeting.pipe( + Effect.provide(LayerNode.compile(root, { replacements: [value.replace(other)] })), + ) + expect(result.value).toBe("hello other") + }), + ) + + it.effect("starts dependencies in parallel and nested group roots in order", () => + Effect.gen(function* () { + const valueStarted = yield* Deferred.make() + const greetingStarted = yield* Deferred.make() + const firstStarted = yield* Deferred.make() + const releaseFirst = yield* Deferred.make() + const events: string[] = [] + const value = make({ + service: Value, + layer: Layer.effect( + Value, + Effect.gen(function* () { + yield* Deferred.succeed(valueStarted, undefined) + yield* Deferred.await(greetingStarted) + return Value.of({ value: "value" }) + }), + ), + deps: [], + }) + const greeting = make({ + service: Greeting, + layer: Layer.effect( + Greeting, + Effect.gen(function* () { + yield* Deferred.succeed(greetingStarted, undefined) + yield* Deferred.await(valueStarted) + return Greeting.of({ value: "greeting" }) + }), + ), + deps: [], + }) + const first = make({ + service: Left, + layer: Layer.effect( + Left, + Effect.gen(function* () { + yield* Value + yield* Greeting + events.push("first started") + yield* Deferred.succeed(firstStarted, undefined) + yield* Deferred.await(releaseFirst) + events.push("first finished") + return Left.of({ value: "first" }) + }), + ), + deps: [value, greeting], + }) + const second = make({ + service: Right, + layer: Layer.effect( + Right, + Effect.sync(() => { + expect(events).toEqual(["first started", "first finished"]) + events.push("second started") + return Right.of({ value: "second" }) + }), + ), + deps: [], + }) + const fiber = yield* Layer.build(LayerNode.compile(LayerNode.group([LayerNode.group([first]), second]))).pipe( + Effect.forkChild, + ) + yield* Deferred.await(firstStarted) + expect(events).toEqual(["first started"]) + yield* Deferred.succeed(releaseFirst, undefined) + const context = yield* Fiber.join(fiber) + expect(events).toEqual(["first started", "first finished", "second started"]) + expect(Context.get(context, Left).value).toBe("first") + expect(Context.get(context, Right).value).toBe("second") + }), + ) + ;[false, true].forEach((topLevel) => { + it.effect( + `LayerMap isolates builds and retains resources ${topLevel ? "with" : "without"} a top-level global owner`, + () => + Effect.gen(function* () { + const acquired = { global: 0, local: 0, support: 0 } + const released: string[] = [] + const startup: string[] = [] + yield* Effect.gen(function* () { + const memoMap = yield* Layer.makeMemoMap + const tags = LayerNode.tags({ location: ["global"], global: [] }) + const global = tags.make("global") + const location = tags.make("location") + const support = LayerNode.make({ + service: Support, + layer: Layer.effect( + Support, + Effect.acquireRelease( + Effect.sync(() => { + acquired.support++ + return Support.of({}) + }), + () => + Effect.sync(() => { + released.push("support") + }), + ), + ), + deps: [], + }) + const value = global({ + service: Value, + layer: Layer.effect( + Value, + Effect.andThen( + Support, + Effect.acquireRelease( + Effect.sync(() => { + startup.push("global") + return Value.of({ value: `global-${++acquired.global}` }) + }), + (value) => + Effect.sync(() => { + released.push(value.value) + }), + ), + ), + ), + deps: [support], + }) + const local = location({ + service: Greeting, + layer: Layer.effect( + Greeting, + Effect.gen(function* () { + yield* Value + return yield* Effect.acquireRelease( + Effect.sync(() => Greeting.of({ value: `local-${++acquired.local}` })), + (value) => + Effect.sync(() => { + released.push(value.value) + }), + ) + }), + ), + deps: [LayerNode.group([value])], + }) + const root = location({ + service: Right, + layer: Layer.effect( + Right, + Effect.gen(function* () { + const local = yield* Greeting + if (local.value === "local-2") return yield* Effect.fail("failed location" as const) + return Right.of(local) + }), + ), + deps: [local], + }) + // Every key builds the same compiled Layer, not a new graph per lookup. + const compiled = LayerNode.compile(LayerNode.group([value, root]), { shared: tags.values.global }) + const locations = location({ + service: Locations, + layer: Layer.effect( + Locations, + Effect.gen(function* () { + startup.push("map") + expect(Option.getOrUndefined(yield* Effect.serviceOption(Layer.CurrentMemoMap))).toBe(memoMap) + return yield* LayerMap.make((_: string) => compiled, { idleTimeToLive: Duration.infinity }) + }), + ), + deps: [], + }) + const scope = yield* Effect.scope + const context = yield* Layer.buildWithMemoMap( + LayerNode.compile(LayerNode.group([locations, ...(topLevel ? [value] : [])]), { + shared: tags.values.global, + }), + memoMap, + scope, + ) + expect(startup).toEqual(topLevel ? ["map", "global"] : ["map"]) + const map = Context.get(context, Locations) + const first = yield* map.contextEffect("first").pipe(Effect.scoped) + expect(Option.getOrUndefined(Context.getOption(context, Value))).toBe( + topLevel ? Context.get(first, Value) : undefined, + ) + expect(Option.isNone(Context.getOption(first, Greeting))).toBe(true) + expect(Context.get(first, Right).value).toBe("local-1") + + expect(yield* map.contextEffect("failed").pipe(Effect.scoped, Effect.flip)).toBe("failed location") + expect(released).toEqual(["local-2"]) + expect(Context.get(yield* map.contextEffect("first").pipe(Effect.scoped), Right)).toBe( + Context.get(first, Right), + ) + + const second = yield* map.contextEffect("second").pipe(Effect.scoped) + expect(Context.get(second, Value)).toBe(Context.get(first, Value)) + expect(Context.get(second, Right)).not.toBe(Context.get(first, Right)) + expect(acquired).toEqual({ global: 1, local: 3, support: 1 }) + + yield* map.invalidate("first") + expect(released).toEqual(["local-2", "local-1"]) + expect(Context.get(yield* map.contextEffect("second").pipe(Effect.scoped), Right)).toBe( + Context.get(second, Right), + ) + const rebuilt = yield* map.contextEffect("first").pipe(Effect.scoped) + expect(Context.get(rebuilt, Right).value).toBe("local-4") + expect(Context.get(rebuilt, Value)).toBe(Context.get(first, Value)) + expect(acquired).toEqual({ global: 1, local: 4, support: 1 }) + expect(released).not.toContain("global-1") + }).pipe(Effect.scoped) + expect(released.toSorted()).toEqual(["global-1", "local-1", "local-2", "local-3", "local-4", "support"]) + }), + ) }) }) diff --git a/packages/core/test/effect/layer-node/node-build.test.ts b/packages/core/test/effect/layer-node/node-build.test.ts index 96c77e0d258..baed42e3b07 100644 --- a/packages/core/test/effect/layer-node/node-build.test.ts +++ b/packages/core/test/effect/layer-node/node-build.test.ts @@ -1,20 +1,23 @@ import { describe, expect, test } from "bun:test" -import { Context, Effect, Layer, LayerMap, Option } from "effect" +import { Context, Effect, Layer, Option } from "effect" import { Node } from "@opencode-ai/util/effect/app-node" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" -import type { LocationError, LocationServices } from "@opencode-ai/core/location-services" +import { buildLocationServiceMap } from "@opencode-ai/core/location-services" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" import { tmpdir } from "../../fixture/tmpdir" +import { testEffect } from "../../lib/effect" class Value extends Context.Service()("test/TagValue") {} class Result extends Context.Service()("test/TagResult") {} class CycleA extends Context.Service()("test/NodeBuildA") {} class CycleB extends Context.Service()("test/NodeBuildB") {} +const it = testEffect(Layer.empty) + describe("node build", () => { test("does not build a location service map when the graph does not require it", async () => { const result = Node.makeGlobalNode({ @@ -31,7 +34,7 @@ describe("node build", () => { expect(await Effect.runPromise(program)).toBe("plain") }) - test("detects cycles through a replaced location service map", async () => { + test("detects cycles through a replaced location service map", () => { const a = Node.makeGlobalNode({ service: CycleA, layer: Layer.effect(CycleA, Effect.as(LocationServiceMap.Service, CycleA.of({}))), @@ -45,31 +48,49 @@ describe("node build", () => { ), deps: [a], }) - const mapLayer = Layer.effect( - LocationServiceMap.Service, - Effect.gen(function* () { - const service = yield* CycleB - return yield* LayerMap.make( - (ref: Location.Ref) => - Layer.succeed( - Location.Service, - Location.Service.of({ - directory: ref.directory, - workspaceID: ref.workspaceID, - project: { id: Project.ID.global, directory: service.directory, canonical: service.directory }, - }), - ), - { idleTimeToLive: "1 minute" }, - ) - }) as unknown as Effect.Effect, never, CycleB>, - ) + const mapLayer = Layer.unwrap(Effect.as(CycleB, buildLocationServiceMap())) const map = Node.makeGlobalNode({ service: LocationServiceMap.Service, layer: mapLayer, deps: [b] }) - expect(() => AppNodeBuilder.build(LayerNode.group([a]), [[LocationServiceMap.node, map]])).toThrow( - "Cycle detected in layer tree", + expect(() => AppNodeBuilder.build(LayerNode.group([a]), [LocationServiceMap.node.replace(map)])).toThrow( + "Cycle detected in layer graph", ) }) - test("shares top-level project with location services", async () => { + it.effect("supplies the lazy map when only a replacement introduces the dependency", () => + Effect.gen(function* () { + const original = Node.makeGlobalNode({ + service: Result, + layer: Layer.succeed(Result, { value: "original" }), + deps: [], + }) + const replacement = Node.makeGlobalNode({ + service: Result, + layer: Layer.effect(Result, Effect.as(LocationServiceMap.Service, Result.of({ value: "has map" }))), + deps: [LocationServiceMap.node], + }) + const result = yield* Result.pipe(Effect.provide(AppNodeBuilder.build(original, [original.replace(replacement)]))) + expect(result.value).toBe("has map") + }), + ) + + it.effect("caller replacements override the lazy default without building any locations", () => + Effect.gen(function* () { + const acquisitions: string[] = [] + const override = buildLocationServiceMap().pipe( + Layer.tap(() => + Effect.sync(() => { + acquisitions.push("caller map") + }), + ), + ) + const context = yield* Layer.build( + AppNodeBuilder.build(LocationServiceMap.node, [LocationServiceMap.node.replace(override)]), + ) + expect(Context.get(context, LocationServiceMap.Service)).toBeDefined() + expect(acquisitions).toEqual(["caller map"]) + }), + ) + + test("shares top-level project even when the location service map is built first", async () => { await using tmp = await tmpdir() let acquisitions = 0 const projectLayer = Layer.effect( @@ -84,8 +105,8 @@ describe("node build", () => { }), ) const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }) - const layer = AppNodeBuilder.build(LayerNode.group([Project.node, LocationServiceMap.node]), [ - [Project.node, projectLayer], + const layer = AppNodeBuilder.build(LayerNode.group([LocationServiceMap.node, Project.node]), [ + Project.node.replace(projectLayer), ]) const program = Effect.gen(function* () { yield* Project.Service diff --git a/packages/core/test/file-mutation.test.ts b/packages/core/test/file-mutation.test.ts index 0d5c8d868d7..08b5ad770ff 100644 --- a/packages/core/test/file-mutation.test.ts +++ b/packages/core/test/file-mutation.test.ts @@ -21,8 +21,8 @@ function provide(directory: string, transformFiles: EnvironmentFilesTransform = ) return Effect.provide( AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [ - [Location.node, activeLocation], - [Environment.node, transformEnvironmentFiles(transformFiles)], + Location.node.replace(activeLocation), + Environment.node.replace(transformEnvironmentFiles(transformFiles)), ]), ) } diff --git a/packages/core/test/filesystem/search.test.ts b/packages/core/test/filesystem/search.test.ts index dc2de5bbc60..ff8e5da2175 100644 --- a/packages/core/test/filesystem/search.test.ts +++ b/packages/core/test/filesystem/search.test.ts @@ -77,16 +77,15 @@ describe("FileSystemSearch", () => { workspaceID: Workspace.ID.make("wrk_test"), }) const layer = AppNodeBuilder.build(FileSystemSearch.node, [ - [ - Location.node, + Location.node.replace( Layer.succeed( Location.Service, Location.Service.of( location(ref, { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } }), ), ), - ], - [Ripgrep.node, ripgrepStub("remote.ts", (input) => (observed = input))], + ), + Ripgrep.node.replace(ripgrepStub("remote.ts", (input) => (observed = input))), ]) yield* Effect.gen(function* () { @@ -103,8 +102,7 @@ describe("FileSystemSearch", () => { let observed: Ripgrep.FindInput | undefined const home = AbsolutePath.make(os.homedir()) const layer = AppNodeBuilder.build(FileSystemSearch.node, [ - [ - Location.node, + Location.node.replace( Layer.succeed( Location.Service, Location.Service.of( @@ -114,8 +112,8 @@ describe("FileSystemSearch", () => { ), ), ), - ], - [Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))], + ), + Ripgrep.node.replace(ripgrepStub("src/index.ts", (input) => (observed = input))), ]) yield* Effect.gen(function* () { const search = yield* FileSystemSearch.Service @@ -137,17 +135,15 @@ describe("FileSystemSearch", () => { const started = yield* Deferred.make() const release = yield* Deferred.make() const layer = AppNodeBuilder.build(FileSystemSearch.node, [ - [ - Location.node, + Location.node.replace( Layer.succeed( Location.Service, Location.Service.of( location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }), ), ), - ], - [ - Ripgrep.node, + ), + Ripgrep.node.replace( Layer.succeed( Ripgrep.Service, Ripgrep.Service.of({ @@ -169,7 +165,7 @@ describe("FileSystemSearch", () => { grep: () => Effect.succeed([]), }), ), - ], + ), ]) yield* Effect.gen(function* () { @@ -208,17 +204,15 @@ describe("FileSystemSearch", () => { (value) => Effect.sync(() => value.mockRestore()), ) const layer = AppNodeBuilder.build(FileSystemSearch.node, [ - [ - Location.node, + Location.node.replace( Layer.succeed( Location.Service, Location.Service.of( location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }), ), ), - ], - [ - Ripgrep.node, + ), + Ripgrep.node.replace( Layer.succeed( Ripgrep.Service, Ripgrep.Service.of({ @@ -234,7 +228,7 @@ describe("FileSystemSearch", () => { grep: () => Effect.succeed([]), }), ), - ], + ), ]) yield* Effect.gen(function* () { diff --git a/packages/core/test/filesystem/watcher.test.ts b/packages/core/test/filesystem/watcher.test.ts index 570acf91b2d..9ad0401684a 100644 --- a/packages/core/test/filesystem/watcher.test.ts +++ b/packages/core/test/filesystem/watcher.test.ts @@ -6,7 +6,7 @@ import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } fr import { Config } from "@opencode-ai/core/config" import { ConfigLocationWatcherPlugin } from "@opencode-ai/core/config/plugin/location-watcher" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { makeLocationNode, type LocationNode } from "@opencode-ai/util/effect/app-node" +import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Bus } from "@opencode-ai/core/bus" import { FSUtil } from "@opencode-ai/util/fs-util" @@ -129,7 +129,7 @@ function provide( vcs?: Location.Interface["vcs"], watcher?: Layer.Layer, config: Layer.Layer = configLayer, - plugins: LocationNode = pluginNode, + plugins: typeof pluginNode = pluginNode, ) { const locationLayer = Layer.succeed( Location.Service, @@ -138,10 +138,10 @@ function provide( const built = AppNodeBuilder.build( LayerNode.group([LocationWatcher.node, LocationWatcherPolicy.node, Bus.node, Config.node]), [ - [Config.node, config], - [Location.node, locationLayer], - [PluginSupervisor.node, plugins], - ...(watcher ? ([[Watcher.node, watcher]] as const) : []), + Config.node.replace(config), + Location.node.replace(locationLayer), + PluginSupervisor.node.replace(plugins), + ...(watcher ? ([Watcher.node.replace(watcher)] as const) : []), ], ) return Effect.provide(built) @@ -154,7 +154,7 @@ function withTmp( init?: (directory: string) => Promise watcher?: Layer.Layer config?: Layer.Layer - plugins?: LocationNode + plugins?: typeof pluginNode }, ) { return Effect.acquireRelease( diff --git a/packages/core/test/fixture/effect-flock-worker.ts b/packages/core/test/fixture/effect-flock-worker.ts index b79c0a9458a..894254f2956 100644 --- a/packages/core/test/fixture/effect-flock-worker.ts +++ b/packages/core/test/fixture/effect-flock-worker.ts @@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({ log: os.tmpdir(), }) -const testLayer = LayerNode.compile(EffectFlock.node, [[Global.node, testGlobal]]) +const testLayer = LayerNode.compile(EffectFlock.node, { replacements: [Global.node.replace(testGlobal)] }) async function job() { if (msg.ready) await fs.writeFile(msg.ready, String(process.pid)) diff --git a/packages/core/test/fixture/prompt-location.ts b/packages/core/test/fixture/prompt-location.ts index 36784ccf87d..a2114eb7c95 100644 --- a/packages/core/test/fixture/prompt-location.ts +++ b/packages/core/test/fixture/prompt-location.ts @@ -26,9 +26,9 @@ export const promptLocationNode = makeGlobalNode({ SessionPrompt.layer.pipe( Layer.provideMerge( Layer.mergeAll( - LayerNode.compile(LayerNode.group([PluginHooks.node, Image.node, Skill.node]), [ - [Bus.node, Layer.succeed(Bus.Service, bus)], - ]), + LayerNode.compile(LayerNode.group([PluginHooks.node, Image.node, Skill.node]), { + replacements: [Bus.node.replace(Layer.succeed(Bus.Service, bus))], + }), Layer.succeed(FSUtil.Service, fs), Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }), Layer.mock(Reference.Service, { refresh: () => Effect.void }), diff --git a/packages/core/test/formatter.test.ts b/packages/core/test/formatter.test.ts index d4d9f96e242..2f4adf3b13e 100644 --- a/packages/core/test/formatter.test.ts +++ b/packages/core/test/formatter.test.ts @@ -20,7 +20,7 @@ import { testEffect } from "./lib/effect" const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [ - [Global.node, tempGlobalLayer], + Global.node.replace(tempGlobalLayer), ]), ) type ConfigInput = typeof Info.Encoded diff --git a/packages/core/test/instance-plugins.test.ts b/packages/core/test/instance-plugins.test.ts index 5dbc71fe82c..786a033afe7 100644 --- a/packages/core/test/instance-plugins.test.ts +++ b/packages/core/test/instance-plugins.test.ts @@ -34,7 +34,7 @@ const instances = Layer.effect( (ref: Location.Ref) => Instance.layer(ref, { plugins: path.basename(ref.directory) === "thread-a" ? [agentPlugin("thread-a-plugin", "thread-a-agent")] : [], - replacements: [[Global.node, tempGlobalLayer]], + replacements: [Global.node.replace(tempGlobalLayer)], }), { idleTimeToLive: Duration.infinity }, ), @@ -42,8 +42,8 @@ const instances = Layer.effect( const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [ - [Global.node, tempGlobalLayer], - [LocationServiceMap.node, instances], + Global.node.replace(tempGlobalLayer), + LocationServiceMap.node.replace(instances), ]), ) diff --git a/packages/core/test/instance-vanilla.test.ts b/packages/core/test/instance-vanilla.test.ts index b55c4bc02e3..a18d16934a9 100644 --- a/packages/core/test/instance-vanilla.test.ts +++ b/packages/core/test/instance-vanilla.test.ts @@ -23,14 +23,13 @@ import { Bus } from "../src/bus" // Config the host hands the vanilla instance explicitly: a value and an // explicit plugin removal, both of which must survive discovery: false. const hostConfig: LayerNode.Replacements = [ - [ - Config.node, + Config.node.replace( Config.configured({ project: false, global: false, content: JSON.stringify({ shell: "vanilla-host", plugins: ["-opencode.tool.shell"] }), }), - ], + ), ] // Same directory contents, two instances: one vanilla, one with discovery. @@ -43,7 +42,7 @@ const instances = Layer.effect( // "bare" exercises the vanilla defaults themselves: no caller Config. discovery: name !== "vanilla" && name !== "bare", // Caller replacements win over the vanilla defaults. - replacements: [[Global.node, tempGlobalLayer], ...(name === "vanilla" ? hostConfig : [])], + replacements: [Global.node.replace(tempGlobalLayer), ...(name === "vanilla" ? hostConfig : [])], }) }, { idleTimeToLive: Duration.infinity }, @@ -52,8 +51,8 @@ const instances = Layer.effect( const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [ - [Global.node, tempGlobalLayer], - [LocationServiceMap.node, instances], + Global.node.replace(tempGlobalLayer), + LocationServiceMap.node.replace(instances), ]), ) diff --git a/packages/core/test/instruction-discovery.test.ts b/packages/core/test/instruction-discovery.test.ts index 0c2cbb68ad6..1af50a4b1a1 100644 --- a/packages/core/test/instruction-discovery.test.ts +++ b/packages/core/test/instruction-discovery.test.ts @@ -33,19 +33,18 @@ const instructionLayer = (input: { AppNodeBuilder.build( LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]), [ - [InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })], - [ - Global.node, + InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: input.project })), + Global.node.replace( input.config || input.home ? Global.layerWith({ ...(input.config ? { config: input.config } : {}), ...(input.home ? { home: input.home } : {}), }) : tempGlobalLayer, - ], - [Location.node, input.locationServiceLayer], - [Watcher.node, watcher], - ...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []), + ), + Location.node.replace(input.locationServiceLayer), + Watcher.node.replace(watcher), + ...(input.filesystemLayer ? [FSUtil.node.replace(input.filesystemLayer)] : []), ], ), watcher, diff --git a/packages/core/test/instruction-state.test.ts b/packages/core/test/instruction-state.test.ts index c54f2ed6fa9..023c9a0b4b0 100644 --- a/packages/core/test/instruction-state.test.ts +++ b/packages/core/test/instruction-state.test.ts @@ -24,7 +24,7 @@ import { testEffect } from "./lib/effect" const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [ - [Bus.node, Bus.configured({ persist: true })], + Bus.node.replace(Bus.configured({ persist: true })), ]), ) diff --git a/packages/core/test/instructions/builtins.test.ts b/packages/core/test/instructions/builtins.test.ts index db700aef88c..60453fa8101 100644 --- a/packages/core/test/instructions/builtins.test.ts +++ b/packages/core/test/instructions/builtins.test.ts @@ -30,8 +30,8 @@ const locationLayer = Layer.succeed( ) const it = testEffect( AppNodeBuilder.build(InstructionBuiltIns.node, [ - [Location.node, locationLayer], - [Global.node, Global.layerWith({ config: temporary, tmp: temporary })], + Location.node.replace(locationLayer), + Global.node.replace(Global.layerWith({ config: temporary, tmp: temporary })), ]), ) diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index 90091ec610c..c367e16c73c 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -27,7 +27,7 @@ const failingCredentialNode = makeGlobalNode({ deps: [], }) const failingIt = testEffect( - AppNodeBuilder.build(LayerNode.group([Integration.node, Bus.node]), [[Credential.node, failingCredentialNode]]), + AppNodeBuilder.build(LayerNode.group([Integration.node, Bus.node]), [Credential.node.replace(failingCredentialNode)]), ) function eventually( diff --git a/packages/core/test/location-filesystem.test.ts b/packages/core/test/location-filesystem.test.ts index d3b1a76bf52..0be4a284a53 100644 --- a/packages/core/test/location-filesystem.test.ts +++ b/packages/core/test/location-filesystem.test.ts @@ -13,15 +13,16 @@ import { it } from "./lib/effect" const provide = (directory: string, workspaceID?: Workspace.ID) => Effect.provide( - LayerNode.compile(FileSystem.node, [ - [ - Location.node, - Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make(directory), workspaceID })), + LayerNode.compile(FileSystem.node, { + replacements: [ + Location.node.replace( + Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(directory), workspaceID })), + ), ), ], - ]), + }), ) const withTmp = (f: (directory: string) => Effect.Effect) => diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 0a8f4b1e9c2..d85ac711075 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -51,12 +51,12 @@ import { Tool } from "../src/tool" const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [ - [Global.node, tempGlobalLayer], + Global.node.replace(tempGlobalLayer), ]), ) const itWithSdk = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [ - [Global.node, tempGlobalLayer], + Global.node.replace(tempGlobalLayer), ]), ) const activityLocations = Layer.effect( @@ -77,7 +77,7 @@ const activityLocations = Layer.effect( ) const itWithActivity = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node, LocationActivity.node]), [ - [LocationServiceMap.node, activityLocations], + LocationServiceMap.node.replace(activityLocations), ]), ) diff --git a/packages/core/test/location-mutation.test.ts b/packages/core/test/location-mutation.test.ts index f3753d44432..640bcc8bfd9 100644 --- a/packages/core/test/location-mutation.test.ts +++ b/packages/core/test/location-mutation.test.ts @@ -13,20 +13,21 @@ import { it } from "./lib/effect" function provide(directory: string, projectDirectory = directory) { return Effect.provide( - LayerNode.compile(LocationMutation.node, [ - [ - Location.node, - Layer.succeed( - Location.Service, - Location.Service.of( - location( - { directory: AbsolutePath.make(directory) }, - { projectDirectory: AbsolutePath.make(projectDirectory) }, + LayerNode.compile(LocationMutation.node, { + replacements: [ + Location.node.replace( + Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + { projectDirectory: AbsolutePath.make(projectDirectory) }, + ), ), ), ), ], - ]), + }), ) } diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts index bfa18da62c3..3512c8f2ee6 100644 --- a/packages/core/test/location.test.ts +++ b/packages/core/test/location.test.ts @@ -23,7 +23,7 @@ const projectLayer = Layer.succeed( }), }), ) -const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]])) +const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [Project.node.replace(projectLayer)])) describe("Location", () => { it.effect("resolves the current project and vcs information", () => diff --git a/packages/core/test/mcp-instructions.test.ts b/packages/core/test/mcp-instructions.test.ts index 70c8a0f9a27..870974dacd2 100644 --- a/packages/core/test/mcp-instructions.test.ts +++ b/packages/core/test/mcp-instructions.test.ts @@ -23,13 +23,12 @@ const tool = (server: string, name = "search") => new Mcp.Tool({ server: Mcp.Ser const layer = (catalog: () => Mcp.ServerInstructions[], tools: () => Mcp.Tool[]) => AppNodeBuilder.build(McpInstructions.node, [ - [ - Mcp.node, + Mcp.node.replace( Layer.mock(Mcp.Service, { instructions: () => Effect.succeed(catalog()), tools: () => Effect.succeed(tools()), }), - ], + ), ]) describe("McpInstructions", () => { diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index da372e8a3da..bd4fad07fb3 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -378,10 +378,10 @@ const permissions = Layer.mock(Permission.Service, { const events = Layer.mock(Bus.Service, { subscribe: () => Stream.never }) const it = testEffect( AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node]), [ - [Mcp.node, mcp], - [Permission.node, permissions], - [Bus.node, events], - [Image.node, imagePassthrough], + Mcp.node.replace(mcp), + Permission.node.replace(permissions), + Bus.node.replace(events), + Image.node.replace(imagePassthrough), ]), ) @@ -1688,8 +1688,7 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin tr Effect.provide( Layer.fresh( AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [ - [ - Mcp.node, + Mcp.node.replace( Layer.mock(Mcp.Service, { tools: () => Ref.get(catalog), callTool: (input) => @@ -1702,9 +1701,9 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin tr }), ), }), - ], - [Permission.node, Layer.mock(Permission.Service, { assert: () => Effect.void })], - [Image.node, imagePassthrough], + ), + Permission.node.replace(Layer.mock(Permission.Service, { assert: () => Effect.void })), + Image.node.replace(imagePassthrough), ]), ), ), @@ -1731,8 +1730,7 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in }).pipe( Effect.provide( AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [ - [ - Mcp.node, + Mcp.node.replace( Layer.mock(Mcp.Service, { tools: () => Effect.sync(() => [ @@ -1744,9 +1742,9 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in }), ]), }), - ], - [Permission.node, Layer.mock(Permission.Service, { assert: () => Effect.void })], - [Image.node, imagePassthrough], + ), + Permission.node.replace(Layer.mock(Permission.Service, { assert: () => Effect.void })), + Image.node.replace(imagePassthrough), ]), ), ) diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index e41fa71559f..2c0fed83a3d 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -182,9 +182,9 @@ const buildLayer = (state: Ref.Ref, cache: MockCache, options: Models // every test would reuse the cachedInvalidateWithTTL state from the first run. Layer.fresh( AppNodeBuilder.build(LayerNode.group([ModelsDev.node, Bus.node]), [ - [ModelsDev.node, ModelsDev.configured(options)], - [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))], - [KV.node, makeMockKV(cache)], + ModelsDev.node.replace(ModelsDev.configured(options)), + LayerNodePlatform.httpClient.replace(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))), + KV.node.replace(makeMockKV(cache)), ]), ) @@ -312,9 +312,9 @@ describe("ModelsDev Service", () => { const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) const layer = Layer.fresh( AppNodeBuilder.build(ModelsDev.node, [ - [ModelsDev.node, ModelsDev.configured({ fetch: true, snapshot: false })], - [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))], - [KV.node, makeFailingWriteKV(cache)], + ModelsDev.node.replace(ModelsDev.configured({ fetch: true, snapshot: false })), + LayerNodePlatform.httpClient.replace(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))), + KV.node.replace(makeFailingWriteKV(cache)), ]), ) const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(layer)) diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index f31fcec764c..215472d1dbf 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -20,7 +20,7 @@ const writePackage = (dir: string, pkg: Record) => ) const npmLayer = (cache: string) => - AppNodeBuilder.build(Npm.node, [[Global.node, Global.layerWith({ cache, state: path.join(cache, "state") })]]) + AppNodeBuilder.build(Npm.node, [Global.node.replace(Global.layerWith({ cache, state: path.join(cache, "state") }))]) async function createGitFixture(directory: string) { const repository = path.join(directory, "repository") diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index 3c08246f364..294cc489245 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -37,7 +37,7 @@ const it = testEffect( PluginHooks.node, Permission.node, ]), - [[Location.node, current]], + [Location.node.replace(current)], ), ) diff --git a/packages/core/test/plugin-hooks.test.ts b/packages/core/test/plugin-hooks.test.ts index 7efcc825329..b9c680fb8ab 100644 --- a/packages/core/test/plugin-hooks.test.ts +++ b/packages/core/test/plugin-hooks.test.ts @@ -4,12 +4,12 @@ import { Agent } from "@opencode-ai/schema/agent" import { Model } from "@opencode-ai/schema/model" import { Provider } from "@opencode-ai/schema/provider" import { Session } from "@opencode-ai/schema/session" -import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { Effect } from "effect" import { PluginHooks } from "../src/plugin/hooks" import { testEffect } from "./lib/effect" -const layer = PluginHooks.node.implementation as Layer.Layer -const it = testEffect(layer) +const it = testEffect(LayerNode.compile(PluginHooks.node)) describe("PluginHooks", () => { it.effect("registers scoped session hooks and triggers them sequentially", () => diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index 504ad8cccca..cf802ecc4d5 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -27,8 +27,8 @@ const locationLayer = Layer.succeed( ) const it = testEffect( AppNodeBuilder.build(LayerNode.group([Command.node, Mcp.node, Bus.node]), [ - [Mcp.node, emptyMcpLayer], - [Location.node, locationLayer], + Mcp.node.replace(emptyMcpLayer), + Location.node.replace(locationLayer), ]), ) diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index 5ac8dfdfcc0..0dfa9d311ee 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -88,12 +88,14 @@ export const PluginTestLayer = LayerNode.compile( Watcher.node, WebSearch.node, ]), - [ - [Location.node, tempLocationLayer], - [Npm.node, npmLayer], - [Config.node, Config.testLayer()], - [Mcp.node, emptyMcpLayer], - [Generate.node, generateLayer], - [Permission.node, permissionLayer], - ], + { + replacements: [ + Location.node.replace(tempLocationLayer), + Npm.node.replace(npmLayer), + Config.node.replace(Config.testLayer()), + Mcp.node.replace(emptyMcpLayer), + Generate.node.replace(generateLayer), + Permission.node.replace(permissionLayer), + ], + }, ) as unknown as Layer.Layer diff --git a/packages/core/test/plugin/host.test.ts b/packages/core/test/plugin/host.test.ts index 4b63883c813..bb665a0c8c6 100644 --- a/packages/core/test/plugin/host.test.ts +++ b/packages/core/test/plugin/host.test.ts @@ -23,11 +23,11 @@ const it = testEffect( PluginRuntime.providerNodeWithCell(cell), ]), [ - [Global.node, tempGlobalLayer], - [Watcher.node, Watcher.configured({ enabled: false })], - [SessionExecution.node, SessionExecution.noopLayer], - [PluginRuntime.node, PluginRuntime.layerWithCell(cell)], - [PersistentPty.node, PersistentPty.configured()], + Global.node.replace(tempGlobalLayer), + Watcher.node.replace(Watcher.configured({ enabled: false })), + SessionExecution.node.replace(SessionExecution.noopLayer), + PluginRuntime.node.replace(PluginRuntime.layerWithCell(cell)), + PersistentPty.node.replace(PersistentPty.configured()), ], ), ) diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 20c4fecfc59..18a624bdfba 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -27,12 +27,12 @@ const locationLayer = Layer.succeed( Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })), ) const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, Bus.node]), [ - [Location.node, locationLayer], + Location.node.replace(locationLayer), ]) const it = testEffect(layer) const real = testEffect(PluginTestLayer) const models = (file: string) => - AppNodeBuilder.build(ModelsDev.node, [[ModelsDev.node, ModelsDev.configured({ file, fetch: false })]]) + AppNodeBuilder.build(ModelsDev.node, [ModelsDev.node.replace(ModelsDev.configured({ file, fetch: false }))]) describe("ModelsDevPlugin", () => { real.effect("keeps the retained model seed unchanged across catalog replay", () => diff --git a/packages/core/test/plugin/variant.test.ts b/packages/core/test/plugin/variant.test.ts index 762d9378928..2a253634704 100644 --- a/packages/core/test/plugin/variant.test.ts +++ b/packages/core/test/plugin/variant.test.ts @@ -15,7 +15,7 @@ const locationLayer = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })), ) -const it = testEffect(AppNodeBuilder.build(Catalog.node, [[Location.node, locationLayer]])) +const it = testEffect(AppNodeBuilder.build(Catalog.node, [Location.node.replace(locationLayer)])) describe("VariantPlugin", () => { it.effect("adds GLM 5.2 variants after catalog sources", () => diff --git a/packages/core/test/plugin/websearch-fixture.ts b/packages/core/test/plugin/websearch-fixture.ts index ce61770ec0f..f188e287e99 100644 --- a/packages/core/test/plugin/websearch-fixture.ts +++ b/packages/core/test/plugin/websearch-fixture.ts @@ -42,7 +42,7 @@ const http = Layer.succeed( export const webSearchIntegrationTest = testEffect( Layer.merge( AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node, Form.node, WebSearch.node]), [ - [Config.node, Config.testLayer()], + Config.node.replace(Config.testLayer()), ]), http, ), diff --git a/packages/core/test/pty/pty-session.test.ts b/packages/core/test/pty/pty-session.test.ts index 9a184bfd929..06cf8fd2140 100644 --- a/packages/core/test/pty/pty-session.test.ts +++ b/packages/core/test/pty/pty-session.test.ts @@ -17,7 +17,9 @@ const locationLayer = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })), ) -const it = testEffect(AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [[Location.node, locationLayer]])) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [Location.node.replace(locationLayer)]), +) const ptyTest = process.platform === "win32" ? it.live.skip : it.live const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () { @@ -200,7 +202,7 @@ describe("pty", () => { const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash") const configuredIt = testEffect( - AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node, ShellSelect.node]), [[Location.node, locationLayer]]), + AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node, ShellSelect.node]), [Location.node.replace(locationLayer)]), ) const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live diff --git a/packages/core/test/pty/ticket.test.ts b/packages/core/test/pty/ticket.test.ts index 563264ec71e..31c87de49af 100644 --- a/packages/core/test/pty/ticket.test.ts +++ b/packages/core/test/pty/ticket.test.ts @@ -8,7 +8,9 @@ import { testEffect } from "../lib/effect" const it = testEffect(LayerNode.compile(PtyTicket.node)) const itExpiring = testEffect( - LayerNode.compile(PtyTicket.node, [[PtyTicket.node, Layer.effect(PtyTicket.Service, PtyTicket.make(5))]]), + LayerNode.compile(PtyTicket.node, { + replacements: [PtyTicket.node.replace(Layer.effect(PtyTicket.Service, PtyTicket.make(5)))], + }), ) describe("PTY websocket tickets", () => { diff --git a/packages/core/test/reference-instructions.test.ts b/packages/core/test/reference-instructions.test.ts index 5cfac6903b1..2baf4cb15cb 100644 --- a/packages/core/test/reference-instructions.test.ts +++ b/packages/core/test/reference-instructions.test.ts @@ -8,7 +8,7 @@ import { it } from "./lib/effect" import { readInitial, readUpdate } from "./lib/instructions" const instructionsLayer = (referenceLayer: Layer.Layer) => - AppNodeBuilder.build(ReferenceInstructions.node, [[Reference.node, referenceLayer]]) + AppNodeBuilder.build(ReferenceInstructions.node, [Reference.node.replace(referenceLayer)]) describe("ReferenceInstructions", () => { it.effect("lists available references in the instructions", () => diff --git a/packages/core/test/reference.test.ts b/packages/core/test/reference.test.ts index 430287ade21..657d65a1f9e 100644 --- a/packages/core/test/reference.test.ts +++ b/packages/core/test/reference.test.ts @@ -11,7 +11,7 @@ import { it } from "./lib/effect" const cache = Layer.mock(RepositoryCache.Service, { ensure: () => Effect.die("unexpected Git materialization"), }) -const referenceLayer = AppNodeBuilder.build(Reference.node, [[RepositoryCache.node, cache]]) +const referenceLayer = AppNodeBuilder.build(Reference.node, [RepositoryCache.node.replace(cache)]) describe("Reference", () => { it.effect("registers normalized sources for the owning scope", () => diff --git a/packages/core/test/repository-cache.test.ts b/packages/core/test/repository-cache.test.ts index 6245ce26ea2..47665d2811d 100644 --- a/packages/core/test/repository-cache.test.ts +++ b/packages/core/test/repository-cache.test.ts @@ -227,8 +227,8 @@ describe("RepositoryCache", () => { function cacheLayer(root: string) { return AppNodeBuilder.build(LayerNode.group([RepositoryCache.node, KV.node]), [ - [Global.node, Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })], - [Database.node, Database.configured({ path: path.join(root, "cache.sqlite") })], + Global.node.replace(Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })), + Database.node.replace(Database.configured({ path: path.join(root, "cache.sqlite") })), ]) } diff --git a/packages/core/test/ripgrep.test.ts b/packages/core/test/ripgrep.test.ts index 2f11446d58c..08bbbe53384 100644 --- a/packages/core/test/ripgrep.test.ts +++ b/packages/core/test/ripgrep.test.ts @@ -10,7 +10,7 @@ import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" import { tempLocationLayer } from "./fixture/location" -const it = testEffect(AppNodeBuilder.build(Ripgrep.node, [[Location.node, tempLocationLayer]])) +const it = testEffect(AppNodeBuilder.build(Ripgrep.node, [Location.node.replace(tempLocationLayer)])) describe("Ripgrep", () => { it.live("globs files as an array", () => diff --git a/packages/core/test/rpc.test.ts b/packages/core/test/rpc.test.ts index 59b23a4e566..32812547614 100644 --- a/packages/core/test/rpc.test.ts +++ b/packages/core/test/rpc.test.ts @@ -15,7 +15,7 @@ import { testEffect } from "./lib/effect" const ref = Location.Ref.make({ directory: AbsolutePath.make("/rpc-project") }) const it = testEffect( AppNodeBuilder.build(LayerNode.group([Rpc.node, Bus.node, Location.node]), [ - [Location.node, Layer.succeed(Location.Service, location(ref))], + Location.node.replace(Layer.succeed(Location.Service, location(ref))), ]), ) const Echo = Rpc.define({ @@ -200,8 +200,7 @@ describe("Rpc", () => { events: {}, }) yield* rpc.register(Failing, { - standard: (_input, context) => - Effect.fail(context.error("missing", "Missing", { attempts: "2" })), + standard: (_input, context) => Effect.fail(context.error("missing", "Missing", { attempts: "2" })), effect: (_input, context) => Effect.fail(context.error("invalid", "Invalid", { count: 3 })), }) @@ -269,7 +268,6 @@ describe("Rpc", () => { expect(Exit.isFailure(yield* rpc.call(Raw.id, "count", "42").pipe(Effect.exit))).toBe(true) expect(Exit.isFailure(yield* rpc.call(Raw.id, "count", 0).pipe(Effect.exit))).toBe(true) expect(Exit.isFailure(yield* registration.events.emit("counted", { count: 0 }).pipe(Effect.exit))).toBe(true) - }), ) @@ -330,10 +328,12 @@ describe("Rpc", () => { const bus = yield* Bus.Service const otherRef = Location.Ref.make({ directory: ref.directory, workspaceID: Workspace.ID.make("wrk_other") }) const otherContext = yield* Layer.build( - LayerNode.compile(Rpc.node, [ - [Bus.node, Layer.succeed(Bus.Service, bus)], - [Location.node, Layer.succeed(Location.Service, location(otherRef))], - ]).pipe(Layer.fresh), + LayerNode.compile(Rpc.node, { + replacements: [ + Bus.node.replace(Layer.succeed(Bus.Service, bus)), + Location.node.replace(Layer.succeed(Location.Service, location(otherRef))), + ], + }).pipe(Layer.fresh), ) const other = Context.get(otherContext, Rpc.Service) const first = yield* rpc.register(Echo, { echo: () => Effect.succeed("first") }) diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index 9c625d6ff87..c15112d7aed 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -65,9 +65,9 @@ const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), [ - [LocationServiceMap.node, locations], - [Project.node, globalProjectNode], - [SessionExecution.node, SessionExecution.noopLayer], + LocationServiceMap.node.replace(locations), + Project.node.replace(globalProjectNode), + SessionExecution.node.replace(SessionExecution.noopLayer), ], ), ) diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index ed3c2a40ab1..41c9e99e070 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -88,10 +88,7 @@ const it = testEffect( SessionCompaction.node, SessionModelRequest.node, ]), - [ - [Bus.node, Bus.configured({ persist: true })], - [llmClient, client], - ], + [Bus.node.replace(Bus.configured({ persist: true })), llmClient.replace(client)], ), ) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index d4e319293f5..e6b7cdac58c 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -51,30 +51,27 @@ const it = testEffect( InstructionEntry.node, ]), [ - [Bus.node, Bus.configured({ persist: true })], - [Project.node, globalProjectNode], - [LocationServiceMap.node, promptLocationNode], - [SessionExecution.node, SessionExecution.noopLayer], + Bus.node.replace(Bus.configured({ persist: true })), + Project.node.replace(globalProjectNode), + LocationServiceMap.node.replace(promptLocationNode), + SessionExecution.node.replace(SessionExecution.noopLayer), ], ), ) const liveIt = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, Project.node, SessionProjector.node, SessionStore.node, Session.node]), - [ - [Bus.node, Bus.configured({ persist: true })], - [SessionExecution.node, SessionExecution.noopLayer], - ], + [Bus.node.replace(Bus.configured({ persist: true })), SessionExecution.node.replace(SessionExecution.noopLayer)], ), ) const projectIt = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, Project.node, SessionProjector.node, SessionStore.node, Session.node]), [ - [Bus.node, Bus.configured({ persist: true })], + Bus.node.replace(Bus.configured({ persist: true })), // Project adoption needs plain-prompt admission, not live plugin/provider startup. - [LocationServiceMap.node, promptLocationNode], - [SessionExecution.node, SessionExecution.noopLayer], + LocationServiceMap.node.replace(promptLocationNode), + SessionExecution.node.replace(SessionExecution.noopLayer), ], ), ) @@ -968,8 +965,8 @@ describe("Session.create", () => { const targetLayer = AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]), [ - [Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })], - [Bus.node, Bus.configured({ persist: true })], + Database.node.replace(Database.configured({ path: path.join(tmp.path, "target.sqlite") })), + Bus.node.replace(Bus.configured({ persist: true })), ], ) diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index 69e768ab793..818f1e5fc32 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -142,17 +142,17 @@ const it = testEffect( SessionGenerateNode.node, ]), [ - [Bus.node, Bus.configured({ persist: true })], - [llmClient, client], - [SessionRunnerModel.node, models], - [InstructionBuiltIns.node, builtins], - [InstructionDiscovery.node, discovery], - [SkillInstructions.node, skills], - [ReferenceInstructions.node, references], - [McpInstructions.node, mcp], - [PluginSupervisor.node, plugins], - [Tool.node, tools], - [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + Bus.node.replace(Bus.configured({ persist: true })), + llmClient.replace(client), + SessionRunnerModel.node.replace(models), + InstructionBuiltIns.node.replace(builtins), + InstructionDiscovery.node.replace(discovery), + SkillInstructions.node.replace(skills), + ReferenceInstructions.node.replace(references), + McpInstructions.node.replace(mcp), + PluginSupervisor.node.replace(plugins), + Tool.node.replace(tools), + Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })), ], ), ) diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index c9b8bd79119..bd9decea5c2 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -53,7 +53,7 @@ const readToolNode = makeLocationNode({ const permission = permissionLayer({ assert: () => Effect.void }) const config = Config.testLayer() -const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]]) +const imageLayer = AppNodeBuilder.build(Image.node, [Config.node.replace(config)]) const testLayer = AppNodeBuilder.build( LayerNode.group([ @@ -74,12 +74,12 @@ const testLayer = AppNodeBuilder.build( Image.node, ]), [ - [Project.node, globalProjectNode], - [SessionExecution.node, SessionExecution.noopLayer], - [Location.node, tempLocationLayer], - [Permission.node, permission], - [Config.node, config], - [Image.node, imageLayer], + Project.node.replace(globalProjectNode), + SessionExecution.node.replace(SessionExecution.noopLayer), + Location.node.replace(tempLocationLayer), + Permission.node.replace(permission), + Config.node.replace(config), + Image.node.replace(imageLayer), ], ) diff --git a/packages/core/test/session-log.test.ts b/packages/core/test/session-log.test.ts index be5bfd556bc..a0a739428c7 100644 --- a/packages/core/test/session-log.test.ts +++ b/packages/core/test/session-log.test.ts @@ -22,9 +22,9 @@ const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), [ - [Bus.node, Bus.configured({ persist: true })], - [Project.node, globalProjectNode], - [SessionExecution.node, SessionExecution.noopLayer], + Bus.node.replace(Bus.configured({ persist: true })), + Project.node.replace(globalProjectNode), + SessionExecution.node.replace(SessionExecution.noopLayer), ], ), ) diff --git a/packages/core/test/session-message-update.test.ts b/packages/core/test/session-message-update.test.ts index 3bd23bd8e43..25076956662 100644 --- a/packages/core/test/session-message-update.test.ts +++ b/packages/core/test/session-message-update.test.ts @@ -30,10 +30,9 @@ const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), [ - [Bus.node, Bus.configured({ persist: true })], - [Project.node, globalProjectNode], - [ - SessionExecution.node, + Bus.node.replace(Bus.configured({ persist: true })), + Project.node.replace(globalProjectNode), + SessionExecution.node.replace( Layer.succeed( SessionExecution.Service, SessionExecution.Service.of({ @@ -45,7 +44,7 @@ const it = testEffect( awaitIdle: () => Effect.void, }), ), - ], + ), ], ), ) @@ -154,8 +153,8 @@ describe("Session.updateMessage", () => { const target = AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]), [ - [Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })], - [Bus.node, Bus.configured({ persist: true })], + Database.node.replace(Database.configured({ path: path.join(tmp.path, "target.sqlite") })), + Bus.node.replace(Bus.configured({ persist: true })), ], ) diff --git a/packages/core/test/session-model-request.test.ts b/packages/core/test/session-model-request.test.ts index 7ca0852d6e7..99eb74c9a12 100644 --- a/packages/core/test/session-model-request.test.ts +++ b/packages/core/test/session-model-request.test.ts @@ -20,9 +20,13 @@ import { testEffect } from "./lib/effect" const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] }) const it = testEffect( - LayerNode.compile(LayerNode.group([SessionModelRequest.node, PluginHooks.node]), [ - [SessionModelTransport.node, SessionModelTransport.makeLayer({ open: () => Effect.die("Unexpected connection") })], - ]), + LayerNode.compile(LayerNode.group([SessionModelRequest.node, PluginHooks.node]), { + replacements: [ + SessionModelTransport.node.replace( + SessionModelTransport.makeLayer({ open: () => Effect.die("Unexpected connection") }), + ), + ], + }), ) const requestInput = (model: LanguageModel) => ({ diff --git a/packages/core/test/session-move.test.ts b/packages/core/test/session-move.test.ts index b625986efb8..f465e53b296 100644 --- a/packages/core/test/session-move.test.ts +++ b/packages/core/test/session-move.test.ts @@ -25,10 +25,7 @@ import { globalProjectNode } from "./lib/project" const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), - [ - [Project.node, globalProjectNode], - [SessionExecution.node, SessionExecution.noopLayer], - ], + [Project.node.replace(globalProjectNode), SessionExecution.node.replace(SessionExecution.noopLayer)], ), ) const itWithActiveExecution = testEffect( @@ -42,20 +39,21 @@ const itWithActiveExecution = testEffect( Session.node, ]), [ - [Project.node, globalProjectNode], - [ - LocationServiceMap.node, + Project.node.replace(globalProjectNode), + LocationServiceMap.node.replace( Layer.effect( LocationServiceMap.Service, LayerMap.make( (ref: Location.Ref) => Layer.merge( - LayerNode.compile(Location.boundNode(ref), [[Project.node, globalProjectNode]]), + LayerNode.compile(Location.boundNode(ref), { + replacements: [Project.node.replace(globalProjectNode)], + }), Layer.succeed(SessionRunner.Service, { drain: () => Effect.never }), ) as unknown as Layer.Layer, ), ), - ], + ), ], ), ) @@ -69,9 +67,9 @@ const itWithUnavailableDestination = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), [ - [Project.node, globalProjectNode], - [SessionExecution.node, SessionExecution.noopLayer], - [LocationServiceMap.node, unavailableLocations], + Project.node.replace(globalProjectNode), + SessionExecution.node.replace(SessionExecution.noopLayer), + LocationServiceMap.node.replace(unavailableLocations), ], ), ) diff --git a/packages/core/test/session-owned.test.ts b/packages/core/test/session-owned.test.ts index 848e04e9d10..5fbc5f4b609 100644 --- a/packages/core/test/session-owned.test.ts +++ b/packages/core/test/session-owned.test.ts @@ -51,10 +51,9 @@ const it = testEffect( SessionInbox.node, FSUtil.node, ]), - [ - [Bus.node, Bus.configured({ persist: true })], - [Global.node, tempGlobalLayer], - ], + { + replacements: [Bus.node.replace(Bus.configured({ persist: true })), Global.node.replace(tempGlobalLayer)], + }, ), ) const sessionID = SessionSchema.ID.make("ses_owned") diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 7591856881c..0043dffdf4d 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -35,10 +35,10 @@ import { Snapshot } from "@opencode-ai/core/snapshot" const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node, SessionStore.node]), - [[Bus.node, Bus.configured({ persist: true })]], + [Bus.node.replace(Bus.configured({ persist: true }))], ), ) -const sessionsLayer = AppNodeBuilder.build(Session.node, [[SessionExecution.node, SessionExecution.noopLayer]]) +const sessionsLayer = AppNodeBuilder.build(Session.node, [SessionExecution.node.replace(SessionExecution.noopLayer)]) const sessionID = Session.ID.make("ses_projector_test") const created = DateTime.makeUnsafe(0) const model = { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") } diff --git a/packages/core/test/session-prompt-hooks.test.ts b/packages/core/test/session-prompt-hooks.test.ts index 599c8707e7f..27193f2a2cd 100644 --- a/packages/core/test/session-prompt-hooks.test.ts +++ b/packages/core/test/session-prompt-hooks.test.ts @@ -38,11 +38,11 @@ const it = testEffect( PluginRuntime.providerNodeWithCell(runtime), ]), [ - [Bus.node, Bus.configured({ persist: true })], - [Global.node, tempGlobalLayer], - [Watcher.node, Watcher.configured({ enabled: false })], - [SessionExecution.node, SessionExecution.noopLayer], - [PluginRuntime.node, PluginRuntime.layerWithCell(runtime)], + Bus.node.replace(Bus.configured({ persist: true })), + Global.node.replace(tempGlobalLayer), + Watcher.node.replace(Watcher.configured({ enabled: false })), + SessionExecution.node.replace(SessionExecution.noopLayer), + PluginRuntime.node.replace(PluginRuntime.layerWithCell(runtime)), ], ), ) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 30c7c5859f7..26c20c98a85 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -95,9 +95,9 @@ const locations = (references: Layer.Layer) => Layer.provideMerge( Layer.mergeAll( references, - LayerNode.compile(LayerNode.group([PluginHooks.node, Skill.node]), [ - [Bus.node, Layer.succeed(Bus.Service, bus)], - ]), + LayerNode.compile(LayerNode.group([PluginHooks.node, Skill.node]), { + replacements: [Bus.node.replace(Layer.succeed(Bus.Service, bus))], + }), Layer.mock(Image.Service, { normalize: (_resource, content) => ready @@ -131,9 +131,9 @@ const sessionLayer = (references = Layer.mock(Reference.Service, { refresh: () = AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), [ - [Bus.node, Bus.configured({ persist: true })], - [SessionExecution.node, execution], - [LocationServiceMap.node, locations(references)], + Bus.node.replace(Bus.configured({ persist: true })), + SessionExecution.node.replace(execution), + LocationServiceMap.node.replace(locations(references)), ], ) const it = testEffect(sessionLayer()) @@ -298,13 +298,14 @@ describe("Session.prompt", () => { }).pipe( Effect.provide( AppNodeBuilder.build(Reference.node, [ - [Global.node, Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })], - [ - RepositoryCache.node, + Global.node.replace( + Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") }), + ), + RepositoryCache.node.replace( Layer.succeed(RepositoryCache.Service, { ensure: (input) => cache.ensure(input).pipe(Effect.tap(() => Queue.offer(completed, undefined))), }), - ], + ), ]), ), ) @@ -312,7 +313,7 @@ describe("Session.prompt", () => { Effect.scoped, Effect.provide( AppNodeBuilder.build(LayerNode.group([RepositoryCache.node, KV.node, EffectFlock.node]), [ - [Global.node, Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })], + Global.node.replace(Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })), ]), ), ) diff --git a/packages/core/test/session-remove.test.ts b/packages/core/test/session-remove.test.ts index 2ef15c97ba4..5f8ced6cd9a 100644 --- a/packages/core/test/session-remove.test.ts +++ b/packages/core/test/session-remove.test.ts @@ -39,9 +39,9 @@ const it = testEffect( LocationServiceMap.node, ]), [ - [Project.node, globalProjectNode], - [SessionExecution.node, SessionExecution.noopLayer], - [SessionModelTransport.node, transport], + Project.node.replace(globalProjectNode), + SessionExecution.node.replace(SessionExecution.noopLayer), + SessionModelTransport.node.replace(transport), ], ), ) diff --git a/packages/core/test/session-revert.test.ts b/packages/core/test/session-revert.test.ts index 525ee2a6b5e..9080771aa9a 100644 --- a/packages/core/test/session-revert.test.ts +++ b/packages/core/test/session-revert.test.ts @@ -31,9 +31,9 @@ const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]), [ - [Bus.node, Bus.configured({ persist: true })], - [Global.node, tempGlobalLayer], - [SessionExecution.node, SessionExecution.noopLayer], + Bus.node.replace(Bus.configured({ persist: true })), + Global.node.replace(tempGlobalLayer), + SessionExecution.node.replace(SessionExecution.noopLayer), ], ), ) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 6404e179880..dd8e05825c0 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -1,6 +1,6 @@ import { HttpRecorder } from "@opencode-ai/http-recorder" import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat" -import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/ai/route" +import { Auth, LLMClient, type LLMClientService, RequestExecutor } from "@opencode-ai/ai/route" import { Catalog } from "@opencode-ai/core/catalog" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -100,22 +100,22 @@ const promptCatalog = Layer.mock(Catalog.Service, { small: () => Effect.undefined, }, }) -const runnerLayer = (llmClient: Layer.Layer) => +const runnerLayer = (llmClient: Layer.Layer) => AppNodeBuilder.build(SessionRunnerLLM.node, [ - [Snapshot.node, Snapshot.noopLayer], - [LayerNodePlatform.llmClient, llmClient], - [SessionRunnerModel.node, models], - [InstructionBuiltIns.node, systemContext], - [InstructionDiscovery.node, instructionContext], - [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], - [SkillInstructions.node, skillInstructions], - [ReferenceInstructions.node, referenceInstructions], - [McpInstructions.node, mcpInstructions], - [Config.node, config], - [Permission.node, permission], - [PluginSupervisor.node, pluginSupervisor], + Snapshot.node.replace(Snapshot.noopLayer), + LayerNodePlatform.llmClient.replace(llmClient), + SessionRunnerModel.node.replace(models), + InstructionBuiltIns.node.replace(systemContext), + InstructionDiscovery.node.replace(instructionContext), + Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })), + SkillInstructions.node.replace(skillInstructions), + ReferenceInstructions.node.replace(referenceInstructions), + McpInstructions.node.replace(mcpInstructions), + Config.node.replace(config), + Permission.node.replace(permission), + PluginSupervisor.node.replace(pluginSupervisor), ]) -const execution = (llmClient: Layer.Layer) => +const execution = (llmClient: Layer.Layer) => Layer.effect( SessionExecution.Service, Effect.gen(function* () { @@ -133,7 +133,7 @@ const execution = (llmClient: Layer.Layer) => }) }), ).pipe(Layer.provide(runnerLayer(llmClient))) -const testLayer = (llmClient: Layer.Layer) => +const testLayer = (llmClient: Layer.Layer) => AppNodeBuilder.build( LayerNode.group([ Database.node, @@ -155,21 +155,21 @@ const testLayer = (llmClient: Layer.Layer) => Session.node, ]), [ - [Bus.node, Bus.configured({ persist: true })], - [LocationServiceMap.node, promptLocationNode], - [LayerNodePlatform.llmClient, llmClient], - [Permission.node, permission], - [Catalog.node, promptCatalog], - [SessionRunnerModel.node, models], - [InstructionBuiltIns.node, systemContext], - [InstructionDiscovery.node, instructionContext], - [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], - [SkillInstructions.node, skillInstructions], - [ReferenceInstructions.node, referenceInstructions], - [Config.node, config], - [Snapshot.node, Snapshot.noopLayer], - [PluginSupervisor.node, pluginSupervisor], - [SessionExecution.node, execution(llmClient)], + Bus.node.replace(Bus.configured({ persist: true })), + LocationServiceMap.node.replace(promptLocationNode), + LayerNodePlatform.llmClient.replace(llmClient), + Permission.node.replace(permission), + Catalog.node.replace(promptCatalog), + SessionRunnerModel.node.replace(models), + InstructionBuiltIns.node.replace(systemContext), + InstructionDiscovery.node.replace(instructionContext), + Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })), + SkillInstructions.node.replace(skillInstructions), + ReferenceInstructions.node.replace(referenceInstructions), + Config.node.replace(config), + Snapshot.node.replace(Snapshot.noopLayer), + PluginSupervisor.node.replace(pluginSupervisor), + SessionExecution.node.replace(execution(llmClient)), ], ) const it = testEffect(testLayer(client)) diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 8a2ac3ecaa9..4e0fdc9121d 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -132,7 +132,7 @@ test("provider-executed success derives content and retains provider result stat testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [ - [Bus.node, Bus.configured({ persist: true })], + Bus.node.replace(Bus.configured({ persist: true })), ]), ).effect("commits a hosted tool result when cancellation races with the aggregate lock", () => Effect.gen(function* () { diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 2eabb13beed..e50ec46818b 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -408,22 +408,22 @@ const layer = Layer.unwrap( }, }) const replacements: LayerNode.Replacements = [ - [Snapshot.node, Snapshot.noopLayer], - [LayerNodePlatform.llmClient, TestLLM.clientLayer], - [SessionRunnerModel.node, models], - [InstructionBuiltIns.node, systemContext], - [InstructionDiscovery.node, instructionContext], - [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], - [SkillInstructions.node, skillInstructions], - [ReferenceInstructions.node, referenceInstructions], - [Permission.node, permission], - [Config.node, config], - [PluginSupervisor.node, pluginSupervisor], - [SessionModelTransport.node, modelTransport], + Snapshot.node.replace(Snapshot.noopLayer), + LayerNodePlatform.llmClient.replace(TestLLM.clientLayer.pipe(Layer.provide(testLLM))), + SessionRunnerModel.node.replace(models), + InstructionBuiltIns.node.replace(systemContext), + InstructionDiscovery.node.replace(instructionContext), + Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })), + SkillInstructions.node.replace(skillInstructions), + ReferenceInstructions.node.replace(referenceInstructions), + Permission.node.replace(permission), + Config.node.replace(config), + PluginSupervisor.node.replace(pluginSupervisor), + SessionModelTransport.node.replace(modelTransport), ] const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ ...replacements, - [McpInstructions.node, mcpInstructions], + McpInstructions.node.replace(mcpInstructions), ]) const execution = Layer.effect( SessionExecution.Service, @@ -485,10 +485,10 @@ const layer = Layer.unwrap( ]), [ ...replacements, - [Bus.node, Bus.configured({ persist: true })], - [LocationServiceMap.node, promptLocationNode], - [Catalog.node, promptCatalog], - [SessionExecution.node, execution], + Bus.node.replace(Bus.configured({ persist: true })), + LocationServiceMap.node.replace(promptLocationNode), + Catalog.node.replace(promptCatalog), + SessionExecution.node.replace(execution), ], ) }), diff --git a/packages/core/test/session-shell.test.ts b/packages/core/test/session-shell.test.ts index 46546c5a961..a0cb863c7ef 100644 --- a/packages/core/test/session-shell.test.ts +++ b/packages/core/test/session-shell.test.ts @@ -54,8 +54,8 @@ const executionLayer = Layer.effect( const it = testEffect( AppNodeBuilder.build(LayerNode.group([Bus.node, Session.node, SessionExecution.node, LocationServiceMap.node]), [ - [Bus.node, Bus.configured({ persist: true })], - [SessionExecution.node, executionLayer], + Bus.node.replace(Bus.configured({ persist: true })), + SessionExecution.node.replace(executionLayer.pipe(Layer.provide(controlLayer))), ]).pipe(Layer.provideMerge(controlLayer)), ) diff --git a/packages/core/test/session-skill.test.ts b/packages/core/test/session-skill.test.ts index 0ac7ad96837..1cb4c8f2c2f 100644 --- a/packages/core/test/session-skill.test.ts +++ b/packages/core/test/session-skill.test.ts @@ -71,9 +71,9 @@ const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), [ - [LocationServiceMap.node, locations], - [Project.node, globalProjectNode], - [SessionExecution.node, SessionExecution.noopLayer], + LocationServiceMap.node.replace(locations), + Project.node.replace(globalProjectNode), + SessionExecution.node.replace(SessionExecution.noopLayer), ], ), ) diff --git a/packages/core/test/session-step.test.ts b/packages/core/test/session-step.test.ts index e993f66fb17..063ade46afa 100644 --- a/packages/core/test/session-step.test.ts +++ b/packages/core/test/session-step.test.ts @@ -27,7 +27,7 @@ import { testEffect } from "./lib/effect" const it = testEffect( Layer.merge( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, ToolOutput.node]), [ - [Bus.node, Bus.configured({ persist: true })], + Bus.node.replace(Bus.configured({ persist: true })), ]), TestLLM.testLayer(), ), diff --git a/packages/core/test/session-store.test.ts b/packages/core/test/session-store.test.ts index fcf472f37d2..ae72cfc52e8 100644 --- a/packages/core/test/session-store.test.ts +++ b/packages/core/test/session-store.test.ts @@ -17,7 +17,7 @@ import { testEffect } from "./lib/effect" const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]), [ - [Bus.node, Bus.configured({ persist: true })], + Bus.node.replace(Bus.configured({ persist: true })), ]), ) diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index 5422eedef34..8f508d1bb4b 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -126,11 +126,11 @@ const it = testEffect( SessionTitle.node, ]), [ - [llmClient, client], - [Catalog.node, catalog], - [SessionRunnerModel.node, models], - [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], - [PluginSupervisor.node, Layer.mock(PluginSupervisor.Service, { flush: Effect.void })], + llmClient.replace(client), + Catalog.node.replace(catalog), + SessionRunnerModel.node.replace(models), + Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })), + PluginSupervisor.node.replace(Layer.mock(PluginSupervisor.Service, { flush: Effect.void })), ], ), ) diff --git a/packages/core/test/session-tool-progress.test.ts b/packages/core/test/session-tool-progress.test.ts index d37fed2b9cd..191d8be18bd 100644 --- a/packages/core/test/session-tool-progress.test.ts +++ b/packages/core/test/session-tool-progress.test.ts @@ -21,7 +21,7 @@ import { testEffect } from "./lib/effect" const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [ - [Bus.node, Bus.configured({ persist: true })], + Bus.node.replace(Bus.configured({ persist: true })), ]), ) const model = { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") } diff --git a/packages/core/test/session-view.test.ts b/packages/core/test/session-view.test.ts index f7e74c73cb9..e6b244785bc 100644 --- a/packages/core/test/session-view.test.ts +++ b/packages/core/test/session-view.test.ts @@ -25,9 +25,9 @@ const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), [ - [Bus.node, Bus.configured({ persist: true })], - [Project.node, globalProjectNode], - [SessionExecution.node, SessionExecution.noopLayer], + Bus.node.replace(Bus.configured({ persist: true })), + Project.node.replace(globalProjectNode), + SessionExecution.node.replace(SessionExecution.noopLayer), ], ), ) @@ -186,8 +186,8 @@ describe("Session.view", () => { const targetLayer = AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]), [ - [Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })], - [Bus.node, Bus.configured({ persist: true })], + Database.node.replace(Database.configured({ path: path.join(tmp.path, "target.sqlite") })), + Bus.node.replace(Bus.configured({ persist: true })), ], ) diff --git a/packages/core/test/session-wait.test.ts b/packages/core/test/session-wait.test.ts index e6947b13b81..1f01d9803c0 100644 --- a/packages/core/test/session-wait.test.ts +++ b/packages/core/test/session-wait.test.ts @@ -22,10 +22,7 @@ const execution = Layer.mock(SessionExecution.Service, { const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), - [ - [Project.node, globalProjectNode], - [SessionExecution.node, execution], - ], + [Project.node.replace(globalProjectNode), SessionExecution.node.replace(execution)], ), ) diff --git a/packages/core/test/shell-cleanup.test.ts b/packages/core/test/shell-cleanup.test.ts index 41804a25e0b..30b8cdd315d 100644 --- a/packages/core/test/shell-cleanup.test.ts +++ b/packages/core/test/shell-cleanup.test.ts @@ -14,7 +14,7 @@ const withStore = (body: (fs: FSUtil.Interface, root: string) => Effect Effect.promise(() => tmpdir()), (tmp) => { const layer = AppNodeBuilder.build(LayerNode.group([FSUtil.node, Global.node]), [ - [Global.node, Global.layerWith({ data: tmp.path })], + Global.node.replace(Global.layerWith({ data: tmp.path })), ]) return Effect.gen(function* () { const fs = yield* FSUtil.Service diff --git a/packages/core/test/skill-discovery.test.ts b/packages/core/test/skill-discovery.test.ts index b8dc53e2896..0c49d558301 100644 --- a/packages/core/test/skill-discovery.test.ts +++ b/packages/core/test/skill-discovery.test.ts @@ -44,7 +44,7 @@ const fixture = Effect.gen(function* () { return yield* discovery.pull(base) }).pipe( Effect.provide( - AppNodeBuilder.build(SkillDiscovery.node, [[Global.node, Global.layerWith({ cache: tmp.path })]]), + AppNodeBuilder.build(SkillDiscovery.node, [Global.node.replace(Global.layerWith({ cache: tmp.path }))]), ), ) return { directories, requests: state.requests.slice() } diff --git a/packages/core/test/skill/instructions.test.ts b/packages/core/test/skill/instructions.test.ts index dcb54d478ed..c3e352dc650 100644 --- a/packages/core/test/skill/instructions.test.ts +++ b/packages/core/test/skill/instructions.test.ts @@ -41,7 +41,7 @@ const manual = Skill.Info.make({ const layer = (list: () => Skill.Info[]) => AppNodeBuilder.build(SkillInstructions.node, [ - [Skill.node, Layer.mock(Skill.Service, { list: () => Effect.succeed(list()) })], + Skill.node.replace(Layer.mock(Skill.Service, { list: () => Effect.succeed(list()) })), ]) describe("SkillInstructions", () => { diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts index 122818facd5..1f1027ac9f9 100644 --- a/packages/core/test/snapshot.test.ts +++ b/packages/core/test/snapshot.test.ts @@ -54,9 +54,9 @@ describe("Snapshot", () => { }, }) const layer = AppNodeBuilder.build(Snapshot.node, [ - [Location.node, Layer.succeed(Location.Service, location)], - [Global.node, Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })], - [Git.node, Layer.succeed(Git.Service, instrumented)], + Location.node.replace(Layer.succeed(Location.Service, location)), + Global.node.replace(Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })), + Git.node.replace(Layer.succeed(Git.Service, instrumented)), ]) yield* Effect.gen(function* () { @@ -239,8 +239,8 @@ describe("Snapshot", () => { function snapshotLayer(data: string, directory: string) { return AppNodeBuilder.build(Snapshot.node, [ - [Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))], - [Global.node, Global.layerWith({ data, config: path.join(data, "config") })], + Location.node.replace(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))), + Global.node.replace(Global.layerWith({ data, config: path.join(data, "config") })), ]) } diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index fa0b3e22a27..9cdfe584def 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -92,8 +92,7 @@ const withTool = ( }).pipe( Effect.provide( AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, editToolNode]), [ - [ - Environment.node, + Environment.node.replace( transformEnvironmentFiles((files) => ({ read: (target, range) => files @@ -104,10 +103,10 @@ const withTool = ( write: (target, content) => Effect.sync(() => fixture.writes.push(target)).pipe(Effect.andThen(files.write(target, content))), })), - ], - [Location.node, activeLocation], - [Formatter.node, fixture.formatter], - [Permission.node, fixture.permission], + ), + Location.node.replace(activeLocation), + Formatter.node.replace(fixture.formatter), + Permission.node.replace(fixture.permission), ]), ), ) diff --git a/packages/core/test/tool-output.test.ts b/packages/core/test/tool-output.test.ts index b5fa2d14aad..1c14c9acada 100644 --- a/packages/core/test/tool-output.test.ts +++ b/packages/core/test/tool-output.test.ts @@ -18,7 +18,7 @@ const withStore = ( Effect.promise(() => tmpdir()), (tmp) => { const layer = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [ - [Global.node, Global.layerWith({ data: tmp.path })], + Global.node.replace(Global.layerWith({ data: tmp.path })), ]) return Effect.gen(function* () { const output = yield* ToolOutput.Service diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index 0295b042009..30e95e5486b 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -100,8 +100,7 @@ const withTool = ( }).pipe( Effect.provide( AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, patchToolNode]), [ - [ - Environment.node, + Environment.node.replace( transformEnvironmentFiles((files) => ({ read: (target, range) => Effect.sync(() => { @@ -120,10 +119,10 @@ const withTool = ( return files.write(target, content) }, })), - ], - [Location.node, activeLocation], - [Formatter.node, formatter], - [Permission.node, permission], + ), + Location.node.replace(activeLocation), + Formatter.node.replace(formatter), + Permission.node.replace(permission), ]), ), ) diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index 520dbe3225f..ec5becae5fd 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -66,9 +66,9 @@ const questionToolNode = makeLocationNode({ const it = testEffect( AppNodeBuilder.build(LayerNode.group([Tool.node, questionToolNode]), [ - [Permission.node, permission], - [Form.node, form], - [Image.node, imagePassthrough], + Permission.node.replace(permission), + Form.node.replace(form), + Image.node.replace(imagePassthrough), ]), ) diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index d680e8e4f26..56fc610ca17 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -142,14 +142,14 @@ const unavailableImage = Layer.mock(Image.Service, { const readLayer = (imageLayer: Layer.Layer) => Layer.mergeAll( AppNodeBuilder.build(LayerNode.group([Tool.node, readToolNode]), [ - [ReadToolFileSystem.node, reader], - [Permission.node, permission], - [Config.node, config], - [Image.node, imageLayer], - [LocationMutation.node, mutation], - [FSUtil.node, testFileSystem], - [Location.node, locationLayer], - [Global.node, Global.layerWith({ data: Global.Path.data })], + ReadToolFileSystem.node.replace(reader), + Permission.node.replace(permission), + Config.node.replace(config), + Image.node.replace(imageLayer), + LocationMutation.node.replace(mutation), + FSUtil.node.replace(testFileSystem), + Location.node.replace(locationLayer), + Global.node.replace(Global.layerWith({ data: Global.Path.data })), ]), // Merge by reference so Config.Test and Image.Service resolve to the memoized instances. config, diff --git a/packages/core/test/tool-registry.test.ts b/packages/core/test/tool-registry.test.ts index b25643aab42..b38826585c1 100644 --- a/packages/core/test/tool-registry.test.ts +++ b/packages/core/test/tool-registry.test.ts @@ -43,7 +43,7 @@ const imageStore = Layer.mock(Image.Service, { }, }) const registryLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, PluginHooks.node, SessionModelRequest.node]), [ - [Image.node, imageStore], + Image.node.replace(imageStore), ]) const it = testEffect(registryLayer) const identity = { diff --git a/packages/core/test/tool-search.test.ts b/packages/core/test/tool-search.test.ts index 0627d3555e9..dec824ab238 100644 --- a/packages/core/test/tool-search.test.ts +++ b/packages/core/test/tool-search.test.ts @@ -45,19 +45,17 @@ const withTools = ( }).pipe( Effect.provide( AppNodeBuilder.build(LayerNode.group([Tool.node, globToolNode, grepToolNode]), [ - [ - Location.node, + Location.node.replace( Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), - ], - [ - Permission.node, + ), + Permission.node.replace( permissionLayer({ assert: (input) => Effect.sync(() => { assertions?.push(input) }), }), - ], + ), ]), ), ) diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 6c9feb96f78..779b0353a6a 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -156,17 +156,19 @@ const nodes = LayerNode.group([ Global.node, ]) const replacements = [ - [SessionExecution.node, executionNode], - [Permission.node, permission], - [Global.node, tempGlobalLayer], + SessionExecution.node.replace(executionNode), + Permission.node.replace(permission), + Global.node.replace(tempGlobalLayer), ] satisfies LayerNode.Replacements const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements)) -const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, shellPluginSupervisor]])) +const it = testEffect( + AppNodeBuilder.build(nodes, [...replacements, PluginSupervisor.node.replace(shellPluginSupervisor)]), +) const permissionIt = testEffect( AppNodeBuilder.build(LayerNode.group([nodes, PermissionSaved.node]), [ - [SessionExecution.node, executionNode], - [Global.node, tempGlobalLayer], - [PluginSupervisor.node, shellPluginSupervisor], + SessionExecution.node.replace(executionNode), + Global.node.replace(tempGlobalLayer), + PluginSupervisor.node.replace(shellPluginSupervisor), ]), ) diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index a4f93e1c111..ecd27383a03 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -74,9 +74,9 @@ describe("SkillTool", () => { list: () => Effect.succeed(current), }) const skillToolLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, skillToolNode]), [ - [Permission.node, permission], - [Skill.node, skills], - [Image.node, imagePassthrough], + Permission.node.replace(permission), + Skill.node.replace(skills), + Image.node.replace(imagePassthrough), ]) return yield* Effect.gen(function* () { diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index b028dd8bfa4..460b800cede 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -122,18 +122,19 @@ const nodes = LayerNode.group([ LocationServiceMap.node, ]) const replacements = [ - [SessionExecution.node, executionNode], - [Global.node, tempGlobalLayer], + SessionExecution.node.replace(executionNode), + Global.node.replace(tempGlobalLayer), ] satisfies LayerNode.Replacements const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements)) -const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, subagentPluginSupervisor]])) +const it = testEffect( + AppNodeBuilder.build(nodes, [...replacements, PluginSupervisor.node.replace(subagentPluginSupervisor)]), +) const completionIt = testEffect( AppNodeBuilder.build(LayerNode.group([nodes, SessionRestart.node, KV.node]), [ - [Global.node, tempGlobalLayer], - [PluginSupervisor.node, subagentPluginSupervisor], - [LayerNodePlatform.llmClient, TestLLM.testLayer({ fallback: TestLLM.text(childText, "completion") })], - [ - SessionRunnerModel.node, + Global.node.replace(tempGlobalLayer), + PluginSupervisor.node.replace(subagentPluginSupervisor), + LayerNodePlatform.llmClient.replace(TestLLM.testLayer({ fallback: TestLLM.text(childText, "completion") })), + SessionRunnerModel.node.replace( Layer.succeed(SessionRunnerModel.Service, { resolve: () => Effect.succeed( @@ -147,7 +148,7 @@ const completionIt = testEffect( ), ), }), - ], + ), ]), ) diff --git a/packages/core/test/tool-webfetch.test.ts b/packages/core/test/tool-webfetch.test.ts index dcc8895fd9f..f58f6cf8bc4 100644 --- a/packages/core/test/tool-webfetch.test.ts +++ b/packages/core/test/tool-webfetch.test.ts @@ -42,11 +42,11 @@ const http = Layer.succeed( const permission = permissionLayer({ assert: (input) => Effect.sync(() => assertions.push(input)) }) const toolLayer = (replacements: LayerNode.Replacements = []) => AppNodeBuilder.build(LayerNode.group([Tool.node, webFetchToolNode]), [ - [Permission.node, permission], - [Image.node, imagePassthrough], + Permission.node.replace(permission), + Image.node.replace(imagePassthrough), ...replacements, ]) -const it = testEffect(toolLayer([[LayerNodePlatform.httpClient, http]])) +const it = testEffect(toolLayer([LayerNodePlatform.httpClient.replace(http)])) const live = testEffect(toolLayer()) const reset = () => { diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 704d5a9ee68..4259599546d 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -70,8 +70,7 @@ const setup = Effect.gen(function* () { ) const context = yield* Layer.build( AppNodeBuilder.build(LayerNode.group([Tool.node, webSearchToolNode]), [ - [ - Permission.node, + Permission.node.replace( permissionLayer({ assert: (input) => Effect.sync(() => { @@ -79,10 +78,9 @@ const setup = Effect.gen(function* () { fixture.assertions.push(input) }), }), - ], - [WebSearch.node, Layer.succeed(WebSearch.Service, websearch)], - [ - Form.node, + ), + WebSearch.node.replace(Layer.succeed(WebSearch.Service, websearch)), + Form.node.replace( Layer.mock(Form.Service, { ask: (input) => Effect.gen(function* () { @@ -91,8 +89,8 @@ const setup = Effect.gen(function* () { return fixture.formResponses.shift() ?? fixture.formResponse }), }), - ], - [Image.node, imagePassthrough], + ), + Image.node.replace(imagePassthrough), ]), ) return Object.assign(fixture, { websearch, kv, registry: Context.get(context, Tool.Service) }) diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index 52fc4a92ad2..f1a18a28e8f 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -80,16 +80,15 @@ const withTool = ( }).pipe( Effect.provide( AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]), [ - [ - Environment.node, + Environment.node.replace( transformEnvironmentFiles((files) => ({ write: (target, content) => Effect.sync(() => fixture.writes.push(target)).pipe(Effect.andThen(files.write(target, content))), })), - ], - [Location.node, activeLocation], - [Formatter.node, fixture.formatter], - [Permission.node, fixture.permission], + ), + Location.node.replace(activeLocation), + Formatter.node.replace(fixture.formatter), + Permission.node.replace(fixture.permission), ]), ), ) diff --git a/packages/core/test/util/effect-flock.test.ts b/packages/core/test/util/effect-flock.test.ts index 4af0445c8c3..ff4e074e30b 100644 --- a/packages/core/test/util/effect-flock.test.ts +++ b/packages/core/test/util/effect-flock.test.ts @@ -55,7 +55,7 @@ const testGlobal = Global.layerWith({ log: os.tmpdir(), }) -const testLayer = LayerNode.compile(EffectFlock.node, [[Global.node, testGlobal]]) +const testLayer = LayerNode.compile(EffectFlock.node, { replacements: [Global.node.replace(testGlobal)] }) // --------------------------------------------------------------------------- // Tests diff --git a/packages/core/test/vcs-hg.test.ts b/packages/core/test/vcs-hg.test.ts index 5469c16e93f..3563b70fa98 100644 --- a/packages/core/test/vcs-hg.test.ts +++ b/packages/core/test/vcs-hg.test.ts @@ -22,20 +22,21 @@ const describeHg = Bun.which("hg") ? describe : describe.skip const provide = (directory: string) => Effect.provide( - LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node, AppProcess.node, FSUtil.node]), [ - [ - Location.node, - Layer.succeed( - Location.Service, - Location.Service.of( - location( - { directory: AbsolutePath.make(directory) }, - { vcs: { type: "hg", store: AbsolutePath.make(path.join(directory, ".hg")) } }, + LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node, AppProcess.node, FSUtil.node]), { + replacements: [ + Location.node.replace( + Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + { vcs: { type: "hg", store: AbsolutePath.make(path.join(directory, ".hg")) } }, + ), ), ), ), ], - ]), + }), ) const withTmp = (f: (directory: string) => Effect.Effect) => diff --git a/packages/core/test/vcs.test.ts b/packages/core/test/vcs.test.ts index bb14ef9f437..2ee1a96d379 100644 --- a/packages/core/test/vcs.test.ts +++ b/packages/core/test/vcs.test.ts @@ -22,25 +22,26 @@ import { host } from "./plugin/host" const provide = (directory: string, input: { git?: boolean; worktree?: string } = {}) => Effect.provide( - LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node, AppProcess.node, FSUtil.node, Git.node]), [ - [ - Location.node, - Layer.succeed( - Location.Service, - Location.Service.of( - location( - { directory: AbsolutePath.make(directory) }, - { - projectDirectory: input.worktree ? AbsolutePath.make(input.worktree) : undefined, - ...(input.git - ? { vcs: { type: "git", store: AbsolutePath.make(path.join(input.worktree ?? directory, ".git")) } } - : {}), - }, + LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node, AppProcess.node, FSUtil.node, Git.node]), { + replacements: [ + Location.node.replace( + Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + { + projectDirectory: input.worktree ? AbsolutePath.make(input.worktree) : undefined, + ...(input.git + ? { vcs: { type: "git", store: AbsolutePath.make(path.join(input.worktree ?? directory, ".git")) } } + : {}), + }, + ), ), ), ), ], - ]), + }), ) const withTmp = (f: (directory: string) => Effect.Effect) => diff --git a/packages/core/test/workspace.test.ts b/packages/core/test/workspace.test.ts index f1944ca431c..3797cfdffe5 100644 --- a/packages/core/test/workspace.test.ts +++ b/packages/core/test/workspace.test.ts @@ -41,7 +41,7 @@ const driver = WorkspaceDriver.make({ const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Workspace.configured({ idleThreshold: "5 minutes", pollInterval: "1 minute" })]), - [[WorkspaceDriver.node, WorkspaceDriver.registryNode({ fake: driver, other: driver })]], + [WorkspaceDriver.node.replace(WorkspaceDriver.registryNode({ fake: driver, other: driver }))], ), ) diff --git a/packages/sdk/src/internal/host.ts b/packages/sdk/src/internal/host.ts index 7adb2111f73..66f8b6884a1 100644 --- a/packages/sdk/src/internal/host.ts +++ b/packages/sdk/src/internal/host.ts @@ -35,7 +35,7 @@ export const create = Effect.fn("EmbeddedHost.create")(function* ( database: { path: ":memory:", ...server.database }, }, workspaceProviders - ? [...(embed.overrides ?? []), [WorkspaceDriver.node, WorkspaceDriver.registryNode(workspaceProviders)]] + ? [...(embed.overrides ?? []), WorkspaceDriver.node.replace(WorkspaceDriver.registryNode(workspaceProviders))] : embed.overrides, ).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(layer(log))), ) diff --git a/packages/sdk/test/embedded.test.ts b/packages/sdk/test/embedded.test.ts index 1a7fd4661ec..2a7f7a13d88 100644 --- a/packages/sdk/test/embedded.test.ts +++ b/packages/sdk/test/embedded.test.ts @@ -43,13 +43,15 @@ for (const selection of ["explicit", "default"] as const) { const llm = yield* TestLLM.Test.pipe( Effect.provide(TestLLM.testLayer({ fallback: TestLLM.text("ready", "answer") })), ) - const supervisor = Layer.effect( - PluginSupervisor.Service, - Effect.gen(function* () { - const plugins = yield* PluginSupervisor.Service - return { flush: release.open.pipe(Effect.andThen(plugins.flush)) } - }), - ).pipe(Layer.provide(PluginSupervisor.layer)) + const supervisor = PluginSupervisor.node.mapLayer((layer) => + Layer.effect( + PluginSupervisor.Service, + Effect.gen(function* () { + const plugins = yield* PluginSupervisor.Service + return { flush: release.open.pipe(Effect.andThen(plugins.flush)) } + }), + ).pipe(Layer.provide(layer)), + ) const opencode = yield* fixture.sdk.OpenCode.create( { config: { @@ -71,8 +73,8 @@ for (const selection of ["explicit", "default"] as const) { }, { overrides: [ - [llmClient, Layer.succeed(LLMClient.Service, llm)], - [PluginSupervisor.node, { ...PluginSupervisor.node, implementation: supervisor }], + llmClient.replace(Layer.succeed(LLMClient.Service, llm)), + PluginSupervisor.node.replace(supervisor), ], }, ) @@ -685,10 +687,7 @@ const workspaceModelScenario = (fixture: Fixture, policy: "eager" | "lazy") => workspaceProviders: { fake: driver }, }, { - overrides: [ - [llmClient, client], - [SessionRunnerModel.node, models], - ], + overrides: [llmClient.replace(client), SessionRunnerModel.node.replace(models)], }, ) const workspaceID = yield* opencode.workspace.create({ provider: "fake" }) @@ -790,8 +789,8 @@ it.live( }, { overrides: [ - [llmClient, Layer.succeed(LLMClient.Service, llm)], - [SessionRunnerModel.node, models], + llmClient.replace(Layer.succeed(LLMClient.Service, llm)), + SessionRunnerModel.node.replace(models), ], }, ) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 9d2ee4a7c27..4c9cd55c555 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -100,35 +100,33 @@ function makeRoutes( ) { const pluginRuntimeCell = PluginRuntime.makeCell() const standard: LayerNode.Replacements = [ - [Database.node, Database.configured(options.database)], - [PersistentPty.node, PersistentPty.configured(options.pty)], - [Bus.node, Bus.configured({ persist: options.events?.persist })], - [App.node, App.configured(options.app)], - [ModelsDev.node, ModelsDev.configured(options.models)], - [Watcher.node, Watcher.configured({ enabled: options.fs?.filewatcher })], - [FileSystemSearch.node, FileSystemSearch.configured({ fff: options.fs?.fff })], - [Global.node, Global.layerWith(options.config?.directory ? { config: options.config.directory } : {})], - [ - Config.node, + Database.node.replace(Database.configured(options.database)), + PersistentPty.node.replace(PersistentPty.configured(options.pty)), + Bus.node.replace(Bus.configured({ persist: options.events?.persist })), + App.node.replace(App.configured(options.app)), + ModelsDev.node.replace(ModelsDev.configured(options.models)), + Watcher.node.replace(Watcher.configured({ enabled: options.fs?.filewatcher })), + FileSystemSearch.node.replace(FileSystemSearch.configured({ fff: options.fs?.fff })), + Global.node.replace(Global.layerWith(options.config?.directory ? { config: options.config.directory } : {})), + Config.node.replace( Config.configured({ project: options.config?.project, file: options.config?.file, content: options.config?.content, }), - ], - [InstructionDiscovery.node, InstructionDiscovery.configured({ project: options.config?.project })], - [ShellSelect.node, ShellSelect.configured({ gitbash: options.windows?.gitbash })], - [ - Mcp.node, + ), + InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: options.config?.project })), + ShellSelect.node.replace(ShellSelect.configured({ gitbash: options.windows?.gitbash })), + Mcp.node.replace( Mcp.configured({ clientInfo: { name: options.app?.name ?? "opencode", version: options.app?.version ?? "unknown", }, }), - ], - [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)], - [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)], + ), + PluginRuntime.node.replace(PluginRuntime.layerWithCell(pluginRuntimeCell)), + PluginRuntime.providerNode.replace(PluginRuntime.providerNodeWithCell(pluginRuntimeCell)), ] const replacements: LayerNode.Replacements = [...standard, ...overrides] const serviceLayer = options.simulation diff --git a/packages/server/src/workerd.ts b/packages/server/src/workerd.ts index a27dadadba7..2d2e23f865e 100644 --- a/packages/server/src/workerd.ts +++ b/packages/server/src/workerd.ts @@ -75,16 +75,16 @@ export function serverOptions(options: Options): ServerOptions { /** The workerd replacement graph, applied after the standard server replacements. */ export function replacements(options: Options): LayerNode.Replacements { return [ - [Database.node, Database.configuredClient(sqliteLayer({ storage: options.storage }))], - [CrossSpawnSpawner.node, EnvironmentUnavailable.layer], - [Snapshot.node, Snapshot.noopLayer], - [Vcs.node, vcsLayer], - [FileSystem.node, fileSystemLayer], - [FileSystemSearch.node, fileSystemSearchLayer], - [Pty.node, ptyLayer], + Database.node.replace(Database.configuredClient(sqliteLayer({ storage: options.storage }))), + CrossSpawnSpawner.node.replace(EnvironmentUnavailable.layer), + Snapshot.node.replace(Snapshot.noopLayer), + Vcs.node.replace(vcsLayer), + FileSystem.node.replace(fileSystemLayer), + FileSystemSearch.node.replace(fileSystemSearchLayer), + Pty.node.replace(ptyLayer), // Precompiled (internal and SDK) plugins only: no plugin-directory scan, npm // install, or import of plugin code from disk. - [ConfigPluginSource.node, ConfigPluginSource.empty], + ConfigPluginSource.node.replace(ConfigPluginSource.empty), ] } diff --git a/packages/server/test/fetch.test.ts b/packages/server/test/fetch.test.ts index c7a3cc98d37..3b59b9bf71a 100644 --- a/packages/server/test/fetch.test.ts +++ b/packages/server/test/fetch.test.ts @@ -255,7 +255,7 @@ it.live("creates idempotent caller-identified workspaces through the HttpApi", ( Effect.gen(function* () { const handler = yield* ServerFetch.make(options, { overrides: [ - [WorkspaceDriver.node, WorkspaceDriver.registryNode({ fake: workspaceDriver, other: workspaceDriver })], + WorkspaceDriver.node.replace(WorkspaceDriver.registryNode({ fake: workspaceDriver, other: workspaceDriver })), ], }) const id = Workspace.ID.create() diff --git a/packages/server/test/generate.test.ts b/packages/server/test/generate.test.ts index 7c64aef94de..a61c4f0ec0f 100644 --- a/packages/server/test/generate.test.ts +++ b/packages/server/test/generate.test.ts @@ -48,7 +48,7 @@ it.live("uses base configuration without depending on process.cwd()", () => config: { directory: global }, fs: { filewatcher: false }, }, - { overrides: [[Generate.node, generate]] }, + { overrides: [Generate.node.replace(generate)] }, ) expect(global).not.toBe(process.cwd()) diff --git a/packages/server/test/session-message-update.test.ts b/packages/server/test/session-message-update.test.ts index 39352db6bf1..49b0b72eb2b 100644 --- a/packages/server/test/session-message-update.test.ts +++ b/packages/server/test/session-message-update.test.ts @@ -8,6 +8,7 @@ import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionMessage } from "@opencode-ai/core/session/message" import { Money } from "@opencode-ai/schema/money" +import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { Effect, Layer } from "effect" import { it } from "../../core/test/lib/effect" import { ServerFetch } from "../src/fetch" @@ -55,7 +56,13 @@ it.live("updates completed assistant message content through the session HTTP AP ) const handler = yield* ServerFetch.make( { app: { version: "test-version" }, database: { path: ":memory:" }, fs: { filewatcher: false } }, - { overrides: [[SessionExecution.node, execution]] }, + { + overrides: [ + SessionExecution.node.replace( + makeGlobalNode({ service: SessionExecution.Service, layer: execution, deps: [Bus.node] }), + ), + ], + }, ) const created = yield* Effect.promise(() => handler( diff --git a/packages/server/test/vcs.test.ts b/packages/server/test/vcs.test.ts index 760854fac95..59921c5451b 100644 --- a/packages/server/test/vcs.test.ts +++ b/packages/server/test/vcs.test.ts @@ -77,8 +77,7 @@ it.live("maps a failing base provider to HTTP 503 instead of null metadata", () { database: { path: ":memory:" }, config: { directory: tmp.path }, fs: { filewatcher: false } }, { overrides: [ - [ - SdkPlugins.node, + SdkPlugins.node.replace( Layer.succeed( SdkPlugins.Service, SdkPlugins.Service.of({ @@ -106,7 +105,7 @@ it.live("maps a failing base provider to HTTP 503 instead of null metadata", () ], }), ), - ], + ), ], }, ) diff --git a/packages/simulation/src/backend/index.ts b/packages/simulation/src/backend/index.ts index 537405b44b4..04c2d588d34 100644 --- a/packages/simulation/src/backend/index.ts +++ b/packages/simulation/src/backend/index.ts @@ -23,37 +23,39 @@ import { SimulatedProvider } from "./simulated-provider" export const simulationReplacements: (app: { readonly version: string -}) => Effect.Effect = Effect.fn("Simulation.replacements")(function* (app: { - readonly version: string -}) { - // ModelsDev dies when its catalog fetch fails, so simulation answers it with - // an empty catalog; providers come from seeded config instead. - const models = SimulationNetwork.json("GET", "https://models.opencode.ai/api.json", {}) - const drive = yield* Config.string("OPENCODE_DRIVE").pipe(Config.withDefault(undefined)) - if (!drive) return [[httpClient, SimulationNetwork.layer([models])]] satisfies LayerNode.Replacements +}) => Effect.Effect = Effect.fn("Simulation.replacements")( + function* (app: { readonly version: string }) { + // ModelsDev dies when its catalog fetch fails, so simulation answers it with + // an empty catalog; providers come from seeded config instead. + const models = SimulationNetwork.json("GET", "https://models.opencode.ai/api.json", {}) + const drive = yield* Config.string("OPENCODE_DRIVE").pipe(Config.withDefault(undefined)) + if (!drive) return [httpClient.replace(SimulationNetwork.layer([models]))] satisfies LayerNode.Replacements - const manifest = yield* DriveManifest.resolve() - const networkLayer = Layer.effect( - HttpClient.HttpClient, - Effect.gen(function* () { - const provider = yield* SimulatedProvider.Service - const network = yield* SimulationNetwork.make([SimulationOpenAI.route(provider), models]) - return network.client - }), - ).pipe( - Layer.provide( - SimulatedProvider.layerDrive({ - endpoint: manifest.endpoints.backend, - version: app.version, + const manifest = yield* DriveManifest.resolve() + const networkLayer = Layer.effect( + HttpClient.HttpClient, + Effect.gen(function* () { + const provider = yield* SimulatedProvider.Service + const network = yield* SimulationNetwork.make([SimulationOpenAI.route(provider), models]) + return network.client }), - ), - ) - const networkNode = makeGlobalNode({ - service: HttpClient.HttpClient, - layer: networkLayer, - deps: [SdkPlugins.node], - }) - return [[httpClient, networkNode]] satisfies LayerNode.Replacements -}) + ).pipe( + Layer.provide( + SimulatedProvider.layerDrive({ + endpoint: manifest.endpoints.backend, + version: app.version, + }), + ), + // The platform HTTP contract is infallible; a failed driver installation aborts startup. + Layer.orDie, + ) + const networkNode = makeGlobalNode({ + service: HttpClient.HttpClient, + layer: networkLayer, + deps: [SdkPlugins.node], + }) + return [httpClient.replace(networkNode)] satisfies LayerNode.Replacements + }, +) export * as Simulation from "./index" diff --git a/packages/simulation/test/simulated-provider.test.ts b/packages/simulation/test/simulated-provider.test.ts index d016f6d1a24..c6b17038a28 100644 --- a/packages/simulation/test/simulated-provider.test.ts +++ b/packages/simulation/test/simulated-provider.test.ts @@ -701,7 +701,7 @@ const toolLifecycleLayer = (endpoint: string) => { }) return AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, provider]), - [[Config.node, Config.testLayer()]], + [Config.node.replace(Config.testLayer())], ) } diff --git a/packages/util/docs/layer-node.md b/packages/util/docs/layer-node.md new file mode 100644 index 00000000000..aa78972df20 --- /dev/null +++ b/packages/util/docs/layer-node.md @@ -0,0 +1,155 @@ +# Layer Graphs + +`LayerNode` describes replaceable wiring for ordinary Effect Layers. Declarations +and compilation do not acquire resources. Effect still owns acquisition, +memoization, scopes, and finalization. + +There are three values to compose: + +- A **Node** is a replaceable declaration with a fixed output, error, and tag + contract. `make` supplies its default implementation; `unbound` leaves that + implementation for the caller to supply. +- A **Graph** is a node or an ordered `group` of graphs. Grouping exposes the + selected roots, not their transitive dependencies. Groups are not replacement + targets. +- A **Replacement** is a checked instruction made by `node.replace(...)`. + Replacement arrays can be stored and concatenated without losing their checks. + +```ts +import { Context, Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" + +class Database extends Context.Service()("example/Database") {} +class Users extends Context.Service()("example/Users") {} + +const tags = LayerNode.tags({ global: [], location: ["global"] }) +const global = tags.make("global") +const location = tags.make("location") + +const database = global({ + service: Database, + layer: Layer.succeed(Database, { name: "live" }), + deps: [], +}) +const users = location({ + service: Users, + layer: Layer.effect( + Users, + Effect.map(Database, (db) => ({ name: db.name })), + ), + deps: [database], +}) + +const replacements: LayerNode.Replacements = [database.replace(Layer.succeed(Database, { name: "test" }))] + +const layer = LayerNode.compile(LayerNode.group([database, users]), { + replacements, + shared: tags.values.global, +}) +// Layer: both selected roots remain available. +``` + +## Substitution + +Replacements target the exact declaration, not its display name or service key. +Two independently declared nodes can provide the same service in different +branches without capturing each other's overrides. + +Keep the original declaration as the target when configuring a default: + +```ts +const replacements = [Database.node.replace(Database.configured(options.database)), ...profile.replacements] +``` + +The replacement may be a closed Layer or another node. It must provide all the +source's outputs, may provide additional outputs, must not introduce new errors, +and must retain the source's tag. An open Layer must first be wired into a node. +Replacing a node prunes its original dependencies; replacing it with another node +uses that node's dependencies instead. + +The rules are: + +- The last replacement for the same source wins, before any traversal. +- Replacements also apply inside replacement dependency graphs and through + replacement chains. +- Replacing a node with itself is the identity operation. +- Unreachable replacements neither acquire resources nor traverse their graphs. +- Unbound nodes and cycles in the effective graph fail during compilation. + +A node's contract is invariant. It cannot be widened or narrowed to authorize a +replacement that would violate another use of the original declaration. A graph +can forget outputs like an ordinary Layer, but cannot be used as a weaker +replacement handle. + +Choice and composition are different: `group([a, b])` provides both roots, while +`group([condition ? a : b])` promises only outputs common to both alternatives. +The same rule applies to conditional implementation Layers. Use tuples for known +roots; a dynamic array can be empty and therefore guarantees no service outputs. +Every alternative of a conditional replacement must satisfy the source contract. + +## Decoration + +Use `mapLayer` to decorate a default implementation without duplicating or +inspecting its dependencies: + +```ts +const observed = database.mapLayer((layer) => layer.pipe(Layer.tap(() => Effect.logDebug("database ready")))) +const replacements = [database.replace(observed)] +``` + +The callback is parametric in the Layer's requirements: it must preserve the +declared wiring and service/error contract. `mapLayer` returns a new node; it does +not mutate or automatically override the original. Only implemented nodes offer +this operation, not unbound slots or groups. + +## Lifetimes + +Without `shared`, compilation uses Effect's enclosing memo map throughout. +With `shared: tag`, matching nodes and their dependency subgraphs use that map; +remaining wiring uses one fresh map per build. A shared service's dependencies +must live at least as long as the service, including untagged helpers. This keeps application globals shared while isolating each +Location, without exposing incomplete graphs or losing shared root outputs. + +The enclosing memo map remains ambient during construction, so a lazy LayerMap +captures the shared ancestry rather than a Location's private cache. Cache +selection is internal metadata, not an extra service output of each dependency. + +Native Layer identity still controls acquisition within each memo map. Use a +distinct implementation Layer for differently wired definitions; compilation +rejects conflicting wiring of the same Layer within one graph's memo domain. +Compiled wiring is memoized once per build and memo domain, so a shared dependency +graph is not expanded into a tree during acquisition. Original Layer identities +still determine service sharing across separately compiled builds. An explicitly +fresh implementation refreshes its acquisition when its wiring is built. + +Groups preserve root startup order and associate transparently. A node's +dependencies are built concurrently through native `Layer.provide`. Named +startup actions and Context references are supported even when their required +service output type is `never`. + +## Migration + +| Previous API | Current API | +| ------------------------------------------------ | ----------------------------------------------- | +| `[source, replacement]` | `source.replace(replacement)` | +| `compile(graph, replacements)` | `compile(graph, { replacements })` | +| `hoist`, compile both halves, `fresh`, `provide` | `compile(graph, { replacements, shared: tag })` | +| Spread a node and overwrite `implementation` | `node.mapLayer(transform)` | +| Generic root parameter `Node` | `Graph` | + +An application can prepend a default binding and append caller overrides instead +of inspecting the original graph with `hasUnbound`. Unused defaults are pruned, +and defaults introduced as dependencies by an override are found naturally. + +Code that reconstructed a replacement source by name must retain the original +declaration instead. Compiler representation fields are no longer public. + +`PersistentPty.configured(options)` now returns a wired node, like the other Core +configuration factories. `PersistentPty.layer` remains the default raw Layer. + +## Verification + +Run `bun run typecheck:dist` in `packages/util` to build the package and check the +same positive and negative contract tests against its emitted declarations. +This ensures package consumers receive the same inference and replacement checks +as callers importing the source. diff --git a/packages/util/package.json b/packages/util/package.json index 8117eba8a38..75fe0861342 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -34,7 +34,8 @@ "scripts": { "build": "bun run script/build.ts", "test": "bun test --only-failures", - "typecheck": "tsgo --noEmit" + "typecheck": "tsgo --noEmit", + "typecheck:dist": "bun run build && bun typecheck --project tsconfig.dist.json" }, "dependencies": { "@effect/opentelemetry": "catalog:", diff --git a/packages/util/src/effect/app-node.ts b/packages/util/src/effect/app-node.ts index e0491c9f015..dd5cfce6cec 100644 --- a/packages/util/src/effect/app-node.ts +++ b/packages/util/src/effect/app-node.ts @@ -5,8 +5,8 @@ export const tags = LayerNode.tags({ global: [], }) -export type GlobalNode = LayerNode.Node -export type LocationNode = LayerNode.Node +export type GlobalGraph = LayerNode.Graph +export type LocationGraph = LayerNode.Graph export const makeGlobalNode = tags.make("global") export const makeLocationNode = tags.make("location") diff --git a/packages/util/src/effect/layer-node.ts b/packages/util/src/effect/layer-node.ts index 325777ab737..8a2942d48e6 100644 --- a/packages/util/src/effect/layer-node.ts +++ b/packages/util/src/effect/layer-node.ts @@ -1,57 +1,171 @@ -import { Brand, Context, Layer } from "effect" +import { Brand, Context, Effect, Layer, type Scope } from "effect" -type AnyNode = Node -type RuntimeLayer = Layer.Layer -type NodeList = readonly [] | readonly [Item, ...Item[]] -export type Output = [Item] extends [never] ? never : Item extends Node ? A : never -export type Error = [Item] extends [never] ? never : Item extends Node ? E : never -type NodeTag = [Item] extends [never] ? undefined : Item extends Node ? T : never -type Missing = Exclude> -type CheckDependencies = [ - Missing, Dependencies>, -] extends [never] - ? unknown - : { readonly "Missing dependencies": Missing, Dependencies> } -declare const $OutputType: unique symbol -declare const $ErrorType: unique symbol +export * as LayerNode from "./layer-node.js" + +const GraphTypeId = Symbol("LayerNode.Graph") +const VarianceTypeId = Symbol("LayerNode.Variance") +const NodeTypeId = Symbol("LayerNode.Node") +const ReplacementTypeId = Symbol("LayerNode.Replacement") export type Tag = Name & Brand.Brand<"LayerNode.Tag"> - const makeTag = Brand.nominal() -export interface Node { - readonly kind: "layer" | "unbound" | "group" - readonly name: string - readonly service?: Context.Service.Any - readonly implementation?: Layer.Any - readonly dependencies: readonly AnyNode[] - readonly tag?: T - readonly [$OutputType]?: () => A - readonly [$ErrorType]?: () => E +export type Graph = GraphValue +export type Node = NodeValue +export type Provider = ProviderNode +export type Replacement = ReplacementValue +export type Replacements = readonly Replacement[] + +type AnyGraph = Graph +type AnyNode = AnyGraph & { readonly [NodeTypeId]: unknown; readonly tag: Tag | undefined } +type RuntimeLayer = Layer.Layer +type GraphList = readonly [] | readonly [AnyGraph, ...AnyGraph[]] + +export type Output = CommonOutput ? A : never> +export type Error = Item extends Graph ? E : never +type GraphTag = [Item] extends [never] ? undefined : Item extends Graph ? T : never +type CommonOutput = A extends unknown ? ([Item] extends [Graph] ? A : never) : never +type LayerOutput> = A extends unknown + ? [Item] extends [Layer.Layer] + ? A + : never + : never +type ListOutput = [Items] extends [ + readonly [infer Head extends AnyGraph, ...infer Tail extends readonly AnyGraph[]], +] + ? ListOutput> + : A + +type Definition = + | { readonly kind: "group"; readonly name: string; readonly dependencies: readonly AnyGraph[] } + | { readonly kind: "unbound"; readonly name: string } + | { + readonly kind: "layer" + readonly name: string + readonly implementation: RuntimeLayer + readonly dependencies: readonly AnyGraph[] + } + +class GraphValue { + declare private readonly graph: void + // Public symbol types survive declaration emit; the private brand prevents structural forgery. + declare readonly [VarianceTypeId]: { + readonly output: (_: A) => void + readonly error: () => E + readonly tags: () => T + } + + readonly [GraphTypeId]: Definition + + constructor(definition: Definition) { + this[GraphTypeId] = definition + } + + get name() { + return this[GraphTypeId].name + } } -type NodeIdentity = +class NodeValue extends GraphValue { + // Forgetting part of a node's contract must not authorize a weaker replacement. + declare readonly [NodeTypeId]: (_: [A, E, T]) => [A, E, T] + + constructor( + definition: Exclude, + readonly tag: T, + ) { + super(definition) + } + + /** Replace this declaration, including every dependency on it, without acquiring its old wiring. */ + replace( + this: Node, + replacement: Target & CheckReplacement, + ): Replacement { + if (replacement instanceof NodeValue) { + if (replacement.tag !== this.tag) throw new Error(`Cannot replace ${this.name} across tags`) + return new ReplacementValue(this, replacement) + } + if (!Layer.isLayer(replacement)) throw new Error("A replacement must be a node or an Effect Layer") + return new ReplacementValue(this, makeProvider({ name: this.name, layer: replacement, deps: [] }, this.tag)) + } +} + +class ProviderNode extends NodeValue { + /** Decorate the implementation while preserving its dependency wiring and service contract. */ + mapLayer(this: Provider, f: (layer: Layer.Layer) => Layer.Layer): Provider { + const definition = this[GraphTypeId] + if (definition.kind !== "layer") throw new Error(`Cannot map unbound layer node: ${this.name}`) + return new ProviderNode( + { ...definition, implementation: f(definition.implementation as Layer.Layer) }, + this.tag, + ) + } +} + +class ReplacementValue { + declare private readonly checked: void + readonly [ReplacementTypeId]: { readonly source: AnyNode; readonly target: AnyNode } + + constructor(source: AnyNode, target: AnyNode) { + this[ReplacementTypeId] = { source, target } + } +} + +type CheckErrors = [Exclude] extends [never] + ? never + : { readonly "New replacement errors": Exclude } + +type CheckReplacement = [ReplacementErrors] extends [never] + ? unknown + : ReplacementErrors + +type ReplacementErrors = Target extends AnyNode + ? [Exclude>] extends [never] + ? [GraphTag] extends [T] + ? CheckErrors> + : { readonly "Invalid replacement tag": GraphTag } + : { readonly "Missing replacement outputs": Exclude> } + : Target extends Layer.Layer + ? CheckErrors + : { readonly "Replacement must be a closed layer": Target } + +type CheckDependencies = [ + Exclude, ListOutput>, +] extends [never] + ? unknown + : { readonly "Missing dependencies": Exclude, ListOutput> } + +type Identity = | { readonly service: Context.Service.Any; readonly name?: never } | { readonly name: string; readonly service?: never } +type CheckLayer = [Implementation] extends [RuntimeLayer] + ? unknown + : { readonly "Layer contract must be preserved": Implementation } +type TagInput = { readonly tag: T } | ([T] extends [undefined] ? { readonly tag?: undefined } : never) +type MakeInput = Identity & + TagInput & { + readonly layer: Implementation & CheckLayer> + readonly deps: Items & CheckDependencies> + } type DistributiveOmit = A extends unknown ? Omit : never export type TagConfig = Readonly> type TagNames = keyof Config & string -type NodeInTags = Node | undefined> -type CheckTags = [Exclude>] extends [ - never, -] +type CheckTags = [ + Exclude, Tag | undefined>, +] extends [never] ? unknown - : { readonly "Invalid tag dependencies": Exclude> } + : { readonly "Invalid tag dependencies": Exclude, Tag | undefined> } export interface Tags { readonly values: { readonly [Name in TagNames]: Tag } readonly make: >( name: Name, - ) => ( + ) => ( input: DistributiveOmit>, "tag"> & CheckTags>, - ) => Node, Layer.Error | Error, Tag> + ) => Provider, Layer.Error | Error, Tag> } export function tags( @@ -61,271 +175,156 @@ export function tags [name, makeTag(name)])) as Tags["values"] return { values, - make: ((name: TagNames) => (input: DistributiveOmit, "tag">) => - make({ ...input, tag: values[name] })) as Tags["make"], + make: + >(name: Name) => + ( + input: DistributiveOmit>, "tag"> & + CheckTags>, + ) => + makeProvider>(input, values[name]), } } -// Nodes --------------------------------------------------------------------- - -type MakeInput< - Implementation extends Layer.Any, - Items extends NodeList, - T extends Tag | undefined = undefined, -> = NodeIdentity & { - readonly layer: Implementation - readonly deps: Items & CheckDependencies> - readonly tag?: T -} - export function make< const Implementation extends Layer.Any, - const Items extends NodeList, + const Items extends GraphList, const T extends Tag | undefined = undefined, >( input: MakeInput, -): Node, Layer.Error | Error, T> { - return { - kind: "layer", - name: input.service !== undefined ? input.service.key : input.name, - service: input.service, - implementation: input.layer, - dependencies: input.deps, - tag: input.tag, - } +): Provider, Layer.Error | Error, T> { + return makeProvider(input, input.tag as T) +} + +function makeProvider( + input: Identity & { readonly layer: Implementation; readonly deps: Items }, + tag: T, +): Provider, Layer.Error | Error, T> { + if (!Layer.isLayer(input.layer)) throw new Error("A layer node requires an Effect Layer") + return new ProviderNode( + { + kind: "layer", + name: input.service !== undefined ? input.service.key : input.name, + implementation: input.layer, + dependencies: [...input.deps], + }, + tag, + ) } export function unbound(service: Context.Key, tag: T): Node { - return { - kind: "unbound", - name: service.key, - service, - dependencies: [], - tag, - } + return new NodeValue({ kind: "unbound", name: service.key }, tag) } -export function group( +/** Ordered, associative composition. Only these roots' outputs are exposed; their dependencies remain private. */ +export function group( dependencies: Items, -): Node, Error, NodeTag> { - return { kind: "group", name: "group", dependencies } +): Graph, Error, GraphTag> { + return new GraphValue({ kind: "group", name: "group", dependencies: [...dependencies] }) } -export type Replacement = readonly [source: AnyNode, replacement: AnyNode | Layer.Any] -export type Replacements = readonly Replacement[] - -type CheckReplacementErrors = [Exclude] extends [never] - ? unknown - : { readonly "New replacement errors": Exclude } - -type CheckReplacement = Item extends readonly [Node, infer Replacement] - ? Replacement extends Node, infer E2, T> - ? CheckReplacementErrors> - : Replacement extends Layer.Layer, infer E2, never> - ? CheckReplacementErrors> - : { readonly "Invalid replacement": Replacement } - : { readonly "Invalid replacement": Item } - -type CheckReplacements = { - readonly [K in keyof Items]: CheckReplacement +export interface CompileOptions { + readonly replacements?: Replacements + /** Share subgraphs rooted at this tag; give the remaining wiring a fresh map for each build. */ + readonly shared?: Tag } -type ValidReplacements = Items & CheckReplacements +type Resolved = { + readonly implementation: RuntimeLayer + readonly dependencies: readonly Resolved[] + readonly shared: boolean +} -function replacementNode(source: AnyNode, replacement: AnyNode | Layer.Any) { - const replacementNode = isNode(replacement) - ? replacement - : make({ - ...nodeMakeIdentity(source), - layer: replacement as Layer.Layer, - deps: [], - tag: source.tag, - }) - if (source.name !== replacementNode.name) { - throw new Error(`Cannot replace ${source.name} with ${replacementNode.name}`) +/** Resolve the final overrides before validating or acquiring anything. Effect owns acquisition and finalization. */ +export function compile(root: Graph, options: CompileOptions = {}): Layer.Layer { + const shared = options.shared + const replacements = new Map( + options.replacements?.map((item) => { + const replacement = item[ReplacementTypeId] + return [replacement.source, replacement.target] as const + }), + ) + const cache = { + shared: new Map(), + local: new Map(), } - if (source.tag !== replacementNode.tag) { - throw new Error(`Cannot replace ${source.name} across tags`) - } - return replacementNode -} + const stack: AnyGraph[] = [] + const definitions = { shared: new Map(), local: new Map() } -function nodeMakeIdentity(node: AnyNode): NodeIdentity { - if (node.service !== undefined) return { service: node.service } - return { name: node.name } -} - -function isNode(input: Layer.Any | AnyNode): input is AnyNode { - return "kind" in input && "dependencies" in input -} - -// Tree ----------------------------------------------------------------------- - -type Visit = (node: AnyNode, context: VisitContext) => Result - -type VisitContext = { - readonly visit: (node: AnyNode) => Result -} - -function walk( - root: AnyNode, - visit: Visit, - options: { - readonly cache?: Map - readonly resolve?: (node: AnyNode) => AnyNode - } = {}, -) { - const cache = options.cache ?? new Map() - const visiting = new Set() - const stack: AnyNode[] = [] - - const recur = (node: AnyNode): Result => { - const target = options.resolve?.(node) ?? node - const cached = cache.get(target) - if (cached !== undefined || cache.has(target)) return cached! - - if (visiting.has(target)) { - const start = stack.indexOf(target) + const resolve = (graph: AnyGraph, inherited = false): readonly Resolved[] => { + const definition = graph[GraphTypeId] + const isShared = inherited || (shared !== undefined && graph instanceof NodeValue && graph.tag === shared) + const resolved = isShared ? cache.shared : cache.local + const cached = resolved.get(graph) + if (cached) return cached + const cycle = stack.indexOf(graph) + if (cycle !== -1) { throw new Error( - `Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`, + `Cycle detected in layer graph: ${[...stack.slice(cycle), graph].map((item) => item.name).join(" -> ")}`, ) } - - visiting.add(target) - stack.push(target) - try { - const result = visit(target, { visit: recur }) - if (!cache.has(target)) cache.set(target, result) - return result - } finally { - stack.pop() - visiting.delete(target) - } - } - - return recur(root) -} - -export function hoist( - root: Node, - tag: T, - replacements?: ValidReplacements, -): { - readonly node: Node - readonly hoisted: Node -} { - const hoisted = new Map() - const replacementMap = replacementMapFrom(replacements) - - const node = walk( - root, - (node, context) => { - if (node.kind === "group") { - return { ...node, dependencies: node.dependencies.map(context.visit) } + stack.push(graph) + const replacement = replacements.get(graph) + const result = (() => { + if (replacement && replacement !== graph) return resolve(replacement, isShared) + if (definition.kind === "group") + return definition.dependencies.flatMap((dependency) => resolve(dependency, isShared)) + if (definition.kind === "unbound") throw new Error(`Unbound layer node: ${definition.name}`) + const node: Resolved = { + implementation: definition.implementation, + dependencies: definition.dependencies.flatMap((dependency) => resolve(dependency, isShared)), + shared: isShared, } - if (node.tag === tag) { - const existing = hoisted.get(node.name) - if (existing && existing.implementation !== node.implementation) { - throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`) + const registry = node.shared ? definitions.shared : definitions.local + const existing = registry.get(node.implementation) + if (existing) { + if ( + existing.dependencies.length !== node.dependencies.length || + existing.dependencies.some((dependency, index) => dependency !== node.dependencies[index]) + ) { + throw new Error( + `Layer ${definition.name} is wired to different dependencies; use a distinct implementation Layer`, + ) } - hoisted.set(node.name, rewriteReplacementDependencies(node, replacementMap)) - return group([]) + return [existing] } - if (node.kind === "unbound") { - return node - } - return { ...node, dependencies: node.dependencies.map(context.visit) } - }, - { resolve: (node) => replacementMap.get(node.name) ?? node }, - ) - - return { - node: node as Node, - hoisted: group(Array.from(hoisted.values())) as Node, + registry.set(node.implementation, node) + return [node] + })() + stack.pop() + resolved.set(graph, result) + return result } -} -export function compile( - root: Node, - replacements?: ValidReplacements, -): Layer.Layer { - const replacementMap = replacementMapFrom(replacements) - const cache = new Map() - const compileNode = (node: AnyNode) => - walk( - node, - (node, context) => { - if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`) - const dependencies = node.dependencies.flatMap(flatten).map(context.visit) - const implementation = node.implementation! as RuntimeLayer - return dependencies.length === 0 - ? implementation - : implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]])) - }, - { cache, resolve: (node) => replacementMap.get(node.name) ?? node }, + const roots = resolve(root) + return Layer.fromBuild((memoMap, scope) => { + const local = shared === undefined ? memoMap : Layer.makeMemoMapUnsafe() + const ambient = Layer.succeed(Layer.CurrentMemoMap, memoMap) + const layers = new Map() + const build = (node: Resolved): RuntimeLayer => { + const cached = layers.get(node) + if (cached) return cached + const dependencies = node.dependencies.map(build) + // Acquisition uses the selected cache, while lazy LayerMaps inherit the enclosing shared cache. + const implementation = Layer.suspend(() => node.implementation.pipe(Layer.provide([ambient, ...dependencies]))) + const layer = Layer.fromBuild((_, scope) => buildContext(implementation, node.shared ? memoMap : local, scope)) + layers.set(node, layer) + return layer + } + return buildContext( + roots.map(build).reduce((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty), + memoMap, + scope, ) - const layers = flatten(root).map((node) => compileNode(node)) - const layer = layers.reduce((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty) - return layer as Layer.Layer + }) as Layer.Layer } -function replacementMapFrom(replacements?: Replacements) { - return ( - replacements?.reduce((map, [source, replacement]) => { - const normalized = rewriteReplacementDependencies(replacementNode(source, replacement), map) - const current = new Map([[source.name, normalized]]) - for (const [name, node] of map) map.set(name, rewriteReplacementDependencies(node, current)) - map.set(source.name, normalized) - return map - }, new Map()) ?? new Map() - ) +class BuildResult extends Context.Service>()("@opencode/LayerNode/BuildResult") {} + +function buildContext(layer: RuntimeLayer, memoMap: Layer.MemoMap, scope: Scope.Scope) { + // Preserve the exact output context before Effect appends its own memo-map metadata. + return Layer.buildWithMemoMap( + layer.pipe(Layer.flatMap((context) => Layer.succeed(BuildResult, context))), + memoMap, + scope, + ).pipe(Effect.map(Context.get(BuildResult))) } - -function rewriteReplacementDependencies(root: AnyNode, replacements: ReadonlyMap) { - if (replacements.size === 0) return root - const cache = new Map() - const visiting = new Set() - const stack: AnyNode[] = [] - - const recur = (node: AnyNode, isRoot = false): AnyNode => { - const target = isRoot ? node : (replacements.get(node.name) ?? node) - const cached = cache.get(target) - if (cached !== undefined || cache.has(target)) return cached! - if (visiting.has(target)) { - const start = stack.indexOf(target) - throw new Error( - `Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`, - ) - } - - visiting.add(target) - stack.push(target) - try { - const dependencies = target.dependencies.map((dependency) => recur(dependency)) - const result = dependencies.every((dependency, index) => dependency === target.dependencies[index]) - ? target - : { ...target, dependencies } - cache.set(target, result) - return result - } finally { - stack.pop() - visiting.delete(target) - } - } - - return recur(root, true) -} - -export function hasUnbound(root: Node, source: AnyNode): boolean { - if (source.kind !== "unbound") throw new Error(`Cannot check non-unbound layer node: ${source.name}`) - return walk(root, (node, context) => { - if (node === source) return true - return node.dependencies.some(context.visit) - }) -} - -function flatten(node: AnyNode): readonly AnyNode[] { - return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node] -} - -export * as LayerNode from "./layer-node.js" diff --git a/packages/util/tsconfig.dist.json b/packages/util/tsconfig.dist.json new file mode 100644 index 00000000000..c24daa277b5 --- /dev/null +++ b/packages/util/tsconfig.dist.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@opencode-ai/util/effect/layer-node": ["./dist/effect/layer-node.d.ts"], + "@opencode-ai/util/effect/app-node": ["./dist/effect/app-node.d.ts"] + } + }, + "include": ["../core/test/effect/layer-node/layer-node-types.test.ts"] +} From 3e9b009642ba396fd0cef405a591942497b66b32 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 31 Aug 2026 13:46:33 -0400 Subject: [PATCH 5/5] feat(core): add session-aware instance selection (#46442) --- packages/core/src/instance.ts | 1 + packages/core/src/instance/service.ts | 42 +++ packages/core/src/plugin/host.ts | 9 +- packages/core/src/plugin/runtime.ts | 2 +- packages/core/src/session.ts | 18 +- packages/core/src/session/execution.ts | 12 +- packages/core/src/session/session.ts | 29 +- packages/core/test/session-execution.test.ts | 6 +- packages/core/test/session-owned.test.ts | 17 +- packages/core/test/session-remove.test.ts | 78 ++++- packages/server/src/handlers/form.ts | 23 +- packages/server/src/handlers/permission.ts | 34 +- packages/server/src/location.ts | 30 +- .../server/src/middleware/form-location.ts | 12 +- .../server/src/middleware/session-location.ts | 14 +- packages/server/src/routes.ts | 2 + packages/server/test/fetch.test.ts | 16 +- .../server/test/session-instances.test.ts | 328 ++++++++++++++++++ 18 files changed, 549 insertions(+), 124 deletions(-) create mode 100644 packages/core/src/instance/service.ts create mode 100644 packages/server/test/session-instances.test.ts diff --git a/packages/core/src/instance.ts b/packages/core/src/instance.ts index 98f6ebcfbea..0be7ecf37ca 100644 --- a/packages/core/src/instance.ts +++ b/packages/core/src/instance.ts @@ -55,6 +55,7 @@ import { ToolOutput } from "./tool-output.js" import { Vcs } from "./vcs.js" export * as Instance from "./instance.js" +export { Service, byLocationNode, type Interface } from "./instance/service.js" const nodes = [ Location.node, diff --git a/packages/core/src/instance/service.ts b/packages/core/src/instance/service.ts new file mode 100644 index 00000000000..c5a46b08f62 --- /dev/null +++ b/packages/core/src/instance/service.ts @@ -0,0 +1,42 @@ +export * as Instance from "./service.js" +export type { Services } from "../instance.js" + +import { Context, Effect, Layer, Option, Scope } from "effect" +import type { Session } from "@opencode-ai/schema/session" +import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" +import type { Services } from "../instance.js" +import { LocationServiceMap } from "../location-service-map.js" + +/** Selects Session capabilities; implementations own caching and lifetime. */ +export interface Interface { + readonly provide: ( + session: Session.Info, + ) => (effect: Effect.Effect) => Effect.Effect> + /** Borrow a cached instance without initializing one when it is absent. */ + readonly provideIfLoaded: ( + session: Session.Info, + ) => (effect: Effect.Effect) => Effect.Effect, E, Exclude> +} + +export class Service extends Context.Service()("@opencode/Instance") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + return Service.of({ + provide: (session) => Effect.provide(locations.get(session.location)), + provideIfLoaded: (session) => (effect) => + // Scope the borrowed reference without replacing the caller's Scope. + Effect.scopedWith((scope) => + Effect.gen(function* () { + const context = yield* locations.contextEffectOption(session.location).pipe(Scope.provide(scope)) + if (Option.isNone(context)) return Option.none() + return Option.some(yield* effect.pipe(Effect.provide(context.value))) + }), + ), + }) + }), +) + +export const byLocationNode = makeGlobalNode({ service: Service, layer, deps: [LocationServiceMap.node] }) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 3e1d5598d47..b725c542a8e 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -80,7 +80,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p const response = (effect: Effect.Effect) => effect.pipe(Effect.map((data) => ({ location: locationInfo(), data }))) - return { + // Keep the instance graph's inferred types independent of Session handles. + const context: Plugin.Context = { app, location: locationInfo(), options: {}, @@ -206,7 +207,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p .subscribe() .pipe( Stream.filter( - (event): event is EventManifest.ServerEvent | RpcEvent => EventManifest.isServer(event) || isRpcEvent(event), + (event): event is EventManifest.ServerEvent | RpcEvent => + EventManifest.isServer(event) || isRpcEvent(event), ), ), }, @@ -449,7 +451,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p wait: (input) => runtime.session.wait(input.sessionID), context: (input) => runtime.session.context(input.sessionID), }, - } satisfies Plugin.Context + } + return context }) export function storage(kv: KV.Interface, pluginID: string): Plugin.Context["storage"] { diff --git a/packages/core/src/plugin/runtime.ts b/packages/core/src/plugin/runtime.ts index 89cf606da05..05d52b25875 100644 --- a/packages/core/src/plugin/runtime.ts +++ b/packages/core/src/plugin/runtime.ts @@ -62,7 +62,7 @@ const require = (cell: Cell, f: (runtime: Interface) => Effect.Effect +export const layerWithCell = (cell: Cell): Layer.Layer => Layer.succeed( Service, Service.of({ diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 1ff70c3032c..25298d3f415 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1,7 +1,7 @@ export * as Session from "./session.js" export * from "./session/schema.js" -import { Cause, Effect, Layer, Schema, Context, RcMap, Stream } from "effect" +import { Cause, Effect, Layer, Schema, Context, Stream } from "effect" import { ListAnchor } from "@opencode-ai/schema/session" import { and, desc, eq } from "drizzle-orm" import { Project } from "./project.js" @@ -10,6 +10,7 @@ import { Location } from "./location.js" import { SessionMessage } from "./session/message.js" import { PromptInput } from "@opencode-ai/schema/prompt-input" import { Bus } from "./bus.js" +import { Instance } from "./instance/service.js" import { Database } from "./database/database.js" import { SessionProjector } from "./session/projector.js" import { SessionMessageTable } from "./session/sql.js" @@ -238,20 +239,16 @@ const layer = Layer.effect( const global = yield* Global.Service const execution = yield* SessionExecution.Service const store = yield* SessionStore.Service + const instances = yield* Instance.Service const locations = yield* LocationServiceMap.Service const fs = yield* FSUtil.Service const jobs = yield* Job.Service const environments = yield* SessionEnvironment.Service - const sessions = yield* Session.make((ref) => locations.get(ref)) + const sessions = yield* Session.make() const admission = yield* SessionInbox.Service const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) { - const location = Location.Ref.make({ - directory: session.location.directory, - workspaceID: session.location.workspaceID, - }) - if (!(yield* RcMap.has(locations.rcMap, location))) return yield* SessionModelTransport.Service.use((transport) => transport.close(session.id)).pipe( - Effect.provide(locations.get(location)), + instances.provideIfLoaded(session), ) }) const isDurableSessionEvent = Schema.is(SessionEvent.Durable) @@ -403,7 +400,7 @@ const layer = Layer.effect( prompt: (input) => sessions.forSession(input.sessionID).prompt(input), generate: Effect.fn("Session.generate")(function* (input) { const session = yield* result.get(input.sessionID) - const generate = yield* SessionGenerate.Service.pipe(Effect.provide(locations.get(session.location))) + const generate = yield* SessionGenerate.Service.pipe(instances.provide(session)) return yield* generate.generate(input) }), command: Effect.fn("Session.command")(function* (input) { @@ -412,7 +409,7 @@ const layer = Layer.effect( const plugins = yield* PluginSupervisor.Service yield* plugins.flush return yield* Command.Service - }).pipe(Effect.provide(locations.get(session.location))) + }).pipe(instances.provide(session)) const delivery = input.delivery ?? "steer" yield* commands.execute({ name: input.command, @@ -535,6 +532,7 @@ export const node = makeGlobalNode({ Project.node, SessionExecution.node, SessionStore.node, + Instance.byLocationNode, SessionInbox.node, LocationServiceMap.node, SessionProjector.node, diff --git a/packages/core/src/session/execution.ts b/packages/core/src/session/execution.ts index 3842614395e..a01c1645f17 100644 --- a/packages/core/src/session/execution.ts +++ b/packages/core/src/session/execution.ts @@ -4,7 +4,7 @@ import { Cause, Context, Effect, Exit, Layer } from "effect" import { Bus } from "../bus.js" import { Database } from "../database/database.js" import { Job } from "../job.js" -import { LocationServiceMap } from "../location-service-map.js" +import { Instance } from "../instance/service.js" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { SessionEvent } from "./event.js" import { SessionRunCoordinator } from "./run-coordinator.js" @@ -35,7 +35,7 @@ export interface Interface { readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect } -/** Routes execution from a Session ID to the runner owned by that Session's Location. */ +/** Routes execution from a Session ID to its selected instance's runner. */ export class Service extends Context.Service()("@opencode/SessionExecution") {} type InterruptReason = "user" | "shutdown" @@ -48,12 +48,12 @@ export function terminal(exit: Exit.Exit, reason?: return { type: "failed" as const, error: toSessionError(failure) } } -/** Process-local execution: drains run in this process, routed through the Session's Location graph. */ +/** Process-local execution: drains run in this process using the selected instance. */ export const layer = Layer.effect( Service, Effect.gen(function* () { const store = yield* SessionStore.Service - const locations = yield* LocationServiceMap.Service + const instances = yield* Instance.Service const bus = yield* Bus.Service const jobs = yield* Job.Service const db = (yield* Database.Service).db @@ -90,7 +90,7 @@ export const layer = Layer.effect( const result = yield* SessionRunner.Service.use((runner) => runner.drain({ sessionID, force, continuation, promotable }), ).pipe( - Effect.provide(locations.get(session.location)), + instances.provide(session), Effect.tapCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.void @@ -173,7 +173,7 @@ export const layer = Layer.effect( export const node = makeGlobalNode({ service: Service, layer, - deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node, Job.node], + deps: [SessionStore.node, Instance.byLocationNode, Bus.node, Database.node, Job.node], }) /** Low-level compatibility layer for callers that only need durable Session recording. */ diff --git a/packages/core/src/session/session.ts b/packages/core/src/session/session.ts index ce404ed1fc6..7dd1db4df85 100644 --- a/packages/core/src/session/session.ts +++ b/packages/core/src/session/session.ts @@ -1,11 +1,11 @@ export * as Session from "./session.js" -import { DateTime, Effect, Fiber, Layer, Schema, Scope } from "effect" +import { DateTime, Effect, Fiber, Schema, Scope } from "effect" import type { Agent } from "@opencode-ai/schema/agent" import type { Model } from "@opencode-ai/schema/model" import { Event } from "@opencode-ai/schema/event" import { Bus } from "../bus.js" -import { Location } from "../location.js" +import { Instance } from "../instance/service.js" import { PluginSupervisor } from "../plugin/supervisor-service.js" import { Shell } from "../shell.js" import { ShellResult } from "../shell/result.js" @@ -33,26 +33,19 @@ import { SessionRevert } from "./revert.js" import { SessionSchema } from "./schema.js" import { SessionStore } from "./store.js" -export type Services = - | PluginSupervisor.Service - | Reference.Service - | SessionPrompt.Service - | SessionRevert.Service - | Shell.Service - | Skill.Service - type PromptRequest = SessionPrompt.Input & { id?: SessionMessage.ID resume?: boolean } /** - * Build once in the host Scope: `const sessions = yield* Session.make(servicesFor)`. + * Build once in the host Scope: `const sessions = yield* Session.make()`. * Use `sessions.forSession(id)` for handles that share host services and reload current state. */ -export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Location.Ref) => Layer.Layer) { +export const make = Effect.fn("Session.make")(function* () { const bus = yield* Bus.Service const store = yield* SessionStore.Service + const instances = yield* Instance.Service const execution = yield* SessionExecution.Service const admission = yield* SessionInbox.Service const scope = yield* Scope.Scope @@ -174,7 +167,7 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca const preparation = yield* SessionPrompt.Service const references = yield* Reference.Service return { item: yield* preparation.prepare({ sessionID, messageID, input }), references } - }).pipe(Effect.provide(servicesFor(session.location))), + }).pipe(instances.provide(session)), ) // Commit a staged revert only after preparation succeeds, before admitting new work. if (session.revert) yield* SessionRevert.commit(bus, session) @@ -205,7 +198,7 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca const plugins = yield* PluginSupervisor.Service yield* plugins.flush return yield* Shell.Service - }).pipe(Effect.provide(servicesFor(session.location))) + }).pipe(instances.provide(session)) const started = yield* shell .create({ command: input.command, @@ -256,7 +249,7 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca input: { id?: SessionMessage.ID; skill: Skill.ID; resume?: boolean }, ) { const session = yield* get(sessionID) - const skills = yield* Skill.Service.pipe(Effect.provide(servicesFor(session.location))) + const skills = yield* Skill.Service.pipe(instances.provide(session)) const skill = yield* skills.get(input.skill) if (!skill) return yield* new SkillNotFoundError({ skill: input.skill }) yield* bus.publish( @@ -355,14 +348,12 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID }) return yield* SessionRevert.Service.use((revert) => revert.stage({ session, messageID: input.messageID, files: input.files }), - ).pipe(Effect.provide(servicesFor(session.location))) + ).pipe(instances.provide(session)) }) const clear = Effect.fn("Session.revert.clear")(function* (sessionID: SessionSchema.ID) { const session = yield* get(sessionID) if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID }) - yield* SessionRevert.Service.use((revert) => revert.clear(session)).pipe( - Effect.provide(servicesFor(session.location)), - ) + yield* SessionRevert.Service.use((revert) => revert.clear(session)).pipe(instances.provide(session)) return yield* execution.wake(sessionID) }) const commit = Effect.fn("Session.revert.commit")(function* (sessionID: SessionSchema.ID) { diff --git a/packages/core/test/session-execution.test.ts b/packages/core/test/session-execution.test.ts index fab76d0e316..076c9052f8b 100644 --- a/packages/core/test/session-execution.test.ts +++ b/packages/core/test/session-execution.test.ts @@ -4,6 +4,7 @@ import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Bus } from "@opencode-ai/core/bus" +import { Instance } from "@opencode-ai/core/instance/service" import { Job } from "@opencode-ai/core/job" import { KV } from "@opencode-ai/core/kv" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" @@ -1371,7 +1372,10 @@ function buildExecution( Layer.provide(Layer.succeed(Bus.Service, bus)), Layer.provide(Layer.succeed(SessionStore.Service, store)), Layer.provide(Layer.succeed(Job.Service, jobs)), - Layer.provide(locations), + // Do not reuse the outer harness's selector with its already-captured Location map. + Layer.provide( + LayerNode.compile(Instance.byLocationNode, [[LocationServiceMap.node, locations]]).pipe(Layer.fresh), + ), ), scope, ) diff --git a/packages/core/test/session-owned.test.ts b/packages/core/test/session-owned.test.ts index 5fbc5f4b609..da18ebe03f1 100644 --- a/packages/core/test/session-owned.test.ts +++ b/packages/core/test/session-owned.test.ts @@ -15,6 +15,7 @@ import { Bus } from "../src/bus.js" import { Database } from "../src/database/database.js" import { EventTable } from "../src/event/sql.js" import { Image } from "../src/image.js" +import { Instance } from "../src/instance/service.js" import { Location } from "../src/location.js" import { PluginHooks } from "../src/plugin/hooks.js" import { PluginSupervisor } from "../src/plugin/supervisor-service.js" @@ -129,7 +130,7 @@ const setup = Effect.fnUntraced(function* (options?: { Layer.mock(Image.Service, {}), options?.shell ?? Layer.mock(Shell.Service, {}), ) - const servicesFor = (ref: Location.Ref): Layer.Layer => { + const servicesFor = (ref: Location.Ref) => { locations.push(ref) return Layer.merge(SessionRevert.layer, SessionPrompt.layer).pipe( Layer.provideMerge( @@ -158,10 +159,20 @@ const setup = Effect.fnUntraced(function* (options?: { Layer.fresh, ) } - const sessions = yield* Session.make(servicesFor).pipe( + const sessions = yield* Session.make().pipe( Effect.satisfiesServicesType< - Bus.Service | SessionStore.Service | SessionExecution.Service | SessionInbox.Service | Scope.Scope + | Bus.Service + | SessionStore.Service + | Instance.Service + | SessionExecution.Service + | SessionInbox.Service + | Scope.Scope >(), + Effect.provideService(Instance.Service, { + // This fixture supplies only the instance services exercised by Session. + provide: (session) => Effect.provide(servicesFor(session.location) as Layer.Layer), + provideIfLoaded: () => () => Effect.die("Unexpected loaded-only instance lookup"), + }), Effect.provideService(SessionExecution.Service, options?.execution ?? execution), ) return { sessions, hooks, locations, flushes, resumes, wakes, db: database.db, bus, store } diff --git a/packages/core/test/session-remove.test.ts b/packages/core/test/session-remove.test.ts index 5f8ced6cd9a..71874d5cea9 100644 --- a/packages/core/test/session-remove.test.ts +++ b/packages/core/test/session-remove.test.ts @@ -1,9 +1,10 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect, Layer, Option, RcMap, Scope } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Database } from "@opencode-ai/core/database/database" import { Bus } from "@opencode-ai/core/bus" +import { Instance } from "@opencode-ai/core/instance/service" import { Location } from "@opencode-ai/core/location" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -19,12 +20,18 @@ import { globalProjectNode } from "./lib/project" import { tmpdirScoped } from "./fixture/tmpdir" const closed: Session.ID[] = [] -const transport = Layer.succeed( +const transportScopes = new Set() +const transport = Layer.effect( SessionModelTransport.Service, - SessionModelTransport.Service.of({ - bind: () => ({ execute: () => Effect.die("Unexpected WebSocket execution") }), - close: (sessionID) => Effect.sync(() => closed.push(sessionID)), - closeAll: Effect.void, + Effect.gen(function* () { + const scope = yield* Scope.Scope + transportScopes.add(scope) + yield* Effect.addFinalizer(() => Effect.sync(() => transportScopes.delete(scope))) + return SessionModelTransport.Service.of({ + bind: () => ({ execute: () => Effect.die("Unexpected WebSocket execution") }), + close: (sessionID) => Effect.sync(() => closed.push(sessionID)), + closeAll: Effect.void, + }) }), ) const it = testEffect( @@ -36,6 +43,7 @@ const it = testEffect( SessionStore.node, SessionEnvironment.node, Session.node, + Instance.byLocationNode, LocationServiceMap.node, ]), [ @@ -72,6 +80,27 @@ describe("Session.remove", () => { }), ) + it.live("removes unloaded sessions and children without initializing an instance", () => + Effect.gen(function* () { + const temporary = yield* tmpdirScoped() + const sessions = yield* Session.Service + const locations = yield* LocationServiceMap.Service + const parent = yield* sessions.create({ + location: Location.Ref.make({ directory: AbsolutePath.make(temporary.path) }), + }) + yield* sessions.create({ parentID: parent.id }) + closed.length = 0 + expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([]) + + yield* sessions.remove(parent.id) + + expect(closed).toEqual([]) + expect(transportScopes.size).toBe(0) + expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([]) + expect((yield* sessions.list()).data).toEqual([]) + }), + ) + it.effect("fails when the session does not exist", () => Effect.gen(function* () { const session = yield* Session.Service @@ -84,3 +113,40 @@ describe("Session.remove", () => { }), ) }) + +describe("Instance.provideIfLoaded", () => { + it.live("skips absent instances and scopes loaded borrows without replacing the caller's Scope", () => + Effect.gen(function* () { + const temporary = yield* tmpdirScoped() + const sessions = yield* Session.Service + const instances = yield* Instance.Service + const locations = yield* LocationServiceMap.Service + const scope = yield* Scope.Scope + const session = yield* sessions.create({ + location: Location.Ref.make({ directory: AbsolutePath.make(temporary.path) }), + }) + const absent = Effect.die("An unloaded instance must not run the effect").pipe(instances.provideIfLoaded(session)) + + expect(yield* absent).toEqual(Option.none()) + expect(transportScopes.size).toBe(0) + yield* Location.Service.pipe(instances.provide(session)) + expect(transportScopes.size).toBe(1) + expect(yield* Effect.void.pipe(instances.provideIfLoaded(session))).toEqual(Option.some(undefined)) + const failure = new Error("Borrowed operation failed") + expect(yield* Effect.fail(failure).pipe(instances.provideIfLoaded(session), Effect.flip)).toBe(failure) + + const borrowed = yield* Effect.gen(function* () { + const location = yield* Location.Service + const callerScope = yield* Scope.Scope + expect(callerScope).toBe(scope) + yield* locations.invalidate(session.location) + expect(transportScopes.size).toBe(1) + return location.directory + }).pipe(instances.provideIfLoaded(session), Effect.satisfiesServicesType()) + + expect(borrowed).toEqual(Option.some(session.location.directory)) + expect(transportScopes.size).toBe(0) + expect(yield* absent).toEqual(Option.none()) + }), + ) +}) diff --git a/packages/server/src/handlers/form.ts b/packages/server/src/handlers/form.ts index f7cdba048ba..9166873b9f8 100644 --- a/packages/server/src/handlers/form.ts +++ b/packages/server/src/handlers/form.ts @@ -1,6 +1,7 @@ -import { Database } from "@opencode-ai/core/database/database" import { Form } from "@opencode-ai/core/form" +import { Instance } from "@opencode-ai/core/instance/service" import { LocationServiceMap } from "@opencode-ai/core/location-services" +import { Session } from "@opencode-ai/core/session" import { ConflictError, FormAlreadySettledError, @@ -11,7 +12,7 @@ import { import { Effect, Option } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { requestRef, response, sessionRef, withLoadedLocationServices } from "../location" +import { requestRef, response, sessionInfo, withLoadedLocationServices } from "../location" function missingForm(id: Form.ID) { return new FormNotFoundError({ id, message: `Form not found: ${id}` }) @@ -20,7 +21,8 @@ function missingForm(id: Form.ID) { export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) => Effect.gen(function* () { const locations = yield* LocationServiceMap.Service - const database = yield* Database.Service + const instances = yield* Instance.Service + const sessions = yield* Session.Service const requireOwnedForm = Effect.fnUntraced(function* (sessionID: Form.Info["sessionID"], formID: Form.ID) { const form = yield* Form.Service const info = yield* form.get(formID).pipe(Effect.catchTag("Form.NotFoundError", () => missingForm(formID))) @@ -39,15 +41,12 @@ export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) = .handle( "session.form.list", Effect.fn(function* (ctx) { - const ref = - ctx.params.sessionID === "global" - ? requestRef(ctx.request) - : yield* sessionRef(database, ctx.params.sessionID) - const forms = yield* withLoadedLocationServices( - locations, - ref, - Form.Service.use((form) => form.list({ sessionID: ctx.params.sessionID })), - ) + const session = + ctx.params.sessionID === "global" ? undefined : yield* sessionInfo(sessions, ctx.params.sessionID) + const read = Form.Service.use((form) => form.list({ sessionID: ctx.params.sessionID })) + const forms = yield* session + ? read.pipe(instances.provideIfLoaded(session)) + : withLoadedLocationServices(locations, requestRef(ctx.request), read) return { data: Option.getOrElse(forms, () => []) } }), ) diff --git a/packages/server/src/handlers/permission.ts b/packages/server/src/handlers/permission.ts index b33b64ef740..76fcdfad9ef 100644 --- a/packages/server/src/handlers/permission.ts +++ b/packages/server/src/handlers/permission.ts @@ -1,13 +1,14 @@ -import { Database } from "@opencode-ai/core/database/database" +import { Instance } from "@opencode-ai/core/instance/service" import { Location } from "@opencode-ai/core/location" -import { LocationServiceMap } from "@opencode-ai/core/location-services" import { Permission } from "@opencode-ai/core/permission" import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { Session } from "@opencode-ai/core/session" import { Effect, Option } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { PermissionNotFoundError, SessionNotFoundError } from "@opencode-ai/protocol/errors" -import { response, sessionRef, withLoadedLocationServices } from "../location" +import { PermissionNotFoundError } from "@opencode-ai/protocol/errors" +import { response, sessionInfo } from "../location" +import { missingSession } from "./session-error" function missingRequest(id: Permission.ID) { return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` }) @@ -15,8 +16,8 @@ function missingRequest(id: Permission.ID) { export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", (handlers) => Effect.gen(function* () { - const locations = yield* LocationServiceMap.Service - const database = yield* Database.Service + const instances = yield* Instance.Service + const sessions = yield* Session.Service const requireOwnedRequest = Effect.fnUntraced(function* ( sessionID: Permission.Request["sessionID"], requestID: Permission.ID, @@ -51,28 +52,17 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", source: ctx.payload.source, agent: ctx.payload.agent, }) - .pipe( - Effect.catchTag( - "Session.NotFoundError", - (error) => - new SessionNotFoundError({ - sessionID: error.sessionID, - message: `Session not found: ${error.sessionID}`, - }), - ), - ), + .pipe(Effect.catchTag("Session.NotFoundError", missingSession)), } }), ) .handle( "session.permission.list", Effect.fn(function* (ctx) { - const ref = yield* sessionRef(database, ctx.params.sessionID) - const requests = yield* withLoadedLocationServices( - locations, - ref, - Permission.Service.use((permission) => permission.forSession(ctx.params.sessionID)), - ) + const session = yield* sessionInfo(sessions, ctx.params.sessionID) + const requests = yield* Permission.Service.use((permission) => + permission.forSession(ctx.params.sessionID), + ).pipe(instances.provideIfLoaded(session)) return { data: Option.getOrElse(requests, () => []) } }), ) diff --git a/packages/server/src/location.ts b/packages/server/src/location.ts index 13a0b4fbfb0..2761687ac70 100644 --- a/packages/server/src/location.ts +++ b/packages/server/src/location.ts @@ -1,15 +1,13 @@ -import { Database } from "@opencode-ai/core/database/database" import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-services" import { AbsolutePath } from "@opencode-ai/core/schema" import { Session } from "@opencode-ai/core/session" -import { SessionTable } from "@opencode-ai/core/session/sql" import { Workspace } from "@opencode-ai/core/workspace" -import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors" -import { eq } from "drizzle-orm" +import { InvalidRequestError } from "@opencode-ai/protocol/errors" import { Context, Effect, Layer, Option, Schema } from "effect" import { HttpServerRequest } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" +import { missingSession } from "./handlers/session-error" export type LocationServices = Layer.Success> @@ -33,24 +31,12 @@ export function response(data: Effect.Effect) { const decodeSessionID = Schema.decodeUnknownEffect(Session.ID) -export function sessionRef(database: Context.Service.Shape, sessionID: unknown) { - return Effect.gen(function* () { - const id = yield* decodeSessionID(sessionID).pipe( - Effect.mapError(() => new InvalidRequestError({ message: "Invalid session ID", field: "sessionID" })), - ) - const row = yield* database.db - .select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id }) - .from(SessionTable) - .where(eq(SessionTable.id, id)) - .get() - .pipe(Effect.orDie) - if (!row) return yield* new SessionNotFoundError({ sessionID: id, message: `Session not found: ${id}` }) - return Location.Ref.make({ - directory: AbsolutePath.make(row.directory), - workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined, - }) - }) -} +export const sessionInfo = Effect.fnUntraced(function* (sessions: Session.Interface, sessionID: unknown) { + const id = yield* decodeSessionID(sessionID).pipe( + Effect.mapError(() => new InvalidRequestError({ message: "Invalid session ID", field: "sessionID" })), + ) + return yield* sessions.get(id).pipe(Effect.catchTag("Session.NotFoundError", missingSession)) +}) export function withLoadedLocationServices( locations: Context.Service.Shape, diff --git a/packages/server/src/middleware/form-location.ts b/packages/server/src/middleware/form-location.ts index ebcef974020..d089d20f407 100644 --- a/packages/server/src/middleware/form-location.ts +++ b/packages/server/src/middleware/form-location.ts @@ -1,10 +1,11 @@ -import { Database } from "@opencode-ai/core/database/database" +import { Instance } from "@opencode-ai/core/instance/service" import { LocationServiceMap } from "@opencode-ai/core/location-services" +import { Session } from "@opencode-ai/core/session" import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors" import { Effect, Layer } from "effect" import { HttpRouter, HttpServerRequest } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" -import { requestRef, sessionRef, type LocationServices } from "../location" +import { requestRef, sessionInfo, type LocationServices } from "../location" export class FormLocationMiddleware extends HttpApiMiddleware.Service< FormLocationMiddleware, @@ -16,7 +17,8 @@ export class FormLocationMiddleware extends HttpApiMiddleware.Service< export const formLocationLayer = Layer.effect( FormLocationMiddleware, Effect.gen(function* () { - const database = yield* Database.Service + const sessions = yield* Session.Service + const instances = yield* Instance.Service const locations = yield* LocationServiceMap.Service return FormLocationMiddleware.of((effect) => @@ -30,8 +32,8 @@ export const formLocationLayer = Layer.effect( return yield* effect.pipe(Effect.provide(locations.get(requestRef(request)))) } - const ref = yield* sessionRef(database, route.params.sessionID) - return yield* effect.pipe(Effect.provide(locations.get(ref))) + const session = yield* sessionInfo(sessions, route.params.sessionID) + return yield* effect.pipe(instances.provide(session)) }), ) }), diff --git a/packages/server/src/middleware/session-location.ts b/packages/server/src/middleware/session-location.ts index ca5c7182b19..b0f91ab4989 100644 --- a/packages/server/src/middleware/session-location.ts +++ b/packages/server/src/middleware/session-location.ts @@ -1,10 +1,10 @@ -import { Database } from "@opencode-ai/core/database/database" -import { LocationServiceMap } from "@opencode-ai/core/location-services" +import { Instance } from "@opencode-ai/core/instance/service" +import { Session } from "@opencode-ai/core/session" import { Effect, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors" -import { sessionRef, type LocationServices } from "../location" +import { sessionInfo, type LocationServices } from "../location" export class SessionLocationMiddleware extends HttpApiMiddleware.Service< SessionLocationMiddleware, @@ -16,14 +16,14 @@ export class SessionLocationMiddleware extends HttpApiMiddleware.Service< export const sessionLocationLayer = Layer.effect( SessionLocationMiddleware, Effect.gen(function* () { - const database = yield* Database.Service - const locations = yield* LocationServiceMap.Service + const sessions = yield* Session.Service + const instances = yield* Instance.Service return SessionLocationMiddleware.of((effect) => Effect.gen(function* () { const route = yield* HttpRouter.RouteContext - const ref = yield* sessionRef(database, route.params.sessionID) - return yield* effect.pipe(Effect.provide(locations.get(ref))) + const session = yield* sessionInfo(sessions, route.params.sessionID) + return yield* effect.pipe(instances.provide(session)) }), ) }), diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 4c9cd55c555..e537f3ccaa6 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -14,6 +14,7 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { PersistentPty } from "@opencode-ai/core/persistent-pty" import { Project } from "@opencode-ai/core/project" import { Session } from "@opencode-ai/core/session" +import { Instance } from "@opencode-ai/core/instance/service" import { SessionTransfer } from "@opencode-ai/core/session/transfer" import { ShellSelect } from "@opencode-ai/core/shell/select" import { Job } from "@opencode-ai/core/job" @@ -56,6 +57,7 @@ const applicationServiceNodes = [ Project.node, Worktree.node, Session.node, + Instance.byLocationNode, SessionTransfer.node, PluginRuntime.providerNode, SdkPlugins.node, diff --git a/packages/server/test/fetch.test.ts b/packages/server/test/fetch.test.ts index 3b59b9bf71a..3074d591670 100644 --- a/packages/server/test/fetch.test.ts +++ b/packages/server/test/fetch.test.ts @@ -340,7 +340,7 @@ it.live("serves the session view operation and missing-session error", () => }), ) -it.live("does not load a location when reading pending session requests", () => +it.live("routes pending requests by Session without loading an instance", () => Effect.gen(function* () { const config = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-pending-read-"))) const handler = yield* ServerFetch.make({ @@ -368,16 +368,18 @@ it.live("does not load a location when reading pending session requests", () => ), ) + // Session routing must ignore the caller's unrelated Location. + const headers = { "x-opencode-directory": encodeURIComponent(config.path) } expect(yield* loaded()).toEqual([]) for (const resource of ["permission", "form"]) { const response = yield* Effect.promise(() => - handler(new Request(`http://opencode.local/api/session/${created.data.id}/${resource}`)), + handler(new Request(`http://opencode.local/api/session/${created.data.id}/${resource}`, { headers })), ) expect(response.status).toBe(200) expect(yield* Effect.promise(() => response.json())).toEqual({ data: [] }) const missing = yield* Effect.promise(() => - handler(new Request(`http://opencode.local/api/session/ses_missing_pending/${resource}`)), + handler(new Request(`http://opencode.local/api/session/ses_missing_pending/${resource}`, { headers })), ) expect(missing.status).toBe(404) } @@ -396,7 +398,7 @@ it.live("does not load a location when reading pending session requests", () => handler( new Request(`http://opencode.local/api/session/${created.data.id}/form`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", ...headers }, body: JSON.stringify({ title: "Test form", fields: [{ key: "answer", type: "string" }] }), }), ), @@ -404,7 +406,7 @@ it.live("does not load a location when reading pending session requests", () => expect(createdForm.status).toBe(200) const forms = yield* Effect.promise(() => - handler(new Request(`http://opencode.local/api/session/${created.data.id}/form`)), + handler(new Request(`http://opencode.local/api/session/${created.data.id}/form`, { headers })), ) expect(forms.status).toBe(200) expect(yield* Effect.promise(() => forms.json())).toMatchObject({ @@ -442,7 +444,7 @@ it.live("does not load a location when reading pending session requests", () => handler( new Request(`http://opencode.local/api/session/${created.data.id}/permission`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", ...headers }, body: JSON.stringify({ id: "per_pending_read", action: "shell", resources: ["pwd"] }), }), ), @@ -453,7 +455,7 @@ it.live("does not load a location when reading pending session requests", () => }) const permissions = yield* Effect.promise(() => - handler(new Request(`http://opencode.local/api/session/${created.data.id}/permission`)), + handler(new Request(`http://opencode.local/api/session/${created.data.id}/permission`, { headers })), ) expect(permissions.status).toBe(200) expect(yield* Effect.promise(() => permissions.json())).toMatchObject({ diff --git a/packages/server/test/session-instances.test.ts b/packages/server/test/session-instances.test.ts new file mode 100644 index 00000000000..bf3cf89eb7e --- /dev/null +++ b/packages/server/test/session-instances.test.ts @@ -0,0 +1,328 @@ +import { expect } from "bun:test" +import { App } from "@opencode-ai/core/app" +import { Bus } from "@opencode-ai/core/bus" +import { Database } from "@opencode-ai/core/database/database" +import { llmClient } from "@opencode-ai/core/effect/app-node-platform" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" +import { Form } from "@opencode-ai/core/form" +import { Instance } from "@opencode-ai/core/instance" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { ModelsDev } from "@opencode-ai/core/models-dev" +import { Permission } from "@opencode-ai/core/permission" +import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" +import { Session } from "@opencode-ai/core/session" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { Plugin } from "@opencode-ai/plugin/effect" +import { Agent } from "@opencode-ai/schema/agent" +import { Location } from "@opencode-ai/schema/location" +import { AbsolutePath } from "@opencode-ai/schema/schema" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { Global } from "@opencode-ai/util/global" +import { Context, Duration, Effect, Layer, LayerMap, Option, RcMap, Schema, Scope } from "effect" +import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http" +import { LanguageModel, LLMClient } from "../../ai/src" +import { OpenAIChat } from "../../ai/src/protocols/openai-chat" +import { TestLLM } from "../../ai/src/testing" +import { tempGlobalLayer } from "../../core/test/fixture/global" +import { tmpdirScoped } from "../../core/test/fixture/tmpdir" +import { it } from "../../core/test/lib/effect" +import { createEmbeddedRoutes } from "../src/routes" + +it.live( + "isolates same-directory Session tools, hooks, commands, and HTTP requests through one selector", + () => + Effect.gen(function* () { + const directory = yield* tmpdirScoped() + const location = Location.Ref.make({ directory: AbsolutePath.make(directory.path) }) + const first = { id: Session.ID.make("ses_instance_first"), tool: "instance_first", temperature: 0.1 } + const second = { id: Session.ID.make("ses_instance_second"), tool: "instance_second", temperature: 0.2 } + const configs = [first, second] + const boots: Session.ID[] = [] + const executed: Session.ID[] = [] + const commands: Session.ID[] = [] + const llm = yield* TestLLM.Test.pipe(Effect.provide(TestLLM.testLayer())) + const model = SessionRunnerModel.resolved( + LanguageModel.make({ id: "instance-model", provider: "test", route: OpenAIChat.route }), + { + capabilities: { tools: true, input: ["text"], output: ["text"] }, + cost: [], + limit: { context: 200_000, output: 8_192 }, + }, + ) + const cell = PluginRuntime.makeCell() + // Host and private instances must reuse the same global layer identities. + const replacements: LayerNode.Replacements = [ + [Global.node, tempGlobalLayer], + [Database.node, Database.node], + [Bus.node, Bus.node], + [App.node, App.node], + [ModelsDev.node, ModelsDev.configured({ fetch: false })], + [Watcher.node, Watcher.configured({ enabled: false })], + [PluginRuntime.node, PluginRuntime.layerWithCell(cell)], + [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(cell)], + [llmClient, Layer.succeed(LLMClient.Service, llm)], + [SessionRunnerModel.node, Layer.succeed(SessionRunnerModel.Service, { resolve: () => Effect.succeed(model) })], + [ + Instance.byLocationNode, + Layer.effect( + Instance.Service, + Effect.gen(function* () { + const instances = yield* LayerMap.make( + (id: Session.ID) => { + const config = configs.find((config) => config.id === id) + if (!config) throw new Error(`No instance configuration for ${id}`) + return Instance.layer(location, { + discovery: false, + replacements, + plugins: [ + Plugin.define({ + id: "session-instance", + effect: Effect.fnUntraced(function* (ctx) { + boots.push(config.id) + yield* ctx.agent.transform((draft) => + draft.update("build", (agent) => { + agent.permissions = [{ action: "*", resource: "*", effect: "allow" }] + }), + ) + yield* ctx.session.hook("prompt", (event) => + Effect.sync(() => { + event.prompt.text += ` [${config.tool}]` + }), + ) + yield* ctx.session.hook("context", (event) => + Effect.sync(() => { + event.generation.temperature = config.temperature + }), + ) + yield* ctx.permission.hook("evaluate", (event) => + Effect.sync(() => { + event.effect = event.action === "instance-test" ? "ask" : "allow" + if (event.action === "instance-test") event.message = config.tool + }), + ) + yield* ctx.tool.transform((draft) => + draft.add({ + name: config.tool, + description: `Tool for ${config.id}`, + input: Schema.Struct({}), + output: Schema.String, + options: { codemode: false }, + execute: () => + Effect.sync(() => { + executed.push(config.id) + return { output: config.id, content: config.id } + }), + }), + ) + yield* ctx.command.transform((draft) => + draft.add({ + name: "instance-check", + execute: (input) => + ctx.session + .prompt({ + sessionID: input.sessionID, + text: `command ${config.tool}`, + delivery: input.delivery, + resume: false, + }) + .pipe( + Effect.tap(() => Effect.sync(() => commands.push(config.id))), + Effect.asVoid, + ), + }), + ) + }), + }), + ], + }) + }, + { idleTimeToLive: Duration.infinity }, + ) + return Instance.Service.of({ + provide: (session) => Effect.provide(instances.get(session.id)), + provideIfLoaded: (session) => (effect) => + Effect.scopedWith((scope) => + Effect.gen(function* () { + const context = yield* instances.contextEffectOption(session.id).pipe(Scope.provide(scope)) + if (Option.isNone(context)) return Option.none() + return Option.some(yield* effect.pipe(Effect.provide(context.value))) + }), + ), + }) + }), + ), + ], + ] + const context = yield* Layer.build( + createEmbeddedRoutes({}, replacements).pipe(Layer.provide(HttpServer.layerServices)), + ) + const sessions = Context.get(context, Session.Service) + const instances = Context.get(context, Instance.Service) + const locations = Context.get(context, LocationServiceMap.Service) + const handler = Context.get(context, HttpRouter.HttpRouter) + .asHttpEffect() + .pipe(HttpEffect.toWebHandlerWith(context)) + const request = (route: string, body?: unknown) => + Effect.promise(() => + handler( + new Request( + `http://opencode.local${route}`, + body === undefined + ? undefined + : { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }, + ), + ), + ) + + yield* Effect.forEach(configs, (config) => + sessions.create({ + id: config.id, + title: config.tool, + agent: Agent.ID.make("build"), + model: model.ref, + location, + }), + ) + expect((yield* sessions.list()).data.map((session) => session.location)).toEqual([location, location]) + expect(yield* sessions.messages({ sessionID: first.id })).toEqual([]) + yield* sessions.get(second.id) + for (const config of configs) { + for (const resource of ["permission", "form"]) { + const response = yield* request(`/api/session/${config.id}/${resource}`) + expect(response.status).toBe(200) + expect(yield* Effect.promise(() => response.json())).toEqual({ data: [] }) + } + } + expect(boots).toEqual([]) + + for (const config of configs) { + const admitted = yield* sessions.prompt({ sessionID: config.id, text: "run", resume: false }) + expect(admitted.payload.text).toBe(`run [${config.tool}]`) + } + expect(boots).toEqual([first.id, second.id]) + + for (const config of configs) { + yield* llm.push(TestLLM.tool(`call_${config.tool}`, config.tool, {}), TestLLM.text(config.id, config.tool)) + yield* sessions.resume(config.id) + } + expect(executed).toEqual([first.id, second.id]) + + for (const config of configs) { + yield* llm.push(TestLLM.text(`generated ${config.id}`, config.tool)) + expect(yield* sessions.generate({ sessionID: config.id, prompt: "summarize" })).toBe(`generated ${config.id}`) + yield* sessions.command({ sessionID: config.id, command: "instance-check", text: "" }) + expect(yield* sessions.inbox(config.id)).toMatchObject([ + { type: "user", payload: { text: `command ${config.tool} [${config.tool}]` } }, + ]) + } + expect(commands).toEqual([first.id, second.id]) + expect( + (yield* llm.requests()).map((request) => ({ + temperature: request.generation?.temperature, + tools: request.tools?.filter((tool) => tool.name.startsWith("instance_")).map((tool) => tool.name), + })), + ).toEqual( + [first, first, second, second, first, second].map((config) => ({ + temperature: config.temperature, + tools: [config.tool], + })), + ) + + // Seed through Core, then use HTTP to reach those exact private instances. + const pending = yield* Effect.forEach(configs, (config) => + Effect.gen(function* () { + const session = yield* sessions.get(config.id) + return yield* Effect.gen(function* () { + const forms = yield* Form.Service + const permissions = yield* Permission.Service + const form = yield* forms.create({ + sessionID: config.id, + title: config.tool, + fields: [{ key: "answer", type: "string" }], + }) + const permission = { + id: Permission.ID.create(), + sessionID: config.id, + action: "instance-test", + resources: [config.tool], + } + expect(yield* permissions.ask(permission)).toEqual({ id: permission.id, effect: "ask" }) + const foreignID = config.id === first.id ? second.id : first.id + const foreignForm = yield* forms.create({ + sessionID: foreignID, + title: "Foreign form", + fields: [{ key: "answer", type: "string" }], + }) + const foreignPermission = yield* permissions.ask({ + ...permission, + id: Permission.ID.create(), + sessionID: foreignID, + }) + return { + session, + form, + permission: { ...permission, message: config.tool }, + foreignForm, + foreignPermission, + } + }).pipe(instances.provide(session)) + }), + ) + for (const entry of pending) { + const forms = yield* request(`/api/session/${entry.session.id}/form`) + expect(forms.status).toBe(200) + expect(yield* Effect.promise(() => forms.json())).toEqual({ data: [entry.form] }) + const permissions = yield* request(`/api/session/${entry.session.id}/permission`) + expect(permissions.status).toBe(200) + expect(yield* Effect.promise(() => permissions.json())).toEqual({ data: [entry.permission] }) + + // These IDs exist in the selected instance, but belong to the other Session. + expect((yield* request(`/api/session/${entry.session.id}/form/${entry.foreignForm.id}`)).status).toBe(404) + expect( + (yield* request(`/api/session/${entry.session.id}/permission/${entry.foreignPermission.id}`)).status, + ).toBe(404) + expect( + (yield* request(`/api/session/${entry.session.id}/form/${entry.foreignForm.id}/reply`, { + answer: { answer: "wrong" }, + })).status, + ).toBe(404) + expect( + (yield* request(`/api/session/${entry.session.id}/permission/${entry.foreignPermission.id}/reply`, { + reply: "once", + })).status, + ).toBe(404) + yield* Effect.gen(function* () { + const forms = yield* Form.Service + const permissions = yield* Permission.Service + expect(yield* forms.state(entry.foreignForm.id)).toEqual({ status: "pending" }) + expect(yield* permissions.get(entry.foreignPermission.id)).toMatchObject({ + sessionID: entry.foreignForm.sessionID, + }) + }).pipe(instances.provide(entry.session)) + } + for (const entry of pending) { + expect( + (yield* request(`/api/session/${entry.session.id}/form/${entry.form.id}/reply`, { + answer: { answer: entry.session.id }, + })).status, + ).toBe(204) + expect( + (yield* request(`/api/session/${entry.session.id}/permission/${entry.permission.id}/reply`, { + reply: "once", + })).status, + ).toBe(204) + yield* Effect.gen(function* () { + const forms = yield* Form.Service + const permissions = yield* Permission.Service + expect(yield* forms.state(entry.form.id)).toEqual({ + status: "answered", + answer: { answer: entry.session.id }, + }) + expect(yield* permissions.get(entry.permission.id)).toBeUndefined() + }).pipe(instances.provide(entry.session)) + } + expect(boots).toEqual([first.id, second.id]) + expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([]) + }), + 15_000, +)