@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.
- package/README.md +30 -16
- package/dist/{AiError-eLV9t1lU.d.mts → AiError-DvhLIbmd.d.mts} +15 -4
- package/dist/{AiError-eLV9t1lU.d.mts.map → AiError-DvhLIbmd.d.mts.map} +1 -1
- package/dist/{Loop-B54AB1ME.d.mts → Loop-ChpaO-ya.d.mts} +2 -2
- package/dist/{Loop-B54AB1ME.d.mts.map → Loop-ChpaO-ya.d.mts.map} +1 -1
- package/dist/MessengerError-CeK6VAcu.d.mts +64 -0
- package/dist/MessengerError-CeK6VAcu.d.mts.map +1 -0
- package/dist/{Toolkit-DAjJ9Tvl.d.mts → Toolkit-tlL0F2YR.d.mts} +2 -2
- package/dist/{Toolkit-DAjJ9Tvl.d.mts.map → Toolkit-tlL0F2YR.d.mts.map} +1 -1
- package/dist/chunker/Chunker.d.mts +1 -1
- package/dist/domain/AiError.d.mts +2 -2
- package/dist/domain/AiError.mjs +9 -2
- package/dist/domain/AiError.mjs.map +1 -1
- package/dist/domain/Realtime.d.mts +616 -0
- package/dist/domain/Realtime.d.mts.map +1 -0
- package/dist/domain/Realtime.mjs +21 -0
- package/dist/domain/Realtime.mjs.map +1 -0
- package/dist/domain/Transcript.d.mts +17 -3
- package/dist/domain/Transcript.d.mts.map +1 -1
- package/dist/domain/Transcript.mjs +30 -1
- package/dist/domain/Transcript.mjs.map +1 -1
- package/dist/domain/Transcript.test.d.mts +1 -0
- package/dist/domain/Transcript.test.mjs +110 -0
- package/dist/domain/Transcript.test.mjs.map +1 -0
- package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
- package/dist/image-generator/ImageGenerator.d.mts +1 -1
- package/dist/index.d.mts +11 -4
- package/dist/index.mjs +9 -2
- package/dist/job/Job.d.mts +1 -1
- package/dist/language-model/LanguageModel.d.mts +2 -2
- package/dist/loop/Loop.d.mts +1 -1
- package/dist/messenger/Messenger.d.mts +472 -0
- package/dist/messenger/Messenger.d.mts.map +1 -0
- package/dist/messenger/Messenger.mjs +66 -0
- package/dist/messenger/Messenger.mjs.map +1 -0
- package/dist/messenger/MessengerAdapter.d.mts +35 -0
- package/dist/messenger/MessengerAdapter.d.mts.map +1 -0
- package/dist/messenger/MessengerAdapter.mjs +102 -0
- package/dist/messenger/MessengerAdapter.mjs.map +1 -0
- package/dist/messenger/MessengerAdapter.test.d.mts +1 -0
- package/dist/messenger/MessengerAdapter.test.mjs +138 -0
- package/dist/messenger/MessengerAdapter.test.mjs.map +1 -0
- package/dist/messenger/MessengerError.d.mts +2 -0
- package/dist/messenger/MessengerError.mjs +39 -0
- package/dist/messenger/MessengerError.mjs.map +1 -0
- package/dist/music-generator/MusicGenerator.d.mts +1 -1
- package/dist/realtime/RealtimeSession.d.mts +50 -0
- package/dist/realtime/RealtimeSession.d.mts.map +1 -0
- package/dist/realtime/RealtimeSession.mjs +45 -0
- package/dist/realtime/RealtimeSession.mjs.map +1 -0
- package/dist/realtime/RealtimeSession.test.d.mts +1 -0
- package/dist/realtime/RealtimeSession.test.mjs +95 -0
- package/dist/realtime/RealtimeSession.test.mjs.map +1 -0
- package/dist/reranker/Reranker.d.mts +1 -1
- package/dist/research/DeepResearch.d.mts +1 -1
- package/dist/retry/Retry.d.mts +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +1 -1
- package/dist/streaming/Settle.d.mts +45 -0
- package/dist/streaming/Settle.d.mts.map +1 -0
- package/dist/streaming/Settle.mjs +95 -0
- package/dist/streaming/Settle.mjs.map +1 -0
- package/dist/streaming/Settle.test.d.mts +1 -0
- package/dist/streaming/Settle.test.mjs +252 -0
- package/dist/streaming/Settle.test.mjs.map +1 -0
- package/dist/streaming/WebSocketSession.d.mts +36 -0
- package/dist/streaming/WebSocketSession.d.mts.map +1 -0
- package/dist/streaming/WebSocketSession.mjs +89 -0
- package/dist/streaming/WebSocketSession.mjs.map +1 -0
- package/dist/streaming/WebSocketSession.test.d.mts +1 -0
- package/dist/streaming/WebSocketSession.test.mjs +51 -0
- package/dist/streaming/WebSocketSession.test.mjs.map +1 -0
- package/dist/testing/FakeWebSocket.d.mts +25 -0
- package/dist/testing/FakeWebSocket.d.mts.map +1 -0
- package/dist/testing/FakeWebSocket.mjs +79 -0
- package/dist/testing/FakeWebSocket.mjs.map +1 -0
- package/dist/testing/MockMessenger.d.mts +192 -0
- package/dist/testing/MockMessenger.d.mts.map +1 -0
- package/dist/testing/MockMessenger.mjs +76 -0
- package/dist/testing/MockMessenger.mjs.map +1 -0
- package/dist/testing/MockRealtimeSession.d.mts +30 -0
- package/dist/testing/MockRealtimeSession.d.mts.map +1 -0
- package/dist/testing/MockRealtimeSession.mjs +52 -0
- package/dist/testing/MockRealtimeSession.mjs.map +1 -0
- package/dist/tool/Toolkit.d.mts +1 -1
- package/dist/transcriber/Transcriber.d.mts +1 -1
- package/dist/web-read/WebRead.d.mts +1 -1
- package/dist/web-read/WebReadTool.d.mts +1 -1
- package/dist/web-search/WebSearch.d.mts +1 -1
- package/dist/web-search/WebSearchTool.d.mts +1 -1
- package/package.json +41 -1
- package/src/domain/AiError.ts +11 -0
- package/src/domain/Realtime.ts +101 -0
- package/src/domain/Transcript.test.ts +98 -0
- package/src/domain/Transcript.ts +53 -1
- package/src/index.ts +7 -0
- package/src/messenger/Messenger.ts +293 -0
- package/src/messenger/MessengerAdapter.test.ts +179 -0
- package/src/messenger/MessengerAdapter.ts +189 -0
- package/src/messenger/MessengerError.ts +67 -0
- package/src/realtime/RealtimeSession.test.ts +100 -0
- package/src/realtime/RealtimeSession.ts +77 -0
- package/src/streaming/Settle.test.ts +344 -0
- package/src/streaming/Settle.ts +196 -0
- package/src/streaming/WebSocketSession.test.ts +52 -0
- package/src/streaming/WebSocketSession.ts +132 -0
- package/src/testing/FakeWebSocket.ts +120 -0
- package/src/testing/MockMessenger.ts +147 -0
- 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
|
+
})
|