@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/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
|
package/src/Transport.ts
ADDED
|
@@ -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
|
+
})
|