@effect-uai/ui 0.0.0-stage → 0.19.0-dev-20261109183959

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 +113 -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/Thread.ts ADDED
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Optional chat model on top of `RunView`: a message list, the run that is
3
+ * still streaming, and inputs waiting for their run. `make` builds the fold
4
+ * for any entry types; the module's own exports are the default preset
5
+ * (user entries hold the input's messages, assistant entries the `RunView`).
6
+ *
7
+ * A fold's fields (`initial`, `step`, `submitted`, `ended`) are what
8
+ * `Atoms.session` and `Atoms.make` take, so `{ ...Thread.make(config) }`
9
+ * plugs in directly.
10
+ *
11
+ * A settled assistant entry with a call awaiting approval still changes when
12
+ * that call is answered (`updateTool`): save entries when they change, not
13
+ * only when they settle.
14
+ */
15
+ import { Array as Arr, Option, Schema } from "effect"
16
+ import * as Input from "./Input.js"
17
+ import { type Envelope, InputId, type Payloads, StreamId, type Untyped } from "./RunEvent.js"
18
+ import * as RunView from "./RunView.js"
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Generic fold
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export type UserEntry<U> = { readonly role: "user"; readonly id: InputId; readonly value: U }
25
+ export type AssistantEntry<A> = {
26
+ readonly role: "assistant"
27
+ readonly id: string
28
+ readonly value: A
29
+ }
30
+ export type Entry<U, A> = UserEntry<U> | AssistantEntry<A>
31
+
32
+ /** `P` types the active run's payloads (see `UiContract`). */
33
+ export type State<U, A, P extends Payloads = Untyped> = {
34
+ readonly messages: ReadonlyArray<Entry<U, A>>
35
+ /** The run still streaming, if any. */
36
+ readonly active: RunView.RunView<P> | null
37
+ /**
38
+ * Inputs waiting for their own run (sent during a run, or echoed by a
39
+ * server that queues). They join `messages` when a `run-started` lists them.
40
+ */
41
+ readonly pending: ReadonlyArray<UserEntry<U>>
42
+ /** The stream `lastSeq` counts in; a frame from another stream starts a new count. */
43
+ readonly streamId: StreamId | null
44
+ /** Highest applied `seq` across runs, so a settled run's frames stay deduplicated. */
45
+ readonly lastSeq: number
46
+ }
47
+
48
+ /**
49
+ * The state as a Schema, for persisting or sending it. `view` is the active
50
+ * run's schema: `RunView.RunView` (default) or a contract's `RunView`.
51
+ */
52
+ export const State = <U, A, V>(
53
+ user: Schema.Codec<U, unknown, never, never>,
54
+ assistant: Schema.Codec<A, unknown, never, never>,
55
+ view: Schema.Codec<V, unknown, never, never>,
56
+ ) => {
57
+ const UserEntry = Schema.Struct({ role: Schema.Literal("user"), id: InputId, value: user })
58
+ const AssistantEntry = Schema.Struct({
59
+ role: Schema.Literal("assistant"),
60
+ id: Schema.String,
61
+ value: assistant,
62
+ })
63
+ return Schema.Struct({
64
+ messages: Schema.Array(Schema.Union([UserEntry, AssistantEntry])),
65
+ active: Schema.NullOr(view),
66
+ pending: Schema.Array(UserEntry),
67
+ streamId: Schema.NullOr(StreamId),
68
+ lastSeq: Schema.Number,
69
+ })
70
+ }
71
+
72
+ export type Config<I, U, A, P extends Payloads = Untyped> = {
73
+ /** The user entry for an input; `None` for inputs without one (approvals). */
74
+ readonly toUser: (input: I) => Option.Option<U>
75
+ /** The assistant entry a finished run settles into. */
76
+ readonly settle: (run: RunView.RunView<P>) => A
77
+ /** Decodes an `input-accepted` echo into an input; without it, echoes are ignored. */
78
+ readonly decodeInput?: (value: unknown) => Option.Option<I>
79
+ /**
80
+ * Applies a tool event to a settled entry that holds the call (`None` if it
81
+ * doesn't). An approved call's result arrives in the next run; with this,
82
+ * it updates the card it was asked from. Without it, it lands in the new run.
83
+ */
84
+ readonly updateTool?: (entry: A, event: RunView.ToolUpdate<P>) => Option.Option<A>
85
+ }
86
+
87
+ export type Fold<I, U, A, P extends Payloads = Untyped> = {
88
+ readonly initial: State<U, A, P>
89
+ readonly step: (state: State<U, A, P>, envelope: Envelope<P>) => State<U, A, P>
90
+ /** Optimistic: show the input before the server answers. */
91
+ readonly submitted: (state: State<U, A, P>, input: I) => State<U, A, P>
92
+ /** Settle the active run when its stream ends without a terminal. */
93
+ readonly ended: (state: State<U, A, P>, reason: "stopped" | "disconnected") => State<U, A, P>
94
+ }
95
+
96
+ /** Whether the state already shows the input `id` (settled or pending). */
97
+ export const hasInput = <U, A, P extends Payloads>(state: State<U, A, P>, id: InputId): boolean =>
98
+ state.messages.some((m) => m.role === "user" && m.id === id) ||
99
+ state.pending.some((m) => m.id === id)
100
+
101
+ /** Drop everything after the entry `id` (regenerate, edit). */
102
+ export const truncateAfter = <U, A, P extends Payloads>(
103
+ state: State<U, A, P>,
104
+ id: string,
105
+ ): State<U, A, P> => {
106
+ const index = state.messages.findIndex((m) => m.id === id)
107
+ return index === -1
108
+ ? state
109
+ : { ...state, messages: state.messages.slice(0, index + 1), active: null }
110
+ }
111
+
112
+ // Written once over `Payloads`; the exported signature carries the caller's `P`.
113
+ export function make<I extends { readonly id: InputId }, U, A, P extends Payloads = Untyped>(
114
+ config: Config<I, U, A, P>,
115
+ ): Fold<I, U, A, P>
116
+ export function make<I extends { readonly id: InputId }, U, A>(
117
+ config: Config<I, U, A, Payloads>,
118
+ ): Fold<I, U, A, Payloads> {
119
+ type S = State<U, A, Payloads>
120
+
121
+ const initial: S = { messages: [], active: null, pending: [], streamId: null, lastSeq: 0 }
122
+
123
+ const settled = (run: RunView.RunView<Payloads>, fallbackSeq: number): AssistantEntry<A> => ({
124
+ role: "assistant",
125
+ id: run.runId ?? `seq-${fallbackSeq}`,
126
+ value: config.settle(run),
127
+ })
128
+
129
+ // `seq` is per stream: a run on a new stream (one stream per run) counts from 1 again.
130
+ const onStream = (state: S, envelope: Envelope<Payloads>): S =>
131
+ envelope.streamId === state.streamId
132
+ ? state
133
+ : {
134
+ ...state,
135
+ streamId: envelope.streamId,
136
+ lastSeq: 0,
137
+ active: state.active === null ? null : { ...state.active, lastSeq: 0 },
138
+ }
139
+
140
+ // A starting run takes its inputs out of `pending`, so they sit above its answer.
141
+ const consume = (state: S, envelope: Envelope<Payloads>): S => {
142
+ const event = envelope.event
143
+ if (event.type !== "run-started" || state.pending.length === 0) return state
144
+ const taken = (m: UserEntry<U>) => event.inputs.includes(m.id)
145
+ return {
146
+ ...state,
147
+ messages: [...state.messages, ...state.pending.filter(taken)],
148
+ pending: state.pending.filter((m) => !taken(m)),
149
+ }
150
+ }
151
+
152
+ // The server's echo of an input it accepted: shown as pending unless already shown.
153
+ const accepted = (state: S, value: unknown): S =>
154
+ Option.fromNullishOr(config.decodeInput).pipe(
155
+ Option.flatMap((decode) => decode(value)),
156
+ Option.filter((input) => !hasInput(state, input.id)),
157
+ Option.flatMap((input) =>
158
+ config
159
+ .toUser(input)
160
+ .pipe(Option.map((u): UserEntry<U> => ({ role: "user", id: input.id, value: u }))),
161
+ ),
162
+ Option.match({
163
+ onNone: () => state,
164
+ onSome: (entry) => ({ ...state, pending: [...state.pending, entry] }),
165
+ }),
166
+ )
167
+
168
+ const submitted = (state: S, input: I): S => {
169
+ const idle = state.active === null
170
+ const run: RunView.RunView<Payloads> = {
171
+ ...RunView.initial,
172
+ status: "submitted",
173
+ lastSeq: state.lastSeq,
174
+ }
175
+ return Option.match(config.toUser(input), {
176
+ onNone: () => (idle ? { ...state, active: run } : state),
177
+ onSome: (u): S => {
178
+ const entry: UserEntry<U> = { role: "user", id: input.id, value: u }
179
+ return idle
180
+ ? { ...state, messages: [...state.messages, entry], active: run }
181
+ : { ...state, pending: [...state.pending, entry] }
182
+ },
183
+ })
184
+ }
185
+
186
+ // The latest settled entry that holds the call, updated by `config.updateTool`.
187
+ const updateSettled = (
188
+ messages: ReadonlyArray<Entry<U, A>>,
189
+ event: RunView.ToolUpdate<Payloads>,
190
+ ): Option.Option<ReadonlyArray<Entry<U, A>>> =>
191
+ Option.fromNullishOr(config.updateTool).pipe(
192
+ Option.flatMap((update) =>
193
+ Arr.findLast(messages, (m, index) =>
194
+ m.role === "assistant"
195
+ ? update(m.value, event).pipe(
196
+ Option.map((value) => ({ index, entry: { ...m, value } })),
197
+ )
198
+ : Option.none(),
199
+ ),
200
+ ),
201
+ Option.map(({ index, entry }) =>
202
+ messages.map((m, i): Entry<U, A> => (i === index ? entry : m)),
203
+ ),
204
+ )
205
+
206
+ const step = (current: S, envelope: Envelope<Payloads>): S => {
207
+ const fresh = onStream(current, envelope)
208
+ if (envelope.seq <= fresh.lastSeq) return current
209
+ const state = consume(fresh, envelope)
210
+ const event = envelope.event
211
+ if (event.type === "input-accepted")
212
+ return { ...accepted(state, event.input), lastSeq: envelope.seq }
213
+ // Stream-scoped frames (no `runId`) and frames outside a run are not a run's content.
214
+ if (envelope.runId === undefined || (state.active === null && event.type !== "run-started")) {
215
+ return { ...state, lastSeq: envelope.seq }
216
+ }
217
+ // A call made in an earlier run (approved after it was interrupted) updates that run.
218
+ if (RunView.isToolUpdate(event)) {
219
+ const updated = updateSettled(state.messages, event)
220
+ if (Option.isSome(updated))
221
+ return { ...state, messages: updated.value, lastSeq: envelope.seq }
222
+ }
223
+ const active: RunView.RunView<Payloads> = state.active ?? {
224
+ ...RunView.initial,
225
+ lastSeq: state.lastSeq,
226
+ }
227
+ const run = RunView.step(active, envelope)
228
+ if (!RunView.isTerminal(run)) return { ...state, active: run, lastSeq: envelope.seq }
229
+ return {
230
+ ...state,
231
+ messages: [...state.messages, settled(run, envelope.seq)],
232
+ active: null,
233
+ lastSeq: envelope.seq,
234
+ }
235
+ }
236
+
237
+ const ended = (state: S, reason: "stopped" | "disconnected"): S => {
238
+ const run = state.active
239
+ if (run === null) return state
240
+ const final: RunView.RunView<Payloads> =
241
+ reason === "stopped"
242
+ ? { ...run, status: "stopped" }
243
+ : { ...run, status: "failed", error: { reason: "disconnected" } }
244
+ return { ...state, messages: [...state.messages, settled(final, state.lastSeq)], active: null }
245
+ }
246
+
247
+ return { initial, step, submitted, ended }
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Default preset: user entries hold the input's messages, assistant entries the run
252
+ // ---------------------------------------------------------------------------
253
+
254
+ /** A user entry's value in the preset: the messages the input carried. */
255
+ export const UserContent = Schema.Struct({ messages: Schema.Array(Input.UserMessage) })
256
+ export type UserContent = typeof UserContent.Type
257
+
258
+ export const Thread = State(UserContent, RunView.RunView, RunView.RunView)
259
+ /** The preset's state; with a contract's `P`, runs carry decoded payloads. */
260
+ export type Thread<P extends Payloads = Untyped> = State<UserContent, RunView.RunView<P>, P>
261
+ export type Message<P extends Payloads = Untyped> = Entry<UserContent, RunView.RunView<P>>
262
+
263
+ /** Inputs the preset understands: any with an id; those with messages get a user entry. */
264
+ export type PresetInput = {
265
+ readonly id: InputId
266
+ readonly messages?: ReadonlyArray<Input.UserMessage>
267
+ }
268
+
269
+ /** The preset's fold for a contract's `P` (`UiContract` and `Atoms` use it). */
270
+ export const presetFor = <P extends Payloads = Untyped>(): Fold<
271
+ PresetInput,
272
+ UserContent,
273
+ RunView.RunView<P>,
274
+ P
275
+ > =>
276
+ make<PresetInput, UserContent, RunView.RunView<P>, P>({
277
+ toUser: (input) =>
278
+ Option.fromNullishOr(input.messages).pipe(Option.map((messages) => ({ messages }))),
279
+ settle: (run) => run,
280
+ decodeInput: Schema.decodeUnknownOption(Input.InputJson),
281
+ updateTool: RunView.applyToolUpdate,
282
+ })
283
+
284
+ export const preset = presetFor()
285
+
286
+ export const { initial, step, submitted, ended } = preset
@@ -0,0 +1,182 @@
1
+ /**
2
+ * How a client reaches the server. Every transport turns an input into the
3
+ * envelopes of the run it starts; durable transports can also re-attach to a
4
+ * run and stop it. Missing capabilities are absent from the value, so a UI
5
+ * can tell whether stop or resume exists.
6
+ *
7
+ * `I` is the app's input (`Input.Input` for the preset); `S` is the client
8
+ * state passed to `start`, so a request can carry any app data from it (a
9
+ * chat id, settings).
10
+ */
11
+ import { Effect, type Option, type Schedule, Stream } from "effect"
12
+ import type { HttpClient, HttpClientRequest } from "effect/http"
13
+ import * as Client from "./Client.js"
14
+ import * as Run from "./Run.js"
15
+ import { type Envelope, type RunEvent, StreamId } from "./RunEvent.js"
16
+
17
+ export type Transport<I, S, E, R> = {
18
+ /** Start a run for `input` and stream its envelopes. Interrupting the stream abandons it. */
19
+ readonly start: (input: I, state: S) => Stream.Stream<Envelope, E, R>
20
+ /** Re-attach to a run after a reload, from `after`. Durable only. */
21
+ readonly resume?: (streamId: StreamId, after: number) => Stream.Stream<Envelope, E, R>
22
+ /** Ask the server to stop a run. Durable only; direct mode stops by interrupting `start`. */
23
+ readonly stop?: (streamId: StreamId) => Effect.Effect<void, E, R>
24
+ }
25
+
26
+ /**
27
+ * Direct mode: one HTTP request per input whose response body is the run.
28
+ * Stop interrupts the stream, which aborts the request; the server sees the
29
+ * client go away and interrupts its run.
30
+ *
31
+ * Any other source (RPC streams, server functions) is a transport too:
32
+ * write `{ start: (input, state) => stream }` directly.
33
+ */
34
+ export const direct = <I, S, EB = never, RB = never>(options: {
35
+ readonly request: (
36
+ input: I,
37
+ state: S,
38
+ ) => Effect.Effect<HttpClientRequest.HttpClientRequest, EB, RB>
39
+ readonly format?: "sse" | "ndjson"
40
+ }): Transport<I, S, Client.ClientError | EB, HttpClient.HttpClient | RB> => ({
41
+ start: (input, state) =>
42
+ Stream.unwrap(
43
+ options
44
+ .request(input, state)
45
+ .pipe(
46
+ Effect.map(
47
+ (request): Stream.Stream<Envelope, Client.ClientError, HttpClient.HttpClient> =>
48
+ options.format === "ndjson" ? Client.ndjson(request) : Client.sse(request),
49
+ ),
50
+ ),
51
+ ),
52
+ })
53
+
54
+ /**
55
+ * Run the agent in the same process (browser agent, tests, Storybook). The
56
+ * run's events are sequenced under `streamId`.
57
+ */
58
+ export const local = <I, S, E, R>(
59
+ run: (input: I, state: S) => Stream.Stream<RunEvent, E, R>,
60
+ options?: { readonly streamId?: StreamId },
61
+ ): Transport<I, S, E, R> => ({
62
+ start: (input, state) =>
63
+ run(input, state).pipe(Run.sequence(options?.streamId ?? StreamId.make("local"))),
64
+ })
65
+
66
+ /** A per-run transport that can re-attach to a run (durable). */
67
+ export type Durable<I, S, E, R> = Transport<I, S, E, R> & {
68
+ readonly resume: (streamId: StreamId, after: number) => Stream.Stream<Envelope, E, R>
69
+ }
70
+
71
+ /**
72
+ * Durable runs: `submit` starts one on the server and returns its stream id;
73
+ * `events` reads it from `after` (wrap it in `Client.resumable` to survive
74
+ * drops). The run lives on the server, so a reload re-attaches with `resume`.
75
+ */
76
+ export const durable = <I, S, ES, RS, EE, RE, EX = never, RX = never>(options: {
77
+ readonly submit: (input: I, state: S) => Effect.Effect<StreamId, ES, RS>
78
+ readonly events: (streamId: StreamId, after: number) => Stream.Stream<Envelope, EE, RE>
79
+ readonly stop?: (streamId: StreamId) => Effect.Effect<void, EX, RX>
80
+ }): Durable<I, S, ES | EE | EX, RS | RE | RX> => ({
81
+ start: (input, state) =>
82
+ Stream.unwrap(
83
+ options.submit(input, state).pipe(Effect.map((streamId) => options.events(streamId, 0))),
84
+ ),
85
+ resume: options.events,
86
+ ...(options.stop !== undefined ? { stop: options.stop } : {}),
87
+ })
88
+
89
+ /**
90
+ * A durable session (a chat): one stream carries every run, and the server
91
+ * owns its history. `send` without a stream id starts a new session and
92
+ * returns its id (the app's own chat id, branded); with one, it adds a run to
93
+ * that session. `events` follows the whole session from `after` (wrap it in
94
+ * `Client.resumable` with `until: "never"`). The run arrives on that stream,
95
+ * not as a response to `send`.
96
+ */
97
+ export type Session<I, S, E, R> = {
98
+ readonly send: (
99
+ streamId: Option.Option<StreamId>,
100
+ input: I,
101
+ state: S,
102
+ ) => Effect.Effect<StreamId, E, R>
103
+ readonly events: (streamId: StreamId, after: number) => Stream.Stream<Envelope, E, R>
104
+ readonly stop?: (streamId: StreamId) => Effect.Effect<void, E, R>
105
+ }
106
+
107
+ export const session = <I, S, ES, RS, EE, RE, EX = never, RX = never>(options: {
108
+ readonly send: (
109
+ streamId: Option.Option<StreamId>,
110
+ input: I,
111
+ state: S,
112
+ ) => Effect.Effect<StreamId, ES, RS>
113
+ readonly events: (streamId: StreamId, after: number) => Stream.Stream<Envelope, EE, RE>
114
+ readonly stop?: (streamId: StreamId) => Effect.Effect<void, EX, RX>
115
+ }): Session<I, S, ES | EE | EX, RS | RE | RX> => options
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Over HTTP: `durable` and `session` with SSE events that reconnect
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /** What the HTTP helpers can fail with. */
122
+ export type HttpError = Client.ClientError | Client.Disconnected
123
+
124
+ type HttpOptions = {
125
+ /** The SSE request for a stream from `after`. */
126
+ readonly events: (streamId: StreamId, after: number) => HttpClientRequest.HttpClientRequest
127
+ /** The request that stops a stream's active run. */
128
+ readonly stop?: (streamId: StreamId) => HttpClientRequest.HttpClientRequest
129
+ /** When to reconnect a dropped event stream (it resumes from the last `seq`). */
130
+ readonly reconnect: Schedule.Schedule<unknown, HttpError>
131
+ }
132
+
133
+ const stopOver = (stop: HttpOptions["stop"]) =>
134
+ stop === undefined
135
+ ? {}
136
+ : { stop: (streamId: StreamId) => Client.execute(stop(streamId)).pipe(Effect.asVoid) }
137
+
138
+ /**
139
+ * `durable` over HTTP. `submit` starts a run and returns its stream id (the
140
+ * app decodes its own response, e.g. with `HttpClientResponse.schemaJson`);
141
+ * `events` is read as SSE and reconnects per `reconnect`.
142
+ */
143
+ export const durableHttp = <I, S, ES = never, RS = never>(
144
+ options: HttpOptions & {
145
+ readonly submit: (input: I, state: S) => Effect.Effect<StreamId, ES, RS>
146
+ },
147
+ ): Durable<I, S, HttpError | ES, HttpClient.HttpClient | RS> =>
148
+ durable({
149
+ submit: options.submit,
150
+ events: (streamId, after) =>
151
+ Client.resumable((from) => Client.sse(options.events(streamId, from)), {
152
+ from: after,
153
+ schedule: options.reconnect,
154
+ }),
155
+ ...stopOver(options.stop),
156
+ })
157
+
158
+ /**
159
+ * `session` over HTTP. `send` adds an input and returns the session's stream
160
+ * id (without one, the server starts a session); `events` follows the whole
161
+ * session as SSE. It carries many runs and never ends on its own, so
162
+ * `reconnect` usually retries forever (e.g. `Schedule.spaced("1 second")`).
163
+ */
164
+ export const sessionHttp = <I, S, ES = never, RS = never>(
165
+ options: HttpOptions & {
166
+ readonly send: (
167
+ streamId: Option.Option<StreamId>,
168
+ input: I,
169
+ state: S,
170
+ ) => Effect.Effect<StreamId, ES, RS>
171
+ },
172
+ ): Session<I, S, HttpError | ES, HttpClient.HttpClient | RS> =>
173
+ session({
174
+ send: options.send,
175
+ events: (streamId, after) =>
176
+ Client.resumable((from) => Client.sse(options.events(streamId, from)), {
177
+ from: after,
178
+ until: "never",
179
+ schedule: options.reconnect,
180
+ }),
181
+ ...stopOver(options.stop),
182
+ })