@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
@@ -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
+ })
@@ -0,0 +1,67 @@
1
+ import { Data, type Duration, Match } from "effect"
2
+
3
+ // `reason` is required wherever one exists: the platform always says
4
+ // something, and an adapter raising `Unsupported` knows why it did.
5
+
6
+ /** Transport never came up: bad token, rejected handshake. Raised at layer build. */
7
+ export class MessengerConnectFailed extends Data.TaggedError("MessengerConnectFailed")<{
8
+ provider: string
9
+ reason: string
10
+ raw: unknown
11
+ }> {}
12
+
13
+ /**
14
+ * Inbound transport ended for good (reconnect budget spent, fatal close).
15
+ * Routine reconnects stay inside the adapter and never surface here.
16
+ */
17
+ export class MessengerTransportClosed extends Data.TaggedError("MessengerTransportClosed")<{
18
+ provider: string
19
+ reason: string
20
+ raw?: unknown
21
+ }> {}
22
+
23
+ /** Which verb was rejected. */
24
+ export type MessengerOperation = "post" | "edit" | "react" | "typing"
25
+
26
+ /** An outbound verb was rejected: permission, unknown chat, text too long, no-op edit. */
27
+ export class MessengerRequestFailed extends Data.TaggedError("MessengerRequestFailed")<{
28
+ provider: string
29
+ operation: MessengerOperation
30
+ reason: string
31
+ raw: unknown
32
+ }> {}
33
+
34
+ /**
35
+ * Slow down, for this long. Typed rather than retried blindly so callers -
36
+ * `streamViaEdits` above all - decide whether waiting is still worth it.
37
+ */
38
+ export class MessengerRateLimited extends Data.TaggedError("MessengerRateLimited")<{
39
+ provider: string
40
+ retryAfter: Duration.Duration
41
+ raw?: unknown
42
+ }> {}
43
+
44
+ /** The platform cannot express this at all: off-set reaction emoji, edits on WhatsApp. */
45
+ export class MessengerUnsupported extends Data.TaggedError("MessengerUnsupported")<{
46
+ provider: string
47
+ capability: string
48
+ reason: string
49
+ }> {}
50
+
51
+ export type MessengerError =
52
+ | MessengerConnectFailed
53
+ | MessengerTransportClosed
54
+ | MessengerRequestFailed
55
+ | MessengerRateLimited
56
+ | MessengerUnsupported
57
+
58
+ /** Human-readable description for logs. Prose, not a contract; branch on `_tag`. */
59
+ export const describe: (e: MessengerError) => string = Match.type<MessengerError>().pipe(
60
+ Match.discriminatorsExhaustive("_tag")({
61
+ MessengerConnectFailed: (e) => `the messenger could not connect: ${e.reason}`,
62
+ MessengerTransportClosed: (e) => `the messenger connection closed: ${e.reason}`,
63
+ MessengerRequestFailed: (e) => `the ${e.operation} was rejected: ${e.reason}`,
64
+ MessengerRateLimited: () => "the messenger is rate limited",
65
+ MessengerUnsupported: (e) => `this messenger does not support ${e.capability}: ${e.reason}`,
66
+ }),
67
+ )
@@ -0,0 +1,147 @@
1
+ import { Array as Arr, Data, Effect, Layer, Option, Ref, Stream } from "effect"
2
+ import {
3
+ type ConversationRef,
4
+ CurrentConversation,
5
+ type InboundEvent,
6
+ MessageId,
7
+ type MessageRef,
8
+ Messenger,
9
+ type MessengerLimits,
10
+ type MessengerService,
11
+ type Outbound,
12
+ } from "../messenger/Messenger.js"
13
+ import {
14
+ type StreamViaEditsOptions,
15
+ splitForLimit,
16
+ streamViaEdits,
17
+ } from "../messenger/MessengerAdapter.js"
18
+ import * as MessengerError from "../messenger/MessengerError.js"
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Call log
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * One entry per outbound call. `Post` is logged once even when the text was
26
+ * long enough to split, so a caller that rolled over on its own is
27
+ * distinguishable from one that leaned on `post` to do it.
28
+ */
29
+ export type Call = Data.TaggedEnum<{
30
+ Post: { readonly conversation: ConversationRef; readonly message: Outbound }
31
+ Edit: { readonly message: MessageRef; readonly next: Outbound }
32
+ React: { readonly message: MessageRef; readonly emoji: string }
33
+ TypingStart: { readonly conversation: ConversationRef }
34
+ TypingStop: { readonly conversation: ConversationRef }
35
+ }>
36
+
37
+ export const Call = Data.taggedEnum<Call>()
38
+
39
+ export type MockMessengerRecorder = {
40
+ readonly calls: ReadonlyArray<Call>
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Script
45
+ // ---------------------------------------------------------------------------
46
+
47
+ export type MockMessengerScript = {
48
+ /** Inbound events to replay. Empty by default. */
49
+ readonly events?: Stream.Stream<InboundEvent, MessengerError.MessengerError>
50
+ readonly limits?: MessengerLimits
51
+ /**
52
+ * Failures handed back one per `post` / `edit` call, in order; `undefined`
53
+ * entries succeed, and everything past the end succeeds.
54
+ */
55
+ readonly failures?: ReadonlyArray<MessengerError.MessengerError | undefined>
56
+ /** Passed through to `streamViaEdits`, which backs `stream`. */
57
+ readonly streamOptions?: StreamViaEditsOptions
58
+ }
59
+
60
+ const defaultLimits: MessengerLimits = { maxText: 4096, maxCaption: 1024 }
61
+
62
+ const bodyText = (msg: Outbound): Option.Option<string> =>
63
+ msg.body._tag === "Text" ? Option.some(msg.body.text) : Option.none()
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Service builder
67
+ // ---------------------------------------------------------------------------
68
+
69
+ const buildService = (
70
+ script: MockMessengerScript,
71
+ record: (call: Call) => Effect.Effect<void>,
72
+ cursor: Ref.Ref<number>,
73
+ minted: Ref.Ref<number>,
74
+ ): MessengerService => {
75
+ const limits = script.limits ?? defaultLimits
76
+
77
+ const nextFailure = Ref.getAndUpdate(cursor, (n) => n + 1).pipe(
78
+ Effect.flatMap((i) =>
79
+ Option.match(Option.fromNullishOr((script.failures ?? [])[i]), {
80
+ onNone: () => Effect.void,
81
+ onSome: Effect.fail<MessengerError.MessengerError>,
82
+ }),
83
+ ),
84
+ )
85
+
86
+ const mint = Ref.updateAndGet(minted, (n) => n + 1).pipe(Effect.map((n) => MessageId(`m${n}`)))
87
+
88
+ const post: MessengerService["post"] = (msg) =>
89
+ Effect.gen(function* () {
90
+ const conversation = yield* CurrentConversation
91
+ yield* record(Call.Post({ conversation, message: msg }))
92
+ yield* nextFailure
93
+ // One id per chunk, mirroring an adapter that splits at the ceiling.
94
+ const chunks = Option.match(bodyText(msg), {
95
+ onNone: () => 1,
96
+ onSome: (t) => splitForLimit(t, limits.maxText).length,
97
+ })
98
+ return yield* Effect.map(
99
+ Effect.forEach(Arr.range(1, chunks), () => mint),
100
+ Arr.lastNonEmpty,
101
+ )
102
+ })
103
+
104
+ const edit: MessengerService["edit"] = (msg, next) =>
105
+ record(Call.Edit({ message: msg, next })).pipe(Effect.andThen(nextFailure))
106
+
107
+ return {
108
+ events: script.events ?? Stream.empty,
109
+ post,
110
+ edit,
111
+ react: (msg, emoji) => record(Call.React({ message: msg, emoji })),
112
+ typing: Effect.gen(function* () {
113
+ const conversation = yield* CurrentConversation
114
+ yield* record(Call.TypingStart({ conversation }))
115
+ yield* Effect.addFinalizer(() => record(Call.TypingStop({ conversation })))
116
+ }),
117
+ stream: streamViaEdits({ post, edit, limits }, script.streamOptions),
118
+ limits,
119
+ }
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Public API
124
+ // ---------------------------------------------------------------------------
125
+
126
+ const makeUnsafe = (script: MockMessengerScript) => {
127
+ const calls = Ref.makeUnsafe<ReadonlyArray<Call>>([])
128
+ const service = buildService(
129
+ script,
130
+ (call) => Ref.update(calls, Arr.append(call)),
131
+ Ref.makeUnsafe(0),
132
+ Ref.makeUnsafe(0),
133
+ )
134
+ return {
135
+ service,
136
+ recorder: Ref.get(calls).pipe(Effect.map((c): MockMessengerRecorder => ({ calls: c }))),
137
+ }
138
+ }
139
+
140
+ /** Layer registering the mock against the `Messenger` tag, plus its recorder. */
141
+ export const layer = (script: MockMessengerScript = {}) => {
142
+ const { service, recorder } = makeUnsafe(script)
143
+ return { layer: Layer.succeed(Messenger, service), recorder }
144
+ }
145
+
146
+ /** Bare service value + recorder. Use with `Effect.provideService`. */
147
+ export const make = (script: MockMessengerScript = {}) => makeUnsafe(script)