httpapi-promptasync-context.test.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  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, 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 { mkdir } from "node:fs/promises"
  16. import { registerAdapter } from "../../src/control-plane/adapters"
  17. import type { WorkspaceAdapter } from "../../src/control-plane/types"
  18. import { Workspace } from "../../src/control-plane/workspace"
  19. import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
  20. import { InstanceLayer } from "../../src/project/instance-layer"
  21. import { Project } from "../../src/project/project"
  22. import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
  23. import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
  24. import { resetDatabase } from "../fixture/db"
  25. import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
  26. import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace"
  27. import { testEffect } from "../lib/effect"
  28. const testStateLayer = Layer.effectDiscard(
  29. Effect.gen(function* () {
  30. yield* Effect.promise(() => resetDatabase())
  31. yield* Effect.addFinalizer(() =>
  32. Effect.promise(async () => {
  33. await disposeAllInstances()
  34. await resetDatabase()
  35. }),
  36. )
  37. }),
  38. )
  39. const workspaceLayer = workspaceLayerWithRuntimeFlags({ experimentalWorkspaces: true })
  40. const it = testEffect(
  41. Layer.mergeAll(
  42. testStateLayer,
  43. NodeHttpServer.layerTest,
  44. NodeServices.layer,
  45. InstanceLayer.layer,
  46. Project.defaultLayer,
  47. workspaceLayer,
  48. ),
  49. )
  50. const instanceContextTestLayer = instanceRouterMiddleware
  51. .combine(workspaceRouterMiddleware)
  52. .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
  53. const localAdapter = (directory: string): WorkspaceAdapter => ({
  54. name: "Local Test",
  55. description: "Create a local test workspace",
  56. configure: (info) => ({ ...info, name: "local-test", directory }),
  57. create: async () => {
  58. await mkdir(directory, { recursive: true })
  59. },
  60. async remove() {},
  61. target: () => ({ type: "local" as const, directory }),
  62. })
  63. const setupWorkspace = (kind: string) =>
  64. Effect.gen(function* () {
  65. const dir = yield* tmpdirScoped({ git: true })
  66. yield* Project.use.fromDirectory(dir)
  67. const projectID = yield* Project.Service.use((svc) => svc.fromDirectory(dir).pipe(Effect.map((p) => p.project.id)))
  68. registerAdapter(projectID, kind, localAdapter(dir))
  69. const workspace = yield* Workspace.Service.use((svc) =>
  70. svc.create({ type: kind, branch: null, extra: null, projectID }),
  71. )
  72. return { dir, workspace }
  73. })
  74. type Capture = { directory?: string; workspaceID?: string }
  75. const captureInstance = Effect.gen(function* () {
  76. const instance = yield* InstanceRef
  77. const workspaceID = yield* WorkspaceRef
  78. return { directory: instance?.directory, workspaceID } satisfies Capture
  79. })
  80. describe("HttpApi handler context inheritance", () => {
  81. // Mirrors handlers/session.ts:281 promptAsync. The forked fiber inherits
  82. // the request's Context — including InstanceRef and WorkspaceRef provided
  83. // by InstanceContextMiddleware — without any explicit re-provide.
  84. it.live("Effect.forkIn preserves InstanceRef/WorkspaceRef across the fork", () =>
  85. Effect.gen(function* () {
  86. const { dir, workspace } = yield* setupWorkspace("local-fork")
  87. const capture = yield* Deferred.make<Capture>()
  88. yield* HttpRouter.add(
  89. "POST",
  90. "/fork-probe",
  91. Effect.gen(function* () {
  92. const scope = yield* Scope.Scope
  93. yield* Effect.gen(function* () {
  94. yield* Deferred.succeed(capture, yield* captureInstance)
  95. }).pipe(Effect.forkIn(scope, { startImmediately: true }))
  96. return HttpServerResponse.empty({ status: 204 })
  97. }),
  98. ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
  99. const response = yield* HttpClient.post(
  100. `/fork-probe?directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`,
  101. )
  102. expect(response.status).toBe(204)
  103. const observed = yield* Deferred.await(capture).pipe(Effect.timeout("2 seconds"))
  104. expect(observed.directory).toBe(dir)
  105. expect(observed.workspaceID).toBe(workspace.id)
  106. }),
  107. )
  108. // Mirrors handlers/session.ts:255 prompt — the streaming handler reads
  109. // InstanceRef/WorkspaceRef in the request fiber and re-provides them to
  110. // the Stream.fromEffect body. This test locks in why the explicit
  111. // provides are required: without them the stream body sees undefined.
  112. it.live("Stream.fromEffect body needs explicit provides — inheritance does not carry through", () =>
  113. Effect.gen(function* () {
  114. const { dir, workspace } = yield* setupWorkspace("local-stream")
  115. const withoutCapture = yield* Deferred.make<Capture>()
  116. const withCapture = yield* Deferred.make<Capture>()
  117. yield* HttpRouter.add(
  118. "POST",
  119. "/stream-probe-without",
  120. Effect.gen(function* () {
  121. return HttpServerResponse.stream(
  122. Stream.fromEffect(
  123. Effect.gen(function* () {
  124. yield* Deferred.succeed(withoutCapture, yield* captureInstance)
  125. return ""
  126. }),
  127. ).pipe(Stream.encodeText),
  128. { contentType: "application/json" },
  129. )
  130. }),
  131. ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
  132. yield* HttpRouter.add(
  133. "POST",
  134. "/stream-probe-with",
  135. Effect.gen(function* () {
  136. const instance = yield* InstanceRef
  137. const workspaceID = yield* WorkspaceRef
  138. return HttpServerResponse.stream(
  139. Stream.fromEffect(
  140. Effect.gen(function* () {
  141. yield* Deferred.succeed(withCapture, yield* captureInstance)
  142. return ""
  143. }).pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspaceID)),
  144. ).pipe(Stream.encodeText),
  145. { contentType: "application/json" },
  146. )
  147. }),
  148. ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
  149. const queryString = `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`
  150. const responseWithout = yield* HttpClient.post(`/stream-probe-without?${queryString}`)
  151. yield* responseWithout.text
  152. const responseWith = yield* HttpClient.post(`/stream-probe-with?${queryString}`)
  153. yield* responseWith.text
  154. const without = yield* Deferred.await(withoutCapture).pipe(Effect.timeout("2 seconds"))
  155. expect(without.directory).toBeUndefined()
  156. expect(without.workspaceID).toBeUndefined()
  157. const withProvide = yield* Deferred.await(withCapture).pipe(Effect.timeout("2 seconds"))
  158. expect(withProvide.directory).toBe(dir)
  159. expect(withProvide.workspaceID).toBe(workspace.id)
  160. }),
  161. )
  162. })