@effect-uai/core 0.4.0 → 0.5.1

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 (124) hide show
  1. package/dist/{AiError-csR8Bhxx.d.mts → AiError-CAX_48RU.d.mts} +2 -2
  2. package/dist/{AiError-csR8Bhxx.d.mts.map → AiError-CAX_48RU.d.mts.map} +1 -1
  3. package/dist/{Image-DxyXqzAM.d.mts → Image-HNmMpMTh.d.mts} +4 -4
  4. package/dist/{Image-DxyXqzAM.d.mts.map → Image-HNmMpMTh.d.mts.map} +1 -1
  5. package/dist/{Items-Hg5AsYxl.d.mts → Items-BH8xUkoR.d.mts} +3 -3
  6. package/dist/{Items-Hg5AsYxl.d.mts.map → Items-BH8xUkoR.d.mts.map} +1 -1
  7. package/dist/{StructuredFormat-Cl41C56K.d.mts → StructuredFormat-BbN4dosH.d.mts} +11 -4
  8. package/dist/StructuredFormat-BbN4dosH.d.mts.map +1 -0
  9. package/dist/{Tool-B8B5qVEy.d.mts → Tool-87ViKCCO.d.mts} +20 -4
  10. package/dist/Tool-87ViKCCO.d.mts.map +1 -0
  11. package/dist/Turn-0CwCAyVe.d.mts +388 -0
  12. package/dist/Turn-0CwCAyVe.d.mts.map +1 -0
  13. package/dist/domain/AiError.d.mts +1 -1
  14. package/dist/domain/AiError.mjs +1 -1
  15. package/dist/domain/AiError.mjs.map +1 -1
  16. package/dist/domain/Image.d.mts +1 -1
  17. package/dist/domain/Items.d.mts +1 -1
  18. package/dist/domain/Items.mjs +1 -1
  19. package/dist/domain/Items.mjs.map +1 -1
  20. package/dist/domain/Turn.d.mts +2 -2
  21. package/dist/domain/Turn.mjs +22 -4
  22. package/dist/domain/Turn.mjs.map +1 -1
  23. package/dist/domain/Turn.test.d.mts +1 -0
  24. package/dist/domain/Turn.test.mjs +136 -0
  25. package/dist/domain/Turn.test.mjs.map +1 -0
  26. package/dist/embedding-model/Embedding.d.mts +15 -3
  27. package/dist/embedding-model/Embedding.d.mts.map +1 -1
  28. package/dist/embedding-model/Embedding.mjs.map +1 -1
  29. package/dist/embedding-model/EmbeddingModel.d.mts +33 -17
  30. package/dist/embedding-model/EmbeddingModel.d.mts.map +1 -1
  31. package/dist/embedding-model/EmbeddingModel.mjs.map +1 -1
  32. package/dist/embedding-model/EmbeddingModel.test.d.mts +1 -0
  33. package/dist/embedding-model/EmbeddingModel.test.mjs +59 -0
  34. package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -0
  35. package/dist/index.d.mts +6 -6
  36. package/dist/language-model/LanguageModel.d.mts +30 -8
  37. package/dist/language-model/LanguageModel.d.mts.map +1 -1
  38. package/dist/language-model/LanguageModel.mjs +33 -3
  39. package/dist/language-model/LanguageModel.mjs.map +1 -1
  40. package/dist/language-model/LanguageModel.test.d.mts +1 -0
  41. package/dist/language-model/LanguageModel.test.mjs +143 -0
  42. package/dist/language-model/LanguageModel.test.mjs.map +1 -0
  43. package/dist/loop/Loop.d.mts +94 -11
  44. package/dist/loop/Loop.d.mts.map +1 -1
  45. package/dist/loop/Loop.mjs +92 -26
  46. package/dist/loop/Loop.mjs.map +1 -1
  47. package/dist/loop/Loop.test.mjs +171 -3
  48. package/dist/loop/Loop.test.mjs.map +1 -1
  49. package/dist/music-generator/MusicGenerator.d.mts +1 -1
  50. package/dist/observability/Metrics.d.mts +1 -1
  51. package/dist/observability/Metrics.mjs +1 -1
  52. package/dist/observability/Metrics.mjs.map +1 -1
  53. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +1 -1
  54. package/dist/streaming/JSONL.d.mts +1 -1
  55. package/dist/streaming/JSONL.d.mts.map +1 -1
  56. package/dist/streaming/JSONL.mjs +7 -12
  57. package/dist/streaming/JSONL.mjs.map +1 -1
  58. package/dist/structured-format/StructuredFormat.d.mts +2 -2
  59. package/dist/structured-format/StructuredFormat.mjs +9 -1
  60. package/dist/structured-format/StructuredFormat.mjs.map +1 -1
  61. package/dist/structured-format/StructuredFormat.test.d.mts +1 -0
  62. package/dist/structured-format/StructuredFormat.test.mjs +70 -0
  63. package/dist/structured-format/StructuredFormat.test.mjs.map +1 -0
  64. package/dist/testing/MockMusicGenerator.d.mts.map +1 -1
  65. package/dist/testing/MockMusicGenerator.mjs +2 -2
  66. package/dist/testing/MockMusicGenerator.mjs.map +1 -1
  67. package/dist/testing/MockProvider.d.mts +23 -18
  68. package/dist/testing/MockProvider.d.mts.map +1 -1
  69. package/dist/testing/MockProvider.mjs +56 -72
  70. package/dist/testing/MockProvider.mjs.map +1 -1
  71. package/dist/testing/MockSpeechSynthesizer.d.mts.map +1 -1
  72. package/dist/testing/MockSpeechSynthesizer.mjs +2 -2
  73. package/dist/testing/MockSpeechSynthesizer.mjs.map +1 -1
  74. package/dist/testing/MockTranscriber.d.mts.map +1 -1
  75. package/dist/testing/MockTranscriber.mjs +2 -2
  76. package/dist/testing/MockTranscriber.mjs.map +1 -1
  77. package/dist/tool/HistoryCheck.d.mts +1 -1
  78. package/dist/tool/Outcome.d.mts +1 -1
  79. package/dist/tool/Resolvers.d.mts +65 -8
  80. package/dist/tool/Resolvers.d.mts.map +1 -1
  81. package/dist/tool/Resolvers.mjs +8 -12
  82. package/dist/tool/Resolvers.mjs.map +1 -1
  83. package/dist/tool/Resolvers.test.mjs +6 -5
  84. package/dist/tool/Resolvers.test.mjs.map +1 -1
  85. package/dist/tool/Tool.d.mts +2 -2
  86. package/dist/tool/Tool.mjs +18 -1
  87. package/dist/tool/Tool.mjs.map +1 -1
  88. package/dist/tool/Tool.test.d.mts +1 -0
  89. package/dist/tool/Tool.test.mjs +66 -0
  90. package/dist/tool/Tool.test.mjs.map +1 -0
  91. package/dist/tool/Toolkit.d.mts +4 -6
  92. package/dist/tool/Toolkit.d.mts.map +1 -1
  93. package/dist/tool/Toolkit.mjs +14 -43
  94. package/dist/tool/Toolkit.mjs.map +1 -1
  95. package/dist/transcriber/Transcriber.d.mts +1 -1
  96. package/package.json +1 -1
  97. package/src/domain/AiError.ts +1 -1
  98. package/src/domain/Items.ts +1 -1
  99. package/src/domain/Turn.test.ts +141 -0
  100. package/src/domain/Turn.ts +50 -43
  101. package/src/embedding-model/Embedding.ts +23 -0
  102. package/src/embedding-model/EmbeddingModel.test.ts +92 -0
  103. package/src/embedding-model/EmbeddingModel.ts +30 -20
  104. package/src/language-model/LanguageModel.test.ts +170 -0
  105. package/src/language-model/LanguageModel.ts +64 -1
  106. package/src/loop/Loop.test.ts +256 -3
  107. package/src/loop/Loop.ts +225 -49
  108. package/src/observability/Metrics.ts +1 -1
  109. package/src/streaming/JSONL.ts +9 -18
  110. package/src/structured-format/StructuredFormat.test.ts +105 -0
  111. package/src/structured-format/StructuredFormat.ts +14 -1
  112. package/src/testing/MockMusicGenerator.ts +4 -6
  113. package/src/testing/MockProvider.ts +126 -105
  114. package/src/testing/MockSpeechSynthesizer.ts +4 -6
  115. package/src/testing/MockTranscriber.ts +4 -6
  116. package/src/tool/Resolvers.test.ts +8 -5
  117. package/src/tool/Resolvers.ts +17 -19
  118. package/src/tool/Tool.test.ts +105 -0
  119. package/src/tool/Tool.ts +20 -0
  120. package/src/tool/Toolkit.ts +49 -50
  121. package/dist/StructuredFormat-Cl41C56K.d.mts.map +0 -1
  122. package/dist/Tool-B8B5qVEy.d.mts.map +0 -1
  123. package/dist/Turn-7geUcKsf.d.mts +0 -194
  124. package/dist/Turn-7geUcKsf.d.mts.map +0 -1
