From 2e3feab8c3264d32b3c4c469c91672148e884bfc Mon Sep 17 00:00:00 2001 From: Mikhail Limarenko Date: Thu, 8 Oct 2026 12:54:27 +0300 Subject: [PATCH 1/2] fix(server): avoid booting cold locations for pending prompt reads --- packages/core/src/effect/app-node-builder.ts | 1 + packages/core/src/instance.ts | 2 +- packages/core/src/instance/service.ts | 17 ++- packages/core/src/location-services.ts | 1 + packages/core/test/instance-vanilla.test.ts | 1 + .../test/plugin/supervisor-reload.test.ts | 1 + packages/core/test/plugin/supervisor.test.ts | 1 + packages/core/test/session-generate.test.ts | 2 + packages/core/test/session-move.test.ts | 6 + packages/core/test/session-owned.test.ts | 2 + packages/protocol/src/groups/session.ts | 18 +-- packages/sdk/src/internal/instances.ts | 5 +- packages/sdk/test/instances-lifecycle.test.ts | 8 +- packages/sdk/test/instances.test.ts | 20 ++- packages/server/src/handlers/permission.ts | 10 +- packages/server/src/handlers/session.ts | 20 ++- packages/server/src/location.ts | 9 ++ packages/server/test/fetch.test.ts | 26 ++-- .../server/test/session-instances.test.ts | 8 +- packages/server/test/session-prompts.test.ts | 140 ++++++++++++++++++ 20 files changed, 261 insertions(+), 37 deletions(-) create mode 100644 packages/server/test/session-prompts.test.ts diff --git a/packages/core/src/effect/app-node-builder.ts b/packages/core/src/effect/app-node-builder.ts index dfefbf8d6620..9f030ed82909 100644 --- a/packages/core/src/effect/app-node-builder.ts +++ b/packages/core/src/effect/app-node-builder.ts @@ -13,6 +13,7 @@ const instances = makeGlobalNode({ const locations = yield* LocationServiceMap.Service return Instance.Service.of({ provide: (session) => Effect.provide(locations.get(session.location)), + provideCached: (session) => Instance.cached(locations, LocationServiceMap.canonical(session.location)), }) }), ), diff --git a/packages/core/src/instance.ts b/packages/core/src/instance.ts index 0586fbaf9dc8..19613b20c231 100644 --- a/packages/core/src/instance.ts +++ b/packages/core/src/instance.ts @@ -54,7 +54,7 @@ import { ToolOutput } from "./tool-output.js" import { Vcs } from "./vcs.js" export * as Instance from "./instance.js" -export { Service, node, type Interface } from "./instance/service.js" +export { Service, node, cached, type Interface } from "./instance/service.js" const nodes = [ Location.node, diff --git a/packages/core/src/instance/service.ts b/packages/core/src/instance/service.ts index f23d28f49f2d..9ac42e243942 100644 --- a/packages/core/src/instance/service.ts +++ b/packages/core/src/instance/service.ts @@ -1,7 +1,7 @@ export * as Instance from "./service.js" export type { Services } from "../instance.js" -import { Context, type Effect } from "effect" +import { Context, Effect, LayerMap, Option, RcMap } from "effect" import type { Session } from "@opencode/schema/session" import { Node } from "@opencode/util/effect/app-node" import { LayerNode } from "@opencode/util/effect/layer-node" @@ -12,6 +12,21 @@ export interface Interface { readonly provide: ( session: Session.Info, ) => (effect: Effect.Effect) => Effect.Effect> + readonly provideCached: ( + session: Session.Info, + ) => (effect: Effect.Effect) => Effect.Effect, E | Error, Exclude> +} + +/** Borrow an existing graph for the duration of a read without constructing a missing entry. */ +export function cached(map: LayerMap.LayerMap, key: K) { + return (effect: Effect.Effect) => + Effect.scoped( + Effect.gen(function* () { + const context = yield* RcMap.getOption(map.rcMap, key) + if (Option.isNone(context)) return Option.none() + return Option.some(yield* Effect.provide(effect, context.value)) + }), + ) } export class Service extends Context.Service()("@opencode/Instance") {} diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 8498df7f6f5b..bef212bc6250 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -76,6 +76,7 @@ export function buildLocationServiceMap( Instance.node.replace( Layer.succeed(Instance.Service, { provide: (session) => Effect.provide(map.get(session.location)), + provideCached: (session) => Instance.cached(map, LocationServiceMap.canonical(session.location)), }), ), ...replacements, diff --git a/packages/core/test/instance-vanilla.test.ts b/packages/core/test/instance-vanilla.test.ts index 75d450aa56d4..c2ca02aa0f65 100644 --- a/packages/core/test/instance-vanilla.test.ts +++ b/packages/core/test/instance-vanilla.test.ts @@ -53,6 +53,7 @@ const instances = Layer.effect( Instance.node.replace( Layer.succeed(Instance.Service, { provide: (session) => Effect.provide(map.get(session.location)), + provideCached: (session) => Instance.cached(map, session.location), }), ), ] diff --git a/packages/core/test/plugin/supervisor-reload.test.ts b/packages/core/test/plugin/supervisor-reload.test.ts index c136ce17ef28..dd98204f128c 100644 --- a/packages/core/test/plugin/supervisor-reload.test.ts +++ b/packages/core/test/plugin/supervisor-reload.test.ts @@ -74,6 +74,7 @@ const instances = Layer.effect( Instance.node.replace( Layer.succeed(Instance.Service, { provide: (session) => Effect.provide(map.get(session.location)), + provideCached: (session) => Instance.cached(map, session.location), }), ), ] diff --git a/packages/core/test/plugin/supervisor.test.ts b/packages/core/test/plugin/supervisor.test.ts index 111a02b20740..e0f0c475dfcf 100644 --- a/packages/core/test/plugin/supervisor.test.ts +++ b/packages/core/test/plugin/supervisor.test.ts @@ -61,6 +61,7 @@ const instances = Layer.effect( Instance.node.replace( Layer.succeed(Instance.Service, { provide: (session) => Effect.provide(map.get(session.location)), + provideCached: (session) => Instance.cached(map, session.location), }), ), ] diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index 1e3f3aeedddd..49af5dd1e0ac 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -238,6 +238,8 @@ const setup = Effect.gen(function* () { session, instructions: yield* instructionBuiltIns.load(), instances: Instance.Service.of({ + // Generation has no cached graph in this fixture. + provideCached: () => () => Effect.succeedNone, // Generation only exercises the Location's model context. provide: () => Effect.provide( diff --git a/packages/core/test/session-move.test.ts b/packages/core/test/session-move.test.ts index 5e7fb7967489..23b64bb9cadc 100644 --- a/packages/core/test/session-move.test.ts +++ b/packages/core/test/session-move.test.ts @@ -117,6 +117,8 @@ const sourceProbe = (options: { execution?: boolean } = {}) => Effect.gen(function* () { const locations = yield* LocationServiceMap.Service return Instance.Service.of({ + provideCached: (session) => + Instance.cached(locations, LocationServiceMap.canonical(session.location)), provide: (session) => (effect) => Effect.gen(function* () { if (session.location.directory === source) { @@ -304,6 +306,10 @@ describe("Session.move", () => { { idleTimeToLive: Duration.infinity }, ) const selector = Instance.Service.of({ + provideCached: (session) => + session.id === selectedID && session.location.directory === source.directory + ? Instance.cached(privateInstances, session.id) + : Instance.cached(locations, LocationServiceMap.canonical(session.location)), provide: (session) => Effect.provide( session.id === selectedID && session.location.directory === source.directory diff --git a/packages/core/test/session-owned.test.ts b/packages/core/test/session-owned.test.ts index 327071b56771..805469c495da 100644 --- a/packages/core/test/session-owned.test.ts +++ b/packages/core/test/session-owned.test.ts @@ -147,6 +147,8 @@ const setup = Effect.fnUntraced(function* (options?: { const instances = Instance.Service.of({ // This fixture supplies only the instance services exercised by Session. provide: (session) => Effect.provide(servicesFor(session.location) as Layer.Layer), + // This fixture has no cache and is only used for active Session operations. + provideCached: () => () => Effect.succeedNone, }) const sessions = yield* Session.make().pipe( Effect.satisfiesServicesType< diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index d5bf2af1db12..a268c4a29ea5 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -794,16 +794,14 @@ export const makeSessionGroup = LayerNode.Replacemen ...LocationServiceMap.canonical(session.location), }) const provide = (session: Session.Info) => Effect.provide(instances.get(key(session))) + const provideCached = (session: Session.Info) => Instance.cached(instances, key(session)) const instances: LayerMap.LayerMap< ReturnType, Instance.Services, @@ -72,7 +73,7 @@ export function layer(options: Options, replacements: () => LayerNode.Replacemen ...replacements(), // Instances borrow this selector and the host's Location map instead of retaining // their Layer scopes; retaining the selector would block its shutdown on its own entries. - Instance.node.replace(Layer.succeed(Instance.Service, { provide })), + Instance.node.replace(Layer.succeed(Instance.Service, { provide, provideCached })), LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, locations)), ], }).pipe( @@ -102,7 +103,7 @@ export function layer(options: Options, replacements: () => LayerNode.Replacemen ), { idleTimeToLive: Duration.infinity }, ) - return Instance.Service.of({ provide }) + return Instance.Service.of({ provide, provideCached }) }), ) } diff --git a/packages/sdk/test/instances-lifecycle.test.ts b/packages/sdk/test/instances-lifecycle.test.ts index 7d78288a7462..05b45a65db10 100644 --- a/packages/sdk/test/instances-lifecycle.test.ts +++ b/packages/sdk/test/instances-lifecycle.test.ts @@ -3,7 +3,7 @@ import { Instance } from "@opencode/core/instance/service" import { Session } from "@opencode/core/session" import { Location } from "@opencode/schema/location" import { AbsolutePath } from "@opencode/schema/schema" -import { Context, Deferred, Effect, Exit, Fiber, Layer } from "effect" +import { Context, Deferred, Effect, Exit, Fiber, Layer, Option } from "effect" import { tmpdirScoped } from "../../core/test/fixture/tmpdir" import { testEffect } from "../../core/test/lib/effect" import { EmbeddedHost } from "../src/internal/host" @@ -47,16 +47,22 @@ it.live("a cancelled borrower cannot strand a later failed instance construction location: Location.Ref.make({ directory: AbsolutePath.make(directory.path) }), }) expect(acquired).toEqual([]) + expect(yield* Effect.void.pipe(instances.provideCached(session))).toEqual(Option.none()) + expect(acquired).toEqual([]) const borrower = yield* Effect.void.pipe(instances.provide(session), Effect.forkScoped) const lookup = yield* Deferred.await(started) + const cached = yield* Effect.void.pipe(instances.provideCached(session), Effect.forkScoped) + yield* Effect.yieldNow yield* Fiber.interrupt(borrower) expect(released).toEqual([]) yield* Deferred.succeed(release, undefined) expect(Exit.isFailure(yield* Fiber.await(lookup).pipe(Effect.timeout("1 second")))).toBe(true) + expect(Exit.isFailure(yield* Fiber.await(cached).pipe(Effect.timeout("1 second")))).toBe(true) expect(released).toEqual([1]) yield* Effect.void.pipe(instances.provide(session)) + expect(yield* Effect.succeed("warm").pipe(instances.provideCached(session))).toEqual(Option.some("warm")) expect(acquired).toEqual([1, 2]) expect(released).toEqual([1]) diff --git a/packages/sdk/test/instances.test.ts b/packages/sdk/test/instances.test.ts index 87857bd08383..8d099a97b8d2 100644 --- a/packages/sdk/test/instances.test.ts +++ b/packages/sdk/test/instances.test.ts @@ -88,10 +88,11 @@ test("Promise instances are lazy, share by key and Location, and stay isolated b expect(await second.sessions.get({ sessionID })).toEqual(separateHost) expect(configured).toEqual([]) expect(setups).toEqual([]) - // Permission and form lists read instance services, so they acquire the Session's instance. + // Pending lists only borrow existing instances; cold hydration does not configure one. expect(await first.permission.list({ sessionID })).toEqual([]) expect(await first.session.form.list({ sessionID })).toEqual([]) - expect(configured).toEqual(["first:alpha"]) + expect(configured).toEqual([]) + expect(setups).toEqual([]) await Promise.all( [ @@ -111,6 +112,21 @@ test("Promise instances are lazy, share by key and Location, and stay isolated b }), ) + const alphaForm = await first.session.form.create({ + sessionID: original.id, + title: "Alpha", + fields: [{ key: "answer", type: "string" }], + }) + const betaForm = await first.session.form.create({ + sessionID: separateKey.id, + title: "Beta", + fields: [{ key: "answer", type: "string" }], + }) + expect(await first.session.form.list({ sessionID: original.id })).toEqual([alphaForm]) + expect(await first.session.form.list({ sessionID: separateKey.id })).toEqual([betaForm]) + expect(await first.session.form.list({ sessionID: sameKey.id })).toEqual([]) + expect(await second.session.form.list({ sessionID: separateHost.id })).toEqual([]) + await first.sessions.switchAgent({ sessionID, agent: "plan" }) const fork = await first.sessions.fork({ sessionID }) expect(fork.metadata).toEqual(original.metadata) diff --git a/packages/server/src/handlers/permission.ts b/packages/server/src/handlers/permission.ts index 72b61c0fbd7b..34377431fd39 100644 --- a/packages/server/src/handlers/permission.ts +++ b/packages/server/src/handlers/permission.ts @@ -1,9 +1,9 @@ -import { Instance } from "@opencode/core/instance/service" import { Location } from "@opencode/core/location" +import { Instance } from "@opencode/core/instance/service" import { Permission } from "@opencode/core/permission" import { PermissionSaved } from "@opencode/core/permission/saved" import { Session } from "@opencode/core/session" -import { Effect } from "effect" +import { Effect, Option } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" import { PermissionNotFoundError } from "@opencode/protocol/errors" @@ -16,8 +16,8 @@ function missingRequest(id: Permission.ID) { export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", (handlers) => Effect.gen(function* () { - const instances = yield* Instance.Service const sessions = yield* Session.Service + const instances = yield* Instance.Service const requireOwnedRequest = Effect.fnUntraced(function* ( sessionID: Permission.Request["sessionID"], requestID: Permission.ID, @@ -62,8 +62,8 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", const session = yield* sessionInfo(sessions, ctx.params.sessionID) const requests = yield* Permission.Service.use((permission) => permission.forSession(ctx.params.sessionID), - ).pipe(instances.provide(session), locationErrors) - return { data: requests } + ).pipe(instances.provideCached(session), locationErrors) + return { data: Option.getOrElse(requests, () => []) } }), ) .handle( diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 73a076335209..c55eba01c70b 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -4,7 +4,10 @@ import { SessionTitle } from "@opencode/core/session/title" import { SessionTransfer } from "@opencode/core/session/transfer" import { InstructionEntry } from "@opencode/core/session/instruction-entry" import { Form } from "@opencode/core/form" -import { DateTime, Effect, Stream } from "effect" +import { Instance } from "@opencode/core/instance/service" +import { LocationServiceMap } from "@opencode/core/location-services" +import { DateTime, Effect, Option, Stream } from "effect" +import { HttpServerRequest } from "effect/unstable/http" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" import { SessionsCursor } from "@opencode/protocol/groups/session" @@ -23,7 +26,7 @@ import { SkillNotFoundError, } from "@opencode/protocol/errors" import { AbsolutePath } from "@opencode/core/schema" -import { locationErrors } from "../location" +import { cachedLocation, locationErrors, requestRef, sessionInfo } from "../location" import { failedMessageDecode, failedSnapshot, missingMessage, missingSession } from "./session-error" const DefaultSessionsLimit = 50 @@ -35,6 +38,8 @@ function missingForm(id: Form.ID) { export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) => Effect.gen(function* () { const session = yield* Session.Service + const locations = yield* LocationServiceMap.Service + const instances = yield* Instance.Service const transfer = yield* SessionTransfer.Service const requireOwnedForm = Effect.fnUntraced(function* (sessionID: Form.Info["sessionID"], formID: Form.ID) { const form = yield* Form.Service @@ -645,8 +650,15 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl .handle( "session.form.list", Effect.fn(function* (ctx) { - const form = yield* Form.Service - return { data: yield* form.list({ sessionID: ctx.params.sessionID }) } + const read = Form.Service.use((form) => form.list({ sessionID: ctx.params.sessionID })) + const forms = + ctx.params.sessionID === "global" + ? yield* cachedLocation(locations, requestRef(yield* HttpServerRequest.HttpServerRequest), read) + : yield* read.pipe( + instances.provideCached(yield* sessionInfo(session, ctx.params.sessionID)), + locationErrors, + ) + return { data: Option.getOrElse(forms, () => []) } }), ) .handle( diff --git a/packages/server/src/location.ts b/packages/server/src/location.ts index 7553fa9adc6f..6d06e2cde0f1 100644 --- a/packages/server/src/location.ts +++ b/packages/server/src/location.ts @@ -1,4 +1,5 @@ import { FileSystem } from "@opencode/core/filesystem" +import { Instance } from "@opencode/core/instance/service" import { Location } from "@opencode/core/location" import { LocationServiceMap } from "@opencode/core/location-services" import { AbsolutePath } from "@opencode/core/schema" @@ -11,6 +12,14 @@ import { missingSession } from "./handlers/session-error" export type LocationServices = Layer.Success> +export function cachedLocation( + locations: typeof LocationServiceMap.Service.Service, + ref: Location.Ref, + read: Effect.Effect, +) { + return read.pipe(Instance.cached(locations, LocationServiceMap.canonical(ref)), locationErrors) +} + export class LocationMiddleware extends HttpApiMiddleware.Service()( "@opencode/HttpApiLocation", { error: [LocationNotFoundError] }, diff --git a/packages/server/test/fetch.test.ts b/packages/server/test/fetch.test.ts index 952a89aca0ab..4f244d3386fb 100644 --- a/packages/server/test/fetch.test.ts +++ b/packages/server/test/fetch.test.ts @@ -38,14 +38,7 @@ it.live("returns LocationNotFoundError for a missing folder and recovers once it const session = Schema.decodeUnknownSync(Schema.Struct({ data: Session.Info }))( yield* Effect.promise(() => created.json()), ).data - const endpoints = [ - "/api/model", - "/api/integration", - `/api/session/${session.id}/permission`, - `/api/experimental/session/${session.id}/instructions/entries`, - `/api/session/${session.id}/form`, - "/api/session/global/form", - ] + const endpoints = ["/api/model", "/api/integration", `/api/experimental/session/${session.id}/instructions/entries`] for (const endpoint of endpoints) { const response = yield* Effect.promise(() => handler( @@ -61,6 +54,21 @@ it.live("returns LocationNotFoundError for a missing folder and recovers once it message: `Location not found: ${directory}`, }) } + for (const endpoint of [ + `/api/session/${session.id}/permission`, + `/api/session/${session.id}/form`, + "/api/session/global/form", + ]) { + const response = yield* Effect.promise(() => + handler( + new Request(`http://opencode.local${endpoint}`, { + headers: { "x-opencode-directory": encodeURIComponent(directory) }, + }), + ), + ) + expect(response.status).toBe(200) + expect(yield* Effect.promise(() => response.json())).toEqual({ data: [] }) + } yield* Effect.promise(() => fs.mkdir(directory)) const recovered = yield* Effect.promise(() => handler( @@ -506,7 +514,7 @@ it.live("routes pending requests through the Session's instance", () => ) expect(global.status).toBe(200) expect(yield* Effect.promise(() => global.json())).toEqual({ data: [] }) - expect(yield* loaded()).toEqual([{ directory: process.cwd() }]) + expect(yield* loaded()).toEqual([]) const createdForm = yield* Effect.promise(() => handler( diff --git a/packages/server/test/session-instances.test.ts b/packages/server/test/session-instances.test.ts index 0cea57e4aff4..e3254e5224c8 100644 --- a/packages/server/test/session-instances.test.ts +++ b/packages/server/test/session-instances.test.ts @@ -145,6 +145,7 @@ it.live( ) const selector = Instance.Service.of({ provide: (session) => Effect.provide(instances.get(session.id)), + provideCached: (session) => Instance.cached(instances, session.id), }) const bindings: LayerNode.Replacements = [ ...replacements, @@ -197,8 +198,11 @@ it.live( expect(yield* Effect.promise(() => response.json())).toEqual({ data: [] }) } } - // Reading permissions or forms builds the Session's Instance and starts its plugin activation at once; the - // ordering assertion after the prompts is what proves each Session boots exactly once. + expect(boots).toEqual([]) + for (const resource of ["permission", "form"]) { + expect((yield* request(`/api/session/ses_missing_instance/${resource}`)).status).toBe(404) + } + expect(boots).toEqual([]) for (const config of configs) { const session = yield* sessions.get(config.id) diff --git a/packages/server/test/session-prompts.test.ts b/packages/server/test/session-prompts.test.ts new file mode 100644 index 000000000000..1bac7db3e685 --- /dev/null +++ b/packages/server/test/session-prompts.test.ts @@ -0,0 +1,140 @@ +import { expect } from "bun:test" +import { App } from "@opencode/core/app" +import { Bus } from "@opencode/core/bus" +import { Database } from "@opencode/core/database/database" +import { ModelsDev } from "@opencode/core/models-dev" +import { Watcher } from "@opencode/core/filesystem/watcher" +import { Instance } from "@opencode/core/instance" +import { LocationServiceMap } from "@opencode/core/location-service-map" +import { Form } from "@opencode/core/form" +import { Permission } from "@opencode/core/permission" +import { Session } from "@opencode/core/session" +import { Location } from "@opencode/schema/location" +import { AbsolutePath } from "@opencode/schema/schema" +import { LayerNode } from "@opencode/util/effect/layer-node" +import { Global } from "@opencode/util/global" +import { Context, Deferred, Duration, Effect, Fiber, Layer, LayerMap, Option, RcMap } from "effect" +import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http" +import { tempGlobalLayer } from "../../core/test/fixture/global" +import { tmpdirScoped } from "../../core/test/fixture/tmpdir" +import { it } from "../../core/test/lib/effect" +import { createEmbeddedRoutes } from "../src/routes" +import { cachedLocation } from "../src/location" + +it.live( + "reads pending session prompts without booting cold locations", + () => + Effect.gen(function* () { + const directory = yield* tmpdirScoped() + const location = LocationServiceMap.canonical(Location.Ref.make({ directory: AbsolutePath.make(directory.path) })) + const lookups: Location.Ref[] = [] + const closed: Location.Ref[] = [] + const replacements: LayerNode.Replacements = [ + Global.node.replace(tempGlobalLayer), + App.node.replace(App.node), + Bus.node.replace(Bus.node), + Database.node.replace(Database.node), + ModelsDev.node.replace(ModelsDev.configured({ fetch: false })), + Watcher.node.replace(Watcher.configured({ enabled: false })), + LocationServiceMap.node.replace( + Layer.effect( + LocationServiceMap.Service, + Effect.gen(function* () { + const map = yield* LayerMap.make( + (ref: Location.Ref) => { + lookups.push(ref) + return Layer.merge( + Instance.layer(ref, { discovery: false, replacements: bindings }), + Layer.effectDiscard(Effect.addFinalizer(() => Effect.sync(() => closed.push(ref)))), + ) + }, + { idleTimeToLive: Duration.infinity }, + ) + const bindings: LayerNode.Replacements = [ + ...replacements, + Instance.node.replace( + Layer.succeed(Instance.Service, { + provide: (session) => Effect.provide(map.get(session.location)), + provideCached: (session) => Instance.cached(map, LocationServiceMap.canonical(session.location)), + }), + ), + LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, map)), + ] + return map + }), + ), + ), + ] + const context = yield* Layer.build( + createEmbeddedRoutes( + { database: { path: ":memory:" }, models: { fetch: false }, fs: { filewatcher: false } }, + replacements, + ).pipe(Layer.provide(HttpServer.layerServices)), + ) + const sessions = Context.get(context, Session.Service) + const locations = Context.get(context, LocationServiceMap.Service) + const handler = Context.get(context, HttpRouter.HttpRouter) + .asHttpEffect() + .pipe(HttpEffect.toWebHandlerWith(context)) + const read = (route: string, status = 200) => + Effect.promise(async () => { + const response = await handler(new Request(`http://opencode.local${route}`)) + expect(response.status).toBe(status) + return await response.json() + }) + const session = yield* sessions.create({ location, permissions: [{ action: "*", resource: "*", effect: "ask" }] }) + const global = `/api/session/global/form?location[directory]=${encodeURIComponent(location.directory)}` + for (const resource of ["form", "permission"]) { + expect(yield* read(`/api/session/${session.id}/${resource}`)).toEqual({ data: [] }) + expect(lookups).toEqual([]) + expect(yield* read(`/api/session/${Session.ID.create()}/${resource}`, 404)).toMatchObject({ + _tag: "SessionNotFoundError", + }) + expect(lookups).toEqual([]) + } + expect(yield* read(global)).toEqual({ data: [] }) + expect(lookups).toEqual([]) + const pending = yield* Effect.gen(function* () { + const forms = yield* Form.Service + const permissions = yield* Permission.Service + const form = yield* forms.create({ + sessionID: session.id, + title: "Session", + fields: [{ key: "answer", type: "string" }], + }) + const globalForm = yield* forms.create({ + sessionID: "global", + title: "Global", + fields: [{ key: "answer", type: "string" }], + }) + const permission = { id: Permission.ID.create(), sessionID: session.id, action: "read", resources: ["file"] } + expect(yield* permissions.ask(permission)).toEqual({ id: permission.id, effect: "ask" }) + return { form, globalForm, permission } + }).pipe(Effect.provide(locations.get(location))) + expect(yield* read(`/api/session/${session.id}/form`)).toEqual({ data: [pending.form] }) + expect(yield* read(`/api/session/${session.id}/permission`)).toEqual({ data: [pending.permission] }) + expect(yield* read(global)).toEqual({ data: [pending.globalForm] }) + expect(lookups).toEqual([location]) + expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([location]) + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + const reading = yield* cachedLocation( + locations, + location, + Effect.gen(function* () { + const forms = yield* Form.Service + yield* Deferred.succeed(entered, undefined) + yield* Deferred.await(release) + return yield* forms.list({ sessionID: session.id }) + }), + ).pipe(Effect.forkChild) + yield* Deferred.await(entered) + yield* locations.invalidate(location) + expect(closed).toEqual([]) + expect(lookups).toEqual([location]) + yield* Deferred.succeed(release, undefined) + expect(yield* Fiber.join(reading)).toEqual(Option.some([pending.form])) + expect(closed).toEqual([location]) + }), + 15_000, +) From 800f6b1d4ebcd798d0855f9b6a2044af231c3922 Mon Sep 17 00:00:00 2001 From: Mikhail Limarenko Date: Fri, 9 Oct 2026 00:59:11 +0300 Subject: [PATCH 2/2] test(server): focus pending prompt reads on cold and warm locations --- packages/server/test/session-prompts.test.ts | 29 ++------------------ 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/packages/server/test/session-prompts.test.ts b/packages/server/test/session-prompts.test.ts index 1bac7db3e685..ca449aa0891e 100644 --- a/packages/server/test/session-prompts.test.ts +++ b/packages/server/test/session-prompts.test.ts @@ -13,13 +13,12 @@ import { Location } from "@opencode/schema/location" import { AbsolutePath } from "@opencode/schema/schema" import { LayerNode } from "@opencode/util/effect/layer-node" import { Global } from "@opencode/util/global" -import { Context, Deferred, Duration, Effect, Fiber, Layer, LayerMap, Option, RcMap } from "effect" +import { Context, Duration, Effect, Layer, LayerMap } from "effect" import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http" import { tempGlobalLayer } from "../../core/test/fixture/global" import { tmpdirScoped } from "../../core/test/fixture/tmpdir" import { it } from "../../core/test/lib/effect" import { createEmbeddedRoutes } from "../src/routes" -import { cachedLocation } from "../src/location" it.live( "reads pending session prompts without booting cold locations", @@ -28,7 +27,6 @@ it.live( const directory = yield* tmpdirScoped() const location = LocationServiceMap.canonical(Location.Ref.make({ directory: AbsolutePath.make(directory.path) })) const lookups: Location.Ref[] = [] - const closed: Location.Ref[] = [] const replacements: LayerNode.Replacements = [ Global.node.replace(tempGlobalLayer), App.node.replace(App.node), @@ -43,10 +41,7 @@ it.live( const map = yield* LayerMap.make( (ref: Location.Ref) => { lookups.push(ref) - return Layer.merge( - Instance.layer(ref, { discovery: false, replacements: bindings }), - Layer.effectDiscard(Effect.addFinalizer(() => Effect.sync(() => closed.push(ref)))), - ) + return Instance.layer(ref, { discovery: false, replacements: bindings }) }, { idleTimeToLive: Duration.infinity }, ) @@ -115,26 +110,6 @@ it.live( expect(yield* read(`/api/session/${session.id}/permission`)).toEqual({ data: [pending.permission] }) expect(yield* read(global)).toEqual({ data: [pending.globalForm] }) expect(lookups).toEqual([location]) - expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([location]) - const entered = yield* Deferred.make() - const release = yield* Deferred.make() - const reading = yield* cachedLocation( - locations, - location, - Effect.gen(function* () { - const forms = yield* Form.Service - yield* Deferred.succeed(entered, undefined) - yield* Deferred.await(release) - return yield* forms.list({ sessionID: session.id }) - }), - ).pipe(Effect.forkChild) - yield* Deferred.await(entered) - yield* locations.invalidate(location) - expect(closed).toEqual([]) - expect(lookups).toEqual([location]) - yield* Deferred.succeed(release, undefined) - expect(yield* Fiber.join(reading)).toEqual(Option.some([pending.form])) - expect(closed).toEqual([location]) }), 15_000, )