@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,98 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import { Schema } from "effect"
3
+ import { expect } from "vitest"
4
+ import { type Envelope, RunId, type RunEvent, StreamId } from "./RunEvent.js"
5
+ import * as RunView from "./RunView.js"
6
+
7
+ const s = StreamId.make("s")
8
+ const r1 = RunId.make("r1")
9
+
10
+ const fold = (events: ReadonlyArray<RunEvent>): RunView.RunView =>
11
+ events
12
+ .map((event, i): Envelope => ({ streamId: s, seq: i + 1, runId: r1, event }))
13
+ .reduce(RunView.step, RunView.initial)
14
+
15
+ const started: RunEvent = {
16
+ type: "run-started",
17
+ runId: r1,
18
+ inputs: [],
19
+ protocol: 2,
20
+ }
21
+
22
+ describe("RunView.step", () => {
23
+ it("step-reset drops the failed attempt's partial output but keeps earlier steps", () => {
24
+ const view = fold([
25
+ started,
26
+ { type: "step-started" },
27
+ { type: "text-delta", text: "First step." },
28
+ { type: "step-finished" },
29
+ { type: "step-started" },
30
+ { type: "text-delta", text: "Partial from the failing mod" },
31
+ { type: "step-reset" },
32
+ { type: "text-delta", text: " Retried." },
33
+ ])
34
+ expect(RunView.text(view)).toBe("First step. Retried.")
35
+ })
36
+
37
+ it("builds an object from patches, survives a bad patch, and settles on done", () => {
38
+ const view = fold([
39
+ started,
40
+ { type: "object-started", id: "o1", name: "recipe" },
41
+ { type: "object-delta", id: "o1", patch: [{ op: "add", path: "/title", value: "Nata" }] },
42
+ { type: "object-delta", id: "o1", patch: [{ op: "add", path: "/items", value: ["eggs"] }] },
43
+ { type: "object-delta", id: "o1", patch: [{ op: "replace", path: "/missing/x", value: 1 }] },
44
+ { type: "object-delta", id: "o1", patch: [{ op: "add", path: "/items/-", value: "sugar" }] },
45
+ ])
46
+ expect(view.blocks).toContainEqual({
47
+ type: "object",
48
+ id: "o1",
49
+ name: "recipe",
50
+ status: "streaming",
51
+ partial: { title: "Nata", items: ["eggs", "sugar"] },
52
+ })
53
+ const done = RunView.step(view, {
54
+ streamId: s,
55
+ seq: 100,
56
+ runId: r1,
57
+ event: {
58
+ type: "object-done",
59
+ id: "o1",
60
+ name: "recipe",
61
+ value: { title: "Pastéis de nata", items: ["eggs"] },
62
+ },
63
+ })
64
+ expect(done.blocks).toContainEqual({
65
+ type: "object",
66
+ id: "o1",
67
+ name: "recipe",
68
+ status: "done",
69
+ partial: { title: "Nata", items: ["eggs", "sugar"] },
70
+ value: { title: "Pastéis de nata", items: ["eggs"] },
71
+ })
72
+ })
73
+
74
+ it("attaches citations to the text they cite, as core output_text annotations", () => {
75
+ const source = { type: "url_citation", url: "https://ipma.pt", title: "IPMA" } as const
76
+ const view = fold([
77
+ started,
78
+ { type: "step-started" },
79
+ { type: "text-delta", text: "Lisbon is " },
80
+ { type: "text-delta", text: "sunny." },
81
+ { type: "citation", annotation: source },
82
+ ])
83
+ expect(view.blocks).toEqual([
84
+ { type: "output_text", id: "b0", text: "Lisbon is sunny.", annotations: [source] },
85
+ ])
86
+ })
87
+
88
+ it("keeps a refusal apart from the answer text", () => {
89
+ const view = fold([
90
+ started,
91
+ { type: "step-started" },
92
+ { type: "refusal-delta", text: "I can't " },
93
+ { type: "refusal-delta", text: "help with that." },
94
+ ])
95
+ expect(RunView.text(view)).toBe("")
96
+ expect(RunView.refusal(view)).toBe("I can't help with that.")
97
+ })
98
+ })
package/src/RunView.ts ADDED
@@ -0,0 +1,583 @@
1
+ /**
2
+ * The client's view of one server run: plain data folded from envelopes.
3
+ * `initial` + `step` plug into `Stream.scan` (live states) or
4
+ * `Stream.runFold` (final state). `step` is pure, total and idempotent on
5
+ * `seq`, so replaying a stream from any point is safe. A delta replaces only
6
+ * the block it touches; every other block keeps its identity.
7
+ *
8
+ * `P` types the payloads (see `UiContract`): with a contract, a tool block
9
+ * narrows by `name` to its decoded input and output. Without one (`Untyped`),
10
+ * payloads are the JSON the server sent.
11
+ */
12
+ import * as Items from "@effect-uai/core/Items"
13
+ import { Array as Arr, JsonPatch, Match, Option, Schema } from "effect"
14
+ import {
15
+ type Envelope,
16
+ InputId,
17
+ Interrupt as InterruptSchema,
18
+ type Interrupt,
19
+ type Json,
20
+ type NamedPayload,
21
+ outcome,
22
+ type Outcome,
23
+ type Payloads,
24
+ RunId,
25
+ type RunEvent,
26
+ type ToolPayload,
27
+ type Untyped,
28
+ } from "./RunEvent.js"
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Data
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export const ToolStatus = Schema.Literals([
35
+ "input-streaming",
36
+ "ready",
37
+ "awaiting-approval",
38
+ "running",
39
+ "done",
40
+ "failed",
41
+ ])
42
+ export type ToolStatus = typeof ToolStatus.Type
43
+
44
+ export const ObjectStatus = Schema.Literals(["streaming", "done", "failed"])
45
+ export type ObjectStatus = typeof ObjectStatus.Type
46
+
47
+ // Settled content reuses core's shapes (plus a block `id`), so a view's text,
48
+ // citations, images and refusals read like the turn's own content blocks.
49
+
50
+ /** Core's `output_text`; citations attach to the text they cite. */
51
+ const TextBlock = Schema.Struct({ ...Items.OutputText.fields, id: Schema.String })
52
+ /** Core's `refusal`: the model declined to answer. */
53
+ const RefusalBlock = Schema.Struct({ ...Items.Refusal.fields, id: Schema.String })
54
+ /** Core's `output_image`; `partialIndex` marks a preview the final image replaces. */
55
+ const ImageBlock = Schema.Struct({
56
+ ...Items.OutputImage.fields,
57
+ id: Schema.String,
58
+ partialIndex: Schema.optionalKey(Schema.Number),
59
+ })
60
+ const ReasoningBlock = Schema.Struct({
61
+ type: Schema.Literal("reasoning"),
62
+ id: Schema.String,
63
+ text: Schema.String,
64
+ })
65
+
66
+ export type TextBlock = typeof TextBlock.Type
67
+ export type RefusalBlock = typeof RefusalBlock.Type
68
+ export type ImageBlock = typeof ImageBlock.Type
69
+ export type ReasoningBlock = typeof ReasoningBlock.Type
70
+
71
+ /**
72
+ * A tool call; `id` is the call id. `partial` is the latest intermediate value
73
+ * of a streaming tool; `outcome` is how the call ended (`done` or `failed`).
74
+ */
75
+ export type ToolBlock<P extends Payloads = Untyped> = P["tool"] extends infer T
76
+ ? T extends ToolPayload
77
+ ? {
78
+ readonly type: "tool"
79
+ readonly id: string
80
+ readonly name: T["name"]
81
+ readonly status: ToolStatus
82
+ readonly input?: T["input"]
83
+ readonly argsText?: string
84
+ readonly partial?: T["output"]
85
+ readonly outcome?: Outcome<T["output"], T["failure"]>
86
+ }
87
+ : never
88
+ : never
89
+
90
+ type UnnamedObject = {
91
+ readonly type: "object"
92
+ readonly id: string
93
+ readonly name?: undefined
94
+ readonly status: ObjectStatus
95
+ readonly partial?: Json
96
+ readonly value?: Json
97
+ readonly issues?: ReadonlyArray<Json>
98
+ }
99
+
100
+ /** A structured object: `partial` while it streams, the typed `value` once done. */
101
+ export type ObjectBlock<P extends Payloads = Untyped> =
102
+ | UnnamedObject
103
+ | (P["object"] extends infer T
104
+ ? T extends NamedPayload
105
+ ? {
106
+ readonly type: "object"
107
+ readonly id: string
108
+ readonly name: T["name"]
109
+ readonly status: ObjectStatus
110
+ readonly partial?: Json
111
+ readonly value?: T["value"]
112
+ readonly issues?: ReadonlyArray<Json>
113
+ }
114
+ : never
115
+ : never)
116
+
117
+ /** A persisted app payload; a repeated id replaces it in place. */
118
+ export type DataBlock<P extends Payloads = Untyped> = P["data"] extends infer T
119
+ ? T extends NamedPayload
120
+ ? {
121
+ readonly type: "data"
122
+ readonly id: string
123
+ readonly name: T["name"]
124
+ readonly value: T["value"]
125
+ }
126
+ : never
127
+ : never
128
+
129
+ export type Block<P extends Payloads = Untyped> =
130
+ | TextBlock
131
+ | RefusalBlock
132
+ | ImageBlock
133
+ | ReasoningBlock
134
+ | ToolBlock<P>
135
+ | ObjectBlock<P>
136
+ | DataBlock<P>
137
+
138
+ /** Transient payloads by name; the latest value wins. A contract's names are optional keys. */
139
+ export type Live<P extends Payloads = Untyped> = string extends P["live"]["name"]
140
+ ? { readonly [name: string]: P["live"]["value"] }
141
+ : { readonly [T in P["live"] as T["name"]]?: T["value"] }
142
+
143
+ export const Status = Schema.Literals([
144
+ "idle",
145
+ "submitted",
146
+ "streaming",
147
+ "completed",
148
+ "interrupted",
149
+ "failed",
150
+ "stopped",
151
+ ])
152
+ export type Status = typeof Status.Type
153
+
154
+ const RunError = Schema.Struct({
155
+ reason: Schema.String,
156
+ message: Schema.optionalKey(Schema.String),
157
+ })
158
+
159
+ export type RunView<P extends Payloads = Untyped> = {
160
+ readonly runId?: RunId
161
+ readonly name?: string
162
+ readonly inputs: ReadonlyArray<InputId>
163
+ readonly status: Status
164
+ readonly blocks: ReadonlyArray<Block<P>>
165
+ readonly interrupts: ReadonlyArray<Interrupt<P>>
166
+ readonly live: Live<P>
167
+ readonly usage?: Items.Usage
168
+ readonly metadata?: P["metadata"]
169
+ readonly error?: typeof RunError.Type
170
+ /** Highest applied `seq`; envelopes at or below it are ignored. */
171
+ readonly lastSeq: number
172
+ /** Index of the current step's first block (for `step-reset`). */
173
+ readonly stepStart: number
174
+ readonly nextBlock: number
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Schemas: built from payload codecs, so `UiContract` builds the typed ones
179
+ // ---------------------------------------------------------------------------
180
+
181
+ /** Block schemas for one tool, object or data name; `UiContract` makes one per name. */
182
+ export const toolBlock = <
183
+ N extends Schema.Top,
184
+ I extends Schema.Top,
185
+ O extends Schema.Top,
186
+ F extends Schema.Top,
187
+ >(
188
+ name: N,
189
+ input: I,
190
+ output: O,
191
+ failure: F,
192
+ ) =>
193
+ Schema.Struct({
194
+ type: Schema.Literal("tool"),
195
+ id: Schema.String,
196
+ name,
197
+ status: ToolStatus,
198
+ input: Schema.optionalKey(input),
199
+ argsText: Schema.optionalKey(Schema.String),
200
+ partial: Schema.optionalKey(output),
201
+ outcome: Schema.optionalKey(outcome(output, failure)),
202
+ })
203
+
204
+ export const objectBlock = <N extends Schema.Top, V extends Schema.Top>(name: N, value: V) =>
205
+ Schema.Struct({
206
+ type: Schema.Literal("object"),
207
+ id: Schema.String,
208
+ name,
209
+ status: ObjectStatus,
210
+ partial: Schema.optionalKey(Schema.Json),
211
+ value: Schema.optionalKey(value),
212
+ issues: Schema.optionalKey(Schema.Array(Schema.Json)),
213
+ })
214
+
215
+ export const dataBlock = <N extends Schema.Top, V extends Schema.Top>(name: N, value: V) =>
216
+ Schema.Struct({ type: Schema.Literal("data"), id: Schema.String, name, value })
217
+
218
+ /** Blocks without payloads: the same in every view. */
219
+ export const contentBlocks = [TextBlock, RefusalBlock, ImageBlock, ReasoningBlock] as const
220
+
221
+ /** A view's schema from its payload-dependent parts; `block` is the whole block union. */
222
+ export const schema = <
223
+ B extends Schema.Top,
224
+ I extends Schema.Top,
225
+ L extends Schema.Top,
226
+ M extends Schema.Top,
227
+ >(parts: {
228
+ readonly block: B
229
+ readonly interrupt: I
230
+ readonly live: L
231
+ readonly metadata: M
232
+ }) =>
233
+ Schema.Struct({
234
+ runId: Schema.optionalKey(RunId),
235
+ name: Schema.optionalKey(Schema.String),
236
+ inputs: Schema.Array(InputId),
237
+ status: Status,
238
+ blocks: Schema.Array(parts.block),
239
+ interrupts: Schema.Array(parts.interrupt),
240
+ live: parts.live,
241
+ usage: Schema.optionalKey(Items.Usage),
242
+ metadata: Schema.optionalKey(parts.metadata),
243
+ error: Schema.optionalKey(RunError),
244
+ lastSeq: Schema.Number,
245
+ stepStart: Schema.Number,
246
+ nextBlock: Schema.Number,
247
+ })
248
+
249
+ /** An object without a name: no contract entry, so its value stays JSON. */
250
+ export const unnamedObject = Schema.Struct({
251
+ type: Schema.Literal("object"),
252
+ id: Schema.String,
253
+ status: ObjectStatus,
254
+ partial: Schema.optionalKey(Schema.Json),
255
+ value: Schema.optionalKey(Schema.Json),
256
+ issues: Schema.optionalKey(Schema.Array(Schema.Json)),
257
+ })
258
+
259
+ const untypedObject = Schema.Union([objectBlock(Schema.String, Schema.Json), unnamedObject])
260
+
261
+ /** The untyped view's schema: payloads are JSON. */
262
+ export const RunView = schema({
263
+ block: Schema.Union([
264
+ ...contentBlocks,
265
+ toolBlock(Schema.String, Schema.Json, Schema.Json, Schema.Json),
266
+ untypedObject,
267
+ dataBlock(Schema.String, Schema.Json),
268
+ ]),
269
+ interrupt: InterruptSchema,
270
+ live: Schema.Record(Schema.String, Schema.Json),
271
+ metadata: Schema.Json,
272
+ })
273
+
274
+ /** An empty view (a contract's `initial` is the typed one). */
275
+ export const initial: RunView = {
276
+ inputs: [],
277
+ status: "idle",
278
+ blocks: [],
279
+ interrupts: [],
280
+ live: {},
281
+ lastSeq: 0,
282
+ stepStart: 0,
283
+ nextBlock: 0,
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Fold: written once over `Payloads` (names are strings, payloads `unknown`);
288
+ // the exported signatures carry the caller's `P`.
289
+ // ---------------------------------------------------------------------------
290
+
291
+ type View = RunView<Payloads>
292
+ type AnyBlock = Block<Payloads>
293
+ type AnyTool = ToolBlock<Payloads>
294
+ type AnyObject = ObjectBlock<Payloads>
295
+
296
+ const replaceAt = (blocks: ReadonlyArray<AnyBlock>, index: number, block: AnyBlock) =>
297
+ blocks.map((b, i) => (i === index ? block : b))
298
+
299
+ const push = (v: View, make: (id: string) => AnyBlock): View => ({
300
+ ...v,
301
+ blocks: [...v.blocks, make(`b${v.nextBlock}`)],
302
+ nextBlock: v.nextBlock + 1,
303
+ })
304
+
305
+ const appendText = (v: View, kind: "output_text" | "refusal" | "reasoning", text: string): View => {
306
+ const index = v.blocks.length - 1
307
+ const last = v.blocks[index]
308
+ if (last !== undefined && last.type === kind && index >= v.stepStart) {
309
+ return { ...v, blocks: replaceAt(v.blocks, index, { ...last, text: last.text + text }) }
310
+ }
311
+ return push(v, (id) => ({ type: kind, id, text }))
312
+ }
313
+
314
+ // A citation joins the step's latest text; one that arrives before any text
315
+ // starts an empty text block, which the following text extends.
316
+ const cite = (v: View, annotation: Items.Annotation): View =>
317
+ Arr.findLastIndex(v.blocks, (b, i) => b.type === "output_text" && i >= v.stepStart).pipe(
318
+ Option.flatMap((index) =>
319
+ Option.fromNullishOr(v.blocks[index]).pipe(
320
+ Option.filter((b): b is TextBlock => b.type === "output_text"),
321
+ Option.map((b) => ({ index, block: b })),
322
+ ),
323
+ ),
324
+ Option.match({
325
+ onNone: () =>
326
+ push(v, (id) => ({ type: "output_text", id, text: "", annotations: [annotation] })),
327
+ onSome: ({ index, block }) => ({
328
+ ...v,
329
+ blocks: replaceAt(v.blocks, index, {
330
+ ...block,
331
+ annotations: [...(block.annotations ?? []), annotation],
332
+ }),
333
+ }),
334
+ }),
335
+ )
336
+
337
+ const updateTool = (
338
+ v: View,
339
+ callId: string,
340
+ name: string,
341
+ f: (block: AnyTool) => AnyTool,
342
+ ): View => {
343
+ const index = v.blocks.findIndex((b) => b.type === "tool" && b.id === callId)
344
+ const current = v.blocks[index]
345
+ if (current !== undefined && current.type === "tool") {
346
+ return { ...v, blocks: replaceAt(v.blocks, index, f(current)) }
347
+ }
348
+ const created: AnyTool = {
349
+ type: "tool",
350
+ id: callId,
351
+ name,
352
+ status: "input-streaming",
353
+ }
354
+ return { ...v, blocks: [...v.blocks, f(created)] }
355
+ }
356
+
357
+ const updateObject = (v: View, id: string, f: (block: AnyObject) => AnyObject): View => {
358
+ const index = v.blocks.findIndex((b) => b.type === "object" && b.id === id)
359
+ const current = v.blocks[index]
360
+ if (current !== undefined && current.type === "object") {
361
+ return { ...v, blocks: replaceAt(v.blocks, index, f(current)) }
362
+ }
363
+ return { ...v, blocks: [...v.blocks, f({ type: "object", id, status: "streaming" })] }
364
+ }
365
+
366
+ const applyPatch = Option.liftThrowable(JsonPatch.apply)
367
+ // An unnamed object has no contract entry, so its value stays JSON.
368
+ const isJson = Schema.is(Schema.Json)
369
+
370
+ // A bad patch leaves the partial value as is; the next snapshot or `object-done` repairs it.
371
+ const objectDelta = (
372
+ block: AnyObject,
373
+ e: Extract<RunEvent, { type: "object-delta" }>,
374
+ ): AnyObject => {
375
+ if (e.value !== undefined) return { ...block, partial: e.value }
376
+ const patch = e.patch
377
+ if (patch === undefined) return block
378
+ return applyPatch(patch, block.partial ?? {}).pipe(
379
+ Option.match({ onNone: () => block, onSome: (partial) => ({ ...block, partial }) }),
380
+ )
381
+ }
382
+
383
+ const sum = (a: number | undefined, b: number | undefined): number | undefined =>
384
+ a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0)
385
+
386
+ const addUsage = (
387
+ total: Items.Usage | undefined,
388
+ step: Items.Usage | undefined,
389
+ ): Items.Usage | undefined => {
390
+ if (step === undefined) return total
391
+ if (total === undefined) return step
392
+ const input = sum(total.input_tokens, step.input_tokens)
393
+ const output = sum(total.output_tokens, step.output_tokens)
394
+ const all = sum(total.total_tokens, step.total_tokens)
395
+ return {
396
+ ...(input !== undefined ? { input_tokens: input } : {}),
397
+ ...(output !== undefined ? { output_tokens: output } : {}),
398
+ ...(all !== undefined ? { total_tokens: all } : {}),
399
+ }
400
+ }
401
+
402
+ const withUsage = (v: View, usage: Items.Usage | undefined): View => {
403
+ const total = addUsage(v.usage, usage)
404
+ return total === undefined ? v : { ...v, usage: total }
405
+ }
406
+
407
+ const apply = (v: View, event: RunEvent<Payloads>): View =>
408
+ Match.value(event).pipe(
409
+ Match.discriminatorsExhaustive("type")({
410
+ "run-started": (e): View => ({
411
+ ...v,
412
+ status: "streaming",
413
+ runId: e.runId,
414
+ inputs: e.inputs,
415
+ ...(e.name !== undefined ? { name: e.name } : {}),
416
+ }),
417
+ "run-completed": (e): View => ({
418
+ ...v,
419
+ status: "completed",
420
+ ...(e.metadata !== undefined ? { metadata: e.metadata } : {}),
421
+ }),
422
+ "run-interrupted": (e): View =>
423
+ e.interrupts.reduce<View>(
424
+ (acc, i) =>
425
+ updateTool(acc, i.callId, i.name, (b) => ({
426
+ ...b,
427
+ status: "awaiting-approval",
428
+ input: i.input,
429
+ })),
430
+ { ...v, status: "interrupted", interrupts: e.interrupts },
431
+ ),
432
+ "run-failed": (e): View => ({
433
+ ...v,
434
+ status: "failed",
435
+ error: {
436
+ reason: e.reason,
437
+ ...(e.message !== undefined ? { message: e.message } : {}),
438
+ },
439
+ }),
440
+ "run-stopped": (): View => ({ ...v, status: "stopped" }),
441
+ "step-started": (): View => ({ ...v, stepStart: v.blocks.length }),
442
+ "step-finished": (e): View => withUsage(v, e.usage),
443
+ "step-reset": (): View => ({ ...v, blocks: v.blocks.slice(0, v.stepStart) }),
444
+ "text-delta": (e) => appendText(v, "output_text", e.text),
445
+ "refusal-delta": (e) => appendText(v, "refusal", e.text),
446
+ "reasoning-started": (): View =>
447
+ v.blocks.some((b, i) => b.type === "reasoning" && i >= v.stepStart)
448
+ ? v
449
+ : push(v, (id) => ({ type: "reasoning", id, text: "" })),
450
+ "reasoning-delta": (e) => appendText(v, "reasoning", e.text),
451
+ citation: (e) => cite(v, e.annotation),
452
+ image: (e): View => {
453
+ const index = v.blocks.length - 1
454
+ const last = v.blocks[index]
455
+ const block = (id: string): AnyBlock =>
456
+ e.partialIndex === undefined
457
+ ? { type: "output_image", id, source: e.image }
458
+ : { type: "output_image", id, source: e.image, partialIndex: e.partialIndex }
459
+ // A preview frame is replaced by the next preview or the final image.
460
+ return last !== undefined && last.type === "output_image" && last.partialIndex !== undefined
461
+ ? { ...v, blocks: replaceAt(v.blocks, index, block(last.id)) }
462
+ : push(v, block)
463
+ },
464
+ "object-started": (e): View =>
465
+ updateObject(v, e.id, (b) => (e.name === undefined ? b : { ...b, name: e.name })),
466
+ "object-delta": (e) => updateObject(v, e.id, (b) => objectDelta(b, e)),
467
+ "object-done": (e) =>
468
+ updateObject(v, e.id, (b) =>
469
+ b.name === undefined
470
+ ? { ...b, status: "done", ...(isJson(e.value) ? { value: e.value } : {}) }
471
+ : { ...b, status: "done", value: e.value },
472
+ ),
473
+ "object-failed": (e) =>
474
+ updateObject(v, e.id, (b) => ({ ...b, status: "failed", issues: e.issues })),
475
+ "tool-call-started": (e) => updateTool(v, e.callId, e.name, (b) => b),
476
+ "tool-call-args-delta": (e) =>
477
+ updateTool(v, e.callId, "", (b) => ({ ...b, argsText: (b.argsText ?? "") + e.delta })),
478
+ "tool-call-ready": (e) =>
479
+ updateTool(v, e.callId, e.name, (b) => ({
480
+ ...b,
481
+ name: e.name,
482
+ status: "ready",
483
+ input: e.input,
484
+ })),
485
+ "tool-approval-requested": (e) =>
486
+ updateTool(v, e.callId, e.name, (b) => ({
487
+ ...b,
488
+ status: "awaiting-approval",
489
+ input: e.input,
490
+ })),
491
+ "tool-partial": (e) =>
492
+ updateTool(v, e.callId, e.name, (b) => ({ ...b, status: "running", partial: e.value })),
493
+ "tool-result": (e) =>
494
+ updateTool(v, e.callId, e.name, (b) => ({
495
+ ...b,
496
+ status: e.result._tag === "Success" ? "done" : "failed",
497
+ outcome: e.result,
498
+ })),
499
+ usage: (e): View => ({ ...v, usage: e.usage }),
500
+ data: (e): View => {
501
+ const index =
502
+ e.id === undefined ? -1 : v.blocks.findIndex((b) => b.type === "data" && b.id === e.id)
503
+ const existing = v.blocks[index]
504
+ return existing !== undefined
505
+ ? {
506
+ ...v,
507
+ blocks: replaceAt(v.blocks, index, {
508
+ type: "data",
509
+ id: existing.id,
510
+ name: e.name,
511
+ value: e.value,
512
+ }),
513
+ }
514
+ : push(v, (id) => ({ type: "data", id: e.id ?? id, name: e.name, value: e.value }))
515
+ },
516
+ live: (e): View => ({ ...v, live: { ...v.live, [e.name]: e.value } }),
517
+ // Stream-scoped; a chat fold (`Thread`) shows it, a run view has nothing to add.
518
+ "input-accepted": (): View => v,
519
+ }),
520
+ )
521
+
522
+ /**
523
+ * Fold one envelope into the view. Ignores envelopes already applied
524
+ * (`seq <= lastSeq`) and run-scoped envelopes of a different run.
525
+ */
526
+ export function step<P extends Payloads = Untyped>(
527
+ view: RunView<P>,
528
+ envelope: Envelope<P>,
529
+ ): RunView<P>
530
+ export function step(view: View, envelope: Envelope<Payloads>): View {
531
+ if (envelope.seq <= view.lastSeq) return view
532
+ const otherRun =
533
+ view.runId !== undefined && envelope.runId !== undefined && envelope.runId !== view.runId
534
+ return { ...(otherRun ? view : apply(view, envelope.event)), lastSeq: envelope.seq }
535
+ }
536
+
537
+ /** A tool event that can answer a call made in an earlier run (after an approval). */
538
+ export type ToolUpdate<P extends Payloads = Untyped> = Extract<
539
+ RunEvent<P>,
540
+ { readonly type: "tool-result" | "tool-partial" }
541
+ >
542
+
543
+ export const isToolUpdate = <P extends Payloads>(event: RunEvent<P>): event is ToolUpdate<P> =>
544
+ event.type === "tool-result" || event.type === "tool-partial"
545
+
546
+ /**
547
+ * Apply a tool event to the view if it holds that call; `None` if it doesn't.
548
+ * For a run that already ended (an interrupted run whose call was approved
549
+ * later), so `seq` and the run id are not checked.
550
+ */
551
+ export function applyToolUpdate<P extends Payloads>(
552
+ view: RunView<P>,
553
+ event: ToolUpdate<P>,
554
+ ): Option.Option<RunView<P>>
555
+ export function applyToolUpdate(view: View, event: ToolUpdate<Payloads>): Option.Option<View> {
556
+ const holds = view.blocks.some((b) => b.type === "tool" && b.id === event.callId)
557
+ return holds ? Option.some(apply(view, event)) : Option.none()
558
+ }
559
+
560
+ // ---------------------------------------------------------------------------
561
+ // Selectors
562
+ // ---------------------------------------------------------------------------
563
+
564
+ const TERMINAL: ReadonlySet<Status> = new Set(["completed", "interrupted", "failed", "stopped"])
565
+
566
+ export const isTerminal = <P extends Payloads>(view: RunView<P>): boolean =>
567
+ TERMINAL.has(view.status)
568
+
569
+ export const isBusy = <P extends Payloads>(view: RunView<P>): boolean =>
570
+ view.status === "submitted" || view.status === "streaming"
571
+
572
+ /** All assistant text of the run, in order (refusals excluded, see `refusal`). */
573
+ export function text<P extends Payloads>(view: RunView<P>): string
574
+ export function text(view: View): string {
575
+ return view.blocks.flatMap((b) => (b.type === "output_text" ? [b.text] : [])).join("")
576
+ }
577
+
578
+ /** The refusal text, if the model declined; `undefined` otherwise. */
579
+ export function refusal<P extends Payloads>(view: RunView<P>): string | undefined
580
+ export function refusal(view: View): string | undefined {
581
+ const parts = view.blocks.flatMap((b) => (b.type === "refusal" ? [b.text] : []))
582
+ return parts.length === 0 ? undefined : parts.join("")
583
+ }