@effect-uai/core 0.15.0 → 0.17.0

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 (114) hide show
  1. package/README.md +30 -16
  2. package/dist/{AiError-eLV9t1lU.d.mts → AiError-DvhLIbmd.d.mts} +15 -4
  3. package/dist/{AiError-eLV9t1lU.d.mts.map → AiError-DvhLIbmd.d.mts.map} +1 -1
  4. package/dist/{Loop-B54AB1ME.d.mts → Loop-ChpaO-ya.d.mts} +2 -2
  5. package/dist/{Loop-B54AB1ME.d.mts.map → Loop-ChpaO-ya.d.mts.map} +1 -1
  6. package/dist/{Toolkit-DAjJ9Tvl.d.mts → Toolkit-tlL0F2YR.d.mts} +2 -2
  7. package/dist/{Toolkit-DAjJ9Tvl.d.mts.map → Toolkit-tlL0F2YR.d.mts.map} +1 -1
  8. package/dist/chunker/Chunker.d.mts +1 -1
  9. package/dist/decision-model/Decision.d.mts +155 -0
  10. package/dist/decision-model/Decision.d.mts.map +1 -0
  11. package/dist/decision-model/Decision.mjs +118 -0
  12. package/dist/decision-model/Decision.mjs.map +1 -0
  13. package/dist/decision-model/Decision.test.mjs +164 -0
  14. package/dist/decision-model/Decision.test.mjs.map +1 -0
  15. package/dist/decision-model/DecisionModel.d.mts +45 -0
  16. package/dist/decision-model/DecisionModel.d.mts.map +1 -0
  17. package/dist/decision-model/DecisionModel.mjs +25 -0
  18. package/dist/decision-model/DecisionModel.mjs.map +1 -0
  19. package/dist/domain/AiError.d.mts +2 -2
  20. package/dist/domain/AiError.mjs +9 -2
  21. package/dist/domain/AiError.mjs.map +1 -1
  22. package/dist/domain/Realtime.d.mts +616 -0
  23. package/dist/domain/Realtime.d.mts.map +1 -0
  24. package/dist/domain/Realtime.mjs +21 -0
  25. package/dist/domain/Realtime.mjs.map +1 -0
  26. package/dist/domain/Transcript.d.mts +17 -3
  27. package/dist/domain/Transcript.d.mts.map +1 -1
  28. package/dist/domain/Transcript.mjs +30 -1
  29. package/dist/domain/Transcript.mjs.map +1 -1
  30. package/dist/domain/Transcript.test.d.mts +1 -0
  31. package/dist/domain/Transcript.test.mjs +110 -0
  32. package/dist/domain/Transcript.test.mjs.map +1 -0
  33. package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
  34. package/dist/image-generator/ImageGenerator.d.mts +1 -1
  35. package/dist/index.d.mts +11 -5
  36. package/dist/index.mjs +9 -3
  37. package/dist/job/Job.d.mts +1 -1
  38. package/dist/language-model/LanguageModel.d.mts +2 -2
  39. package/dist/loop/Loop.d.mts +1 -1
  40. package/dist/math/Probability.d.mts +21 -0
  41. package/dist/math/Probability.d.mts.map +1 -0
  42. package/dist/math/Probability.mjs +31 -0
  43. package/dist/math/Probability.mjs.map +1 -0
  44. package/dist/math/Probability.test.d.mts +1 -0
  45. package/dist/math/Probability.test.mjs +95 -0
  46. package/dist/math/Probability.test.mjs.map +1 -0
  47. package/dist/music-generator/MusicGenerator.d.mts +1 -1
  48. package/dist/realtime/RealtimeSession.d.mts +50 -0
  49. package/dist/realtime/RealtimeSession.d.mts.map +1 -0
  50. package/dist/realtime/RealtimeSession.mjs +45 -0
  51. package/dist/realtime/RealtimeSession.mjs.map +1 -0
  52. package/dist/realtime/RealtimeSession.test.d.mts +1 -0
  53. package/dist/realtime/RealtimeSession.test.mjs +95 -0
  54. package/dist/realtime/RealtimeSession.test.mjs.map +1 -0
  55. package/dist/reranker/Reranker.d.mts +1 -1
  56. package/dist/research/DeepResearch.d.mts +1 -1
  57. package/dist/retry/Retry.d.mts +1 -1
  58. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +1 -1
  59. package/dist/streaming/Settle.d.mts +45 -0
  60. package/dist/streaming/Settle.d.mts.map +1 -0
  61. package/dist/streaming/Settle.mjs +95 -0
  62. package/dist/streaming/Settle.mjs.map +1 -0
  63. package/dist/streaming/Settle.test.d.mts +1 -0
  64. package/dist/streaming/Settle.test.mjs +252 -0
  65. package/dist/streaming/Settle.test.mjs.map +1 -0
  66. package/dist/streaming/WebSocketSession.d.mts +36 -0
  67. package/dist/streaming/WebSocketSession.d.mts.map +1 -0
  68. package/dist/streaming/WebSocketSession.mjs +89 -0
  69. package/dist/streaming/WebSocketSession.mjs.map +1 -0
  70. package/dist/streaming/WebSocketSession.test.d.mts +1 -0
  71. package/dist/streaming/WebSocketSession.test.mjs +51 -0
  72. package/dist/streaming/WebSocketSession.test.mjs.map +1 -0
  73. package/dist/testing/FakeWebSocket.d.mts +25 -0
  74. package/dist/testing/FakeWebSocket.d.mts.map +1 -0
  75. package/dist/testing/FakeWebSocket.mjs +79 -0
  76. package/dist/testing/FakeWebSocket.mjs.map +1 -0
  77. package/dist/testing/MockRealtimeSession.d.mts +30 -0
  78. package/dist/testing/MockRealtimeSession.d.mts.map +1 -0
  79. package/dist/testing/MockRealtimeSession.mjs +52 -0
  80. package/dist/testing/MockRealtimeSession.mjs.map +1 -0
  81. package/dist/tool/Toolkit.d.mts +1 -1
  82. package/dist/transcriber/Transcriber.d.mts +1 -1
  83. package/dist/web-read/WebRead.d.mts +1 -1
  84. package/dist/web-read/WebReadTool.d.mts +1 -1
  85. package/dist/web-search/WebSearch.d.mts +1 -1
  86. package/dist/web-search/WebSearchTool.d.mts +1 -1
  87. package/package.json +36 -4
  88. package/src/decision-model/Decision.test.ts +146 -0
  89. package/src/decision-model/Decision.ts +273 -0
  90. package/src/decision-model/DecisionModel.ts +53 -0
  91. package/src/domain/AiError.ts +11 -0
  92. package/src/domain/Realtime.ts +101 -0
  93. package/src/domain/Transcript.test.ts +98 -0
  94. package/src/domain/Transcript.ts +53 -1
  95. package/src/index.ts +7 -1
  96. package/src/math/Probability.test.ts +61 -0
  97. package/src/math/Probability.ts +44 -0
  98. package/src/realtime/RealtimeSession.test.ts +100 -0
  99. package/src/realtime/RealtimeSession.ts +77 -0
  100. package/src/streaming/Settle.test.ts +344 -0
  101. package/src/streaming/Settle.ts +196 -0
  102. package/src/streaming/WebSocketSession.test.ts +52 -0
  103. package/src/streaming/WebSocketSession.ts +132 -0
  104. package/src/testing/FakeWebSocket.ts +120 -0
  105. package/src/testing/MockRealtimeSession.ts +92 -0
  106. package/dist/inbox/Inbox.d.mts +0 -16
  107. package/dist/inbox/Inbox.d.mts.map +0 -1
  108. package/dist/inbox/Inbox.mjs +0 -28
  109. package/dist/inbox/Inbox.mjs.map +0 -1
  110. package/dist/inbox/Inbox.test.mjs +0 -42
  111. package/dist/inbox/Inbox.test.mjs.map +0 -1
  112. package/src/inbox/Inbox.test.ts +0 -52
  113. package/src/inbox/Inbox.ts +0 -33
  114. /package/dist/{inbox/Inbox.test.d.mts → decision-model/Decision.test.d.mts} +0 -0
