mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-13 23:52:06 +00:00
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:
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user