@effect-uai/ui 0.0.0-stage → 0.17.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 (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -2
  3. package/dist/Atoms.d.mts +141 -0
  4. package/dist/Atoms.d.mts.map +1 -0
  5. package/dist/Atoms.mjs +132 -0
  6. package/dist/Atoms.mjs.map +1 -0
  7. package/dist/Client-C9UYN_VK.d.mts +219 -0
  8. package/dist/Client-C9UYN_VK.d.mts.map +1 -0
  9. package/dist/Client.d.mts +2 -0
  10. package/dist/Client.mjs +55 -0
  11. package/dist/Client.mjs.map +1 -0
  12. package/dist/Input-Cz-Kxusm.d.mts +277 -0
  13. package/dist/Input-Cz-Kxusm.d.mts.map +1 -0
  14. package/dist/Input.d.mts +2 -0
  15. package/dist/Input.mjs +127 -0
  16. package/dist/Input.mjs.map +1 -0
  17. package/dist/Projection.d.mts +44 -0
  18. package/dist/Projection.d.mts.map +1 -0
  19. package/dist/Projection.mjs +133 -0
  20. package/dist/Projection.mjs.map +1 -0
  21. package/dist/Run.d.mts +44 -0
  22. package/dist/Run.d.mts.map +1 -0
  23. package/dist/Run.mjs +117 -0
  24. package/dist/Run.mjs.map +1 -0
  25. package/dist/RunEvent.d.mts +1225 -0
  26. package/dist/RunEvent.d.mts.map +1 -0
  27. package/dist/RunEvent.mjs +264 -0
  28. package/dist/RunEvent.mjs.map +1 -0
  29. package/dist/RunView-nHfJwN8t.d.mts +380 -0
  30. package/dist/RunView-nHfJwN8t.d.mts.map +1 -0
  31. package/dist/RunView.d.mts +2 -0
  32. package/dist/RunView.mjs +452 -0
  33. package/dist/RunView.mjs.map +1 -0
  34. package/dist/SseEncoding.d.mts +437 -0
  35. package/dist/SseEncoding.d.mts.map +1 -0
  36. package/dist/SseEncoding.mjs +64 -0
  37. package/dist/SseEncoding.mjs.map +1 -0
  38. package/dist/Thread-CAXsjhpX.d.mts +474 -0
  39. package/dist/Thread-CAXsjhpX.d.mts.map +1 -0
  40. package/dist/Thread.d.mts +2 -0
  41. package/dist/Thread.mjs +225 -0
  42. package/dist/Thread.mjs.map +1 -0
  43. package/dist/Transport.d.mts +96 -0
  44. package/dist/Transport.d.mts.map +1 -0
  45. package/dist/Transport.mjs +82 -0
  46. package/dist/Transport.mjs.map +1 -0
  47. package/dist/UiContract.d.mts +114 -0
  48. package/dist/UiContract.d.mts.map +1 -0
  49. package/dist/UiContract.mjs +229 -0
  50. package/dist/UiContract.mjs.map +1 -0
  51. package/dist/index.d.mts +12 -0
  52. package/dist/index.mjs +12 -0
  53. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  54. package/package.json +99 -3
  55. package/src/Atoms.test.ts +163 -0
  56. package/src/Atoms.ts +355 -0
  57. package/src/Client.test.ts +53 -0
  58. package/src/Client.ts +92 -0
  59. package/src/Input.test.ts +68 -0
  60. package/src/Input.ts +165 -0
  61. package/src/Projection.test.ts +198 -0
  62. package/src/Projection.ts +142 -0
  63. package/src/Run.test.ts +119 -0
  64. package/src/Run.ts +166 -0
  65. package/src/RunEvent.test.ts +84 -0
  66. package/src/RunEvent.ts +374 -0
  67. package/src/RunView.test.ts +98 -0
  68. package/src/RunView.ts +583 -0
  69. package/src/SseEncoding.test.ts +46 -0
  70. package/src/SseEncoding.ts +77 -0
  71. package/src/Thread.test.ts +304 -0
  72. package/src/Thread.ts +286 -0
  73. package/src/Transport.ts +182 -0
  74. package/src/UiContract.test.ts +342 -0
  75. package/src/UiContract.ts +402 -0
  76. package/src/index.ts +11 -0
package/src/Atoms.ts ADDED
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Framework-neutral state over a transport, built on `effect/reactivity`.
3
+ * Per-run transports: `make` takes any fold (`initial` + `step`), `thread`
4
+ * is the `Thread` preset. Session transports: `session` and
5
+ * `sessionThread`, likewise. React reads them with `@effect/atom-react` (`useAtomValue`,
6
+ * `useAtomSet`), Vue and Solid with their official bindings, plain JS with
7
+ * `AtomRegistry.subscribe`.
8
+ *
9
+ * With a `UiContract`, every envelope is decoded once on arrival, so the
10
+ * state holds decoded payloads and is typed by the contract.
11
+ */
12
+ import { Effect, Exit, Option, type Schema, Stream } from "effect"
13
+ import type { SecureRandom } from "effect/crypto"
14
+ import { Atom } from "effect/reactivity"
15
+ import * as Input from "./Input.js"
16
+ import type { Envelope, Payloads, StreamId, Untyped } from "./RunEvent.js"
17
+ import * as Thread from "./Thread.js"
18
+ import type * as Transport from "./Transport.js"
19
+ import type { UiContract } from "./UiContract.js"
20
+
21
+ /**
22
+ * What `submit` does while a run is in flight. `"block"` ignores the new
23
+ * input; `"stop-then-submit"` abandons the current run locally and starts
24
+ * the new one (in durable mode the server-side run keeps going).
25
+ */
26
+ export type Busy = "block" | "stop-then-submit"
27
+
28
+ /**
29
+ * The atoms' runtime: the transport's services plus `SecureRandom` for
30
+ * input ids, e.g. `Atom.runtime(Layer.mergeAll(FetchHttpClient.layer,
31
+ * SecureRandom.layerWebCrypto))`. A test provides a seeded one.
32
+ */
33
+ export type Runtime<R, ER> = Atom.AtomRuntime<R | SecureRandom.SecureRandom, ER>
34
+
35
+ /** Turns a wire envelope into what `step` folds (a contract's `decode`). */
36
+ export type Decode<D> = (envelope: Envelope) => Effect.Effect<D, Schema.SchemaError>
37
+
38
+ // `decode` may be left out only when `step` folds wire envelopes.
39
+ type WithDecode<D> = [Envelope] extends [D]
40
+ ? { readonly decode?: Decode<D> }
41
+ : { readonly decode: Decode<D> }
42
+
43
+ /**
44
+ * `I` is the app's input type. `submit` takes a draft of it (`id` optional);
45
+ * the transport receives it with the id assigned (`Input.Sent<I>`, a UUIDv7
46
+ * from `SecureRandom`). `D` is the envelope `step` folds: the wire envelope,
47
+ * or a contract's decoded one (then pass `decode`).
48
+ */
49
+ type BaseOptions<I, S, E, R, ER, D> = {
50
+ readonly runtime: Runtime<R, ER>
51
+ readonly transport: Transport.Transport<Input.Sent<I>, S, E, R>
52
+ readonly initial: S
53
+ readonly step: (state: S, envelope: D) => S
54
+ readonly busy: Busy
55
+ readonly isBusy?: (state: S) => boolean
56
+ /** Optimistic update applied before the request goes out. */
57
+ readonly submitted?: (state: S, input: Input.Sent<I>) => S
58
+ /** Applied when a run's stream ends without its terminal. */
59
+ readonly ended?: (state: S, reason: "stopped" | "disconnected") => S
60
+ }
61
+
62
+ export type Options<I, S, E, R, ER, D = Envelope> = BaseOptions<I, S, E, R, ER, D> & WithDecode<D>
63
+
64
+ const identity: Decode<Envelope> = Effect.succeed
65
+
66
+ const build = <I, S, E, R, ER, D>(
67
+ options: BaseOptions<I, S, E, R, ER, D> & { readonly decode: Decode<D> },
68
+ ) => {
69
+ const state = Atom.make(options.initial).pipe(Atom.keepAlive)
70
+ const streamId = Atom.make<StreamId | null>(null).pipe(Atom.keepAlive)
71
+
72
+ const follow = (envelopes: Stream.Stream<Envelope, E, R>) =>
73
+ envelopes.pipe(
74
+ Stream.runForEach((envelope) =>
75
+ options
76
+ .decode(envelope)
77
+ .pipe(
78
+ Effect.flatMap((decoded) =>
79
+ Atom.set(streamId, envelope.streamId).pipe(
80
+ Effect.andThen(Atom.update(state, (s) => options.step(s, decoded))),
81
+ ),
82
+ ),
83
+ ),
84
+ ),
85
+ Effect.onExit((exit) => {
86
+ const ended = options.ended
87
+ if (Exit.isSuccess(exit) || ended === undefined) return Effect.void
88
+ const reason = Exit.hasInterrupts(exit) ? "stopped" : "disconnected"
89
+ return Atom.update(state, (s) => ended(s, reason))
90
+ }),
91
+ )
92
+
93
+ // Writing to a non-concurrent `Atom.fn` interrupts its previous run, so the
94
+ // streaming lives in `run` and `submit` is a concurrent gate that applies
95
+ // the busy policy before anything is interrupted. Every fn atom is kept
96
+ // alive: an unobserved one is disposed, which interrupts its fiber.
97
+ const run = options.runtime
98
+ .fn<Input.Sent<I>>()((input) =>
99
+ Effect.gen(function* () {
100
+ const submitted = options.submitted
101
+ if (submitted !== undefined) yield* Atom.update(state, (s) => submitted(s, input))
102
+ yield* follow(options.transport.start(input, yield* Atom.get(state)))
103
+ }),
104
+ )
105
+ .pipe(Atom.keepAlive)
106
+
107
+ const submit = options.runtime
108
+ .fn<Input.Draft<I>>()(
109
+ (draft) =>
110
+ Effect.gen(function* () {
111
+ const blocked =
112
+ options.busy === "block" && options.isBusy?.(yield* Atom.get(state)) === true
113
+ if (blocked) return
114
+ const input = yield* Input.withId(draft)
115
+ yield* Atom.set(run, input)
116
+ }),
117
+ { concurrent: true },
118
+ )
119
+ .pipe(Atom.keepAlive)
120
+
121
+ // Durable: ask the server (its `run-stopped` arrives on the stream).
122
+ // Direct: interrupt the request; `ended` settles the run locally.
123
+ const stop = options.runtime
124
+ .fn<void>()(() =>
125
+ Effect.gen(function* () {
126
+ const id = yield* Atom.get(streamId)
127
+ const serverStop = options.transport.stop
128
+ if (serverStop !== undefined && id !== null) return yield* serverStop(id)
129
+ yield* Atom.set(run, Atom.Interrupt)
130
+ }),
131
+ )
132
+ .pipe(Atom.keepAlive)
133
+
134
+ const resume = options.transport.resume
135
+ const attach =
136
+ resume === undefined
137
+ ? undefined
138
+ : options.runtime
139
+ .fn<{ readonly streamId: StreamId; readonly after: number }>()((arg) =>
140
+ follow(resume(arg.streamId, arg.after)),
141
+ )
142
+ .pipe(Atom.keepAlive)
143
+
144
+ return {
145
+ state,
146
+ streamId,
147
+ submit,
148
+ /** The current run; `AtomRegistry.getResult` on it waits for the run to finish. */
149
+ run,
150
+ stop,
151
+ /** Re-attach to a run (`{ streamId, after }`); set for durable transports. */
152
+ resume: attach,
153
+ }
154
+ }
155
+
156
+ type Built<I, S, E, R, ER> = ReturnType<typeof build<I, S, E, R, ER, unknown>>
157
+
158
+ /**
159
+ * State over a per-run transport: `state`, `submit`, `stop`, `run`, and
160
+ * `resume` when the transport can re-attach (`Transport.durable`,
161
+ * `durableHttp`), typed as present for those.
162
+ */
163
+ export function make<I, S, E, R, ER, D = Envelope>(
164
+ options: Options<I, S, E, R, ER, D> & {
165
+ readonly transport: Transport.Durable<Input.Sent<I>, S, E, R>
166
+ },
167
+ ): Omit<Built<I, S, E, R, ER>, "resume"> & {
168
+ readonly resume: NonNullable<Built<I, S, E, R, ER>["resume"]>
169
+ }
170
+ export function make<I, S, E, R, ER, D = Envelope>(
171
+ options: Options<I, S, E, R, ER, D>,
172
+ ): Built<I, S, E, R, ER>
173
+ export function make<I, S, E, R, ER>(
174
+ options: Options<I, S, E, R, ER, Envelope>,
175
+ ): Built<I, S, E, R, ER> {
176
+ return build({ ...options, decode: options.decode ?? identity })
177
+ }
178
+
179
+ type ThreadOptions<I, E, R, ER, P extends Payloads> = {
180
+ readonly runtime: Runtime<R, ER>
181
+ readonly transport: Transport.Transport<Input.Sent<I>, Thread.Thread<P>, E, R>
182
+ readonly busy: Busy
183
+ readonly initial?: Thread.Thread<P>
184
+ }
185
+
186
+ /**
187
+ * `make` with the `Thread` preset: a message list plus the active run. Takes
188
+ * the `Input` preset, with or without typed `data`. With `contract`, the
189
+ * state is typed by it. For other entry types, spread a `Thread.make(config)`
190
+ * fold into `make`.
191
+ */
192
+ export function thread<I extends Input.Input, E, R, ER, P extends Payloads>(
193
+ options: ThreadOptions<I, E, R, ER, P> & { readonly contract: UiContract<P> },
194
+ ): Built<I, Thread.Thread<P>, E, R, ER>
195
+ export function thread<I extends Input.Input, E, R, ER>(
196
+ options: ThreadOptions<I, E, R, ER, Untyped>,
197
+ ): Built<I, Thread.Thread, E, R, ER>
198
+ // The implementation sees `P` as `any`: the options both read and receive
199
+ // `Thread<P>`, so no single `P` fits both overloads. Callers get the overloads.
200
+ export function thread<I extends Input.Input, E, R, ER>(
201
+ options: ThreadOptions<I, E, R, ER, any> & { readonly contract?: UiContract<any> },
202
+ ): Built<I, Thread.Thread<any>, E, R, ER> {
203
+ const fold = Thread.presetFor<any>()
204
+ return build({
205
+ runtime: options.runtime,
206
+ transport: options.transport,
207
+ busy: options.busy,
208
+ initial: options.initial ?? fold.initial,
209
+ step: fold.step,
210
+ decode: options.contract?.decode ?? identity,
211
+ isBusy: (t) => t.active !== null,
212
+ submitted: (t, input: Input.Sent<I>) => fold.submitted(t, input),
213
+ ended: fold.ended,
214
+ })
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Sessions: one stream carries every run (a durable chat)
219
+ // ---------------------------------------------------------------------------
220
+
221
+ type BaseSessionOptions<I, S, E, R, ER, D> = {
222
+ readonly runtime: Runtime<R, ER>
223
+ readonly transport: Transport.Session<Input.Sent<I>, S, E, R>
224
+ readonly initial: S
225
+ readonly step: (state: S, envelope: D) => S
226
+ /** While true, `submit` ignores new input (the server may also refuse). */
227
+ readonly isBusy?: (state: S) => boolean
228
+ /** Optimistic update applied before the input is sent. */
229
+ readonly submitted?: (state: S, input: Input.Sent<I>) => S
230
+ /** Applied when sending fails or the session's stream gives up. */
231
+ readonly ended?: (state: S, reason: "stopped" | "disconnected") => S
232
+ }
233
+
234
+ export type SessionOptions<I, S, E, R, ER, D = Envelope> = BaseSessionOptions<I, S, E, R, ER, D> &
235
+ WithDecode<D>
236
+
237
+ const buildSession = <I, S, E, R, ER, D>(
238
+ options: BaseSessionOptions<I, S, E, R, ER, D> & { readonly decode: Decode<D> },
239
+ ) => {
240
+ const state = Atom.make(options.initial).pipe(Atom.keepAlive)
241
+ const streamId = Atom.make<StreamId | null>(null).pipe(Atom.keepAlive)
242
+
243
+ const settle = (exit: Exit.Exit<unknown, unknown>) => {
244
+ const ended = options.ended
245
+ return ended === undefined || Exit.isSuccess(exit) || Exit.hasInterrupts(exit)
246
+ ? Effect.void
247
+ : Atom.update(state, (s) => ended(s, "disconnected"))
248
+ }
249
+
250
+ // Non-concurrent: opening another session stops following the previous one.
251
+ const open = options.runtime
252
+ .fn<StreamId>()((id) =>
253
+ Atom.set(streamId, id).pipe(
254
+ Effect.andThen(
255
+ options.transport
256
+ .events(id, 0)
257
+ .pipe(
258
+ Stream.runForEach((envelope) =>
259
+ options
260
+ .decode(envelope)
261
+ .pipe(
262
+ Effect.flatMap((decoded) =>
263
+ Atom.update(state, (s) => options.step(s, decoded)),
264
+ ),
265
+ ),
266
+ ),
267
+ ),
268
+ ),
269
+ Effect.onExit(settle),
270
+ ),
271
+ )
272
+ .pipe(Atom.keepAlive)
273
+
274
+ const submit = options.runtime
275
+ .fn<Input.Draft<I>>()(
276
+ (draft) =>
277
+ Effect.gen(function* () {
278
+ if (options.isBusy?.(yield* Atom.get(state)) === true) return
279
+ const input = yield* Input.withId(draft)
280
+ const submitted = options.submitted
281
+ if (submitted !== undefined) yield* Atom.update(state, (s) => submitted(s, input))
282
+ const opened = Option.fromNullishOr(yield* Atom.get(streamId))
283
+ const sent = yield* options.transport.send(opened, input, yield* Atom.get(state))
284
+ if (Option.isNone(opened)) yield* Atom.set(open, sent)
285
+ }).pipe(Effect.onExit(settle)),
286
+ { concurrent: true },
287
+ )
288
+ .pipe(Atom.keepAlive)
289
+
290
+ // The server stops the active run; its `run-stopped` arrives on the stream.
291
+ const stop = options.runtime
292
+ .fn<void>()(() =>
293
+ Effect.gen(function* () {
294
+ const id = yield* Atom.get(streamId)
295
+ const serverStop = options.transport.stop
296
+ if (serverStop !== undefined && id !== null) yield* serverStop(id)
297
+ }),
298
+ )
299
+ .pipe(Atom.keepAlive)
300
+
301
+ return { state, streamId, open, submit, stop }
302
+ }
303
+
304
+ type BuiltSession<I, S, E, R, ER> = ReturnType<typeof buildSession<I, S, E, R, ER, unknown>>
305
+
306
+ /**
307
+ * State over a session transport. `open(streamId)` follows a session's
308
+ * stream from its start (e.g. a chat id kept across reloads). `submit` sends
309
+ * an input; without an open session the server starts one, which is then
310
+ * opened. Runs arrive on the followed stream, not as responses to `submit`.
311
+ */
312
+ export function session<I, S, E, R, ER, D = Envelope>(
313
+ options: SessionOptions<I, S, E, R, ER, D>,
314
+ ): BuiltSession<I, S, E, R, ER>
315
+ export function session<I, S, E, R, ER>(
316
+ options: SessionOptions<I, S, E, R, ER, Envelope>,
317
+ ): BuiltSession<I, S, E, R, ER> {
318
+ return buildSession({ ...options, decode: options.decode ?? identity })
319
+ }
320
+
321
+ type SessionThreadOptions<I, E, R, ER, P extends Payloads> = {
322
+ readonly runtime: Runtime<R, ER>
323
+ readonly transport: Transport.Session<Input.Sent<I>, Thread.Thread<P>, E, R>
324
+ readonly isBusy?: (thread: Thread.Thread<P>) => boolean
325
+ }
326
+
327
+ /**
328
+ * `session` with the `Thread` preset. Input sent while a run is active, or
329
+ * echoed by the server (`input-accepted`), waits in `thread.pending`: the
330
+ * server queues it. Pass `isBusy` for a server that refuses instead. With
331
+ * `contract`, the state is typed by it. For other entry types, spread a
332
+ * `Thread.make(config)` fold into `session`.
333
+ */
334
+ export function sessionThread<I extends Input.Input, E, R, ER, P extends Payloads>(
335
+ options: SessionThreadOptions<I, E, R, ER, P> & { readonly contract: UiContract<P> },
336
+ ): BuiltSession<I, Thread.Thread<P>, E, R, ER>
337
+ export function sessionThread<I extends Input.Input, E, R, ER>(
338
+ options: SessionThreadOptions<I, E, R, ER, Untyped>,
339
+ ): BuiltSession<I, Thread.Thread, E, R, ER>
340
+ // `any` for `P` in the implementation only, as in `thread`.
341
+ export function sessionThread<I extends Input.Input, E, R, ER>(
342
+ options: SessionThreadOptions<I, E, R, ER, any> & { readonly contract?: UiContract<any> },
343
+ ): BuiltSession<I, Thread.Thread<any>, E, R, ER> {
344
+ const fold = Thread.presetFor<any>()
345
+ return buildSession({
346
+ runtime: options.runtime,
347
+ transport: options.transport,
348
+ initial: fold.initial,
349
+ step: fold.step,
350
+ decode: options.contract?.decode ?? identity,
351
+ ...(options.isBusy !== undefined ? { isBusy: options.isBusy } : {}),
352
+ submitted: (t, input: Input.Sent<I>) => fold.submitted(t, input),
353
+ ended: fold.ended,
354
+ })
355
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import { Data, Effect, Ref, Schedule, Stream } from "effect"
3
+ import { expect } from "vitest"
4
+ import * as Client from "./Client.js"
5
+ import { type Envelope, RunId, type RunEvent, StreamId } from "./RunEvent.js"
6
+
7
+ const r1 = RunId.make("r1")
8
+
9
+ class Dropped extends Data.TaggedError("Dropped")<{}> {}
10
+
11
+ const frames: ReadonlyArray<Envelope> = (
12
+ [
13
+ { type: "run-started", runId: r1, inputs: [], protocol: 2 },
14
+ { type: "text-delta", text: "a" },
15
+ { type: "text-delta", text: "b" },
16
+ { type: "text-delta", text: "c" },
17
+ { type: "run-completed" },
18
+ ] satisfies ReadonlyArray<RunEvent>
19
+ ).map((event, i) => ({ streamId: StreamId.make("s"), seq: i + 1, runId: r1, event }))
20
+
21
+ // A server that replays from `after`; how each connection ends is scripted.
22
+ const server = (endings: ReadonlyArray<{ readonly upTo: number; readonly fails: boolean }>) =>
23
+ Effect.gen(function* () {
24
+ const calls = yield* Ref.make<ReadonlyArray<number>>([])
25
+ const connect = (after: number) =>
26
+ Stream.unwrap(
27
+ Ref.modify(calls, (cs) => [cs.length, [...cs, after]]).pipe(
28
+ Effect.map((attempt) => {
29
+ const ending = endings[attempt] ?? { upTo: frames.length, fails: false }
30
+ // Re-sends one frame it already delivered, as real servers may.
31
+ const sent = Stream.fromIterable(frames.slice(Math.max(0, after - 1), ending.upTo))
32
+ return ending.fails ? sent.pipe(Stream.concat(Stream.fail(new Dropped()))) : sent
33
+ }),
34
+ ),
35
+ )
36
+ return { calls, connect }
37
+ })
38
+
39
+ describe("Client.resumable", () => {
40
+ it.effect.each([
41
+ { label: "a failed connection", fails: true },
42
+ { label: "a connection that closes before the terminal", fails: false },
43
+ ])("resumes after $label from the last seq, without duplicates", ({ fails }) =>
44
+ Effect.gen(function* () {
45
+ const { calls, connect } = yield* server([{ upTo: 2, fails }])
46
+ const received = yield* Client.resumable(connect, { schedule: Schedule.recurs(3) }).pipe(
47
+ Stream.runCollect,
48
+ )
49
+ expect(received.map((e) => e.seq)).toEqual([1, 2, 3, 4, 5])
50
+ expect(yield* Ref.get(calls)).toEqual([0, 2])
51
+ }),
52
+ )
53
+ })
package/src/Client.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Browser-safe sources of envelopes. Every source yields
3
+ * `Stream<Envelope>`; `resumable` adds reconnect-from-last-seq on top of
4
+ * any of them. HTTP goes through `effect/http`'s `HttpClient`, so a browser
5
+ * provides `FetchHttpClient.layer` and tests provide a fake client.
6
+ */
7
+ import { Data, Effect, Ref, type Schedule, type Schema, Stream } from "effect"
8
+ import { Ndjson, type Sse } from "effect/encoding"
9
+ import { HttpClient, type HttpClientError, type HttpClientRequest } from "effect/http"
10
+ import { type Envelope, EnvelopeJson, isTerminal } from "./RunEvent.js"
11
+ import * as SseEncoding from "./SseEncoding.js"
12
+
13
+ /** The connection ended before the run's terminal event arrived. */
14
+ export class Disconnected extends Data.TaggedError("Disconnected")<{ readonly lastSeq: number }> {}
15
+
16
+ /** What an HTTP source can fail with: transport, a malformed SSE frame, or a malformed NDJSON line. */
17
+ export type ClientError =
18
+ | HttpClientError.HttpClientError
19
+ | Schema.SchemaError
20
+ | Sse.Retry
21
+ | Sse.SseError
22
+ | Ndjson.NdjsonError
23
+
24
+ /** Run a request, failing on a non-2xx status. */
25
+ export const execute = (request: HttpClientRequest.HttpClientRequest) =>
26
+ HttpClient.HttpClient.pipe(
27
+ Effect.flatMap((client) => HttpClient.filterStatusOk(client).execute(request)),
28
+ )
29
+
30
+ const body = (request: HttpClientRequest.HttpClientRequest) =>
31
+ Stream.unwrap(Effect.map(execute(request), (response) => response.stream))
32
+
33
+ /** An SSE response (`text/event-stream`) as envelopes. */
34
+ export const sse = (request: HttpClientRequest.HttpClientRequest) =>
35
+ body(request).pipe(SseEncoding.decode)
36
+
37
+ /** An NDJSON response (one envelope per line) as envelopes. */
38
+ export const ndjson = (request: HttpClientRequest.HttpClientRequest) =>
39
+ body(request).pipe(Stream.pipeThroughChannelOrFail(Ndjson.decodeSchema(EnvelopeJson)()))
40
+
41
+ /**
42
+ * Keep events flowing across dropped connections: `connect(after)` is
43
+ * re-invoked with the last seen `seq` on failure, per `schedule`; duplicates
44
+ * are dropped. A connection that ends cleanly before the end counts as a
45
+ * failure (`Disconnected`), so it is retried too.
46
+ *
47
+ * `until: "terminal"` (default) follows one run and completes after its
48
+ * terminal. `until: "never"` follows a session stream that carries many runs
49
+ * (a durable chat) and never completes on its own.
50
+ */
51
+ export const resumable = <E, R, X>(
52
+ connect: (after: number) => Stream.Stream<Envelope, E, R>,
53
+ options: {
54
+ readonly from?: number
55
+ readonly until?: "terminal" | "never"
56
+ readonly schedule: Schedule.Schedule<X, E | Disconnected>
57
+ },
58
+ ): Stream.Stream<Envelope, E | Disconnected, R> =>
59
+ Stream.unwrap(
60
+ Effect.gen(function* () {
61
+ const untilTerminal = options.until !== "never"
62
+ const ends = (envelope: Envelope) => untilTerminal && isTerminal(envelope.event)
63
+ // The last seen seq, and whether the run's terminal arrived.
64
+ const position = yield* Ref.make({ seq: options.from ?? 0, done: false })
65
+ const attempt = Stream.unwrap(
66
+ Ref.get(position).pipe(
67
+ Effect.map(({ seq: after }) =>
68
+ connect(after).pipe(
69
+ Stream.filterEffect((envelope) =>
70
+ Ref.modify(position, (p) =>
71
+ envelope.seq > p.seq
72
+ ? [true, { seq: envelope.seq, done: ends(envelope) }]
73
+ : [false, p],
74
+ ),
75
+ ),
76
+ Stream.takeUntil(ends),
77
+ Stream.concat(
78
+ Stream.unwrap(
79
+ Ref.get(position).pipe(
80
+ Effect.map((p) =>
81
+ p.done ? Stream.empty : Stream.fail(new Disconnected({ lastSeq: p.seq })),
82
+ ),
83
+ ),
84
+ ),
85
+ ),
86
+ ),
87
+ ),
88
+ ),
89
+ )
90
+ return attempt.pipe(Stream.retry(options.schedule))
91
+ }),
92
+ )
@@ -0,0 +1,68 @@
1
+ import * as ToolDecision from "@effect-uai/core/ToolDecision"
2
+ import * as Image from "@effect-uai/core/Image"
3
+ import { describe, it } from "@effect/vitest"
4
+ import { Effect, Exit, Schema } from "effect"
5
+ import { expect } from "vitest"
6
+ import * as Input from "./Input.js"
7
+ import { InputId } from "./RunEvent.js"
8
+
9
+ const ChatInput = Input.make(Schema.Struct({ templateId: Schema.String }))
10
+ const Body = Schema.fromJsonString(ChatInput.InputJson)
11
+
12
+ describe("Input wire codec", () => {
13
+ it.effect("carries image bytes and typed data through a JSON body", () =>
14
+ Effect.gen(function* () {
15
+ const draft = Input.submit(
16
+ [
17
+ Input.user("What is in this picture?", [
18
+ {
19
+ type: "input_image",
20
+ source: Image.imageBytes(new Uint8Array([1, 2, 3]), "image/png"),
21
+ },
22
+ ]),
23
+ Input.user("Answer in one sentence."),
24
+ ],
25
+ { data: { templateId: "describe" } },
26
+ )
27
+ const input = { ...draft, id: InputId.make("in_1") }
28
+ const body = yield* Schema.encodeEffect(Body)(input)
29
+ expect(yield* Schema.decodeUnknownEffect(Body)(body)).toEqual(input)
30
+ }),
31
+ )
32
+
33
+ it.effect("rejects a submit whose data does not match the app schema", () =>
34
+ Effect.gen(function* () {
35
+ const body = JSON.stringify({ ...Input.submit("Hi"), data: { templateId: 42 } })
36
+ const exit = yield* Effect.exit(Schema.decodeUnknownEffect(Body)(body))
37
+ expect(Exit.isFailure(exit)).toBe(true)
38
+ }),
39
+ )
40
+
41
+ it.effect("carries the app's own decisions and rejects one it doesn't define", () =>
42
+ Effect.gen(function* () {
43
+ const Decisions = ToolDecision.make({
44
+ Approve: { always: Schema.optionalKey(Schema.Boolean) },
45
+ Respond: { output: Schema.Json },
46
+ })
47
+ const Booking = Input.make(Schema.Struct({}), Decisions.schema)
48
+ const Resolve = Schema.fromJsonString(Booking.InputJson)
49
+ const input = {
50
+ ...Input.resolve([
51
+ { callId: "c1", decision: Decisions.Approve({ always: true }) },
52
+ { callId: "c2", decision: Decisions.Respond({ output: { booked: true } }) },
53
+ ]),
54
+ id: InputId.make("in_1"),
55
+ }
56
+ const body = yield* Schema.encodeEffect(Resolve)(input)
57
+ expect(yield* Schema.decodeUnknownEffect(Resolve)(body)).toEqual(input)
58
+
59
+ const denied = JSON.stringify({
60
+ ...input,
61
+ decisions: [{ callId: "c1", decision: { _tag: "Deny" } }],
62
+ })
63
+ expect(Exit.isFailure(yield* Effect.exit(Schema.decodeUnknownEffect(Resolve)(denied)))).toBe(
64
+ true,
65
+ )
66
+ }),
67
+ )
68
+ })