@effect-uai/core 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +30 -16
  2. package/dist/{AiError-eLV9t1lU.d.mts → AiError-DvhLIbmd.d.mts} +15 -4
  3. package/dist/{AiError-eLV9t1lU.d.mts.map → AiError-DvhLIbmd.d.mts.map} +1 -1
  4. package/dist/{Loop-B54AB1ME.d.mts → Loop-ChpaO-ya.d.mts} +2 -2
  5. package/dist/{Loop-B54AB1ME.d.mts.map → Loop-ChpaO-ya.d.mts.map} +1 -1
  6. package/dist/MessengerError-CeK6VAcu.d.mts +64 -0
  7. package/dist/MessengerError-CeK6VAcu.d.mts.map +1 -0
  8. package/dist/{Toolkit-DAjJ9Tvl.d.mts → Toolkit-tlL0F2YR.d.mts} +2 -2
  9. package/dist/{Toolkit-DAjJ9Tvl.d.mts.map → Toolkit-tlL0F2YR.d.mts.map} +1 -1
  10. package/dist/chunker/Chunker.d.mts +1 -1
  11. package/dist/domain/AiError.d.mts +2 -2
  12. package/dist/domain/AiError.mjs +9 -2
  13. package/dist/domain/AiError.mjs.map +1 -1
  14. package/dist/domain/Realtime.d.mts +616 -0
  15. package/dist/domain/Realtime.d.mts.map +1 -0
  16. package/dist/domain/Realtime.mjs +21 -0
  17. package/dist/domain/Realtime.mjs.map +1 -0
  18. package/dist/domain/Transcript.d.mts +17 -3
  19. package/dist/domain/Transcript.d.mts.map +1 -1
  20. package/dist/domain/Transcript.mjs +30 -1
  21. package/dist/domain/Transcript.mjs.map +1 -1
  22. package/dist/domain/Transcript.test.d.mts +1 -0
  23. package/dist/domain/Transcript.test.mjs +110 -0
  24. package/dist/domain/Transcript.test.mjs.map +1 -0
  25. package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
  26. package/dist/image-generator/ImageGenerator.d.mts +1 -1
  27. package/dist/index.d.mts +11 -4
  28. package/dist/index.mjs +9 -2
  29. package/dist/job/Job.d.mts +1 -1
  30. package/dist/language-model/LanguageModel.d.mts +2 -2
  31. package/dist/loop/Loop.d.mts +1 -1
  32. package/dist/messenger/Messenger.d.mts +472 -0
  33. package/dist/messenger/Messenger.d.mts.map +1 -0
  34. package/dist/messenger/Messenger.mjs +66 -0
  35. package/dist/messenger/Messenger.mjs.map +1 -0
  36. package/dist/messenger/MessengerAdapter.d.mts +35 -0
  37. package/dist/messenger/MessengerAdapter.d.mts.map +1 -0
  38. package/dist/messenger/MessengerAdapter.mjs +102 -0
  39. package/dist/messenger/MessengerAdapter.mjs.map +1 -0
  40. package/dist/messenger/MessengerAdapter.test.d.mts +1 -0
  41. package/dist/messenger/MessengerAdapter.test.mjs +138 -0
  42. package/dist/messenger/MessengerAdapter.test.mjs.map +1 -0
  43. package/dist/messenger/MessengerError.d.mts +2 -0
  44. package/dist/messenger/MessengerError.mjs +39 -0
  45. package/dist/messenger/MessengerError.mjs.map +1 -0
  46. package/dist/music-generator/MusicGenerator.d.mts +1 -1
  47. package/dist/realtime/RealtimeSession.d.mts +50 -0
  48. package/dist/realtime/RealtimeSession.d.mts.map +1 -0
  49. package/dist/realtime/RealtimeSession.mjs +45 -0
  50. package/dist/realtime/RealtimeSession.mjs.map +1 -0
  51. package/dist/realtime/RealtimeSession.test.d.mts +1 -0
  52. package/dist/realtime/RealtimeSession.test.mjs +95 -0
  53. package/dist/realtime/RealtimeSession.test.mjs.map +1 -0
  54. package/dist/reranker/Reranker.d.mts +1 -1
  55. package/dist/research/DeepResearch.d.mts +1 -1
  56. package/dist/retry/Retry.d.mts +1 -1
  57. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +1 -1
  58. package/dist/streaming/Settle.d.mts +45 -0
  59. package/dist/streaming/Settle.d.mts.map +1 -0
  60. package/dist/streaming/Settle.mjs +95 -0
  61. package/dist/streaming/Settle.mjs.map +1 -0
  62. package/dist/streaming/Settle.test.d.mts +1 -0
  63. package/dist/streaming/Settle.test.mjs +252 -0
  64. package/dist/streaming/Settle.test.mjs.map +1 -0
  65. package/dist/streaming/WebSocketSession.d.mts +36 -0
  66. package/dist/streaming/WebSocketSession.d.mts.map +1 -0
  67. package/dist/streaming/WebSocketSession.mjs +89 -0
  68. package/dist/streaming/WebSocketSession.mjs.map +1 -0
  69. package/dist/streaming/WebSocketSession.test.d.mts +1 -0
  70. package/dist/streaming/WebSocketSession.test.mjs +51 -0
  71. package/dist/streaming/WebSocketSession.test.mjs.map +1 -0
  72. package/dist/testing/FakeWebSocket.d.mts +25 -0
  73. package/dist/testing/FakeWebSocket.d.mts.map +1 -0
  74. package/dist/testing/FakeWebSocket.mjs +79 -0
  75. package/dist/testing/FakeWebSocket.mjs.map +1 -0
  76. package/dist/testing/MockMessenger.d.mts +192 -0
  77. package/dist/testing/MockMessenger.d.mts.map +1 -0
  78. package/dist/testing/MockMessenger.mjs +76 -0
  79. package/dist/testing/MockMessenger.mjs.map +1 -0
  80. package/dist/testing/MockRealtimeSession.d.mts +30 -0
  81. package/dist/testing/MockRealtimeSession.d.mts.map +1 -0
  82. package/dist/testing/MockRealtimeSession.mjs +52 -0
  83. package/dist/testing/MockRealtimeSession.mjs.map +1 -0
  84. package/dist/tool/Toolkit.d.mts +1 -1
  85. package/dist/transcriber/Transcriber.d.mts +1 -1
  86. package/dist/web-read/WebRead.d.mts +1 -1
  87. package/dist/web-read/WebReadTool.d.mts +1 -1
  88. package/dist/web-search/WebSearch.d.mts +1 -1
  89. package/dist/web-search/WebSearchTool.d.mts +1 -1
  90. package/package.json +41 -1
  91. package/src/domain/AiError.ts +11 -0
  92. package/src/domain/Realtime.ts +101 -0
  93. package/src/domain/Transcript.test.ts +98 -0
  94. package/src/domain/Transcript.ts +53 -1
  95. package/src/index.ts +7 -0
  96. package/src/messenger/Messenger.ts +293 -0
  97. package/src/messenger/MessengerAdapter.test.ts +179 -0
  98. package/src/messenger/MessengerAdapter.ts +189 -0
  99. package/src/messenger/MessengerError.ts +67 -0
  100. package/src/realtime/RealtimeSession.test.ts +100 -0
  101. package/src/realtime/RealtimeSession.ts +77 -0
  102. package/src/streaming/Settle.test.ts +344 -0
  103. package/src/streaming/Settle.ts +196 -0
  104. package/src/streaming/WebSocketSession.test.ts +52 -0
  105. package/src/streaming/WebSocketSession.ts +132 -0
  106. package/src/testing/FakeWebSocket.ts +120 -0
  107. package/src/testing/MockMessenger.ts +147 -0
  108. package/src/testing/MockRealtimeSession.ts +92 -0
