@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.
- package/LICENSE +21 -0
- package/README.md +64 -2
- package/dist/Atoms.d.mts +141 -0
- package/dist/Atoms.d.mts.map +1 -0
- package/dist/Atoms.mjs +132 -0
- package/dist/Atoms.mjs.map +1 -0
- package/dist/Client-C9UYN_VK.d.mts +219 -0
- package/dist/Client-C9UYN_VK.d.mts.map +1 -0
- package/dist/Client.d.mts +2 -0
- package/dist/Client.mjs +55 -0
- package/dist/Client.mjs.map +1 -0
- package/dist/Input-Cz-Kxusm.d.mts +277 -0
- package/dist/Input-Cz-Kxusm.d.mts.map +1 -0
- package/dist/Input.d.mts +2 -0
- package/dist/Input.mjs +127 -0
- package/dist/Input.mjs.map +1 -0
- package/dist/Projection.d.mts +44 -0
- package/dist/Projection.d.mts.map +1 -0
- package/dist/Projection.mjs +133 -0
- package/dist/Projection.mjs.map +1 -0
- package/dist/Run.d.mts +44 -0
- package/dist/Run.d.mts.map +1 -0
- package/dist/Run.mjs +117 -0
- package/dist/Run.mjs.map +1 -0
- package/dist/RunEvent.d.mts +1225 -0
- package/dist/RunEvent.d.mts.map +1 -0
- package/dist/RunEvent.mjs +264 -0
- package/dist/RunEvent.mjs.map +1 -0
- package/dist/RunView-nHfJwN8t.d.mts +380 -0
- package/dist/RunView-nHfJwN8t.d.mts.map +1 -0
- package/dist/RunView.d.mts +2 -0
- package/dist/RunView.mjs +452 -0
- package/dist/RunView.mjs.map +1 -0
- package/dist/SseEncoding.d.mts +437 -0
- package/dist/SseEncoding.d.mts.map +1 -0
- package/dist/SseEncoding.mjs +64 -0
- package/dist/SseEncoding.mjs.map +1 -0
- package/dist/Thread-CAXsjhpX.d.mts +474 -0
- package/dist/Thread-CAXsjhpX.d.mts.map +1 -0
- package/dist/Thread.d.mts +2 -0
- package/dist/Thread.mjs +225 -0
- package/dist/Thread.mjs.map +1 -0
- package/dist/Transport.d.mts +96 -0
- package/dist/Transport.d.mts.map +1 -0
- package/dist/Transport.mjs +82 -0
- package/dist/Transport.mjs.map +1 -0
- package/dist/UiContract.d.mts +114 -0
- package/dist/UiContract.d.mts.map +1 -0
- package/dist/UiContract.mjs +229 -0
- package/dist/UiContract.mjs.map +1 -0
- package/dist/index.d.mts +12 -0
- package/dist/index.mjs +12 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/package.json +99 -3
- package/src/Atoms.test.ts +163 -0
- package/src/Atoms.ts +355 -0
- package/src/Client.test.ts +53 -0
- package/src/Client.ts +92 -0
- package/src/Input.test.ts +68 -0
- package/src/Input.ts +165 -0
- package/src/Projection.test.ts +198 -0
- package/src/Projection.ts +142 -0
- package/src/Run.test.ts +119 -0
- package/src/Run.ts +166 -0
- package/src/RunEvent.test.ts +84 -0
- package/src/RunEvent.ts +374 -0
- package/src/RunView.test.ts +98 -0
- package/src/RunView.ts +583 -0
- package/src/SseEncoding.test.ts +46 -0
- package/src/SseEncoding.ts +77 -0
- package/src/Thread.test.ts +304 -0
- package/src/Thread.ts +286 -0
- package/src/Transport.ts +182 -0
- package/src/UiContract.test.ts +342 -0
- package/src/UiContract.ts +402 -0
- 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
|
+
})
|