feat(sdk): restore session runtime operations (#33777)

This commit is contained in:
Kit Langton
2026-06-25 14:23:01 -04:00
committed by GitHub
parent 44806777ca
commit f44423609b
20 changed files with 1100 additions and 93 deletions
+3 -1
View File
@@ -11,7 +11,9 @@ const opencode = yield * OpenCode.create()
const session = yield * opencode.sessions.get({ sessionID })
```
It also exposes local-only `tools.register(...)`. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations.
It also exports `Tool` and exposes local-only `tools.register(...)`, replacing the former `@opencode-ai/core/public` facade. Registration uses Core's host-level `ApplicationTools` service shared by the host's Locations; each Location retains its own `ToolRegistry` for overlay, lookup, and settlement. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations.
`sessions.events({ sessionID, after })` replays durable events after the optional aggregate sequence, then emits newly committed durable events. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message.
The same constructor is available as a service Layer:
+27 -33
View File
@@ -2,43 +2,37 @@ import { OpenCode } from "@opencode-ai/client/effect"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
import { Cause, Context, Effect, Layer } from "effect"
import {
HttpClient,
HttpRouter,
HttpServer,
HttpServerError,
HttpServerRequest,
HttpServerResponse,
} from "effect/unstable/http"
import { Context, Effect, Layer, Scope } from "effect"
import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"
export const create = Effect.fn("OpenCode.create")(function* () {
const applicationTools = ApplicationTools.layer
const { handler, permissions, tools } = yield* Effect.all({
// Reusing this Layer value lets registration and every Location share one memoized host-level registry.
handler: HttpRouter.toHttpEffect(
createEmbeddedRoutes().pipe(Layer.provide(applicationTools), Layer.provide(HttpServer.layerServices)),
),
permissions: PermissionSaved.Service,
tools: ApplicationTools.Service,
}).pipe(Effect.provide(Layer.merge(applicationTools, PermissionSaved.defaultLayer)))
const httpClient = HttpClient.make(
Effect.fnUntraced(function* (request) {
const response = yield* handler.pipe(
Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromClientRequest(request)),
Effect.provideService(ApplicationTools.Service, tools),
Effect.provideService(PermissionSaved.Service, permissions),
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.interrupt
: HttpServerError.causeResponse(cause).pipe(Effect.map(([response]) => response)),
),
)
return HttpServerResponse.toClientResponse(response, { request })
}, Effect.scoped),
const scope = yield* Scope.Scope
const memoMap = yield* Layer.makeMemoMap
const context = yield* Layer.buildWithMemoMap(
Layer.merge(ApplicationTools.layer, PermissionSaved.defaultLayer),
memoMap,
scope,
)
const tools = Context.get(context, ApplicationTools.Service)
const permissions = Context.get(context, PermissionSaved.Service)
const web = yield* Effect.acquireRelease(
Effect.sync(() =>
HttpRouter.toWebHandler(
createEmbeddedRoutes().pipe(
HttpRouter.provideRequest(Layer.succeed(PermissionSaved.Service, permissions)),
Layer.provide(HttpServer.layerServices),
),
{ disableLogger: true, memoMap },
),
),
(web) => Effect.promise(web.dispose),
)
const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => web.handler(new Request(input, init)), {
preconnect: () => undefined,
}) satisfies typeof globalThis.fetch
const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
Effect.provide(FetchHttpClient.layer),
Effect.provideService(FetchHttpClient.Fetch, fetch),
)
return {
...client,
+34 -4
View File
@@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Effect, Schema } from "effect"
import { Effect, Option, Schema, Stream } from "effect"
test("embedded client uses the real router and handlers", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-"))
@@ -39,8 +39,31 @@ test("embedded client uses the real router and handlers", async () => {
resume: false,
})
const context = yield* opencode.sessions.context({ sessionID })
const missing = yield* Effect.flip(
opencode.sessions.get({ sessionID: Session.ID.make(`ses_missing_${crypto.randomUUID()}`) }),
const event = yield* opencode.sessions
.events({ sessionID })
.pipe(Stream.take(1), Stream.runHead, Effect.map(Option.getOrUndefined))
const modelMessage = Option.fromNullishOr(context.find((message) => message.type === "model-switched")).pipe(
Option.getOrThrow,
)
const message = yield* opencode.sessions.message({ sessionID, messageID: modelMessage.id })
yield* opencode.sessions.interrupt({ sessionID })
const other = yield* opencode.sessions.create({
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
})
const missingSessionID = Session.ID.make(`ses_missing_${crypto.randomUUID()}`)
const missing = yield* Effect.all(
[
opencode.sessions.events({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip),
opencode.sessions.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip),
opencode.sessions.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip),
],
{ concurrency: "unbounded" },
)
const missingMessage = yield* Effect.flip(
opencode.sessions.message({
sessionID: other.id,
messageID: modelMessage.id,
}),
)
expect(created.id).toBe(sessionID)
@@ -49,7 +72,14 @@ test("embedded client uses the real router and handlers", async () => {
expect(page.data.some((session) => session.id === sessionID)).toBe(true)
expect(admitted.sessionID).toBe(sessionID)
expect(context.some((message) => message.type === "model-switched")).toBe(true)
expect(missing._tag).toBe("SessionNotFoundError")
expect(event).toMatchObject({ type: "session.next.model.switched", durable: { seq: 1 } })
expect(message).toEqual(modelMessage)
expect(missing.map((error) => error._tag)).toEqual([
"SessionNotFoundError",
"SessionNotFoundError",
"SessionNotFoundError",
])
expect(missingMessage._tag).toBe("MessageNotFoundError")
})
await Effect.runPromise(Effect.scoped(program))
} finally {