@effect-uai/core 0.17.0 → 0.19.0-dev-20261109183959

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/README.md +5 -5
  2. package/dist/Content-D_Qfn8Ja.d.mts +48 -0
  3. package/dist/Content-D_Qfn8Ja.d.mts.map +1 -0
  4. package/dist/File-B923KvZ9.d.mts +46 -0
  5. package/dist/File-B923KvZ9.d.mts.map +1 -0
  6. package/dist/{Image-Cv6iBLTy.d.mts → Image-Bzc1H_UK.d.mts} +19 -8
  7. package/dist/Image-Bzc1H_UK.d.mts.map +1 -0
  8. package/dist/{Items-C7MyrYPX.d.mts → Items-Bvx8s9QH.d.mts} +109 -16
  9. package/dist/Items-Bvx8s9QH.d.mts.map +1 -0
  10. package/dist/{Loop-ChpaO-ya.d.mts → Loop-CeA0AelN.d.mts} +7 -6
  11. package/dist/Loop-CeA0AelN.d.mts.map +1 -0
  12. package/dist/Tool-Bkryw6-h.d.mts +206 -0
  13. package/dist/Tool-Bkryw6-h.d.mts.map +1 -0
  14. package/dist/ToolEvent-9inLx5cI.d.mts +22 -0
  15. package/dist/ToolEvent-9inLx5cI.d.mts.map +1 -0
  16. package/dist/ToolOutput-BNTIQgGB.d.mts +151 -0
  17. package/dist/ToolOutput-BNTIQgGB.d.mts.map +1 -0
  18. package/dist/Toolkit-D2VGeD5R.d.mts +212 -0
  19. package/dist/Toolkit-D2VGeD5R.d.mts.map +1 -0
  20. package/dist/browser/BrowserTool.d.mts +84 -22
  21. package/dist/browser/BrowserTool.d.mts.map +1 -1
  22. package/dist/browser/BrowserTool.mjs +5 -0
  23. package/dist/browser/BrowserTool.mjs.map +1 -1
  24. package/dist/capabilities/Capabilities.d.mts +1 -1
  25. package/dist/decision-model/Decision.test.mjs +4 -4
  26. package/dist/dist-B5K-y4Be.mjs +191 -0
  27. package/dist/dist-B5K-y4Be.mjs.map +1 -0
  28. package/dist/dist-CorQTQL-.mjs +1533 -0
  29. package/dist/dist-CorQTQL-.mjs.map +1 -0
  30. package/dist/dist-DNeuh84d.mjs +7 -0
  31. package/dist/dist-DNeuh84d.mjs.map +1 -0
  32. package/dist/dist-DzeSyeYF.mjs +10253 -0
  33. package/dist/dist-DzeSyeYF.mjs.map +1 -0
  34. package/dist/domain/File.d.mts +2 -0
  35. package/dist/domain/File.mjs +51 -0
  36. package/dist/domain/File.mjs.map +1 -0
  37. package/dist/domain/Image.d.mts +2 -2
  38. package/dist/domain/Image.mjs +12 -2
  39. package/dist/domain/Image.mjs.map +1 -1
  40. package/dist/domain/Items.d.mts +2 -2
  41. package/dist/domain/Items.mjs +65 -4
  42. package/dist/domain/Items.mjs.map +1 -1
  43. package/dist/domain/Realtime.d.mts +72 -490
  44. package/dist/domain/Realtime.d.mts.map +1 -1
  45. package/dist/domain/Research.d.mts +1 -1
  46. package/dist/domain/Transcript.test.mjs +3 -3
  47. package/dist/domain/Turn.d.mts +57 -262
  48. package/dist/domain/Turn.d.mts.map +1 -1
  49. package/dist/domain/Turn.mjs +1 -1
  50. package/dist/domain/Turn.test.mjs +2 -2
  51. package/dist/embedding-model/Embedding.d.mts +1 -1
  52. package/dist/embedding-model/EmbeddingModel.test.mjs +2 -2
  53. package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -1
  54. package/dist/image-generator/ImageGenerator.d.mts +10 -66
  55. package/dist/image-generator/ImageGenerator.d.mts.map +1 -1
  56. package/dist/index.C4tKZ0yW-LuFnBtmu.mjs +7042 -0
  57. package/dist/index.C4tKZ0yW-LuFnBtmu.mjs.map +1 -0
  58. package/dist/index.d.mts +10 -9
  59. package/dist/index.mjs +7 -6
  60. package/dist/language-model/LanguageModel.d.mts +2 -2
  61. package/dist/language-model/LanguageModel.test.mjs +2 -2
  62. package/dist/loop/Loop.d.mts +1 -1
  63. package/dist/loop/Loop.mjs.map +1 -1
  64. package/dist/loop/Loop.test.mjs +3 -3
  65. package/dist/math/Probability.test.mjs +2 -2
  66. package/dist/messenger/Messenger.d.mts +43 -289
  67. package/dist/messenger/Messenger.d.mts.map +1 -1
  68. package/dist/messenger/MessengerAdapter.test.mjs +3 -3
  69. package/dist/music-generator/MusicGenerator.test.mjs +3 -3
  70. package/dist/observability/Metrics.d.mts +1 -1
  71. package/dist/observability/Metrics.test.mjs +3 -3
  72. package/dist/observability/Telemetry.d.mts +1 -1
  73. package/dist/observability/Telemetry.mjs +1 -1
  74. package/dist/observability/Telemetry.mjs.map +1 -1
  75. package/dist/observability/Telemetry.test.mjs +4 -4
  76. package/dist/observability/Telemetry.test.mjs.map +1 -1
  77. package/dist/realtime/RealtimeSession.d.mts +1 -1
  78. package/dist/realtime/RealtimeSession.test.mjs +4 -4
  79. package/dist/retry/Retry.test.mjs +2 -2
  80. package/dist/sandbox/Sandbox.d.mts +32 -237
  81. package/dist/sandbox/Sandbox.d.mts.map +1 -1
  82. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +3 -3
  83. package/dist/streaming/JSONL.test.mjs +2 -2
  84. package/dist/streaming/Multipart.d.mts +1 -1
  85. package/dist/streaming/Multipart.mjs +3 -2
  86. package/dist/streaming/Multipart.mjs.map +1 -1
  87. package/dist/streaming/Multipart.test.mjs +28 -0
  88. package/dist/streaming/Multipart.test.mjs.map +1 -0
  89. package/dist/streaming/SSE.d.mts +1 -1
  90. package/dist/streaming/SSE.mjs +2 -2
  91. package/dist/streaming/SSE.mjs.map +1 -1
  92. package/dist/streaming/SSE.test.mjs +2 -2
  93. package/dist/streaming/Settle.test.mjs +3 -3
  94. package/dist/streaming/WebSocketSession.d.mts +5 -3
  95. package/dist/streaming/WebSocketSession.d.mts.map +1 -1
  96. package/dist/streaming/WebSocketSession.mjs +22 -10
  97. package/dist/streaming/WebSocketSession.mjs.map +1 -1
  98. package/dist/streaming/WebSocketSession.test.mjs +41 -5
  99. package/dist/streaming/WebSocketSession.test.mjs.map +1 -1
  100. package/dist/structured-format/StructuredFormat.mjs.map +1 -1
  101. package/dist/structured-format/StructuredFormat.test.mjs +2 -2
  102. package/dist/testing/FakeWebSocket.d.mts +5 -1
  103. package/dist/testing/FakeWebSocket.d.mts.map +1 -1
  104. package/dist/testing/FakeWebSocket.mjs +12 -6
  105. package/dist/testing/FakeWebSocket.mjs.map +1 -1
  106. package/dist/testing/MockMessenger.d.mts +19 -134
  107. package/dist/testing/MockMessenger.d.mts.map +1 -1
  108. package/dist/testing/MockProvider.d.mts +1 -1
  109. package/dist/testing/MockSandbox.d.mts +65 -446
  110. package/dist/testing/MockSandbox.d.mts.map +1 -1
  111. package/dist/tool/Content.d.mts +2 -0
  112. package/dist/tool/Content.mjs +46 -0
  113. package/dist/tool/Content.mjs.map +1 -0
  114. package/dist/tool/Content.test.d.mts +1 -0
  115. package/dist/tool/Content.test.mjs +48 -0
  116. package/dist/tool/Content.test.mjs.map +1 -0
  117. package/dist/tool/HistoryCheck.d.mts +6 -15
  118. package/dist/tool/HistoryCheck.d.mts.map +1 -1
  119. package/dist/tool/HistoryCheck.mjs +10 -24
  120. package/dist/tool/HistoryCheck.mjs.map +1 -1
  121. package/dist/tool/Tool.d.mts +2 -2
  122. package/dist/tool/Tool.mjs +55 -130
  123. package/dist/tool/Tool.mjs.map +1 -1
  124. package/dist/tool/Tool.test.mjs +222 -178
  125. package/dist/tool/Tool.test.mjs.map +1 -1
  126. package/dist/tool/ToolDecision.d.mts +164 -0
  127. package/dist/tool/ToolDecision.d.mts.map +1 -0
  128. package/dist/tool/ToolDecision.mjs +146 -0
  129. package/dist/tool/ToolDecision.mjs.map +1 -0
  130. package/dist/tool/ToolDecision.test.d.mts +1 -0
  131. package/dist/tool/ToolDecision.test.mjs +330 -0
  132. package/dist/tool/ToolDecision.test.mjs.map +1 -0
  133. package/dist/tool/ToolEvent.d.mts +2 -2
  134. package/dist/tool/ToolEvent.mjs +2 -34
  135. package/dist/tool/ToolEvent.mjs.map +1 -1
  136. package/dist/tool/ToolOutput.d.mts +2 -0
  137. package/dist/tool/ToolOutput.mjs +128 -0
  138. package/dist/tool/ToolOutput.mjs.map +1 -0
  139. package/dist/tool/ToolOutput.test.d.mts +1 -0
  140. package/dist/tool/ToolOutput.test.mjs +153 -0
  141. package/dist/tool/ToolOutput.test.mjs.map +1 -0
  142. package/dist/tool/Toolkit.d.mts +2 -2
  143. package/dist/tool/Toolkit.mjs +230 -165
  144. package/dist/tool/Toolkit.mjs.map +1 -1
  145. package/dist/tool/Toolkit.test.mjs +433 -391
  146. package/dist/tool/Toolkit.test.mjs.map +1 -1
  147. package/dist/transcriber/Transcriber.test.mjs +3 -3
  148. package/dist/web-read/WebReadTool.d.mts +2 -2
  149. package/dist/web-read/WebReadTool.mjs +1 -0
  150. package/dist/web-read/WebReadTool.mjs.map +1 -1
  151. package/dist/web-search/WebSearchTool.d.mts +2 -2
  152. package/dist/web-search/WebSearchTool.mjs +1 -0
  153. package/dist/web-search/WebSearchTool.mjs.map +1 -1
  154. package/package.json +18 -10
  155. package/src/browser/BrowserTool.ts +5 -0
  156. package/src/domain/File.ts +79 -0
  157. package/src/domain/Image.ts +14 -4
  158. package/src/domain/Items.ts +39 -3
  159. package/src/embedding-model/EmbeddingModel.test.ts +2 -2
  160. package/src/index.ts +3 -2
  161. package/src/loop/Loop.ts +25 -14
  162. package/src/observability/Telemetry.test.ts +1 -1
  163. package/src/observability/Telemetry.ts +2 -2
  164. package/src/streaming/Multipart.test.ts +26 -0
  165. package/src/streaming/Multipart.ts +6 -5
  166. package/src/streaming/SSE.ts +2 -2
  167. package/src/streaming/WebSocketSession.test.ts +54 -2
  168. package/src/streaming/WebSocketSession.ts +27 -6
  169. package/src/structured-format/StructuredFormat.ts +1 -1
  170. package/src/testing/FakeWebSocket.ts +21 -6
  171. package/src/tool/Content.test.ts +40 -0
  172. package/src/tool/Content.ts +38 -0
  173. package/src/tool/HistoryCheck.ts +21 -26
  174. package/src/tool/Tool.test.ts +240 -241
  175. package/src/tool/Tool.ts +204 -309
  176. package/src/tool/ToolDecision.test.ts +301 -0
  177. package/src/tool/ToolDecision.ts +360 -0
  178. package/src/tool/ToolEvent.ts +21 -41
  179. package/src/tool/ToolOutput.test.ts +132 -0
  180. package/src/tool/ToolOutput.ts +198 -0
  181. package/src/tool/Toolkit.test.ts +594 -454
  182. package/src/tool/Toolkit.ts +631 -356
  183. package/src/web-read/WebReadTool.ts +2 -1
  184. package/src/web-search/WebSearchTool.ts +2 -1
  185. package/dist/Image-Cv6iBLTy.d.mts.map +0 -1
  186. package/dist/Items-C7MyrYPX.d.mts.map +0 -1
  187. package/dist/Loop-ChpaO-ya.d.mts.map +0 -1
  188. package/dist/Tool-BF78QWtn.d.mts +0 -314
  189. package/dist/Tool-BF78QWtn.d.mts.map +0 -1
  190. package/dist/ToolEvent-Di4a723B.d.mts +0 -144
  191. package/dist/ToolEvent-Di4a723B.d.mts.map +0 -1
  192. package/dist/ToolResult-BsYQRjmW.d.mts +0 -152
  193. package/dist/ToolResult-BsYQRjmW.d.mts.map +0 -1
  194. package/dist/Toolkit-tlL0F2YR.d.mts +0 -193
  195. package/dist/Toolkit-tlL0F2YR.d.mts.map +0 -1
  196. package/dist/dist-BaenoU1A.mjs +0 -145
  197. package/dist/dist-BaenoU1A.mjs.map +0 -1
  198. package/dist/dist-nohcnkD6.mjs +0 -182
  199. package/dist/dist-nohcnkD6.mjs.map +0 -1
  200. package/dist/magic-string.es-CB2hOw9v.mjs +0 -1014
  201. package/dist/magic-string.es-CB2hOw9v.mjs.map +0 -1
  202. package/dist/test.DNmyFkvJ-YPzDR0Co.mjs +0 -10254
  203. package/dist/test.DNmyFkvJ-YPzDR0Co.mjs.map +0 -1
  204. package/dist/tool/Approval.d.mts +0 -113
  205. package/dist/tool/Approval.d.mts.map +0 -1
  206. package/dist/tool/Approval.mjs +0 -80
  207. package/dist/tool/Approval.mjs.map +0 -1
  208. package/dist/tool/Approval.test.mjs +0 -371
  209. package/dist/tool/Approval.test.mjs.map +0 -1
  210. package/dist/tool/ToolResult.d.mts +0 -2
  211. package/dist/tool/ToolResult.mjs +0 -85
  212. package/dist/tool/ToolResult.mjs.map +0 -1
  213. package/dist/utils-CiH_DjFW.mjs +0 -3918
  214. package/dist/utils-CiH_DjFW.mjs.map +0 -1
  215. package/src/tool/Approval.test.ts +0 -400
  216. package/src/tool/Approval.ts +0 -181
  217. package/src/tool/ToolResult.ts +0 -114
  218. /package/dist/{tool/Approval.test.d.mts → streaming/Multipart.test.d.mts} +0 -0