@@ -0,0 +1,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
+ })
@@ -0,0 +1,189 @@
1
+ /**
2
+ * What an adapter author needs and a recipe author never sees: text
3
+ * splitting at a platform ceiling, and post-then-edit progressive delivery
4
+ * for platforms without a native streaming API.
5
+ */
6
+ import { Array as Arr, Clock, Data, Duration, Effect, Option, Schedule, Stream } from "effect"
7
+ import {
8
+ CurrentConversation,
9
+ type MessageId,
10
+ type MessengerService,
11
+ type StreamOptions,
12
+ text,
13
+ } from "./Messenger.js"
14
+ import type * as MessengerError from "./MessengerError.js"
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Text splitting
18
+ // ---------------------------------------------------------------------------
19
+
20
+ // Paragraph break first, then line break, then space.
21
+ const boundaries = ["\n\n", "\n", " "] as const
22
+
23
+ /** Best split at or before `limit`; a hard cut only for an unbroken run. */
24
+ const cutAt = (body: string, limit: number): readonly [head: string, rest: string] =>
25
+ body.length <= limit
26
+ ? [body, ""]
27
+ : Arr.findFirst(boundaries, (sep) =>
28
+ Option.map(
29
+ Option.liftPredicate(body.lastIndexOf(sep, limit - sep.length), (at) => at > 0),
30
+ (at) => [body.slice(0, at), body.slice(at + sep.length)] as const,
31
+ ),
32
+ ).pipe(Option.getOrElse(() => [body.slice(0, limit), body.slice(limit)] as const))
33
+
34
+ /**
35
+ * Chunk `body` into pieces no longer than `limit`, breaking on paragraph,
36
+ * line, then word boundaries. Always yields at least one chunk, so callers
37
+ * have something to send even for empty input.
38
+ */
39
+ export const splitForLimit = (body: string, limit: number): Arr.NonEmptyReadonlyArray<string> => {
40
+ const [head, rest] = cutAt(body, limit)
41
+ return rest.length === 0 ? [head] : [head, ...splitForLimit(rest, limit)]
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // streamViaEdits
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /** The slice of a messenger that {@link streamViaEdits} drives. */
49
+ export type EditableVerbs = Pick<MessengerService, "post" | "edit" | "limits">
50
+
51
+ export type StreamViaEditsOptions = {
52
+ /** Minimum wall time between edits. Default 1 second. */
53
+ readonly every?: Duration.Input
54
+ /** Minimum growth since the last edit before spending another. Default 40. */
55
+ readonly minChars?: number
56
+ /** How often to honour a `MessengerRateLimited` before giving up. Default 3. */
57
+ readonly rateLimitRetries?: number
58
+ }
59
+
60
+ const isRateLimited = (e: MessengerError.MessengerError): boolean =>
61
+ e._tag === "MessengerRateLimited"
62
+
63
+ // A retry schedule's input is the error, so the delay is the platform's own
64
+ // `retry_after` rather than a guess. Bounded by `recurs`, so an unlucky call
65
+ // eventually surfaces the rate limit instead of stalling behind it.
66
+ const honourRetryAfter = (
67
+ times: number,
68
+ ): Schedule.Schedule<number, MessengerError.MessengerError> =>
69
+ Schedule.recurs(times).pipe(
70
+ Schedule.setInputType<MessengerError.MessengerError>(),
71
+ Schedule.modifyDelay(({ input }) =>
72
+ Effect.succeed(input._tag === "MessengerRateLimited" ? input.retryAfter : Duration.zero),
73
+ ),
74
+ )
75
+
76
+ /**
77
+ * The message being filled: not on the platform yet, or posted and edited
78
+ * since. Only the first draft carries the reply; rollover drafts do not.
79
+ */
80
+ type Progress = Data.TaggedEnum<{
81
+ Draft: { readonly pending: string; readonly envelope: StreamOptions }
82
+ Sent: {
83
+ readonly id: MessageId
84
+ /** Text the message shows now. Never resent unchanged. */
85
+ readonly sent: string
86
+ /** Text it should show, deltas included. */
87
+ readonly pending: string
88
+ readonly lastFlush: number
89
+ }
90
+ }>
91
+
92
+ const Progress = Data.taggedEnum<Progress>()
93
+
94
+ type Sent = Data.TaggedEnum.Value<Progress, "Sent">
95
+
96
+ const appended = (s: Progress, delta: string): Progress => ({
97
+ ...s,
98
+ pending: s.pending + delta,
99
+ })
100
+
101
+ /**
102
+ * Progressive delivery as post-then-edit, for adapters with no native
103
+ * streaming API. Coalesces deltas by time and growth, never resends
104
+ * unchanged text, waits out `MessengerRateLimited`, rolls over to a fresh
105
+ * message past `limits.maxText`, and always flushes the tail.
106
+ *
107
+ * The first chunk goes out as soon as any text arrives, so the message shows
108
+ * up immediately and fills in from there. A stream that produced no text
109
+ * posts nothing and yields `None`.
110
+ */
111
+ export const streamViaEdits =
112
+ (verbs: EditableVerbs, options?: StreamViaEditsOptions): MessengerService["stream"] =>
113
+ (deltas, streamOptions = {}) =>
114
+ Effect.gen(function* () {
115
+ const every = Duration.toMillis(options?.every ?? "1 second")
116
+ const minChars = options?.minChars ?? 40
117
+ const schedule = honourRetryAfter(options?.rateLimitRetries ?? 3)
118
+ const conversation = yield* CurrentConversation
119
+
120
+ const patiently = <A, R2>(
121
+ effect: Effect.Effect<A, MessengerError.MessengerError, R2>,
122
+ ): Effect.Effect<A, MessengerError.MessengerError, R2> =>
123
+ Effect.retry(effect, { schedule, while: isRateLimited })
124
+
125
+ // Post a draft, edit a sent message, and skip an edit that would resend
126
+ // what the message already shows.
127
+ const deliver = (
128
+ s: Progress,
129
+ body: string,
130
+ now: number,
131
+ ): Effect.Effect<Sent, MessengerError.MessengerError, CurrentConversation> =>
132
+ Progress.$match(s, {
133
+ Draft: ({ pending, envelope }) =>
134
+ patiently(verbs.post(text(body, envelope))).pipe(
135
+ Effect.map((id) => Progress.Sent({ id, sent: body, pending, lastFlush: now })),
136
+ ),
137
+ Sent: (sent) =>
138
+ body === sent.sent
139
+ ? Effect.succeed(sent)
140
+ : patiently(verbs.edit({ conversation, id: sent.id }, text(body))).pipe(
141
+ Effect.as(Progress.Sent({ ...sent, sent: body, lastFlush: now })),
142
+ ),
143
+ })
144
+
145
+ // Anything past the ceiling becomes the next message: finish the current
146
+ // one at a clean boundary, then start a draft on the remainder.
147
+ const rollover = (
148
+ s: Progress,
149
+ now: number,
150
+ ): Effect.Effect<Progress, MessengerError.MessengerError, CurrentConversation> => {
151
+ const [head, rest] = cutAt(s.pending, verbs.limits.maxText)
152
+ return rest.length === 0
153
+ ? Effect.succeed(s)
154
+ : deliver(s, head, now).pipe(
155
+ Effect.flatMap(() => rollover(Progress.Draft({ pending: rest, envelope: {} }), now)),
156
+ )
157
+ }
158
+
159
+ // A draft lands on its first real text; a sent message waits for both
160
+ // gates. Blank is not text: a model that opens a tool-calling turn with
161
+ // a newline would otherwise post it, and platforms reject a message
162
+ // that is only whitespace.
163
+ const due = (s: Progress, now: number): boolean =>
164
+ Progress.$match(s, {
165
+ Draft: ({ pending }) => pending.trim().length > 0,
166
+ Sent: ({ sent, pending, lastFlush }) =>
167
+ now - lastFlush >= every && pending.length - sent.length >= minChars,
168
+ })
169
+
170
+ const step = (before: Progress, delta: string) =>
171
+ Effect.gen(function* () {
172
+ const now = yield* Clock.currentTimeMillis
173
+ const s = yield* rollover(appended(before, delta), now)
174
+ if (!due(s, now)) return s
175
+ return yield* deliver(s, s.pending, now)
176
+ })
177
+
178
+ const folded = yield* Stream.runFoldEffect(
179
+ deltas,
180
+ () => Progress.Draft({ pending: "", envelope: streamOptions }),
181
+ step,
182
+ )
183
+ const now = yield* Clock.currentTimeMillis
184
+ const s = yield* rollover(folded, now)
185
+ // The tail always lands; a draft that never held text is nothing to send.
186
+ if (s._tag === "Draft" && s.pending.trim().length === 0) return Option.none()
187
+ const final = yield* deliver(s, s.pending, now)
188
+ return Option.some(final.id)
189
+ })