@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
@@ -0,0 +1,46 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import { Effect, Option, Stream } from "effect"
3
+ import { expect } from "vitest"
4
+ import { type Envelope, RunId, StreamId } from "./RunEvent.js"
5
+ import * as SseEncoding from "./SseEncoding.js"
6
+
7
+ describe("SseEncoding", () => {
8
+ const envelopes: ReadonlyArray<Envelope> = [
9
+ {
10
+ streamId: StreamId.make("s"),
11
+ seq: 1,
12
+ runId: RunId.make("r1"),
13
+ event: { type: "text-delta", text: "two\nlines" },
14
+ },
15
+ {
16
+ streamId: StreamId.make("s"),
17
+ seq: 2,
18
+ event: {
19
+ type: "image",
20
+ image: { _tag: "bytes", bytes: new Uint8Array([1, 2]), mimeType: "image/png" },
21
+ },
22
+ },
23
+ ]
24
+
25
+ it.effect("frames id = seq, event = type, and round-trips non-JSON values", () =>
26
+ Effect.gen(function* () {
27
+ const bytes = yield* Stream.fromIterable(envelopes).pipe(
28
+ SseEncoding.encode,
29
+ Stream.runCollect,
30
+ )
31
+ const text = new TextDecoder().decode(Buffer.concat(bytes))
32
+ expect(text).toContain("id: 1\nevent: text-delta\n")
33
+ const decoded = yield* Stream.fromIterable(bytes).pipe(SseEncoding.decode, Stream.runCollect)
34
+ expect(decoded).toEqual(envelopes)
35
+ }),
36
+ )
37
+
38
+ it.each([
39
+ { label: "web Headers", headers: new Headers({ "last-event-id": "9" }), seq: Option.some(9) },
40
+ { label: "Effect record headers", headers: { "last-event-id": "7" }, seq: Option.some(7) },
41
+ { label: "no header", headers: new Headers(), seq: Option.none() },
42
+ { label: "a non-integer value", headers: { "last-event-id": "abc" }, seq: Option.none() },
43
+ ])("lastEventId reads $label", ({ headers, seq }) => {
44
+ expect(SseEncoding.lastEventId(headers)).toEqual(seq)
45
+ })
46
+ })
@@ -0,0 +1,77 @@
1
+ /**
2
+ * SSE framing for envelopes, on Effect's `effect/encoding/Sse`. A frame
3
+ * carries `id: <seq>` (so a browser's auto-reconnect sends `Last-Event-ID`),
4
+ * `event: <type>` and the envelope's JSON in `data`. `EnvelopeEvent` is that
5
+ * mapping as a Schema; `encode` / `decode` run it with Effect's SSE
6
+ * encoder and parser.
7
+ * For NDJSON use `effect/encoding/Ndjson` with `RunEvent.EnvelopeJson`.
8
+ */
9
+ import { Effect, Option, Schema, SchemaTransformation, Stream } from "effect"
10
+ import { Sse } from "effect/encoding"
11
+ import { Envelope, EnvelopeJson } from "./RunEvent.js"
12
+
13
+ /** An envelope as an SSE event: `id` = seq, `event` = type, `data` = the envelope as JSON. */
14
+ export const EnvelopeEvent = Schema.Struct({
15
+ id: Schema.optional(Schema.String),
16
+ event: Schema.String,
17
+ data: Schema.fromJsonString(EnvelopeJson),
18
+ }).pipe(
19
+ Schema.decodeTo(
20
+ Schema.toType(Envelope),
21
+ SchemaTransformation.transform({
22
+ decode: (event) => event.data,
23
+ encode: (envelope) => ({
24
+ id: String(envelope.seq),
25
+ event: envelope.event.type,
26
+ data: envelope,
27
+ }),
28
+ }),
29
+ ),
30
+ )
31
+
32
+ const encodeEvent = Schema.encodeEffect(EnvelopeEvent)
33
+
34
+ /** Envelopes → an SSE response body. */
35
+ export const encode = <E, R>(
36
+ self: Stream.Stream<Envelope, E, R>,
37
+ ): Stream.Stream<Uint8Array, E | Schema.SchemaError, R> =>
38
+ self.pipe(
39
+ Stream.mapEffect((envelope) =>
40
+ encodeEvent(envelope).pipe(
41
+ Effect.map((event) => Sse.encoder.write({ _tag: "Event", id: event.id, ...event })),
42
+ ),
43
+ ),
44
+ Stream.encodeText,
45
+ )
46
+
47
+ /** An SSE response body → envelopes. A `retry:` hint from the server fails with `Sse.Retry`. */
48
+ export const decode = <E, R>(
49
+ self: Stream.Stream<Uint8Array, E, R>,
50
+ ): Stream.Stream<Envelope, E | Sse.Retry | Sse.SseError | Schema.SchemaError, R> =>
51
+ self.pipe(Stream.decodeText, Stream.pipeThroughChannelOrFail(Sse.decodeSchema(EnvelopeEvent)))
52
+
53
+ /**
54
+ * Headers an SSE response needs. Behind nginx-style proxies, also send
55
+ * `x-accel-buffering: no` so frames are not buffered.
56
+ */
57
+ export const responseHeaders: Readonly<Record<string, string>> = {
58
+ "content-type": "text/event-stream",
59
+ "cache-control": "no-cache",
60
+ }
61
+
62
+ const parseSeq = Schema.decodeUnknownOption(Schema.FiniteFromString.check(Schema.isInt()))
63
+
64
+ /**
65
+ * The `seq` a reconnecting SSE client last received, from the standard
66
+ * `Last-Event-ID` header (a browser's EventSource sends it on every
67
+ * auto-reconnect). Accepts web `Headers` or Effect's record headers.
68
+ *
69
+ * Prefer it over any position the app carries in the URL: EventSource keeps
70
+ * reconnecting to its original URL, so a URL position goes stale.
71
+ */
72
+ export const lastEventId = (
73
+ headers: Headers | { readonly [key: string]: string | undefined },
74
+ ): Option.Option<number> =>
75
+ Option.fromNullishOr(
76
+ headers instanceof Headers ? headers.get("last-event-id") : headers["last-event-id"],
77
+ ).pipe(Option.flatMap(parseSeq))
@@ -0,0 +1,304 @@
1
+ import * as ToolDecision from "@effect-uai/core/ToolDecision"
2
+ import type * as Items from "@effect-uai/core/Items"
3
+ import { loop, onTurnComplete, stop } from "@effect-uai/core/Loop"
4
+ import * as MockProvider from "@effect-uai/core/testing/MockProvider"
5
+ import * as Tool from "@effect-uai/core/Tool"
6
+ import * as Toolkit from "@effect-uai/core/Toolkit"
7
+ import * as Turn from "@effect-uai/core/Turn"
8
+ import { describe, it } from "@effect/vitest"
9
+ import { Effect, Option, pipe, Schema, Stream } from "effect"
10
+ import { expect } from "vitest"
11
+ import * as Input from "./Input.js"
12
+ import * as Projection from "./Projection.js"
13
+ import * as Run from "./Run.js"
14
+ import { type Envelope, InputId, RunId, type RunEvent, StreamId } from "./RunEvent.js"
15
+ import * as RunView from "./RunView.js"
16
+ import * as SseEncoding from "./SseEncoding.js"
17
+ import * as Thread from "./Thread.js"
18
+ import * as UiContract from "./UiContract.js"
19
+
20
+ const getWeather = Tool.make({
21
+ name: "get_weather",
22
+ description: "Current weather for a city.",
23
+ inputSchema: Schema.Struct({ city: Schema.String }),
24
+ outputSchema: Schema.Struct({ city: Schema.String, temperatureC: Schema.Number }),
25
+ run: ({ city }) => Effect.succeed({ city, temperatureC: 19.4 }),
26
+ })
27
+ const toolkit = Toolkit.make(getWeather)
28
+ const usage = { input_tokens: 5, output_tokens: 5 }
29
+
30
+ type State = { readonly history: ReadonlyArray<Items.HistoryItem> }
31
+
32
+ // Server side: a real tool loop, projected, bracketed, sequenced, framed as SSE bytes.
33
+ const serve = (inputId: InputId) => {
34
+ const { service } = MockProvider.make([
35
+ {
36
+ stop_reason: "tool_calls",
37
+ usage,
38
+ items: [
39
+ {
40
+ type: "function_call",
41
+ call_id: "c1",
42
+ name: "get_weather",
43
+ arguments: `{"city":"Lisbon"}`,
44
+ },
45
+ ],
46
+ },
47
+ {
48
+ stop_reason: "stop",
49
+ usage,
50
+ items: [
51
+ { type: "message", role: "assistant", content: [{ type: "output_text", text: "19°C." }] },
52
+ ],
53
+ },
54
+ ])
55
+ const initialState: State = { history: [] }
56
+ return pipe(
57
+ initialState,
58
+ loop((state: State) =>
59
+ service.streamTurn({ history: state.history, model: "mock", tools: toolkit }).pipe(
60
+ onTurnComplete((turn) =>
61
+ Effect.sync(() => {
62
+ const calls = Turn.getToolCalls(turn)
63
+ return calls.length === 0
64
+ ? stop()
65
+ : Toolkit.run(toolkit, calls).pipe(Toolkit.next(toolkit, state, turn))
66
+ }),
67
+ ),
68
+ ),
69
+ ),
70
+ Projection.toRunEvents({ contract: UiContract.make({ tools: [getWeather] }) }),
71
+ Stream.catchCause((cause) => Stream.succeed(Run.failed(cause))),
72
+ Run.start({ runId: RunId.make("r1"), inputs: [inputId] }),
73
+ Run.complete,
74
+ Run.sequence(StreamId.make("st_1")),
75
+ SseEncoding.encode,
76
+ )
77
+ }
78
+
79
+ const receive = (bytes: Stream.Stream<Uint8Array, Schema.SchemaError>) =>
80
+ bytes.pipe(SseEncoding.decode, Stream.runCollect)
81
+
82
+ describe("Thread over the wire", () => {
83
+ it.effect("folds a served tool run into a settled assistant message", () =>
84
+ Effect.gen(function* () {
85
+ const input = { ...Input.submit("Weather in Lisbon?"), id: InputId.make("in_1") }
86
+ const envelopes = yield* receive(serve(input.id))
87
+ const thread = envelopes.reduce(Thread.step, Thread.submitted(Thread.initial, input))
88
+
89
+ expect(thread.active).toBeNull()
90
+ expect(thread.messages.map((m) => m.role)).toEqual(["user", "assistant"])
91
+ const assistant = thread.messages[1]
92
+ expect(assistant?.role === "assistant" && assistant.value.status).toBe("completed")
93
+ expect(assistant?.role === "assistant" && assistant.value.usage).toEqual({
94
+ input_tokens: 10,
95
+ output_tokens: 10,
96
+ })
97
+ expect(assistant?.role === "assistant" && assistant.value.blocks).toMatchObject([
98
+ {
99
+ type: "tool",
100
+ id: "c1",
101
+ name: "get_weather",
102
+ status: "done",
103
+ input: { city: "Lisbon" },
104
+ outcome: { _tag: "Success", value: { city: "Lisbon", temperatureC: 19.4 } },
105
+ },
106
+ { type: "output_text", text: "19°C." },
107
+ ])
108
+ }),
109
+ )
110
+
111
+ it.effect("is unchanged when a reconnect replays frames it already applied", () =>
112
+ Effect.gen(function* () {
113
+ const input = { ...Input.submit("Weather in Lisbon?"), id: InputId.make("in_1") }
114
+ const envelopes = yield* receive(serve(input.id))
115
+ const start = Thread.submitted(Thread.initial, input)
116
+ const once = envelopes.reduce(Thread.step, start)
117
+ // Drop after frame 4, reconnect, receive everything again from the start.
118
+ const replayed = [...envelopes.slice(0, 4), ...envelopes].reduce(Thread.step, start)
119
+ expect(replayed).toEqual(once)
120
+ }),
121
+ )
122
+ })
123
+
124
+ describe("Thread across streams", () => {
125
+ // One stream per run: each run's seq starts at 1 again.
126
+ const run = (stream: string, text: string): ReadonlyArray<Envelope> =>
127
+ (
128
+ [
129
+ {
130
+ type: "run-started",
131
+ runId: RunId.make(stream),
132
+ inputs: [],
133
+ protocol: 2,
134
+ },
135
+ { type: "text-delta", text },
136
+ { type: "run-completed" },
137
+ ] satisfies ReadonlyArray<RunEvent>
138
+ ).map((event, i) => ({
139
+ streamId: StreamId.make(stream),
140
+ seq: i + 1,
141
+ runId: RunId.make(stream),
142
+ event,
143
+ }))
144
+
145
+ it("keeps input sent during a run pending until its own run starts", () => {
146
+ const s = StreamId.make("chat")
147
+ const frame = (seq: number, runId: string, event: RunEvent): Envelope => ({
148
+ streamId: s,
149
+ seq,
150
+ runId: RunId.make(runId),
151
+ event,
152
+ })
153
+ const first = { ...Input.submit("first"), id: InputId.make("in_1") }
154
+ const second = { ...Input.submit("second"), id: InputId.make("in_2") }
155
+ const started = (runId: string, input: InputId): RunEvent => ({
156
+ type: "run-started",
157
+ runId: RunId.make(runId),
158
+ inputs: [input],
159
+ protocol: 2,
160
+ })
161
+
162
+ const streaming = [
163
+ frame(1, "r1", started("r1", first.id)),
164
+ frame(2, "r1", { type: "text-delta", text: "a" }),
165
+ ].reduce(Thread.step, Thread.submitted(Thread.initial, first))
166
+ const queued = Thread.submitted(streaming, second)
167
+ expect(queued.pending.map((m) => m.id)).toEqual([second.id])
168
+
169
+ const done = [
170
+ frame(3, "r1", { type: "run-completed" }),
171
+ frame(4, "r2", started("r2", second.id)),
172
+ frame(5, "r2", { type: "text-delta", text: "b" }),
173
+ frame(6, "r2", { type: "run-completed" }),
174
+ ].reduce(Thread.step, queued)
175
+ expect(done.pending).toEqual([])
176
+ expect(
177
+ done.messages.map((m) => (m.role === "user" ? Input.text(m.value) : RunView.text(m.value))),
178
+ ).toEqual(["first", "a", "second", "b"])
179
+ })
180
+
181
+ it("a custom fold rebuilds the conversation from the stream alone (reload): echoes become entries", () => {
182
+ // Entries as plain text instead of the preset's messages / RunView.
183
+ const text = Thread.make({
184
+ toUser: (input: Input.Submit) => Option.some(Input.text(input)),
185
+ settle: RunView.text,
186
+ decodeInput: Schema.decodeUnknownOption(Input.SubmitJson),
187
+ })
188
+ const s = StreamId.make("chat")
189
+ const r = RunId.make("r1")
190
+ const echo = Schema.encodeSync(Input.SubmitJson)({
191
+ ...Input.submit("hi"),
192
+ id: InputId.make("in_1"),
193
+ })
194
+ const frames: ReadonlyArray<Envelope> = [
195
+ { streamId: s, seq: 1, event: { type: "input-accepted", input: echo } },
196
+ {
197
+ streamId: s,
198
+ seq: 2,
199
+ runId: r,
200
+ event: {
201
+ type: "run-started",
202
+ runId: r,
203
+ inputs: [InputId.make("in_1")],
204
+ protocol: 2,
205
+ },
206
+ },
207
+ { streamId: s, seq: 3, runId: r, event: { type: "text-delta", text: "Hello" } },
208
+ { streamId: s, seq: 4, runId: r, event: { type: "run-completed" } },
209
+ ]
210
+
211
+ const afterEcho = text.step(text.initial, frames[0]!)
212
+ expect(afterEcho.pending.map((m) => m.value)).toEqual(["hi"])
213
+
214
+ const done = frames.reduce(text.step, text.initial)
215
+ expect(done.pending).toEqual([])
216
+ expect(done.messages.map((m) => [m.role, m.value])).toEqual([
217
+ ["user", "hi"],
218
+ ["assistant", "Hello"],
219
+ ])
220
+ })
221
+
222
+ it("does not mistake a later run on a new stream for a replay", () => {
223
+ const thread = [...run("a", "first"), ...run("b", "second")].reduce(Thread.step, Thread.initial)
224
+ expect(
225
+ thread.messages.map((m) => (m.role === "assistant" ? RunView.text(m.value) : "")),
226
+ ).toEqual(["first", "second"])
227
+ })
228
+ })
229
+
230
+ describe("Thread approvals", () => {
231
+ const s = StreamId.make("chat")
232
+ const frame = (seq: number, runId: string, event: RunEvent): Envelope => ({
233
+ streamId: s,
234
+ seq,
235
+ runId: RunId.make(runId),
236
+ event,
237
+ })
238
+ const ask = { ...Input.submit("Book a table for 8"), id: InputId.make("in_1") }
239
+ const call = { callId: "call_1", name: "book_table", input: { time: "20:00" } }
240
+
241
+ // The first run asks for approval and ends interrupted.
242
+ const interrupted = [
243
+ frame(1, "r1", { type: "run-started", runId: RunId.make("r1"), inputs: [ask.id], protocol: 2 }),
244
+ frame(2, "r1", { type: "tool-call-ready", ...call }),
245
+ frame(3, "r1", { type: "run-interrupted", interrupts: [{ ...call, reason: "approval" }] }),
246
+ ].reduce(Thread.step, Thread.submitted(Thread.initial, ask))
247
+
248
+ // The answer runs the call (or not) in the next run.
249
+ const answer = (approved: boolean, result: RunEvent) => {
250
+ const decision = approved ? ToolDecision.Default.Approve() : ToolDecision.Default.Deny()
251
+ const resolve = {
252
+ ...Input.resolve([{ callId: "call_1", decision }]),
253
+ id: InputId.make("in_2"),
254
+ }
255
+ return [
256
+ frame(4, "r2", {
257
+ type: "run-started",
258
+ runId: RunId.make("r2"),
259
+ inputs: [resolve.id],
260
+ protocol: 2,
261
+ }),
262
+ frame(5, "r2", result),
263
+ frame(6, "r2", { type: "text-delta", text: approved ? "Booked." : "Not booked." }),
264
+ frame(7, "r2", { type: "run-completed" }),
265
+ ].reduce(Thread.step, Thread.submitted(interrupted, resolve))
266
+ }
267
+
268
+ const blocksOf = (thread: Thread.Thread, index: number) => {
269
+ const m = thread.messages[index]
270
+ return m?.role === "assistant" ? m.value.blocks : []
271
+ }
272
+
273
+ it("an approved call's result updates the card it was asked from", () => {
274
+ const thread = answer(true, {
275
+ type: "tool-result",
276
+ callId: "call_1",
277
+ name: "book_table",
278
+ result: { _tag: "Success", value: { booked: true } },
279
+ })
280
+ expect(blocksOf(thread, 1)).toMatchObject([
281
+ {
282
+ type: "tool",
283
+ id: "call_1",
284
+ status: "done",
285
+ outcome: { _tag: "Success", value: { booked: true } },
286
+ },
287
+ ])
288
+ expect(blocksOf(thread, 2)).toEqual([{ type: "output_text", id: "b0", text: "Booked." }])
289
+ })
290
+
291
+ it("a denied call fails on the same card, with the server's error", () => {
292
+ const error = { _tag: "Denied", message: "not tonight" }
293
+ const thread = answer(false, {
294
+ type: "tool-result",
295
+ callId: "call_1",
296
+ name: "book_table",
297
+ result: { _tag: "Failure", error },
298
+ })
299
+ expect(blocksOf(thread, 1)).toMatchObject([
300
+ { type: "tool", id: "call_1", status: "failed", outcome: { _tag: "Failure", error } },
301
+ ])
302
+ expect(blocksOf(thread, 2)).toEqual([{ type: "output_text", id: "b0", text: "Not booked." }])
303
+ })
304
+ })