@effect-uai/core 0.14.0 → 0.15.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 (38) hide show
  1. package/dist/MessengerError-CeK6VAcu.d.mts +64 -0
  2. package/dist/MessengerError-CeK6VAcu.d.mts.map +1 -0
  3. package/dist/inbox/Inbox.d.mts +16 -0
  4. package/dist/inbox/Inbox.d.mts.map +1 -0
  5. package/dist/inbox/Inbox.mjs +28 -0
  6. package/dist/inbox/Inbox.mjs.map +1 -0
  7. package/dist/inbox/Inbox.test.d.mts +1 -0
  8. package/dist/inbox/Inbox.test.mjs +42 -0
  9. package/dist/inbox/Inbox.test.mjs.map +1 -0
  10. package/dist/index.d.mts +5 -1
  11. package/dist/index.mjs +5 -1
  12. package/dist/messenger/Messenger.d.mts +472 -0
  13. package/dist/messenger/Messenger.d.mts.map +1 -0
  14. package/dist/messenger/Messenger.mjs +66 -0
  15. package/dist/messenger/Messenger.mjs.map +1 -0
  16. package/dist/messenger/MessengerAdapter.d.mts +35 -0
  17. package/dist/messenger/MessengerAdapter.d.mts.map +1 -0
  18. package/dist/messenger/MessengerAdapter.mjs +102 -0
  19. package/dist/messenger/MessengerAdapter.mjs.map +1 -0
  20. package/dist/messenger/MessengerAdapter.test.d.mts +1 -0
  21. package/dist/messenger/MessengerAdapter.test.mjs +138 -0
  22. package/dist/messenger/MessengerAdapter.test.mjs.map +1 -0
  23. package/dist/messenger/MessengerError.d.mts +2 -0
  24. package/dist/messenger/MessengerError.mjs +39 -0
  25. package/dist/messenger/MessengerError.mjs.map +1 -0
  26. package/dist/testing/MockMessenger.d.mts +192 -0
  27. package/dist/testing/MockMessenger.d.mts.map +1 -0
  28. package/dist/testing/MockMessenger.mjs +76 -0
  29. package/dist/testing/MockMessenger.mjs.map +1 -0
  30. package/package.json +21 -1
  31. package/src/inbox/Inbox.test.ts +52 -0
  32. package/src/inbox/Inbox.ts +33 -0
  33. package/src/index.ts +4 -0
  34. package/src/messenger/Messenger.ts +293 -0
  35. package/src/messenger/MessengerAdapter.test.ts +179 -0
  36. package/src/messenger/MessengerAdapter.ts +189 -0
  37. package/src/messenger/MessengerError.ts +67 -0
  38. package/src/testing/MockMessenger.ts +147 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-uai/core",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Low-level primitives (loop, conversation, items, tools, streaming codecs) for building AI agents with Effect.",
5
5
  "keywords": [
6
6
  "agents",
@@ -114,6 +114,18 @@
114
114
  "types": "./dist/language-model/LanguageModel.d.mts",
115
115
  "import": "./dist/language-model/LanguageModel.mjs"
116
116
  },
117
+ "./Messenger": {
118
+ "types": "./dist/messenger/Messenger.d.mts",
119
+ "import": "./dist/messenger/Messenger.mjs"
120
+ },
121
+ "./MessengerAdapter": {
122
+ "types": "./dist/messenger/MessengerAdapter.d.mts",
123
+ "import": "./dist/messenger/MessengerAdapter.mjs"
124
+ },
125
+ "./MessengerError": {
126
+ "types": "./dist/messenger/MessengerError.d.mts",
127
+ "import": "./dist/messenger/MessengerError.mjs"
128
+ },
117
129
  "./MusicGenerator": {
118
130
  "types": "./dist/music-generator/MusicGenerator.d.mts",
119
131
  "import": "./dist/music-generator/MusicGenerator.mjs"
@@ -166,6 +178,10 @@
166
178
  "types": "./dist/web-search/WebSearchTool.d.mts",
167
179
  "import": "./dist/web-search/WebSearchTool.mjs"
168
180
  },
