@effect-uai/core 0.14.0 → 0.16.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 (108) 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/MessengerError-CeK6VAcu.d.mts +64 -0
  7. package/dist/MessengerError-CeK6VAcu.d.mts.map +1 -0
  8. package/dist/{Toolkit-DAjJ9Tvl.d.mts → Toolkit-tlL0F2YR.d.mts} +2 -2
  9. package/dist/{Toolkit-DAjJ9Tvl.d.mts.map → Toolkit-tlL0F2YR.d.mts.map} +1 -1
  10. package/dist/chunker/Chunker.d.mts +1 -1
  11. package/dist/domain/AiError.d.mts +2 -2
  12. package/dist/domain/AiError.mjs +9 -2
  13. package/dist/domain/AiError.mjs.map +1 -1
  14. package/dist/domain/Realtime.d.mts +616 -0
  15. package/dist/domain/Realtime.d.mts.map +1 -0
  16. package/dist/domain/Realtime.mjs +21 -0
  17. package/dist/domain/Realtime.mjs.map +1 -0
  18. package/dist/domain/Transcript.d.mts +17 -3
  19. package/dist/domain/Transcript.d.mts.map +1 -1
  20. package/dist/domain/Transcript.mjs +30 -1
  21. package/dist/domain/Transcript.mjs.map +1 -1
  22. package/dist/domain/Transcript.test.d.mts +1 -0
  23. package/dist/domain/Transcript.test.mjs +110 -0
  24. package/dist/domain/Transcript.test.mjs.map +1 -0
  25. package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
  26. package/dist/image-generator/ImageGenerator.d.mts +1 -1
  27. package/dist/index.d.mts +11 -4
  28. package/dist/index.mjs +9 -2
  29. package/dist/job/Job.d.mts +1 -1
  30. package/dist/language-model/LanguageModel.d.mts +2 -2
  31. package/dist/loop/Loop.d.mts +1 -1
  32. package/dist/messenger/Messenger.d.mts +472 -0
  33. package/dist/messenger/Messenger.d.mts.map +1 -0
  34. package/dist/messenger/Messenger.mjs +66 -0
  35. package/dist/messenger/Messenger.mjs.map +1 -0
  36. package/dist/messenger/MessengerAdapter.d.mts +35 -0
  37. package/dist/messenger/MessengerAdapter.d.mts.map +1 -0
  38. package/dist/messenger/MessengerAdapter.mjs +102 -0
  39. package/dist/messenger/MessengerAdapter.mjs.map +1 -0
  40. package/dist/messenger/MessengerAdapter.test.d.mts +1 -0
  41. package/dist/messenger/MessengerAdapter.test.mjs +138 -0
  42. package/dist/messenger/MessengerAdapter.test.mjs.map +1 -0
  43. package/dist/messenger/MessengerError.d.mts +2 -0
  44. package/dist/messenger/MessengerError.mjs +39 -0
  45. package/dist/messenger/MessengerError.mjs.map +1 -0
  46. package/dist/music-generator/MusicGenerator.d.mts +1 -1
  47. package/dist/realtime/RealtimeSession.d.mts +50 -0
  48. package/dist/realtime/RealtimeSession.d.mts.map +1 -0
  49. package/dist/realtime/RealtimeSession.mjs +45 -0
  50. package/dist/realtime/RealtimeSession.mjs.map +1 -0
  51. package/dist/realtime/RealtimeSession.test.d.mts +1 -0
  52. package/dist/realtime/RealtimeSession.test.mjs +95 -0
  53. package/dist/realtime/RealtimeSession.test.mjs.map +1 -0
  54. package/dist/reranker/Reranker.d.mts +1 -1
  55. package/dist/research/DeepResearch.d.mts +1 -1
  56. package/dist/retry/Retry.d.mts +1 -1
  57. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +1 -1
  58. package/dist/streaming/Settle.d.mts +45 -0
  59. package/dist/streaming/Settle.d.mts.map +1 -0
  60. package/dist/streaming/Settle.mjs +95 -0
  61. package/dist/streaming/Settle.mjs.map +1 -0
  62. package/dist/streaming/Settle.test.d.mts +1 -0
  63. package/dist/streaming/Settle.test.mjs +252 -0
  64. package/dist/streaming/Settle.test.mjs.map +1 -0
  65. package/dist/streaming/WebSocketSession.d.mts +36 -0
  66. package/dist/streaming/WebSocketSession.d.mts.map +1 -0
  67. package/dist/streaming/WebSocketSession.mjs +89 -0
  68. package/dist/streaming/WebSocketSession.mjs.map +1 -0
  69. package/dist/streaming/WebSocketSession.test.d.mts +1 -0
  70. package/dist/streaming/WebSocketSession.test.mjs +51 -0
  71. package/dist/streaming/WebSocketSession.test.mjs.map +1 -0
  72. package/dist/testing/FakeWebSocket.d.mts +25 -0
  73. package/dist/testing/FakeWebSocket.d.mts.map +1 -0
  74. package/dist/testing/FakeWebSocket.mjs +79 -0
  75. package/dist/testing/FakeWebSocket.mjs.map +1 -0
  76. package/dist/testing/MockMessenger.d.mts +192 -0
  77. package/dist/testing/MockMessenger.d.mts.map +1 -0
  78. package/dist/testing/MockMessenger.mjs +76 -0
  79. package/dist/testing/MockMessenger.mjs.map +1 -0
  80. package/dist/testing/MockRealtimeSession.d.mts +30 -0
  81. package/dist/testing/MockRealtimeSession.d.mts.map +1 -0
  82. package/dist/testing/MockRealtimeSession.mjs +52 -0
  83. package/dist/testing/MockRealtimeSession.mjs.map +1 -0
  84. package/dist/tool/Toolkit.d.mts +1 -1
  85. package/dist/transcriber/Transcriber.d.mts +1 -1
  86. package/dist/web-read/WebRead.d.mts +1 -1
  87. package/dist/web-read/WebReadTool.d.mts +1 -1
  88. package/dist/web-search/WebSearch.d.mts +1 -1
  89. package/dist/web-search/WebSearchTool.d.mts +1 -1
  90. package/package.json +41 -1
  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 -0
  96. package/src/messenger/Messenger.ts +293 -0
  97. package/src/messenger/MessengerAdapter.test.ts +179 -0
  98. package/src/messenger/MessengerAdapter.ts +189 -0
  99. package/src/messenger/MessengerError.ts +67 -0
  100. package/src/realtime/RealtimeSession.test.ts +100 -0
  101. package/src/realtime/RealtimeSession.ts +77 -0
  102. package/src/streaming/Settle.test.ts +344 -0
  103. package/src/streaming/Settle.ts +196 -0
  104. package/src/streaming/WebSocketSession.test.ts +52 -0
  105. package/src/streaming/WebSocketSession.ts +132 -0
  106. package/src/testing/FakeWebSocket.ts +120 -0
  107. package/src/testing/MockMessenger.ts +147 -0
  108. package/src/testing/MockRealtimeSession.ts +92 -0