@@ -1,4 +1,4 @@
1
- import { Context, Effect, Stream } from "effect"
1
+ import { Array as Arr, Context, Data, Effect, Option, type Schedule, Stream } from "effect"
2
2
  import * as AiError from "../domain/AiError.js"
3
3
  import type { Item } from "../domain/Items.js"
4
4
  import type * as StructuredFormat from "../structured-format/StructuredFormat.js"
@@ -51,3 +51,66 @@ export const streamTurn = (
51
51
  request: CommonRequest,
52
52
  ): Stream.Stream<TurnEvent, AiError.AiError, LanguageModel> =>
53
53
  Stream.unwrap(Effect.map(LanguageModel.asEffect(), (m) => m.streamTurn(request)))
54
+
55
+ /**
56
+ * Drain `streamTurn` and return the assembled `Turn` from the terminal
57
+ * `TurnComplete` event. Fails with `IncompleteTurn` if the stream ends
58
+ * without one. Derived from `streamTurn`; providers get it for free.
59
+ */
60
+ export const turn = (
61
+ request: CommonRequest,
62
+ ): Effect.Effect<Turn, AiError.AiError | AiError.IncompleteTurn, LanguageModel> =>
63
+ streamTurn(request).pipe(
64
+ Stream.runCollect,
65
+ Effect.flatMap((events) =>
66
+ Arr.findLast(events, isTurnComplete).pipe(
67
+ Option.match({
68
+ onNone: () => Effect.fail(new AiError.IncompleteTurn({})),
69
+ onSome: (e) => Effect.succeed(e.turn),
70
+ }),
71
+ ),
72
+ ),
73
+ )
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // retry — retry the retryable subset of AiError, let other failures escape
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /** Internal wrapper around the retryable subset of `AiError`. */
80
+ export class Retryable extends Data.TaggedError("RetryableAi")<{
81
+ readonly cause: AiError.RateLimited | AiError.Unavailable | AiError.Timeout
82
+ }> {}
83
+
84
+ const isRetryable = (
85
+ e: AiError.AiError,
86
+ ): e is AiError.RateLimited | AiError.Unavailable | AiError.Timeout =>
87
+ e._tag === "RateLimited" || e._tag === "Unavailable" || e._tag === "Timeout"
88
+
89
+ // Lift events to Items, non-retryable failures to Terminal values (escape
90
+ // retry), retryable failures to wrapped errors (only thing retry sees).
91
+ type Lifted<A> =
92
+ | { readonly _tag: "Item"; readonly value: A }
93
+ | { readonly _tag: "Terminal"; readonly cause: AiError.AiError }
94
+
95
+ /**
96
+ * Retry a stream of `AiError` on the retryable subset
97
+ * (`RateLimited | Unavailable | Timeout`). Other failures bypass the
98
+ * schedule and propagate unchanged. Like all `Stream.retry`, the entire
99
+ * stream re-runs — deltas before the failure replay on the next attempt.
100
+ */
101
+ export const retry =
102
+ <Out>(schedule: Schedule.Schedule<Out, Retryable>) =>
103
+ <A, R>(stream: Stream.Stream<A, AiError.AiError, R>): Stream.Stream<A, AiError.AiError, R> =>
104
+ stream.pipe(
105
+ Stream.map((value): Lifted<A> => ({ _tag: "Item", value })),
106
+ Stream.catchIf(
107
+ isRetryable,
108
+ (cause) => Stream.fail(new Retryable({ cause })),
109
+ (cause) => Stream.succeed<Lifted<A>>({ _tag: "Terminal", cause }),
110
+ ),
111
+ Stream.retry(schedule),
112
+ Stream.catchTag("RetryableAi", (e) => Stream.fail<AiError.AiError>(e.cause)),
113
+ Stream.flatMap((item) =>
114
+ item._tag === "Item" ? Stream.succeed(item.value) : Stream.fail(item.cause),
115
+ ),
116
+ )
@@ -1,13 +1,19 @@
1
- import { Deferred, Effect, Fiber, Latch, Ref, Stream, SubscriptionRef } from "effect"
2
- import { describe, expect, it } from "vitest"
1
+ import { Deferred, Effect, Fiber, Latch, pipe, Ref, Stream, SubscriptionRef } from "effect"
2
+ import { describe, expect, expectTypeOf, it } from "vitest"
3
+ import * as AiError from "../domain/AiError.js"
4
+ import { TurnEvent } from "../domain/Turn.js"
3
5
  import {
4
6
  type Event,
5
7
  loop,
8
+ loopFrom,
6
9
  loopWithState,
7
10
  next,
8
11
  nextAfter,
9
- stopEvent,
12
+ onTurnComplete,
13
+ stop,
10
14
  stopAfter,
15
+ stopEvent,
16
+ stopWith,
11
17
  value,
12
18
  } from "./Loop.js"
