opencode/packages/client/test/rpc-effect.test.ts

495 lines
20 KiB
TypeScript

import { expect, test } from "bun:test"
import { Rpc } from "@opencode-ai/schema/rpc"
import { Cause, Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { OpenCode } from "../src/effect/index"
const definition = Rpc.define({
id: "example",
methods: {
count: {
input: Schema.Struct({ count: Schema.FiniteFromString }),
output: Schema.FiniteFromString,
errors: { too_large: Schema.Struct({ limit: Schema.FiniteFromString }) },
},
echo: { input: Schema.Json, output: Schema.Json },
empty: { input: Schema.Undefined, output: Schema.Undefined },
raw: { input: { type: "string" }, output: { type: "number" } },
},
events: {
progress: { schema: Schema.Struct({ count: Schema.FiniteFromString }) },
message: { schema: Schema.Struct({ text: Schema.String }) },
},
})
const connected = { id: "evt_connected", type: "server.connected", data: {} }
function rpcEvent(count: unknown, directory = "/project/one", rpcID = "example", name = "progress") {
return {
id: "evt_progress",
created: 123,
type: `rpc.${rpcID}.${name}`,
location: { directory },
metadata: { origin: "test" },
data: { count },
}
}
function eventSource() {
const requests: HttpClientRequest.HttpClientRequest[] = []
const opened = Promise.withResolvers<{
controller: ReadableStreamDefaultController<Uint8Array>
signal: AbortSignal
}>()
const cancelled = Promise.withResolvers<void>()
return {
requests,
opened: opened.promise,
cancelled: cancelled.promise,
async push(event: unknown) {
const source = await opened.promise
source.controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`))
},
httpClient: HttpClient.make((request, _url, signal) => {
requests.push(request)
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
opened.resolve({ controller, signal })
},
cancel() {
cancelled.resolve()
},
}),
{ headers: { "content-type": "text/event-stream" } },
),
),
)
}),
}
}
test("Effect RPC calls retain encoded inputs, decode outputs, and preserve raw native RPC calls", async () => {
const requests: Array<{ url: string; body: unknown }> = []
const httpClient = HttpClient.make((request) => {
const body = request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : {}
requests.push({ url: request.url, body })
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json({
output: request.url.endsWith("/count") ? "42" : request.url.endsWith("/raw") ? 7 : body.input,
}),
),
)
})
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: new URL("http://localhost:3000") })
const rpc = client.rpc(definition)
const count = yield* rpc.count({ count: "2" })
const primitives = yield* Effect.forEach([null, false, 0, "hello", [1, "two"]], (value) => rpc.echo(value))
const empty = yield* rpc.empty()
const raw = yield* rpc.raw("input")
const native = yield* client.rpc.call({ rpcID: "example", method: "count", input: null })
expect(Object.keys(rpc.events)).toEqual(["subscribe"])
return { count, primitives, empty, raw, native }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result).toEqual({
count: 42,
primitives: [null, false, 0, "hello", [1, "two"]],
empty: undefined,
raw: 7,
native: { output: "42" },
})
expect(requests[0]).toEqual({ url: "http://localhost:3000/api/rpc/example/count", body: { input: { count: "2" } } })
expect(requests.find((request) => request.url.endsWith("/empty"))?.body).toEqual({})
})
test("Effect RPC trusts server-side Standard Schema transforms for outputs and events", async () => {
const validations: unknown[] = []
const standard = {
"~standard": {
version: 1 as const,
vendor: "fixture",
validate(value: unknown) {
validations.push(value)
return { value: String(value) + " transformed" }
},
},
}
const service = Rpc.define({
id: "standard",
methods: { transform: { input: standard, output: standard } },
events: {
transformed: {
schema: {
"~standard": {
version: 1 as const,
vendor: "fixture",
validate(value: unknown) {
validations.push(value)
return { value: { text: String(value) + " transformed" } }
},
},
},
},
},
})
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
request.url.endsWith("/api/event")
? new Response(
`data: ${JSON.stringify({ ...rpcEvent(1), type: "rpc.standard.transformed", data: { text: "done" } })}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
)
: Response.json({ output: "done" }),
),
),
)
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const rpc = client.rpc(service)
return {
output: yield* rpc.transform("input"),
events: yield* Stream.runCollect(rpc.events.subscribe("transformed")),
}
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result.output).toBe("done")
expect(result.events[0].data).toEqual({ text: "done" })
expect(validations).toEqual([])
})
test("Effect RPC validates decoded outputs in the failure channel", async () => {
const requests: string[] = []
const httpClient = HttpClient.make((request) => {
requests.push(request.url)
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ output: "not a number" })))
})
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* Effect.flip(client.rpc(definition).count({ count: "1" }))
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(Schema.isSchemaError(error)).toBe(true)
expect(requests).toEqual(["http://localhost:3000/api/rpc/example/count"])
})
test("Effect RPC decodes declared errors and removes the generic transport wrapper", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
{ _tag: "RpcError", type: "too_large", message: "Too large", data: { limit: "3" } },
{ status: 400 },
),
),
),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.rpc(definition).count({ count: "4" }).pipe(Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error).toEqual({ type: "too_large", message: "Too large", data: { limit: 3 } })
})
test("Effect RPC removes the internal transport wrapper", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
{ _tag: "RpcInternalError", type: "rpc.internal", message: "Failed" },
{ status: 500 },
),
),
),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.rpc(definition).count({ count: "4" }).pipe(Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error).toEqual({ type: "rpc.internal", message: "Failed" })
})
test("Effect RPC isolates per-call location and headers while preserving configured defaults and native behavior", async () => {
const requests: Array<{ url: URL; headers: HttpClientRequest.HttpClientRequest["headers"] }> = []
const release = Promise.withResolvers<void>()
const started = Promise.withResolvers<void>()
const httpClient = HttpClient.make((request, url) => {
requests.push({ url, headers: request.headers })
if (requests.length === 1) started.resolve()
return Effect.promise(() => release.promise).pipe(
Effect.as(
HttpClientResponse.fromWeb(
request,
url.pathname.endsWith("/health")
? Response.json({ healthy: true, version: "test", pid: 1 })
: Response.json({ output: "3" }),
),
),
)
}).pipe(HttpClient.mapRequest(HttpClientRequest.setHeaders({ authorization: "Bearer base", "x-default": "base" })))
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)),
)
const rpc = client.rpc(definition)
const first = Effect.runPromise(
rpc.count(
{ count: "1" },
{ location: { directory: "/project/one", workspace: "one" }, headers: { "x-call": "one" } },
),
)
await started.promise
const second = Effect.runPromise(
rpc.count(
{ count: "2" },
{ location: { directory: "/project/two" }, headers: new Headers({ "x-call": "two", "x-default": "override" }) },
),
)
const native = Effect.runPromise(client.health.get())
release.resolve()
expect(await Promise.all([first, second])).toEqual([3, 3])
expect(await native).toEqual({ healthy: true, version: "test", pid: 1 })
expect(requests.map((request) => request.headers.authorization)).toEqual([
"Bearer base",
"Bearer base",
"Bearer base",
])
expect(requests.map((request) => request.headers["x-call"])).toEqual(["one", "two", undefined])
expect(requests.map((request) => request.headers["x-default"])).toEqual(["base", "override", "base"])
expect(requests.map((request) => request.url.searchParams.get("location[directory]"))).toEqual([
"/project/one",
"/project/two",
null,
])
expect(requests.map((request) => request.url.searchParams.get("location[workspace]"))).toEqual(["one", null, null])
})
test("RPC signals and consumer interruption abort only their own HTTP calls", async () => {
const started: Array<ReturnType<typeof Promise.withResolvers<AbortSignal>>> = [
Promise.withResolvers<AbortSignal>(),
Promise.withResolvers<AbortSignal>(),
]
const signals: AbortSignal[] = []
const finalized: number[] = []
const httpClient = HttpClient.make((_request, _url, signal) => {
const index = signals.length
signals.push(signal)
started[index].resolve(signal)
return Effect.never.pipe(Effect.ensuring(Effect.sync(() => finalized.push(index))))
})
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)),
)
const rpc = client.rpc(definition)
const abort = new AbortController()
const first = Effect.runFork(rpc.count({ count: "1" }, { signal: abort.signal }))
const second = Effect.runFork(rpc.count({ count: "2" }))
await Promise.all(started.map((entry) => entry.promise))
abort.abort()
const exit = await Effect.runPromise(Fiber.await(first))
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(signals.map((signal) => signal.aborted)).toEqual([true, false])
expect(finalized).toEqual([0])
await Effect.runPromise(Fiber.interrupt(second))
expect(signals[1].aborted).toBe(true)
expect(finalized).toEqual([0, 1])
const preAborted = await Effect.runPromiseExit(rpc.count({ count: "3" }, { signal: abort.signal }))
expect(Exit.isFailure(preAborted) && Cause.hasInterruptsOnly(preAborted.cause)).toBe(true)
expect(signals).toHaveLength(2)
})
test("native and RPC Effect streams share one lazy source, cache connected, and filter across all locations", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const rpc = client.rpc(definition)
const native = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
const progress = Stream.toAsyncIterable(rpc.events.subscribe("progress"))[Symbol.asyncIterator]()
expect(source.requests).toHaveLength(0)
const marker = native.next()
await source.push(connected)
expect((await marker).value).toEqual(connected)
const first = progress.next()
const late = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
expect((await late.next()).value).toEqual(connected)
await native.return?.()
await late.return?.()
await source.push(rpcEvent("ignored", "/project/one", "other"))
await source.push(rpcEvent("ignored", "/project/one", "example", "message"))
await source.push(rpcEvent("1"))
expect((await first).value).toEqual({
id: "evt_progress",
created: 123,
type: "rpc.example.progress",
metadata: { origin: "test" },
data: { count: 1 },
location: { directory: "/project/one" },
})
const second = progress.next()
await source.push(rpcEvent("2", "/project/two"))
expect((await second).value).toEqual(
expect.objectContaining({ data: { count: 2 }, location: { directory: "/project/two" } }),
)
expect(source.requests).toHaveLength(1)
expect((await source.opened).signal.aborted).toBe(false)
const third = progress.next()
await source.push(rpcEvent("3"))
expect((await third).value.data).toEqual({ count: 3 })
const pending = progress.next()
await progress.return?.()
expect((await pending).done).toBe(true)
await source.cancelled
expect((await source.opened).signal.aborted).toBe(true)
})
test("interrupting a native Effect stream leaves an active RPC consumer running", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runFork(Stream.runCollect(client.event.subscribe()))
const progress = Stream.toAsyncIterable(client.rpc(definition).events.subscribe("progress"))[Symbol.asyncIterator]()
const first = progress.next()
await source.push(rpcEvent("1"))
expect((await first).value.data).toEqual({ count: 1 })
await Effect.runPromise(Fiber.interrupt(native))
expect((await source.opened).signal.aborted).toBe(false)
const second = progress.next()
await source.push(rpcEvent("2"))
expect((await second).value.data).toEqual({ count: 2 })
await progress.return?.()
await source.cancelled
})
test("shared Effect streams preserve EOF without reconnecting", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runPromise(Stream.runCollect(client.event.subscribe()))
const progress = Effect.runPromise(Stream.runCollect(client.rpc(definition).events.subscribe("progress")))
await source.push(connected)
await source.push(rpcEvent("1"))
const connection = await source.opened
connection.controller.close()
expect((await native).map((event) => event.type)).toEqual(["server.connected", "rpc.example.progress"])
expect((await progress).map((event) => event.data)).toEqual([{ count: 1 }])
expect(source.requests).toHaveLength(1)
})
test("native protocol failures reach both native and RPC streams as ClientError", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runPromise(Effect.flip(Stream.runCollect(client.event.subscribe())))
const progress = Effect.runPromise(
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
)
await source.push({ type: "server.connected" })
expect((await native)._tag).toBe("ClientError")
expect(await progress).toBe(await native)
expect(source.requests).toHaveLength(1)
})
test("HTTP source failures reach every Effect consumer", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runPromise(Effect.flip(Stream.runCollect(client.event.subscribe())))
const progress = Effect.runPromise(
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
)
await source.push(connected)
const connection = await source.opened
connection.controller.error(new Error("connection lost"))
expect((await native)._tag).toBe("ClientError")
expect(await progress).toBe(await native)
expect(source.requests).toHaveLength(1)
})
test("RPC payload decoding fails only the matching consumer, not the native event stream", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
const raw = native.next()
const progress = Effect.runPromise(
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
)
await source.push(rpcEvent("not a number"))
expect((await raw).value.type).toBe("rpc.example.progress")
expect(Schema.isSchemaError(await progress)).toBe(true)
expect((await source.opened).signal.aborted).toBe(false)
const next = native.next()
await source.push(connected)
expect((await next).value.type).toBe("server.connected")
await native.return?.()
await source.cancelled
})
test("shared event source runs with the Effect context captured by make", async () => {
const Token = Context.Reference("test/rpc-effect/token", { defaultValue: () => "missing" })
const httpClient = HttpClient.make((request) =>
Effect.gen(function* () {
const token = yield* Token
expect(token).toBe("captured")
return HttpClientResponse.fromWeb(
request,
new Response(`data: ${JSON.stringify(connected)}\n\n`, { headers: { "content-type": "text/event-stream" } }),
)
}),
)
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
Effect.provideService(Token, "captured"),
),
)
expect((await Effect.runPromise(Stream.runCollect(client.event.subscribe())))[0]).toEqual(connected)
})
test("Effect RPC rejects inherited event names without opening the source", async () => {
const requests: string[] = []
const httpClient = HttpClient.make((request) => {
requests.push(request.url)
return Effect.die(new Error("Unexpected request"))
})
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const broad: Rpc.Definition = definition
return yield* client.rpc(broad).events.subscribe("toString").pipe(Stream.runDrain, Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error).toEqual(new Error("Unknown RPC event: rpc.example.toString"))
expect(requests).toEqual([])
})