@@ -0,0 +1,67 @@
1
+ import { Data, type Duration, Match } from "effect"
2
+
3
+ // `reason` is required wherever one exists: the platform always says
4
+ // something, and an adapter raising `Unsupported` knows why it did.
5
+
6
+ /** Transport never came up: bad token, rejected handshake. Raised at layer build. */
7
+ export class MessengerConnectFailed extends Data.TaggedError("MessengerConnectFailed")<{
8
+ provider: string
9
+ reason: string
10
+ raw: unknown
11
+ }> {}
12
+
13
+ /**
14
+ * Inbound transport ended for good (reconnect budget spent, fatal close).
15
+ * Routine reconnects stay inside the adapter and never surface here.
16
+ */
17
+ export class MessengerTransportClosed extends Data.TaggedError("MessengerTransportClosed")<{
18
+ provider: string
19
+ reason: string
20
+ raw?: unknown
21
+ }> {}
22
+
23
+ /** Which verb was rejected. */
24
+ export type MessengerOperation = "post" | "edit" | "react" | "typing"
25
+
26
+ /** An outbound verb was rejected: permission, unknown chat, text too long, no-op edit. */
27
+ export class MessengerRequestFailed extends Data.TaggedError("MessengerRequestFailed")<{
28
+ provider: string
29
+ operation: MessengerOperation
30
+ reason: string
31
+ raw: unknown
32
+ }> {}
33
+
34
+ /**
35
+ * Slow down, for this long. Typed rather than retried blindly so callers -
36
+ * `streamViaEdits` above all - decide whether waiting is still worth it.
37
+ */
38
+ export class MessengerRateLimited extends Data.TaggedError("MessengerRateLimited")<{
39
+ provider: string
40
+ retryAfter: Duration.Duration
41
+ raw?: unknown
42
+ }> {}
43
+
44
+ /** The platform cannot express this at all: off-set reaction emoji, edits on WhatsApp. */
45
+ export class MessengerUnsupported extends Data.TaggedError("MessengerUnsupported")<{
46
+ provider: string
47
+ capability: string
48
+ reason: string
49
+ }> {}
50
+
51
+ export type MessengerError =
52
+ | MessengerConnectFailed
53
+ | MessengerTransportClosed
54
+ | MessengerRequestFailed
55
+ | MessengerRateLimited
56
+ | MessengerUnsupported
57
+
58
+ /** Human-readable description for logs. Prose, not a contract; branch on `_tag`. */
59
+ export const describe: (e: MessengerError) => string = Match.type<MessengerError>().pipe(
60
+ Match.discriminatorsExhaustive("_tag")({
61
+ MessengerConnectFailed: (e) => `the messenger could not connect: ${e.reason}`,
62
+ MessengerTransportClosed: (e) => `the messenger connection closed: ${e.reason}`,
63
+ MessengerRequestFailed: (e) => `the ${e.operation} was rejected: ${e.reason}`,
64
+ MessengerRateLimited: () => "the messenger is rate limited",
65
+ MessengerUnsupported: (e) => `this messenger does not support ${e.capability}: ${e.reason}`,
66
+ }),
67
+ )
@@ -0,0 +1,100 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import { Effect, Stream } from "effect"
3
+ import { expect, expectTypeOf } from "vitest"
4
+ import type * as AiError from "../domain/AiError.js"
5
+ import { imageBase64 } from "../domain/Image.js"
6
+ import { type CommonSessionRequest, RealtimeEvent, RealtimeInput } from "../domain/Realtime.js"
7
+ import * as MockRealtimeSession from "../testing/MockRealtimeSession.js"
8
+ import { RealtimeVideoInput, open, sendVideoFrame, toolCalls } from "./RealtimeSession.js"
9
+
10
+ const pcm = { container: "raw", encoding: "pcm_s16le", sampleRate: 24000, channels: 1 } as const
11
+
12
+ const request: CommonSessionRequest = {
13
+ model: "gpt-realtime-2.1",
14
+ inputFormat: pcm,
15
+ outputFormat: pcm,
16
+ }
17
+
18
+ const call = {
19
+ type: "function_call",
20
+ call_id: "call_1",
21
+ name: "get_time",
22
+ arguments: "{}",
23
+ } as const
24
+
25
+ describe("RealtimeSession against a scripted session", () => {
26
+ it.effect("answers each input and ends `events` when the scope closes", () =>
27
+ Effect.gen(function* () {
28
+ const mock = MockRealtimeSession.layer({
29
+ initial: [RealtimeEvent.ResponseStarted({ responseId: "r1" })],
30
+ onInput: (input) =>
31
+ RealtimeInput.$is("Audio")(input)
32
+ ? [RealtimeEvent.AudioDelta({ responseId: "r1", bytes: new Uint8Array([7]) })]
33
+ : [RealtimeEvent.ResponseDone({ responseId: "r1", reason: "interrupted" })],
34
+ })
35
+
36
+ const handle = yield* Effect.scoped(
37
+ Effect.gen(function* () {
38
+ const handle = yield* open(request)
39
+ yield* handle.send(RealtimeInput.Audio({ bytes: new Uint8Array([1, 2]) }))
40
+ yield* handle.send(RealtimeInput.Interrupt())
41
+ return handle
42
+ }),
43
+ ).pipe(Effect.provide(mock.layer))
44
+
45
+ // The scope is closed, so the stream drains what is queued and ends
46
+ // rather than hanging.
47
+ const events = yield* Stream.runCollect(handle.events)
48
+ expect(events).toEqual([
49
+ RealtimeEvent.ResponseStarted({ responseId: "r1" }),
50
+ RealtimeEvent.AudioDelta({ responseId: "r1", bytes: new Uint8Array([7]) }),
51
+ RealtimeEvent.ResponseDone({ responseId: "r1", reason: "interrupted" }),
52
+ ])
53
+
54
+ const recorder = yield* mock.recorder
55
+ expect(recorder.openCalls.map((r) => r.model)).toEqual(["gpt-realtime-2.1"])
56
+ expect(recorder.sent.map((i) => i._tag)).toEqual(["Audio", "Interrupt"])
57
+ }),
58
+ )
59
+
60
+ it.effect("narrows `toolCalls` to the calls the model made", () =>
61
+ Effect.gen(function* () {
62
+ const mock = MockRealtimeSession.layer({
63
+ initial: [
64
+ RealtimeEvent.ResponseStarted({ responseId: "r1" }),
65
+ RealtimeEvent.ToolCall({ responseId: "r1", call }),
66
+ RealtimeEvent.OutputTranscriptDelta({ responseId: "r1", text: "one moment" }),
67
+ ],
68
+ })
69
+
70
+ const handle = yield* Effect.scoped(open(request)).pipe(Effect.provide(mock.layer))
71
+ const calls = yield* Stream.runCollect(toolCalls(handle.events))
72
+
73
+ expect(calls.map((e) => e.call.call_id)).toEqual(["call_1"])
74
+ }),
75
+ )
76
+ })
77
+
78
+ describe("RealtimeVideoInput marker", () => {
79
+ const frame = imageBase64("aW1n", "image/jpeg")
80
+
81
+ it("leaves the marker unsatisfied against an audio-only Layer", () => {
82
+ const mock = MockRealtimeSession.layerAudioOnly({})
83
+ const program = Effect.scoped(
84
+ Effect.flatMap(open(request), (handle) => sendVideoFrame(handle, frame)),
85
+ ).pipe(Effect.provide(mock.layer))
86
+
87
+ // `RealtimeSession` is provided, `RealtimeVideoInput` is not, so running
88
+ // this would be a type error.
89
+ expectTypeOf(program).toEqualTypeOf<Effect.Effect<void, AiError.AiError, RealtimeVideoInput>>()
90
+ })
91
+
92
+ it("clears R to never against a Layer that ships the marker", () => {
93
+ const mock = MockRealtimeSession.layer({})
94
+ const program = Effect.scoped(
95
+ Effect.flatMap(open(request), (handle) => sendVideoFrame(handle, frame)),
96
+ ).pipe(Effect.provide(mock.layer))
97
+
98
+ expectTypeOf(program).toEqualTypeOf<Effect.Effect<void, AiError.AiError, never>>()
99
+ })
100
+ })
@@ -0,0 +1,77 @@
1
+ /**
2
+ * A duplex speech-to-speech session. The server drives turn-taking, so this is
3
+ * its own primitive rather than a variant of `Loop`.
4
+ *
5
+ * The session is deliberately low level: tool execution, playback, barge-in
6
+ * bookkeeping and reconnection are the caller's, built from `send` and
7
+ * `events`. There is no agent wrapper, no hidden queue and no automatic
8
+ * reconnect here.
9
+ */
10
+ import { Context, Effect, type Scope, Stream } from "effect"
11
+ import type * as AiError from "../domain/AiError.js"
12
+ import type { ImageSource } from "../domain/Image.js"
13
+ import { type CommonSessionRequest, RealtimeInput, RealtimeEvent } from "../domain/Realtime.js"
14
+
15
+ /**
16
+ * The live connection. `events` ends when the socket closes cleanly; a close
17
+ * during a response ends it with `AiError.IncompleteTurn` instead, and never
18
+ * synthesizes a `ResponseDone`. `send` after close fails `Unavailable`.
19
+ */
20
+ export type RealtimeSessionHandle = {
21
+ readonly send: (input: RealtimeInput) => Effect.Effect<void, AiError.AiError>
22
+ readonly events: Stream.Stream<RealtimeEvent, AiError.AiError>
23
+ }
24
+
25
+ export type RealtimeSessionService = {
26
+ /**
27
+ * Connect and complete the provider handshake before succeeding. Closing the
28
+ * `Scope` closes the socket.
29
+ */
30
+ readonly open: (
31
+ request: CommonSessionRequest,
32
+ ) => Effect.Effect<RealtimeSessionHandle, AiError.AiError, Scope.Scope>
33
+ }
34
+
35
+ export class RealtimeSession extends Context.Service<RealtimeSession, RealtimeSessionService>()(
36
+ "@betalyra/effect-uai/RealtimeSession",
37
+ ) {}
38
+
39
+ /**
40
+ * Capability marker for sessions that accept video frames. Phantom: providers
41
+ * register with `Layer.succeed(RealtimeVideoInput, undefined)`. Only Gemini
42
+ * ships it, so `sendVideoFrame` against an OpenAI-only Layer is a type error.
43
+ */
44
+ export class RealtimeVideoInput extends Context.Service<RealtimeVideoInput, void>()(
45
+ "@betalyra/effect-uai/capability/RealtimeVideoInput",
46
+ ) {}
47
+
48
+ /** Open a session for the lifetime of the surrounding `Scope`. */
49
+ export const open = (
50
+ request: CommonSessionRequest,
51
+ ): Effect.Effect<RealtimeSessionHandle, AiError.AiError, RealtimeSession | Scope.Scope> =>
52
+ Effect.flatMap(RealtimeSession, (s) => s.open(request))
53
+
54
+ /**
55
+ * Push one video frame. Requires `RealtimeVideoInput`, so a provider without
56
+ * video fails at `Effect.provide` rather than at runtime.
57
+ */
58
+ export const sendVideoFrame = (
59
+ handle: RealtimeSessionHandle,
60
+ frame: ImageSource,
61
+ ): Effect.Effect<void, AiError.AiError, RealtimeVideoInput> =>
62
+ Effect.flatMap(RealtimeVideoInput, () => handle.send(RealtimeInput.VideoFrame({ frame })))
63
+
64
+ const isAudioDelta = RealtimeEvent.$is("AudioDelta")
65
+ const isToolCall = RealtimeEvent.$is("ToolCall")
66
+
67
+ /** Just the audio, for wiring straight to playback. */
68
+ export const audioDeltas = <E, R>(
69
+ events: Stream.Stream<RealtimeEvent, E, R>,
70
+ ): Stream.Stream<Extract<RealtimeEvent, { readonly _tag: "AudioDelta" }>, E, R> =>
71
+ Stream.filter(events, isAudioDelta)
72
+
73
+ /** Just the tool calls. Answer each with a `ToolResult`; the adapter resumes. */
74
+ export const toolCalls = <E, R>(
75
+ events: Stream.Stream<RealtimeEvent, E, R>,
76
+ ): Stream.Stream<Extract<RealtimeEvent, { readonly _tag: "ToolCall" }>, E, R> =>
77
+ Stream.filter(events, isToolCall)
@@ -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
+ })