@effect-uai/core 0.15.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 (91) 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/{Toolkit-DAjJ9Tvl.d.mts → Toolkit-tlL0F2YR.d.mts} +2 -2
  7. package/dist/{Toolkit-DAjJ9Tvl.d.mts.map → Toolkit-tlL0F2YR.d.mts.map} +1 -1
  8. package/dist/chunker/Chunker.d.mts +1 -1
  9. package/dist/domain/AiError.d.mts +2 -2
  10. package/dist/domain/AiError.mjs +9 -2
  11. package/dist/domain/AiError.mjs.map +1 -1
  12. package/dist/domain/Realtime.d.mts +616 -0
  13. package/dist/domain/Realtime.d.mts.map +1 -0
  14. package/dist/domain/Realtime.mjs +21 -0
  15. package/dist/domain/Realtime.mjs.map +1 -0
  16. package/dist/domain/Transcript.d.mts +17 -3
  17. package/dist/domain/Transcript.d.mts.map +1 -1
  18. package/dist/domain/Transcript.mjs +30 -1
  19. package/dist/domain/Transcript.mjs.map +1 -1
  20. package/dist/domain/Transcript.test.mjs +110 -0
  21. package/dist/domain/Transcript.test.mjs.map +1 -0
  22. package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
  23. package/dist/image-generator/ImageGenerator.d.mts +1 -1
  24. package/dist/index.d.mts +8 -5
  25. package/dist/index.mjs +6 -3
  26. package/dist/job/Job.d.mts +1 -1
  27. package/dist/language-model/LanguageModel.d.mts +2 -2
  28. package/dist/loop/Loop.d.mts +1 -1
  29. package/dist/music-generator/MusicGenerator.d.mts +1 -1
  30. package/dist/realtime/RealtimeSession.d.mts +50 -0
  31. package/dist/realtime/RealtimeSession.d.mts.map +1 -0
  32. package/dist/realtime/RealtimeSession.mjs +45 -0
  33. package/dist/realtime/RealtimeSession.mjs.map +1 -0
  34. package/dist/realtime/RealtimeSession.test.d.mts +1 -0
  35. package/dist/realtime/RealtimeSession.test.mjs +95 -0
  36. package/dist/realtime/RealtimeSession.test.mjs.map +1 -0
  37. package/dist/reranker/Reranker.d.mts +1 -1
  38. package/dist/research/DeepResearch.d.mts +1 -1
  39. package/dist/retry/Retry.d.mts +1 -1
  40. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +1 -1
  41. package/dist/streaming/Settle.d.mts +45 -0
  42. package/dist/streaming/Settle.d.mts.map +1 -0
  43. package/dist/streaming/Settle.mjs +95 -0
  44. package/dist/streaming/Settle.mjs.map +1 -0
  45. package/dist/streaming/Settle.test.d.mts +1 -0
  46. package/dist/streaming/Settle.test.mjs +252 -0
  47. package/dist/streaming/Settle.test.mjs.map +1 -0
  48. package/dist/streaming/WebSocketSession.d.mts +36 -0
  49. package/dist/streaming/WebSocketSession.d.mts.map +1 -0
  50. package/dist/streaming/WebSocketSession.mjs +89 -0
  51. package/dist/streaming/WebSocketSession.mjs.map +1 -0
  52. package/dist/streaming/WebSocketSession.test.d.mts +1 -0
  53. package/dist/streaming/WebSocketSession.test.mjs +51 -0
  54. package/dist/streaming/WebSocketSession.test.mjs.map +1 -0
  55. package/dist/testing/FakeWebSocket.d.mts +25 -0
  56. package/dist/testing/FakeWebSocket.d.mts.map +1 -0
  57. package/dist/testing/FakeWebSocket.mjs +79 -0
  58. package/dist/testing/FakeWebSocket.mjs.map +1 -0
  59. package/dist/testing/MockRealtimeSession.d.mts +30 -0
  60. package/dist/testing/MockRealtimeSession.d.mts.map +1 -0
  61. package/dist/testing/MockRealtimeSession.mjs +52 -0
  62. package/dist/testing/MockRealtimeSession.mjs.map +1 -0
  63. package/dist/tool/Toolkit.d.mts +1 -1
  64. package/dist/transcriber/Transcriber.d.mts +1 -1
  65. package/dist/web-read/WebRead.d.mts +1 -1
  66. package/dist/web-read/WebReadTool.d.mts +1 -1
  67. package/dist/web-search/WebSearch.d.mts +1 -1
  68. package/dist/web-search/WebSearchTool.d.mts +1 -1
  69. package/package.json +24 -4
  70. package/src/domain/AiError.ts +11 -0
  71. package/src/domain/Realtime.ts +101 -0
  72. package/src/domain/Transcript.test.ts +98 -0
  73. package/src/domain/Transcript.ts +53 -1
  74. package/src/index.ts +4 -1
  75. package/src/realtime/RealtimeSession.test.ts +100 -0
  76. package/src/realtime/RealtimeSession.ts +77 -0
  77. package/src/streaming/Settle.test.ts +344 -0
  78. package/src/streaming/Settle.ts +196 -0
  79. package/src/streaming/WebSocketSession.test.ts +52 -0
  80. package/src/streaming/WebSocketSession.ts +132 -0
  81. package/src/testing/FakeWebSocket.ts +120 -0
  82. package/src/testing/MockRealtimeSession.ts +92 -0
  83. package/dist/inbox/Inbox.d.mts +0 -16
  84. package/dist/inbox/Inbox.d.mts.map +0 -1
  85. package/dist/inbox/Inbox.mjs +0 -28
  86. package/dist/inbox/Inbox.mjs.map +0 -1
  87. package/dist/inbox/Inbox.test.mjs +0 -42
  88. package/dist/inbox/Inbox.test.mjs.map +0 -1
  89. package/src/inbox/Inbox.test.ts +0 -52
  90. package/src/inbox/Inbox.ts +0 -33
  91. /package/dist/{inbox/Inbox.test.d.mts → domain/Transcript.test.d.mts} +0 -0
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Acting on a pause in a stream of arrivals.
3
+ *
4
+ * A chat user types three lines then stops; a transcriber emits token deltas
5
+ * then goes quiet. Both want a resetting window: every arrival pushes the
6
+ * deadline out. Effect's `groupedWithin` measures a fixed window from the first
7
+ * element of a batch and `debounce` drops all but the last element, so neither
8
+ * expresses this.
9
+ *
10
+ * `settleBurst` withholds elements until a burst ends; `onQuiet` holds nothing
11
+ * back and only adds an event once things go still.
12
+ */
13
+ import {
14
+ Array as Arr,
15
+ Cause,
16
+ Clock,
17
+ Deferred,
18
+ Duration,
19
+ Effect,
20
+ Function,
21
+ Option,
22
+ Pull,
23
+ Queue,
24
+ Ref,
25
+ Result,
26
+ Stream,
27
+ } from "effect"
28
+
29
+ /** Buffer between a source stream and the batcher. 64 matches the realtime adapters. */
30
+ const DEFAULT_CAPACITY = 64
31
+
32
+ /**
33
+ * Block for the first item, then keep taking while the next one lands within
34
+ * `settle` of the previous. The window resets on every arrival, so a burst
35
+ * becomes one batch and a lone message followed by silence returns at once.
36
+ *
37
+ * A take that loses to the window is interrupted before it removes anything,
38
+ * so late arrivals stay queued for the next drain. The queue is the caller's:
39
+ * it owns the backpressure strategy.
40
+ */
41
+ export const drainBurst = <A, E>(
42
+ queue: Queue.Dequeue<A, E>,
43
+ settle: Duration.Input,
44
+ ): Effect.Effect<Arr.NonEmptyReadonlyArray<A>, E> => {
45
+ const settled = (
46
+ batch: Arr.NonEmptyReadonlyArray<A>,
47
+ ): Effect.Effect<Arr.NonEmptyReadonlyArray<A>, E> =>
48
+ Queue.take(queue).pipe(
49
+ Effect.timeoutOption(settle),
50
+ Effect.flatMap(
51
+ Option.match({
52
+ onNone: () => Effect.succeed(batch),
53
+ onSome: (item) => settled(Arr.append(batch, item)),
54
+ }),
55
+ ),
56
+ // A queue that ends mid-burst yields the batch in hand. Interrupts and
57
+ // real failures still propagate.
58
+ Effect.catchCause((cause) =>
59
+ Pull.isDoneCause(cause) ? Effect.succeed(batch) : Effect.failCause(cause),
60
+ ),
61
+ )
62
+ return Effect.flatMap(Queue.take(queue), (first) => settled([first]))
63
+ }
64
+
65
+ export type SettleBurstOptions = {
66
+ /** Elements buffered ahead of the batcher. A full buffer suspends the source. */
67
+ readonly capacity?: number
68
+ }
69
+
70
+ /**
71
+ * `drainBurst` over a stream: one array per burst, nothing while a burst is
72
+ * still growing.
73
+ *
74
+ * Detecting a pause means reading ahead of the consumer, so this buffers by
75
+ * construction. The buffer is bounded, so a slow consumer suspends the source
76
+ * instead of growing memory.
77
+ */
78
+ export const settleBurst: {
79
+ (
80
+ settle: Duration.Input,
81
+ options?: SettleBurstOptions,
82
+ ): <A, E, R>(self: Stream.Stream<A, E, R>) => Stream.Stream<ReadonlyArray<A>, E, R>
83
+ <A, E, R>(
84
+ self: Stream.Stream<A, E, R>,
85
+ settle: Duration.Input,
86
+ options?: SettleBurstOptions,
87
+ ): Stream.Stream<ReadonlyArray<A>, E, R>
88
+ } = Function.dual(
89
+ (args) => Stream.isStream(args[0]),
90
+ <A, E, R>(
91
+ self: Stream.Stream<A, E, R>,
92
+ settle: Duration.Input,
93
+ options?: SettleBurstOptions,
94
+ ): Stream.Stream<ReadonlyArray<A>, E, R> =>
95
+ Stream.unwrap(
96
+ Effect.gen(function* () {
97
+ // `Cause.Done` as the failure type is what makes `Queue.end` available;
98
+ // `Queue.shutdown` would clear items still waiting to be batched.
99
+ const queue = yield* Queue.bounded<A, E | Cause.Done>(options?.capacity ?? DEFAULT_CAPACITY)
100
+ yield* Stream.runForEach(self, (a) => Queue.offer(queue, a)).pipe(
101
+ // A failing source must reach the consumer. Ending the queue instead
102
+ // would present the failure as a clean end of stream.
103
+ Effect.matchCauseEffect({
104
+ onFailure: (cause) =>
105
+ Cause.hasFails(cause) ? Queue.failCause(queue, cause) : Queue.end(queue),
106
+ onSuccess: () => Queue.end(queue),
107
+ }),
108
+ Effect.forkScoped,
109
+ )
110
+ return Stream.fromPull(
111
+ Effect.succeed(Effect.map(drainBurst(queue, settle), (batch) => [batch] as const)),
112
+ )
113
+ }),
114
+ ),
115
+ )
116
+
117
+ /**
118
+ * Pass every element through the moment it arrives, and emit whatever `emit`
119
+ * yields once nothing has arrived for `settle`. At most one emission per quiet
120
+ * period: the next element re-arms it.
121
+ *
122
+ * Nothing is held back, so this suits live output that wants a boundary marker
123
+ * rather than batching. Ends with the source.
124
+ */
125
+ export const onQuiet: {
126
+ <B>(
127
+ settle: Duration.Input,
128
+ emit: Effect.Effect<Option.Option<B>>,
129
+ ): <A, E, R>(self: Stream.Stream<A, E, R>) => Stream.Stream<A | B, E, R>
130
+ <A, E, R, B>(
131
+ self: Stream.Stream<A, E, R>,
132
+ settle: Duration.Input,
133
+ emit: Effect.Effect<Option.Option<B>>,
134
+ ): Stream.Stream<A | B, E, R>
135
+ } = Function.dual(
136
+ 3,
137
+ <A, E, R, B>(
138
+ self: Stream.Stream<A, E, R>,
139
+ settle: Duration.Input,
140
+ emit: Effect.Effect<Option.Option<B>>,
141
+ ): Stream.Stream<A | B, E, R> =>
142
+ Stream.unwrap(
143
+ Effect.gen(function* () {
144
+ const settleMs = Duration.toMillis(settle)
145
+ const lastAt = yield* Ref.make(0)
146
+ // Opened by the first arrival after each emission. An idle stream never
147
+ // wakes the metronome at all.
148
+ const gate = yield* Ref.make(yield* Deferred.make<void>())
149
+
150
+ const source = self.pipe(
151
+ Stream.tap(() =>
152
+ Effect.gen(function* () {
153
+ // Stamp before opening: the metronome re-reads the stamp after it
154
+ // swaps the gate, so an arrival that races the swap still counts.
155
+ const now = yield* Clock.currentTimeMillis
156
+ yield* Ref.set(lastAt, now)
157
+ const open = yield* Ref.get(gate)
158
+ yield* Deferred.succeed(open, undefined)
159
+ }),
160
+ ),
161
+ )
162
+
163
+ const quietFor = Effect.map(
164
+ Effect.all([Clock.currentTimeMillis, Ref.get(lastAt)]),
165
+ ([now, last]) => now - last,
166
+ )
167
+
168
+ // Waits for the gate, then sleeps out whatever is left of the window.
169
+ // Never polls, whether the source is busy or idle.
170
+ const tick = Effect.gen(function* () {
171
+ yield* Effect.flatMap(Ref.get(gate), Deferred.await)
172
+ const elapsed = yield* quietFor
173
+ if (elapsed < settleMs) {
174
+ yield* Effect.sleep(Duration.millis(settleMs - elapsed))
175
+ return Option.none<B>()
176
+ }
177
+ const next = yield* Deferred.make<void>()
178
+ yield* Ref.set(gate, next)
179
+ // An arrival between the check and the swap opened the old gate, so
180
+ // carry it over rather than sleeping through the burst it started.
181
+ if ((yield* quietFor) < settleMs) {
182
+ yield* Deferred.succeed(next, undefined)
183
+ return Option.none<B>()
184
+ }
185
+ return yield* emit
186
+ })
187
+
188
+ const metronome = Stream.fromEffect(tick).pipe(
189
+ Stream.forever,
190
+ Stream.filterMap((o) => (Option.isSome(o) ? Result.succeed(o.value) : Result.failVoid)),
191
+ )
192
+
193
+ return source.pipe(Stream.merge(metronome, { haltStrategy: "left" }))
194
+ }),
195
+ ),
196
+ )
@@ -0,0 +1,52 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import * as Socket from "effect/unstable/socket/Socket"
3
+ import { expect } from "vitest"
4
+ import * as AiError from "../domain/AiError.js"
5
+ import { toAiError } from "./WebSocketSession.js"
6
+
7
+ const KEY = "AIzaSyEXAMPLEKEY"
8
+ const URL = `wss://generativelanguage.googleapis.com/ws/BidiGenerateContent?key=${KEY}`
9
+
10
+ /**
11
+ * What Bun hands the `error` listener when the upgrade is rejected: the whole
12
+ * socket URL, in prose, on a prototype getter. Node and Deno say less, but the
13
+ * adapter cannot know which runtime it is on.
14
+ */
15
+ class BunErrorEvent {
16
+ get message(): string {
17
+ return `WebSocket connection to '${URL}' failed: Connection ended`
18
+ }
19
+ }
20
+
21
+ /** Deno names the status but not the URL, also on a prototype getter. */
22
+ class DenoErrorEvent {
23
+ constructor(private readonly detail: string) {}
24
+ get message(): string {
25
+ return `NetworkError: failed to connect to WebSocket: ${this.detail}`
26
+ }
27
+ }
28
+
29
+ const openFailure = (cause: unknown) =>
30
+ new Socket.SocketError({ reason: new Socket.SocketOpenError({ kind: "Unknown", cause }) })
31
+
32
+ describe("WebSocketSession errors", () => {
33
+ it("keeps the key out of the error a rejected upgrade produces", () => {
34
+ const error = toAiError("google")(openFailure(new BunErrorEvent()))
35
+
36
+ expect(JSON.stringify(error)).not.toContain(KEY)
37
+ expect(AiError.describe(error)).not.toContain(KEY)
38
+ // The host is still there: an error that says nothing is its own problem.
39
+ expect(JSON.stringify(error)).toContain("generativelanguage.googleapis.com")
40
+ })
41
+
42
+ it("reads the rejected status out of the prose, wherever the runtime put it", () => {
43
+ // Deno's wording. The status is the only hint a rejected upgrade gives.
44
+ const forbidden = toAiError("google")(
45
+ openFailure(new DenoErrorEvent("Invalid status code: 403")),
46
+ )
47
+ const ended = toAiError("google")(openFailure(new BunErrorEvent()))
48
+
49
+ expect(forbidden._tag).toBe("AuthFailed")
50
+ expect(ended._tag).toBe("Unavailable")
51
+ })
52
+ })
@@ -0,0 +1,132 @@
1
+ /**
2
+ * One JSON-over-WebSocket session: connect, read decoded frames off a queue,
3
+ * write frames back.
4
+ *
5
+ * For duplex protocols where the caller drives the conversation frame by
6
+ * frame. The stream-shaped adapters (realtime STT and TTS) still carry their
7
+ * own copies of this.
8
+ */
9
+ import {
10
+ Cause,
11
+ type Duration,
12
+ Effect,
13
+ Match,
14
+ Option,
15
+ Predicate,
16
+ Queue,
17
+ Schema,
18
+ type Scope,
19
+ } from "effect"
20
+ import * as Socket from "effect/unstable/socket/Socket"
21
+ import * as AiError from "../domain/AiError.js"
22
+ import * as JSONL from "./JSONL.js"
23
+
24
+ export type WebSocketSession<A> = {
25
+ /** Fails `Unavailable` once the socket is closed. */
26
+ readonly send: (frame: string) => Effect.Effect<void, AiError.AiError>
27
+ /** Ends when the socket closes cleanly, fails when it does not. */
28
+ readonly frames: Queue.Dequeue<A, AiError.AiError | Cause.Done>
29
+ }
30
+
31
+ export type OpenOptions<A, I> = {
32
+ readonly url: string
33
+ readonly provider: string
34
+ /** Frames that do not decode are dropped, and logged at debug level. */
35
+ readonly schema: Schema.Codec<A, I>
36
+ readonly openTimeout?: Duration.Input
37
+ readonly capacity?: number
38
+ }
39
+
40
+ /**
41
+ * The cause of a failed open is a DOM `ErrorEvent`, not an `Error`, and its
42
+ * `message` is a prototype getter, so a structural decode does not see it.
43
+ */
44
+ const describeCause = (cause: unknown): string =>
45
+ Predicate.hasProperty(cause, "message") && Predicate.isString(cause.message)
46
+ ? cause.message
47
+ : String(cause)
48
+
49
+ /**
50
+ * Some wires authenticate with a key in the query string, and some runtimes
51
+ * name the socket URL in their open error (Bun does). Everything taken off a
52
+ * socket error goes through this.
53
+ */
54
+ export const redactUrl = (text: string): string =>
55
+ text.replace(/(\b(?:wss?|https?):\/\/[^\s"']*?)\?[^\s"']*/gi, "$1?<redacted>")
56
+
57
+ const detailOf = Match.type<Socket.SocketError["reason"]>().pipe(
58
+ Match.tag("SocketCloseError", (reason) => reason.message),
59
+ Match.orElse((reason) => describeCause(reason.cause)),
60
+ )
61
+
62
+ /** Never the `SocketError` itself: callers print `raw` with `Cause.pretty`. */
63
+ const rawOf = (error: Socket.SocketError): string =>
64
+ redactUrl(`${error.reason._tag}: ${detailOf(error.reason)}`)
65
+
66
+ /** A rejected WS upgrade surfaces only as prose, so the status is read from it. */
67
+ const isAuthRejection = (text: string): boolean => /\b40[13]\b/.test(text)
68
+
69
+ export const toAiError =
70
+ (provider: string) =>
71
+ (error: Socket.SocketError): AiError.AiError => {
72
+ const raw = rawOf(error)
73
+ return Match.value(error.reason).pipe(
74
+ Match.tag("SocketOpenError", (reason) =>
75
+ Match.value(reason.kind).pipe(
76
+ Match.when("Timeout", (): AiError.AiError => new AiError.Timeout({ provider, raw })),
77
+ Match.orElse((): AiError.AiError =>
78
+ isAuthRejection(describeCause(reason.cause))
79
+ ? new AiError.AuthFailed({ provider, subtype: "auth", raw })
80
+ : new AiError.Unavailable({ provider, raw }),
81
+ ),
82
+ ),
83
+ ),
84
+ Match.orElse((): AiError.AiError => new AiError.Unavailable({ provider, raw })),
85
+ )
86
+ }
87
+
88
+ /**
89
+ * Connect for the lifetime of the surrounding `Scope`. The reader runs in a
90
+ * forked fiber; a dirty close reaches the consumer as a failed queue rather
91
+ * than a clean end.
92
+ */
93
+ export const open = <A, I>(
94
+ options: OpenOptions<A, I>,
95
+ ): Effect.Effect<WebSocketSession<A>, AiError.AiError, Scope.Scope | Socket.WebSocketConstructor> =>
96
+ Effect.gen(function* () {
97
+ const asAiError = toAiError(options.provider)
98
+ const decode = Schema.decodeUnknownEffect(options.schema)
99
+ const socket = yield* Socket.makeWebSocket(options.url, {
100
+ // Effect's Socket treats every close code as an error by default.
101
+ closeCodeIsError: (code) => code !== 1000 && code !== 1001 && code !== 1005,
102
+ ...(options.openTimeout !== undefined && { openTimeout: options.openTimeout }),
103
+ })
104
+ const frames = yield* Queue.bounded<A, AiError.AiError | Cause.Done>(options.capacity ?? 64)
105
+ const write = yield* socket.writer
106
+
107
+ const onFrame = (raw: string) =>
108
+ Effect.gen(function* () {
109
+ const json = yield* JSONL.parseSafe(raw)
110
+ if (json === undefined) return
111
+ const decoded = yield* decode(json).pipe(Effect.option)
112
+ if (Option.isNone(decoded)) {
113
+ yield* Effect.logDebug(`[${options.provider}] dropped frame`, { frame: json })
114
+ return
115
+ }
116
+ yield* Queue.offer(frames, decoded.value)
117
+ })
118
+
119
+ yield* socket.runString(onFrame).pipe(
120
+ Effect.mapError(asAiError),
121
+ // `Queue.end` on a clean close so the consumer drains what is queued;
122
+ // a failure must not arrive looking like the end of the conversation.
123
+ Effect.matchCauseEffect({
124
+ onFailure: (cause) =>
125
+ Cause.hasFails(cause) ? Queue.failCause(frames, cause) : Queue.end(frames),
126
+ onSuccess: () => Queue.end(frames),
127
+ }),
128
+ Effect.forkScoped,
129
+ )
130
+
131
+ return { send: (frame: string) => write(frame).pipe(Effect.mapError(asAiError)), frames }
132
+ })
@@ -0,0 +1,120 @@
1
+ /**
2
+ * An in-memory WebSocket for testing socket adapters without a server.
3
+ *
4
+ * Provide `layer` in place of the real `Socket.WebSocketConstructor` and the
5
+ * adapter runs end to end: its own connect, handshake, decode and teardown.
6
+ * The test plays the other end with `reply`, `push`, `close` and `sent`.
7
+ *
8
+ * Client frames land on a queue, so the scripted server is a forked fiber and
9
+ * `reply` is a real Effect. What stays imperative is the listener table the
10
+ * shim registers, since `Socket.makeWebSocket` drives it with plain callbacks.
11
+ * The shim is a plain object, so nothing here touches a platform global.
12
+ */
13
+ import { Effect, Layer, Queue, Ref, type Scope, Stream } from "effect"
14
+ import * as Socket from "effect/unstable/socket/Socket"
15
+
16
+ export type FakeWebSocketServer = {
17
+ /** Every frame the client has sent, JSON-parsed, oldest first. */
18
+ readonly sent: Effect.Effect<ReadonlyArray<unknown>>
19
+ /** Deliver one frame to the client. */
20
+ readonly push: (frame: unknown) => Effect.Effect<void>
21
+ /** Close the socket. 1000, 1001 and 1005 are the clean codes. */
22
+ readonly close: (code?: number) => Effect.Effect<void>
23
+ /** Hand this to an adapter that takes a socket constructor directly. */
24
+ readonly connect: Socket.WebSocketConstructor["Service"]
25
+ /** Provide this in place of the real constructor. */
26
+ readonly layer: Layer.Layer<Socket.WebSocketConstructor>
27
+ }
28
+
29
+ export type FakeWebSocketOptions = {
30
+ /** Pushed once the client subscribes, for a protocol whose server speaks first. */
31
+ readonly greeting?: ReadonlyArray<unknown>
32
+ /** How the server answers each client frame. */
33
+ readonly reply?: (frame: unknown) => Effect.Effect<ReadonlyArray<unknown>>
34
+ }
35
+
36
+ type Listener = (event: any) => void
37
+
38
+ const OPEN = 1
39
+ const CLOSED = 3
40
+
41
+ /** Run after the current call unwinds, so the client is never re-entered. */
42
+ const soon = (run: () => void): void => void Promise.resolve().then(run)
43
+
44
+ export const make = (
45
+ options?: FakeWebSocketOptions,
46
+ ): Effect.Effect<FakeWebSocketServer, never, Scope.Scope> =>
47
+ Effect.gen(function* () {
48
+ const inbound = yield* Queue.unbounded<unknown>()
49
+ const sent = yield* Ref.make<ReadonlyArray<unknown>>([])
50
+
51
+ const wiring = {
52
+ listeners: {} as Record<string, ReadonlyArray<Listener>>,
53
+ readyState: OPEN,
54
+ greeted: false,
55
+ }
56
+
57
+ const emit = (type: string, event: unknown): void =>
58
+ (wiring.listeners[type] ?? []).forEach((listener) => listener(event))
59
+
60
+ const deliverSync = (frames: ReadonlyArray<unknown>): void =>
61
+ frames.forEach((f) => emit("message", { data: JSON.stringify(f) }))
62
+
63
+ const deliver = (frames: ReadonlyArray<unknown>): Effect.Effect<void> =>
64
+ Effect.sync(() => deliverSync(frames))
65
+
66
+ const shutdown = (code: number): void => {
67
+ if (wiring.readyState === CLOSED) return
68
+ wiring.readyState = CLOSED
69
+ emit("close", { code, reason: "" })
70
+ }
71
+
72
+ // The scripted server. Answers land on their own turn, like a real one.
73
+ yield* Stream.fromQueue(inbound).pipe(
74
+ Stream.runForEach((frame) =>
75
+ Effect.gen(function* () {
76
+ yield* Ref.update(sent, (frames) => [...frames, frame])
77
+ const answer = yield* options?.reply?.(frame) ?? Effect.succeed([])
78
+ yield* deliver(answer)
79
+ }),
80
+ ),
81
+ Effect.forkScoped,
82
+ )
83
+
84
+ const socket = {
85
+ get readyState() {
86
+ return wiring.readyState
87
+ },
88
+ addEventListener: (type: string, fn: Listener): void => {
89
+ wiring.listeners = {
90
+ ...wiring.listeners,
91
+ [type]: [...(wiring.listeners[type] ?? []), fn],
92
+ }
93
+ // The greeting waits for a subscriber before it lands.
94
+ if (type === "message" && !wiring.greeted) {
95
+ wiring.greeted = true
96
+ soon(() => deliverSync(options?.greeting ?? []))
97
+ }
98
+ },
99
+ removeEventListener: (type: string, fn: Listener): void => {
100
+ wiring.listeners = {
101
+ ...wiring.listeners,
102
+ [type]: (wiring.listeners[type] ?? []).filter((l) => l !== fn),
103
+ }
104
+ },
105
+ // Safe from a synchronous callback, and no runtime needed.
106
+ send: (data: string): void => void Queue.offerUnsafe(inbound, JSON.parse(data)),
107
+ close: (code = 1000): void => shutdown(code),
108
+ }
109
+
110
+ const connect: Socket.WebSocketConstructor["Service"] = () =>
111
+ socket as unknown as globalThis.WebSocket
112
+
113
+ return {
114
+ sent: Ref.get(sent),
115
+ push: (frame) => deliver([frame]),
116
+ close: (code = 1000) => Effect.sync(() => shutdown(code)),
117
+ connect,
118
+ layer: Layer.succeed(Socket.WebSocketConstructor, connect),
119
+ }
120
+ })
@@ -0,0 +1,92 @@
1
+ import { Cause, Effect, Layer, Queue, Ref, Stream } from "effect"
2
+ import type { CommonSessionRequest, RealtimeEvent, RealtimeInput } from "../domain/Realtime.js"
3
+ import {
4
+ RealtimeSession,
5
+ RealtimeVideoInput,
6
+ type RealtimeSessionHandle,
7
+ type RealtimeSessionService,
8
+ } from "../realtime/RealtimeSession.js"
9
+
10
+ export type MockRealtimeSessionRecorder = {
11
+ readonly openCalls: ReadonlyArray<CommonSessionRequest>
12
+ readonly sent: ReadonlyArray<RealtimeInput>
13
+ }
14
+
15
+ export type MockRealtimeSessionScript = {
16
+ /** Emitted as soon as the session opens. */
17
+ readonly initial?: ReadonlyArray<RealtimeEvent>
18
+ /** The server's answer to each input. */
19
+ readonly onInput?: (input: RealtimeInput) => ReadonlyArray<RealtimeEvent>
20
+ }
21
+
22
+ type Recorders = {
23
+ readonly open: (request: CommonSessionRequest) => Effect.Effect<void>
24
+ readonly send: (input: RealtimeInput) => Effect.Effect<void>
25
+ }
26
+
27
+ const makeService = (
28
+ script: MockRealtimeSessionScript,
29
+ record: Recorders,
30
+ ): RealtimeSessionService => ({
31
+ open: (request) =>
32
+ Effect.gen(function* () {
33
+ yield* record.open(request)
34
+ const queue = yield* Queue.unbounded<RealtimeEvent, Cause.Done>()
35
+ // Closing the scope ends `events`, leaving anything already queued to drain.
36
+ yield* Effect.addFinalizer(() => Queue.end(queue))
37
+ yield* Queue.offerAll(queue, script.initial ?? [])
38
+ const handle: RealtimeSessionHandle = {
39
+ send: (input) =>
40
+ Effect.andThen(
41
+ record.send(input),
42
+ Queue.offerAll(queue, script.onInput?.(input) ?? []),
43
+ ).pipe(Effect.asVoid),
44
+ events: Stream.fromQueue(queue),
45
+ }
46
+ return handle
47
+ }),
48
+ })
49
+
50
+ const build = (script: MockRealtimeSessionScript) => {
51
+ const openCalls = Ref.makeUnsafe<ReadonlyArray<CommonSessionRequest>>([])
52
+ const sent = Ref.makeUnsafe<ReadonlyArray<RealtimeInput>>([])
53
+ const sessionLayer = Layer.succeed(
54
+ RealtimeSession,
55
+ makeService(script, {
56
+ open: (request) => Ref.update(openCalls, (xs) => [...xs, request]),
57
+ send: (input) => Ref.update(sent, (xs) => [...xs, input]),
58
+ }),
59
+ )
60
+ const recorder: Effect.Effect<MockRealtimeSessionRecorder> = Effect.gen(function* () {
61
+ return { openCalls: yield* Ref.get(openCalls), sent: yield* Ref.get(sent) }
62
+ })
63
+ return { sessionLayer, recorder }
64
+ }
65
+
66
+ /** Scripted session that also ships the `RealtimeVideoInput` marker. */
67
+ export const layer = (
68
+ script: MockRealtimeSessionScript,
69
+ ): {
70
+ readonly layer: Layer.Layer<RealtimeSession | RealtimeVideoInput>
71
+ readonly recorder: Effect.Effect<MockRealtimeSessionRecorder>
72
+ } => {
73
+ const { sessionLayer, recorder } = build(script)
74
+ return {
75
+ layer: Layer.merge(sessionLayer, Layer.succeed(RealtimeVideoInput, undefined)),
76
+ recorder,
77
+ }
78
+ }
79
+
80
+ /**
81
+ * The same without the marker, to test that a caller sending video against an
82
+ * audio-only provider fails to compile.
83
+ */
84
+ export const layerAudioOnly = (
85
+ script: MockRealtimeSessionScript,
86
+ ): {
87
+ readonly layer: Layer.Layer<RealtimeSession>
88
+ readonly recorder: Effect.Effect<MockRealtimeSessionRecorder>
89
+ } => {
90
+ const { sessionLayer, recorder } = build(script)
91
+ return { layer: sessionLayer, recorder }
92
+ }
@@ -1,16 +0,0 @@
1
- import { Array, Duration, Effect, Queue } from "effect";
2
- declare namespace Inbox_d_exports {
3
- export { drainBurst };
4
- }
5
- /**
6
- * Block for the first item, then keep taking while the next one lands within
7
- * `settle` of the previous. The window resets on every arrival, so a burst
8
- * becomes one batch and a lone message followed by silence returns at once.
9
- *
10
- * A take that loses to the window is interrupted before it removes anything,
11
- * so late arrivals stay queued for the next drain.
12
- */
13
- declare const drainBurst: <A, E>(queue: Queue.Dequeue<A, E>, settle: Duration.Input) => Effect.Effect<Array.NonEmptyReadonlyArray<A>, E>;
14
- //#endregion
15
- export { drainBurst, Inbox_d_exports as t };
16
- //# sourceMappingURL=Inbox.d.mts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"Inbox.d.mts","names":[],"sources":["../../src/inbox/Inbox.ts"],"mappings":";;;;;;;;;;;;cAea,aAAc,GAAG,GAAC,OACtB,MAAM,QAAQ,GAAG,IAAE,QAClB,SAAS,UAChB,OAAO,OAAO,MAAI,sBAAsB,IAAI"}
@@ -1,28 +0,0 @@
1
- import { n as __exportAll } from "../rolldown-runtime-EAsCxrDo.mjs";
2
- import { Array, Effect, Option, Queue } from "effect";
3
- //#region src/inbox/Inbox.ts
4
- /**
5
- * Input a long-lived agent reads between turns. A chat user rarely sends one
6
- * tidy message: they type three lines in a row, then go quiet. The loop wants
7
- * that as one batch, taken at a clean turn boundary.
8
- */
9
- var Inbox_exports = /* @__PURE__ */ __exportAll({ drainBurst: () => drainBurst });
10
- /**
11
- * Block for the first item, then keep taking while the next one lands within
12
- * `settle` of the previous. The window resets on every arrival, so a burst
13
- * becomes one batch and a lone message followed by silence returns at once.
14
- *
15
- * A take that loses to the window is interrupted before it removes anything,
16
- * so late arrivals stay queued for the next drain.
17
- */
18
- const drainBurst = (queue, settle) => {
19
- const settled = (batch) => Queue.take(queue).pipe(Effect.timeoutOption(settle), Effect.flatMap(Option.match({
20
- onNone: () => Effect.succeed(batch),
21
- onSome: (item) => settled(Array.append(batch, item))
22
- })));
23
- return Effect.flatMap(Queue.take(queue), (first) => settled([first]));
24
- };
25
- //#endregion
26
- export { drainBurst, Inbox_exports as t };
27
-
28
- //# sourceMappingURL=Inbox.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"Inbox.mjs","names":["Arr"],"sources":["../../src/inbox/Inbox.ts"],"sourcesContent":["/**\n * Input a long-lived agent reads between turns. A chat user rarely sends one\n * tidy message: they type three lines in a row, then go quiet. The loop wants\n * that as one batch, taken at a clean turn boundary.\n */\nimport { Array as Arr, type Duration, Effect, Option, Queue } from \"effect\"\n\n/**\n * Block for the first item, then keep taking while the next one lands within\n * `settle` of the previous. The window resets on every arrival, so a burst\n * becomes one batch and a lone message followed by silence returns at once.\n *\n * A take that loses to the window is interrupted before it removes anything,\n * so late arrivals stay queued for the next drain.\n */\nexport const drainBurst = <A, E>(\n queue: Queue.Dequeue<A, E>,\n settle: Duration.Input,\n): Effect.Effect<Arr.NonEmptyReadonlyArray<A>, E> => {\n const settled = (\n batch: Arr.NonEmptyReadonlyArray<A>,\n ): Effect.Effect<Arr.NonEmptyReadonlyArray<A>, E> =>\n Queue.take(queue).pipe(\n Effect.timeoutOption(settle),\n Effect.flatMap(\n Option.match({\n onNone: () => Effect.succeed(batch),\n onSome: (item) => settled(Arr.append(batch, item)),\n }),\n ),\n )\n return Effect.flatMap(Queue.take(queue), (first) => settled([first]))\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAeA,MAAa,cACX,OACA,WACmD;CACnD,MAAM,WACJ,UAEA,MAAM,KAAK,KAAK,CAAC,CAAC,KAChB,OAAO,cAAc,MAAM,GAC3B,OAAO,QACL,OAAO,MAAM;EACX,cAAc,OAAO,QAAQ,KAAK;EAClC,SAAS,SAAS,QAAQA,MAAI,OAAO,OAAO,IAAI,CAAC;CACnD,CAAC,CACH,CACF;CACF,OAAO,OAAO,QAAQ,MAAM,KAAK,KAAK,IAAI,UAAU,QAAQ,CAAC,KAAK,CAAC,CAAC;AACtE"}