@effect-uai/ui 0.19.0-dev-20261110065930 → 0.20.0-dev-20261110202152
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/dist/Atoms.d.mts +13 -4
- package/dist/Atoms.d.mts.map +1 -1
- package/dist/Atoms.mjs +8 -2
- package/dist/Atoms.mjs.map +1 -1
- package/dist/{Client-C9UYN_VK.d.mts → Client-B2Eqo39q.d.mts} +5 -4
- package/dist/{Client-C9UYN_VK.d.mts.map → Client-B2Eqo39q.d.mts.map} +1 -1
- package/dist/Client.d.mts +1 -1
- package/dist/Client.mjs +8 -5
- package/dist/Client.mjs.map +1 -1
- package/dist/Run.d.mts +2 -2
- package/dist/Run.d.mts.map +1 -1
- package/dist/SseEncoding.d.mts +10 -3
- package/dist/SseEncoding.d.mts.map +1 -1
- package/dist/SseEncoding.mjs +10 -1
- package/dist/SseEncoding.mjs.map +1 -1
- package/dist/Transport.d.mts +58 -17
- package/dist/Transport.d.mts.map +1 -1
- package/dist/Transport.mjs +81 -21
- package/dist/Transport.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/package.json +4 -4
- package/src/Atoms.test.ts +30 -0
- package/src/Atoms.ts +16 -7
- package/src/Client.ts +9 -7
- package/src/SseEncoding.test.ts +20 -1
- package/src/SseEncoding.ts +18 -1
- package/src/Transport.test.ts +199 -0
- package/src/Transport.ts +173 -34
package/dist/Transport.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
2
|
import { StreamId } from "./RunEvent.mjs";
|
|
3
|
-
import { execute, ndjson, resumable, sse } from "./Client.mjs";
|
|
3
|
+
import { Disconnected, execute, ndjson, resumable, sse } from "./Client.mjs";
|
|
4
4
|
import { sequence } from "./Run.mjs";
|
|
5
|
-
import { Effect, Stream } from "effect";
|
|
5
|
+
import { DateTime, Duration, Effect, Function, Option, Predicate, Schedule, Schema, Stream } from "effect";
|
|
6
|
+
import { Sse } from "effect/encoding";
|
|
7
|
+
import { HttpClient, HttpClientError } from "effect/http";
|
|
6
8
|
//#region src/Transport.ts
|
|
7
9
|
/**
|
|
8
10
|
* How a client reaches the server. Every transport turns an input into the
|
|
@@ -19,6 +21,7 @@ var Transport_exports = /* @__PURE__ */ __exportAll({
|
|
|
19
21
|
durable: () => durable,
|
|
20
22
|
durableHttp: () => durableHttp,
|
|
21
23
|
local: () => local,
|
|
24
|
+
reconnect: () => reconnect,
|
|
22
25
|
session: () => session,
|
|
23
26
|
sessionHttp: () => sessionHttp
|
|
24
27
|
});
|
|
@@ -36,47 +39,104 @@ const direct = (options) => ({ start: (input, state) => Stream.unwrap(options.re
|
|
|
36
39
|
* run's events are sequenced under `streamId`.
|
|
37
40
|
*/
|
|
38
41
|
const local = (run, options) => ({ start: (input, state) => run(input, state).pipe(sequence(options?.streamId ?? StreamId.make("local"))) });
|
|
42
|
+
const durableOf = (parts) => ({
|
|
43
|
+
...parts,
|
|
44
|
+
start: (input, state) => Stream.unwrap(Effect.map(parts.submit(input, state), (streamId) => parts.resume(streamId, 0)))
|
|
45
|
+
});
|
|
39
46
|
/**
|
|
40
47
|
* Durable runs: `submit` starts one on the server and returns its stream id;
|
|
41
|
-
* `events` reads it from `after
|
|
42
|
-
*
|
|
48
|
+
* `events` reads it from `after`. The run lives on the server, so a reload
|
|
49
|
+
* re-attaches with `resume`. A stream that ends before the run's terminal
|
|
50
|
+
* fails with `Client.Disconnected`; pipe through `reconnect` to resume it.
|
|
43
51
|
*/
|
|
44
|
-
const durable = (options) => ({
|
|
45
|
-
|
|
46
|
-
resume: options.events,
|
|
52
|
+
const durable = (options) => durableOf({
|
|
53
|
+
submit: options.submit,
|
|
54
|
+
resume: (streamId, after) => resumable((from) => options.events(streamId, from), { from: after }),
|
|
47
55
|
...options.stop !== void 0 ? { stop: options.stop } : {}
|
|
48
56
|
});
|
|
49
|
-
|
|
57
|
+
/**
|
|
58
|
+
* A session stream never ends on its own, so one that completes fails with
|
|
59
|
+
* `Client.Disconnected`; pipe through `reconnect` to resume it.
|
|
60
|
+
*/
|
|
61
|
+
const session = (options) => ({
|
|
62
|
+
...options,
|
|
63
|
+
events: (streamId, after) => resumable((from) => options.events(streamId, from), {
|
|
64
|
+
from: after,
|
|
65
|
+
until: "never"
|
|
66
|
+
})
|
|
67
|
+
});
|
|
50
68
|
const stopOver = (stop) => stop === void 0 ? {} : { stop: (streamId) => execute(stop(streamId)).pipe(Effect.asVoid) };
|
|
51
69
|
/**
|
|
52
70
|
* `durable` over HTTP. `submit` starts a run and returns its stream id (the
|
|
53
71
|
* app decodes its own response, e.g. with `HttpClientResponse.schemaJson`);
|
|
54
|
-
* `events` is read as SSE
|
|
72
|
+
* `events` is read as SSE. Pipe through `reconnect` to survive drops.
|
|
55
73
|
*/
|
|
56
74
|
const durableHttp = (options) => durable({
|
|
57
75
|
submit: options.submit,
|
|
58
|
-
events: (streamId, after) =>
|
|
59
|
-
from: after,
|
|
60
|
-
schedule: options.reconnect
|
|
61
|
-
}),
|
|
76
|
+
events: (streamId, after) => sse(options.events(streamId, after)),
|
|
62
77
|
...stopOver(options.stop)
|
|
63
78
|
});
|
|
64
79
|
/**
|
|
65
80
|
* `session` over HTTP. `send` adds an input and returns the session's stream
|
|
66
81
|
* id (without one, the server starts a session); `events` follows the whole
|
|
67
|
-
* session as SSE.
|
|
68
|
-
* `reconnect` usually retries forever (e.g. `Schedule.spaced("1 second")`).
|
|
82
|
+
* session as SSE. Pipe through `reconnect` to survive drops.
|
|
69
83
|
*/
|
|
70
84
|
const sessionHttp = (options) => session({
|
|
71
85
|
send: options.send,
|
|
72
|
-
events: (streamId, after) =>
|
|
73
|
-
from: after,
|
|
74
|
-
until: "never",
|
|
75
|
-
schedule: options.reconnect
|
|
76
|
-
}),
|
|
86
|
+
events: (streamId, after) => sse(options.events(streamId, after)),
|
|
77
87
|
...stopOver(options.stop)
|
|
78
88
|
});
|
|
89
|
+
const defaultSchedule = Schedule.min([Schedule.exponential("500 millis"), Schedule.spaced("10 seconds")]).pipe(Schedule.jittered);
|
|
90
|
+
const isDisconnected = (error) => error instanceof Disconnected;
|
|
91
|
+
const isTransient = (error) => Sse.Retry.is(error) || HttpClient.isTransientError(error);
|
|
92
|
+
const parseSeconds = Schema.decodeUnknownOption(Schema.FiniteFromString);
|
|
93
|
+
const retryAfter = (value, now) => parseSeconds(value).pipe(Option.map((seconds) => Duration.seconds(Math.max(0, seconds))), Option.orElse(() => DateTime.make(value).pipe(Option.map((at) => Duration.millis(Math.max(0, DateTime.toEpochMillis(at) - now))))));
|
|
94
|
+
const serverDelay = (error, now) => {
|
|
95
|
+
if (Sse.Retry.is(error)) return Option.some(error.duration);
|
|
96
|
+
if (!HttpClientError.isHttpClientError(error)) return Option.none();
|
|
97
|
+
return Option.fromNullishOr(error.response?.headers["retry-after"]).pipe(Option.flatMap((value) => retryAfter(value, now)));
|
|
98
|
+
};
|
|
99
|
+
const policy = (options) => {
|
|
100
|
+
const base = options?.schedule ?? defaultSchedule;
|
|
101
|
+
const extra = options?.while;
|
|
102
|
+
return (options?.times === void 0 ? base : Schedule.upTo(base, { times: options.times })).pipe(Schedule.while(({ input }) => isDisconnected(input) || isTransient(input) || extra !== void 0 && extra(input)), Schedule.modifyDelay(({ input, duration, now }) => Effect.succeed(Option.match(serverDelay(input, now), {
|
|
103
|
+
onNone: () => duration,
|
|
104
|
+
onSome: (asked) => Duration.max(duration, asked)
|
|
105
|
+
}))));
|
|
106
|
+
};
|
|
107
|
+
const isSession = (self) => "send" in self;
|
|
108
|
+
const isResumable = (u) => Predicate.hasProperty(u, "send") || Predicate.hasProperty(u, "resume");
|
|
109
|
+
/**
|
|
110
|
+
* Resume a dropped event stream from the last seq, as `HttpClient.retryTransient`
|
|
111
|
+
* does for requests. Retries drops (`Client.Disconnected`), timeouts, transport
|
|
112
|
+
* errors, 408 / 429 / 5xx and an SSE `retry:` line, waiting at least what the
|
|
113
|
+
* server asks for (`Retry-After`, `retry:`); anything else (404, 401, a
|
|
114
|
+
* malformed frame) ends the stream with that error. `while` adds the app's own
|
|
115
|
+
* transient errors (e.g. a dropped WebSocket).
|
|
116
|
+
*
|
|
117
|
+
* A session is followed forever; a durable run until its terminal. A durable
|
|
118
|
+
* `submit` is never retried, so an input is never sent twice. Not for `direct`
|
|
119
|
+
* / `local`: there the run is the request, with no position to resume from.
|
|
120
|
+
*
|
|
121
|
+
* const transport = Transport.reconnect(Transport.sessionHttp({ send, events, stop }))
|
|
122
|
+
* const ws = pipe(Transport.session({ send, events }), Transport.reconnect({ while: isDropped }))
|
|
123
|
+
*/
|
|
124
|
+
const reconnect = Function.dual((args) => isResumable(args[0]), (self, options) => {
|
|
125
|
+
const schedule = policy(options);
|
|
126
|
+
const resumed = (events, until) => (streamId, after) => resumable((from) => events(streamId, from), {
|
|
127
|
+
from: after,
|
|
128
|
+
until,
|
|
129
|
+
schedule
|
|
130
|
+
});
|
|
131
|
+
return isSession(self) ? {
|
|
132
|
+
...self,
|
|
133
|
+
events: resumed(self.events, "never")
|
|
134
|
+
} : durableOf({
|
|
135
|
+
...self,
|
|
136
|
+
resume: resumed(self.resume, "terminal")
|
|
137
|
+
});
|
|
138
|
+
});
|
|
79
139
|
//#endregion
|
|
80
|
-
export { direct, durable, durableHttp, local, session, sessionHttp, Transport_exports as t };
|
|
140
|
+
export { direct, durable, durableHttp, local, reconnect, session, sessionHttp, Transport_exports as t };
|
|
81
141
|
|
|
82
142
|
//# sourceMappingURL=Transport.mjs.map
|
package/dist/Transport.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Transport.mjs","names":["Client.ndjson","Client.sse","Run.sequence","Client.execute","Client.resumable"],"sources":["../src/Transport.ts"],"sourcesContent":["/**\n * How a client reaches the server. Every transport turns an input into the\n * envelopes of the run it starts; durable transports can also re-attach to a\n * run and stop it. Missing capabilities are absent from the value, so a UI\n * can tell whether stop or resume exists.\n *\n * `I` is the app's input (`Input.Input` for the preset); `S` is the client\n * state passed to `start`, so a request can carry any app data from it (a\n * chat id, settings).\n */\nimport { Effect, type Option, type Schedule, Stream } from \"effect\"\nimport type { HttpClient, HttpClientRequest } from \"effect/http\"\nimport * as Client from \"./Client.js\"\nimport * as Run from \"./Run.js\"\nimport { type Envelope, type RunEvent, StreamId } from \"./RunEvent.js\"\n\nexport type Transport<I, S, E, R> = {\n /** Start a run for `input` and stream its envelopes. Interrupting the stream abandons it. */\n readonly start: (input: I, state: S) => Stream.Stream<Envelope, E, R>\n /** Re-attach to a run after a reload, from `after`. Durable only. */\n readonly resume?: (streamId: StreamId, after: number) => Stream.Stream<Envelope, E, R>\n /** Ask the server to stop a run. Durable only; direct mode stops by interrupting `start`. */\n readonly stop?: (streamId: StreamId) => Effect.Effect<void, E, R>\n}\n\n/**\n * Direct mode: one HTTP request per input whose response body is the run.\n * Stop interrupts the stream, which aborts the request; the server sees the\n * client go away and interrupts its run.\n *\n * Any other source (RPC streams, server functions) is a transport too:\n * write `{ start: (input, state) => stream }` directly.\n */\nexport const direct = <I, S, EB = never, RB = never>(options: {\n readonly request: (\n input: I,\n state: S,\n ) => Effect.Effect<HttpClientRequest.HttpClientRequest, EB, RB>\n readonly format?: \"sse\" | \"ndjson\"\n}): Transport<I, S, Client.ClientError | EB, HttpClient.HttpClient | RB> => ({\n start: (input, state) =>\n Stream.unwrap(\n options\n .request(input, state)\n .pipe(\n Effect.map(\n (request): Stream.Stream<Envelope, Client.ClientError, HttpClient.HttpClient> =>\n options.format === \"ndjson\" ? Client.ndjson(request) : Client.sse(request),\n ),\n ),\n ),\n})\n\n/**\n * Run the agent in the same process (browser agent, tests, Storybook). The\n * run's events are sequenced under `streamId`.\n */\nexport const local = <I, S, E, R>(\n run: (input: I, state: S) => Stream.Stream<RunEvent, E, R>,\n options?: { readonly streamId?: StreamId },\n): Transport<I, S, E, R> => ({\n start: (input, state) =>\n run(input, state).pipe(Run.sequence(options?.streamId ?? StreamId.make(\"local\"))),\n})\n\n/** A per-run transport that can re-attach to a run (durable). */\nexport type Durable<I, S, E, R> = Transport<I, S, E, R> & {\n readonly resume: (streamId: StreamId, after: number) => Stream.Stream<Envelope, E, R>\n}\n\n/**\n * Durable runs: `submit` starts one on the server and returns its stream id;\n * `events` reads it from `after` (wrap it in `Client.resumable` to survive\n * drops). The run lives on the server, so a reload re-attaches with `resume`.\n */\nexport const durable = <I, S, ES, RS, EE, RE, EX = never, RX = never>(options: {\n readonly submit: (input: I, state: S) => Effect.Effect<StreamId, ES, RS>\n readonly events: (streamId: StreamId, after: number) => Stream.Stream<Envelope, EE, RE>\n readonly stop?: (streamId: StreamId) => Effect.Effect<void, EX, RX>\n}): Durable<I, S, ES | EE | EX, RS | RE | RX> => ({\n start: (input, state) =>\n Stream.unwrap(\n options.submit(input, state).pipe(Effect.map((streamId) => options.events(streamId, 0))),\n ),\n resume: options.events,\n ...(options.stop !== undefined ? { stop: options.stop } : {}),\n})\n\n/**\n * A durable session (a chat): one stream carries every run, and the server\n * owns its history. `send` without a stream id starts a new session and\n * returns its id (the app's own chat id, branded); with one, it adds a run to\n * that session. `events` follows the whole session from `after` (wrap it in\n * `Client.resumable` with `until: \"never\"`). The run arrives on that stream,\n * not as a response to `send`.\n */\nexport type Session<I, S, E, R> = {\n readonly send: (\n streamId: Option.Option<StreamId>,\n input: I,\n state: S,\n ) => Effect.Effect<StreamId, E, R>\n readonly events: (streamId: StreamId, after: number) => Stream.Stream<Envelope, E, R>\n readonly stop?: (streamId: StreamId) => Effect.Effect<void, E, R>\n}\n\nexport const session = <I, S, ES, RS, EE, RE, EX = never, RX = never>(options: {\n readonly send: (\n streamId: Option.Option<StreamId>,\n input: I,\n state: S,\n ) => Effect.Effect<StreamId, ES, RS>\n readonly events: (streamId: StreamId, after: number) => Stream.Stream<Envelope, EE, RE>\n readonly stop?: (streamId: StreamId) => Effect.Effect<void, EX, RX>\n}): Session<I, S, ES | EE | EX, RS | RE | RX> => options\n\n// ---------------------------------------------------------------------------\n// Over HTTP: `durable` and `session` with SSE events that reconnect\n// ---------------------------------------------------------------------------\n\n/** What the HTTP helpers can fail with. */\nexport type HttpError = Client.ClientError | Client.Disconnected\n\ntype HttpOptions = {\n /** The SSE request for a stream from `after`. */\n readonly events: (streamId: StreamId, after: number) => HttpClientRequest.HttpClientRequest\n /** The request that stops a stream's active run. */\n readonly stop?: (streamId: StreamId) => HttpClientRequest.HttpClientRequest\n /** When to reconnect a dropped event stream (it resumes from the last `seq`). */\n readonly reconnect: Schedule.Schedule<unknown, HttpError>\n}\n\nconst stopOver = (stop: HttpOptions[\"stop\"]) =>\n stop === undefined\n ? {}\n : { stop: (streamId: StreamId) => Client.execute(stop(streamId)).pipe(Effect.asVoid) }\n\n/**\n * `durable` over HTTP. `submit` starts a run and returns its stream id (the\n * app decodes its own response, e.g. with `HttpClientResponse.schemaJson`);\n * `events` is read as SSE and reconnects per `reconnect`.\n */\nexport const durableHttp = <I, S, ES = never, RS = never>(\n options: HttpOptions & {\n readonly submit: (input: I, state: S) => Effect.Effect<StreamId, ES, RS>\n },\n): Durable<I, S, HttpError | ES, HttpClient.HttpClient | RS> =>\n durable({\n submit: options.submit,\n events: (streamId, after) =>\n Client.resumable((from) => Client.sse(options.events(streamId, from)), {\n from: after,\n schedule: options.reconnect,\n }),\n ...stopOver(options.stop),\n })\n\n/**\n * `session` over HTTP. `send` adds an input and returns the session's stream\n * id (without one, the server starts a session); `events` follows the whole\n * session as SSE. It carries many runs and never ends on its own, so\n * `reconnect` usually retries forever (e.g. `Schedule.spaced(\"1 second\")`).\n */\nexport const sessionHttp = <I, S, ES = never, RS = never>(\n options: HttpOptions & {\n readonly send: (\n streamId: Option.Option<StreamId>,\n input: I,\n state: S,\n ) => Effect.Effect<StreamId, ES, RS>\n },\n): Session<I, S, HttpError | ES, HttpClient.HttpClient | RS> =>\n session({\n send: options.send,\n events: (streamId, after) =>\n Client.resumable((from) => Client.sse(options.events(streamId, from)), {\n from: after,\n until: \"never\",\n schedule: options.reconnect,\n }),\n ...stopOver(options.stop),\n })\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,MAAa,UAAwC,aAMwB,EAC3E,QAAQ,OAAO,UACb,OAAO,OACL,QACG,QAAQ,OAAO,KAAK,CAAC,CACrB,KACC,OAAO,KACJ,YACC,QAAQ,WAAW,WAAWA,OAAc,OAAO,IAAIC,IAAW,OAAO,CAC7E,CACF,CACJ,EACJ;;;;;AAMA,MAAa,SACX,KACA,aAC2B,EAC3B,QAAQ,OAAO,UACb,IAAI,OAAO,KAAK,CAAC,CAAC,KAAKC,SAAa,SAAS,YAAY,SAAS,KAAK,OAAO,CAAC,CAAC,EACpF;;;;;;AAYA,MAAa,WAAyD,aAIpB;CAChD,QAAQ,OAAO,UACb,OAAO,OACL,QAAQ,OAAO,OAAO,KAAK,CAAC,CAAC,KAAK,OAAO,KAAK,aAAa,QAAQ,OAAO,UAAU,CAAC,CAAC,CAAC,CACzF;CACF,QAAQ,QAAQ;CAChB,GAAI,QAAQ,SAAS,KAAA,IAAY,EAAE,MAAM,QAAQ,KAAK,IAAI,CAAC;AAC7D;AAoBA,MAAa,WAAyD,YAQrB;AAkBjD,MAAM,YAAY,SAChB,SAAS,KAAA,IACL,CAAC,IACD,EAAE,OAAO,aAAuBC,QAAe,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,OAAO,MAAM,EAAE;;;;;;AAOzF,MAAa,eACX,YAIA,QAAQ;CACN,QAAQ,QAAQ;CAChB,SAAS,UAAU,UACjBC,WAAkB,SAASH,IAAW,QAAQ,OAAO,UAAU,IAAI,CAAC,GAAG;EACrE,MAAM;EACN,UAAU,QAAQ;CACpB,CAAC;CACH,GAAG,SAAS,QAAQ,IAAI;AAC1B,CAAC;;;;;;;AAQH,MAAa,eACX,YAQA,QAAQ;CACN,MAAM,QAAQ;CACd,SAAS,UAAU,UACjBG,WAAkB,SAASH,IAAW,QAAQ,OAAO,UAAU,IAAI,CAAC,GAAG;EACrE,MAAM;EACN,OAAO;EACP,UAAU,QAAQ;CACpB,CAAC;CACH,GAAG,SAAS,QAAQ,IAAI;AAC1B,CAAC"}
|
|
1
|
+
{"version":3,"file":"Transport.mjs","names":["Client.ndjson","Client.sse","Run.sequence","Client.resumable","Client.execute","Client.Disconnected"],"sources":["../src/Transport.ts"],"sourcesContent":["/**\n * How a client reaches the server. Every transport turns an input into the\n * envelopes of the run it starts; durable transports can also re-attach to a\n * run and stop it. Missing capabilities are absent from the value, so a UI\n * can tell whether stop or resume exists.\n *\n * `I` is the app's input (`Input.Input` for the preset); `S` is the client\n * state passed to `start`, so a request can carry any app data from it (a\n * chat id, settings).\n */\nimport {\n DateTime,\n Duration,\n Effect,\n Option,\n Function,\n Predicate,\n Schedule,\n Schema,\n Stream,\n} from \"effect\"\nimport { Sse } from \"effect/encoding\"\nimport { HttpClient, HttpClientError, type HttpClientRequest } from \"effect/http\"\nimport * as Client from \"./Client.js\"\nimport * as Run from \"./Run.js\"\nimport { type Envelope, type RunEvent, StreamId } from \"./RunEvent.js\"\n\nexport type Transport<I, S, E, R> = {\n /** Start a run for `input` and stream its envelopes. Interrupting the stream abandons it. */\n readonly start: (input: I, state: S) => Stream.Stream<Envelope, E, R>\n /** Re-attach to a run after a reload, from `after`. Durable only. */\n readonly resume?: (streamId: StreamId, after: number) => Stream.Stream<Envelope, E, R>\n /** Ask the server to stop a run. Durable only; direct mode stops by interrupting `start`. */\n readonly stop?: (streamId: StreamId) => Effect.Effect<void, E, R>\n}\n\n/**\n * Direct mode: one HTTP request per input whose response body is the run.\n * Stop interrupts the stream, which aborts the request; the server sees the\n * client go away and interrupts its run.\n *\n * Any other source (RPC streams, server functions) is a transport too:\n * write `{ start: (input, state) => stream }` directly.\n */\nexport const direct = <I, S, EB = never, RB = never>(options: {\n readonly request: (\n input: I,\n state: S,\n ) => Effect.Effect<HttpClientRequest.HttpClientRequest, EB, RB>\n readonly format?: \"sse\" | \"ndjson\"\n}): Transport<I, S, Client.ClientError | EB, HttpClient.HttpClient | RB> => ({\n start: (input, state) =>\n Stream.unwrap(\n options\n .request(input, state)\n .pipe(\n Effect.map(\n (request): Stream.Stream<Envelope, Client.ClientError, HttpClient.HttpClient> =>\n options.format === \"ndjson\" ? Client.ndjson(request) : Client.sse(request),\n ),\n ),\n ),\n})\n\n/**\n * Run the agent in the same process (browser agent, tests, Storybook). The\n * run's events are sequenced under `streamId`.\n */\nexport const local = <I, S, E, R>(\n run: (input: I, state: S) => Stream.Stream<RunEvent, E, R>,\n options?: { readonly streamId?: StreamId },\n): Transport<I, S, E, R> => ({\n start: (input, state) =>\n run(input, state).pipe(Run.sequence(options?.streamId ?? StreamId.make(\"local\"))),\n})\n\n/** A per-run transport that can re-attach to a run (durable). `start` is `submit`, then `resume` from 0. */\nexport type Durable<I, S, E, R> = Transport<I, S, E, R> & {\n /** Start a run on the server and return its stream id. */\n readonly submit: (input: I, state: S) => Effect.Effect<StreamId, E, R>\n readonly resume: (streamId: StreamId, after: number) => Stream.Stream<Envelope, E, R>\n}\n\nconst durableOf = <I, S, E, R>(parts: Omit<Durable<I, S, E, R>, \"start\">): Durable<I, S, E, R> => ({\n ...parts,\n start: (input, state) =>\n Stream.unwrap(Effect.map(parts.submit(input, state), (streamId) => parts.resume(streamId, 0))),\n})\n\n/**\n * Durable runs: `submit` starts one on the server and returns its stream id;\n * `events` reads it from `after`. The run lives on the server, so a reload\n * re-attaches with `resume`. A stream that ends before the run's terminal\n * fails with `Client.Disconnected`; pipe through `reconnect` to resume it.\n */\nexport const durable = <I, S, ES, RS, EE, RE, EX = never, RX = never>(options: {\n readonly submit: (input: I, state: S) => Effect.Effect<StreamId, ES, RS>\n readonly events: (streamId: StreamId, after: number) => Stream.Stream<Envelope, EE, RE>\n readonly stop?: (streamId: StreamId) => Effect.Effect<void, EX, RX>\n}): Durable<I, S, ES | EE | EX | Client.Disconnected, RS | RE | RX> =>\n durableOf<I, S, ES | EE | EX | Client.Disconnected, RS | RE | RX>({\n submit: options.submit,\n resume: (streamId, after) =>\n Client.resumable((from) => options.events(streamId, from), { from: after }),\n ...(options.stop !== undefined ? { stop: options.stop } : {}),\n })\n\n/**\n * A durable session (a chat): one stream carries every run, and the server\n * owns its history. `send` without a stream id starts a new session and\n * returns its id (the app's own chat id, branded); with one, it adds a run to\n * that session. `events` follows the whole session from `after`. The run\n * arrives on that stream, not as a response to `send`.\n */\nexport type Session<I, S, E, R> = {\n readonly send: (\n streamId: Option.Option<StreamId>,\n input: I,\n state: S,\n ) => Effect.Effect<StreamId, E, R>\n readonly events: (streamId: StreamId, after: number) => Stream.Stream<Envelope, E, R>\n readonly stop?: (streamId: StreamId) => Effect.Effect<void, E, R>\n}\n\n/**\n * A session stream never ends on its own, so one that completes fails with\n * `Client.Disconnected`; pipe through `reconnect` to resume it.\n */\nexport const session = <I, S, ES, RS, EE, RE, EX = never, RX = never>(options: {\n readonly send: (\n streamId: Option.Option<StreamId>,\n input: I,\n state: S,\n ) => Effect.Effect<StreamId, ES, RS>\n readonly events: (streamId: StreamId, after: number) => Stream.Stream<Envelope, EE, RE>\n readonly stop?: (streamId: StreamId) => Effect.Effect<void, EX, RX>\n}): Session<I, S, ES | EE | EX | Client.Disconnected, RS | RE | RX> => ({\n ...options,\n events: (streamId, after) =>\n Client.resumable((from) => options.events(streamId, from), { from: after, until: \"never\" }),\n})\n\n// ---------------------------------------------------------------------------\n// Over HTTP: `durable` and `session` with SSE events\n// ---------------------------------------------------------------------------\n\n/** What the HTTP helpers can fail with. */\nexport type HttpError = Client.ClientError | Client.Disconnected\n\ntype HttpOptions = {\n /** The SSE request for a stream from `after`. */\n readonly events: (streamId: StreamId, after: number) => HttpClientRequest.HttpClientRequest\n /** The request that stops a stream's active run. */\n readonly stop?: (streamId: StreamId) => HttpClientRequest.HttpClientRequest\n}\n\nconst stopOver = (stop: HttpOptions[\"stop\"]) =>\n stop === undefined\n ? {}\n : { stop: (streamId: StreamId) => Client.execute(stop(streamId)).pipe(Effect.asVoid) }\n\n/**\n * `durable` over HTTP. `submit` starts a run and returns its stream id (the\n * app decodes its own response, e.g. with `HttpClientResponse.schemaJson`);\n * `events` is read as SSE. Pipe through `reconnect` to survive drops.\n */\nexport const durableHttp = <I, S, ES = never, RS = never>(\n options: HttpOptions & {\n readonly submit: (input: I, state: S) => Effect.Effect<StreamId, ES, RS>\n },\n): Durable<I, S, HttpError | ES, HttpClient.HttpClient | RS> =>\n durable({\n submit: options.submit,\n events: (streamId, after) => Client.sse(options.events(streamId, after)),\n ...stopOver(options.stop),\n })\n\n/**\n * `session` over HTTP. `send` adds an input and returns the session's stream\n * id (without one, the server starts a session); `events` follows the whole\n * session as SSE. Pipe through `reconnect` to survive drops.\n */\nexport const sessionHttp = <I, S, ES = never, RS = never>(\n options: HttpOptions & {\n readonly send: (\n streamId: Option.Option<StreamId>,\n input: I,\n state: S,\n ) => Effect.Effect<StreamId, ES, RS>\n },\n): Session<I, S, HttpError | ES, HttpClient.HttpClient | RS> =>\n session({\n send: options.send,\n events: (streamId, after) => Client.sse(options.events(streamId, after)),\n ...stopOver(options.stop),\n })\n\n// ---------------------------------------------------------------------------\n// Reconnect: resume a dropped event stream from the last seq\n// ---------------------------------------------------------------------------\n\nexport type ReconnectOptions<E> = {\n /**\n * When to retry. Default: exponential from 500 ms, capped at 10 s, jittered.\n * Cap your own exponential: the schedule resets only when a frame arrives,\n * so an idle session's backoff otherwise grows without bound.\n */\n readonly schedule?: Schedule.Schedule<unknown, E | Client.Disconnected>\n /** More errors to treat as transient, on top of drops, timeouts, 408 / 429 / 5xx and SSE `retry:`. */\n readonly while?: Predicate.Predicate<E>\n /** Max attempts in a row; the count resets once a frame arrives. Default: unlimited. */\n readonly times?: number\n}\n\nconst defaultSchedule = Schedule.min([\n Schedule.exponential(\"500 millis\"),\n Schedule.spaced(\"10 seconds\"),\n]).pipe(Schedule.jittered)\n\nconst isDisconnected = (error: unknown): error is Client.Disconnected =>\n error instanceof Client.Disconnected\n\nconst isTransient = (error: unknown) => Sse.Retry.is(error) || HttpClient.isTransientError(error)\n\nconst parseSeconds = Schema.decodeUnknownOption(Schema.FiniteFromString)\n\n// `Retry-After` is either seconds or an HTTP date.\nconst retryAfter = (value: string, now: number): Option.Option<Duration.Duration> =>\n parseSeconds(value).pipe(\n Option.map((seconds) => Duration.seconds(Math.max(0, seconds))),\n Option.orElse(() =>\n DateTime.make(value).pipe(\n Option.map((at) => Duration.millis(Math.max(0, DateTime.toEpochMillis(at) - now))),\n ),\n ),\n )\n\n// The wait the server asked for: an SSE `retry:` line or a `Retry-After` header.\nconst serverDelay = (error: unknown, now: number): Option.Option<Duration.Duration> => {\n if (Sse.Retry.is(error)) return Option.some(error.duration)\n if (!HttpClientError.isHttpClientError(error)) return Option.none()\n return Option.fromNullishOr(error.response?.headers[\"retry-after\"]).pipe(\n Option.flatMap((value) => retryAfter(value, now)),\n )\n}\n\nconst policy = <E>(\n options: ReconnectOptions<E> | undefined,\n): Schedule.Schedule<unknown, E | Client.Disconnected> => {\n const base = options?.schedule ?? defaultSchedule\n const extra = options?.while\n return (options?.times === undefined ? base : Schedule.upTo(base, { times: options.times })).pipe(\n Schedule.while(\n ({ input }) =>\n isDisconnected(input) || isTransient(input) || (extra !== undefined && extra(input)),\n ),\n Schedule.modifyDelay(({ input, duration, now }) =>\n Effect.succeed(\n Option.match(serverDelay(input, now), {\n onNone: () => duration,\n onSome: (asked) => Duration.max(duration, asked),\n }),\n ),\n ),\n )\n}\n\n/** A transport `reconnect` applies to: one with a position to resume from. */\nexport type Resumable = Session<any, any, any, any> | Durable<any, any, any, any>\n\n/** What a transport can fail with. */\nexport type ErrorOf<T extends Resumable> =\n T extends Session<any, any, infer E, any>\n ? E\n : T extends Durable<any, any, infer E, any>\n ? E\n : never\n\ntype WithDisconnected<F> = F extends (...args: infer A) => Stream.Stream<infer X, infer E, infer R>\n ? (...args: A) => Stream.Stream<X, E | Client.Disconnected, R>\n : F\n\n/** `T` with `Client.Disconnected` added to its streams; `send` / `submit` keep their exact types. */\nexport type Reconnected<T extends Resumable> = { readonly [K in keyof T]: WithDisconnected<T[K]> }\n\nconst isSession = (self: Resumable): self is Session<any, any, any, any> => \"send\" in self\n\nconst isResumable = (u: unknown): u is Resumable =>\n Predicate.hasProperty(u, \"send\") || Predicate.hasProperty(u, \"resume\")\n\n/**\n * Resume a dropped event stream from the last seq, as `HttpClient.retryTransient`\n * does for requests. Retries drops (`Client.Disconnected`), timeouts, transport\n * errors, 408 / 429 / 5xx and an SSE `retry:` line, waiting at least what the\n * server asks for (`Retry-After`, `retry:`); anything else (404, 401, a\n * malformed frame) ends the stream with that error. `while` adds the app's own\n * transient errors (e.g. a dropped WebSocket).\n *\n * A session is followed forever; a durable run until its terminal. A durable\n * `submit` is never retried, so an input is never sent twice. Not for `direct`\n * / `local`: there the run is the request, with no position to resume from.\n *\n * const transport = Transport.reconnect(Transport.sessionHttp({ send, events, stop }))\n * const ws = pipe(Transport.session({ send, events }), Transport.reconnect({ while: isDropped }))\n */\nexport const reconnect: {\n <E>(options?: ReconnectOptions<E>): <T extends Resumable>(self: T) => Reconnected<T>\n <T extends Resumable>(self: T, options?: ReconnectOptions<ErrorOf<T>>): Reconnected<T>\n} = Function.dual(\n (args) => isResumable(args[0]),\n (self: Resumable, options?: ReconnectOptions<unknown>): Resumable => {\n const schedule = policy(options)\n const resumed =\n (events: Session<any, any, any, any>[\"events\"], until: \"terminal\" | \"never\") =>\n (streamId: StreamId, after: number) =>\n Client.resumable((from) => events(streamId, from), { from: after, until, schedule })\n return isSession(self)\n ? { ...self, events: resumed(self.events, \"never\") }\n : durableOf({ ...self, resume: resumed(self.resume, \"terminal\") })\n },\n)\n"],"mappings":"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"}
|
package/dist/index.d.mts
CHANGED
|
@@ -2,7 +2,7 @@ import { t as RunEvent_d_exports } from "./RunEvent.mjs";
|
|
|
2
2
|
import { s as Input_d_exports } from "./Input-Cz-Kxusm.mjs";
|
|
3
3
|
import { u as RunView_d_exports } from "./RunView-nHfJwN8t.mjs";
|
|
4
4
|
import { l as Thread_d_exports } from "./Thread-CAXsjhpX.mjs";
|
|
5
|
-
import { n as Client_d_exports } from "./Client-
|
|
5
|
+
import { n as Client_d_exports } from "./Client-B2Eqo39q.mjs";
|
|
6
6
|
import { t as Transport_d_exports } from "./Transport.mjs";
|
|
7
7
|
import { t as UiContract_d_exports } from "./UiContract.mjs";
|
|
8
8
|
import { t as Atoms_d_exports } from "./Atoms.mjs";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-uai/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0-dev-20261110202152",
|
|
4
4
|
"description": "Agentic UI primitives for @effect-uai/core: a resumable run-event protocol, projection, client folds and Effect atoms.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -89,11 +89,11 @@
|
|
|
89
89
|
"@effect/vitest": "4.1.2",
|
|
90
90
|
"effect": "4.1.2",
|
|
91
91
|
"typescript": "^7.0.2",
|
|
92
|
-
"@effect-uai/core": "0.
|
|
92
|
+
"@effect-uai/core": "0.20.0-dev-20261110202152"
|
|
93
93
|
},
|
|
94
94
|
"peerDependencies": {
|
|
95
|
-
"@effect-uai/core": "0.
|
|
96
|
-
"effect": ">=4.0
|
|
95
|
+
"@effect-uai/core": "0.20.0-dev-20261110202152",
|
|
96
|
+
"effect": ">=4.1.0 <5.0.0"
|
|
97
97
|
},
|
|
98
98
|
"scripts": {
|
|
99
99
|
"build": "tsdown",
|
package/src/Atoms.test.ts
CHANGED
|
@@ -160,4 +160,34 @@ describe("Atoms.sessionThread", () => {
|
|
|
160
160
|
expect(replayed.messages.map((m) => m.role)).toEqual(["assistant", "assistant"])
|
|
161
161
|
}),
|
|
162
162
|
)
|
|
163
|
+
|
|
164
|
+
it.effect("a hydrated thread starts as `initial` and opens from its last seq", () =>
|
|
165
|
+
Effect.gen(function* () {
|
|
166
|
+
const { chatId, transport } = yield* fakeSession
|
|
167
|
+
const first = Atoms.sessionThread({ runtime, transport })
|
|
168
|
+
const elsewhere = AtomRegistry.make()
|
|
169
|
+
elsewhere.set(first.submit, Input.submit("first"))
|
|
170
|
+
const hydrated = yield* settledCount(elsewhere, first.state, 2)
|
|
171
|
+
elsewhere.set(first.submit, Input.submit("second"))
|
|
172
|
+
yield* settledCount(elsewhere, first.state, 4)
|
|
173
|
+
|
|
174
|
+
const opened = yield* Ref.make<ReadonlyArray<number>>([])
|
|
175
|
+
const spied = Transport.session({
|
|
176
|
+
send: transport.send,
|
|
177
|
+
events: (id, after) =>
|
|
178
|
+
Stream.unwrap(
|
|
179
|
+
Ref.update(opened, (a) => [...a, after]).pipe(Effect.as(transport.events(id, after))),
|
|
180
|
+
),
|
|
181
|
+
})
|
|
182
|
+
const chat = Atoms.sessionThread({ runtime, transport: spied, initial: hydrated })
|
|
183
|
+
const registry = AtomRegistry.make()
|
|
184
|
+
expect(registry.get(chat.state)).toEqual(hydrated)
|
|
185
|
+
expect(registry.get(chat.streamId)).toBeNull()
|
|
186
|
+
|
|
187
|
+
registry.set(chat.open, { streamId: chatId, after: hydrated.lastSeq })
|
|
188
|
+
const thread = yield* settledCount(registry, chat.state, 3)
|
|
189
|
+
expect(yield* Ref.get(opened)).toEqual([hydrated.lastSeq])
|
|
190
|
+
expect(thread.messages.map((m) => m.role)).toEqual(["user", "assistant", "assistant"])
|
|
191
|
+
}),
|
|
192
|
+
)
|
|
163
193
|
})
|
package/src/Atoms.ts
CHANGED
|
@@ -234,6 +234,9 @@ type BaseSessionOptions<I, S, E, R, ER, D> = {
|
|
|
234
234
|
export type SessionOptions<I, S, E, R, ER, D = Envelope> = BaseSessionOptions<I, S, E, R, ER, D> &
|
|
235
235
|
WithDecode<D>
|
|
236
236
|
|
|
237
|
+
/** What `open` follows: a session from its start, or from `after` (e.g. a hydrated thread's `lastSeq`). */
|
|
238
|
+
export type Open = StreamId | { readonly streamId: StreamId; readonly after: number }
|
|
239
|
+
|
|
237
240
|
const buildSession = <I, S, E, R, ER, D>(
|
|
238
241
|
options: BaseSessionOptions<I, S, E, R, ER, D> & { readonly decode: Decode<D> },
|
|
239
242
|
) => {
|
|
@@ -249,11 +252,13 @@ const buildSession = <I, S, E, R, ER, D>(
|
|
|
249
252
|
|
|
250
253
|
// Non-concurrent: opening another session stops following the previous one.
|
|
251
254
|
const open = options.runtime
|
|
252
|
-
.fn<
|
|
253
|
-
|
|
255
|
+
.fn<Open>()((target) => {
|
|
256
|
+
const { streamId: id, after } =
|
|
257
|
+
typeof target === "string" ? { streamId: target, after: 0 } : target
|
|
258
|
+
return Atom.set(streamId, id).pipe(
|
|
254
259
|
Effect.andThen(
|
|
255
260
|
options.transport
|
|
256
|
-
.events(id,
|
|
261
|
+
.events(id, after)
|
|
257
262
|
.pipe(
|
|
258
263
|
Stream.runForEach((envelope) =>
|
|
259
264
|
options
|
|
@@ -267,8 +272,8 @@ const buildSession = <I, S, E, R, ER, D>(
|
|
|
267
272
|
),
|
|
268
273
|
),
|
|
269
274
|
Effect.onExit(settle),
|
|
270
|
-
)
|
|
271
|
-
)
|
|
275
|
+
)
|
|
276
|
+
})
|
|
272
277
|
.pipe(Atom.keepAlive)
|
|
273
278
|
|
|
274
279
|
const submit = options.runtime
|
|
@@ -305,7 +310,9 @@ type BuiltSession<I, S, E, R, ER> = ReturnType<typeof buildSession<I, S, E, R, E
|
|
|
305
310
|
|
|
306
311
|
/**
|
|
307
312
|
* State over a session transport. `open(streamId)` follows a session's
|
|
308
|
-
* stream from its start (e.g. a chat id kept across reloads)
|
|
313
|
+
* stream from its start (e.g. a chat id kept across reloads);
|
|
314
|
+
* `open({ streamId, after })` from a position, for state hydrated elsewhere
|
|
315
|
+
* (pass it as `initial`; `initial` never opens anything). `submit` sends
|
|
309
316
|
* an input; without an open session the server starts one, which is then
|
|
310
317
|
* opened. Runs arrive on the followed stream, not as responses to `submit`.
|
|
311
318
|
*/
|
|
@@ -322,6 +329,8 @@ type SessionThreadOptions<I, E, R, ER, P extends Payloads> = {
|
|
|
322
329
|
readonly runtime: Runtime<R, ER>
|
|
323
330
|
readonly transport: Transport.Session<Input.Sent<I>, Thread.Thread<P>, E, R>
|
|
324
331
|
readonly isBusy?: (thread: Thread.Thread<P>) => boolean
|
|
332
|
+
/** A thread hydrated elsewhere (e.g. from a database); follow it with `open({ streamId, after: lastSeq })`. */
|
|
333
|
+
readonly initial?: Thread.Thread<P>
|
|
325
334
|
}
|
|
326
335
|
|
|
327
336
|
/**
|
|
@@ -345,7 +354,7 @@ export function sessionThread<I extends Input.Input, E, R, ER>(
|
|
|
345
354
|
return buildSession({
|
|
346
355
|
runtime: options.runtime,
|
|
347
356
|
transport: options.transport,
|
|
348
|
-
initial: fold.initial,
|
|
357
|
+
initial: options.initial ?? fold.initial,
|
|
349
358
|
step: fold.step,
|
|
350
359
|
decode: options.contract?.decode ?? identity,
|
|
351
360
|
...(options.isBusy !== undefined ? { isBusy: options.isBusy } : {}),
|
package/src/Client.ts
CHANGED
|
@@ -42,26 +42,27 @@ export const ndjson = (request: HttpClientRequest.HttpClientRequest) =>
|
|
|
42
42
|
* Keep events flowing across dropped connections: `connect(after)` is
|
|
43
43
|
* re-invoked with the last seen `seq` on failure, per `schedule`; duplicates
|
|
44
44
|
* are dropped. A connection that ends cleanly before the end counts as a
|
|
45
|
-
* failure (`Disconnected`), so it is retried too.
|
|
45
|
+
* failure (`Disconnected`), so it is retried too. Without `schedule` nothing
|
|
46
|
+
* is retried: an early end just fails with `Disconnected`.
|
|
46
47
|
*
|
|
47
48
|
* `until: "terminal"` (default) follows one run and completes after its
|
|
48
49
|
* terminal. `until: "never"` follows a session stream that carries many runs
|
|
49
50
|
* (a durable chat) and never completes on its own.
|
|
50
51
|
*/
|
|
51
|
-
export const resumable = <E, R, X>(
|
|
52
|
+
export const resumable = <E, R, X = never>(
|
|
52
53
|
connect: (after: number) => Stream.Stream<Envelope, E, R>,
|
|
53
|
-
options
|
|
54
|
+
options?: {
|
|
54
55
|
readonly from?: number
|
|
55
56
|
readonly until?: "terminal" | "never"
|
|
56
|
-
readonly schedule
|
|
57
|
+
readonly schedule?: Schedule.Schedule<X, E | Disconnected>
|
|
57
58
|
},
|
|
58
59
|
): Stream.Stream<Envelope, E | Disconnected, R> =>
|
|
59
60
|
Stream.unwrap(
|
|
60
61
|
Effect.gen(function* () {
|
|
61
|
-
const untilTerminal = options
|
|
62
|
+
const untilTerminal = options?.until !== "never"
|
|
62
63
|
const ends = (envelope: Envelope) => untilTerminal && isTerminal(envelope.event)
|
|
63
64
|
// The last seen seq, and whether the run's terminal arrived.
|
|
64
|
-
const position = yield* Ref.make({ seq: options
|
|
65
|
+
const position = yield* Ref.make({ seq: options?.from ?? 0, done: false })
|
|
65
66
|
const attempt = Stream.unwrap(
|
|
66
67
|
Ref.get(position).pipe(
|
|
67
68
|
Effect.map(({ seq: after }) =>
|
|
@@ -87,6 +88,7 @@ export const resumable = <E, R, X>(
|
|
|
87
88
|
),
|
|
88
89
|
),
|
|
89
90
|
)
|
|
90
|
-
|
|
91
|
+
const schedule = options?.schedule
|
|
92
|
+
return schedule === undefined ? attempt : attempt.pipe(Stream.retry(schedule))
|
|
91
93
|
}),
|
|
92
94
|
)
|
package/src/SseEncoding.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it } from "@effect/vitest"
|
|
2
|
-
import { Effect, Option, Stream } from "effect"
|
|
2
|
+
import { Effect, Fiber, Option, Schedule, Stream } from "effect"
|
|
3
|
+
import { TestClock } from "effect/testing"
|
|
3
4
|
import { expect } from "vitest"
|
|
4
5
|
import { type Envelope, RunId, StreamId } from "./RunEvent.js"
|
|
5
6
|
import * as SseEncoding from "./SseEncoding.js"
|
|
@@ -43,4 +44,22 @@ describe("SseEncoding", () => {
|
|
|
43
44
|
])("lastEventId reads $label", ({ headers, seq }) => {
|
|
44
45
|
expect(SseEncoding.lastEventId(headers)).toEqual(seq)
|
|
45
46
|
})
|
|
47
|
+
|
|
48
|
+
it.effect("heartbeat interleaves comments, ends with the events, and decodes away", () =>
|
|
49
|
+
Effect.gen(function* () {
|
|
50
|
+
const fiber = yield* Stream.fromIterable(envelopes).pipe(
|
|
51
|
+
Stream.schedule(Schedule.spaced("1 second")),
|
|
52
|
+
SseEncoding.encode,
|
|
53
|
+
SseEncoding.heartbeat("400 millis"),
|
|
54
|
+
Stream.runCollect,
|
|
55
|
+
Effect.forkChild,
|
|
56
|
+
)
|
|
57
|
+
yield* TestClock.adjust("3 seconds")
|
|
58
|
+
const bytes = yield* Fiber.join(fiber)
|
|
59
|
+
const text = new TextDecoder().decode(Buffer.concat(bytes))
|
|
60
|
+
expect(text.split(": heartbeat\n\n").length).toBeGreaterThan(2)
|
|
61
|
+
const decoded = yield* Stream.fromIterable(bytes).pipe(SseEncoding.decode, Stream.runCollect)
|
|
62
|
+
expect(decoded).toEqual(envelopes)
|
|
63
|
+
}),
|
|
64
|
+
)
|
|
46
65
|
})
|
package/src/SseEncoding.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* encoder and parser.
|
|
7
7
|
* For NDJSON use `effect/encoding/Ndjson` with `RunEvent.EnvelopeJson`.
|
|
8
8
|
*/
|
|
9
|
-
import { Effect, Option, Schema, SchemaTransformation, Stream } from "effect"
|
|
9
|
+
import { type Duration, Effect, Option, Schema, SchemaTransformation, Stream } from "effect"
|
|
10
10
|
import { Sse } from "effect/encoding"
|
|
11
11
|
import { Envelope, EnvelopeJson } from "./RunEvent.js"
|
|
12
12
|
|
|
@@ -50,6 +50,23 @@ export const decode = <E, R>(
|
|
|
50
50
|
): Stream.Stream<Envelope, E | Sse.Retry | Sse.SseError | Schema.SchemaError, R> =>
|
|
51
51
|
self.pipe(Stream.decodeText, Stream.pipeThroughChannelOrFail(Sse.decodeSchema(EnvelopeEvent)))
|
|
52
52
|
|
|
53
|
+
const comment = new TextEncoder().encode(": heartbeat\n\n")
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Interleave an SSE comment every `interval`, so proxies don't close a stream
|
|
57
|
+
* that sits idle (a session between answers). Pipe after `encode`: each frame
|
|
58
|
+
* is one chunk, so a comment always lands between whole frames. Parsers
|
|
59
|
+
* (`EventSource`, `decode`) ignore comments. Ends with the event stream.
|
|
60
|
+
*/
|
|
61
|
+
export const heartbeat =
|
|
62
|
+
(interval: Duration.Input) =>
|
|
63
|
+
<E, R>(self: Stream.Stream<Uint8Array, E, R>): Stream.Stream<Uint8Array, E, R> =>
|
|
64
|
+
self.pipe(
|
|
65
|
+
Stream.merge(Stream.tick(interval).pipe(Stream.map(() => comment)), {
|
|
66
|
+
haltStrategy: "left",
|
|
67
|
+
}),
|
|
68
|
+
)
|
|
69
|
+
|
|
53
70
|
/**
|
|
54
71
|
* Headers an SSE response needs. Behind nginx-style proxies, also send
|
|
55
72
|
* `x-accel-buffering: no` so frames are not buffered.
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, it } from "@effect/vitest"
|
|
2
|
+
import { Data, Duration, Effect, Fiber, Function, Ref, Schedule, Stream } from "effect"
|
|
3
|
+
import { Sse } from "effect/encoding"
|
|
4
|
+
import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"
|
|
5
|
+
import { TestClock } from "effect/testing"
|
|
6
|
+
import { expect, expectTypeOf } from "vitest"
|
|
7
|
+
import * as Client from "./Client.js"
|
|
8
|
+
import { type Envelope, RunId, type RunEvent, StreamId } from "./RunEvent.js"
|
|
9
|
+
import * as Transport from "./Transport.js"
|
|
10
|
+
|
|
11
|
+
const r1 = RunId.make("r1")
|
|
12
|
+
const chatId = StreamId.make("chat")
|
|
13
|
+
|
|
14
|
+
class SocketDropped extends Data.TaggedError("SocketDropped")<{}> {}
|
|
15
|
+
|
|
16
|
+
type Failure = HttpClientError.HttpClientError | Sse.Retry | SocketDropped
|
|
17
|
+
|
|
18
|
+
const frames: ReadonlyArray<Envelope> = (
|
|
19
|
+
[
|
|
20
|
+
{ type: "run-started", runId: r1, inputs: [], protocol: 2 },
|
|
21
|
+
{ type: "text-delta", text: "a" },
|
|
22
|
+
{ type: "text-delta", text: "b" },
|
|
23
|
+
{ type: "text-delta", text: "c" },
|
|
24
|
+
{ type: "run-completed" },
|
|
25
|
+
] satisfies ReadonlyArray<RunEvent>
|
|
26
|
+
).map((event, i) => ({ streamId: chatId, seq: i + 1, runId: r1, event }))
|
|
27
|
+
|
|
28
|
+
const status = (code: number, headers: Record<string, string> = {}) => {
|
|
29
|
+
const request = HttpClientRequest.get("/chats/chat/events")
|
|
30
|
+
const response = HttpClientResponse.fromWeb(
|
|
31
|
+
request,
|
|
32
|
+
new Response(null, { status: code, headers }),
|
|
33
|
+
)
|
|
34
|
+
return new HttpClientError.HttpClientError({
|
|
35
|
+
reason: new HttpClientError.StatusCodeError({ request, response }),
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Attempt = { readonly upTo: number; readonly error?: Failure }
|
|
40
|
+
|
|
41
|
+
// A server that replays from `after`; how each connection ends is scripted.
|
|
42
|
+
// Past the script, it sends the rest and then stays open (a live session).
|
|
43
|
+
const server = (attempts: ReadonlyArray<Attempt>) =>
|
|
44
|
+
Effect.gen(function* () {
|
|
45
|
+
const calls = yield* Ref.make<ReadonlyArray<number>>([])
|
|
46
|
+
const submits = yield* Ref.make(0)
|
|
47
|
+
const events = (_: StreamId, after: number): Stream.Stream<Envelope, Failure> =>
|
|
48
|
+
Stream.unwrap(
|
|
49
|
+
Ref.modify(calls, (cs) => [cs.length, [...cs, after]]).pipe(
|
|
50
|
+
Effect.map((n) => {
|
|
51
|
+
const attempt = attempts[n]
|
|
52
|
+
if (attempt === undefined) {
|
|
53
|
+
return Stream.fromIterable(frames.slice(after)).pipe(Stream.concat(Stream.never))
|
|
54
|
+
}
|
|
55
|
+
const sent = Stream.fromIterable(frames.slice(after, attempt.upTo))
|
|
56
|
+
const error = attempt.error
|
|
57
|
+
return error === undefined ? sent : sent.pipe(Stream.concat(Stream.fail(error)))
|
|
58
|
+
}),
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
const send = () => Effect.succeed(chatId)
|
|
62
|
+
const submit = () => Ref.update(submits, (n) => n + 1).pipe(Effect.as(chatId))
|
|
63
|
+
const session = Transport.session({ send, events })
|
|
64
|
+
const durable = Transport.durable({ submit, events })
|
|
65
|
+
return { calls, submits, session, durable }
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const fast = Schedule.forever
|
|
69
|
+
|
|
70
|
+
const seqs = (envelopes: ReadonlyArray<Envelope>) => envelopes.map((e) => e.seq)
|
|
71
|
+
|
|
72
|
+
const follow = (session: Transport.Session<unknown, unknown, unknown, never>, take = 5) =>
|
|
73
|
+
session.events(chatId, 0).pipe(Stream.take(take), Stream.runCollect)
|
|
74
|
+
|
|
75
|
+
const failure = (session: Transport.Session<unknown, unknown, unknown, never>) =>
|
|
76
|
+
session.events(chatId, 0).pipe(Stream.runDrain, Effect.flip)
|
|
77
|
+
|
|
78
|
+
describe("Transport.session / durable", () => {
|
|
79
|
+
it.effect("a session stream that completes fails with Disconnected at the last seq", () =>
|
|
80
|
+
Effect.gen(function* () {
|
|
81
|
+
const { session } = yield* server([{ upTo: 2 }])
|
|
82
|
+
expect(yield* failure(session)).toEqual(new Client.Disconnected({ lastSeq: 2 }))
|
|
83
|
+
}),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
it.effect("a durable run that completes before its terminal fails with Disconnected", () =>
|
|
87
|
+
Effect.gen(function* () {
|
|
88
|
+
const { durable } = yield* server([{ upTo: 3 }])
|
|
89
|
+
const error = yield* durable.start(undefined, undefined).pipe(Stream.runDrain, Effect.flip)
|
|
90
|
+
expect(error).toEqual(new Client.Disconnected({ lastSeq: 3 }))
|
|
91
|
+
}),
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe("Transport.reconnect", () => {
|
|
96
|
+
it.effect.each([
|
|
97
|
+
{ label: "a clean close", error: undefined },
|
|
98
|
+
{ label: "a 503", error: status(503) },
|
|
99
|
+
{
|
|
100
|
+
label: "an SSE retry: line",
|
|
101
|
+
error: new Sse.Retry({ duration: Duration.zero, lastEventId: "2" }),
|
|
102
|
+
},
|
|
103
|
+
])("re-opens a session from the last seq after $label", ({ error }) =>
|
|
104
|
+
Effect.gen(function* () {
|
|
105
|
+
const { calls, session } = yield* server([
|
|
106
|
+
error === undefined ? { upTo: 2 } : { upTo: 2, error },
|
|
107
|
+
])
|
|
108
|
+
const received = yield* follow(Transport.reconnect(session, { schedule: fast }))
|
|
109
|
+
expect(seqs(received)).toEqual([1, 2, 3, 4, 5])
|
|
110
|
+
expect(yield* Ref.get(calls)).toEqual([0, 2])
|
|
111
|
+
}),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
it.effect.each([{ code: 404 }, { code: 401 }])(
|
|
115
|
+
"ends with a $code, without retrying",
|
|
116
|
+
({ code }) =>
|
|
117
|
+
Effect.gen(function* () {
|
|
118
|
+
const { calls, session } = yield* server([{ upTo: 1, error: status(code) }])
|
|
119
|
+
const error = yield* failure(Transport.reconnect(session, { schedule: fast }))
|
|
120
|
+
expect(error).toEqual(status(code))
|
|
121
|
+
expect(yield* Ref.get(calls)).toEqual([0])
|
|
122
|
+
}),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
it.effect("`while` adds the app's own transient errors, typed by the transport", () =>
|
|
126
|
+
Effect.gen(function* () {
|
|
127
|
+
const dropped = { upTo: 2, error: new SocketDropped() }
|
|
128
|
+
const plain = yield* server([dropped])
|
|
129
|
+
expect(yield* failure(Transport.reconnect(plain.session, { schedule: fast }))).toEqual(
|
|
130
|
+
new SocketDropped(),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
const extended = yield* server([dropped])
|
|
134
|
+
const reconnected = Transport.reconnect(extended.session, {
|
|
135
|
+
schedule: fast,
|
|
136
|
+
while: (error) => {
|
|
137
|
+
expectTypeOf(error).toEqualTypeOf<Failure | Client.Disconnected>()
|
|
138
|
+
return error instanceof SocketDropped
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
expect(seqs(yield* follow(reconnected))).toEqual([1, 2, 3, 4, 5])
|
|
142
|
+
}),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
it.effect("pipes, and keeps the transport's kind and errors", () =>
|
|
146
|
+
Effect.gen(function* () {
|
|
147
|
+
const { session, durable } = yield* server([{ upTo: 2, error: status(503) }])
|
|
148
|
+
const piped = Function.pipe(session, Transport.reconnect({ schedule: fast }))
|
|
149
|
+
expectTypeOf(piped).toExtend<
|
|
150
|
+
Transport.Session<unknown, unknown, Failure | Client.Disconnected, never>
|
|
151
|
+
>()
|
|
152
|
+
expectTypeOf(Function.pipe(durable, Transport.reconnect())).toExtend<
|
|
153
|
+
Transport.Durable<unknown, unknown, Failure | Client.Disconnected, never>
|
|
154
|
+
>()
|
|
155
|
+
expect(seqs(yield* follow(piped))).toEqual([1, 2, 3, 4, 5])
|
|
156
|
+
}),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
it.effect("`times` caps the attempts in a row", () =>
|
|
160
|
+
Effect.gen(function* () {
|
|
161
|
+
const { calls, session } = yield* server(
|
|
162
|
+
Array.from({ length: 5 }, () => ({ upTo: 0, error: status(503) })),
|
|
163
|
+
)
|
|
164
|
+
const error = yield* failure(Transport.reconnect(session, { schedule: fast, times: 2 }))
|
|
165
|
+
expect(error).toEqual(status(503))
|
|
166
|
+
expect((yield* Ref.get(calls)).length).toBe(3)
|
|
167
|
+
}),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
it.effect("waits at least the server's Retry-After", () =>
|
|
171
|
+
Effect.gen(function* () {
|
|
172
|
+
const { calls, session } = yield* server([
|
|
173
|
+
{ upTo: 2, error: status(429, { "retry-after": "5" }) },
|
|
174
|
+
])
|
|
175
|
+
const fiber = yield* follow(Transport.reconnect(session, { schedule: fast })).pipe(
|
|
176
|
+
Effect.forkChild,
|
|
177
|
+
)
|
|
178
|
+
yield* TestClock.adjust("4 seconds")
|
|
179
|
+
expect(yield* Ref.get(calls)).toEqual([0])
|
|
180
|
+
yield* TestClock.adjust("1 second")
|
|
181
|
+
expect(seqs(yield* Fiber.join(fiber))).toEqual([1, 2, 3, 4, 5])
|
|
182
|
+
expect(yield* Ref.get(calls)).toEqual([0, 2])
|
|
183
|
+
}),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
it.effect.each([
|
|
187
|
+
{ label: "after the first frames", upTo: 2, resumedFrom: 2 },
|
|
188
|
+
{ label: "before the first frame", upTo: 0, resumedFrom: 0 },
|
|
189
|
+
])("durable start submits once and resumes a drop $label", ({ upTo, resumedFrom }) =>
|
|
190
|
+
Effect.gen(function* () {
|
|
191
|
+
const { calls, submits, durable } = yield* server([{ upTo, error: status(502) }])
|
|
192
|
+
const reconnected = Transport.reconnect(durable, { schedule: fast })
|
|
193
|
+
const received = yield* reconnected.start(undefined, undefined).pipe(Stream.runCollect)
|
|
194
|
+
expect(seqs(received)).toEqual([1, 2, 3, 4, 5])
|
|
195
|
+
expect(yield* Ref.get(submits)).toBe(1)
|
|
196
|
+
expect(yield* Ref.get(calls)).toEqual([0, resumedFrom])
|
|
197
|
+
}),
|
|
198
|
+
)
|
|
199
|
+
})
|