@@ -83,12 +83,12 @@ describe("embed / embedMany free exports preserve E in their return type", () =>
83
83
  // `JinaEmbedding` tag.
84
84
  // These calls exist only to assert the encoding is a compile error; they
85
85
  // are intentionally never run, so the floating-effect check is off here.
86
- // @effect-diagnostics effect/floatingEffect:off
86
+ // @effect-diagnostics floatingEffect:off
87
87
  // @ts-expect-error "multivector" is not an EmbedEncoding
88
88
  embedMany({ inputs: ["x"], model: "m", encoding: "multivector" })
89
89
  // @ts-expect-error "sparse" is not an EmbedEncoding
90
90
  embed({ input: "x", model: "m", encoding: "sparse" })
91
- // @effect-diagnostics effect/floatingEffect:error
91
+ // @effect-diagnostics floatingEffect:error
92
92
  })
93
93
 
94
94
  it("when the request is widened to CommonEmbedRequest, the response is the open union", () => {
package/src/index.ts CHANGED
@@ -46,11 +46,12 @@ export * as Settle from "./streaming/Settle.js"
46
46
  export * as WebSocketSession from "./streaming/WebSocketSession.js"
47
47
  export * as Loop from "./loop/Loop.js"
48
48
  export * as Retry from "./retry/Retry.js"
49
+ export * as Content from "./tool/Content.js"
49
50
  export * as Tool from "./tool/Tool.js"
50
51
  export * as Toolkit from "./tool/Toolkit.js"
51
- export * as ToolResult from "./tool/ToolResult.js"
52
52
  export * as ToolEvent from "./tool/ToolEvent.js"
53
- export * as Approval from "./tool/Approval.js"
53
+ export * as ToolOutput from "./tool/ToolOutput.js"
54
+ export * as ToolDecision from "./tool/ToolDecision.js"
54
55
  export * as HistoryCheck from "./tool/HistoryCheck.js"
55
56
  export * as JSONL from "./streaming/JSONL.js"
56
57
  export * as Lines from "./streaming/Lines.js"
package/src/loop/Loop.ts CHANGED
@@ -125,26 +125,27 @@ export const stop = <S = never>(state?: S): Stream.Stream<Step<never, S>> =>
125
125
  * Dual: data-first `onTurnComplete(deltas, then)` and data-last
126
126
  * `deltas.pipe(onTurnComplete(then))` both work.
127
127
  */
128
- type TurnContinuation<S, A, E2, R2> =
128
+ // The Effect and the stream it returns infer their errors separately.
129
+ type TurnContinuation<S, A, E2, R2, E3, R3> =
129
130
  | Stream.Stream<Step<A, S>, E2, R2>
130
- | Effect.Effect<Stream.Stream<Step<A, S>, E2, R2>, E2, R2>
131
+ | Effect.Effect<Stream.Stream<Step<A, S>, E2, R2>, E3, R3>
131
132
 
132
133
  export const onTurnComplete: {
133
- <S, A, E2 = never, R2 = never>(
134
- then: (turn: Turn) => TurnContinuation<S, A, E2, R2>,
134
+ <S, A, E2 = never, R2 = never, E3 = never, R3 = never>(
135
+ then: (turn: Turn) => TurnContinuation<S, A, E2, R2, E3, R3>,
135
136
  ): <E, R>(
136
137
  deltas: Stream.Stream<TurnEvent, E, R>,
137
- ) => Stream.Stream<Step<TurnEvent | A, S>, E | E2 | IncompleteTurn, R | R2>
138
- <S, A, E, R, E2 = never, R2 = never>(
138
+ ) => Stream.Stream<Step<TurnEvent | A, S>, E | E2 | E3 | IncompleteTurn, R | R2 | R3>
139
+ <S, A, E, R, E2 = never, R2 = never, E3 = never, R3 = never>(
139
140
  deltas: Stream.Stream<TurnEvent, E, R>,
140
- then: (turn: Turn) => TurnContinuation<S, A, E2, R2>,
141
- ): Stream.Stream<Step<TurnEvent | A, S>, E | E2 | IncompleteTurn, R | R2>
141
+ then: (turn: Turn) => TurnContinuation<S, A, E2, R2, E3, R3>,
142
+ ): Stream.Stream<Step<TurnEvent | A, S>, E | E2 | E3 | IncompleteTurn, R | R2 | R3>
142
143
  } = Function.dual(
143
144
  2,
144
- <S, A, E, R, E2, R2>(
145
+ <S, A, E, R, E2, R2, E3, R3>(
145
146
  deltas: Stream.Stream<TurnEvent, E, R>,
146
- then: (turn: Turn) => TurnContinuation<S, A, E2, R2>,
147
- ): Stream.Stream<Step<TurnEvent | A, S>, E | E2 | IncompleteTurn, R | R2> =>
147
+ then: (turn: Turn) => TurnContinuation<S, A, E2, R2, E3, R3>,
148
+ ): Stream.Stream<Step<TurnEvent | A, S>, E | E2 | E3 | IncompleteTurn, R | R2 | R3> =>
148
149
  Stream.unwrap(
149
150
  Effect.gen(function* () {
150
151
  const turnRef = yield* Ref.make<Option.Option<Turn>>(Option.none())
@@ -217,6 +218,11 @@ type LoopBody<S, A, E, R> = (
217
218
  state: S,
218
219
  ) => Stream.Stream<Step<A, S>, E, R> | Effect.Effect<Stream.Stream<Step<A, S>, E, R>, E, R>
219
220
 
221
+ // What callers pass: the Effect and its stream infer errors and requirements separately.
222
+ type LoopBodyIn<S, A, E, R, E2, R2> = (
223
+ state: S,
224
+ ) => Stream.Stream<Step<A, S>, E, R> | Effect.Effect<Stream.Stream<Step<A, S>, E, R>, E2, R2>
225
+
220
226
  /**
221
227
  * Drive a state-threaded loop body. Each iteration runs `body(state)` to get
222
228
  * a `Stream<Step<A, S>>`; values flow downstream, `next(s)` continues with
@@ -227,8 +233,13 @@ type LoopBody<S, A, E, R> = (
227
233
  * (or `pipe(initial, loop(body))`) both work.
228
234
  */
229
235
  export const loop: {
230
- <S, A, E, R>(body: LoopBody<S, A, E, R>): (initial: S) => Stream.Stream<A, E, R>
231
- <S, A, E, R>(initial: S, body: LoopBody<S, A, E, R>): Stream.Stream<A, E, R>
236
+ <S, A, E, R, E2 = never, R2 = never>(
237
+ body: LoopBodyIn<S, A, E, R, E2, R2>,
238
+ ): (initial: S) => Stream.Stream<A, E | E2, R | R2>
239
+ <S, A, E, R, E2 = never, R2 = never>(
240
+ initial: S,
241
+ body: LoopBodyIn<S, A, E, R, E2, R2>,
242
+ ): Stream.Stream<A, E | E2, R | R2>
232
243
  } = Function.dual(2, <S, A, E, R>(initial: S, body: LoopBody<S, A, E, R>): Stream.Stream<A, E, R> =>
233
244
  Stream.scoped(
234
245
  Stream.fromPull(
@@ -307,7 +318,7 @@ export const loop: {
307
318
 
308
319
  // `Stream.fromPull` expects the generator to RETURN the pull effect,
309
320
  // not run it; `return yield* pull` would execute a pull and break it.
310
- // @effect-diagnostics-next-line effect/returnEffectInGen:off
321
+ // @effect-diagnostics-next-line returnEffectInGen:off
311
322
  return pull
312
323
  }),
313
324
  ),
@@ -1,6 +1,6 @@
1
1
  import { describe, it } from "@effect/vitest"
2
2
  import { Array as Arr, Duration, Effect, Layer, Metric, Option, Ref, Stream } from "effect"
3
- import { type HttpBody, HttpClient, HttpClientResponse } from "effect/unstable/http"
3
+ import { type HttpBody, HttpClient, HttpClientResponse } from "effect/http"
4
4
  import { expect } from "vitest"
5
5
  import { type Measurement, makeEvent } from "./Metrics.js"
6
6
  import { layerOtlp, record } from "./Telemetry.js"
@@ -1,6 +1,6 @@
1
1
  import { Duration, Effect, Layer, Match, Metric, Stream } from "effect"
2
- import type { HttpClient } from "effect/unstable/http"
3
- import { OtlpMetrics, OtlpSerialization } from "effect/unstable/observability"
2
+ import type { HttpClient } from "effect/http"
3
+ import { OtlpMetrics, OtlpSerialization } from "effect/observability"
4
4
  import { isMetricEvent, type Measurement, type MetricEvent } from "./Metrics.js"
5
5
 
6
6
  // ---------------------------------------------------------------------------
@@ -0,0 +1,26 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import { Effect } from "effect"
3
+ import { HttpClientRequest } from "effect/http"
4
+ import { expect } from "vitest"
5
+ import { bodyMultipart } from "./Multipart.js"
6
+
7
+ describe("bodyMultipart", () => {
8
+ it.effect("sends the boundary the encoded parts are delimited by", () =>
9
+ Effect.gen(function* () {
10
+ const form = new FormData()
11
+ form.set("file", new Blob([new Uint8Array([1, 2, 3])], { type: "audio/ogg" }), "audio.ogg")
12
+ form.set("model", "whisper-1")
13
+
14
+ const request = HttpClientRequest.post("https://example.test/upload").pipe(
15
+ yield* bodyMultipart(form),
16
+ )
17
+ const contentType = request.headers["content-type"] ?? ""
18
+ const boundary = /boundary=(.+)$/.exec(contentType)?.[1]
19
+
20
+ expect(boundary).toBeDefined()
21
+ const body =
22
+ request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""
23
+ expect(body.startsWith(`--${boundary}\r\n`)).toBe(true)
24
+ }),
25
+ )
26
+ })
@@ -1,5 +1,5 @@
1
1
  import { Effect } from "effect"
2
- import { HttpClientRequest } from "effect/unstable/http"
2
+ import { HttpClientRequest } from "effect/http"
3
3
 
4
4
  /**
5
5
  * Multipart body for an `HttpClientRequest`, encoded to bytes.
@@ -21,9 +21,10 @@ export const bodyMultipart = (
21
21
  > =>
22
22
  Effect.gen(function* () {
23
23
  const encoded = new Response(form)
24
+ // Read before the body: once it is consumed, Bun answers `null` for this
25
+ // header (Node keeps it), which would drop the boundary and leave the
26
+ // server unable to parse the parts.
27
+ const contentType = encoded.headers.get("content-type") ?? "multipart/form-data"
24
28
  const buffer = yield* Effect.tryPromise(() => encoded.arrayBuffer())
25
- return HttpClientRequest.bodyUint8Array(
26
- new Uint8Array(buffer),
27
- encoded.headers.get("content-type") ?? "multipart/form-data",
28
- )
29
+ return HttpClientRequest.bodyUint8Array(new Uint8Array(buffer), contentType)
29
30
  })
@@ -1,5 +1,5 @@
1
1
  import { Stream } from "effect"
2
- import { Sse } from "effect/unstable/encoding"
2
+ import { Sse } from "effect/encoding"
3
3
 
4
4
  /**
5
5
  * One Server-Sent Event. Fields per the WHATWG spec:
@@ -35,7 +35,7 @@ const makeParserState = (): ParserState => {
35
35
 
36
36
  /**
37
37
  * Decode a `Stream<Uint8Array>` (e.g. an HTTP response body) into a
38
- * `Stream<SSE.Event>`, using the spec parser from `effect/unstable/encoding`.
38
+ * `Stream<SSE.Event>`, using the spec parser from `effect/encoding`.
39
39
  * An event is dispatched only once its terminating blank line arrives, so a
40
40
  * partial event at stream end (clean or failed) is discarded per the spec
41
41
  * rather than surfaced as a truncated frame.
@@ -1,8 +1,10 @@
1
1
  import { describe, it } from "@effect/vitest"
2
- import * as Socket from "effect/unstable/socket/Socket"
2
+ import { Effect, Exit, Pull, Queue, Schema } from "effect"
3
+ import * as Socket from "effect/socket/Socket"
3
4
  import { expect } from "vitest"
4
5
  import * as AiError from "../domain/AiError.js"
5
- import { toAiError } from "./WebSocketSession.js"
6
+ import * as FakeWebSocket from "../testing/FakeWebSocket.js"
7
+ import { open, toAiError } from "./WebSocketSession.js"
6
8
 
7
9
  const KEY = "AIzaSyEXAMPLEKEY"
8
10
  const URL = `wss://generativelanguage.googleapis.com/ws/BidiGenerateContent?key=${KEY}`
@@ -50,3 +52,53 @@ describe("WebSocketSession errors", () => {
50
52
  expect(ended._tag).toBe("Unavailable")
51
53
  })
52
54
  })
55
+
56
+ const Frame = Schema.Struct({ n: Schema.Number })
57
+
58
+ const session = (server: FakeWebSocket.FakeWebSocketServer) =>
59
+ open({ url: "wss://example.test", provider: "test", schema: Frame }).pipe(
60
+ Effect.provide(server.layer),
61
+ )
62
+
63
+ describe("WebSocketSession lifetime", () => {
64
+ it.live("writes a reply to a frame before the next one arrives", () =>
65
+ Effect.gen(function* () {
66
+ const server = yield* FakeWebSocket.make({ greeting: [{ n: 1 }] })
67
+ const { send, frames } = yield* session(server)
68
+
69
+ const first = yield* Queue.take(frames)
70
+ yield* send(JSON.stringify({ n: first.n + 1 }))
71
+ yield* Effect.sleep("5 millis")
72
+
73
+ expect(yield* server.sent).toEqual([{ n: 2 }])
74
+ }).pipe(Effect.scoped),
75
+ )
76
+
77
+ it.live("ends the frames on a clean close, and refuses a send after it", () =>
78
+ Effect.gen(function* () {
79
+ const server = yield* FakeWebSocket.make({ greeting: [{ n: 1 }] })
80
+ const { send, frames } = yield* session(server)
81
+
82
+ yield* Queue.take(frames)
83
+ yield* server.close(1000)
84
+ const end = yield* Effect.exit(Queue.take(frames))
85
+ const late = yield* Effect.exit(send(JSON.stringify({ n: 2 })))
86
+
87
+ expect(Exit.isFailure(end) && Pull.isDoneCause(end.cause)).toBe(true)
88
+ expect(Exit.isFailure(late) && JSON.stringify(late)).toContain("Unavailable")
89
+ }).pipe(Effect.scoped),
90
+ )
91
+
92
+ it.live("fails the frames on a dirty close", () =>
93
+ Effect.gen(function* () {
94
+ const server = yield* FakeWebSocket.make({ greeting: [{ n: 1 }] })
95
+ const { frames } = yield* session(server)
96
+
97
+ yield* Queue.take(frames)
98
+ yield* server.close(1011)
99
+ const end = yield* Effect.exit(Queue.take(frames))
100
+
101
+ expect(JSON.stringify(end)).toContain("Unavailable")
102
+ }).pipe(Effect.scoped),
103
+ )
104
+ })
@@ -14,10 +14,12 @@ import {
14
14
  Option,
15
15
  Predicate,
16
16
  Queue,
17
+ Ref,
17
18
  Schema,
18
19
  type Scope,
20
+ Stream,
19
21
  } from "effect"
20
- import * as Socket from "effect/unstable/socket/Socket"
22
+ import * as Socket from "effect/socket/Socket"
21
23
  import * as AiError from "../domain/AiError.js"
22
24
  import * as JSONL from "./JSONL.js"
23
25
 
@@ -85,6 +87,13 @@ export const toAiError =
85
87
  )
86
88
  }
87
89
 
90
+ /** Normal closure, going away, and no status code. */
91
+ const cleanCloseCodes: ReadonlySet<number> = new Set([1000, 1001, 1005])
92
+
93
+ /** Effect's `Socket` fails on every close, clean or not. */
94
+ export const isCleanClose = (error: Socket.SocketError): boolean =>
95
+ error.reason._tag === "SocketCloseError" && cleanCloseCodes.has(error.reason.code)
96
+
88
97
  /**
89
98
  * Connect for the lifetime of the surrounding `Scope`. The reader runs in a
90
99
  * forked fiber; a dirty close reaches the consumer as a failed queue rather
@@ -97,12 +106,14 @@ export const open = <A, I>(
97
106
  const asAiError = toAiError(options.provider)
98
107
  const decode = Schema.decodeUnknownEffect(options.schema)
99
108
  const socket = yield* Socket.makeWebSocket(options.url, {
100
- // Effect's Socket treats every close code as an error by default.
101
- closeCodeIsError: (code) => code !== 1000 && code !== 1001 && code !== 1005,
102
109
  ...(options.openTimeout !== undefined && { openTimeout: options.openTimeout }),
103
110
  })
104
111
  const frames = yield* Queue.bounded<A, AiError.AiError | Cause.Done>(options.capacity ?? 64)
105
- const write = yield* socket.writer
112
+ const { write } = yield* socket.writer
113
+ // The writer waits for the next connection rather than failing, so a send
114
+ // after the reader ends has to be refused here.
115
+ const ended = yield* Ref.make(false)
116
+ const closed = new AiError.Unavailable({ provider: options.provider, raw: "socket closed" })
106
117
 
107
118
  const onFrame = (raw: string) =>
108
119
  Effect.gen(function* () {
@@ -116,8 +127,13 @@ export const open = <A, I>(
116
127
  yield* Queue.offer(frames, decoded.value)
117
128
  })
118
129
 
119
- yield* socket.runString(onFrame).pipe(
130
+ yield* Stream.fromPull(Socket.readerString(socket)).pipe(
131
+ Stream.scoped,
132
+ Stream.catchIf(isCleanClose, () => Stream.empty),
133
+ Stream.runForEach(onFrame),
120
134
  Effect.mapError(asAiError),
135
+ // Set before `frames` ends, so a send after the last frame already fails.
136
+ Effect.ensuring(Ref.set(ended, true)),
121
137
  // `Queue.end` on a clean close so the consumer drains what is queued;
122
138
  // a failure must not arrive looking like the end of the conversation.
123
139
  Effect.matchCauseEffect({
@@ -128,5 +144,10 @@ export const open = <A, I>(
128
144
  Effect.forkScoped,
129
145
  )
130
146
 
131
- return { send: (frame: string) => write(frame).pipe(Effect.mapError(asAiError)), frames }
147
+ const send = (frame: string): Effect.Effect<void, AiError.AiError> =>
148
+ Effect.flatMap(Ref.get(ended), (isEnded) =>
149
+ isEnded ? Effect.fail(closed) : Effect.mapError(write(frame), asAiError),
150
+ )
151
+
152
+ return { send, frames }
132
153
  })
@@ -145,7 +145,7 @@ export const parseJson =
145
145
  // Validation runs through Standard Schema (Zod / Valibot / Effect), not
146
146
  // Effect Schema, so its JSON codecs don't apply here; and JSON.parse is
147
147
  // a separate step on purpose, to surface JsonParseError distinctly.
148
- // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
148
+ // @effect-diagnostics-next-linepreferSchemaOverJson:off
149
149
  try: () => JSON.parse(raw),
150
150
  catch: (cause) => new JsonParseError({ raw, cause }),
151
151
  }),
@@ -10,12 +10,16 @@
10
10
  * shim registers, since `Socket.makeWebSocket` drives it with plain callbacks.
11
11
  * The shim is a plain object, so nothing here touches a platform global.
12
12
  */
13
- import { Effect, Layer, Queue, Ref, type Scope, Stream } from "effect"
14
- import * as Socket from "effect/unstable/socket/Socket"
13
+ import { Deferred, Effect, Layer, Queue, type Scope, Stream, SubscriptionRef } from "effect"
14
+ import * as Socket from "effect/socket/Socket"
15
15
 
16
16
  export type FakeWebSocketServer = {
17
17
  /** Every frame the client has sent, JSON-parsed, oldest first. */
18
18
  readonly sent: Effect.Effect<ReadonlyArray<unknown>>
19
+ /** Wait until the client has sent at least `count` frames. */
20
+ readonly awaitSent: (count: number) => Effect.Effect<void>
21
+ /** Wait until the client has subscribed and the greeting has landed. */
22
+ readonly greeted: Effect.Effect<void>
19
23
  /** Deliver one frame to the client. */
20
24
  readonly push: (frame: unknown) => Effect.Effect<void>
21
25
  /** Close the socket. 1000, 1001 and 1005 are the clean codes. */
@@ -46,7 +50,8 @@ export const make = (
46
50
  ): Effect.Effect<FakeWebSocketServer, never, Scope.Scope> =>
47
51
  Effect.gen(function* () {
48
52
  const inbound = yield* Queue.unbounded<unknown>()
49
- const sent = yield* Ref.make<ReadonlyArray<unknown>>([])
53
+ const sent = yield* SubscriptionRef.make<ReadonlyArray<unknown>>([])
54
+ const greeted = yield* Deferred.make<void>()
50
55
 
51
56
  const wiring = {
52
57
  listeners: {} as Record<string, ReadonlyArray<Listener>>,
@@ -73,7 +78,7 @@ export const make = (
73
78
  yield* Stream.fromQueue(inbound).pipe(
74
79
  Stream.runForEach((frame) =>
75
80
  Effect.gen(function* () {
76
- yield* Ref.update(sent, (frames) => [...frames, frame])
81
+ yield* SubscriptionRef.update(sent, (frames) => [...frames, frame])
77
82
  const answer = yield* options?.reply?.(frame) ?? Effect.succeed([])
78
83
  yield* deliver(answer)
79
84
  }),
@@ -93,7 +98,10 @@ export const make = (
93
98
  // The greeting waits for a subscriber before it lands.
94
99
  if (type === "message" && !wiring.greeted) {
95
100
  wiring.greeted = true
96
- soon(() => deliverSync(options?.greeting ?? []))
101
+ soon(() => {
102
+ deliverSync(options?.greeting ?? [])
103
+ Deferred.doneUnsafe(greeted, Effect.void)
104
+ })
97
105
  }
98
106
  },
99
107
  removeEventListener: (type: string, fn: Listener): void => {
@@ -111,7 +119,14 @@ export const make = (
111
119
  socket as unknown as globalThis.WebSocket
112
120
 
113
121
  return {
114
- sent: Ref.get(sent),
122
+ sent: SubscriptionRef.get(sent),
123
+ awaitSent: (count) =>
124
+ SubscriptionRef.changes(sent).pipe(
125
+ Stream.filter((frames) => frames.length >= count),
126
+ Stream.take(1),
127
+ Stream.runDrain,
128
+ ),
129
+ greeted: Deferred.await(greeted),
115
130
  push: (frame) => deliver([frame]),
116
131
  close: (code = 1000) => Effect.sync(() => shutdown(code)),
117
132
  connect,
@@ -0,0 +1,40 @@
1
+ import { describe, expect, expectTypeOf, it } from "@effect/vitest"
2
+ import { Effect, Schema } from "effect"
3
+ import { imageBytes } from "../domain/Image.js"
4
+ import * as Content from "./Content.js"
5
+
6
+ const bytes = new Uint8Array([137, 80, 78, 71])
7
+ const encode = Schema.encodeUnknownEffect(Content.Value)
8
+ const decode = Schema.decodeUnknownEffect(Content.Value)
9
+
10
+ describe("Content.Value", () => {
11
+ it("encodes to JSON", () => {
12
+ expectTypeOf<typeof Content.Value.Encoded>().toExtend<Schema.Json>()
13
+ })
14
+
15
+ it.effect("round-trips image bytes through JSON as base64", () =>
16
+ Effect.gen(function* () {
17
+ const encoded = yield* encode(Content.image(imageBytes(bytes, "image/png")))
18
+ expect(encoded).toEqual({
19
+ _tag: "Image",
20
+ source: { _tag: "bytes", bytes: "iVBORw==", mimeType: "image/png" },
21
+ })
22
+
23
+ const decoded = yield* decode(JSON.parse(JSON.stringify(encoded)))
24
+ expect(decoded).toBeInstanceOf(Content.Image)
25
+ expect(decoded).toEqual(Content.image(imageBytes(bytes, "image/png")))
26
+ }),
27
+ )
28
+
29
+ it.effect("keeps plain objects plain and media as classes in a mixed array", () =>
30
+ Effect.gen(function* () {
31
+ const value = [
32
+ { title: "Home" },
33
+ Content.file({ _tag: "url", url: "https://x/a.pdf" }, "a.pdf"),
34
+ ]
35
+ const decoded = yield* decode(yield* encode(value))
36
+ expect(decoded).toEqual(value)
37
+ expect(Array.isArray(decoded) && decoded.map(Content.isMedia)).toEqual([false, true])
38
+ }),
39
+ )
40
+ })
@@ -0,0 +1,38 @@
1
+ /**
2
+ * What a tool's `run` may return without an `outputSchema`. Plain values stay
3
+ * plain (a string is text, anything else JSON); only media is marked, by class,
4
+ * so it is told apart from JSON by `instanceof`, never by shape.
5
+ */
6
+ import { Schema } from "effect"
7
+ import { type FileSource, FileSourceJson } from "../domain/File.js"
8
+ import { type ImageSource, ImageSourceJson } from "../domain/Image.js"
9
+
10
+ export class Image extends Schema.TaggedClass<Image>()("Image", { source: ImageSourceJson }) {}
11
+
12
+ export class File extends Schema.TaggedClass<File>()("File", {
13
+ source: FileSourceJson,
14
+ filename: Schema.optionalKey(Schema.String),
15
+ }) {}
16
+
17
+ export const Media = Schema.Union([Image, File])
18
+ export type Media = typeof Media.Type
19
+
20
+ export const image = (source: ImageSource): Image => new Image({ source })
21
+
22
+ export const file = (source: FileSource, filename?: string): File =>
23
+ new File(filename === undefined ? { source } : { source, filename })
24
+
25
+ export const isMedia = (u: unknown): u is Media => u instanceof Image || u instanceof File
26
+
27
+ /**
28
+ * Text, JSON, one medium, or a top-level array mixing JSON and media. Encodes to
29
+ * JSON (bytes as base64), so it also serves as the UI codec for tools without
30
+ * an `outputSchema`.
31
+ */
32
+ export const Value = Schema.Union([
33
+ Schema.String,
34
+ Media,
35
+ Schema.Array(Schema.Union([Media, Schema.Json])),
36
+ Schema.Json,
37
+ ])
38
+ export type Value = typeof Value.Type
@@ -1,23 +1,20 @@
1
1
  /**
2
- * History-consistency primitives. Useful even WITHOUT HITL.
3
- *
4
- * Every provider rejects a new request if any prior `function_call` lacks
5
- * a matching `function_call_output`. Multi-turn flows that can be
6
- * interrupted, restarted, or branched (HITL, mid-stream abort, persisted
7
- * checkpoints, stateless HTTP servers) need to detect orphans and
8
- * synthesize closing outputs before submitting.
9
- *
10
- * Recipe author calls these at known transition points (right before the
11
- * next provider request). Not invoked from inside the loop.
2
+ * History consistency. Providers reject a request when a `function_call` has no
3
+ * matching `function_call_output`; flows that can be interrupted, restarted or
4
+ * branched (approvals, aborts, persisted checkpoints) close orphans with these
5
+ * before the next model call.
12
6
  */
13
- import { type ToolCall, type HistoryItem, isToolCall, isToolCallOutput } from "../domain/Items.js"
14
- import { type ToolResult, cancelled } from "./ToolResult.js"
7
+ import { Effect, type Schema } from "effect"
8
+ import {
9
+ type HistoryItem,
10
+ isToolCall,
11
+ isToolCallOutput,
12
+ type ToolCall,
13
+ type ToolCallOutput,
14
+ } from "../domain/Items.js"
15
+ import * as ToolOutput from "./ToolOutput.js"
15
16
 
16
- /**
17
- * Return every `function_call` in `history` that does not have a matching
18
- * `function_call_output` later in `history` (correlated by `call_id`).
19
- * Empty result = history is provider-submittable from this invariant.
20
- */
17
+ /** Every `function_call` in `history` without a `function_call_output` (by `call_id`). */
21
18
  export const findUnansweredCalls = (
22
19
  history: ReadonlyArray<HistoryItem>,
23
20
  ): ReadonlyArray<ToolCall> => {
@@ -25,19 +22,17 @@ export const findUnansweredCalls = (
25
22
  return history.filter(isToolCall).filter((c) => !answered.has(c.call_id))
26
23
  }
27
24
 
28
- /** Cheap predicate: is this history submittable to a provider? */
29
25
  export const isReconciled = (history: ReadonlyArray<HistoryItem>): boolean =>
30
26
  findUnansweredCalls(history).length === 0
31
27
 
32
28
  /**
33
- * Synthesize cancellation results for every unanswered call. Caller maps
34
- * via `toToolCallOutput` and appends to history before submitting.
35
- *
36
- * Use when: a new user message arrives mid-approval; an approval timer
37
- * fires; a persisted checkpoint contains orphans (crash recovery); a
38
- * stateless HTTP server reconstructed history from a stale checkpoint.
29
+ * A `Cancelled` output for every unanswered call, to append before the next
30
+ * model call. For your own error, use `findUnansweredCalls` + `ToolOutput.failure`.
39
31
  */
40
32
  export const cancelAllPending = (
41
33
  history: ReadonlyArray<HistoryItem>,
42
- reason?: string,
43
- ): ReadonlyArray<ToolResult> => findUnansweredCalls(history).map((call) => cancelled(call, reason))
34
+ reason = "The call was cancelled before it ran.",
35
+ ): Effect.Effect<ReadonlyArray<ToolCallOutput>, Schema.SchemaError> =>
36
+ Effect.forEach(findUnansweredCalls(history), (call) =>
37
+ ToolOutput.failure(call, ToolOutput.Cancelled, new ToolOutput.Cancelled({ message: reason })),
38
+ )