mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-04 11:04:30 +00:00
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.
378 lines
15 KiB
TypeScript
378 lines
15 KiB
TypeScript
import { describe, expect } from "bun:test"
|
|
import { Bus } from "@opencode-ai/core/bus"
|
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
import { Location } from "@opencode-ai/core/location"
|
|
import { Rpc } from "@opencode-ai/core/rpc"
|
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
|
import { Workspace } from "@opencode-ai/core/workspace"
|
|
import type { Event } from "@opencode-ai/schema/event"
|
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
|
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, Stream } from "effect"
|
|
import { z } from "zod"
|
|
import { location } from "./fixture/location"
|
|
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.replace(Layer.succeed(Location.Service, location(ref))),
|
|
]),
|
|
)
|
|
const Echo = Rpc.define({
|
|
id: "test.rpc",
|
|
methods: { echo: { input: z.string(), output: z.string() } },
|
|
events: { updated: { schema: z.object({ text: z.string() }) } },
|
|
})
|
|
|
|
describe("Rpc", () => {
|
|
it.effect("creates handles before registration and resolves on every execution", () =>
|
|
Effect.gen(function* () {
|
|
const rpc = yield* Rpc.Service
|
|
const client = rpc.client(Echo)
|
|
const request = client.echo("hello")
|
|
expect(yield* request.pipe(Effect.flip)).toEqual({
|
|
type: "rpc.unavailable",
|
|
message: "RPC is unavailable: test.rpc",
|
|
})
|
|
|
|
yield* rpc.register(Echo, { echo: (value) => Effect.succeed(value) })
|
|
expect(yield* request).toBe("hello")
|
|
yield* rpc.register(Echo, { echo: (value) => Effect.succeed(`${value}!`) })
|
|
expect(yield* request).toBe("hello!")
|
|
expect(yield* rpc.call(Echo.id, "missing", "hello").pipe(Effect.flip)).toEqual({
|
|
type: "rpc.method_not_found",
|
|
message: "Unknown RPC method: test.rpc.missing",
|
|
})
|
|
expect(yield* rpc.call(Echo.id, "toString", "hello").pipe(Effect.flip)).toEqual({
|
|
type: "rpc.method_not_found",
|
|
message: "Unknown RPC method: test.rpc.toString",
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.effect("uses the latest whole registration and reveals previous implementations on disposal", () =>
|
|
Effect.gen(function* () {
|
|
const rpc = yield* Rpc.Service
|
|
const client = rpc.client(Echo)
|
|
const first = yield* rpc.register(Echo, { echo: () => Effect.succeed("first") })
|
|
const second = yield* rpc.register(Echo, { echo: () => Effect.succeed("second") })
|
|
const third = yield* rpc.register(Echo, { echo: () => Effect.succeed("third") })
|
|
expect(yield* client.echo("hello")).toBe("third")
|
|
yield* second.dispose
|
|
expect(yield* client.echo("hello")).toBe("third")
|
|
yield* third.dispose
|
|
expect(yield* client.echo("hello")).toBe("first")
|
|
yield* third.dispose
|
|
expect(yield* client.echo("hello")).toBe("first")
|
|
yield* first.dispose
|
|
expect(Exit.isFailure(yield* client.echo("hello").pipe(Effect.exit))).toBe(true)
|
|
}),
|
|
)
|
|
|
|
it.effect("removes registrations when their owning scope closes", () =>
|
|
Effect.gen(function* () {
|
|
const rpc = yield* Rpc.Service
|
|
yield* rpc.register(Echo, { echo: () => Effect.succeed("original") })
|
|
const scope = yield* Scope.make()
|
|
yield* rpc.register(Echo, { echo: () => Effect.succeed("override") }).pipe(Scope.provide(scope))
|
|
expect(yield* rpc.client(Echo).echo("hello")).toBe("override")
|
|
yield* Scope.close(scope, Exit.void)
|
|
expect(yield* rpc.client(Echo).echo("hello")).toBe("original")
|
|
}),
|
|
)
|
|
|
|
it.effect("validates inputs before running handlers and validates returned results", () =>
|
|
Effect.gen(function* () {
|
|
const rpc = yield* Rpc.Service
|
|
const received: string[] = []
|
|
yield* rpc.register(Echo, {
|
|
echo: (value) =>
|
|
Effect.sync(() => {
|
|
received.push(value)
|
|
return value
|
|
}),
|
|
})
|
|
expect(Exit.isFailure(yield* rpc.call(Echo.id, "echo", 42).pipe(Effect.exit))).toBe(true)
|
|
expect(received).toEqual([])
|
|
|
|
const Checked = Rpc.define({
|
|
id: "checked",
|
|
methods: { echo: { input: z.string(), output: z.string().min(3) } },
|
|
events: {},
|
|
})
|
|
yield* rpc.register(Checked, { echo: () => Effect.succeed("a") })
|
|
expect(Exit.isFailure(yield* rpc.client(Checked).echo("hello").pipe(Effect.exit))).toBe(true)
|
|
}),
|
|
)
|
|
|
|
it.effect("leaves local transport values to the declared schema", () =>
|
|
Effect.gen(function* () {
|
|
const rpc = yield* Rpc.Service
|
|
const Identity = Rpc.define({
|
|
id: "identity",
|
|
methods: { echo: { input: Schema.Unknown, output: Schema.Unknown } },
|
|
events: {},
|
|
})
|
|
yield* rpc.register(Identity, { echo: Effect.succeed })
|
|
const value = new Date(0)
|
|
expect(yield* rpc.client(Identity).echo(value)).toBe(value)
|
|
}),
|
|
)
|
|
|
|
it.effect("applies Standard Schema transforms once for inputs, outputs, and events", () =>
|
|
Effect.gen(function* () {
|
|
const rpc = yield* Rpc.Service
|
|
const counts = { input: 0, output: 0, event: 0 }
|
|
const Transformed = Rpc.define({
|
|
id: "transformed",
|
|
methods: {
|
|
count: {
|
|
input: z.string().transform((value) => {
|
|
counts.input++
|
|
return Number(value)
|
|
}),
|
|
output: z.number().transform((value) => {
|
|
counts.output++
|
|
return String(value)
|
|
}),
|
|
},
|
|
},
|
|
events: {
|
|
counted: {
|
|
schema: z.object({ count: z.number() }).transform(({ count }) => {
|
|
counts.event++
|
|
return { text: String(count) }
|
|
}),
|
|
},
|
|
},
|
|
})
|
|
const registration = yield* rpc.register(Transformed, { count: (value) => Effect.succeed(value + 1) })
|
|
const client = rpc.client(Transformed)
|
|
expect(yield* client.count("41")).toBe("42")
|
|
const events = yield* client.events
|
|
.subscribe("counted")
|
|
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
|
yield* Effect.yieldNow
|
|
yield* registration.events.emit("counted", { count: 42 })
|
|
expect((yield* Fiber.join(events))[0].data).toEqual({ text: "42" })
|
|
expect(counts).toEqual({ input: 1, output: 1, event: 1 })
|
|
}),
|
|
)
|
|
|
|
it.effect("keeps encoded dispatch and decoded local results consistent for Effect codecs", () =>
|
|
Effect.gen(function* () {
|
|
const rpc = yield* Rpc.Service
|
|
const Codec = Rpc.define({
|
|
id: "codec",
|
|
methods: { count: { input: Schema.FiniteFromString, output: Schema.FiniteFromString } },
|
|
events: { counted: { schema: Schema.Struct({ count: Schema.FiniteFromString }) } },
|
|
})
|
|
const registration = yield* rpc.register(Codec, { count: (value) => Effect.succeed(value + 1) })
|
|
expect(yield* rpc.call(Codec.id, "count", "41")).toBe("42")
|
|
expect(yield* rpc.client(Codec).count("41")).toBe(42)
|
|
const events = yield* rpc
|
|
.client(Codec)
|
|
.events.subscribe("counted")
|
|
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
|
yield* Effect.yieldNow
|
|
yield* registration.events.emit("counted", { count: 42 })
|
|
expect((yield* Fiber.join(events))[0].data).toEqual({ count: 42 })
|
|
}),
|
|
)
|
|
|
|
it.effect("validates declared error data and decodes it for local clients", () =>
|
|
Effect.gen(function* () {
|
|
const rpc = yield* Rpc.Service
|
|
const Failing = Rpc.define({
|
|
id: "failing",
|
|
methods: {
|
|
standard: {
|
|
input: z.undefined(),
|
|
output: z.string(),
|
|
errors: { missing: z.object({ attempts: z.string().transform(Number) }) },
|
|
},
|
|
effect: {
|
|
input: Schema.Undefined,
|
|
output: Schema.String,
|
|
errors: { invalid: Schema.Struct({ count: Schema.FiniteFromString }) },
|
|
},
|
|
},
|
|
events: {},
|
|
})
|
|
yield* rpc.register(Failing, {
|
|
standard: (_input, context) => Effect.fail(context.error("missing", "Missing", { attempts: "2" })),
|
|
effect: (_input, context) => Effect.fail(context.error("invalid", "Invalid", { count: 3 })),
|
|
})
|
|
|
|
expect(yield* rpc.call(Failing.id, "standard", undefined).pipe(Effect.flip)).toEqual({
|
|
type: "missing",
|
|
message: "Missing",
|
|
data: { attempts: 2 },
|
|
})
|
|
expect(yield* rpc.client(Failing).standard().pipe(Effect.flip)).toEqual({
|
|
type: "missing",
|
|
message: "Missing",
|
|
data: { attempts: 2 },
|
|
})
|
|
expect(yield* rpc.call(Failing.id, "effect", undefined).pipe(Effect.flip)).toEqual({
|
|
type: "invalid",
|
|
message: "Invalid",
|
|
data: { count: "3" },
|
|
})
|
|
expect(yield* rpc.client(Failing).effect().pipe(Effect.flip)).toEqual({
|
|
type: "invalid",
|
|
message: "Invalid",
|
|
data: { count: 3 },
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.effect("keeps other event consumers running after one subscription ends", () =>
|
|
Effect.gen(function* () {
|
|
const rpc = yield* Rpc.Service
|
|
const registration = yield* rpc.register(Echo, { echo: (value) => Effect.succeed(value) })
|
|
const client = rpc.client(Echo)
|
|
const first = yield* client.events.subscribe("updated").pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
|
const second = yield* client.events
|
|
.subscribe("updated")
|
|
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
|
yield* Effect.yieldNow
|
|
yield* registration.events.emit("updated", { text: "first" })
|
|
const received = yield* Fiber.join(first)
|
|
expect(received.map((event) => event.data.text)).toEqual(["first"])
|
|
Reflect.set(received[0].location, "directory", "/consumer-mutated")
|
|
yield* registration.events.emit("updated", { text: "second" })
|
|
expect((yield* Fiber.join(second)).map((event) => event.data.text)).toEqual(["first", "second"])
|
|
}),
|
|
)
|
|
|
|
it.effect("validates plain JSON Schema inputs and outputs without type inference", () =>
|
|
Effect.gen(function* () {
|
|
const rpc = yield* Rpc.Service
|
|
const Raw = Rpc.define({
|
|
id: "raw",
|
|
methods: { count: { input: { type: "integer", minimum: 0 }, output: { type: "integer", minimum: 1 } } },
|
|
events: {
|
|
counted: {
|
|
schema: {
|
|
type: "object",
|
|
properties: { count: { type: "integer", minimum: 1 } },
|
|
required: ["count"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
const registration = yield* rpc.register(Raw, { count: (value) => Effect.succeed(value) })
|
|
expect(yield* rpc.call(Raw.id, "count", 42)).toBe(42)
|
|
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)
|
|
}),
|
|
)
|
|
|
|
it.effect("supports methods with no input and no returned value", () =>
|
|
Effect.gen(function* () {
|
|
const rpc = yield* Rpc.Service
|
|
const Empty = Rpc.define({
|
|
id: "empty",
|
|
methods: { ping: { input: z.undefined(), output: z.undefined() } },
|
|
events: {},
|
|
})
|
|
yield* rpc.register(Empty, { ping: () => Effect.undefined })
|
|
expect(yield* rpc.client(Empty).ping()).toBeUndefined()
|
|
}),
|
|
)
|
|
|
|
it.effect("keeps in-flight calls on their original implementation after removal", () =>
|
|
Effect.gen(function* () {
|
|
const rpc = yield* Rpc.Service
|
|
const started = yield* Deferred.make<void>()
|
|
const release = yield* Deferred.make<void>()
|
|
const registration = yield* rpc.register(Echo, {
|
|
echo: (value) =>
|
|
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as(value)),
|
|
})
|
|
const call = yield* rpc.client(Echo).echo("original").pipe(Effect.forkScoped)
|
|
yield* Deferred.await(started)
|
|
yield* registration.dispose
|
|
yield* rpc.register(Echo, { echo: () => Effect.succeed("replacement") })
|
|
expect(yield* rpc.client(Echo).echo("hello")).toBe("replacement")
|
|
yield* Deferred.succeed(release, undefined)
|
|
expect(yield* Fiber.join(call)).toBe("original")
|
|
}),
|
|
)
|
|
|
|
it.effect("interrupts the running Effect handler when its call is cancelled", () =>
|
|
Effect.gen(function* () {
|
|
const rpc = yield* Rpc.Service
|
|
const started = yield* Deferred.make<void>()
|
|
const stopped = yield* Deferred.make<void>()
|
|
yield* rpc.register(Echo, {
|
|
echo: () =>
|
|
Deferred.succeed(started, undefined).pipe(
|
|
Effect.andThen(Effect.never),
|
|
Effect.onInterrupt(() => Deferred.succeed(stopped, undefined)),
|
|
),
|
|
})
|
|
const call = yield* rpc.client(Echo).echo("hello").pipe(Effect.forkScoped)
|
|
yield* Deferred.await(started)
|
|
yield* Fiber.interrupt(call)
|
|
yield* Deferred.await(stopped)
|
|
}),
|
|
)
|
|
|
|
it.effect("isolates registrations and subscriptions while publishing location-tagged events on the shared bus", () =>
|
|
Effect.gen(function* () {
|
|
const rpc = yield* Rpc.Service
|
|
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, {
|
|
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") })
|
|
expect(Exit.isFailure(yield* other.client(Echo).echo("hello").pipe(Effect.exit))).toBe(true)
|
|
const second = yield* other.register(Echo, { echo: () => Effect.succeed("second") })
|
|
expect(yield* rpc.client(Echo).echo("hello")).toBe("first")
|
|
expect(yield* other.client(Echo).echo("hello")).toBe("second")
|
|
|
|
const all: Event.Payload[] = []
|
|
const unsubscribe = yield* bus.listen((event) =>
|
|
Effect.sync(() => {
|
|
all.push(event)
|
|
}),
|
|
)
|
|
const localEvents = yield* rpc
|
|
.client(Echo)
|
|
.events.subscribe("updated")
|
|
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
|
const otherEvents = yield* other
|
|
.client(Echo)
|
|
.events.subscribe("updated")
|
|
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
|
yield* Effect.yieldNow
|
|
yield* second.events.emit("updated", { text: "second" })
|
|
yield* first.events
|
|
.emit("updated", { text: "first" })
|
|
.pipe(Effect.provideService(Location.Service, location(otherRef)))
|
|
expect((yield* Fiber.join(localEvents))[0]).toMatchObject({
|
|
type: "rpc.test.rpc.updated",
|
|
data: { text: "first" },
|
|
location: ref,
|
|
})
|
|
expect((yield* Fiber.join(otherEvents))[0]).toMatchObject({
|
|
type: "rpc.test.rpc.updated",
|
|
data: { text: "second" },
|
|
location: otherRef,
|
|
})
|
|
expect(all.map((event) => event.location)).toEqual([otherRef, ref])
|
|
yield* unsubscribe
|
|
}),
|
|
)
|
|
})
|