Compare commits

..
18 Commits
Author SHA1 Message Date
Kit Langton 79fa67df51 test(server): update instance fixture replacement syntax 2026-08-31 14:11:20 -04:00
Kit Langton cc12d7b07d chore(core): reconcile snapshot tests with current v2 wiring 2026-08-31 14:08:21 -04:00
Kit Langton f8bd499de5 test(core): make snapshot fixtures portable on Windows 2026-08-31 14:02:02 -04:00
Kit Langton 3e9b009642 feat(core): add session-aware instance selection (#46442) 2026-08-31 13:46:33 -04:00
Kit Langton 36ac35a7c8 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.
2026-08-31 13:46:27 -04:00
Kit Langton 197d28e033 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.
2026-08-31 13:45:55 -04:00
opencode-agent[bot]andkitlangton fcce2d7cc9 test(tui): await dialog text selection (#46143)
Co-authored-by: kitlangton <7587245+kitlangton@users.noreply.github.com>
2026-08-31 13:44:00 -04:00
Kit Langton 0002d1df81 refactor(core): simplify snapshot restore and fixtures 2026-08-31 13:35:13 -04:00
Kit Langton 24a226ead0 chore: run snapshot checks outside release branches 2026-08-31 13:17:21 -04:00
Kit Langton f07fb81a6e fix(core): preserve snapshots across session moves 2026-08-31 13:13:24 -04:00
Dax Raad ec0dcb3da9 docs: improve build documentation discovery 2026-08-31 12:52:59 -04:00
Kit Langton afd7492018 fix(tui): reduce cached transcript remount work (#46145)
Configure custom Markdown renderers before assigning content and share a reactive message-position index across assistant footers. Preserve completion ordering and historical footer metrics, with regression coverage for prepend, same-length refresh, and revert.
2026-08-31 12:15:39 -04:00
Kit Langton 9517ff1054 fix(core): preserve active session continuation when moving 2026-08-31 12:14:45 -04:00
Kit Langton 1ced747051 fix(ai): handle message-less Gemini errors (#46069) 2026-08-31 12:14:31 -04:00
opencode-agent[bot]andDavid 43819dc376 fix(app): restore maskable pwa icons (#46434)
Co-authored-by: David <1879069+iamdavidhill@users.noreply.github.com>
2026-09-01 00:11:07 +08:00
Kit Langton e15dd8ecd3 fix(ai): require Bedrock message stop for finish (#46065) 2026-08-31 12:03:22 -04:00
Dax 6a38cacc1d docs: improve plugin guide readability (#46342) 2026-08-31 11:48:04 -04:00
Kit Langton d609752891 refactor(codemode): avoid merging root definitions twice (#46081) 2026-08-31 11:40:17 -04:00
161 changed files with 4024 additions and 1727 deletions
+16 -20
View File
@@ -510,9 +510,10 @@ interface ParserState {
readonly tools: ToolStream.State<number>
readonly finishedTools: ReadonlySet<number>
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
// can emit exactly one finish after both chunks have had a chance to arrive.
readonly pendingFinish: { readonly reason: FinishReasonDetails; readonly usage?: Usage } | undefined
// `metadata` (carries usage). Hold both in state so `onHalt` can emit exactly
// one finish after both chunks have had a chance to arrive.
readonly finishReason: FinishReasonDetails | undefined
readonly usage: Usage | undefined
readonly hasToolCalls: boolean
readonly lifecycle: Lifecycle.State
readonly reasoningSignatures: Readonly<Record<number, string>>
@@ -692,12 +693,9 @@ const step = (state: ParserState, event: BedrockEvent) =>
return [
{
...state,
pendingFinish: {
reason: {
normalized: mapFinishReason(event.messageStop.stopReason),
raw: event.messageStop.stopReason,
},
usage: state.pendingFinish?.usage,
finishReason: {
normalized: mapFinishReason(event.messageStop.stopReason),
raw: event.messageStop.stopReason,
},
},
[],
@@ -705,14 +703,11 @@ const step = (state: ParserState, event: BedrockEvent) =>
}
if (event.metadata) {
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.pendingFinish?.usage
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.usage
return [
{
...state,
pendingFinish: {
reason: state.pendingFinish?.reason ?? { normalized: "stop" },
usage,
},
usage,
},
[],
] as const
@@ -736,18 +731,18 @@ const step = (state: ParserState, event: BedrockEvent) =>
const framing = BedrockEventStream.framing(ADAPTER)
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> => {
if (!state.pendingFinish) return []
if (!state.finishReason) return []
const normalized = (() => {
if (state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls) return "tool-calls"
return state.pendingFinish.reason.normalized
if (state.finishReason.normalized === "stop" && state.hasToolCalls) return "tool-calls"
return state.finishReason.normalized
})()
const events: LLMEvent[] = []
Lifecycle.finish(state.lifecycle, events, {
reason: {
...state.pendingFinish.reason,
...state.finishReason,
normalized,
},
usage: state.pendingFinish.usage,
usage: state.usage,
})
return events
}
@@ -771,7 +766,8 @@ export const protocol = Protocol.make({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
finishedTools: new Set<number>(),
pendingFinish: undefined,
finishReason: undefined,
usage: undefined,
hasToolCalls: false,
lifecycle: Lifecycle.initial(),
reasoningSignatures: {},
+11 -2
View File
@@ -609,18 +609,27 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
}
const step = (state: ParserState, event: GeminiEvent) => {
if (ProviderShared.isRecord(event.error) && typeof event.error.message === "string") {
if (ProviderShared.isRecord(event.error)) {
const body = ProviderShared.encodeJson(event)
return Effect.fail(
new AIError({
reason: classifyProviderFailure({
message: event.error.message,
message:
typeof event.error.message === "string" && event.error.message.length > 0
? event.error.message
: typeof event.error.status === "string" && event.error.status.length > 0
? event.error.status
: "Gemini provider error",
status: typeof event.error.code === "number" ? event.error.code : undefined,
rawBody: body,
}),
}),
)
}
if ("error" in event)
return Effect.fail(
ProviderShared.eventError(state.route, `Invalid ${state.route} stream event`, ProviderShared.encodeJson(event)),
)
const nextState = {
...state,
promptFeedback: event.promptFeedback ?? state.promptFeedback,
@@ -631,10 +631,45 @@ describe("Bedrock Converse route", () => {
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
}),
)
it.effect("retains metadata usage that arrives before messageStop", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
["messageStop", { stopReason: "end_turn" }],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
}),
)
it.effect("rejects metadata-only streams as incomplete with HTTP context", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(eventStreamBody(["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }])),
),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
classification: "incomplete-stream",
http: {
status: 200,
headers: { "content-type": "application/vnd.amazon.eventstream" },
},
})
}),
)
it.effect("assembles streamed tool call input", () =>
Effect.gen(function* () {
const body = eventStreamBody(
@@ -62,6 +62,62 @@ describe("provider error retention", () => {
)
}
it.effect("classifies a message-less Gemini 429 and retains its event and HTTP context", () =>
Effect.gen(function* () {
const body = JSON.stringify({
error: { code: 429, status: "RESOURCE_EXHAUSTED", details: { opaque: [1, 2] } },
trace: { opaque: "outer" },
})
const error = yield* LLMClient.generate(
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
).pipe(
Effect.provide(
fixedResponse(sseEvents(body), {
headers: { "content-type": "text/event-stream", "x-provider-trace": "trace-1" },
}),
),
Effect.flip,
)
expect(error.message).toBe("RESOURCE_EXHAUSTED")
expect(error.reason._tag).toBe("RateLimit")
expect(error.reason.body).toBe(body)
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-1" } })
expect(error.reason.http?.url).toStartWith("https://provider.test/")
}),
)
it.effect("rejects a malformed non-record Gemini error", () =>
Effect.gen(function* () {
const body = JSON.stringify({ error: "RESOURCE_EXHAUSTED", trace: { opaque: "outer" } })
const error = yield* LLMClient.generate(
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
).pipe(Effect.provide(fixedResponse(sseEvents(body))), Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("Invalid google/gemini stream event")
expect(error.reason.body).toBe(body)
expect(error.reason.http?.status).toBe(200)
}),
)
it.effect("rejects and retains an explicit null Gemini error", () =>
Effect.gen(function* () {
const body = JSON.stringify({ error: null, trace: { opaque: "outer" } })
const error = yield* LLMClient.generate(
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
).pipe(
Effect.provide(fixedResponse(sseEvents(body), { headers: { "x-provider-trace": "trace-null" } })),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.reason.body).toBe(body)
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-null" } })
expect(error.reason.http?.url).toStartWith("https://provider.test/")
}),
)
it.effect("retains malformed provider frames and the original decode cause", () =>
Effect.gen(function* () {
const body = '{"type":"error","error":{"message":42,"opaque":{"nested":true}},"trace":"outer"}'
+2 -2
View File
@@ -9,13 +9,13 @@
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
"purpose": "maskable"
}
],
"theme_color": "#080808",
+1
View File
@@ -59,6 +59,7 @@ test.each(["dev", "beta", "prod"])("serves %s app icons", async (channel) => {
async function check(channel: string, read: (path: string) => Promise<Uint8Array>) {
const html = new TextDecoder().decode(await read("/index.html"))
const actual: typeof manifest = JSON.parse(new TextDecoder().decode(await read("/site.webmanifest")))
expect(actual.icons.every((icon) => icon.purpose === "maskable")).toBe(true)
expect(actual).toEqual({
...manifest,
icons: manifest.icons.map((icon) => ({ ...icon, src: `/icons/${channel}${icon.src}` })),
+6 -5
View File
@@ -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({
+6 -5
View File
@@ -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),
)
+1 -1
View File
@@ -180,7 +180,7 @@ export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false
export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => {
try {
return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty })
return renderSchema(schema, { definitions: {}, pretty })
} catch {
return "unknown"
}
+29
View File
@@ -216,6 +216,35 @@ describe("pretty signature rendering", () => {
})
})
describe("JSON Schema definition scope", () => {
test.each(["definitions", "$defs"])("resolves root %s and lets $defs take precedence", (key) => {
const schema = { $ref: `#/${key}/Value`, [key]: { Value: { type: "string" } } }
expect(jsonSchemaToTypeScript(schema)).toBe("string")
expect(jsonSchemaToTypeScript(schema, true)).toBe("string")
const overridden = { ...schema, $defs: { Value: { type: "number" } } }
expect(jsonSchemaToTypeScript(overridden)).toBe("number")
expect(jsonSchemaToTypeScript(overridden, true)).toBe("number")
})
test.each(["definitions", "$defs"])("nested %s shadow inherited definitions without affecting siblings", (key) => {
const schema = {
type: "object",
definitions: { Inherited: { type: "string" } },
$defs: { Value: { type: "number" } },
properties: {
nested: { $ref: `#/${key}/Value`, [key]: { Value: { type: "boolean" } } },
inherited: { $ref: "#/definitions/Inherited" },
sibling: { $ref: "#/$defs/Value" },
},
}
expect(jsonSchemaToTypeScript(schema)).toBe("{ nested?: boolean; inherited?: string; sibling?: number }")
expect(jsonSchemaToTypeScript(schema, true)).toBe(
["{", " nested?: boolean,", " inherited?: string,", " sibling?: number,", "}"].join("\n"),
)
})
})
describe("non-identifier property names render as quoted keys", () => {
// MCP-style schemas routinely carry property names that are not bare TS identifiers
// (`foo-bar`, `@type`, dotted names); the rendered signature must quote them so the
+4 -13
View File
@@ -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<A, E>(root: LayerNode.Node<A, E, any>, 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<unknown, unknown, any>) {
return replacements.some(([source]) => source.name === node.name)
export function build<A, E>(root: LayerNode.Graph<A, E>, replacements: LayerNode.Replacements = []) {
return LayerNode.compile(root, {
replacements: [LocationServiceMap.node.replace(buildLocationServiceMap(replacements)), ...replacements],
})
}
export * as AppNodeBuilder from "./app-node-builder.js"
+114 -23
View File
@@ -14,6 +14,8 @@ export class Repository extends Schema.Class<Repository>("Git.Repository")({
worktree: AbsolutePath,
gitDirectory: AbsolutePath,
commonDirectory: AbsolutePath,
/** Additional read-only object databases; index and worktree remain local. */
objectDirectories: Schema.optional(Schema.Array(AbsolutePath)),
}) {}
// Included from $GIT_DIR/config via include.path (git >= 1.7.10); OpenCode owns
@@ -50,6 +52,7 @@ export class OperationError extends Schema.TaggedError<OperationError>()("Git.Op
"list_files",
"diff",
"restore",
"retain",
]),
message: Schema.String,
directory: Schema.optional(AbsolutePath),
@@ -132,6 +135,12 @@ export interface Interface {
}) => Effect.Effect<ReadonlySet<RelativePath>, OperationError>
}
readonly tree: {
readonly exists: (repository: Repository, tree: TreeID) => Effect.Effect<boolean>
/** Retain a tree's borrowed objects in this repository, independent of its alternates. */
readonly retain: (input: {
repository: Repository
trees: readonly TreeID[]
}) => Effect.Effect<void, OperationError>
readonly capture: (input: {
repository: Repository
scopes: readonly RelativePath[]
@@ -314,7 +323,16 @@ const layer = Layer.effect(
.run(
ChildProcess.make("git", repositoryArgs(repository, args), {
cwd: repository.worktree,
env: options?.env,
env: {
...(repository.objectDirectories?.length
? {
GIT_ALTERNATE_OBJECT_DIRECTORIES: repository.objectDirectories
.map((directory) => JSON.stringify(directory))
.join(path.delimiter),
}
: {}),
...options?.env,
},
extendEnv: true,
}),
{ stdin: options?.stdin },
@@ -477,6 +495,46 @@ const layer = Layer.effect(
return TreeID.make((yield* repositoryOperation("write_tree", repository, ["write-tree"])).text.trim())
})
const treeExists = Effect.fn("Git.tree.exists")((repository: Repository, tree: TreeID) =>
repositoryOperation("list_files", repository, ["cat-file", "-e", `${tree}^{tree}`]).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
),
)
const retain = Effect.fn("Git.tree.retain")((input: { repository: Repository; trees: readonly TreeID[] }) =>
locked(
input.repository,
Effect.gen(function* () {
const retained = (yield* repositoryOperation("retain", input.repository, [
"for-each-ref",
"--format=%(objectname)",
"refs/opencode/snapshots/",
])).text
.trim()
.split("\n")
.filter(Boolean)
const missing = Array.from(new Set(input.trees)).filter((tree) => !retained.includes(tree))
if (!missing.length) return
// Only these refs certify locally retained objects. Ordinary refs or alternates do not.
yield* repositoryOperation(
"retain",
input.repository,
[
"pack-objects",
"--revs",
"--non-empty",
path.join(input.repository.gitDirectory, "objects", "pack", "pack"),
],
{ stdin: [...missing, ...retained.map((tree) => `^${tree}`)].join("\n") + "\n" },
)
yield* repositoryOperation("retain", input.repository, ["update-ref", "--stdin"], {
stdin: missing.map((tree) => `update refs/opencode/snapshots/${tree} ${tree}\n`).join(""),
})
}),
),
)
const captureTree = Effect.fn("Git.tree.capture")(
(input: {
repository: Repository
@@ -586,28 +644,59 @@ const layer = Layer.effect(
(input: { repository: Repository; files: ReadonlyMap<RelativePath, TreeID> }) =>
locked(
input.repository,
Effect.forEach(
input.files,
([file, tree]) =>
Effect.gen(function* () {
if (yield* hasEntry(input.repository, tree, file)) {
yield* repositoryOperation("restore", input.repository, ["checkout", tree, "--", file])
return
}
yield* fs.remove(path.join(input.repository.worktree, file), { recursive: true, force: true }).pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "restore",
directory: input.repository.worktree,
message: `Failed to remove ${file}`,
cause,
}),
),
)
}),
{ discard: true },
),
Effect.gen(function* () {
const entries = yield* Effect.forEach(input.files, ([file, tree]) =>
hasEntry(input.repository, tree, file).pipe(
Effect.map((present) => ({ file, tree, present, depth: file.split("/").length })),
),
)
// Remove descendants before any restoration can replace their ancestor with a symlink or file.
yield* Effect.forEach(
entries.toSorted(
(a, b) => Number(a.present) - Number(b.present) || (a.present ? a.depth - b.depth : b.depth - a.depth),
),
({ file, tree, present }) =>
Effect.gen(function* () {
if (present) {
yield* repositoryOperation("restore", input.repository, [
"--literal-pathspecs",
"restore",
`--source=${tree}`,
...(input.repository.objectDirectories?.length ? [] : ["--staged"]),
"--worktree",
"--",
file,
])
// Re-index foreign content without alternates so future local captures can read it.
if (input.repository.objectDirectories?.length)
yield* repositoryOperation(
"restore",
new Repository({ ...input.repository, objectDirectories: undefined }),
["--literal-pathspecs", "add", "--force", "--sparse", "--", file],
)
return
}
yield* fs.remove(path.join(input.repository.worktree, file), { recursive: true, force: true }).pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "restore",
directory: input.repository.worktree,
message: `Failed to remove ${file}`,
cause,
}),
),
)
yield* repositoryOperation("restore", input.repository, [
"update-index",
"--force-remove",
"--",
file,
])
}),
{ discard: true },
)
}),
),
)
@@ -690,6 +779,8 @@ const layer = Layer.effect(
worktree: { create: worktreeCreate, remove: worktreeRemove, list: worktreeList },
index: { refresh, ignored },
tree: {
exists: treeExists,
retain,
capture: captureTree,
write: writeTree,
files: treeFiles,
+10 -16
View File
@@ -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,
@@ -110,9 +111,9 @@ const nodes = [
Vcs.node,
// Start repository watches only after boot-critical filesystem and Git work.
LocationWatcher.node,
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
] as const satisfies readonly Node.LocationGraph<never, unknown>[]
export const graph = LayerNode.group<typeof nodes>(nodes)
export const graph = LayerNode.group(nodes)
export type Services = LayerNode.Output<typeof graph>
export type Error = LayerNode.Error<typeof graph>
@@ -141,29 +142,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 +166,5 @@ export function layer(ref: Location.Ref, options: Options = {}) {
durationMs: Math.round(performance.now() - startedAt),
}),
),
Layer.provide(LayerNode.compile(location.hoisted)),
)
}
+42
View File
@@ -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,
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Services>>
/** Borrow a cached instance without initializing one when it is absent. */
readonly provideIfLoaded: (
session: Session.Info,
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<Option.Option<A>, E, Exclude<R, Services>>
}
export class Service extends Context.Service<Service, Interface>()("@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] })
+9 -3
View File
@@ -112,7 +112,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@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))
+6 -3
View File
@@ -80,7 +80,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
const response = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
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"] {
+1 -1
View File
@@ -62,7 +62,7 @@ const require = <A, E, R>(cell: Cell, f: (runtime: Interface) => Effect.Effect<A
const defaultCell = makeCell()
export const layerWithCell = (cell: Cell) =>
export const layerWithCell = (cell: Cell): Layer.Layer<Service> =>
Layer.succeed(
Service,
Service.of({
@@ -11,6 +11,9 @@ truth. Follow links from that page when the question needs more detail. Fetch
<https://opencode.ai/v2/docs/> first when you need to discover the relevant
documentation page.
A machine-readable documentation index is available at
<https://opencode.ai/v2/llms.txt>.
## 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
+10 -11
View File
@@ -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,
@@ -468,7 +465,8 @@ const layer = Layer.effect(
Effect.gen(function* () {
const latest = yield* result.get(input.sessionID)
const source = yield* fs.stat(latest.location.directory).pipe(Effect.orElseSucceed(() => undefined))
if (!source || source.type !== "Directory") {
// Active runners must hand off at a step boundary to retain their continuation.
if ((!source || source.type !== "Directory") && !(yield* execution.isActive(input.sessionID))) {
const cancellations = (yield* SessionInbox.moveIDs(db, input.sessionID)).map(
(item) => [SessionEvent.InboxCancelled, { sessionID: input.sessionID, inboxID: item.id }] as const,
)
@@ -534,6 +532,7 @@ export const node = makeGlobalNode({
Project.node,
SessionExecution.node,
SessionStore.node,
Instance.byLocationNode,
SessionInbox.node,
LocationServiceMap.node,
SessionProjector.node,
+6 -6
View File
@@ -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<void>
}
/** 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<Service, Interface>()("@opencode/SessionExecution") {}
type InterruptReason = "user" | "shutdown"
@@ -48,12 +48,12 @@ export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, 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. */
+10 -19
View File
@@ -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<Services>) {
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) {
+114 -17
View File
@@ -42,9 +42,9 @@ export type Draft = {
export interface Interface extends State.Transformable<Draft> {
/**
* Capture the current Location-scoped filesystem state as a content-addressed
* tree. Returns `undefined` when snapshots are disabled, unsupported, or the
* best-effort capture fails.
* Capture the current Location-scoped filesystem state as an opaque reference
* to a content-addressed tree and its storage. Returns `undefined` when
* snapshots are disabled, unsupported, or the best-effort capture fails.
*/
readonly capture: () => Effect.Effect<ID | undefined>
@@ -100,7 +100,18 @@ const layer = Layer.effect(
: yield* git.repo
.create({ worktree, gitDirectory, seed: source })
.pipe(Effect.mapError((cause) => failure("capture", cause)))
return { source, worktree, snapshotRepository }
return {
source,
worktree,
snapshotRepository,
foreignRepository: (directory: AbsolutePath) =>
new Git.Repository({
worktree,
gitDirectory: directory,
commonDirectory: directory,
objectDirectories: [AbsolutePath.make(path.join(source.commonDirectory, "objects"))],
}),
}
}).pipe(Effect.forkIn(lifetime)),
)
const repository = repositoryFiber.pipe(Effect.uninterruptible, Effect.flatMap(Fiber.join))
@@ -114,18 +125,80 @@ const layer = Layer.effect(
const enabled = () => location.vcs?.type === "git" && state.get().enabled
const resolved = new Map<ID, { directory: AbsolutePath; tree: Git.TreeID }>()
const resolve = Effect.fnUntraced(function* (id: ID) {
const cached = resolved.get(id)
if (cached) return cached
const qualified = /^snapshot:([a-zA-Z0-9_-]+)\/([a-f0-9]{40})\/([a-f0-9]{40}|[a-f0-9]{64})$/.exec(id)
if (qualified)
return {
directory: AbsolutePath.make(path.join(global.data, "snapshot", qualified[1], qualified[2])),
tree: Git.TreeID.make(qualified[3]),
}
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(id))
return yield* new Error({ operation: "restore", message: "Invalid snapshot reference" })
const repo = yield* repository
const tree = Git.TreeID.make(id)
if (yield* git.tree.exists(repo.snapshotRepository, tree))
return { directory: repo.snapshotRepository.gitDirectory, tree }
// Persisted hash-only IDs predate storage-qualified references. Search only on a local miss.
const root = path.join(global.data, "snapshot")
const projects = yield* fs.readDirectoryEntries(root).pipe(Effect.mapError((cause) => failure("restore", cause)))
for (const project of projects.filter(
(entry) => entry.type === "directory" && /^[a-zA-Z0-9_-]+$/.test(entry.name),
)) {
const stores = yield* fs
.readDirectoryEntries(path.join(root, project.name))
.pipe(Effect.mapError((cause) => failure("restore", cause)))
for (const store of stores.filter((entry) => entry.type === "directory" && /^[a-f0-9]{40}$/.test(entry.name))) {
const directory = AbsolutePath.make(path.join(root, project.name, store.name))
const candidate = repo.foreignRepository(directory)
if (!(yield* git.tree.exists(candidate, tree))) continue
// Identical legacy trees can exist in several stores; skip incomplete copies.
if (
!(yield* git.tree.retain({ repository: candidate, trees: [tree] }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
))
)
continue
const result = { directory, tree }
resolved.set(id, result)
return result
}
}
return yield* new Error({ operation: "restore", message: `Snapshot tree not found: ${id}` })
})
const read = Effect.fnUntraced(function* (ids: readonly ID[]) {
const repo = yield* repository
const stores = new Map<AbsolutePath, Git.TreeID[]>()
for (const id of new Set(ids)) {
const ref = yield* resolve(id)
if (ref.directory === repo.snapshotRepository.gitDirectory) continue
stores.set(ref.directory, [...(stores.get(ref.directory) ?? []), ref.tree])
}
for (const [directory, trees] of stores) {
// A renamed checkout can supply the old store's borrowed objects at its new path.
yield* git.tree.retain({ repository: repo.foreignRepository(directory), trees })
}
return new Git.Repository({
...repo.snapshotRepository,
objectDirectories: Array.from(stores.keys(), (directory) => AbsolutePath.make(path.join(directory, "objects"))),
})
})
const capture = Effect.fn("Snapshot.capture")(function* () {
if (!enabled()) return undefined
return yield* Effect.gen(function* () {
const repo = yield* repository
return ID.make(
yield* git.tree.capture({
repository: repo.snapshotRepository,
scopes: [yield* scope(repo.worktree)],
ignores: repo.source,
maximumUntrackedFileBytes: 2 * 1024 * 1024,
}),
)
const tree = yield* git.tree.capture({
repository: repo.snapshotRepository,
scopes: [yield* scope(repo.worktree)],
ignores: repo.source,
maximumUntrackedFileBytes: 2 * 1024 * 1024,
})
return ID.make(`snapshot:${location.project.id}/${Hash.fast(repo.worktree)}/${tree}`)
}).pipe(
Effect.catch((cause) => Effect.logWarning("failed to capture snapshot", { cause }).pipe(Effect.as(undefined))),
)
@@ -133,10 +206,11 @@ const layer = Layer.effect(
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
const snapshots = yield* read([input.from, input.to]).pipe(Effect.mapError((cause) => failure(operation, cause)))
const comparison = {
repository: repo.snapshotRepository,
from: Git.TreeID.make(input.from),
to: Git.TreeID.make(input.to),
repository: snapshots,
from: treeID(input.from),
to: treeID(input.to),
}
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
const ignored = yield* git.index
@@ -171,7 +245,23 @@ const layer = Layer.effect(
const absolute = path.resolve(worktree, file)
if (!FSUtil.contains(worktree, absolute))
return yield* new Error({ operation: "restore", message: `Path escapes the project: ${file}` })
files.set(file, Git.TreeID.make(snapshot))
// Check ancestors, not the leaf: removing a symlink itself must not follow its target.
for (let parent = path.dirname(absolute); parent !== worktree; parent = path.dirname(parent)) {
const canonical = yield* fs.realPath(parent).pipe(
Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined),
Effect.catchReason("PlatformError", "BadResource", (reason, error) =>
Schema.is(Schema.Struct({ code: Schema.Literal("ENOTDIR") }))(reason.cause)
? Effect.undefined
: Effect.fail(error),
),
Effect.mapError((cause) => failure("restore", cause)),
)
if (canonical === undefined) continue
if (!FSUtil.contains(worktree, canonical))
return yield* new Error({ operation: "restore", message: `Path escapes the project: ${file}` })
break
}
files.set(file, treeID(snapshot))
}
return files
})
@@ -179,8 +269,11 @@ const layer = Layer.effect(
const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) {
if (!enabled()) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
const repo = yield* repository.pipe(Effect.mapError((cause) => failure("restore", cause)))
const snapshots = yield* read(Array.from(input.files.values())).pipe(
Effect.mapError((cause) => failure("restore", cause)),
)
yield* git.tree
.restore({ repository: repo.snapshotRepository, files: yield* plan(repo.worktree, input) })
.restore({ repository: snapshots, files: yield* plan(repo.worktree, input) })
.pipe(Effect.mapError((cause) => failure("restore", cause)))
})
@@ -206,6 +299,10 @@ export const noopLayer = Layer.succeed(
}),
)
function treeID(id: ID) {
return Git.TreeID.make(id.slice(id.lastIndexOf("/") + 1))
}
function failure(operation: Error["operation"], cause: unknown) {
if (cause instanceof Error && cause.operation === operation) return cause
return new Error({
+2 -2
View File
@@ -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<unknown, never>,
)
@@ -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") })
+10 -10
View File
@@ -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<void>()
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<void>()
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* () {
+4 -4
View File
@@ -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),
]),
)
+1 -1
View File
@@ -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") })),
]),
),
),
@@ -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* () {
+10 -11
View File
@@ -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),
],
),
),
+3 -4
View File
@@ -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),
]),
),
)
+11 -12
View File
@@ -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),
]),
),
)
+4 -7
View File
@@ -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)],
),
)
+6 -7
View File
@@ -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),
]),
),
)
+2 -2
View File
@@ -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") })),
]),
),
)
@@ -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(
@@ -13,131 +13,218 @@ class OtherError {
readonly _tag = "OtherError"
}
const tags = LayerNode.tags({ app: [] })
const make = tags.make("app")
const build = <A, E>(root: LayerNode.Node<A, E, any>) => LayerNode.compile(root) as Layer.Layer<A, E>
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<typeof aLayer, readonly [], typeof tags.values.app>({ 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<C, never, never>
LayerNode.compile(dependent) satisfies Layer.Layer<B, LayerError, never>
LayerNode.compile(group) satisfies Layer.Layer<A | B, never, never>
LayerNode.compile(LayerNode.group([])) satisfies Layer.Layer<never>
// @ts-expect-error An empty graph cannot supply arbitrary services
LayerNode.compile(LayerNode.group([])) satisfies Layer.Layer<A>
LayerNode.compile(inputA, { replacements: [inputA.replace(a)] }) satisfies Layer.Layer<A, never, never>
// @ts-expect-error A is a private dependency, not a root output
LayerNode.compile(c) satisfies Layer.Layer<A | C>
// @ts-expect-error Dependency failures are not erased
LayerNode.compile(dependent) satisfies Layer.Layer<B>
// @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<C, never, never> = closed
const checkError: Layer.Layer<B, LayerError, never> = 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<A | B, never, typeof tags.values.app> = group
// @ts-expect-error Graphs are opaque
const forgedGraph: LayerNode.Graph<A> = { name: "a" }
class TagA extends Context.Service<TagA, {}>()("test/TagA") {}
class TagB extends Context.Service<TagB, {}>()("test/TagB") {}
class TagC extends Context.Service<TagC, {}>()("test/TagC") {}
const aContract: LayerNode.Node<A, never, typeof tags.values.app> = 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<A, never, typeof tags.values.app> = ab
// @ts-expect-error Output widening cannot add B before replacement
const widenedOutput: LayerNode.Node<A | B, never, typeof tags.values.app> = a
// @ts-expect-error Error widening cannot authorize a new replacement error
const widenedError: LayerNode.Node<A, LayerError, typeof tags.values.app> = a
// @ts-expect-error Error narrowing cannot forget an existing failure
const narrowedError: LayerNode.Node<A, never, typeof tags.values.app> = failing
// @ts-expect-error Tag widening cannot authorize replacement across tags
const widenedTag: LayerNode.Node<A, never, LayerNode.Tag | undefined> = a
const unionTag = LayerNode.unbound(A, tag)
// @ts-expect-error Tag narrowing cannot forget a possible tag
const narrowedTag: LayerNode.Node<A, never, typeof tags.values.app> = 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<A, never, typeof tags.values.app> = group
// @ts-expect-error Graph output projection cannot invent a service
const widenedGraph: LayerNode.Graph<A | B, never, typeof tags.values.app> = 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<A | B>
const conditional = make({ name: "conditional", layer: flag ? aLayer : Layer.succeed(B, {}), deps: [] })
LayerNode.compile(conditional) satisfies Layer.Layer<never>
// @ts-expect-error A conditional implementation does not acquire both branches
LayerNode.compile(conditional) satisfies Layer.Layer<A | B>
LayerNode.compile(LayerNode.group([flag ? a : ab])) satisfies Layer.Layer<A>
const dynamic: Array<typeof a> = []
// @ts-expect-error An unbounded array may contain no roots
LayerNode.compile(LayerNode.group(dynamic)) satisfies Layer.Layer<A>
// @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>
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<A>) => 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<ScopedA, {}>()("test/ScopedA") {}
class ScopedB extends Context.Service<ScopedB, {}>()("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
})
@@ -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<Value, { readonly value: string }>()("test/LayerNodeValue") {}
class Greeting extends Context.Service<Greeting, { readonly value: string }>()("test/LayerNodeGreeting") {}
class Left extends Context.Service<Left, { readonly value: string }>()("test/LayerNodeLeft") {}
class Right extends Context.Service<Right, { readonly value: string }>()("test/LayerNodeRight") {}
class Database extends Context.Service<Database, { readonly name: string }>()("test/GraphDatabase") {}
class Users extends Context.Service<Users, { readonly list: Effect.Effect<string[]> }>()("test/GraphUsers") {}
class App extends Context.Service<App, { readonly run: Effect.Effect<string[]> }>()("test/GraphApp") {}
class Memo extends Context.Service<Memo, Layer.MemoMap>()("test/LayerNodeMemo") {}
class Support extends Context.Service<Support, {}>()("test/LayerNodeSupport") {}
class Locations extends Context.Service<Locations, LayerMap.LayerMap<string, Value | Right, "failed location">>()(
"test/LayerNodeLocations",
) {}
const it = testEffect(Layer.empty)
const tags = LayerNode.tags({ app: [] })
const make = tags.make("app")
const build = <A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) =>
LayerNode.compile(root, replacements) as Layer.Layer<A, E>
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<Greeting> = 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<Greeting>
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<App>
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<void>()
const greetingStarted = yield* Deferred.make<void>()
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
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"])
}),
)
})
})
@@ -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<Value, { readonly value: string }>()("test/TagValue") {}
class Result extends Context.Service<Result, { readonly value: string }>()("test/TagResult") {}
class CycleA extends Context.Service<CycleA, {}>()("test/NodeBuildA") {}
class CycleB extends Context.Service<CycleB, { readonly directory: AbsolutePath }>()("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<LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>, 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
+2 -2
View File
@@ -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)),
]),
)
}
+14 -20
View File
@@ -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<void>()
const release = yield* Deferred.make<void>()
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* () {
@@ -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<Watcher.Service>,
config: Layer.Layer<Config.Service> = configLayer,
plugins: LocationNode<PluginSupervisor.Service> = 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<A, E, R>(
init?: (directory: string) => Promise<void>
watcher?: Layer.Layer<Watcher.Service>
config?: Layer.Layer<Config.Service>
plugins?: LocationNode<PluginSupervisor.Service>
plugins?: typeof pluginNode
},
) {
return Effect.acquireRelease(
@@ -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))
@@ -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 }),
+1 -1
View File
@@ -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
+40 -1
View File
@@ -7,7 +7,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Git } from "@opencode-ai/core/git"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(LayerNode.compile(Git.node))
@@ -105,6 +105,45 @@ describe("Git worktrees", () => {
})
describe("Git trees", () => {
it.live("retains borrowed tree objects once for restoration without the source repository", () =>
Effect.gen(function* () {
const root = yield* tmpdirScoped()
const source = AbsolutePath.make(path.join(root.path, "source"))
const destination = AbsolutePath.make(path.join(root.path, "destination"))
yield* Effect.promise(async () => {
await fs.mkdir(source)
await fs.mkdir(destination)
await initRepo(source)
await Bun.write(path.join(source, "file.txt"), "Borrowed committed content.\n")
await $`git add .`.cwd(source).quiet()
await $`git commit -qm initial`.cwd(source).quiet()
})
const git = yield* Git.Service
const seed = yield* git.repo.discover(source)
if (!seed) throw new Error("Repository not found")
const repository = yield* git.repo.create({
worktree: source,
gitDirectory: AbsolutePath.make(path.join(root.path, "snapshot storage")),
seed,
})
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
expect(yield* git.tree.exists(repository, before)).toBe(true)
expect(yield* git.tree.exists(repository, Git.TreeID.make("0".repeat(40)))).toBe(false)
yield* git.tree.retain({ repository, trees: [before, before] })
yield* Effect.promise(() => Bun.write(path.join(source, "file.txt"), "Snapshot-only content.\n"))
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
yield* git.tree.retain({ repository, trees: [before, after] })
const packs = yield* Effect.promise(() => fs.readdir(path.join(repository.gitDirectory, "objects/pack")))
yield* git.tree.retain({ repository, trees: [before, after] })
expect(yield* Effect.promise(() => fs.readdir(path.join(repository.gitDirectory, "objects/pack")))).toEqual(packs)
yield* Effect.promise(() => fs.rm(source, { recursive: true }))
const moved = new Git.Repository({ ...repository, worktree: destination, objectDirectories: [] })
yield* git.tree.restore({ repository: moved, files: new Map([[RelativePath.make("file.txt"), before]]) })
expect(yield* read(path.join(destination, "file.txt"))).toBe("Borrowed committed content.\n")
yield* git.tree.restore({ repository: moved, files: new Map([[RelativePath.make("file.txt"), after]]) })
expect(yield* read(path.join(destination, "file.txt"))).toBe("Snapshot-only content.\n")
}),
)
;[0, 1, 128].forEach((exitCode) => {
it.live(`refresh handles check-ignore exit ${exitCode}`, () =>
Effect.gen(function* () {
+3 -3
View File
@@ -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),
]),
)
+5 -6
View File
@@ -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),
]),
)
@@ -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,
+1 -1
View File
@@ -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 })),
]),
)
@@ -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 })),
]),
)
+1 -1
View File
@@ -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<A, E, R>(
@@ -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 = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
+3 -3
View File
@@ -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),
]),
)
+11 -10
View File
@@ -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) },
),
),
),
),
],
]),
}),
)
}
+1 -1
View File
@@ -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", () =>
+2 -3
View File
@@ -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", () => {
+12 -14
View File
@@ -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),
]),
),
)
+6 -6
View File
@@ -182,9 +182,9 @@ const buildLayer = (state: Ref.Ref<MockState>, 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))
+1 -1
View File
@@ -20,7 +20,7 @@ const writePackage = (dir: string, pkg: Record<string, unknown>) =>
)
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")
+1 -1
View File
@@ -37,7 +37,7 @@ const it = testEffect(
PluginHooks.node,
Permission.node,
]),
[[Location.node, current]],
[Location.node.replace(current)],
),
)
+3 -3
View File
@@ -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<PluginHooks.Service>
const it = testEffect(layer)
const it = testEffect(LayerNode.compile(PluginHooks.node))
describe("PluginHooks", () => {
it.effect("registers scoped session hooks and triggers them sequentially", () =>
+2 -2
View File
@@ -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),
]),
)
+10 -8
View File
@@ -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<unknown, never>
+5 -5
View File
@@ -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()),
],
),
)
+2 -2
View File
@@ -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", () =>
+1 -1
View File
@@ -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", () =>
@@ -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,
),
+4 -2
View File
@@ -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
+3 -1
View File
@@ -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", () => {
@@ -8,7 +8,7 @@ import { it } from "./lib/effect"
import { readInitial, readUpdate } from "./lib/instructions"
const instructionsLayer = (referenceLayer: Layer.Layer<Reference.Service>) =>
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", () =>
+1 -1
View File
@@ -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", () =>
+2 -2
View File
@@ -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") })),
])
}
+1 -1
View File
@@ -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", () =>
+8 -8
View File
@@ -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") })
+3 -3
View File
@@ -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),
],
),
)
@@ -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)],
),
)
+10 -13
View File
@@ -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 })),
],
)
+7 -1
View File
@@ -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,12 @@ 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, {
replacements: [LocationServiceMap.node.replace(locations)],
}).pipe(Layer.fresh),
),
),
scope,
)
+11 -11
View File
@@ -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") })),
],
),
)
@@ -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),
],
)
+3 -3
View File
@@ -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),
],
),
)
@@ -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 })),
],
)
@@ -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) => ({
+67 -5
View File
@@ -15,6 +15,7 @@ import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
import { SessionStore } from "@opencode-ai/core/session/store"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { tmpdirScoped } from "./fixture/tmpdir"
@@ -24,9 +25,35 @@ import { globalProjectNode } from "./lib/project"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[Project.node.replace(globalProjectNode), SessionExecution.node.replace(SessionExecution.noopLayer)],
),
)
const itWithActiveExecution = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
SessionProjector.node,
SessionStore.node,
SessionExecution.node,
Session.node,
]),
[
[Project.node, globalProjectNode],
[SessionExecution.node, SessionExecution.noopLayer],
Project.node.replace(globalProjectNode),
LocationServiceMap.node.replace(
Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
(ref: Location.Ref) =>
Layer.merge(
LayerNode.compile(Location.boundNode(ref), {
replacements: [Project.node.replace(globalProjectNode)],
}),
Layer.succeed(SessionRunner.Service, { drain: () => Effect.never }),
) as unknown as Layer.Layer<LocationServices>,
),
),
),
],
),
)
@@ -40,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),
],
),
)
@@ -104,6 +131,41 @@ describe("Session.move", () => {
),
)
itWithActiveExecution.live("defers an active move when the source directory no longer exists", () =>
tmpdirScoped().pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const session = yield* Session.Service
const execution = yield* SessionExecution.Service
const source = AbsolutePath.make(path.join(tmp.path, "source"))
const destination = AbsolutePath.make(tmp.path)
yield* Effect.promise(() => mkdir(source))
const created = yield* session.create({ location: Location.Ref.make({ directory: source }) })
// Hold real execution open so the move cannot be consumed before admission is checked.
yield* execution.wake(created.id)
expect(yield* execution.isActive(created.id)).toBe(true)
yield* Effect.promise(() => rm(source, { recursive: true }))
yield* session.move({ sessionID: created.id, directory: destination })
expect((yield* session.get(created.id)).location.directory).toBe(source)
expect(yield* session.inbox(created.id)).toMatchObject([
{
type: "move",
delivery: "steer",
payload: { location: { directory: destination } },
},
])
expect(yield* execution.isActive(created.id)).toBe(true)
yield* execution.interrupt(created.id)
yield* execution.awaitIdle(created.id)
}),
),
),
)
it.effect("keeps a moved session out of its former directory's new identity", () =>
tmpdirScoped().pipe(
Effect.flatMap((tmp) =>
+17 -7
View File
@@ -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"
@@ -51,10 +52,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")
@@ -130,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<Session.Services> => {
const servicesFor = (ref: Location.Ref) => {
locations.push(ref)
return Layer.merge(SessionRevert.layer, SessionPrompt.layer).pipe(
Layer.provideMerge(
@@ -159,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<Instance.Services>),
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 }
+2 -2
View File
@@ -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") }
@@ -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)),
],
),
)
+12 -11
View File
@@ -95,9 +95,9 @@ const locations = (references: Layer.Layer<Reference.Service>) =>
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") })),
]),
),
)
+75 -9
View File
@@ -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<Scope.Scope>()
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,12 +43,13 @@ const it = testEffect(
SessionStore.node,
SessionEnvironment.node,
Session.node,
Instance.byLocationNode,
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),
],
),
)
@@ -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<Scope.Scope>())
expect(borrowed).toEqual(Option.some(session.location.directory))
expect(transportScopes.size).toBe(0)
expect(yield* absent).toEqual(Option.none())
}),
)
})
+248 -62
View File
@@ -7,6 +7,7 @@ import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Model } from "@opencode-ai/core/model"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
@@ -24,99 +25,284 @@ import { Money } from "@opencode-ai/schema/money"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { tempGlobalLayer } from "./fixture/global"
import { initRepo, read } from "./fixture/git"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
LayerNode.group([
Database.node,
Bus.node,
SessionProjector.node,
Session.node,
SessionExecution.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),
// These tests move directories explicitly; native watchers can hold them open on Windows.
Watcher.node.replace(Watcher.configured({ enabled: false })),
],
),
)
describe("Session.revert files", () => {
for (const encoding of ["qualified", "legacy"] as const) {
const stored = (id: Snapshot.ID) =>
encoding === "legacy" ? Snapshot.ID.make(id.slice(id.lastIndexOf("/") + 1)) : id
for (const move of [
"repository rename",
"another worktree",
"another project",
"same worktree subdirectory",
] as const) {
it.live(
`restores ${encoding} staged file changes after moving to ${move}`,
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const directory = path.join(tmp.path, "project")
const destination = AbsolutePath.make(
move === "same worktree subdirectory"
? path.join(directory, "nested")
: path.join(tmp.path, "destination"),
)
yield* Effect.promise(async () => {
await fs.mkdir(directory)
await Bun.write(path.join(directory, "file.txt"), "Before assistant edit.\n")
await Bun.write(path.join(directory, "unrelated.txt"), "Unrelated source content.\n")
await initRepo(directory)
await $`git add .`.cwd(directory).quiet()
await $`git commit -qm initial`.cwd(directory).quiet()
if (move === "another worktree")
await $`git worktree add --detach ${destination} HEAD`.cwd(directory).quiet()
if (move === "another project") {
await fs.mkdir(destination)
await Bun.write(path.join(destination, "file.txt"), "Destination content.\n")
await $`git init -q`.cwd(destination).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(destination).quiet()
}
if (move === "same worktree subdirectory") await fs.mkdir(destination)
})
const bus = yield* Bus.Service
const execution = yield* SessionExecution.Service
const { session, created, prompt } = yield* recordSnapshotStep(
directory,
() => Bun.write(path.join(directory, "file.txt"), "After assistant edit.\n"),
stored,
)
const staged = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id, files: true })
const reverted = { ...staged, snapshot: staged.snapshot && stored(staged.snapshot) }
if (encoding === "legacy")
yield* bus.publish(SessionEvent.RevertEvent.Staged, { sessionID: created.id, revert: reverted })
expect(reverted.snapshot).toBeDefined()
expect(reverted.files?.map((file) => file.file)).toEqual(["file.txt"])
expect(yield* read(path.join(directory, "file.txt"))).toBe("Before assistant edit.\n")
if (move === "repository rename") yield* Effect.promise(() => fs.rename(directory, destination))
const root = move === "same worktree subdirectory" ? directory : destination
yield* Effect.promise(() => Bun.write(path.join(root, "unrelated.txt"), "Keep this destination edit.\n"))
const indexPath = yield* Effect.promise(async () =>
path.resolve(root, (await $`git rev-parse --git-path index`.cwd(root).quiet().text()).trim()),
)
const index = yield* Effect.promise(() => Bun.file(indexPath).arrayBuffer())
yield* session.move({ sessionID: created.id, directory: destination })
yield* execution.awaitIdle(created.id)
const moved = yield* session.get(created.id)
expect(moved.location.directory).toBe(destination)
expect(moved.projectID === created.projectID).toBe(move !== "another project")
expect(moved.revert).toEqual(reverted)
yield* session.revert.clear(created.id)
expect(yield* read(path.join(root, "file.txt"))).toBe("After assistant edit.\n")
expect((yield* session.get(created.id)).revert).toBeUndefined()
expect(yield* read(path.join(root, "unrelated.txt"))).toBe("Keep this destination edit.\n")
if (move === "another worktree" || move === "another project")
expect(yield* read(path.join(directory, "file.txt"))).toBe("Before assistant edit.\n")
yield* execution.awaitIdle(created.id)
yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id, files: true })
expect(yield* read(path.join(root, "file.txt"))).toBe("Before assistant edit.\n")
yield* session.revert.clear(created.id)
expect(yield* read(path.join(root, "file.txt"))).toBe("After assistant edit.\n")
expect(yield* Effect.promise(() => Bun.file(indexPath).arrayBuffer())).toEqual(index)
}),
{ timeout: 15_000 },
)
}
it.live(
`rejects ${encoding} redo through a destination symlink ancestor`,
() =>
Effect.gen(function* () {
const source = yield* tmpdirScoped()
const destination = yield* tmpdirScoped()
const file = path.join(source.path, "assets/logo.svg")
yield* Effect.promise(async () => {
await fs.mkdir(path.dirname(file))
await Bun.write(file, "Before assistant edit.\n")
await initRepo(source.path)
await fs.symlink(path.dirname(file), path.join(destination.path, "assets"), "dir")
await $`git init -q`.cwd(destination.path).quiet()
})
const bus = yield* Bus.Service
const execution = yield* SessionExecution.Service
const { session, created, prompt } = yield* recordSnapshotStep(source.path, () => fs.rm(file), stored)
const staged = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id, files: true })
const reverted = { ...staged, snapshot: staged.snapshot && stored(staged.snapshot) }
yield* bus.publish(SessionEvent.RevertEvent.Staged, { sessionID: created.id, revert: reverted })
yield* session.move({ sessionID: created.id, directory: AbsolutePath.make(destination.path) })
yield* execution.awaitIdle(created.id)
expect((yield* session.get(created.id)).projectID).not.toBe(created.projectID)
const error = yield* session.revert.clear(created.id).pipe(Effect.flip)
expect(error).toBeInstanceOf(Snapshot.Error)
expect(error).toMatchObject({
operation: "restore",
message: expect.stringContaining("Path escapes the project"),
})
expect(yield* read(file)).toBe("Before assistant edit.\n")
expect((yield* session.get(created.id)).revert).toEqual(reverted)
}),
{ timeout: 15_000 },
)
it.live(
`recovers ${encoding} redo after missing snapshot data returns`,
() =>
Effect.gen(function* () {
const source = yield* tmpdirScoped()
const destination = yield* tmpdirScoped()
yield* Effect.promise(async () => {
await initRepo(source.path)
await Bun.write(path.join(source.path, "file.txt"), "Before assistant edit.\n")
await Bun.write(path.join(source.path, "unrelated.txt"), "Borrowed committed content.\n")
await $`git add .`.cwd(source.path).quiet()
await $`git commit -qm initial`.cwd(source.path).quiet()
await Bun.write(path.join(destination.path, "file.txt"), "Destination content.\n")
await $`git init -q`.cwd(destination.path).quiet()
})
const bus = yield* Bus.Service
const execution = yield* SessionExecution.Service
const { session, created, prompt } = yield* recordSnapshotStep(
source.path,
() => Bun.write(path.join(source.path, "file.txt"), "After assistant edit.\n"),
stored,
)
const staged = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id, files: true })
const reverted = { ...staged, snapshot: staged.snapshot && stored(staged.snapshot) }
yield* session.move({ sessionID: created.id, directory: AbsolutePath.make(destination.path) })
yield* execution.awaitIdle(created.id)
const unavailable = {
...reverted,
snapshot: stored(Snapshot.ID.make(`snapshot:missing/${"0".repeat(40)}/${"0".repeat(40)}`)),
}
yield* bus.publish(SessionEvent.RevertEvent.Staged, { sessionID: created.id, revert: unavailable })
expect(yield* session.revert.clear(created.id).pipe(Effect.flip)).toBeInstanceOf(Snapshot.Error)
expect((yield* session.get(created.id)).revert).toEqual(unavailable)
expect(yield* read(path.join(destination.path, "file.txt"))).toBe("Destination content.\n")
yield* bus.publish(SessionEvent.RevertEvent.Staged, { sessionID: created.id, revert: reverted })
const seed = path.join(source.path, ".git")
const missing = path.join(source.path, "unavailable-seed")
yield* Effect.promise(() => fs.rename(seed, missing))
expect(yield* session.revert.clear(created.id).pipe(Effect.flip)).toBeInstanceOf(Snapshot.Error)
expect((yield* session.get(created.id)).revert).toEqual(reverted)
expect(yield* read(path.join(destination.path, "file.txt"))).toBe("Destination content.\n")
yield* Effect.promise(() => fs.rename(missing, seed))
yield* session.revert.clear(created.id)
expect(yield* read(path.join(destination.path, "file.txt"))).toBe("After assistant edit.\n")
expect((yield* session.get(created.id)).revert).toBeUndefined()
}),
{ timeout: 15_000 },
)
}
it.live(
"undoes and restores a file rename without losing either path",
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const directory = path.join(tmp.path, "project")
const directory = (yield* tmpdirScoped()).path
const original = path.join(directory, "old name.txt")
const renamed = path.join(directory, "new name.txt")
yield* Effect.promise(async () => {
await fs.mkdir(directory)
await Bun.write(original, "Preserve this content.\n")
await Bun.write(path.join(directory, "unrelated.txt"), "Unrelated content.\n")
await $`git init -q`.cwd(directory).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
})
const session = yield* Session.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location: { directory: AbsolutePath.make(directory) } })
const prompt = yield* session.prompt({ sessionID: created.id, text: "Rename the file", resume: false })
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
const { session, created, prompt } = yield* recordSnapshotStep(directory, () => fs.rename(original, renamed))
const services = LocationServiceMap.Service.get(created.location)
const revert = yield* SessionRevert.Service.pipe(Effect.provide(services))
expect(yield* SessionRevert.Service.pipe(Effect.provide(services))).toBe(revert)
yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.capture()
if (!before) throw new Error("Initial snapshot missing")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: created.id,
assistantMessageID,
agent: Agent.defaultID,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
snapshot: before,
})
yield* Effect.promise(() => fs.rename(original, renamed))
const after = yield* snapshot.capture()
if (!after) throw new Error("Renamed snapshot missing")
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: created.id,
assistantMessageID,
finish: "stop",
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
snapshot: after,
files: yield* snapshot.files({ from: before, to: after }),
})
yield* Effect.promise(() => Bun.write(path.join(directory, "unrelated.txt"), "Keep this later edit.\n"))
const reverted = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id })
expect({
original: yield* Effect.promise(() => Bun.file(original).exists()),
renamed: yield* Effect.promise(() => Bun.file(renamed).exists()),
}).toEqual({ original: true, renamed: false })
expect(yield* read(original)).toBe("Preserve this content.\n")
expect(reverted.files?.map((file) => [file.file, file.status])).toEqual([
["new name.txt", "deleted"],
["old name.txt", "added"],
])
expect(yield* read(path.join(directory, "unrelated.txt"))).toBe("Keep this later edit.\n")
yield* Effect.promise(() => Bun.write(path.join(directory, "unrelated.txt"), "Keep this later edit.\n"))
const reverted = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id })
expect({
original: yield* Effect.promise(() => Bun.file(original).exists()),
renamed: yield* Effect.promise(() => Bun.file(renamed).exists()),
}).toEqual({ original: true, renamed: false })
expect(yield* Effect.promise(() => Bun.file(original).text())).toBe("Preserve this content.\n")
expect(reverted.files?.map((file) => [file.file, file.status])).toEqual([
["new name.txt", "deleted"],
["old name.txt", "added"],
])
expect(yield* Effect.promise(() => Bun.file(path.join(directory, "unrelated.txt")).text())).toBe(
"Keep this later edit.\n",
)
yield* session.revert.clear(created.id)
expect(yield* Effect.promise(() => Bun.file(original).exists())).toBe(false)
expect(yield* Effect.promise(() => Bun.file(renamed).text())).toBe("Preserve this content.\n")
expect(yield* Effect.promise(() => Bun.file(path.join(directory, "unrelated.txt")).text())).toBe(
"Keep this later edit.\n",
)
expect((yield* session.get(created.id)).revert).toBeUndefined()
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
yield* session.revert.clear(created.id)
expect(yield* Effect.promise(() => Bun.file(original).exists())).toBe(false)
expect(yield* read(renamed)).toBe("Preserve this content.\n")
expect(yield* read(path.join(directory, "unrelated.txt"))).toBe("Keep this later edit.\n")
expect((yield* session.get(created.id)).revert).toBeUndefined()
}),
// Real Location/plugin startup and Git snapshots can exceed five seconds under CI load.
{ timeout: 15_000 },
)
})
const recordSnapshotStep = Effect.fnUntraced(function* (
directory: string,
mutate: () => Promise<unknown>,
stored = (id: Snapshot.ID) => id,
) {
const session = yield* Session.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location: { directory: AbsolutePath.make(directory) } })
const prompt = yield* session.prompt({ sessionID: created.id, text: "Edit the files", resume: false })
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.capture()
if (!before) throw new Error("Initial snapshot missing")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: created.id,
assistantMessageID,
agent: Agent.defaultID,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
snapshot: stored(before),
})
yield* Effect.promise(mutate)
const after = yield* snapshot.capture()
if (!after) throw new Error("Edited snapshot missing")
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: created.id,
assistantMessageID,
finish: "stop",
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
snapshot: stored(after),
files: yield* snapshot.files({ from: before, to: after }),
})
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
return { session, created, prompt }
})
@@ -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<typeof LLMClient.Service>) =>
const runnerLayer = (llmClient: Layer.Layer<LLMClientService>) =>
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<typeof LLMClient.Service>) =>
const execution = (llmClient: Layer.Layer<LLMClientService>) =>
Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
@@ -133,7 +133,7 @@ const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
})
}),
).pipe(Layer.provide(runnerLayer(llmClient)))
const testLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
const testLayer = (llmClient: Layer.Layer<LLMClientService>) =>
AppNodeBuilder.build(
LayerNode.group([
Database.node,
@@ -155,21 +155,21 @@ const testLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
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))
@@ -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* () {
+17 -17
View File
@@ -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),
],
)
}),
+2 -2
View File
@@ -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)),
)
+3 -3
View File
@@ -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),
],
),
)
+1 -1
View File
@@ -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(),
),
+1 -1
View File
@@ -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 })),
]),
)
+5 -5
View File
@@ -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 })),
],
),
)
@@ -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") }
+5 -5
View File
@@ -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 })),
],
)

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