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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -2
  3. package/dist/Atoms.d.mts +141 -0
  4. package/dist/Atoms.d.mts.map +1 -0
  5. package/dist/Atoms.mjs +132 -0
  6. package/dist/Atoms.mjs.map +1 -0
  7. package/dist/Client-C9UYN_VK.d.mts +219 -0
  8. package/dist/Client-C9UYN_VK.d.mts.map +1 -0
  9. package/dist/Client.d.mts +2 -0
  10. package/dist/Client.mjs +55 -0
  11. package/dist/Client.mjs.map +1 -0
  12. package/dist/Input-Cz-Kxusm.d.mts +277 -0
  13. package/dist/Input-Cz-Kxusm.d.mts.map +1 -0
  14. package/dist/Input.d.mts +2 -0
  15. package/dist/Input.mjs +127 -0
  16. package/dist/Input.mjs.map +1 -0
  17. package/dist/Projection.d.mts +44 -0
  18. package/dist/Projection.d.mts.map +1 -0
  19. package/dist/Projection.mjs +133 -0
  20. package/dist/Projection.mjs.map +1 -0
  21. package/dist/Run.d.mts +44 -0
  22. package/dist/Run.d.mts.map +1 -0
  23. package/dist/Run.mjs +117 -0
  24. package/dist/Run.mjs.map +1 -0
  25. package/dist/RunEvent.d.mts +1225 -0
  26. package/dist/RunEvent.d.mts.map +1 -0
  27. package/dist/RunEvent.mjs +264 -0
  28. package/dist/RunEvent.mjs.map +1 -0
  29. package/dist/RunView-nHfJwN8t.d.mts +380 -0
  30. package/dist/RunView-nHfJwN8t.d.mts.map +1 -0
  31. package/dist/RunView.d.mts +2 -0
  32. package/dist/RunView.mjs +452 -0
  33. package/dist/RunView.mjs.map +1 -0
  34. package/dist/SseEncoding.d.mts +437 -0
  35. package/dist/SseEncoding.d.mts.map +1 -0
  36. package/dist/SseEncoding.mjs +64 -0
  37. package/dist/SseEncoding.mjs.map +1 -0
  38. package/dist/Thread-CAXsjhpX.d.mts +474 -0
  39. package/dist/Thread-CAXsjhpX.d.mts.map +1 -0
  40. package/dist/Thread.d.mts +2 -0
  41. package/dist/Thread.mjs +225 -0
  42. package/dist/Thread.mjs.map +1 -0
  43. package/dist/Transport.d.mts +96 -0
  44. package/dist/Transport.d.mts.map +1 -0
  45. package/dist/Transport.mjs +82 -0
  46. package/dist/Transport.mjs.map +1 -0
  47. package/dist/UiContract.d.mts +113 -0
  48. package/dist/UiContract.d.mts.map +1 -0
  49. package/dist/UiContract.mjs +229 -0
  50. package/dist/UiContract.mjs.map +1 -0
  51. package/dist/index.d.mts +12 -0
  52. package/dist/index.mjs +12 -0
  53. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  54. package/package.json +99 -3
  55. package/src/Atoms.test.ts +163 -0
  56. package/src/Atoms.ts +355 -0
  57. package/src/Client.test.ts +53 -0
  58. package/src/Client.ts +92 -0
  59. package/src/Input.test.ts +68 -0
  60. package/src/Input.ts +165 -0
  61. package/src/Projection.test.ts +198 -0
  62. package/src/Projection.ts +142 -0
  63. package/src/Run.test.ts +119 -0
  64. package/src/Run.ts +166 -0
  65. package/src/RunEvent.test.ts +84 -0
  66. package/src/RunEvent.ts +374 -0
  67. package/src/RunView.test.ts +98 -0
  68. package/src/RunView.ts +583 -0
  69. package/src/SseEncoding.test.ts +46 -0
  70. package/src/SseEncoding.ts +77 -0
  71. package/src/Thread.test.ts +304 -0
  72. package/src/Thread.ts +286 -0
  73. package/src/Transport.ts +182 -0
  74. package/src/UiContract.test.ts +342 -0
  75. package/src/UiContract.ts +402 -0
  76. package/src/index.ts +11 -0
