@effect-uai/core 0.1.0 → 0.3.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/README.md +2 -2
- package/dist/{AiError-CqmYjXyx.d.mts → AiError-CBuPHVKA.d.mts} +1 -1
- package/dist/{AiError-CqmYjXyx.d.mts.map → AiError-CBuPHVKA.d.mts.map} +1 -1
- package/dist/Image-BZmKfIdq.d.mts +61 -0
- package/dist/Image-BZmKfIdq.d.mts.map +1 -0
- package/dist/{Items-D1C2686t.d.mts → Items-CB8Bo3FI.d.mts} +132 -80
- package/dist/Items-CB8Bo3FI.d.mts.map +1 -0
- package/dist/Media-D_CpcM1Z.d.mts +57 -0
- package/dist/Media-D_CpcM1Z.d.mts.map +1 -0
- package/dist/{StructuredFormat-B5ueioNr.d.mts → StructuredFormat-BWq5Hd1O.d.mts} +5 -5
- package/dist/StructuredFormat-BWq5Hd1O.d.mts.map +1 -0
- package/dist/{Tool-5wxOCuOh.d.mts → Tool-DjVufH7i.d.mts} +13 -13
- package/dist/Tool-DjVufH7i.d.mts.map +1 -0
- package/dist/{Turn-rlTfuHaQ.d.mts → Turn-OPaILVIB.d.mts} +12 -29
- package/dist/Turn-OPaILVIB.d.mts.map +1 -0
- package/dist/{chunk-CfYAbeIz.mjs → chunk-uyGKjUfl.mjs} +2 -1
- package/dist/dist-DV5ISja1.mjs +13782 -0
- package/dist/dist-DV5ISja1.mjs.map +1 -0
- package/dist/domain/AiError.d.mts +1 -1
- package/dist/domain/AiError.mjs +1 -1
- package/dist/domain/Image.d.mts +2 -0
- package/dist/domain/Image.mjs +58 -0
- package/dist/domain/Image.mjs.map +1 -0
- package/dist/domain/Items.d.mts +2 -2
- package/dist/domain/Items.mjs +19 -42
- package/dist/domain/Items.mjs.map +1 -1
- package/dist/domain/Media.d.mts +2 -0
- package/dist/domain/Media.mjs +14 -0
- package/dist/domain/Media.mjs.map +1 -0
- package/dist/domain/Turn.d.mts +2 -2
- package/dist/domain/Turn.mjs +12 -8
- package/dist/domain/Turn.mjs.map +1 -1
- package/dist/embedding-model/Embedding.d.mts +107 -0
- package/dist/embedding-model/Embedding.d.mts.map +1 -0
- package/dist/embedding-model/Embedding.mjs +18 -0
- package/dist/embedding-model/Embedding.mjs.map +1 -0
- package/dist/embedding-model/EmbeddingModel.d.mts +97 -0
- package/dist/embedding-model/EmbeddingModel.d.mts.map +1 -0
- package/dist/embedding-model/EmbeddingModel.mjs +17 -0
- package/dist/embedding-model/EmbeddingModel.mjs.map +1 -0
- package/dist/index.d.mts +16 -8
- package/dist/index.mjs +10 -2
- package/dist/language-model/LanguageModel.d.mts +12 -20
- package/dist/language-model/LanguageModel.d.mts.map +1 -1
- package/dist/language-model/LanguageModel.mjs +3 -20
- package/dist/language-model/LanguageModel.mjs.map +1 -1
- package/dist/loop/Loop.d.mts +111 -2
- package/dist/loop/Loop.d.mts.map +1 -0
- package/dist/loop/Loop.mjs +39 -6
- package/dist/loop/Loop.mjs.map +1 -1
- package/dist/loop/Loop.test.d.mts +1 -0
- package/dist/loop/Loop.test.mjs +411 -0
- package/dist/loop/Loop.test.mjs.map +1 -0
- package/dist/magic-string.es-BgIV5Mu3.mjs +1013 -0
- package/dist/magic-string.es-BgIV5Mu3.mjs.map +1 -0
- package/dist/math/Vector.d.mts +47 -0
- package/dist/math/Vector.d.mts.map +1 -0
- package/dist/math/Vector.mjs +117 -0
- package/dist/math/Vector.mjs.map +1 -0
- package/dist/observability/Metrics.d.mts +2 -2
- package/dist/observability/Metrics.d.mts.map +1 -1
- package/dist/observability/Metrics.mjs +1 -1
- package/dist/observability/Metrics.mjs.map +1 -1
- package/dist/streaming/JSONL.mjs +1 -1
- package/dist/streaming/JSONL.test.d.mts +1 -0
- package/dist/streaming/JSONL.test.mjs +70 -0
- package/dist/streaming/JSONL.test.mjs.map +1 -0
- package/dist/streaming/Lines.mjs +1 -1
- package/dist/streaming/SSE.d.mts +2 -2
- package/dist/streaming/SSE.d.mts.map +1 -1
- package/dist/streaming/SSE.mjs +1 -1
- package/dist/streaming/SSE.mjs.map +1 -1
- package/dist/streaming/SSE.test.d.mts +1 -0
- package/dist/streaming/SSE.test.mjs +72 -0
- package/dist/streaming/SSE.test.mjs.map +1 -0
- package/dist/structured-format/StructuredFormat.d.mts +1 -1
- package/dist/structured-format/StructuredFormat.mjs +1 -1
- package/dist/structured-format/StructuredFormat.mjs.map +1 -1
- package/dist/testing/MockProvider.d.mts +6 -6
- package/dist/testing/MockProvider.d.mts.map +1 -1
- package/dist/testing/MockProvider.mjs.map +1 -1
- package/dist/tool/HistoryCheck.d.mts +6 -3
- package/dist/tool/HistoryCheck.d.mts.map +1 -1
- package/dist/tool/HistoryCheck.mjs +7 -1
- package/dist/tool/HistoryCheck.mjs.map +1 -1
- package/dist/tool/Outcome.d.mts +138 -2
- package/dist/tool/Outcome.d.mts.map +1 -0
- package/dist/tool/Outcome.mjs +34 -18
- package/dist/tool/Outcome.mjs.map +1 -1
- package/dist/tool/Resolvers.d.mts +30 -25
- package/dist/tool/Resolvers.d.mts.map +1 -1
- package/dist/tool/Resolvers.mjs +54 -44
- package/dist/tool/Resolvers.mjs.map +1 -1
- package/dist/tool/Resolvers.test.d.mts +1 -0
- package/dist/tool/Resolvers.test.mjs +317 -0
- package/dist/tool/Resolvers.test.mjs.map +1 -0
- package/dist/tool/Tool.d.mts +1 -1
- package/dist/tool/Tool.mjs +1 -1
- package/dist/tool/Tool.mjs.map +1 -1
- package/dist/tool/ToolEvent.d.mts +151 -2
- package/dist/tool/ToolEvent.d.mts.map +1 -0
- package/dist/tool/ToolEvent.mjs +30 -4
- package/dist/tool/ToolEvent.mjs.map +1 -1
- package/dist/tool/Toolkit.d.mts +24 -15
- package/dist/tool/Toolkit.d.mts.map +1 -1
- package/dist/tool/Toolkit.mjs +14 -13
- package/dist/tool/Toolkit.mjs.map +1 -1
- package/dist/tool/Toolkit.test.d.mts +1 -0
- package/dist/tool/Toolkit.test.mjs +113 -0
- package/dist/tool/Toolkit.test.mjs.map +1 -0
- package/package.json +29 -13
- package/src/domain/Image.ts +75 -0
- package/src/domain/Items.ts +18 -47
- package/src/domain/Media.ts +61 -0
- package/src/domain/Turn.ts +7 -17
- package/src/embedding-model/Embedding.ts +117 -0
- package/src/embedding-model/EmbeddingModel.ts +107 -0
- package/src/index.ts +9 -1
- package/src/language-model/LanguageModel.ts +2 -22
- package/src/loop/Loop.test.ts +114 -2
- package/src/loop/Loop.ts +69 -5
- package/src/math/Vector.ts +138 -0
- package/src/observability/Metrics.ts +1 -1
- package/src/streaming/SSE.ts +1 -1
- package/src/structured-format/StructuredFormat.ts +2 -2
- package/src/testing/MockProvider.ts +2 -2
- package/src/tool/HistoryCheck.ts +2 -5
- package/src/tool/Outcome.ts +39 -53
- package/src/tool/Resolvers.test.ts +46 -117
- package/src/tool/Resolvers.ts +74 -102
- package/src/tool/Tool.ts +9 -9
- package/src/tool/ToolEvent.ts +30 -26
- package/src/tool/Toolkit.test.ts +97 -2
- package/src/tool/Toolkit.ts +65 -67
- package/dist/Items-D1C2686t.d.mts.map +0 -1
- package/dist/Loop-CzSJo1h8.d.mts +0 -87
- package/dist/Loop-CzSJo1h8.d.mts.map +0 -1
- package/dist/Outcome-C2JYknCu.d.mts +0 -40
- package/dist/Outcome-C2JYknCu.d.mts.map +0 -1
- package/dist/StructuredFormat-B5ueioNr.d.mts.map +0 -1
- package/dist/Tool-5wxOCuOh.d.mts.map +0 -1
- package/dist/ToolEvent-B2N10hr3.d.mts +0 -29
- package/dist/ToolEvent-B2N10hr3.d.mts.map +0 -1
- package/dist/Turn-rlTfuHaQ.d.mts.map +0 -1
- package/dist/match/Match.d.mts +0 -16
- package/dist/match/Match.d.mts.map +0 -1
- package/dist/match/Match.mjs +0 -15
- package/dist/match/Match.mjs.map +0 -1
- package/src/match/Match.ts +0 -9
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Schema } from "effect"
|
|
2
|
+
import type { MediaBase64, MediaBytes, MediaSource, MediaUrl } from "./Media.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Image MIME types AI providers typically accept. The first four are the
|
|
6
|
+
* universal subset (Cohere v4, Voyage multimodal, Jina v4, Google
|
|
7
|
+
* `gemini-embedding-2`); HEIC / HEIF are Google-specific. The
|
|
8
|
+
* `(string & {})` tail keeps autocomplete on the literals while still
|
|
9
|
+
* accepting any string, so a newly-supported format works without an
|
|
10
|
+
* SDK update.
|
|
11
|
+
*/
|
|
12
|
+
export type ImageMimeType =
|
|
13
|
+
| "image/png"
|
|
14
|
+
| "image/jpeg"
|
|
15
|
+
| "image/webp"
|
|
16
|
+
| "image/gif"
|
|
17
|
+
| "image/heic"
|
|
18
|
+
| "image/heif"
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
20
|
+
| (string & {})
|
|
21
|
+
|
|
22
|
+
const ImageMimeTypeSchema = Schema.String as unknown as Schema.Schema<ImageMimeType>
|
|
23
|
+
|
|
24
|
+
export type ImageUrlSource = MediaUrl<ImageMimeType>
|
|
25
|
+
export type ImageBase64Source = MediaBase64<ImageMimeType>
|
|
26
|
+
export type ImageBytesSource = MediaBytes<ImageMimeType>
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Where an image lives. Provider layers normalize across these:
|
|
30
|
+
* `bytes` becomes a base64 data URI for OpenAI / Anthropic, an
|
|
31
|
+
* `inlineData` part for Gemini, and a separate field for Cohere /
|
|
32
|
+
* Voyage. URL constraints (must be HTTPS, must be public, …) are
|
|
33
|
+
* provider-specific and validated at the layer, not in the type.
|
|
34
|
+
*/
|
|
35
|
+
export type ImageSource = MediaSource<ImageMimeType>
|
|
36
|
+
|
|
37
|
+
export const ImageUrlSource = Schema.TaggedStruct("url", {
|
|
38
|
+
url: Schema.String,
|
|
39
|
+
mimeType: Schema.optional(ImageMimeTypeSchema),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
export const ImageBase64Source = Schema.TaggedStruct("base64", {
|
|
43
|
+
base64: Schema.String,
|
|
44
|
+
mimeType: ImageMimeTypeSchema,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
export const ImageBytesSource = Schema.TaggedStruct("bytes", {
|
|
48
|
+
bytes: Schema.Uint8Array,
|
|
49
|
+
mimeType: ImageMimeTypeSchema,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
export const ImageSource: Schema.Schema<ImageSource> = Schema.Union([
|
|
53
|
+
ImageUrlSource,
|
|
54
|
+
ImageBase64Source,
|
|
55
|
+
ImageBytesSource,
|
|
56
|
+
]) as unknown as Schema.Schema<ImageSource>
|
|
57
|
+
|
|
58
|
+
export const imageUrl = (url: string, mimeType?: ImageMimeType): ImageUrlSource =>
|
|
59
|
+
mimeType !== undefined ? { _tag: "url", url, mimeType } : { _tag: "url", url }
|
|
60
|
+
|
|
61
|
+
export const imageBase64 = (base64: string, mimeType: ImageMimeType): ImageBase64Source => ({
|
|
62
|
+
_tag: "base64",
|
|
63
|
+
base64,
|
|
64
|
+
mimeType,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
export const imageBytes = (bytes: Uint8Array, mimeType: ImageMimeType): ImageBytesSource => ({
|
|
68
|
+
_tag: "bytes",
|
|
69
|
+
bytes,
|
|
70
|
+
mimeType,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
export const isImageUrl = Schema.is(ImageUrlSource)
|
|
74
|
+
export const isImageBase64 = Schema.is(ImageBase64Source)
|
|
75
|
+
export const isImageBytes = Schema.is(ImageBytesSource)
|
package/src/domain/Items.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Schema } from "effect"
|
|
2
|
+
import { ImageSource } from "./Image.js"
|
|
2
3
|
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
4
5
|
// Content blocks (inside Message.content)
|
|
@@ -10,39 +11,13 @@ export const InputText = Schema.Struct({
|
|
|
10
11
|
})
|
|
11
12
|
export type InputText = typeof InputText.Type
|
|
12
13
|
|
|
13
|
-
/**
|
|
14
|
-
* Where an image lives. `url` covers HTTP(S) URLs (the model fetches
|
|
15
|
-
* them); `base64` covers inline bytes embedded in the request. Provider
|
|
16
|
-
* encoders dispatch on `_tag`. File-id / uploaded-asset references are
|
|
17
|
-
* provider-specific and stay out of this union for now.
|
|
18
|
-
*/
|
|
19
|
-
export const ImageUrlSource = Schema.Struct({
|
|
20
|
-
_tag: Schema.Literal("url"),
|
|
21
|
-
url: Schema.String,
|
|
22
|
-
})
|
|
23
|
-
export type ImageUrlSource = typeof ImageUrlSource.Type
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Inline image bytes. `data` is **already base64-encoded** (matches what
|
|
27
|
-
* the wire formats expect; no double-encoding needed downstream).
|
|
28
|
-
* `media_type` is the MIME type, e.g. `"image/png"`.
|
|
29
|
-
*/
|
|
30
|
-
export const ImageBase64Source = Schema.Struct({
|
|
31
|
-
_tag: Schema.Literal("base64"),
|
|
32
|
-
media_type: Schema.String,
|
|
33
|
-
data: Schema.String,
|
|
34
|
-
})
|
|
35
|
-
export type ImageBase64Source = typeof ImageBase64Source.Type
|
|
36
|
-
|
|
37
|
-
export const ImageSource = Schema.Union([ImageUrlSource, ImageBase64Source])
|
|
38
|
-
export type ImageSource = typeof ImageSource.Type
|
|
39
|
-
|
|
40
|
-
export const isImageUrlSource = (s: ImageSource): s is ImageUrlSource => s._tag === "url"
|
|
41
|
-
export const isImageBase64Source = (s: ImageSource): s is ImageBase64Source => s._tag === "base64"
|
|
42
|
-
|
|
43
14
|
/**
|
|
44
15
|
* User-provided image content block. Pair with `InputText` inside a
|
|
45
16
|
* `Message.content` array to ask "what's in this image?" style questions.
|
|
17
|
+
*
|
|
18
|
+
* `source` is the cross-modality `ImageSource` from `domain/Image.ts` -
|
|
19
|
+
* url, base64, or raw bytes. Provider codecs encode bytes to whatever
|
|
20
|
+
* wire format the provider wants.
|
|
46
21
|
*/
|
|
47
22
|
export const InputImage = Schema.Struct({
|
|
48
23
|
type: Schema.Literal("input_image"),
|
|
@@ -91,11 +66,10 @@ export type FilePath = typeof FilePath.Type
|
|
|
91
66
|
export const Annotation = Schema.Union([UrlCitation, FileCitation, ContainerFileCitation, FilePath])
|
|
92
67
|
export type Annotation = typeof Annotation.Type
|
|
93
68
|
|
|
94
|
-
export const isUrlCitation = (
|
|
95
|
-
export const isFileCitation = (
|
|
96
|
-
export const isContainerFileCitation = (
|
|
97
|
-
|
|
98
|
-
export const isFilePath = (a: Annotation): a is FilePath => a.type === "file_path"
|
|
69
|
+
export const isUrlCitation = Schema.is(UrlCitation)
|
|
70
|
+
export const isFileCitation = Schema.is(FileCitation)
|
|
71
|
+
export const isContainerFileCitation = Schema.is(ContainerFileCitation)
|
|
72
|
+
export const isFilePath = Schema.is(FilePath)
|
|
99
73
|
|
|
100
74
|
export const OutputText = Schema.Struct({
|
|
101
75
|
type: Schema.Literal("output_text"),
|
|
@@ -183,18 +157,15 @@ export type Item = typeof Item.Type
|
|
|
183
157
|
// Type guards
|
|
184
158
|
// ---------------------------------------------------------------------------
|
|
185
159
|
|
|
186
|
-
export const isInputText = (
|
|
187
|
-
export const isInputImage = (
|
|
188
|
-
|
|
189
|
-
export const
|
|
190
|
-
|
|
191
|
-
export const
|
|
192
|
-
|
|
193
|
-
export const
|
|
194
|
-
export const
|
|
195
|
-
export const isFunctionCallOutput = (item: Item): item is FunctionCallOutput =>
|
|
196
|
-
item.type === "function_call_output"
|
|
197
|
-
export const isReasoning = (item: Item): item is Reasoning => item.type === "reasoning"
|
|
160
|
+
export const isInputText = Schema.is(InputText)
|
|
161
|
+
export const isInputImage = Schema.is(InputImage)
|
|
162
|
+
export const isOutputText = Schema.is(OutputText)
|
|
163
|
+
export const isRefusal = Schema.is(Refusal)
|
|
164
|
+
|
|
165
|
+
export const isMessage = Schema.is(Message)
|
|
166
|
+
export const isFunctionCall = Schema.is(FunctionCall)
|
|
167
|
+
export const isFunctionCallOutput = Schema.is(FunctionCallOutput)
|
|
168
|
+
export const isReasoning = Schema.is(Reasoning)
|
|
198
169
|
|
|
199
170
|
// ---------------------------------------------------------------------------
|
|
200
171
|
// Usage and stop reason
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-modality media reference shape.
|
|
3
|
+
*
|
|
4
|
+
* Every "media at rest" reference - image, audio, video, document - is one
|
|
5
|
+
* of three variants:
|
|
6
|
+
*
|
|
7
|
+
* - `url` : a remote address (HTTP, GCS, etc.). The model fetches it.
|
|
8
|
+
* `mimeType` is optional - servers usually set Content-Type.
|
|
9
|
+
* Some providers (Gemini `fileData`) want it explicit.
|
|
10
|
+
*
|
|
11
|
+
* - `base64` : an inline base64-encoded payload. Always carries a
|
|
12
|
+
* `mimeType` so the consumer knows how to decode.
|
|
13
|
+
*
|
|
14
|
+
* - `bytes` : raw `Uint8Array`. Provider layers normalize to base64 or
|
|
15
|
+
* multipart upload at the wire boundary - users don't need
|
|
16
|
+
* to encode themselves.
|
|
17
|
+
*
|
|
18
|
+
* Per-modality files (`Image.ts`, future `Audio.ts` / `Video.ts` /
|
|
19
|
+
* `Document.ts`) instantiate this shape with their typed MIME union to
|
|
20
|
+
* get autocomplete on common formats while keeping the structural type
|
|
21
|
+
* uniform across modalities.
|
|
22
|
+
*
|
|
23
|
+
* Streaming media (live mic feed, streaming TTS playback) is *not*
|
|
24
|
+
* modeled here. Streams carry effect parameters (`Stream<A, E, R>`) and
|
|
25
|
+
* lifecycle (Scope, cancellation) that don't apply to media at rest. The
|
|
26
|
+
* complementary type lives alongside this one as `*Stream` in each
|
|
27
|
+
* per-modality file when those modalities land.
|
|
28
|
+
*
|
|
29
|
+
* Provider-uploaded asset references (OpenAI Files `file_id`, Gemini
|
|
30
|
+
* Files API URIs, Anthropic file IDs) are also out of scope here -
|
|
31
|
+
* they're a separate union (`FileRef`) added when needed.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export type MediaUrl<M extends string = string> = {
|
|
35
|
+
readonly _tag: "url"
|
|
36
|
+
readonly url: string
|
|
37
|
+
readonly mimeType?: M
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type MediaBase64<M extends string = string> = {
|
|
41
|
+
readonly _tag: "base64"
|
|
42
|
+
readonly base64: string
|
|
43
|
+
readonly mimeType: M
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type MediaBytes<M extends string = string> = {
|
|
47
|
+
readonly _tag: "bytes"
|
|
48
|
+
readonly bytes: Uint8Array
|
|
49
|
+
readonly mimeType: M
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type MediaSource<M extends string = string> = MediaUrl<M> | MediaBase64<M> | MediaBytes<M>
|
|
53
|
+
|
|
54
|
+
export const isMediaUrl = <M extends string>(s: MediaSource<M>): s is MediaUrl<M> =>
|
|
55
|
+
s._tag === "url"
|
|
56
|
+
|
|
57
|
+
export const isMediaBase64 = <M extends string>(s: MediaSource<M>): s is MediaBase64<M> =>
|
|
58
|
+
s._tag === "base64"
|
|
59
|
+
|
|
60
|
+
export const isMediaBytes = <M extends string>(s: MediaSource<M>): s is MediaBytes<M> =>
|
|
61
|
+
s._tag === "bytes"
|
package/src/domain/Turn.ts
CHANGED
|
@@ -86,27 +86,17 @@ export const assistantMessages = (turn: Turn): ReadonlyArray<Message> =>
|
|
|
86
86
|
turn.items.filter((i): i is Message => i.type === "message" && i.role === "assistant")
|
|
87
87
|
|
|
88
88
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* around for stop-reason / usage / function-call inspection.
|
|
93
|
-
*
|
|
94
|
-
* Generic over the recipe's state shape - any record carrying a
|
|
95
|
-
* `history: ReadonlyArray<Item>` field works.
|
|
96
|
-
*/
|
|
97
|
-
export type Cursor<S> = S & { readonly turn: Turn }
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Build a `Cursor<S>` from a state record and the just-completed turn.
|
|
101
|
-
* Extends `state.history` with `turn.items` and stamps the turn.
|
|
89
|
+
* Append a completed turn and optional follow-up items to a state record's
|
|
90
|
+
* history. Recipes use this at the point where structured tool results are
|
|
91
|
+
* converted to model-facing `FunctionCallOutput`s.
|
|
102
92
|
*/
|
|
103
|
-
export const
|
|
93
|
+
export const appendTurn = <S extends { readonly history: ReadonlyArray<Item> }>(
|
|
104
94
|
state: S,
|
|
105
95
|
turn: Turn,
|
|
106
|
-
|
|
96
|
+
items: ReadonlyArray<Item> = [],
|
|
97
|
+
): S => ({
|
|
107
98
|
...state,
|
|
108
|
-
history: [...state.history, ...turn.items],
|
|
109
|
-
turn,
|
|
99
|
+
history: [...state.history, ...turn.items, ...items],
|
|
110
100
|
})
|
|
111
101
|
|
|
112
102
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { ImageSource } from "../domain/Image.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One part of a mixed text+image input. Used inside `EmbedInput.content[]`
|
|
5
|
+
* for providers that accept interleaved modalities in a single embed call
|
|
6
|
+
* (Cohere v4, Voyage multimodal, Jina v4, Google `gemini-embedding-2`).
|
|
7
|
+
*/
|
|
8
|
+
export type EmbedContentPart = { readonly text: string } | { readonly image: ImageSource }
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What you embed. The `string` shorthand covers the common text-only case;
|
|
12
|
+
* structured variants exist for image-only and mixed-modality inputs.
|
|
13
|
+
*
|
|
14
|
+
* Not every provider accepts every variant: text-only providers (OpenAI,
|
|
15
|
+
* Mixedbread today) handle `string` and `{ text }`; multimodal providers
|
|
16
|
+
* (Google, Jina v4, Voyage multimodal, Cohere v4) handle all four. A
|
|
17
|
+
* provider layer rejects shapes it can't encode as `AiError.InvalidRequest`.
|
|
18
|
+
*/
|
|
19
|
+
export type EmbedInput =
|
|
20
|
+
| string
|
|
21
|
+
| { readonly text: string }
|
|
22
|
+
| { readonly image: ImageSource }
|
|
23
|
+
| { readonly content: ReadonlyArray<EmbedContentPart> }
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Embedding representations
|
|
27
|
+
//
|
|
28
|
+
// The `_tag` reflects the wire form the provider returned, *not* what the
|
|
29
|
+
// consumer asked for - request `encoding: "int8"` and you get back an
|
|
30
|
+
// `Int8Embedding`. Math primitives are typed against the named interfaces
|
|
31
|
+
// (see `Vector.ts`) so e.g. `sparseCosine` only accepts `SparseEmbedding`.
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/** Dense float32 vector. The default representation across all providers. */
|
|
35
|
+
export type Float32Embedding = {
|
|
36
|
+
readonly _tag: "float32"
|
|
37
|
+
readonly vector: Float32Array
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Dense int8-quantized vector. ~4x smaller than float32 with minimal
|
|
42
|
+
* recall loss on most benchmarks.
|
|
43
|
+
*/
|
|
44
|
+
export type Int8Embedding = {
|
|
45
|
+
readonly _tag: "int8"
|
|
46
|
+
readonly vector: Int8Array
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Dense binary-quantized vector. One bit per dimension, packed into bytes.
|
|
51
|
+
* ~32x smaller than float32; meaningful recall loss but useful for hot
|
|
52
|
+
* indexes paired with a float32 reranker pass.
|
|
53
|
+
*/
|
|
54
|
+
export type BinaryEmbedding = {
|
|
55
|
+
readonly _tag: "binary"
|
|
56
|
+
readonly vector: Uint8Array
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Sparse vector. Token-keyed weights for hybrid search (dense + lexical-
|
|
61
|
+
* style sparse). The single hosted producer today is Jina's `elser-v2`
|
|
62
|
+
* model, which returns subword tokens (e.g. `"bread"`, `"##ing"`) with
|
|
63
|
+
* their relevance weights.
|
|
64
|
+
*
|
|
65
|
+
* The shape is `Record<string, number>` rather than `(indices, values)`
|
|
66
|
+
* because real hosted learned-sparse encoders (ELSER, SPLADE) emit token
|
|
67
|
+
* strings with no shared vocabulary index. Converting to integer indices
|
|
68
|
+
* would either need a vocabulary table the model doesn't expose, or
|
|
69
|
+
* lose the cross-vector matching semantics. If a provider ever exposes
|
|
70
|
+
* index-valued sparse vectors (Pinecone-style, where you bring your own
|
|
71
|
+
* vocab), add an `IndexSparseEmbedding` sibling arm with `_tag:
|
|
72
|
+
* "sparse-indexed"`.
|
|
73
|
+
*
|
|
74
|
+
* Score with `Vector.sparseCosine` — dot product over the intersection
|
|
75
|
+
* of keys, normalized by the L2 norms of both maps.
|
|
76
|
+
*/
|
|
77
|
+
export type SparseEmbedding = {
|
|
78
|
+
readonly _tag: "sparse"
|
|
79
|
+
readonly weights: Readonly<Record<string, number>>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Multivector / late-interaction output: one float32 vector per token.
|
|
84
|
+
* Score documents with `Vector.maxSim` (ColBERT-style: per query vector,
|
|
85
|
+
* max dot product across doc vectors, summed). Typically ~50-500 vectors
|
|
86
|
+
* per document, each shorter than a single-vector embedding (~128 dim
|
|
87
|
+
* vs ~1024).
|
|
88
|
+
*
|
|
89
|
+
* Quantized multivector forms aren't modeled for the same reason as
|
|
90
|
+
* sparse - nothing on hosted APIs ships them yet.
|
|
91
|
+
*/
|
|
92
|
+
export type MultivectorEmbedding = {
|
|
93
|
+
readonly _tag: "multivector"
|
|
94
|
+
readonly vectors: ReadonlyArray<Float32Array>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type Embedding =
|
|
98
|
+
| Float32Embedding
|
|
99
|
+
| Int8Embedding
|
|
100
|
+
| BinaryEmbedding
|
|
101
|
+
| SparseEmbedding
|
|
102
|
+
| MultivectorEmbedding
|
|
103
|
+
|
|
104
|
+
export const isFloat32 = (e: Embedding): e is Float32Embedding => e._tag === "float32"
|
|
105
|
+
export const isInt8 = (e: Embedding): e is Int8Embedding => e._tag === "int8"
|
|
106
|
+
export const isBinary = (e: Embedding): e is BinaryEmbedding => e._tag === "binary"
|
|
107
|
+
export const isSparse = (e: Embedding): e is SparseEmbedding => e._tag === "sparse"
|
|
108
|
+
export const isMultivector = (e: Embedding): e is MultivectorEmbedding => e._tag === "multivector"
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Token usage for one embed / embedMany call. One value per HTTP request,
|
|
112
|
+
* not per input vector. Most providers populate `inputTokens`; the field
|
|
113
|
+
* is optional for those that don't (or for mock layers in tests).
|
|
114
|
+
*/
|
|
115
|
+
export type Usage = {
|
|
116
|
+
readonly inputTokens?: number
|
|
117
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Context, Effect } from "effect"
|
|
2
|
+
import * as AiError from "../domain/AiError.js"
|
|
3
|
+
import type { Embedding, EmbedInput, Usage } from "./Embedding.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Output representation requested from the provider.
|
|
7
|
+
*
|
|
8
|
+
* Dense quantizations - same vector at different storage cost:
|
|
9
|
+
* - `float32` — universal default.
|
|
10
|
+
* - `int8` — ~4x smaller; minimal recall loss on most benchmarks.
|
|
11
|
+
* - `binary` — ~32x smaller; meaningful recall loss but pairs well with
|
|
12
|
+
* a float32 reranker pass over a small candidate set.
|
|
13
|
+
*
|
|
14
|
+
* Non-dense representations:
|
|
15
|
+
* - `sparse` — learned sparse vector for hybrid (dense + lexical) search.
|
|
16
|
+
* Currently Jina ELSER only on hosted APIs.
|
|
17
|
+
* - `multivector` — one vector per token for late-interaction (ColBERT-
|
|
18
|
+
* style) scoring via `Vector.maxSim`. Currently Jina v4 only.
|
|
19
|
+
*
|
|
20
|
+
* Each provider's typed request narrows this to its supported set at
|
|
21
|
+
* compile time (e.g. `JinaEncoding = "float32" | "binary" | "sparse" |
|
|
22
|
+
* "multivector"`). On the generic `EmbeddingModel` path, callers can
|
|
23
|
+
* pass any `Encoding` and the provider's API will reject mismatches at
|
|
24
|
+
* runtime.
|
|
25
|
+
*/
|
|
26
|
+
export type Encoding = "float32" | "int8" | "binary" | "sparse" | "multivector"
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Cross-provider single-embed request. Mirrors the shape of
|
|
30
|
+
* `LanguageModel.CommonRequest`: cross-cutting fields here, vendor
|
|
31
|
+
* specifics in the provider's typed request.
|
|
32
|
+
*
|
|
33
|
+
* Provider-specific extensions (Cohere widened `task` enum, Jina LoRA
|
|
34
|
+
* tasks, Mixedbread free-form `prompt`, etc.) live in that provider's own
|
|
35
|
+
* request interface, which extends this and narrows `model` / widens
|
|
36
|
+
* `task`.
|
|
37
|
+
*/
|
|
38
|
+
export type CommonEmbedRequest = {
|
|
39
|
+
readonly input: EmbedInput
|
|
40
|
+
/**
|
|
41
|
+
* Model identifier. Each provider narrows this to its typed literal
|
|
42
|
+
* union, so code that yields a typed provider tag gets autocompletion.
|
|
43
|
+
*/
|
|
44
|
+
readonly model: string
|
|
45
|
+
/**
|
|
46
|
+
* Retrieval-task hint. Applies to the input. OpenAI ignores this;
|
|
47
|
+
* Mixedbread doesn't have it; Cohere v3+ requires it on the wire (typed
|
|
48
|
+
* as required in `CohereEmbedRequest`). Provider-specific task enums
|
|
49
|
+
* (classification, clustering, code retrieval, …) live on the
|
|
50
|
+
* provider's own request type.
|
|
51
|
+
*/
|
|
52
|
+
readonly task?: "query" | "document"
|
|
53
|
+
/**
|
|
54
|
+
* Matryoshka truncation. Default: provider's native dimension.
|
|
55
|
+
* Discrete-value providers (Cohere, Vertex `multimodalembedding@001`)
|
|
56
|
+
* narrow this to a literal union in their typed request.
|
|
57
|
+
*/
|
|
58
|
+
readonly dimensions?: number
|
|
59
|
+
/**
|
|
60
|
+
* Output representation - see {@link Encoding}. Dense float32 is the
|
|
61
|
+
* default; provider layers reject unsupported values up front with
|
|
62
|
+
* `InvalidRequest`.
|
|
63
|
+
*/
|
|
64
|
+
readonly encoding?: Encoding
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Cross-provider batch-embed request. One `task` for the whole batch -
|
|
69
|
+
* mixed-task batches aren't a real provider feature (rerankers exist for
|
|
70
|
+
* that).
|
|
71
|
+
*/
|
|
72
|
+
export type CommonEmbedManyRequest = Omit<CommonEmbedRequest, "input"> & {
|
|
73
|
+
readonly inputs: ReadonlyArray<EmbedInput>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type EmbedResponse = {
|
|
77
|
+
readonly embedding: Embedding
|
|
78
|
+
readonly usage: Usage
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type EmbedManyResponse = {
|
|
82
|
+
readonly embeddings: ReadonlyArray<Embedding>
|
|
83
|
+
readonly usage: Usage
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type EmbeddingModelService = {
|
|
87
|
+
readonly embed: (request: CommonEmbedRequest) => Effect.Effect<EmbedResponse, AiError.AiError>
|
|
88
|
+
readonly embedMany: (
|
|
89
|
+
request: CommonEmbedManyRequest,
|
|
90
|
+
) => Effect.Effect<EmbedManyResponse, AiError.AiError>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class EmbeddingModel extends Context.Service<EmbeddingModel, EmbeddingModelService>()(
|
|
94
|
+
"@betalyra/effect-uai/EmbeddingModel",
|
|
95
|
+
) {}
|
|
96
|
+
|
|
97
|
+
/** Embed a single input. */
|
|
98
|
+
export const embed = (
|
|
99
|
+
request: CommonEmbedRequest,
|
|
100
|
+
): Effect.Effect<EmbedResponse, AiError.AiError, EmbeddingModel> =>
|
|
101
|
+
Effect.flatMap(EmbeddingModel.asEffect(), (m) => m.embed(request))
|
|
102
|
+
|
|
103
|
+
/** Embed a batch in one provider call. Same `task` for every input. */
|
|
104
|
+
export const embedMany = (
|
|
105
|
+
request: CommonEmbedManyRequest,
|
|
106
|
+
): Effect.Effect<EmbedManyResponse, AiError.AiError, EmbeddingModel> =>
|
|
107
|
+
Effect.flatMap(EmbeddingModel.asEffect(), (m) => m.embedMany(request))
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
export * as AiError from "./domain/AiError.js"
|
|
2
|
+
export * as Image from "./domain/Image.js"
|
|
2
3
|
export * as Items from "./domain/Items.js"
|
|
4
|
+
export * as Media from "./domain/Media.js"
|
|
3
5
|
export * as Turn from "./domain/Turn.js"
|
|
6
|
+
export * as Embedding from "./embedding-model/Embedding.js"
|
|
7
|
+
export * as EmbeddingModel from "./embedding-model/EmbeddingModel.js"
|
|
4
8
|
export * as LanguageModel from "./language-model/LanguageModel.js"
|
|
9
|
+
export * as Vector from "./math/Vector.js"
|
|
5
10
|
export * as Loop from "./loop/Loop.js"
|
|
6
|
-
export * as Match from "./match/Match.js"
|
|
7
11
|
export * as Tool from "./tool/Tool.js"
|
|
8
12
|
export * as Toolkit from "./tool/Toolkit.js"
|
|
13
|
+
export * as Outcome from "./tool/Outcome.js"
|
|
14
|
+
export * as ToolEvent from "./tool/ToolEvent.js"
|
|
15
|
+
export * as Resolvers from "./tool/Resolvers.js"
|
|
16
|
+
export * as HistoryCheck from "./tool/HistoryCheck.js"
|
|
9
17
|
export * as JSONL from "./streaming/JSONL.js"
|
|
10
18
|
export * as Lines from "./streaming/Lines.js"
|
|
11
19
|
export * as SSE from "./streaming/SSE.js"
|
|
@@ -11,7 +11,7 @@ import { isTurnComplete, type Turn, type TurnEvent } from "../domain/Turn.js"
|
|
|
11
11
|
* to a single provider (reasoning effort, prompt caching, store flags,
|
|
12
12
|
* ...) lives in that provider's own request interface, which extends this.
|
|
13
13
|
*/
|
|
14
|
-
export
|
|
14
|
+
export type CommonRequest = {
|
|
15
15
|
readonly history: ReadonlyArray<Item>
|
|
16
16
|
/**
|
|
17
17
|
* Model identifier. Each provider narrows this to its typed literal union,
|
|
@@ -36,7 +36,7 @@ export interface CommonRequest {
|
|
|
36
36
|
readonly structured?: StructuredFormat.StructuredFormat<unknown>
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export
|
|
39
|
+
export type LanguageModelService = {
|
|
40
40
|
readonly streamTurn: (request: CommonRequest) => Stream.Stream<TurnEvent, AiError.AiError>
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -51,23 +51,3 @@ 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
|
-
* Run a single turn to completion and return the assembled `Turn`.
|
|
57
|
-
*
|
|
58
|
-
* Implementation: drain the delta stream and pluck the terminal
|
|
59
|
-
* `turn_complete` event. The provider is contractually required to emit
|
|
60
|
-
* exactly one such event as the last delta.
|
|
61
|
-
*/
|
|
62
|
-
export const turn = (request: CommonRequest): Effect.Effect<Turn, AiError.AiError, LanguageModel> =>
|
|
63
|
-
Effect.flatMap(Stream.runCollect(streamTurn(request)), (deltas) => {
|
|
64
|
-
const last = deltas[deltas.length - 1]
|
|
65
|
-
return last !== undefined && isTurnComplete(last)
|
|
66
|
-
? Effect.succeed(last.turn)
|
|
67
|
-
: Effect.fail(
|
|
68
|
-
new AiError.Unavailable({
|
|
69
|
-
provider: "unknown",
|
|
70
|
-
raw: "Provider stream ended without a turn_complete event",
|
|
71
|
-
}),
|
|
72
|
-
)
|
|
73
|
-
})
|
package/src/loop/Loop.test.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
import { Deferred, Effect, Fiber, Ref, Stream } from "effect"
|
|
1
|
+
import { Deferred, Effect, Fiber, Latch, Ref, Stream, SubscriptionRef } from "effect"
|
|
2
2
|
import { describe, expect, it } from "vitest"
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type Event,
|
|
5
|
+
loop,
|
|
6
|
+
loopWithState,
|
|
7
|
+
next,
|
|
8
|
+
nextAfter,
|
|
9
|
+
stopEvent,
|
|
10
|
+
stopAfter,
|
|
11
|
+
value,
|
|
12
|
+
} from "./Loop.js"
|
|
4
13
|
|
|
5
14
|
describe("Loop.loop", () => {
|
|
6
15
|
it("threads state across iterations and emits each iteration's substream in order", async () => {
|
|
@@ -410,3 +419,106 @@ describe("Loop.loop - pull-specific stream semantics", () => {
|
|
|
410
419
|
expect(result._tag).toBe("Failure")
|
|
411
420
|
})
|
|
412
421
|
})
|
|
422
|
+
|
|
423
|
+
describe("Loop.loopWithState", () => {
|
|
424
|
+
it("exposes the final state in the SubscriptionRef after the stream completes", async () => {
|
|
425
|
+
const program = Effect.gen(function* () {
|
|
426
|
+
const { stream, state } = yield* loopWithState(0, (n: number) =>
|
|
427
|
+
n >= 3 ? stopAfter(Stream.fromIterable([n])) : nextAfter(Stream.fromIterable([n]), n + 1),
|
|
428
|
+
)
|
|
429
|
+
const values = yield* Stream.runCollect(stream)
|
|
430
|
+
const finalState = yield* SubscriptionRef.get(state)
|
|
431
|
+
return { values: Array.from(values), finalState }
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
const { values, finalState } = await Effect.runPromise(program)
|
|
435
|
+
expect(values).toEqual([0, 1, 2, 3])
|
|
436
|
+
// Last `next(state)` was `next(3)` before the iteration that emitted Stop.
|
|
437
|
+
expect(finalState).toBe(3)
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it("the state ref starts at `initial` and stays there if the loop stops without advancing", async () => {
|
|
441
|
+
const program = Effect.gen(function* () {
|
|
442
|
+
const { stream, state } = yield* loopWithState({ count: 7 }, () =>
|
|
443
|
+
Stream.fromIterable([stopEvent]),
|
|
444
|
+
)
|
|
445
|
+
yield* Stream.runDrain(stream)
|
|
446
|
+
return yield* SubscriptionRef.get(state)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
expect(await Effect.runPromise(program)).toEqual({ count: 7 })
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
it("a downstream consumer can read the live state between emitted values", async () => {
|
|
453
|
+
// Body emits one value per iteration, then advances. A `Stream.runForEach`
|
|
454
|
+
// consumer reads the ref each time a value arrives — proving the ref
|
|
455
|
+
// tracks loop state without the body needing to surface it.
|
|
456
|
+
const program = Effect.gen(function* () {
|
|
457
|
+
const { stream, state } = yield* loopWithState(0, (n: number) =>
|
|
458
|
+
n >= 3 ? stopAfter(Stream.fromIterable([n])) : nextAfter(Stream.fromIterable([n]), n + 1),
|
|
459
|
+
)
|
|
460
|
+
const seen: Array<{ value: number; stateAfter: number }> = []
|
|
461
|
+
yield* Stream.runForEach(stream, (v) =>
|
|
462
|
+
Effect.gen(function* () {
|
|
463
|
+
seen.push({ value: v, stateAfter: yield* SubscriptionRef.get(state) })
|
|
464
|
+
}),
|
|
465
|
+
)
|
|
466
|
+
return seen
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
// For each iter `n`, the consumer reads the ref between values: it sees
|
|
470
|
+
// the iteration's input state. The terminal iter (n=3) stops without
|
|
471
|
+
// advancing, so its read still shows 3.
|
|
472
|
+
expect(await Effect.runPromise(program)).toEqual([
|
|
473
|
+
{ value: 0, stateAfter: 0 },
|
|
474
|
+
{ value: 1, stateAfter: 1 },
|
|
475
|
+
{ value: 2, stateAfter: 2 },
|
|
476
|
+
{ value: 3, stateAfter: 3 },
|
|
477
|
+
])
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it("SubscriptionRef.changes emits every state transition to a concurrent observer", async () => {
|
|
481
|
+
const program = Effect.gen(function* () {
|
|
482
|
+
const start = yield* Latch.make(false)
|
|
483
|
+
|
|
484
|
+
// Body waits on the latch in iter 0 so the observer can subscribe first.
|
|
485
|
+
const { stream, state } = yield* loopWithState(0, (n: number) =>
|
|
486
|
+
Effect.gen(function* () {
|
|
487
|
+
if (n === 0) yield* Latch.await(start)
|
|
488
|
+
return n >= 3 ? stopAfter(Stream.empty) : nextAfter(Stream.empty, n + 1)
|
|
489
|
+
}),
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
// Fork the observer; take 4 distinct states (initial + 3 transitions).
|
|
493
|
+
const observerFiber = yield* Effect.forkChild(
|
|
494
|
+
SubscriptionRef.changes(state).pipe(Stream.take(4), Stream.runCollect),
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
// Give the observer fiber a chance to actually subscribe before the
|
|
498
|
+
// loop starts advancing the ref. Without this, the loop could finish
|
|
499
|
+
// before the observer's pubsub subscription is in place.
|
|
500
|
+
yield* Effect.sleep("10 millis")
|
|
501
|
+
|
|
502
|
+
yield* Latch.open(start)
|
|
503
|
+
yield* Stream.runDrain(stream)
|
|
504
|
+
|
|
505
|
+
return Array.from(yield* Fiber.join(observerFiber))
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
// initial 0, then next(1), next(2), next(3) — four distinct states.
|
|
509
|
+
expect(await Effect.runPromise(program)).toEqual([0, 1, 2, 3])
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
it("does not interfere with the body's value stream", async () => {
|
|
513
|
+
const program = Effect.gen(function* () {
|
|
514
|
+
const { stream } = yield* loopWithState(0, (n: number) =>
|
|
515
|
+
n >= 3
|
|
516
|
+
? stopAfter(Stream.fromIterable([n]))
|
|
517
|
+
: nextAfter(Stream.fromIterable([n, n + 0.5]), n + 1),
|
|
518
|
+
)
|
|
519
|
+
return Array.from(yield* Stream.runCollect(stream))
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
expect(await Effect.runPromise(program)).toEqual([0, 0.5, 1, 1.5, 2, 2.5, 3])
|
|
523
|
+
})
|
|
524
|
+
})
|