From 3aa57f691e8d2fe5bb44f01ddfc39d2f37e6fcaf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 2 Sep 2026 23:35:04 -0400 Subject: [PATCH] fix(sdk): bind embedded transport at request time --- packages/sdk/src/effect/opencode.ts | 9 ++-- packages/sdk/test/transport.test.ts | 65 +++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 packages/sdk/test/transport.test.ts diff --git a/packages/sdk/src/effect/opencode.ts b/packages/sdk/src/effect/opencode.ts index fb43c0c7cda3..b71050808fed 100644 --- a/packages/sdk/src/effect/opencode.ts +++ b/packages/sdk/src/effect/opencode.ts @@ -4,7 +4,7 @@ import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/effect" import type { Workspace } from "@opencode-ai/core/workspace" import { Context, Effect, Layer } from "effect" import type { Config, Scope } from "effect" -import { FetchHttpClient } from "effect/unstable/http" +import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { EmbeddedHost } from "../internal/host" import type { SdkInstances } from "../internal/instances" @@ -35,9 +35,12 @@ export const create: ( R = never, >(options: CreateOptions = {}, embed: EmbedOptions = {}) { const host = yield* Effect.acquireRelease(EmbeddedHost.create(options, embed), (host) => Effect.promise(host.close)) + const httpClient = yield* HttpClient.HttpClient.pipe(Effect.provide(FetchHttpClient.layer)) const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe( - Effect.provide( - FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, host.fetch)), Layer.fresh), + Effect.provideService( + HttpClient.HttpClient, + // FetchHttpClient reads Fetch at request time; callers must not replace this host's in-process transport. + HttpClient.transformResponse(httpClient, Effect.provideService(FetchHttpClient.Fetch, host.fetch)), ), ) diff --git a/packages/sdk/test/transport.test.ts b/packages/sdk/test/transport.test.ts new file mode 100644 index 000000000000..6bece90846d8 --- /dev/null +++ b/packages/sdk/test/transport.test.ts @@ -0,0 +1,65 @@ +import { expect } from "bun:test" +import { Context, Effect, Exit, Layer, Scope, Stream } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import { tmpdirScoped } from "../../core/test/fixture/tmpdir" +import { testEffect } from "../../core/test/lib/effect" +import { AbsolutePath, Location, OpenCode, Session } from "../src/effect" + +const it = testEffect(Layer.empty) + +for (const entrypoint of ["create", "layer"] as const) { + it.live(`${entrypoint} keeps requests and streams on its own transport despite an ambient Fetch`, () => + Effect.gen(function* () { + const directory = yield* tmpdirScoped() + const calls: string[] = [] + const ambient = Object.assign( + (input: RequestInfo | URL) => { + calls.push(input instanceof Request ? input.url : String(input)) + return Promise.reject(new Error("The caller's Fetch must not receive embedded SDK requests")) + }, + { preconnect: () => undefined }, + ) + const parent = yield* Effect.scope + const scope = yield* Scope.fork(parent) + const options: OpenCode.CreateOptions = { + app: { version: "transport-test" }, + config: { directory: directory.path, project: false, content: "{}" }, + events: { persist: true }, + models: { fetch: false }, + fs: { filewatcher: false }, + } + const client = yield* ( + entrypoint === "create" + ? OpenCode.create(options).pipe(Scope.provide(scope)) + : Layer.buildWithScope(OpenCode.layer(options), scope).pipe(Effect.map(Context.get(OpenCode.Service))) + ).pipe(Effect.provideService(FetchHttpClient.Fetch, ambient)) + + yield* Effect.gen(function* () { + expect(yield* client.health.get()).toMatchObject({ healthy: true, version: "transport-test" }) + const session = yield* client.sessions.create({ + location: Location.Ref.make({ directory: AbsolutePath.make(directory.path) }), + }) + expect((yield* client.sessions.get({ sessionID: session.id })).id).toBe(session.id) + const events = yield* client.sessions.log({ sessionID: session.id }).pipe(Stream.runCollect) + expect(events.some((event) => event.type === "session.created")).toBe(true) + expect(yield* client.events.subscribe().pipe(Stream.take(1), Stream.runCollect)).toMatchObject([ + { type: "server.connected" }, + ]) + expect(yield* client.sessions.get({ sessionID: Session.ID.create() }).pipe(Effect.flip)).toMatchObject({ + _tag: "SessionNotFoundError", + }) + // Binding the SDK's transport must not change the caller's surrounding context. + expect(yield* FetchHttpClient.Fetch).toBe(ambient) + }).pipe(Effect.provideService(FetchHttpClient.Fetch, ambient)) + expect(calls).toEqual([]) + + yield* Scope.close(scope, Exit.void) + expect( + Exit.isFailure( + yield* client.health.get().pipe(Effect.provideService(FetchHttpClient.Fetch, ambient), Effect.exit), + ), + ).toBe(true) + expect(calls).toEqual([]) + }), + ) +}