test(server): cover HttpApi context inheritance

Adds regression coverage for request context inheritance in promptAsync and explicit context provisioning in stream bodies.
This commit is contained in:
Kit Langton
2026-05-09 18:46:10 -04:00
committed by GitHub
parent 43b51f09d0
commit bd1029b19f

View File

@@ -0,0 +1,191 @@
// Regression coverage for issue #26526's claim that promptAsync's
// Effect.forkIn loses the request's InstanceRef/WorkspaceRef. It does not —
// forkIn preserves Context.Reference values via standard fiber inheritance.
//
// The companion claim that the streaming prompt handler "captures and
// provides" those services is true and load-bearing: Stream.fromEffect's
// body runs detached from the request fiber's context, so the explicit
// Effect.provideService calls there are required, not defensive duplication.
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Layer, Scope } from "effect"
import * as Stream from "effect/Stream"
import { HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { mkdir } from "node:fs/promises"
import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { InstanceLayer } from "../../src/project/instance-layer"
import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project"
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
yield* Effect.promise(() => resetDatabase())
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await disposeAllInstances()
await resetDatabase()
}),
)
}),
)
const workspaceLayer = Workspace.defaultLayer.pipe(
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
)
const it = testEffect(
Layer.mergeAll(
testStateLayer,
NodeHttpServer.layerTest,
NodeServices.layer,
InstanceLayer.layer,
Project.defaultLayer,
workspaceLayer,
),
)
const instanceContextTestLayer = instanceRouterMiddleware
.combine(workspaceRouterMiddleware)
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
const localAdapter = (directory: string): WorkspaceAdapter => ({
name: "Local Test",
description: "Create a local test workspace",
configure: (info) => ({ ...info, name: "local-test", directory }),
create: async () => {
await mkdir(directory, { recursive: true })
},
async remove() {},
target: () => ({ type: "local" as const, directory }),
})
const setupWorkspace = (kind: string) =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
yield* Project.use.fromDirectory(dir)
const projectID = yield* Project.Service.use((svc) =>
svc.fromDirectory(dir).pipe(Effect.map((p) => p.project.id)),
)
registerAdapter(projectID, kind, localAdapter(dir))
const workspace = yield* Workspace.Service.use((svc) =>
svc.create({ type: kind, branch: null, extra: null, projectID }),
)
return { dir, workspace }
})
type Capture = { directory?: string; workspaceID?: string }
const captureInstance = Effect.gen(function* () {
const instance = yield* InstanceRef
const workspaceID = yield* WorkspaceRef
return { directory: instance?.directory, workspaceID } satisfies Capture
})
describe("HttpApi handler context inheritance", () => {
// Mirrors handlers/session.ts:281 promptAsync. The forked fiber inherits
// the request's Context — including InstanceRef and WorkspaceRef provided
// by InstanceContextMiddleware — without any explicit re-provide.
it.live("Effect.forkIn preserves InstanceRef/WorkspaceRef across the fork", () =>
Effect.gen(function* () {
const { dir, workspace } = yield* setupWorkspace("local-fork")
const capture = yield* Deferred.make<Capture>()
yield* HttpRouter.add(
"POST",
"/fork-probe",
Effect.gen(function* () {
const scope = yield* Scope.Scope
yield* Effect.gen(function* () {
yield* Deferred.succeed(capture, yield* captureInstance)
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
return HttpServerResponse.empty({ status: 204 })
}),
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
const response = yield* HttpClient.post(
`/fork-probe?directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`,
)
expect(response.status).toBe(204)
const observed = yield* Deferred.await(capture).pipe(Effect.timeout("2 seconds"))
expect(observed.directory).toBe(dir)
expect(observed.workspaceID).toBe(workspace.id)
}),
)
// Mirrors handlers/session.ts:255 prompt — the streaming handler reads
// InstanceRef/WorkspaceRef in the request fiber and re-provides them to
// the Stream.fromEffect body. This test locks in why the explicit
// provides are required: without them the stream body sees undefined.
it.live("Stream.fromEffect body needs explicit provides — inheritance does not carry through", () =>
Effect.gen(function* () {
const { dir, workspace } = yield* setupWorkspace("local-stream")
const withoutCapture = yield* Deferred.make<Capture>()
const withCapture = yield* Deferred.make<Capture>()
yield* HttpRouter.add(
"POST",
"/stream-probe-without",
Effect.gen(function* () {
return HttpServerResponse.stream(
Stream.fromEffect(
Effect.gen(function* () {
yield* Deferred.succeed(withoutCapture, yield* captureInstance)
return ""
}),
).pipe(Stream.encodeText),
{ contentType: "application/json" },
)
}),
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
yield* HttpRouter.add(
"POST",
"/stream-probe-with",
Effect.gen(function* () {
const instance = yield* InstanceRef
const workspaceID = yield* WorkspaceRef
return HttpServerResponse.stream(
Stream.fromEffect(
Effect.gen(function* () {
yield* Deferred.succeed(withCapture, yield* captureInstance)
return ""
}).pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspaceID)),
).pipe(Stream.encodeText),
{ contentType: "application/json" },
)
}),
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
const queryString = `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`
const responseWithout = yield* HttpClient.post(`/stream-probe-without?${queryString}`)
yield* responseWithout.text
const responseWith = yield* HttpClient.post(`/stream-probe-with?${queryString}`)
yield* responseWith.text
const without = yield* Deferred.await(withoutCapture).pipe(Effect.timeout("2 seconds"))
expect(without.directory).toBeUndefined()
expect(without.workspaceID).toBeUndefined()
const withProvide = yield* Deferred.await(withCapture).pipe(Effect.timeout("2 seconds"))
expect(withProvide.directory).toBe(dir)
expect(withProvide.workspaceID).toBe(workspace.id)
}),
)
})