@@ -0,0 +1,344 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import { Cause, Effect, Fiber, Option, Queue, Ref, Stream } from "effect"
3
+ import { TestClock } from "effect/testing"
4
+ import { expect } from "vitest"
5
+ import { drainBurst, onQuiet, settleBurst } from "./Settle.js"
6
+
7
+ describe("drainBurst", () => {
8
+ it.effect("resets the window on every arrival, so a burst outlives one settle", () =>
9
+ Effect.gen(function* () {
10
+ const queue = yield* Queue.unbounded<string>()
11
+ const burst = yield* Effect.forkChild(drainBurst(queue, "1 second"))
12
+
13
+ yield* Queue.offer(queue, "a")
14
+ yield* TestClock.adjust("900 millis")
15
+ yield* Queue.offer(queue, "b")
16
+ yield* TestClock.adjust("900 millis")
17
+ // 1.8s elapsed, more than one settle, and the burst is still open.
18
+ expect(burst.pollUnsafe()).toBeUndefined()
19
+
20
+ yield* TestClock.adjust("1 second")
21
+ expect(yield* Fiber.join(burst)).toEqual(["a", "b"])
22
+ }),
23
+ )
24
+
25
+ it.effect("waits for the first item without a deadline", () =>
26
+ Effect.gen(function* () {
27
+ const queue = yield* Queue.unbounded<string>()
28
+ const burst = yield* Effect.forkChild(drainBurst(queue, "1 second"))
29
+
30
+ yield* TestClock.adjust("1 hour")
31
+ expect(burst.pollUnsafe()).toBeUndefined()
32
+
33
+ yield* Queue.offer(queue, "late")
34
+ yield* TestClock.adjust("1 second")
35
+ expect(yield* Fiber.join(burst)).toEqual(["late"])
36
+ }),
37
+ )
38
+
39
+ it.effect("ends on the first quiet gap and leaves later items for the next drain", () =>
40
+ Effect.gen(function* () {
41
+ const queue = yield* Queue.unbounded<string>()
42
+ const burst = yield* Effect.forkChild(drainBurst(queue, "1 second"))
43
+
44
+ yield* Queue.offer(queue, "first")
45
+ yield* TestClock.adjust("1 second")
46
+ expect(yield* Fiber.join(burst)).toEqual(["first"])
47
+
48
+ yield* Queue.offer(queue, "second")
49
+ expect(yield* Queue.poll(queue)).toEqual(Option.some("second"))
50
+ }),
51
+ )
52
+
53
+ it.effect("yields the batch in hand when the queue ends mid-burst", () =>
54
+ Effect.gen(function* () {
55
+ const queue = yield* Queue.unbounded<string, Cause.Done>()
56
+ const burst = yield* Effect.forkChild(drainBurst(queue, "1 second"))
57
+
58
+ yield* Queue.offer(queue, "a")
59
+ yield* TestClock.adjust("100 millis")
60
+ yield* Queue.end(queue)
61
+ yield* TestClock.adjust("10 millis")
62
+
63
+ expect(yield* Fiber.join(burst)).toEqual(["a"])
64
+ }),
65
+ )
66
+
67
+ it.effect("propagates a queue failure rather than treating it as the end", () =>
68
+ Effect.gen(function* () {
69
+ const queue = yield* Queue.unbounded<string, string | Cause.Done>()
70
+ const burst = yield* Effect.forkChild(drainBurst(queue, "1 second"))
71
+
72
+ yield* Queue.offer(queue, "a")
73
+ yield* TestClock.adjust("100 millis")
74
+ yield* Queue.fail(queue, "boom")
75
+ yield* TestClock.adjust("10 millis")
76
+
77
+ const exit = yield* Effect.exit(Fiber.join(burst))
78
+ expect(exit._tag).toBe("Failure")
79
+ expect(JSON.stringify(exit)).toContain("boom")
80
+ }),
81
+ )
82
+
83
+ it.effect("does not mistake an interrupt mid-burst for the end of the queue", () =>
84
+ Effect.gen(function* () {
85
+ const queue = yield* Queue.unbounded<string, Cause.Done>()
86
+ const burst = yield* Effect.forkChild(drainBurst(queue, "1 second"))
87
+
88
+ yield* Queue.offer(queue, "a")
89
+ yield* TestClock.adjust("100 millis")
90
+ yield* Fiber.interrupt(burst)
91
+
92
+ // Returning ["a"] here would mean interruption silently looked like a
93
+ // settled burst.
94
+ const exit = yield* Effect.exit(Fiber.join(burst))
95
+ expect(exit._tag).toBe("Failure")
96
+ }),
97
+ )
98
+ })
99
+
100
+ describe("settleBurst", () => {
101
+ const collecting = <A>(stream: Stream.Stream<A, never>) =>
102
+ Effect.gen(function* () {
103
+ const seen = yield* Ref.make<ReadonlyArray<A>>([])
104
+ const fiber = yield* Effect.forkChild(
105
+ Stream.runForEach(stream, (a) => Ref.update(seen, (xs) => [...xs, a])),
106
+ )
107
+ return { seen, fiber }
108
+ })
109
+
110
+ it.effect("holds a growing burst back, then emits it as one batch", () =>
111
+ Effect.gen(function* () {
112
+ const queue = yield* Queue.unbounded<string, Cause.Done>()
113
+ const { seen } = yield* collecting(Stream.fromQueue(queue).pipe(settleBurst("1 second")))
114
+
115
+ yield* Queue.offer(queue, "a")
116
+ yield* TestClock.adjust("900 millis")
117
+ yield* Queue.offer(queue, "b")
118
+ yield* TestClock.adjust("900 millis")
119
+ // Each arrival reset the window, so nothing has been emitted yet.
120
+ expect(yield* Ref.get(seen)).toEqual([])
121
+
122
+ yield* TestClock.adjust("1 second")
123
+ expect(yield* Ref.get(seen)).toEqual([["a", "b"]])
124
+ }),
125
+ )
126
+
127
+ it.effect("emits the in-flight batch when the source ends mid-burst", () =>
128
+ Effect.gen(function* () {
129
+ const queue = yield* Queue.unbounded<string, Cause.Done>()
130
+ const { seen } = yield* collecting(Stream.fromQueue(queue).pipe(settleBurst("1 second")))
131
+
132
+ yield* Queue.offer(queue, "only")
133
+ yield* TestClock.adjust("100 millis")
134
+ yield* Queue.end(queue)
135
+ yield* TestClock.adjust("10 millis")
136
+
137
+ expect(yield* Ref.get(seen)).toEqual([["only"]])
138
+ }),
139
+ )
140
+
141
+ it.effect("splits bursts on every silence, keeping order across a conversation", () =>
142
+ Effect.gen(function* () {
143
+ const queue = yield* Queue.unbounded<string, Cause.Done>()
144
+ const { seen } = yield* collecting(Stream.fromQueue(queue).pipe(settleBurst("1 second")))
145
+
146
+ // Three quick lines, a pause, two more, a pause, then one alone.
147
+ yield* Queue.offer(queue, "hey")
148
+ yield* TestClock.adjust("100 millis")
149
+ yield* Queue.offer(queue, "are you there")
150
+ yield* TestClock.adjust("100 millis")
151
+ yield* Queue.offer(queue, "?")
152
+ yield* TestClock.adjust("2 seconds")
153
+
154
+ yield* Queue.offer(queue, "actually")
155
+ yield* TestClock.adjust("100 millis")
156
+ yield* Queue.offer(queue, "never mind")
157
+ yield* TestClock.adjust("2 seconds")
158
+
159
+ yield* Queue.offer(queue, "bye")
160
+ yield* TestClock.adjust("2 seconds")
161
+
162
+ expect(yield* Ref.get(seen)).toEqual([
163
+ ["hey", "are you there", "?"],
164
+ ["actually", "never mind"],
165
+ ["bye"],
166
+ ])
167
+ }),
168
+ )
169
+
170
+ it.effect("fails the stream when the source fails instead of ending quietly", () =>
171
+ Effect.gen(function* () {
172
+ const source: Stream.Stream<string, string> = Stream.make("a").pipe(
173
+ Stream.concat(Stream.fail("boom")),
174
+ )
175
+ const exit = yield* Effect.exit(Stream.runCollect(source.pipe(settleBurst("1 second"))))
176
+
177
+ expect(exit._tag).toBe("Failure")
178
+ expect(JSON.stringify(exit)).toContain("boom")
179
+ }),
180
+ )
181
+
182
+ it.effect("stops pulling the source while the consumer is blocked", () =>
183
+ Effect.gen(function* () {
184
+ const pulled = yield* Ref.make(0)
185
+ const source = Stream.fromIterable(Array.from({ length: 50 }, (_, i) => i)).pipe(
186
+ Stream.rechunk(1),
187
+ Stream.tap(() => Ref.update(pulled, (n) => n + 1)),
188
+ )
189
+ yield* Effect.forkChild(
190
+ source.pipe(
191
+ settleBurst("1 second", { capacity: 2 }),
192
+ Stream.runForEach(() => Effect.never),
193
+ ),
194
+ )
195
+ yield* TestClock.adjust("10 seconds")
196
+
197
+ // An unbounded buffer would have drained all 50 into memory by now.
198
+ expect(yield* Ref.get(pulled)).toBeLessThan(50)
199
+ }),
200
+ )
201
+ })
202
+
203
+ describe("onQuiet", () => {
204
+ const mark = Effect.succeed(Option.some("<quiet>"))
205
+
206
+ const collecting = (stream: Stream.Stream<string, never>) =>
207
+ Effect.gen(function* () {
208
+ const seen = yield* Ref.make<ReadonlyArray<string>>([])
209
+ yield* Effect.forkChild(
210
+ Stream.runForEach(stream, (a) => Ref.update(seen, (xs) => [...xs, a])),
211
+ )
212
+ return seen
213
+ })
214
+
215
+ it.effect("passes elements downstream without waiting for the quiet period", () =>
216
+ Effect.gen(function* () {
217
+ const queue = yield* Queue.unbounded<string, Cause.Done>()
218
+ const seen = yield* collecting(Stream.fromQueue(queue).pipe(onQuiet("1 second", mark)))
219
+
220
+ yield* Queue.offer(queue, "a")
221
+ yield* TestClock.adjust("10 millis")
222
+ yield* Queue.offer(queue, "b")
223
+ yield* TestClock.adjust("10 millis")
224
+
225
+ // Both are downstream well inside the settle window, and no mark yet.
226
+ expect(yield* Ref.get(seen)).toEqual(["a", "b"])
227
+ }),
228
+ )
229
+
230
+ it.effect("emits once the source goes quiet, and not again until it speaks", () =>
231
+ Effect.gen(function* () {
232
+ const queue = yield* Queue.unbounded<string, Cause.Done>()
233
+ const seen = yield* collecting(Stream.fromQueue(queue).pipe(onQuiet("1 second", mark)))
234
+
235
+ yield* Queue.offer(queue, "a")
236
+ yield* TestClock.adjust("2 seconds")
237
+ expect(yield* Ref.get(seen)).toEqual(["a", "<quiet>"])
238
+
239
+ // Still silent: the mark does not repeat while nothing arrives.
240
+ yield* TestClock.adjust("10 seconds")
241
+ expect(yield* Ref.get(seen)).toEqual(["a", "<quiet>"])
242
+
243
+ // A new element re-arms it.
244
+ yield* Queue.offer(queue, "b")
245
+ yield* TestClock.adjust("2 seconds")
246
+ expect(yield* Ref.get(seen)).toEqual(["a", "<quiet>", "b", "<quiet>"])
247
+ }),
248
+ )
249
+
250
+ it.effect("marks an element that arrives in the same instant as the mark", () =>
251
+ Effect.gen(function* () {
252
+ const queue = yield* Queue.unbounded<string, Cause.Done>()
253
+ const seen = yield* collecting(Stream.fromQueue(queue).pipe(onQuiet("1 second", mark)))
254
+
255
+ yield* Queue.offer(queue, "a")
256
+ // The window expires exactly here, so "b" races the re-arm.
257
+ yield* TestClock.adjust("1 second")
258
+ yield* Queue.offer(queue, "b")
259
+ yield* TestClock.adjust("2 seconds")
260
+
261
+ // Losing that race would leave "b" unmarked until something else spoke.
262
+ expect(yield* Ref.get(seen)).toEqual(["a", "<quiet>", "b", "<quiet>"])
263
+ }),
264
+ )
265
+
266
+ it.effect("keeps the window open while elements keep arriving", () =>
267
+ Effect.gen(function* () {
268
+ const queue = yield* Queue.unbounded<string, Cause.Done>()
269
+ const seen = yield* collecting(Stream.fromQueue(queue).pipe(onQuiet("1 second", mark)))
270
+
271
+ yield* Queue.offer(queue, "a")
272
+ yield* TestClock.adjust("900 millis")
273
+ yield* Queue.offer(queue, "b")
274
+ yield* TestClock.adjust("900 millis")
275
+ // 1.8s elapsed, but never 1s of silence.
276
+ expect(yield* Ref.get(seen)).toEqual(["a", "b"])
277
+
278
+ yield* TestClock.adjust("2 seconds")
279
+ expect(yield* Ref.get(seen)).toEqual(["a", "b", "<quiet>"])
280
+ }),
281
+ )
282
+
283
+ it.effect("marks each utterance in a run of speech, pauses and more speech", () =>
284
+ Effect.gen(function* () {
285
+ const queue = yield* Queue.unbounded<string, Cause.Done>()
286
+ const seen = yield* collecting(Stream.fromQueue(queue).pipe(onQuiet("1 second", mark)))
287
+
288
+ // Deltas of one sentence, arriving faster than the window.
289
+ for (const token of ["what", " about", " Paris"]) {
290
+ yield* Queue.offer(queue, token)
291
+ yield* TestClock.adjust("200 millis")
292
+ }
293
+ expect(yield* Ref.get(seen)).toEqual(["what", " about", " Paris"])
294
+
295
+ // The speaker stops: one mark, and it does not repeat.
296
+ yield* TestClock.adjust("3 seconds")
297
+ // A second sentence re-arms and marks again.
298
+ for (const token of ["in", " winter"]) {
299
+ yield* Queue.offer(queue, token)
300
+ yield* TestClock.adjust("200 millis")
301
+ }
302
+ yield* TestClock.adjust("3 seconds")
303
+
304
+ expect(yield* Ref.get(seen)).toEqual([
305
+ "what",
306
+ " about",
307
+ " Paris",
308
+ "<quiet>",
309
+ "in",
310
+ " winter",
311
+ "<quiet>",
312
+ ])
313
+ }),
314
+ )
315
+
316
+ it.effect("emits nothing when there is nothing to emit, and waits for the next element", () =>
317
+ Effect.gen(function* () {
318
+ const queue = yield* Queue.unbounded<string, Cause.Done>()
319
+ const emitted = yield* Ref.make(0)
320
+ // Stands in for an accumulator that is already empty.
321
+ const emitIfSecond = Effect.map(
322
+ Ref.updateAndGet(emitted, (n) => n + 1),
323
+ (n) => (n === 1 ? Option.none<string>() : Option.some("<quiet>")),
324
+ )
325
+ const seen = yield* collecting(
326
+ Stream.fromQueue(queue).pipe(onQuiet("1 second", emitIfSecond)),
327
+ )
328
+
329
+ yield* Queue.offer(queue, "a")
330
+ yield* TestClock.adjust("3 seconds")
331
+ // First quiet period asked, got None, and stayed silent.
332
+ expect(yield* Ref.get(seen)).toEqual(["a"])
333
+ expect(yield* Ref.get(emitted)).toBe(1)
334
+
335
+ // It is disarmed, so silence alone does not ask again.
336
+ yield* TestClock.adjust("10 seconds")
337
+ expect(yield* Ref.get(emitted)).toBe(1)
338
+
339
+ yield* Queue.offer(queue, "b")
340
+ yield* TestClock.adjust("3 seconds")
341
+ expect(yield* Ref.get(seen)).toEqual(["a", "b", "<quiet>"])
342
+ }),
343
+ )
344
+ })
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Acting on a pause in a stream of arrivals.
3
+ *
4
+ * A chat user types three lines then stops; a transcriber emits token deltas
5
+ * then goes quiet. Both want a resetting window: every arrival pushes the
6
+ * deadline out. Effect's `groupedWithin` measures a fixed window from the first
7
+ * element of a batch and `debounce` drops all but the last element, so neither
8
+ * expresses this.
9
+ *
10
+ * `settleBurst` withholds elements until a burst ends; `onQuiet` holds nothing
11
+ * back and only adds an event once things go still.
12
+ */
13
+ import {
14
+ Array as Arr,
15
+ Cause,
16
+ Clock,
17
+ Deferred,
18
+ Duration,
19
+ Effect,
20
+ Function,
21
+ Option,
22
+ Pull,
23
+ Queue,
24
+ Ref,
25
+ Result,
26
+ Stream,
27
+ } from "effect"
28
+
29
+ /** Buffer between a source stream and the batcher. 64 matches the realtime adapters. */
30
+ const DEFAULT_CAPACITY = 64
31
+
32
+ /**
33
+ * Block for the first item, then keep taking while the next one lands within
34
+ * `settle` of the previous. The window resets on every arrival, so a burst
35
+ * becomes one batch and a lone message followed by silence returns at once.
36
+ *
37
+ * A take that loses to the window is interrupted before it removes anything,
38
+ * so late arrivals stay queued for the next drain. The queue is the caller's:
39
+ * it owns the backpressure strategy.
40
+ */
41
+ export const drainBurst = <A, E>(
42
+ queue: Queue.Dequeue<A, E>,
43
+ settle: Duration.Input,
44
+ ): Effect.Effect<Arr.NonEmptyReadonlyArray<A>, E> => {
45
+ const settled = (
46
+ batch: Arr.NonEmptyReadonlyArray<A>,
47
+ ): Effect.Effect<Arr.NonEmptyReadonlyArray<A>, E> =>
48
+ Queue.take(queue).pipe(
49
+ Effect.timeoutOption(settle),
50
+ Effect.flatMap(
51
+ Option.match({
52
+ onNone: () => Effect.succeed(batch),
53
+ onSome: (item) => settled(Arr.append(batch, item)),
54
+ }),
55
+ ),
56
+ // A queue that ends mid-burst yields the batch in hand. Interrupts and
57
+ // real failures still propagate.
58
+ Effect.catchCause((cause) =>
59
+ Pull.isDoneCause(cause) ? Effect.succeed(batch) : Effect.failCause(cause),
60
+ ),
61
+ )
62
+ return Effect.flatMap(Queue.take(queue), (first) => settled([first]))
63
+ }
64
+
65
+ export type SettleBurstOptions = {
66
+ /** Elements buffered ahead of the batcher. A full buffer suspends the source. */
67
+ readonly capacity?: number
68
+ }
69
+
70
+ /**
71
+ * `drainBurst` over a stream: one array per burst, nothing while a burst is
72
+ * still growing.
73
+ *
74
+ * Detecting a pause means reading ahead of the consumer, so this buffers by
75
+ * construction. The buffer is bounded, so a slow consumer suspends the source
76
+ * instead of growing memory.
77
+ */
78
+ export const settleBurst: {
79
+ (
80
+ settle: Duration.Input,
81
+ options?: SettleBurstOptions,
82
+ ): <A, E, R>(self: Stream.Stream<A, E, R>) => Stream.Stream<ReadonlyArray<A>, E, R>
83
+ <A, E, R>(
84
+ self: Stream.Stream<A, E, R>,
85
+ settle: Duration.Input,
86
+ options?: SettleBurstOptions,
87
+ ): Stream.Stream<ReadonlyArray<A>, E, R>
88
+ } = Function.dual(
89
+ (args) => Stream.isStream(args[0]),
90
+ <A, E, R>(
91
+ self: Stream.Stream<A, E, R>,
92
+ settle: Duration.Input,
93
+ options?: SettleBurstOptions,
94
+ ): Stream.Stream<ReadonlyArray<A>, E, R> =>
95
+ Stream.unwrap(
96
+ Effect.gen(function* () {
97
+ // `Cause.Done` as the failure type is what makes `Queue.end` available;
98
+ // `Queue.shutdown` would clear items still waiting to be batched.
99
+ const queue = yield* Queue.bounded<A, E | Cause.Done>(options?.capacity ?? DEFAULT_CAPACITY)
100
+ yield* Stream.runForEach(self, (a) => Queue.offer(queue, a)).pipe(
101
+ // A failing source must reach the consumer. Ending the queue instead
102
+ // would present the failure as a clean end of stream.
103
+ Effect.matchCauseEffect({
104
+ onFailure: (cause) =>
105
+ Cause.hasFails(cause) ? Queue.failCause(queue, cause) : Queue.end(queue),
106
+ onSuccess: () => Queue.end(queue),
107
+ }),
108
+ Effect.forkScoped,
109
+ )
110
+ return Stream.fromPull(
111
+ Effect.succeed(Effect.map(drainBurst(queue, settle), (batch) => [batch] as const)),
112
+ )
113
+ }),
114
+ ),
115
+ )
116
+
117
+ /**
118
+ * Pass every element through the moment it arrives, and emit whatever `emit`
119
+ * yields once nothing has arrived for `settle`. At most one emission per quiet
120
+ * period: the next element re-arms it.
121
+ *
122
+ * Nothing is held back, so this suits live output that wants a boundary marker
123
+ * rather than batching. Ends with the source.
124
+ */
125
+ export const onQuiet: {
126
+ <B>(
127
+ settle: Duration.Input,
128
+ emit: Effect.Effect<Option.Option<B>>,
129
+ ): <A, E, R>(self: Stream.Stream<A, E, R>) => Stream.Stream<A | B, E, R>
130
+ <A, E, R, B>(
131
+ self: Stream.Stream<A, E, R>,
132
+ settle: Duration.Input,
133
+ emit: Effect.Effect<Option.Option<B>>,
134
+ ): Stream.Stream<A | B, E, R>
135
+ } = Function.dual(
136
+ 3,
137
+ <A, E, R, B>(
138
+ self: Stream.Stream<A, E, R>,
139
+ settle: Duration.Input,
140
+ emit: Effect.Effect<Option.Option<B>>,
141
+ ): Stream.Stream<A | B, E, R> =>
142
+ Stream.unwrap(
143
+ Effect.gen(function* () {
144
+ const settleMs = Duration.toMillis(settle)
145
+ const lastAt = yield* Ref.make(0)
146
+ // Opened by the first arrival after each emission. An idle stream never
147
+ // wakes the metronome at all.
148
+ const gate = yield* Ref.make(yield* Deferred.make<void>())
149
+
150
+ const source = self.pipe(
151
+ Stream.tap(() =>
152
+ Effect.gen(function* () {
153
+ // Stamp before opening: the metronome re-reads the stamp after it
154
+ // swaps the gate, so an arrival that races the swap still counts.
155
+ const now = yield* Clock.currentTimeMillis
156
+ yield* Ref.set(lastAt, now)
157
+ const open = yield* Ref.get(gate)
158
+ yield* Deferred.succeed(open, undefined)
159
+ }),
160
+ ),
161
+ )
162
+
163
+ const quietFor = Effect.map(
164
+ Effect.all([Clock.currentTimeMillis, Ref.get(lastAt)]),
165
+ ([now, last]) => now - last,
166
+ )
167
+
168
+ // Waits for the gate, then sleeps out whatever is left of the window.
169
+ // Never polls, whether the source is busy or idle.
170
+ const tick = Effect.gen(function* () {
171
+ yield* Effect.flatMap(Ref.get(gate), Deferred.await)
172
+ const elapsed = yield* quietFor
173
+ if (elapsed < settleMs) {
174
+ yield* Effect.sleep(Duration.millis(settleMs - elapsed))
175
+ return Option.none<B>()
176
+ }
177
+ const next = yield* Deferred.make<void>()
178
+ yield* Ref.set(gate, next)
179
+ // An arrival between the check and the swap opened the old gate, so
180
+ // carry it over rather than sleeping through the burst it started.
181
+ if ((yield* quietFor) < settleMs) {
182
+ yield* Deferred.succeed(next, undefined)
183
+ return Option.none<B>()
184
+ }
185
+ return yield* emit
186
+ })
187
+
188
+ const metronome = Stream.fromEffect(tick).pipe(
189
+ Stream.forever,
190
+ Stream.filterMap((o) => (Option.isSome(o) ? Result.succeed(o.value) : Result.failVoid)),
191
+ )
192
+
193
+ return source.pipe(Stream.merge(metronome, { haltStrategy: "left" }))
194
+ }),
195
+ ),
196
+ )
@@ -0,0 +1,52 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import * as Socket from "effect/unstable/socket/Socket"
3
+ import { expect } from "vitest"
4
+ import * as AiError from "../domain/AiError.js"
5
+ import { toAiError } from "./WebSocketSession.js"
6
+
7
+ const KEY = "AIzaSyEXAMPLEKEY"
8
+ const URL = `wss://generativelanguage.googleapis.com/ws/BidiGenerateContent?key=${KEY}`
9
+
10
+ /**
11
+ * What Bun hands the `error` listener when the upgrade is rejected: the whole
12
+ * socket URL, in prose, on a prototype getter. Node and Deno say less, but the
13
+ * adapter cannot know which runtime it is on.
14
+ */
15
+ class BunErrorEvent {
16
+ get message(): string {
17
+ return `WebSocket connection to '${URL}' failed: Connection ended`
18
+ }
19
+ }
20
+
21
+ /** Deno names the status but not the URL, also on a prototype getter. */
22
+ class DenoErrorEvent {
23
+ constructor(private readonly detail: string) {}
24
+ get message(): string {
25
+ return `NetworkError: failed to connect to WebSocket: ${this.detail}`
26
+ }
27
+ }
28
+
29
+ const openFailure = (cause: unknown) =>
30
+ new Socket.SocketError({ reason: new Socket.SocketOpenError({ kind: "Unknown", cause }) })
31
+
32
+ describe("WebSocketSession errors", () => {
33
+ it("keeps the key out of the error a rejected upgrade produces", () => {
34
+ const error = toAiError("google")(openFailure(new BunErrorEvent()))
35
+
36
+ expect(JSON.stringify(error)).not.toContain(KEY)
37
+ expect(AiError.describe(error)).not.toContain(KEY)
38
+ // The host is still there: an error that says nothing is its own problem.
39
+ expect(JSON.stringify(error)).toContain("generativelanguage.googleapis.com")
40
+ })
41
+
42
+ it("reads the rejected status out of the prose, wherever the runtime put it", () => {
43
+ // Deno's wording. The status is the only hint a rejected upgrade gives.
44
+ const forbidden = toAiError("google")(
45
+ openFailure(new DenoErrorEvent("Invalid status code: 403")),
46
+ )
47
+ const ended = toAiError("google")(openFailure(new BunErrorEvent()))
48
+
49
+ expect(forbidden._tag).toBe("AuthFailed")
50
+ expect(ended._tag).toBe("Unavailable")
51
+ })
52
+ })