181
+ "./Inbox": {
182
+ "types": "./dist/inbox/Inbox.d.mts",
183
+ "import": "./dist/inbox/Inbox.mjs"
184
+ },
169
185
  "./Loop": {
170
186
  "types": "./dist/loop/Loop.d.mts",
171
187
  "import": "./dist/loop/Loop.mjs"
@@ -238,6 +254,10 @@
238
254
  "types": "./dist/testing/MockProvider.d.mts",
239
255
  "import": "./dist/testing/MockProvider.mjs"
240
256
  },
257
+ "./testing/MockMessenger": {
258
+ "types": "./dist/testing/MockMessenger.d.mts",
259
+ "import": "./dist/testing/MockMessenger.mjs"
260
+ },
241
261
  "./testing/MockMusicGenerator": {
242
262
  "types": "./dist/testing/MockMusicGenerator.d.mts",
243
263
  "import": "./dist/testing/MockMusicGenerator.mjs"
@@ -0,0 +1,52 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import { Effect, Fiber, Option, Queue } from "effect"
3
+ import { TestClock } from "effect/testing"
4
+ import { expect } from "vitest"
5
+ import { drainBurst } from "./Inbox.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
+ })
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Input a long-lived agent reads between turns. A chat user rarely sends one
3
+ * tidy message: they type three lines in a row, then go quiet. The loop wants
4
+ * that as one batch, taken at a clean turn boundary.
5
+ */
6
+ import { Array as Arr, type Duration, Effect, Option, Queue } from "effect"
7
+
8
+ /**
9
+ * Block for the first item, then keep taking while the next one lands within
10
+ * `settle` of the previous. The window resets on every arrival, so a burst
11
+ * becomes one batch and a lone message followed by silence returns at once.
12
+ *
13
+ * A take that loses to the window is interrupted before it removes anything,
14
+ * so late arrivals stay queued for the next drain.
15
+ */
16
+ export const drainBurst = <A, E>(
17
+ queue: Queue.Dequeue<A, E>,
18
+ settle: Duration.Input,
19
+ ): Effect.Effect<Arr.NonEmptyReadonlyArray<A>, E> => {
20
+ const settled = (
21
+ batch: Arr.NonEmptyReadonlyArray<A>,
22
+ ): Effect.Effect<Arr.NonEmptyReadonlyArray<A>, E> =>
23
+ Queue.take(queue).pipe(
24
+ Effect.timeoutOption(settle),
25
+ Effect.flatMap(
26
+ Option.match({
27
+ onNone: () => Effect.succeed(batch),
28
+ onSome: (item) => settled(Arr.append(batch, item)),
29
+ }),
30
+ ),
31
+ )
32
+ return Effect.flatMap(Queue.take(queue), (first) => settled([first]))
33
+ }
package/src/index.ts CHANGED
@@ -19,6 +19,9 @@ export * as Embedding from "./embedding-model/Embedding.js"
19
19
  export * as EmbeddingModel from "./embedding-model/EmbeddingModel.js"
20
20
  export * as ImageGenerator from "./image-generator/ImageGenerator.js"
21
21
  export * as LanguageModel from "./language-model/LanguageModel.js"
22
+ export * as Messenger from "./messenger/Messenger.js"
23
+ export * as MessengerAdapter from "./messenger/MessengerAdapter.js"
24
+ export * as MessengerError from "./messenger/MessengerError.js"
22
25
  export * as MusicGenerator from "./music-generator/MusicGenerator.js"
23
26
  export * as Reranker from "./reranker/Reranker.js"
24
27
  export * as Memory from "./sandbox/Memory.js"
@@ -34,6 +37,7 @@ export * as WebSearch from "./web-search/WebSearch.js"
34
37
  export * as WebSearchTool from "./web-search/WebSearchTool.js"
35
38
  export * as Vector from "./math/Vector.js"
36
39
  export * as Tokenizer from "./tokenizer/Tokenizer.js"
40
+ export * as Inbox from "./inbox/Inbox.js"
37
41
  export * as Loop from "./loop/Loop.js"
