app-graph.test.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. import { describe, expect, test } from "bun:test"
  2. import { Cause, Context, Effect, Exit, Layer } from "effect"
  3. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  4. const { buildLayer: build, group, replace, replaceWithNode } = LayerNode
  5. const node = LayerNode.make
  6. class Value extends Context.Service<Value, { readonly value: string }>()("test/Value") {}
  7. class Greeting extends Context.Service<Greeting, { readonly text: string }>()("test/Greeting") {}
  8. const value = LayerNode.make(Layer.succeed(Value, Value.of({ value: "production" })), [])
  9. const greetingImplementation = Layer.effect(
  10. Greeting,
  11. Effect.gen(function* () {
  12. return Greeting.of({ text: `hello ${(yield* Value).value}` })
  13. }),
  14. )
  15. const greeting = LayerNode.make(greetingImplementation, [value])
  16. // @ts-expect-error Greeting requires Value
  17. LayerNode.make(greetingImplementation, [])
  18. describe("app graph", () => {
  19. test("creates any selected dependency layer", async () => {
  20. const result = Effect.gen(function* () {
  21. return (yield* Greeting).text
  22. }).pipe(Effect.provide(build(greeting)))
  23. expect(await Effect.runPromise(result)).toBe("hello production")
  24. })
  25. test("applies overrides before dependency materialization", async () => {
  26. const replacement = Layer.succeed(Value, Value.of({ value: "simulation" }))
  27. const graph = build(greeting, { replacements: [replace(value, replacement)] })
  28. const result = Effect.gen(function* () {
  29. return (yield* Greeting).text
  30. }).pipe(Effect.provide(graph))
  31. expect(await Effect.runPromise(result)).toBe("hello simulation")
  32. })
  33. test("acquires a shared dependency once", async () => {
  34. class Shared extends Context.Service<Shared, { readonly value: string }>()("test/Shared") {}
  35. class Left extends Context.Service<Left, { readonly value: string }>()("test/Left") {}
  36. class Right extends Context.Service<Right, { readonly value: string }>()("test/Right") {}
  37. let acquisitions = 0
  38. const shared = node(
  39. Layer.effect(
  40. Shared,
  41. Effect.sync(() => {
  42. acquisitions++
  43. return Shared.of({ value: "shared" })
  44. }),
  45. ),
  46. [],
  47. )
  48. const left = node(
  49. Layer.effect(
  50. Left,
  51. Effect.gen(function* () {
  52. return Left.of({ value: `${(yield* Shared).value}-left` })
  53. }),
  54. ),
  55. [shared],
  56. )
  57. const right = node(
  58. Layer.effect(
  59. Right,
  60. Effect.gen(function* () {
  61. return Right.of({ value: `${(yield* Shared).value}-right` })
  62. }),
  63. ),
  64. [shared],
  65. )
  66. const result = Effect.gen(function* () {
  67. return [(yield* Left).value, (yield* Right).value]
  68. }).pipe(Effect.provide(build(group([left, right]))))
  69. expect(await Effect.runPromise(result)).toEqual(["shared-left", "shared-right"])
  70. expect(acquisitions).toBe(1)
  71. })
  72. test("applies a replacement to every transitive consumer", async () => {
  73. class Left extends Context.Service<Left, { readonly value: string }>()("test/ReplacementLeft") {}
  74. class Right extends Context.Service<Right, { readonly value: string }>()("test/ReplacementRight") {}
  75. const left = node(
  76. Layer.effect(
  77. Left,
  78. Effect.gen(function* () {
  79. return Left.of({ value: (yield* Value).value })
  80. }),
  81. ),
  82. [value],
  83. )
  84. const right = node(
  85. Layer.effect(
  86. Right,
  87. Effect.gen(function* () {
  88. return Right.of({ value: (yield* Value).value })
  89. }),
  90. ),
  91. [value],
  92. )
  93. const replacement = Layer.succeed(Value, Value.of({ value: "simulation" }))
  94. const graph = build(group([left, right]), { replacements: [replace(value, replacement)] })
  95. const result = Effect.gen(function* () {
  96. return [(yield* Left).value, (yield* Right).value]
  97. }).pipe(Effect.provide(graph))
  98. expect(await Effect.runPromise(result)).toEqual(["simulation", "simulation"])
  99. })
  100. test("propagates layer acquisition errors", async () => {
  101. class AcquisitionError {
  102. readonly _tag = "AcquisitionError"
  103. }
  104. const failing = node(Layer.effect(Value, Effect.fail(new AcquisitionError())), [])
  105. const exit = await Effect.runPromiseExit(Effect.provide(Value, build(failing)))
  106. expect(Exit.isFailure(exit)).toBe(true)
  107. if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(AcquisitionError)
  108. })
  109. test("groups expose every selected service", async () => {
  110. class Count extends Context.Service<Count, { readonly value: number }>()("test/Count") {}
  111. const count = node(Layer.succeed(Count, Count.of({ value: 3 })), [])
  112. const result = Effect.gen(function* () {
  113. return { text: (yield* Value).value, count: (yield* Count).value }
  114. }).pipe(Effect.provide(build(group([value, count]))))
  115. expect(await Effect.runPromise(result)).toEqual({ text: "production", count: 3 })
  116. })
  117. test("builds an empty group", async () => {
  118. expect(await Effect.runPromise(Effect.succeed("ok").pipe(Effect.provide(build(group([])))))).toBe("ok")
  119. })
  120. test("builds replacements with their own dependencies", async () => {
  121. class ReplacementConfig extends Context.Service<ReplacementConfig, { readonly value: string }>()(
  122. "test/ReplacementConfig",
  123. ) {}
  124. const replacementConfig = node(Layer.succeed(ReplacementConfig, ReplacementConfig.of({ value: "replacement" })), [])
  125. const replacement = node(
  126. Layer.effect(
  127. Value,
  128. Effect.gen(function* () {
  129. return Value.of({ value: (yield* ReplacementConfig).value })
  130. }),
  131. ),
  132. [replacementConfig],
  133. )
  134. const result = Effect.gen(function* () {
  135. return (yield* Greeting).text
  136. }).pipe(Effect.provide(build(greeting, { replacements: [replaceWithNode(value, replacement)] })))
  137. expect(await Effect.runPromise(result)).toBe("hello replacement")
  138. })
  139. test("does not acquire unreachable replacements", async () => {
  140. let acquisitions = 0
  141. const unreachable = node(Layer.succeed(Value, Value.of({ value: "unreachable" })), [])
  142. const replacement = Layer.effect(
  143. Value,
  144. Effect.sync(() => {
  145. acquisitions++
  146. return Value.of({ value: "replacement" })
  147. }),
  148. )
  149. await Effect.runPromise(
  150. Effect.provide(Greeting, build(greeting, { replacements: [replace(unreachable, replacement)] })),
  151. )
  152. expect(acquisitions).toBe(0)
  153. })
  154. test("rejects a direct cycle", () => {
  155. const cyclic = node(Layer.succeed(Value, Value.of({ value: "cyclic" })), [])
  156. ;(cyclic.dependencies as LayerNode.Node<unknown, unknown>[]).push(cyclic)
  157. expect(() => build(cyclic)).toThrow("Cycle detected in app graph: layer#1 -> layer#1")
  158. })
  159. test("rejects an indirect cycle", () => {
  160. const first = node(Layer.succeed(Value, Value.of({ value: "first" })), [])
  161. const second = node(Layer.succeed(Value, Value.of({ value: "second" })), [first])
  162. const third = node(Layer.succeed(Value, Value.of({ value: "third" })), [second])
  163. ;(first.dependencies as LayerNode.Node<unknown, unknown>[]).push(third)
  164. expect(() => build(first)).toThrow("Cycle detected in app graph: layer#1 -> layer#2 -> layer#3 -> layer#1")
  165. })
  166. test("rejects a cycle introduced by a replacement", () => {
  167. const replacement = node(Layer.succeed(Value, Value.of({ value: "replacement" })), [])
  168. const consumer = node(greetingImplementation, [value])
  169. ;(replacement.dependencies as LayerNode.Node<unknown, unknown>[]).push(consumer)
  170. expect(() => build(consumer, { replacements: [replaceWithNode(value, replacement)] })).toThrow(
  171. "Cycle detected in app graph: layer#1 -> layer#2 -> layer#1",
  172. )
  173. })
  174. })