package/src/Run.ts ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Server-side stream operators for runs. Each does one thing; a run pipes
3
+ * the ones it needs:
4
+ *
5
+ * agent(history).pipe(
6
+ * Projection.toRunEvents({ contract }),
7
+ * Run.coalesce("30 millis"),
8
+ * Stream.catchCause((cause) => Stream.succeed(Run.failed(cause))),
9
+ * Run.stopWhen(Deferred.await(stop)), // durable runs
10
+ * Run.start({ runId, inputs }),
11
+ * Run.complete,
12
+ * Run.sequence(streamId),
13
+ * )
14
+ *
15
+ * In this order they keep the protocol's rule: `run-started` first, exactly
16
+ * one terminal last. Direct mode needs no stop: when the client goes away,
17
+ * the server interrupts the stream (Effect's `HttpServer`, or
18
+ * `HttpRouter.toWebHandler` behind a framework's route).
19
+ */
20
+ import { Array as Arr, Cause, Effect, Predicate, Schema, Stream } from "effect"
21
+ import type * as Duration from "effect/Duration"
22
+ import {
23
+ type Envelope,
24
+ type InputId,
25
+ isTerminal,
26
+ PROTOCOL_VERSION,
27
+ RunId,
28
+ type RunEvent,
29
+ type StreamId,
30
+ } from "./RunEvent.js"
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // A run's frame: start, stop, failure, completion
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export type StartOptions = {
37
+ readonly runId: RunId
38
+ /** Client input ids the run answers. */
39
+ readonly inputs?: ReadonlyArray<InputId>
40
+ /** Role label when a stream carries several kinds of run. */
41
+ readonly name?: string
42
+ }
43
+
44
+ /** Prepend `run-started`. */
45
+ export const start =
46
+ (options: StartOptions) =>
47
+ <E, R>(self: Stream.Stream<RunEvent, E, R>): Stream.Stream<RunEvent, E, R> => {
48
+ const started: RunEvent = {
49
+ type: "run-started",
50
+ runId: options.runId,
51
+ inputs: options.inputs ?? [],
52
+ protocol: PROTOCOL_VERSION,
53
+ ...(options.name !== undefined ? { name: options.name } : {}),
54
+ }
55
+ return Stream.make(started).pipe(Stream.concat(self))
56
+ }
57
+
58
+ /** End the run at its first terminal; append `run-completed` if it ends without one. */
59
+ export const complete = <E, R>(
60
+ self: Stream.Stream<RunEvent, E, R>,
61
+ ): Stream.Stream<RunEvent, E, R> =>
62
+ self.pipe(
63
+ Stream.takeUntil(isTerminal),
64
+ Stream.mapAccum(
65
+ () => false,
66
+ (ended, event): readonly [boolean, ReadonlyArray<RunEvent>] => [
67
+ ended || isTerminal(event),
68
+ [event],
69
+ ],
70
+ { onHalt: (ended): ReadonlyArray<RunEvent> => (ended ? [] : [{ type: "run-completed" }]) },
71
+ ),
72
+ )
73
+
74
+ /** End the run with `run-stopped` when `signal` completes (e.g. a stop `Deferred`). */
75
+ export const stopWhen =
76
+ (signal: Effect.Effect<void>) =>
77
+ <E, R>(self: Stream.Stream<RunEvent, E, R>): Stream.Stream<RunEvent, E, R> =>
78
+ self.pipe(
79
+ Stream.merge(Stream.fromEffect(Effect.as(signal, { type: "run-stopped" } as const)), {
80
+ haltStrategy: "left",
81
+ }),
82
+ Stream.takeUntil((event) => event.type === "run-stopped"),
83
+ )
84
+
85
+ /** A `run-failed` for a failed stream: `reason` is the error's `_tag`, else `"internal"`. */
86
+ export const failed = (cause: Cause.Cause<unknown>): RunEvent => {
87
+ const error = Cause.squash(cause)
88
+ const reason =
89
+ Predicate.hasProperty(error, "_tag") && Predicate.isString(error._tag) ? error._tag : "internal"
90
+ return { type: "run-failed", reason }
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // sequence
95
+ // ---------------------------------------------------------------------------
96
+
97
+ export const SequenceState = Schema.Struct({
98
+ seq: Schema.Int,
99
+ /** Id of the run currently open on this stream. */
100
+ runId: Schema.optionalKey(RunId),
101
+ })
102
+ export type SequenceState = typeof SequenceState.Type
103
+
104
+ const sequenceStep =
105
+ (streamId: StreamId) =>
106
+ (s: SequenceState, event: RunEvent): readonly [SequenceState, ReadonlyArray<Envelope>] => {
107
+ const runId = event.type === "run-started" ? event.runId : s.runId
108
+ const seq = s.seq + 1
109
+ const envelope: Envelope =
110
+ runId === undefined ? { streamId, seq, event } : { streamId, seq, runId, event }
111
+ const next: SequenceState = runId === undefined || isTerminal(event) ? { seq } : { seq, runId }
112
+ return [next, [envelope]]
113
+ }
114
+
115
+ /**
116
+ * Wrap events in envelopes with a per-stream `seq` (starting after `from`,
117
+ * default 0) and the current run's id, taken from the last `run-started`.
118
+ */
119
+ export const sequence =
120
+ (streamId: StreamId, options?: { readonly from?: number }) =>
121
+ <E, R>(self: Stream.Stream<RunEvent, E, R>): Stream.Stream<Envelope, E, R> =>
122
+ self.pipe(
123
+ Stream.mapAccum((): SequenceState => ({ seq: options?.from ?? 0 }), sequenceStep(streamId)),
124
+ )
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // coalesce
128
+ // ---------------------------------------------------------------------------
129
+
130
+ const merge = (a: RunEvent, b: RunEvent): RunEvent | undefined => {
131
+ if (a.type === "text-delta" && b.type === "text-delta") return { ...a, text: a.text + b.text }
132
+ if (a.type === "refusal-delta" && b.type === "refusal-delta")
133
+ return { ...a, text: a.text + b.text }
134
+ if (a.type === "reasoning-delta" && b.type === "reasoning-delta" && a.kind === b.kind) {
135
+ return { ...a, text: a.text + b.text }
136
+ }
137
+ if (
138
+ a.type === "tool-call-args-delta" &&
139
+ b.type === "tool-call-args-delta" &&
140
+ a.callId === b.callId
141
+ ) {
142
+ return { ...a, delta: a.delta + b.delta }
143
+ }
144
+ return undefined
145
+ }
146
+
147
+ const mergeAdjacent = (group: ReadonlyArray<RunEvent>): ReadonlyArray<RunEvent> =>
148
+ Arr.reduce(group, Arr.empty<RunEvent>(), (acc: ReadonlyArray<RunEvent>, event) => {
149
+ const last = acc.at(-1)
150
+ const merged = last === undefined ? undefined : merge(last, event)
151
+ return merged === undefined ? [...acc, event] : [...acc.slice(0, -1), merged]
152
+ })
153
+
154
+ /**
155
+ * Merge consecutive text, reasoning and tool-argument deltas that arrive
156
+ * within `window`; every other event passes through unchanged and in order.
157
+ * Bounds frames on the wire and rows in a durable log; adds up to `window`
158
+ * latency.
159
+ */
160
+ export const coalesce =
161
+ (window: Duration.Input) =>
162
+ <E, R>(self: Stream.Stream<RunEvent, E, R>): Stream.Stream<RunEvent, E, R> =>
163
+ self.pipe(
164
+ Stream.groupedWithin(1024, window),
165
+ Stream.flatMap((group) => Stream.fromIterable(mergeAdjacent(group))),
166
+ )
@@ -0,0 +1,84 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import { Effect, Exit, Schema } from "effect"
3
+ import { expect } from "vitest"
4
+ import * as RunEvent from "./RunEvent.js"
5
+
6
+ const Wire = Schema.fromJsonString(RunEvent.EnvelopeJson)
7
+ const encode = Schema.encodeEffect(Wire)
8
+ const decode = Schema.decodeUnknownEffect(Wire)
9
+
10
+ const r1 = RunEvent.RunId.make("r1")
11
+
12
+ const envelope = (seq: number, event: RunEvent.RunEvent): RunEvent.Envelope => ({
13
+ streamId: RunEvent.StreamId.make("st_1"),
14
+ seq,
15
+ runId: r1,
16
+ event,
17
+ })
18
+
19
+ describe("RunEvent wire codec", () => {
20
+ it.effect.each([
21
+ {
22
+ label: "run-started",
23
+ frame: envelope(1, {
24
+ type: "run-started",
25
+ runId: r1,
26
+ inputs: [RunEvent.InputId.make("in_1")],
27
+ protocol: 2,
28
+ }),
29
+ },
30
+ {
31
+ label: "tool-call-ready",
32
+ frame: envelope(2, {
33
+ type: "tool-call-ready",
34
+ callId: "c1",
35
+ name: "get_weather",
36
+ input: { city: "Lisbon" },
37
+ }),
38
+ },
39
+ {
40
+ label: "image bytes",
41
+ frame: envelope(3, {
42
+ type: "image",
43
+ image: { _tag: "bytes", bytes: new Uint8Array([137, 80, 78, 71]), mimeType: "image/png" },
44
+ }),
45
+ },
46
+ {
47
+ label: "object-delta patch",
48
+ frame: envelope(4, {
49
+ type: "object-delta",
50
+ id: "o1",
51
+ patch: [{ op: "add", path: "/items/-", value: "eggs" }],
52
+ }),
53
+ },
54
+ {
55
+ label: "run-completed",
56
+ frame: envelope(5, { type: "run-completed", metadata: { usage: { input_tokens: 10 } } }),
57
+ },
58
+ ])("round-trips $label through JSON", ({ frame }) =>
59
+ encode(frame).pipe(
60
+ Effect.flatMap(decode),
61
+ Effect.map((decoded) => expect(decoded).toEqual(frame)),
62
+ ),
63
+ )
64
+
65
+ it.effect.each([
66
+ { label: "an unknown event type", frame: { streamId: "s", seq: 1, event: { type: "nope" } } },
67
+ {
68
+ label: "another protocol version",
69
+ frame: {
70
+ streamId: "s",
71
+ seq: 1,
72
+ event: { type: "run-started", runId: "r1", inputs: [], protocol: 1 },
73
+ },
74
+ },
75
+ {
76
+ label: "a seq below 1",
77
+ frame: { streamId: "s", seq: 0, event: { type: "text-delta", text: "hi" } },
78
+ },
79
+ ])("rejects $label", ({ frame }) =>
80
+ Effect.exit(decode(JSON.stringify(frame))).pipe(
81
+ Effect.map((exit) => expect(Exit.isFailure(exit)).toBe(true)),
82
+ ),
83
+ )
84
+ })
@@ -0,0 +1,374 @@
1
+ /**
2
+ * The `@effect-uai/ui` wire protocol (v1): what a server streams to a client
3
+ * about agent runs. Every frame is an `Envelope`; `seq` is per stream,
4
+ * starts at 1 and never repeats, so clients dedupe and resume by it.
5
+ *
6
+ * A run starts with `run-started` and ends with exactly one terminal
7
+ * (`run-completed`, `run-interrupted`, `run-failed`, `run-stopped`). Field
8
+ * names are camelCase; the event discriminator is `type`.
9
+ */
10
+ import * as Img from "@effect-uai/core/Image"
11
+ import * as Items from "@effect-uai/core/Items"
12
+ import { Schema } from "effect"
13
+
14
+ export const PROTOCOL_VERSION = 2
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Ids: branded so a stream, run and input id can't be swapped. Wire decoding
18
+ // brands them; build one by hand with `StreamId.make("st_1")`.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export const StreamId = Schema.String.pipe(Schema.brand("StreamId"))
22
+ export type StreamId = typeof StreamId.Type
23
+
24
+ export const RunId = Schema.String.pipe(Schema.brand("RunId"))
25
+ export type RunId = typeof RunId.Type
26
+
27
+ /** Client-generated id of one input. */
28
+ export const InputId = Schema.String.pipe(Schema.brand("InputId"))
29
+ export type InputId = typeof InputId.Type
30
+
31
+ const event = <const T extends string, const F extends Schema.Struct.Fields>(type: T, fields: F) =>
32
+ Schema.Struct({ type: Schema.Literal(type), ...fields })
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Run lifecycle
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export const RunStarted = event("run-started", {
39
+ runId: RunId,
40
+ /** Role of the run when a stream carries several, e.g. "assistant", "reviewer". */
41
+ name: Schema.optional(Schema.String),
42
+ /** Client input ids this run consumed, for optimistic-UI reconciliation. */
43
+ inputs: Schema.Array(InputId),
44
+ protocol: Schema.Literal(PROTOCOL_VERSION),
45
+ })
46
+
47
+ /** A pause point the client must answer (today: a tool call awaiting approval). */
48
+ export const Interrupt = Schema.Struct({
49
+ callId: Schema.String,
50
+ reason: Schema.Literal("approval"),
51
+ name: Schema.String,
52
+ input: Schema.Json,
53
+ })
54
+
55
+ export const RunCompleted = event("run-completed", { metadata: Schema.optional(Schema.Json) })
56
+ export const RunInterrupted = event("run-interrupted", { interrupts: Schema.Array(Interrupt) })
57
+ /** `reason` is a short code (an error's `_tag`); whether to offer a retry is the client's call. */
58
+ export const RunFailed = event("run-failed", {
59
+ reason: Schema.String,
60
+ message: Schema.optional(Schema.String),
61
+ })
62
+ export const RunStopped = event("run-stopped", {})
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Steps (one per model turn) and content
66
+ // ---------------------------------------------------------------------------
67
+
68
+ export const StepStarted = event("step-started", {})
69
+ export const StepFinished = event("step-finished", { usage: Schema.optional(Items.Usage) })
70
+ /** Discard the current step's partial output (mid-stream fallback or retry). */
71
+ export const StepReset = event("step-reset", {})
72
+
73
+ export const TextDelta = event("text-delta", { text: Schema.String })
74
+ /** The model declined to answer; distinct from text (core's `refusal` block). */
75
+ export const RefusalDelta = event("refusal-delta", { text: Schema.String })
76
+ export const ReasoningStarted = event("reasoning-started", {})
77
+ export const ReasoningDelta = event("reasoning-delta", {
78
+ text: Schema.String,
79
+ kind: Schema.Literals(["trace", "summary"]),
80
+ })
81
+ export const Citation = event("citation", { annotation: Items.Annotation })
82
+ export const Image = event("image", {
83
+ image: Img.ImageSource,
84
+ /** Set only on a preview frame; absent means final. */
85
+ partialIndex: Schema.optional(Schema.Number),
86
+ })
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Structured objects
90
+ // ---------------------------------------------------------------------------
91
+
92
+ const JsonPatchOperation = Schema.Union([
93
+ Schema.Struct({ op: Schema.Literal("add"), path: Schema.String, value: Schema.Json }),
94
+ Schema.Struct({ op: Schema.Literal("remove"), path: Schema.String }),
95
+ Schema.Struct({ op: Schema.Literal("replace"), path: Schema.String, value: Schema.Json }),
96
+ ])
97
+
98
+ export const ObjectStarted = event("object-started", {
99
+ id: Schema.String,
100
+ /** Which schema or list the object belongs to. */
101
+ name: Schema.optional(Schema.String),
102
+ })
103
+ /** Either a JSON Patch against the previous partial value, or a snapshot `value`. */
104
+ export const ObjectDelta = event("object-delta", {
105
+ id: Schema.String,
106
+ patch: Schema.optional(Schema.Array(JsonPatchOperation)),
107
+ value: Schema.optional(Schema.Json),
108
+ })
109
+ export const ObjectDone = event("object-done", {
110
+ id: Schema.String,
111
+ /** The object's name again, so a client can decode `value` without state. */
112
+ name: Schema.optional(Schema.String),
113
+ value: Schema.Json,
114
+ })
115
+ export const ObjectFailed = event("object-failed", {
116
+ id: Schema.String,
117
+ issues: Schema.Array(
118
+ Schema.Struct({
119
+ path: Schema.Array(Schema.Union([Schema.String, Schema.Number])),
120
+ message: Schema.String,
121
+ }),
122
+ ),
123
+ })
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Tools
127
+ // ---------------------------------------------------------------------------
128
+
129
+ export const ToolCallStarted = event("tool-call-started", {
130
+ callId: Schema.String,
131
+ name: Schema.String,
132
+ })
133
+ export const ToolCallArgsDelta = event("tool-call-args-delta", {
134
+ callId: Schema.String,
135
+ delta: Schema.String,
136
+ })
137
+ export const ToolCallReady = event("tool-call-ready", {
138
+ callId: Schema.String,
139
+ name: Schema.String,
140
+ input: Schema.Json,
141
+ })
142
+ /** The run is waiting in-process for a decision on this call (it keeps running). */
143
+ export const ToolApprovalRequested = event("tool-approval-requested", {
144
+ callId: Schema.String,
145
+ name: Schema.String,
146
+ input: Schema.Json,
147
+ })
148
+ /** An intermediate value of a streaming tool, encoded like its output. */
149
+ export const ToolPartial = event("tool-partial", {
150
+ callId: Schema.String,
151
+ name: Schema.String,
152
+ value: Schema.Json,
153
+ })
154
+
155
+ /** How a call ended: its output, or an error (the tool's, core's, or the app's). */
156
+ export const outcome = <V extends Schema.Top, E extends Schema.Top>(value: V, error: E) =>
157
+ Schema.Union([
158
+ Schema.TaggedStruct("Success", { value }),
159
+ Schema.TaggedStruct("Failure", { error }),
160
+ ])
161
+
162
+ /** An outcome as sent: value and error are JSON. */
163
+ export const Outcome = outcome(Schema.Json, Schema.Json)
164
+
165
+ export type Outcome<V = Json, E = Json> =
166
+ | { readonly _tag: "Success"; readonly value: V }
167
+ | { readonly _tag: "Failure"; readonly error: E }
168
+
169
+ /**
170
+ * Every call ends with one `tool-result`: calls that ran, and calls the app
171
+ * closed itself (denied, cancelled, answered by the user).
172
+ */
173
+ export const ToolResult = event("tool-result", {
174
+ callId: Schema.String,
175
+ /** The tool's name, so a client can decode `result` without state. */
176
+ name: Schema.String,
177
+ result: Outcome,
178
+ })
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Usage and app payloads
182
+ // ---------------------------------------------------------------------------
183
+
184
+ export const Usage = event("usage", { usage: Items.Usage })
185
+ /** Persisted app payload; a repeated `id` replaces the earlier one in place. */
186
+ export const Data = event("data", {
187
+ name: Schema.String,
188
+ value: Schema.Json,
189
+ id: Schema.optional(Schema.String),
190
+ })
191
+ /** Transient app payload (metrics, status); latest value wins, never stored in a message. */
192
+ export const Live = event("live", { name: Schema.String, value: Schema.Json })
193
+
194
+ /**
195
+ * Stream-scoped: the server accepted an input (queued or about to run). Lets a
196
+ * reloaded client show inputs that wait for their run. `input` is the app's
197
+ * encoded input (e.g. `Input.InputJson`); its run's `run-started` lists its id.
198
+ */
199
+ export const InputAccepted = event("input-accepted", { input: Schema.Json })
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Unions and envelope
203
+ // ---------------------------------------------------------------------------
204
+
205
+ /** Events whose payloads a contract types (see `UiContract`). */
206
+ export const payloadEvents = [
207
+ RunCompleted,
208
+ RunInterrupted,
209
+ ObjectDone,
210
+ ToolCallReady,
211
+ ToolApprovalRequested,
212
+ ToolPartial,
213
+ ToolResult,
214
+ Data,
215
+ Live,
216
+ ] as const
217
+
218
+ /** Events a contract leaves as they are. */
219
+ export const plainEvents = [
220
+ RunStarted,
221
+ RunFailed,
222
+ RunStopped,
223
+ StepStarted,
224
+ StepFinished,
225
+ StepReset,
226
+ TextDelta,
227
+ RefusalDelta,
228
+ ReasoningStarted,
229
+ ReasoningDelta,
230
+ Citation,
231
+ Image,
232
+ ObjectStarted,
233
+ ObjectDelta,
234
+ ObjectFailed,
235
+ ToolCallStarted,
236
+ ToolCallArgsDelta,
237
+ Usage,
238
+ InputAccepted,
239
+ ] as const
240
+
241
+ /** The protocol as sent: payloads are JSON. A contract decodes them (`UiContract`). */
242
+ export const RunEvent = Schema.Union([...plainEvents, ...payloadEvents])
243
+
244
+ export const envelopeFields = {
245
+ streamId: StreamId,
246
+ seq: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
247
+ /** Set on run-scoped events; absent on stream-scoped ones. */
248
+ runId: Schema.optional(RunId),
249
+ }
250
+
251
+ export const Envelope = Schema.Struct({ ...envelopeFields, event: RunEvent })
252
+
253
+ /** JSON codec for the wire: `Uint8Array` image bytes and other non-JSON values encode safely. */
254
+ export const EnvelopeJson = Schema.toCodecJson(Envelope)
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Payload types: what a contract types. `P` defaults to `Untyped` (JSON as sent).
258
+ // ---------------------------------------------------------------------------
259
+
260
+ export type Json = Schema.Json
261
+
262
+ export type ToolPayload = {
263
+ readonly name: string
264
+ readonly input: unknown
265
+ readonly output: unknown
266
+ /** Every error a call of this tool can end with. */
267
+ readonly failure: unknown
268
+ }
269
+ export type NamedPayload = { readonly name: string; readonly value: unknown }
270
+
271
+ /** Typed payloads: each field is a union with one member per name. */
272
+ export type Payloads = {
273
+ readonly tool: ToolPayload
274
+ readonly object: NamedPayload
275
+ readonly data: NamedPayload
276
+ readonly live: NamedPayload
277
+ readonly metadata: unknown
278
+ }
279
+
280
+ /** No contract: any name, payloads as sent. */
281
+ export type Untyped = {
282
+ readonly tool: {
283
+ readonly name: string
284
+ readonly input: Json
285
+ readonly output: Json
286
+ readonly failure: Json
287
+ }
288
+ readonly object: { readonly name: string; readonly value: Json }
289
+ readonly data: { readonly name: string; readonly value: Json }
290
+ readonly live: { readonly name: string; readonly value: Json }
291
+ readonly metadata: Json
292
+ }
293
+
294
+ type Plain = (typeof plainEvents)[number]["Type"]
295
+
296
+ // Distributes over the tool union, so narrowing by `name` types `value` and `result`.
297
+ type ToolEvents<T> = T extends ToolPayload
298
+ ?
299
+ | {
300
+ readonly type: "tool-partial"
301
+ readonly callId: string
302
+ readonly name: T["name"]
303
+ readonly value: T["output"]
304
+ }
305
+ | {
306
+ readonly type: "tool-result"
307
+ readonly callId: string
308
+ readonly name: T["name"]
309
+ readonly result: Outcome<T["output"], T["failure"]>
310
+ }
311
+ : never
312
+
313
+ export type Interrupt<P extends Payloads = Untyped> = {
314
+ readonly callId: string
315
+ readonly reason: "approval"
316
+ readonly name: P["tool"]["name"]
317
+ readonly input: P["tool"]["input"]
318
+ }
319
+
320
+ type Typed<P extends Payloads> =
321
+ | { readonly type: "run-completed"; readonly metadata?: P["metadata"] | undefined }
322
+ | { readonly type: "run-interrupted"; readonly interrupts: ReadonlyArray<Interrupt<P>> }
323
+ | {
324
+ readonly type: "object-done"
325
+ readonly id: string
326
+ readonly name?: P["object"]["name"] | undefined
327
+ readonly value: P["object"]["value"]
328
+ }
329
+ | {
330
+ readonly type: "tool-call-ready"
331
+ readonly callId: string
332
+ readonly name: P["tool"]["name"]
333
+ readonly input: P["tool"]["input"]
334
+ }
335
+ | {
336
+ readonly type: "tool-approval-requested"
337
+ readonly callId: string
338
+ readonly name: P["tool"]["name"]
339
+ readonly input: P["tool"]["input"]
340
+ }
341
+ | ToolEvents<P["tool"]>
342
+ | {
343
+ readonly type: "data"
344
+ readonly name: P["data"]["name"]
345
+ readonly value: P["data"]["value"]
346
+ readonly id?: string | undefined
347
+ }
348
+ | { readonly type: "live"; readonly name: P["live"]["name"]; readonly value: P["live"]["value"] }
349
+
350
+ /** One event; with a contract's `P`, its payloads are decoded. */
351
+ export type RunEvent<P extends Payloads = Untyped> = Plain | Typed<P>
352
+
353
+ export type Envelope<P extends Payloads = Untyped> = {
354
+ readonly streamId: StreamId
355
+ readonly seq: number
356
+ readonly runId?: RunId | undefined
357
+ readonly event: RunEvent<P>
358
+ }
359
+
360
+ export type Terminal<P extends Payloads = Untyped> = Extract<
361
+ RunEvent<P>,
362
+ { readonly type: "run-completed" | "run-interrupted" | "run-failed" | "run-stopped" }
363
+ >
364
+
365
+ const terminalTypes: ReadonlySet<string> = new Set([
366
+ "run-completed",
367
+ "run-interrupted",
368
+ "run-failed",
369
+ "run-stopped",
370
+ ])
371
+
372
+ export const isTerminal = <P extends Payloads = Untyped>(
373
+ event: RunEvent<P>,
374
+ ): event is Terminal<P> => terminalTypes.has(event.type)