38
42
  export * as Retry from "./retry/Retry.js"
39
43
  export * as Tool from "./tool/Tool.js"
@@ -0,0 +1,293 @@
1
+ import { Brand, Context, Data, Effect, type Option, type Scope, Stream } from "effect"
2
+ import type { MediaSource } from "../domain/Media.js"
3
+ import type * as MessengerError from "./MessengerError.js"
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Identity
7
+ // ---------------------------------------------------------------------------
8
+
9
+ /** Where messages go. Providers mint these per their own scheme; core treats them opaquely. */
10
+ export type ChannelId = Brand.Branded<string, "ChannelId">
11
+ export const ChannelId = Brand.nominal<ChannelId>()
12
+
13
+ /** One message inside a channel. Only a named message can be edited or reacted to. */
14
+ export type MessageId = Brand.Branded<string, "MessageId">
15
+ export const MessageId = Brand.nominal<MessageId>()
16
+
17
+ /** The human (or bot) an event came from. */
18
+ export type UserId = Brand.Branded<string, "UserId">
19
+ export const UserId = Brand.nominal<UserId>()
20
+
21
+ /** A sub-conversation inside a channel, where the platform has one. Opaque, provider-minted. */
22
+ export type ThreadId = Brand.Branded<string, "ThreadId">
23
+ export const ThreadId = Brand.nominal<ThreadId>()
24
+
25
+ /**
26
+ * Where a conversation happens. `thread` is provider-interpreted: a Slack
27
+ * `thread_ts`, a Telegram forum topic, unset on Discord (where a thread is
28
+ * its own channel).
29
+ */
30
+ export type ConversationRef = {
31
+ readonly channel: ChannelId
32
+ readonly thread?: ThreadId
33
+ }
34
+
35
+ export type MessageRef = {
36
+ readonly conversation: ConversationRef
37
+ readonly id: MessageId
38
+ }
39
+
40
+ /** Stable key for a conversation. Index per-conversation state with it. */
41
+ export const conversationKey = (ref: ConversationRef): string =>
42
+ ref.thread === undefined ? ref.channel : `${ref.channel}/${ref.thread}`
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Inbound
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * What the world says to the agent. `raw` is the untouched platform payload,
50
+ * for everything the shared shape drops.
51
+ *
52
+ * `addressed` is the rule recipes branch on: a DM, a mention of the bot, or
53
+ * a reply to one of its messages. Each adapter owns that decision (and hides
54
+ * Telegram privacy mode / Discord intents behind it), and delivers `text`
55
+ * with the bot's own mention stripped.
56
+ *
57
+ * `Reaction.emoji` is the platform's own spelling, the same one `react`
58
+ * accepts: unicode where the platform speaks unicode, a shortcode where it
59
+ * speaks shortcodes.
60
+ */
61
+ export type InboundEvent = Data.TaggedEnum<{
62
+ Message: {
63
+ readonly conversation: ConversationRef
64
+ readonly id: MessageId
65
+ readonly author: UserId
66
+ readonly text: string
67
+ readonly addressed: boolean
68
+ readonly replyTo?: MessageId
69
+ readonly raw: unknown
70
+ }
71
+ Reaction: {
72
+ readonly conversation: ConversationRef
73
+ readonly message: MessageId
74
+ readonly emoji: string
75
+ readonly author: UserId
76
+ readonly raw: unknown
77
+ }
78
+ Command: {
79
+ readonly conversation: ConversationRef
80
+ readonly name: string
81
+ readonly args: string
82
+ readonly author: UserId
83
+ readonly raw: unknown
84
+ }
85
+ Action: {
86
+ readonly conversation: ConversationRef
87
+ readonly actionId: string
88
+ readonly value?: string
89
+ readonly author: UserId
90
+ readonly raw: unknown
91
+ }
92
+ }>
93
+
94
+ export const InboundEvent = Data.taggedEnum<InboundEvent>()
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Outbound
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /**
101
+ * What the agent says. `Text` is sent verbatim: each provider layer says
102
+ * which markup it expects (Telegram HTML, Slack markdown), and the prompt is
103
+ * where that gets decided. Nothing here converts. `Media` reuses the core
104
+ * {@link MediaSource}, so a URL, base64 or raw bytes all work; the adapter
105
+ * routes on `mimeType` (`image/*` to a photo endpoint, `audio/*` to an audio
106
+ * one, everything else to a file upload), which is why a URL source without
107
+ * a `mimeType` goes as a plain file. `Raw` bypasses the shape entirely and
108
+ * reaches the platform as-is: the escape hatch for buttons, cards and
109
+ * everything the verbs do not unify.
110
+ */
111
+ export type OutboundBody = Data.TaggedEnum<{
112
+ Text: { readonly text: string }
113
+ Media: {
114
+ readonly media: MediaSource
115
+ readonly caption?: string
116
+ readonly filename?: string
117
+ }
118
+ Raw: { readonly payload: unknown }
119
+ }>
120
+
121
+ export const OutboundBody = Data.taggedEnum<OutboundBody>()
122
+
123
+ /**
124
+ * A body plus the envelope fields true of any message. `replyTo` is ignored
125
+ * for a `Raw` body, which names its own wire fields.
126
+ */
127
+ export type Outbound = {
128
+ readonly body: OutboundBody
129
+ readonly replyTo?: MessageId
130
+ }
131
+
132
+ export type OutboundOptions = { readonly replyTo?: MessageId }
133
+
134
+ const envelope = (body: OutboundBody, options?: OutboundOptions): Outbound => ({
135
+ body,
136
+ ...(options?.replyTo !== undefined && { replyTo: options.replyTo }),
137
+ })
138
+
139
+ /** Text message. The overwhelmingly common case: `post(text("hi"))`. */
140
+ export const text = (body: string, options?: OutboundOptions): Outbound =>
141
+ envelope(OutboundBody.Text({ text: body }), options)
142
+
143
+ /** Media message. `caption` and `filename` describe the media, not the envelope. */
144
+ export const media = (
145
+ source: MediaSource,
146
+ options?: OutboundOptions & { readonly caption?: string; readonly filename?: string },
147
+ ): Outbound =>
148
+ envelope(
149
+ OutboundBody.Media({
150
+ media: source,
151
+ ...(options?.caption !== undefined && { caption: options.caption }),
152
+ ...(options?.filename !== undefined && { filename: options.filename }),
153
+ }),
154
+ options,
155
+ )
156
+
157
+ /** Platform payload, passed through untouched. */
158
+ export const raw = (payload: unknown): Outbound => ({ body: OutboundBody.Raw({ payload }) })
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Ambient conversation targeting
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /**
165
+ * The conversation the outbound verbs target. A tag with no default, so
166
+ * posting outside an established conversation is a compile error. Deep code
167
+ * (a tool posting progress, an approval resolver) inherits it rather than
168
+ * threading a ref down.
169
+ */
170
+ export class CurrentConversation extends Context.Service<CurrentConversation, ConversationRef>()(
171
+ "@betalyra/effect-uai/Messenger/CurrentConversation",
172
+ ) {}
173
+
174
+ /** Run `effect` against `ref`. Wrap a conversation fiber once; every nested post lands. */
175
+ export const inConversation =
176
+ (ref: ConversationRef) =>
177
+ <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, Exclude<R, CurrentConversation>> =>
178
+ Effect.provideService(effect, CurrentConversation, ref)
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Service definition
182
+ // ---------------------------------------------------------------------------
183
+
184
+ /** Envelope of a streamed reply. `replyTo` lands on the first message it posts. */
185
+ export type StreamOptions = OutboundOptions
186
+
187
+ /** Platform ceilings, so recipes can see them before composing a message. */
188
+ export type MessengerLimits = {
189
+ /** Longest single text message. */
190
+ readonly maxText: number
191
+ /** Longest caption on a media message. */
192
+ readonly maxCaption: number
193
+ }
194
+
195
+ /**
196
+ * Cross-platform messaging: one inbound stream, five outbound verbs.
197
+ *
198
+ * `events` is single-consumer and provider-owned. The connection opens when
199
+ * the layer is built and closes with its scope, so there is no connect /
200
+ * disconnect here, and reconnects, acks and callback answering all happen
201
+ * below this line.
202
+ *
203
+ * `post`, `typing` and `stream` target the ambient
204
+ * {@link CurrentConversation}; `edit` and `react` name a message outright.
205
+ */
206
+ export type MessengerService = {
207
+ readonly events: Stream.Stream<InboundEvent, MessengerError.MessengerError>
208
+
209
+ /** Text past `limits.maxText` goes out as several messages; the id is the last. */
210
+ readonly post: (
211
+ msg: Outbound,
212
+ ) => Effect.Effect<MessageId, MessengerError.MessengerError, CurrentConversation>
213
+
214
+ readonly edit: (
215
+ msg: MessageRef,
216
+ next: Outbound,
217
+ ) => Effect.Effect<void, MessengerError.MessengerError>
218
+
219
+ /**
220
+ * `emoji` in the platform's own spelling, as its `Reaction` events deliver
221
+ * it. Nothing is translated; one the platform rejects fails
222
+ * `MessengerUnsupported`.
223
+ */
224
+ readonly react: (
225
+ msg: MessageRef,
226
+ emoji: string,
227
+ ) => Effect.Effect<void, MessengerError.MessengerError>
228
+
229
+ /** Activity indicator, kept alive by the adapter until the scope closes. */
230
+ readonly typing: Effect.Effect<
231
+ void,
232
+ MessengerError.MessengerError,
233
+ CurrentConversation | Scope.Scope
234
+ >
235
+
236
+ /**
237
+ * Progressive delivery of a text stream; the mechanism is the adapter's.
238
+ * The id is the last message posted, `None` when the stream had no text.
239
+ */
240
+ readonly stream: <E, R>(
241
+ deltas: Stream.Stream<string, E, R>,
242
+ options?: StreamOptions,
243
+ ) => Effect.Effect<
244
+ Option.Option<MessageId>,
245
+ MessengerError.MessengerError | E,
246
+ R | CurrentConversation
247
+ >
248
+
249
+ readonly limits: MessengerLimits
250
+ }
251
+
252
+ export class Messenger extends Context.Service<Messenger, MessengerService>()(
253
+ "@betalyra/effect-uai/Messenger",
254
+ ) {}
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Free helpers
258
+ // ---------------------------------------------------------------------------
259
+
260
+ export const events: Stream.Stream<InboundEvent, MessengerError.MessengerError, Messenger> =
261
+ Stream.unwrap(Effect.map(Messenger, (m) => m.events))
262
+
263
+ export const post = (
264
+ msg: Outbound,
265
+ ): Effect.Effect<MessageId, MessengerError.MessengerError, Messenger | CurrentConversation> =>
266
+ Effect.flatMap(Messenger, (m) => m.post(msg))
267
+
268
+ export const edit = (
269
+ msg: MessageRef,
270
+ next: Outbound,
271
+ ): Effect.Effect<void, MessengerError.MessengerError, Messenger> =>
272
+ Effect.flatMap(Messenger, (m) => m.edit(msg, next))
273
+
274
+ export const react = (
275
+ msg: MessageRef,
276
+ emoji: string,
277
+ ): Effect.Effect<void, MessengerError.MessengerError, Messenger> =>
278
+ Effect.flatMap(Messenger, (m) => m.react(msg, emoji))
279
+
280
+ export const typing: Effect.Effect<
281
+ void,
282
+ MessengerError.MessengerError,
283
+ Messenger | CurrentConversation | Scope.Scope
284
+ > = Effect.flatMap(Messenger, (m) => m.typing)
285
+
286
+ export const stream = <E, R>(
287
+ deltas: Stream.Stream<string, E, R>,
288
+ options?: StreamOptions,
289
+ ): Effect.Effect<
290
+ Option.Option<MessageId>,
291
+ MessengerError.MessengerError | E,
292
+ R | Messenger | CurrentConversation
293
+ > => Effect.flatMap(Messenger, (m) => m.stream(deltas, options))
@@ -0,0 +1,179 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import { Array as Arr, Duration, Effect, Option, Result, Stream } from "effect"
3
+ import { TestClock } from "effect/testing"
4
+ import { expect } from "vitest"
5
+ import * as MockMessenger from "../testing/MockMessenger.js"
6
+ import {
7
+ ChannelId,
8
+ type ConversationRef,
9
+ MessageId,
10
+ type Outbound,
11
+ type StreamOptions,
12
+ inConversation,
13
+ } from "./Messenger.js"
14
+ import { type StreamViaEditsOptions, splitForLimit } from "./MessengerAdapter.js"
15
+ import { MessengerRateLimited } from "./MessengerError.js"
16
+
17
+ const here: ConversationRef = { channel: ChannelId("c1") }
18
+
19
+ const bodyOf = (msg: Outbound): string => (msg.body._tag === "Text" ? msg.body.text : "")
20
+
21
+ const posts = (calls: ReadonlyArray<MockMessenger.Call>) =>
22
+ Arr.filterMap(calls, (c) =>
23
+ c._tag === "Post" ? Result.succeed(bodyOf(c.message)) : Result.failVoid,
24
+ )
25
+
26
+ const edits = (calls: ReadonlyArray<MockMessenger.Call>) =>
27
+ Arr.filterMap(calls, (c) =>
28
+ c._tag === "Edit" ? Result.succeed(bodyOf(c.next)) : Result.failVoid,
29
+ )
30
+
31
+ /** Run `deltas` through the mock's `stream` and hand back the call log. */
32
+ const streamed = (
33
+ deltas: ReadonlyArray<string>,
34
+ script: MockMessenger.MockMessengerScript = {},
35
+ streamOptions?: StreamOptions,
36
+ ) =>
37
+ Effect.gen(function* () {
38
+ const { service, recorder } = MockMessenger.make(script)
39
+ const id = yield* service.stream(Stream.fromIterable(deltas), streamOptions)
40
+ const { calls } = yield* recorder
41
+ return { id, calls }
42
+ }).pipe(inConversation(here))
43
+
44
+ const options = (o: StreamViaEditsOptions): MockMessenger.MockMessengerScript => ({
45
+ streamOptions: o,
46
+ })
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // splitForLimit
50
+ // ---------------------------------------------------------------------------
51
+
52
+ describe("splitForLimit", () => {
53
+ it("prefers a paragraph break, then a line, then a word", () => {
54
+ expect(splitForLimit("aaa\n\nbbb ccc", 8)).toEqual(["aaa", "bbb ccc"])
55
+ expect(splitForLimit("aaa\nbbbb ccc", 8)).toEqual(["aaa", "bbbb ccc"])
56
+ expect(splitForLimit("aaaa bbbb ccc", 9)).toEqual(["aaaa", "bbbb ccc"])
57
+ })
58
+
59
+ it("hard-cuts a run with no boundary inside the limit", () => {
60
+ expect(splitForLimit("aaaaaaaaaa", 4)).toEqual(["aaaa", "aaaa", "aa"])
61
+ })
62
+ })
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // streamViaEdits
66
+ // ---------------------------------------------------------------------------
67
+
68
+ describe("streamViaEdits", () => {
69
+ // TestClock keeps wall time frozen, so the `every` gate never opens and
70
+ // every delta after the first coalesces into the final flush.
71
+ it.effect("coalesces deltas arriving inside the `every` window", () =>
72
+ Effect.gen(function* () {
73
+ const { calls } = yield* streamed(["Hel", "lo ", "the", "re!"])
74
+
75
+ expect(posts(calls)).toEqual(["Hel"])
76
+ expect(edits(calls)).toEqual(["Hello there!"])
77
+ }),
78
+ )
79
+
80
+ it.effect("waits for `minChars` of growth before spending an edit", () =>
81
+ Effect.gen(function* () {
82
+ const { calls } = yield* streamed(
83
+ ["aaaa", "bbbb", "cccc", "dddd", "eeee", "ffff"],
84
+ options({ every: 0, minChars: 10 }),
85
+ )
86
+
87
+ // Edits land at 16 chars (12 of growth) and again on the final flush.
88
+ expect(posts(calls)).toEqual(["aaaa"])
89
+ expect(edits(calls).map((t) => t.length)).toEqual([16, 24])
90
+ }),
91
+ )
92
+
93
+ it.effect("skips the final flush when the message already shows the text", () =>
94
+ Effect.gen(function* () {
95
+ const { calls } = yield* streamed(
96
+ ["aaaaa", "bbbbb", "ccccc"],
97
+ options({ every: 0, minChars: 10 }),
98
+ )
99
+
100
+ // The third delta opens the growth gate, so the tail is already sent.
101
+ expect(edits(calls)).toEqual(["aaaaabbbbbccccc"])
102
+ }),
103
+ )
104
+
105
+ it.effect("rolls over to a new message past maxText", () =>
106
+ Effect.gen(function* () {
107
+ const { id, calls } = yield* streamed(["aaaa bbbb cccc dddd"], {
108
+ limits: { maxText: 10, maxCaption: 10 },
109
+ })
110
+
111
+ // Cut on the word boundary, not mid-word, and the id is the last message.
112
+ expect(posts(calls)).toEqual(["aaaa bbbb", "cccc dddd"])
113
+ expect(edits(calls)).toEqual([])
114
+ expect(id).toEqual(Option.some(MessageId("m2")))
115
+ }),
116
+ )
117
+
118
+ it.effect("quotes the question on the first message only, never on a rollover", () =>
119
+ Effect.gen(function* () {
120
+ const { calls } = yield* streamed(
121
+ ["aaaa bbbb cccc dddd"],
122
+ { limits: { maxText: 10, maxCaption: 10 } },
123
+ { replyTo: MessageId("q1") },
124
+ )
125
+
126
+ const replies = Arr.filterMap(calls, (c) =>
127
+ c._tag === "Post" ? Result.succeed(c.message.replyTo) : Result.failVoid,
128
+ )
129
+ expect(replies).toEqual([MessageId("q1"), undefined])
130
+ }),
131
+ )
132
+
133
+ it.effect("posts nothing for a stream with no text", () =>
134
+ Effect.gen(function* () {
135
+ const { id, calls } = yield* streamed(["", ""])
136
+
137
+ expect(calls).toEqual([])
138
+ expect(id).toEqual(Option.none())
139
+ }),
140
+ )
141
+
142
+ // A model opening a tool-calling turn with a newline, which every platform
143
+ // rejects as an empty message.
144
+ it.effect("treats whitespace-only deltas as no text at all", () =>
145
+ Effect.gen(function* () {
146
+ const { id, calls } = yield* streamed(["\n", " ", "\n\n"])
147
+
148
+ expect(calls).toEqual([])
149
+ expect(id).toEqual(Option.none())
150
+ }),
151
+ )
152
+
153
+ it.live("waits out a rate limit and re-sends the same edit", () =>
154
+ Effect.gen(function* () {
155
+ const limited = new MessengerRateLimited({
156
+ provider: "mock",
157
+ retryAfter: Duration.millis(10),
158
+ })
159
+ const { calls } = yield* streamed(["abc", "defghij"], {
160
+ ...options({ every: 0, minChars: 1 }),
161
+ failures: [undefined, limited],
162
+ })
163
+
164
+ expect(posts(calls)).toEqual(["abc"])
165
+ expect(edits(calls)).toEqual(["abcdefghij", "abcdefghij"])
166
+ }),
167
+ )
168
+
169
+ it.effect("gives up once the rate-limit budget is spent", () =>
170
+ Effect.gen(function* () {
171
+ const limited = new MessengerRateLimited({ provider: "mock", retryAfter: Duration.zero })
172
+ const failed = yield* Effect.flip(
173
+ streamed(["abc"], { ...options({ rateLimitRetries: 1 }), failures: [limited, limited] }),
174
+ )
175
+
176
+ expect(failed._tag).toBe("MessengerRateLimited")
177
+ }).pipe(TestClock.withLive),
178
+ )
179
+ })