13
19
 
@@ -101,6 +107,80 @@ describe("Loop.loop", () => {
101
107
  expect(result).toEqual([0, 1, 2])
102
108
  })
103
109
 
110
+ it("type: data-last (pipe) form preserves the body's E channel", () => {
111
+ // Regression for the prior inference bug: when used as
112
+ // `pipe(initial, loop(body))`, the body's E must propagate to the outer
113
+ // stream instead of collapsing to `never`. Generics live on the outer
114
+ // return of each overload, so neither calling form can erase them.
115
+ const result = pipe(
116
+ { count: 0 },
117
+ loop((_state) => Stream.fail(new AiError.RateLimited({ provider: "test", raw: null }))),
118
+ )
119
+ type E = typeof result extends Stream.Stream<unknown, infer X, unknown> ? X : never
120
+ expectTypeOf<E>().toEqualTypeOf<AiError.RateLimited>()
121
+ })
122
+
123
+ it("type: data-first form preserves the body's E channel", () => {
124
+ const result = loop({ count: 0 }, (_state) =>
125
+ Stream.fail(new AiError.RateLimited({ provider: "test", raw: null })),
126
+ )
127
+ type E = typeof result extends Stream.Stream<unknown, infer X, unknown> ? X : never
128
+ expectTypeOf<E>().toEqualTypeOf<AiError.RateLimited>()
129
+ })
130
+
131
+ it("type: onTurnComplete inside loop infers S and A from the handler without annotation", () => {
132
+ // Regression: when piped through loop, onTurnComplete's handler return
133
+ // type (Stream<Event<A, S>>) is the single source of truth for the loop
134
+ // body's element type. The previous workaround required explicit
135
+ // <S, A> at the call site. Now the loop's outer-return generics pull
136
+ // them through automatically.
137
+ type LoopState = { readonly turns: number }
138
+ type ToolEvent = { readonly _tag: "tool"; readonly name: string }
139
+
140
+ const result = pipe(
141
+ { turns: 0 } as LoopState,
142
+ loop((state) =>
143
+ Effect.gen(function* () {
144
+ const deltas: Stream.Stream<TurnEvent> = Stream.empty
145
+ return deltas.pipe(
146
+ onTurnComplete(() =>
147
+ Effect.sync(() =>
148
+ state.turns >= 1
149
+ ? stop
150
+ : nextAfter(Stream.succeed<ToolEvent>({ _tag: "tool", name: "x" }), {
151
+ turns: state.turns + 1,
152
+ }),
153
+ ),
154
+ ),
155
+ )
156
+ }),
157
+ ),
158
+ )
159
+
160
+ type Element = typeof result extends Stream.Stream<infer X, unknown, unknown> ? X : never
161
+ expectTypeOf<Element>().toEqualTypeOf<TurnEvent | ToolEvent>()
162
+ })
163
+
164
+ it("onTurnComplete: data-first form (Function.dual) works at runtime", async () => {
165
+ // Pin both calling forms: deltas.pipe(onTurnComplete(handler)) and
166
+ // onTurnComplete(deltas, handler). Same dispatch as loop's dual.
167
+ const turnComplete: TurnEvent = TurnEvent.TurnComplete({
168
+ turn: { items: [], usage: { input_tokens: 0, output_tokens: 0 }, stop_reason: "stop" },
169
+ })
170
+ const textDelta: TurnEvent = TurnEvent.TextDelta({ text: "hi" })
171
+ const deltas: Stream.Stream<TurnEvent> = Stream.fromIterable([textDelta, turnComplete])
172
+
173
+ const dataFirst = onTurnComplete(deltas, () => Effect.sync(() => stop))
174
+ const dataLast = deltas.pipe(onTurnComplete(() => Effect.sync(() => stop)))
175
+
176
+ const a = await Effect.runPromise(Stream.runCollect(dataFirst))
177
+ const b = await Effect.runPromise(Stream.runCollect(dataLast))
178
+
179
+ // Two value(delta) wraps + one stop sentinel from the handler.
180
+ expect(a.length).toBe(3)
181
+ expect(b.length).toBe(3)
182
+ })
183
+
104
184
  it("is stack-safe and linear-time across many iterations", async () => {
105
185
  // 100k iterations far exceeds V8's typical stack depth (~10–15k frames).
106
186
  const N = 100_000
@@ -522,3 +602,176 @@ describe("Loop.loopWithState", () => {
522
602
  expect(await Effect.runPromise(program)).toEqual([0, 0.5, 1, 1.5, 2, 2.5, 3])
523
603
  })
524
604
  })
