@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.
- package/LICENSE +21 -0
- package/README.md +64 -2
- package/dist/Atoms.d.mts +141 -0
- package/dist/Atoms.d.mts.map +1 -0
- package/dist/Atoms.mjs +132 -0
- package/dist/Atoms.mjs.map +1 -0
- package/dist/Client-C9UYN_VK.d.mts +219 -0
- package/dist/Client-C9UYN_VK.d.mts.map +1 -0
- package/dist/Client.d.mts +2 -0
- package/dist/Client.mjs +55 -0
- package/dist/Client.mjs.map +1 -0
- package/dist/Input-Cz-Kxusm.d.mts +277 -0
- package/dist/Input-Cz-Kxusm.d.mts.map +1 -0
- package/dist/Input.d.mts +2 -0
- package/dist/Input.mjs +127 -0
- package/dist/Input.mjs.map +1 -0
- package/dist/Projection.d.mts +44 -0
- package/dist/Projection.d.mts.map +1 -0
- package/dist/Projection.mjs +133 -0
- package/dist/Projection.mjs.map +1 -0
- package/dist/Run.d.mts +44 -0
- package/dist/Run.d.mts.map +1 -0
- package/dist/Run.mjs +117 -0
- package/dist/Run.mjs.map +1 -0
- package/dist/RunEvent.d.mts +1225 -0
- package/dist/RunEvent.d.mts.map +1 -0
- package/dist/RunEvent.mjs +264 -0
- package/dist/RunEvent.mjs.map +1 -0
- package/dist/RunView-nHfJwN8t.d.mts +380 -0
- package/dist/RunView-nHfJwN8t.d.mts.map +1 -0
- package/dist/RunView.d.mts +2 -0
- package/dist/RunView.mjs +452 -0
- package/dist/RunView.mjs.map +1 -0
- package/dist/SseEncoding.d.mts +437 -0
- package/dist/SseEncoding.d.mts.map +1 -0
- package/dist/SseEncoding.mjs +64 -0
- package/dist/SseEncoding.mjs.map +1 -0
- package/dist/Thread-CAXsjhpX.d.mts +474 -0
- package/dist/Thread-CAXsjhpX.d.mts.map +1 -0
- package/dist/Thread.d.mts +2 -0
- package/dist/Thread.mjs +225 -0
- package/dist/Thread.mjs.map +1 -0
- package/dist/Transport.d.mts +96 -0
- package/dist/Transport.d.mts.map +1 -0
- package/dist/Transport.mjs +82 -0
- package/dist/Transport.mjs.map +1 -0
- package/dist/UiContract.d.mts +113 -0
- package/dist/UiContract.d.mts.map +1 -0
- package/dist/UiContract.mjs +229 -0
- package/dist/UiContract.mjs.map +1 -0
- package/dist/index.d.mts +12 -0
- package/dist/index.mjs +12 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/package.json +99 -3
- package/src/Atoms.test.ts +163 -0
- package/src/Atoms.ts +355 -0
- package/src/Client.test.ts +53 -0
- package/src/Client.ts +92 -0
- package/src/Input.test.ts +68 -0
- package/src/Input.ts +165 -0
- package/src/Projection.test.ts +198 -0
- package/src/Projection.ts +142 -0
- package/src/Run.test.ts +119 -0
- package/src/Run.ts +166 -0
- package/src/RunEvent.test.ts +84 -0
- package/src/RunEvent.ts +374 -0
- package/src/RunView.test.ts +98 -0
- package/src/RunView.ts +583 -0
- package/src/SseEncoding.test.ts +46 -0
- package/src/SseEncoding.ts +77 -0
- package/src/Thread.test.ts +304 -0
- package/src/Thread.ts +286 -0
- package/src/Transport.ts +182 -0
- package/src/UiContract.test.ts +342 -0
- package/src/UiContract.ts +402 -0
- package/src/index.ts +11 -0
|
@@ -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
|
+
}
|