httpapi-promptasync-context.test.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. // Regression coverage for issue #26526's claim that promptAsync's
  2. // Effect.forkIn loses the request's InstanceRef/WorkspaceRef. It does not —
  3. // forkIn preserves Context.Reference values via standard fiber inheritance.
  4. //
  5. // The companion claim that the streaming prompt handler "captures and
  6. // provides" those services is true and load-bearing: Stream.fromEffect's
  7. // body runs detached from the request fiber's context, so the explicit
  8. // Effect.provideService calls there are required, not defensive duplication.
  9. import { NodeHttpServer, NodeServices } from "@effect/platform-node"
  10. import { describe, expect } from "bun:test"
  11. import { Deferred, Effect, Layer, Schema, Scope } from "effect"
  12. import * as Stream from "effect/Stream"
  13. import { HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"
  14. import * as Socket from "effect/unstable/socket/Socket"
  15. import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
  16. import { mkdir } from "node:fs/promises"
  17. import { registerAdapter } from "../../src/control-plane/adapters"
  18. import type { WorkspaceAdapter } from "../../src/control-plane/types"
  19. import { Workspace } from "../../src/control-plane/workspace"
  20. import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
  21. import { InstanceLayer } from "../../src/project/instance-layer"
  22. import { Project } from "../../src/project/project"
  23. import { Session } from "../../src/session/session"
  24. import {
  25. InstanceContextMiddleware,
  26. instanceContextLayer,
  27. } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
  28. import {
  29. WorkspaceRoutingMiddleware,
  30. WorkspaceRoutingQuery,
  31. workspaceRoutingLayer,
  32. } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
  33. import { resetDatabase } from "../fixture/db"
  34. import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
  35. import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace"
  36. import { testEffect } from "../lib/effect"
  37. const testStateLayer = Layer.effectDiscard(
  38. Effect.gen(function* () {
  39. yield* Effect.promise(() => resetDatabase())
  40. yield* Effect.addFinalizer(() =>
  41. Effect.promise(async () => {
  42. await disposeAllInstances()
  43. await resetDatabase()
  44. }),
  45. )
  46. }),
  47. )
  48. const workspaceLayer = workspaceLayerWithRuntimeFlags({ experimentalWorkspaces: true })
  49. const it = testEffect(
  50. Layer.mergeAll(
  51. testStateLayer,
  52. NodeHttpServer.layerTest,
  53. NodeServices.layer,
  54. InstanceLayer.layer,
  55. Project.defaultLayer,
  56. workspaceLayer,
  57. ),
  58. )
  59. const instanceContextTestLayer = Layer.mergeAll(
  60. instanceContextLayer,
  61. workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
  62. )
  63. const localAdapter = (directory: string): WorkspaceAdapter => ({
  64. name: "Local Test",
  65. description: "Create a local test workspace",
  66. configure: (info) => ({ ...info, name: "local-test", directory }),
  67. create: async () => {
  68. await mkdir(directory, { recursive: true })
  69. },
  70. async remove() {},
  71. target: () => ({ type: "local" as const, directory }),
  72. })
  73. const setupWorkspace = (kind: string) =>
  74. Effect.gen(function* () {
  75. const dir = yield* tmpdirScoped({ git: true })
  76. yield* Project.use.fromDirectory(dir)
  77. const projectID = yield* Project.Service.use((svc) => svc.fromDirectory(dir).pipe(Effect.map((p) => p.project.id)))
  78. registerAdapter(projectID, kind, localAdapter(dir))
  79. const workspace = yield* Workspace.Service.use((svc) =>
  80. svc.create({ type: kind, branch: null, extra: null, projectID }),
  81. )
  82. return { dir, workspace }
  83. })
  84. type Capture = { directory?: string; workspaceID?: string }
  85. const captureInstance = Effect.gen(function* () {
  86. const instance = yield* InstanceRef
  87. const workspaceID = yield* WorkspaceRef
  88. return { directory: instance?.directory, workspaceID } satisfies Capture
  89. })
  90. const ProbeApi = HttpApi.make("handler-context-probe").add(
  91. HttpApiGroup.make("probe")
  92. .add(
  93. HttpApiEndpoint.post("fork", "/fork-probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }),
  94. HttpApiEndpoint.post("streamWithout", "/stream-probe-without", {
  95. query: WorkspaceRoutingQuery,
  96. success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })),
  97. }),
  98. HttpApiEndpoint.post("streamWith", "/stream-probe-with", {
  99. query: WorkspaceRoutingQuery,
  100. success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })),
  101. }),
  102. )
  103. .middleware(InstanceContextMiddleware)
  104. .middleware(WorkspaceRoutingMiddleware),
  105. )
  106. const serveProbes = (input: {
  107. fork?: Effect.Effect<boolean, never, Scope.Scope>
  108. streamWithout?: Effect.Effect<HttpServerResponse.HttpServerResponse>
  109. streamWith?: Effect.Effect<HttpServerResponse.HttpServerResponse>
  110. }) =>
  111. HttpApiBuilder.layer(ProbeApi).pipe(
  112. Layer.provide(
  113. HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
  114. handlers
  115. .handle("fork", () => input.fork ?? Effect.succeed(false))
  116. .handleRaw(
  117. "streamWithout",
  118. () => input.streamWithout ?? Effect.succeed(HttpServerResponse.empty({ status: 404 })),
  119. )
  120. .handleRaw("streamWith", () => input.streamWith ?? Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
  121. ),
  122. ),
  123. Layer.provide(instanceContextTestLayer),
  124. Layer.provide(Layer.mock(Session.Service)({})),
  125. HttpRouter.serve,
  126. Layer.build,
  127. )
  128. describe("HttpApi handler context inheritance", () => {
  129. // Mirrors handlers/session.ts:281 promptAsync. The forked fiber inherits
  130. // the request's Context — including InstanceRef and WorkspaceRef provided
  131. // by InstanceContextMiddleware — without any explicit re-provide.
  132. it.live("Effect.forkIn preserves InstanceRef/WorkspaceRef across the fork", () =>
  133. Effect.gen(function* () {
  134. const { dir, workspace } = yield* setupWorkspace("local-fork")
  135. const capture = yield* Deferred.make<Capture>()
  136. yield* serveProbes({
  137. fork: Effect.gen(function* () {
  138. const scope = yield* Scope.Scope
  139. yield* Effect.gen(function* () {
  140. yield* Deferred.succeed(capture, yield* captureInstance)
  141. }).pipe(Effect.forkIn(scope, { startImmediately: true }))
  142. return true
  143. }),
  144. })
  145. const response = yield* HttpClient.post(
  146. `/fork-probe?directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`,
  147. )
  148. expect(response.status).toBe(200)
  149. const observed = yield* Deferred.await(capture).pipe(Effect.timeout("2 seconds"))
  150. expect(observed.directory).toBe(dir)
  151. expect(observed.workspaceID).toBe(workspace.id)
  152. }),
  153. )
  154. // Mirrors handlers/session.ts:255 prompt — the streaming handler reads
  155. // InstanceRef/WorkspaceRef in the request fiber and re-provides them to
  156. // the Stream.fromEffect body. This test locks in why the explicit
  157. // provides are required: without them the stream body sees undefined.
  158. it.live("Stream.fromEffect body needs explicit provides — inheritance does not carry through", () =>
  159. Effect.gen(function* () {
  160. const { dir, workspace } = yield* setupWorkspace("local-stream")
  161. const withoutCapture = yield* Deferred.make<Capture>()
  162. const withCapture = yield* Deferred.make<Capture>()
  163. yield* serveProbes({
  164. streamWithout: Effect.gen(function* () {
  165. return HttpServerResponse.stream(
  166. Stream.fromEffect(
  167. Effect.gen(function* () {
  168. yield* Deferred.succeed(withoutCapture, yield* captureInstance)
  169. return ""
  170. }),
  171. ).pipe(Stream.encodeText),
  172. { contentType: "application/json" },
  173. )
  174. }),
  175. streamWith: Effect.gen(function* () {
  176. const instance = yield* InstanceRef
  177. const workspaceID = yield* WorkspaceRef
  178. return HttpServerResponse.stream(
  179. Stream.fromEffect(
  180. Effect.gen(function* () {
  181. yield* Deferred.succeed(withCapture, yield* captureInstance)
  182. return ""
  183. }).pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspaceID)),
  184. ).pipe(Stream.encodeText),
  185. { contentType: "application/json" },
  186. )
  187. }),
  188. })
  189. const queryString = `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`
  190. const responseWithout = yield* HttpClient.post(`/stream-probe-without?${queryString}`)
  191. yield* responseWithout.text
  192. const responseWith = yield* HttpClient.post(`/stream-probe-with?${queryString}`)
  193. yield* responseWith.text
  194. const without = yield* Deferred.await(withoutCapture).pipe(Effect.timeout("2 seconds"))
  195. expect(without.directory).toBeUndefined()
  196. expect(without.workspaceID).toBeUndefined()
  197. const withProvide = yield* Deferred.await(withCapture).pipe(Effect.timeout("2 seconds"))
  198. expect(withProvide.directory).toBe(dir)
  199. expect(withProvide.workspaceID).toBe(workspace.id)
  200. }),
  201. )
  202. })