605
+
606
+ describe("Loop.loopFrom", () => {
607
+ it("runs a multi-turn inner loop per input until the body emits stop", async () => {
608
+ // Per input: emit (input + turnsSoFar) twice, then stop. State counts
609
+ // total turns ACROSS inputs. Demonstrates that `next(s)` continues with
610
+ // the SAME input, multiple times per input — not one body call per item.
611
+ const result = await Effect.runPromise(
612
+ Stream.fromIterable(["a", "b"]).pipe(
613
+ loopFrom(0, (turns: number, input: string) => {
614
+ if (turns >= 2 * (input === "a" ? 1 : 2)) return Stream.fromIterable([stopEvent])
615
+ return Stream.fromIterable([value(`${input}:${turns}`), next(turns + 1)])
616
+ }),
617
+ Stream.runCollect,
618
+ ),
619
+ )
620
+
621
+ // input="a": turns 0,1 → emit "a:0","a:1"; turns=2 → stop. State threads.
622
+ // input="b": turns 2,3 → emit "b:2","b:3"; turns=4 → stop.
623
+ expect(result).toEqual(["a:0", "a:1", "b:2", "b:3"])
624
+ })
625
+
626
+ it("threads state across inputs (audio-pipeline shape)", async () => {
627
+ // History accumulates across inputs. Each input emits its joined view of
628
+ // history+input, then `stopWith` ends the inner loop AND carries the
629
+ // updated history to the next input.
630
+ const result = await Effect.runPromise(
631
+ Stream.fromIterable(["x", "y", "z"]).pipe(
632
+ loopFrom([] as ReadonlyArray<string>, (history: ReadonlyArray<string>, input: string) =>
633
+ Stream.fromIterable([
634
+ value([...history, input].join(",")),
635
+ stopWith([...history, input]),
636
+ ]),
637
+ ),
638
+ Stream.runCollect,
639
+ ),
640
+ )
641
+
642
+ expect(result).toEqual(["x", "x,y", "x,y,z"])
643
+ })
644
+
645
+ it("simulates a stream of documents with multi-turn tool calls per document", async () => {
646
+ // Document arrives → model "thinks" (one text turn) → calls a tool
647
+ // (one tool turn) → emits final text (one text turn) → done.
648
+ // Three turns per document, two documents.
649
+ type Turn =
650
+ | { readonly kind: "text"; readonly doc: string; readonly text: string }
651
+ | { readonly kind: "tool"; readonly doc: string; readonly tool: string }
652
+ type State = { readonly turn: number; readonly totalTurns: number }
653
+
654
+ const result = await Effect.runPromise(
655
+ Stream.fromIterable(["doc1", "doc2"]).pipe(
656
+ loopFrom({ turn: 0, totalTurns: 0 } as State, (state, doc: string) => {
657
+ // Each document runs three turns then stops.
658
+ if (state.turn === 0) {
659
+ return Stream.fromIterable([
660
+ value<Turn>({ kind: "text", doc, text: "thinking" }),
661
+ next({ turn: 1, totalTurns: state.totalTurns + 1 }),
662
+ ])
663
+ }
664
+ if (state.turn === 1) {
665
+ return Stream.fromIterable([
666
+ value<Turn>({ kind: "tool", doc, tool: "search" }),
667
+ next({ turn: 2, totalTurns: state.totalTurns + 1 }),
668
+ ])
669
+ }
670
+ // Final turn — `stopWith` emits the final value, advances state
671
+ // (reset turn to 0 for the next document, bump totalTurns), and
672
+ // ends this document's inner loop in one shot.
673
+ return Stream.fromIterable([
674
+ value<Turn>({ kind: "text", doc, text: "final" }),
675
+ stopWith({ turn: 0, totalTurns: state.totalTurns + 1 }),
676
+ ])
677
+ }),
678
+ Stream.runCollect,
679
+ ),
680
+ )
681
+
682
+ expect(result).toEqual([
683
+ { kind: "text", doc: "doc1", text: "thinking" },
684
+ { kind: "tool", doc: "doc1", tool: "search" },
685
+ { kind: "text", doc: "doc1", text: "final" },
686
+ { kind: "text", doc: "doc2", text: "thinking" },
687
+ { kind: "tool", doc: "doc2", tool: "search" },
688
+ { kind: "text", doc: "doc2", text: "final" },
689
+ ])
690
+ })
691
+
692
+ it("ends cleanly when the input stream ends mid-conversation", async () => {
693
+ // Single-input case: body advances via `next` then stops cleanly.
694
+ const result = await Effect.runPromise(
695
+ Stream.fromIterable(["only"]).pipe(
696
+ loopFrom(0, (turns: number, input: string) =>
697
+ turns >= 2
698
+ ? Stream.fromIterable([stopEvent])
699
+ : Stream.fromIterable([value(`${input}:${turns}`), next(turns + 1)]),
700
+ ),
701
+ Stream.runCollect,
702
+ ),
703
+ )
704
+
705
+ expect(result).toEqual(["only:0", "only:1"])
706
+ })
707
+
708
+ it("body's `stop` advances to the next input (does NOT halt the whole stream)", async () => {
709
+ // Three inputs, body always stops on its first emission. All three
710
+ // are processed — `stop` is per-input, not global. To halt the whole
711
+ // stream, end the INPUT stream upstream.
712
+ const result = await Effect.runPromise(
713
+ Stream.fromIterable([1, 2, 3]).pipe(
714
+ loopFrom(0, (_state: number, input: number) =>
715
+ Stream.fromIterable([value(input * 10), stopEvent]),
716
+ ),
717
+ Stream.runCollect,
718
+ ),
719
+ )
720
+
721
+ expect(result).toEqual([10, 20, 30])
722
+ })
723
+
724
+ it("data-first form (Function.dual) runs identically to data-last", async () => {
725
+ const inputs = Stream.fromIterable([1, 2])
726
+ const result = await Effect.runPromise(
727
+ Stream.runCollect(
728
+ loopFrom(inputs, 0, (state: number, input: number) =>
729
+ state >= input
730
+ ? Stream.fromIterable([stopEvent])
731
+ : Stream.fromIterable([value(state + input), next(state + 1)]),
732
+ ),
733
+ ),
734
+ )
735
+
736
+ // input=1: state=0 → emit 1, state→1; state=1≥1 → stop.
737
+ // input=2: state=1 → emit 3, state→2; state=2≥2 → stop.
738
+ expect(result).toEqual([1, 3])
739
+ })
740
+
741
+ it("supports Effect-returning bodies (parity with loop)", async () => {
742
+ const result = await Effect.runPromise(
743
+ Stream.fromIterable(["a"]).pipe(
744
+ loopFrom(0, (turns: number, input: string) =>
745
+ Effect.gen(function* () {
746
+ const cur = yield* Effect.succeed(turns)
747
+ if (cur >= 2) return Stream.fromIterable([stopEvent])
748
+ return Stream.fromIterable([value(`${input}:${cur}`), next(cur + 1)])
749
+ }),
750
+ ),
751
+ Stream.runCollect,
752
+ ),
753
+ )
754
+
755
+ expect(result).toEqual(["a:0", "a:1"])
756
+ })
757
+
758
+ it("type: data-last (pipe) form preserves the body's E channel", () => {
759
+ const result = pipe(
760
+ Stream.fromIterable([1]),
761
+ loopFrom(0, (_state: number, _input: number) =>
762
+ Stream.fail(new AiError.RateLimited({ provider: "test", raw: null })),
763
+ ),
764
+ )
765
+ type E = typeof result extends Stream.Stream<unknown, infer X, unknown> ? X : never
766
+ expectTypeOf<E>().toEqualTypeOf<AiError.RateLimited>()
767
+ })
768
+
769
+ it("type: data-first form preserves the body's E channel and unifies with input's E", () => {
770
+ const input: Stream.Stream<number, Error> = Stream.fail(new Error("boom"))
771
+ const result = loopFrom(input, 0, (_state: number, _i: number) =>
772
+ Stream.fail(new AiError.RateLimited({ provider: "test", raw: null })),
773
+ )
774
+ type E = typeof result extends Stream.Stream<unknown, infer X, unknown> ? X : never
775
+ expectTypeOf<E>().toEqualTypeOf<AiError.RateLimited | Error>()
776
+ })
777
+ })