@effect-uai/core 0.4.0 → 0.5.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.
- package/dist/{AiError-csR8Bhxx.d.mts → AiError-CAX_48RU.d.mts} +2 -2
- package/dist/{AiError-csR8Bhxx.d.mts.map → AiError-CAX_48RU.d.mts.map} +1 -1
- package/dist/{Image-DxyXqzAM.d.mts → Image-HNmMpMTh.d.mts} +4 -4
- package/dist/{Image-DxyXqzAM.d.mts.map → Image-HNmMpMTh.d.mts.map} +1 -1
- package/dist/{Items-Hg5AsYxl.d.mts → Items-DqbaJoz7.d.mts} +8 -8
- package/dist/{Items-Hg5AsYxl.d.mts.map → Items-DqbaJoz7.d.mts.map} +1 -1
- package/dist/{StructuredFormat-Cl41C56K.d.mts → StructuredFormat-BbN4dosH.d.mts} +11 -4
- package/dist/StructuredFormat-BbN4dosH.d.mts.map +1 -0
- package/dist/{Tool-B8B5qVEy.d.mts → Tool-Y0__Py1H.d.mts} +20 -4
- package/dist/Tool-Y0__Py1H.d.mts.map +1 -0
- package/dist/Turn-ChbL2foc.d.mts +388 -0
- package/dist/Turn-ChbL2foc.d.mts.map +1 -0
- package/dist/domain/AiError.d.mts +1 -1
- package/dist/domain/AiError.mjs +1 -1
- package/dist/domain/AiError.mjs.map +1 -1
- package/dist/domain/Image.d.mts +1 -1
- package/dist/domain/Items.d.mts +1 -1
- package/dist/domain/Items.mjs +1 -1
- package/dist/domain/Items.mjs.map +1 -1
- package/dist/domain/Turn.d.mts +2 -2
- package/dist/domain/Turn.mjs +22 -4
- package/dist/domain/Turn.mjs.map +1 -1
- package/dist/domain/Turn.test.d.mts +1 -0
- package/dist/domain/Turn.test.mjs +136 -0
- package/dist/domain/Turn.test.mjs.map +1 -0
- package/dist/embedding-model/Embedding.d.mts +15 -3
- package/dist/embedding-model/Embedding.d.mts.map +1 -1
- package/dist/embedding-model/Embedding.mjs.map +1 -1
- package/dist/embedding-model/EmbeddingModel.d.mts +33 -17
- package/dist/embedding-model/EmbeddingModel.d.mts.map +1 -1
- package/dist/embedding-model/EmbeddingModel.mjs.map +1 -1
- package/dist/embedding-model/EmbeddingModel.test.d.mts +1 -0
- package/dist/embedding-model/EmbeddingModel.test.mjs +59 -0
- package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -0
- package/dist/index.d.mts +6 -6
- package/dist/language-model/LanguageModel.d.mts +30 -8
- package/dist/language-model/LanguageModel.d.mts.map +1 -1
- package/dist/language-model/LanguageModel.mjs +33 -3
- package/dist/language-model/LanguageModel.mjs.map +1 -1
- package/dist/language-model/LanguageModel.test.d.mts +1 -0
- package/dist/language-model/LanguageModel.test.mjs +143 -0
- package/dist/language-model/LanguageModel.test.mjs.map +1 -0
- package/dist/loop/Loop.d.mts +94 -11
- package/dist/loop/Loop.d.mts.map +1 -1
- package/dist/loop/Loop.mjs +92 -26
- package/dist/loop/Loop.mjs.map +1 -1
- package/dist/loop/Loop.test.mjs +171 -3
- package/dist/loop/Loop.test.mjs.map +1 -1
- package/dist/music-generator/MusicGenerator.d.mts +1 -1
- package/dist/observability/Metrics.d.mts +1 -1
- package/dist/observability/Metrics.mjs +1 -1
- package/dist/observability/Metrics.mjs.map +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +1 -1
- package/dist/streaming/JSONL.d.mts +1 -1
- package/dist/streaming/JSONL.d.mts.map +1 -1
- package/dist/streaming/JSONL.mjs +7 -12
- package/dist/streaming/JSONL.mjs.map +1 -1
- package/dist/structured-format/StructuredFormat.d.mts +2 -2
- package/dist/structured-format/StructuredFormat.mjs +9 -1
- package/dist/structured-format/StructuredFormat.mjs.map +1 -1
- package/dist/structured-format/StructuredFormat.test.d.mts +1 -0
- package/dist/structured-format/StructuredFormat.test.mjs +70 -0
- package/dist/structured-format/StructuredFormat.test.mjs.map +1 -0
- package/dist/testing/MockMusicGenerator.d.mts.map +1 -1
- package/dist/testing/MockMusicGenerator.mjs +2 -2
- package/dist/testing/MockMusicGenerator.mjs.map +1 -1
- package/dist/testing/MockProvider.d.mts +23 -18
- package/dist/testing/MockProvider.d.mts.map +1 -1
- package/dist/testing/MockProvider.mjs +56 -72
- package/dist/testing/MockProvider.mjs.map +1 -1
- package/dist/testing/MockSpeechSynthesizer.d.mts.map +1 -1
- package/dist/testing/MockSpeechSynthesizer.mjs +2 -2
- package/dist/testing/MockSpeechSynthesizer.mjs.map +1 -1
- package/dist/testing/MockTranscriber.d.mts.map +1 -1
- package/dist/testing/MockTranscriber.mjs +2 -2
- package/dist/testing/MockTranscriber.mjs.map +1 -1
- package/dist/tool/HistoryCheck.d.mts +1 -1
- package/dist/tool/Outcome.d.mts +1 -1
- package/dist/tool/Resolvers.d.mts +65 -8
- package/dist/tool/Resolvers.d.mts.map +1 -1
- package/dist/tool/Resolvers.mjs +8 -12
- package/dist/tool/Resolvers.mjs.map +1 -1
- package/dist/tool/Resolvers.test.mjs +6 -5
- package/dist/tool/Resolvers.test.mjs.map +1 -1
- package/dist/tool/Tool.d.mts +2 -2
- package/dist/tool/Tool.mjs +18 -1
- package/dist/tool/Tool.mjs.map +1 -1
- package/dist/tool/Tool.test.d.mts +1 -0
- package/dist/tool/Tool.test.mjs +66 -0
- package/dist/tool/Tool.test.mjs.map +1 -0
- package/dist/tool/Toolkit.d.mts +4 -6
- package/dist/tool/Toolkit.d.mts.map +1 -1
- package/dist/tool/Toolkit.mjs +14 -43
- package/dist/tool/Toolkit.mjs.map +1 -1
- package/dist/transcriber/Transcriber.d.mts +1 -1
- package/package.json +1 -1
- package/src/domain/AiError.ts +1 -1
- package/src/domain/Items.ts +1 -1
- package/src/domain/Turn.test.ts +141 -0
- package/src/domain/Turn.ts +50 -43
- package/src/embedding-model/Embedding.ts +23 -0
- package/src/embedding-model/EmbeddingModel.test.ts +92 -0
- package/src/embedding-model/EmbeddingModel.ts +30 -20
- package/src/language-model/LanguageModel.test.ts +170 -0
- package/src/language-model/LanguageModel.ts +64 -1
- package/src/loop/Loop.test.ts +256 -3
- package/src/loop/Loop.ts +225 -49
- package/src/observability/Metrics.ts +1 -1
- package/src/streaming/JSONL.ts +9 -18
- package/src/structured-format/StructuredFormat.test.ts +105 -0
- package/src/structured-format/StructuredFormat.ts +14 -1
- package/src/testing/MockMusicGenerator.ts +4 -6
- package/src/testing/MockProvider.ts +126 -105
- package/src/testing/MockSpeechSynthesizer.ts +4 -6
- package/src/testing/MockTranscriber.ts +4 -6
- package/src/tool/Resolvers.test.ts +8 -5
- package/src/tool/Resolvers.ts +17 -19
- package/src/tool/Tool.test.ts +105 -0
- package/src/tool/Tool.ts +20 -0
- package/src/tool/Toolkit.ts +49 -50
- package/dist/StructuredFormat-Cl41C56K.d.mts.map +0 -1
- package/dist/Tool-B8B5qVEy.d.mts.map +0 -1
- package/dist/Turn-7geUcKsf.d.mts +0 -194
- 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
|
+
)
|
package/src/loop/Loop.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
})
|