@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/Input.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * What a client sends to start or continue a run. The library only needs an
3
+ * `id` on an input (generated on the client, so optimistic UI can match it
4
+ * to `run-started { inputs }`); this module is a preset, and an app with a
5
+ * different shape passes its own schema.
6
+ *
7
+ * A `submit` carries user `messages` (core message items: the server appends
8
+ * them to its history as they are) and, with `make(DataSchema)`, typed `data` the server
9
+ * interprets (template id, model choice). Decode request bodies with the
10
+ * `*Json` codecs: they carry image bytes over JSON.
11
+ *
12
+ * Constructors build drafts without randomness; the id is assigned where an
13
+ * input is sent (`Atoms.submit`, or `withId`).
14
+ */
15
+ import * as ToolDecision from "@effect-uai/core/ToolDecision"
16
+ import * as Items from "@effect-uai/core/Items"
17
+ import { Effect, Schema } from "effect"
18
+ import { SecureRandom } from "effect/crypto"
19
+ import { InputId } from "./RunEvent.js"
20
+
21
+ /** A user message: core's `message` item with role `user` and input blocks only. */
22
+ export const UserMessage = Schema.Struct({
23
+ type: Schema.Literal("message"),
24
+ role: Schema.Literal("user"),
25
+ content: Schema.Array(Schema.Union([Items.InputText, Items.InputImage])),
26
+ })
27
+ export type UserMessage = typeof UserMessage.Type
28
+ export type Content = UserMessage["content"][number]
29
+
30
+ /** The answer to one waiting call, with a decision of the app's `ToolDecision.make`. */
31
+ export const answer = <D extends Schema.Top>(decision: D) =>
32
+ Schema.Struct({ callId: Schema.String, decision })
33
+
34
+ /** The default answer: `ToolDecision.Default` (approve, or deny with an optional reason). */
35
+ export const Decision = answer(ToolDecision.Default.schema)
36
+ export type Decision = typeof Decision.Type
37
+
38
+ const submitFields = {
39
+ id: InputId,
40
+ kind: Schema.Literal("submit"),
41
+ messages: Schema.Array(UserMessage),
42
+ }
43
+
44
+ const resolveFields = <D extends Schema.Top>(decision: D) => ({
45
+ id: InputId,
46
+ kind: Schema.Literal("resolve"),
47
+ decisions: Schema.Array(answer(decision)),
48
+ })
49
+
50
+ export const Submit = Schema.Struct(submitFields)
51
+ export type Submit = typeof Submit.Type
52
+
53
+ export const Resolve = Schema.Struct(resolveFields(ToolDecision.Default.schema))
54
+ export type Resolve = typeof Resolve.Type
55
+
56
+ export const Input = Schema.Union([Submit, Resolve])
57
+ export type Input = typeof Input.Type
58
+
59
+ export const SubmitJson = Schema.toCodecJson(Submit)
60
+ export const InputJson = Schema.toCodecJson(Input)
61
+
62
+ /**
63
+ * The preset with typed app `data` (required on `submit`, optional on
64
+ * `resolve`) and, optionally, the app's decisions (`ToolDecision.make(...).schema`).
65
+ */
66
+ export function make<D extends Schema.Top>(data: D): Preset<D, typeof ToolDecision.Default.schema>
67
+ export function make<D extends Schema.Top, A extends Schema.Top>(
68
+ data: D,
69
+ decisions: A,
70
+ ): Preset<D, A>
71
+ export function make(data: Schema.Top, decisions: Schema.Top = ToolDecision.Default.schema) {
72
+ return preset(data, decisions)
73
+ }
74
+
75
+ type Preset<D extends Schema.Top, A extends Schema.Top> = ReturnType<typeof preset<D, A>>
76
+
77
+ const preset = <D extends Schema.Top, A extends Schema.Top>(data: D, decisions: A) => {
78
+ const Submit = Schema.Struct({ ...submitFields, data })
79
+ const Resolve = Schema.Struct({ ...resolveFields(decisions), data: Schema.optionalKey(data) })
80
+ const Input = Schema.Union([Submit, Resolve])
81
+ return {
82
+ Submit,
83
+ Resolve,
84
+ Input,
85
+ SubmitJson: Schema.toCodecJson(Submit),
86
+ InputJson: Schema.toCodecJson(Input),
87
+ }
88
+ }
89
+
90
+ /** An input whose `id` is not assigned yet (per union member). */
91
+ export type Draft<I> = I extends unknown ? Omit<I, "id"> & { readonly id?: InputId } : never
92
+
93
+ /** A draft with its `id` assigned. */
94
+ export type Identified<D> = D & { readonly id: InputId }
95
+
96
+ /** What a transport receives for inputs of type `I`: the draft `submit` took, with its id. */
97
+ export type Sent<I = Input> = Identified<Draft<I>>
98
+
99
+ /** Assign a UUIDv7 id unless the draft already has one. */
100
+ export const withId = <D extends { readonly id?: InputId }>(
101
+ draft: D,
102
+ ): Effect.Effect<Identified<D>, never, SecureRandom.SecureRandom> => {
103
+ const { id } = draft
104
+ return id !== undefined
105
+ ? Effect.succeed({ ...draft, id })
106
+ : SecureRandom.SecureRandom.use((random) => random.uuidV7()).pipe(
107
+ Effect.map((uuid) => ({ ...draft, id: InputId.make(uuid) })),
108
+ )
109
+ }
110
+
111
+ /** A user message: `text` first, then any attachments (images). */
112
+ export const user = (text: string, attachments: ReadonlyArray<Content> = []): UserMessage => ({
113
+ type: "message",
114
+ role: "user",
115
+ content: [{ type: "input_text", text }, ...attachments],
116
+ })
117
+
118
+ type Id = { readonly id?: InputId }
119
+
120
+ const toMessages = (messages: string | ReadonlyArray<UserMessage>): ReadonlyArray<UserMessage> =>
121
+ typeof messages === "string" ? [user(messages)] : messages
122
+
123
+ export function submit(messages: string | ReadonlyArray<UserMessage>, options?: Id): Draft<Submit>
124
+ export function submit<D>(
125
+ messages: string | ReadonlyArray<UserMessage>,
126
+ options: Id & { readonly data: D },
127
+ ): Draft<Submit> & { readonly data: D }
128
+ export function submit(
129
+ messages: string | ReadonlyArray<UserMessage>,
130
+ options?: Id & { readonly data?: unknown },
131
+ ): Draft<Submit> & { readonly data?: unknown } {
132
+ return {
133
+ ...options,
134
+ kind: "submit",
135
+ messages: toMessages(messages),
136
+ }
137
+ }
138
+
139
+ /** A resolve draft; the decision type defaults to `ToolDecision.Default`'s. */
140
+ export type ResolveDraft<A = Decision["decision"]> = {
141
+ readonly id?: InputId
142
+ readonly kind: "resolve"
143
+ readonly decisions: ReadonlyArray<ToolDecision.Answer<A>>
144
+ }
145
+
146
+ export function resolve<A = Decision["decision"]>(
147
+ decisions: ReadonlyArray<ToolDecision.Answer<A>>,
148
+ options?: Id,
149
+ ): ResolveDraft<A>
150
+ export function resolve<A, D>(
151
+ decisions: ReadonlyArray<ToolDecision.Answer<A>>,
152
+ options: Id & { readonly data: D },
153
+ ): ResolveDraft<A> & { readonly data: D }
154
+ export function resolve(
155
+ decisions: ReadonlyArray<ToolDecision.Answer<unknown>>,
156
+ options?: Id & { readonly data?: unknown },
157
+ ): ResolveDraft<unknown> & { readonly data?: unknown } {
158
+ return { ...options, kind: "resolve", decisions }
159
+ }
160
+
161
+ /** All text of the submit's messages, joined by blank lines. */
162
+ export const text = (input: Pick<Submit, "messages">): string =>
163
+ input.messages
164
+ .flatMap((m) => m.content.flatMap((block) => (block.type === "input_text" ? [block.text] : [])))
165
+ .join("\n\n")
@@ -0,0 +1,198 @@
1
+ import type * as Items from "@effect-uai/core/Items"
2
+ import { loop, onTurnComplete, stop } from "@effect-uai/core/Loop"
3
+ import * as MockProvider from "@effect-uai/core/testing/MockProvider"
4
+ import * as Tool from "@effect-uai/core/Tool"
5
+ import * as Toolkit from "@effect-uai/core/Toolkit"
6
+ import * as ToolOutput from "@effect-uai/core/ToolOutput"
7
+ import * as Turn from "@effect-uai/core/Turn"
8
+ import { describe, expect, it } from "@effect/vitest"
9
+ import { Effect, pipe, Result, Schema, Stream } from "effect"
10
+ import * as Projection from "./Projection.js"
11
+ import type { RunEvent } from "./RunEvent.js"
12
+ import * as UiContract from "./UiContract.js"
13
+
14
+ class CityNotFound extends Schema.TaggedError<CityNotFound>()("CityNotFound", {
15
+ city: Schema.String,
16
+ }) {}
17
+
18
+ const getWeather = Tool.make({
19
+ name: "get_weather",
20
+ description: "Current weather for a city.",
21
+ inputSchema: Schema.Struct({ city: Schema.String }),
22
+ outputSchema: Schema.Struct({ city: Schema.String, observedAt: Schema.DateFromString }),
23
+ failureSchema: CityNotFound,
24
+ run: ({ city }) =>
25
+ city === "Atlantis"
26
+ ? Effect.fail(new CityNotFound({ city }))
27
+ : Effect.succeed({ city, observedAt: new Date("2026-01-01T12:00:00.000Z") }),
28
+ })
29
+ const search = Tool.make({
30
+ name: "search",
31
+ description: "Search.",
32
+ inputSchema: Tool.noInput,
33
+ outputSchema: Schema.Struct({ hits: Schema.Number }),
34
+ run: () => Stream.make({ hits: 1 }, { hits: 3 }),
35
+ })
36
+ const toolkit = Toolkit.make(getWeather, search)
37
+ const contract = UiContract.make({ tools: [getWeather, search] })
38
+
39
+ const usage = { input_tokens: 5, output_tokens: 5 }
40
+ const callTurn = (calls: ReadonlyArray<readonly [string, string, string]>): Turn.Turn => ({
41
+ stop_reason: "tool_calls",
42
+ usage,
43
+ items: calls.map(([call_id, name, args]) => ({
44
+ type: "function_call",
45
+ call_id,
46
+ name,
47
+ arguments: args,
48
+ })),
49
+ })
50
+ const answerTurn: Turn.Turn = {
51
+ stop_reason: "stop",
52
+ usage,
53
+ items: [
54
+ { type: "reasoning", summary: "Weather tool says 19.4." },
55
+ { type: "message", role: "assistant", content: [{ type: "output_text", text: "Done." }] },
56
+ ],
57
+ }
58
+
59
+ type State = { readonly history: ReadonlyArray<Items.HistoryItem> }
60
+
61
+ const runLoop = (turns: ReadonlyArray<Turn.Turn>) => {
62
+ const { service } = MockProvider.make(turns)
63
+ const initialState: State = { history: [] }
64
+ return pipe(
65
+ initialState,
66
+ loop((state: State) =>
67
+ service.streamTurn({ history: state.history, model: "mock", tools: toolkit }).pipe(
68
+ onTurnComplete((turn) =>
69
+ Effect.sync(() => {
70
+ const calls = Turn.getToolCalls(turn)
71
+ return calls.length === 0
72
+ ? stop()
73
+ : Toolkit.run(toolkit, calls, { concurrency: 1 }).pipe(
74
+ Toolkit.next(toolkit, state, turn),
75
+ )
76
+ }),
77
+ ),
78
+ ),
79
+ ),
80
+ Projection.toRunEvents({ contract }),
81
+ Stream.runCollect,
82
+ )
83
+ }
84
+
85
+ describe("Projection.toRunEvents", () => {
86
+ it.effect("projects a tool loop: steps per turn, tool lifecycle, encoded outcomes", () =>
87
+ Effect.gen(function* () {
88
+ const events = yield* runLoop([
89
+ callTurn([
90
+ ["c1", "get_weather", `{"city":"Lisbon"}`],
91
+ ["c2", "get_weather", `{"city":"Atlantis"}`],
92
+ ]),
93
+ answerTurn,
94
+ ])
95
+ expect(events.filter((e) => e.type === "tool-result")).toEqual<ReadonlyArray<RunEvent>>([
96
+ {
97
+ type: "tool-result",
98
+ callId: "c1",
99
+ name: "get_weather",
100
+ result: {
101
+ _tag: "Success",
102
+ value: { city: "Lisbon", observedAt: "2026-01-01T12:00:00.000Z" },
103
+ },
104
+ },
105
+ {
106
+ type: "tool-result",
107
+ callId: "c2",
108
+ name: "get_weather",
109
+ result: { _tag: "Failure", error: { _tag: "CityNotFound", city: "Atlantis" } },
110
+ },
111
+ ])
112
+ expect(events.map((e) => e.type)).toEqual([
113
+ "step-started",
114
+ "tool-call-started",
115
+ "tool-call-started",
116
+ "tool-call-ready",
117
+ "tool-call-ready",
118
+ "step-finished",
119
+ "tool-result",
120
+ "tool-result",
121
+ "step-started",
122
+ "reasoning-started",
123
+ "text-delta",
124
+ "step-finished",
125
+ ])
126
+ }),
127
+ )
128
+
129
+ it.effect("projects a streaming tool's values as partials before its result", () =>
130
+ Effect.gen(function* () {
131
+ const events = yield* runLoop([callTurn([["s1", "search", "{}"]]), answerTurn])
132
+ expect(events.filter((e) => e.type === "tool-partial" || e.type === "tool-result")).toEqual<
133
+ ReadonlyArray<RunEvent>
134
+ >([
135
+ { type: "tool-partial", callId: "s1", name: "search", value: { hits: 1 } },
136
+ { type: "tool-partial", callId: "s1", name: "search", value: { hits: 3 } },
137
+ {
138
+ type: "tool-result",
139
+ callId: "s1",
140
+ name: "search",
141
+ result: { _tag: "Success", value: { hits: 3 } },
142
+ },
143
+ ])
144
+ }),
145
+ )
146
+
147
+ it.effect("projects a history item an app closed itself from its parts", () =>
148
+ Effect.gen(function* () {
149
+ const cancelled = yield* ToolOutput.failure(
150
+ { call_id: "c1" },
151
+ ToolOutput.CoreError,
152
+ new ToolOutput.Cancelled({ message: "user moved on" }),
153
+ )
154
+ const events = yield* Stream.fromIterable<Projection.Source>([
155
+ Turn.TurnEvent.TurnComplete({ turn: callTurn([["c1", "get_weather", "{}"]]) }),
156
+ cancelled,
157
+ ]).pipe(Projection.toRunEvents({ contract }), Stream.runCollect)
158
+ expect(events.at(-1)).toEqual<RunEvent>({
159
+ type: "tool-result",
160
+ callId: "c1",
161
+ name: "get_weather",
162
+ result: { _tag: "Failure", error: { _tag: "Cancelled", message: "user moved on" } },
163
+ })
164
+ }),
165
+ )
166
+
167
+ it.effect("fails the stream when a value doesn't fit its contract schema", () =>
168
+ Effect.gen(function* () {
169
+ const output: Projection.Source = {
170
+ _tag: "Output",
171
+ call_id: "c1",
172
+ tool: "search",
173
+ result: Result.succeed({ hits: "many" }),
174
+ }
175
+ const error = yield* Effect.flip(
176
+ Stream.make(output).pipe(Projection.toRunEvents({ contract }), Stream.runCollect),
177
+ )
178
+ expect(error._tag).toBe("SchemaError")
179
+ }),
180
+ )
181
+
182
+ it.effect("projects a refusal as refusal-delta, not as text", () =>
183
+ Stream.make(
184
+ Turn.TurnEvent.RefusalDelta({ text: "I can't help with that." }),
185
+ Turn.TurnEvent.TurnComplete({ turn: { stop_reason: "refusal", usage, items: [] } }),
186
+ ).pipe(
187
+ Projection.toRunEvents({ contract }),
188
+ Stream.runCollect,
189
+ Effect.map((events) =>
190
+ expect(events).toEqual<ReadonlyArray<RunEvent>>([
191
+ { type: "step-started" },
192
+ { type: "refusal-delta", text: "I can't help with that." },
193
+ { type: "step-finished", usage },
194
+ ]),
195
+ ),
196
+ ),
197
+ )
198
+ })
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Project an effect-uai loop onto `RunEvent`s. The projection is a fold pair
3
+ * (`initial`, `step`) because step boundaries need state; `toRunEvents` runs
4
+ * it over a stream. To add app events (metrics, domain data), map them
5
+ * yourself and delegate the rest to `step`.
6
+ *
7
+ * Never forwards `TurnComplete.turn` itself: it carries `providerData`
8
+ * (encrypted reasoning, item ids) that must not reach a browser.
9
+ */
10
+ import type * as Items from "@effect-uai/core/Items"
11
+ import type * as ToolEvent from "@effect-uai/core/ToolEvent"
12
+ import * as Turn from "@effect-uai/core/Turn"
13
+ import { Effect, Match, Option, Schema, Stream } from "effect"
14
+ import type { Json, RunEvent } from "./RunEvent.js"
15
+ import * as UiContract from "./UiContract.js"
16
+
17
+ /** What an effect-uai loop emits: turn events, tool events, and plain tool outputs. */
18
+ export type Source = Turn.TurnEvent | ToolEvent.ToolEvent | Items.ToolCallOutput
19
+
20
+ export const Options = Schema.Struct({
21
+ /**
22
+ * `"signal"` (default): only `reasoning-started`. `"summary"`: also
23
+ * forward summary deltas. `"full"`: also forward raw reasoning traces.
24
+ */
25
+ reasoning: Schema.optionalKey(Schema.Literals(["signal", "summary", "full"])),
26
+ /** Forward raw tool-argument deltas (`tool-call-args-delta`). Default false. */
27
+ toolArgs: Schema.optionalKey(Schema.Boolean),
28
+ })
29
+ export type Options = typeof Options.Type
30
+
31
+ /** `Options` plus the contract whose tool schemas encode tool values and errors. */
32
+ export type Config = Options & {
33
+ readonly contract: Pick<UiContract.UiContract<any>, "toolEvent" | "toolOutput">
34
+ }
35
+
36
+ export const State = Schema.Struct({
37
+ inStep: Schema.Boolean,
38
+ reasoningShown: Schema.Boolean,
39
+ /** Tool names by call id, for outputs that arrive as plain `function_call_output` items. */
40
+ tools: Schema.Record(Schema.String, Schema.String),
41
+ })
42
+ export type State = typeof State.Type
43
+
44
+ export const initial: State = { inStep: false, reasoningShown: false, tools: {} }
45
+
46
+ type Step = readonly [State, ReadonlyArray<RunEvent>]
47
+
48
+ // Tool arguments are a JSON string; hand the client the decoded value, falling
49
+ // back to the raw string when it isn't JSON.
50
+ const parseJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Json))
51
+ const decodeJson = (raw: string): Json => Option.getOrElse(parseJson(raw), () => raw)
52
+
53
+ // Content belongs to a step (one model turn); open one lazily on its first event.
54
+ const inStep = (s: State, events: ReadonlyArray<RunEvent>): Step =>
55
+ s.inStep
56
+ ? [s, events]
57
+ : [{ ...s, inStep: true, reasoningShown: false }, [{ type: "step-started" }, ...events]]
58
+
59
+ const reasoning =
60
+ (options: Options | undefined) =>
61
+ (s: State, e: Extract<Turn.TurnEvent, { _tag: "ReasoningDelta" }>): Step => {
62
+ const mode = options?.reasoning ?? "signal"
63
+ const forward = mode === "full" || (mode === "summary" && e.kind === "summary")
64
+ const [opened, prefix] = inStep(s, [])
65
+ const started: ReadonlyArray<RunEvent> = opened.reasoningShown
66
+ ? []
67
+ : [{ type: "reasoning-started" }]
68
+ const delta: ReadonlyArray<RunEvent> = forward
69
+ ? [{ type: "reasoning-delta", text: e.text, kind: e.kind }]
70
+ : []
71
+ return [{ ...opened, reasoningShown: true }, [...prefix, ...started, ...delta]]
72
+ }
73
+
74
+ const turnComplete = (s: State, turn: Turn.Turn): Step => {
75
+ const calls = Turn.getToolCalls(turn)
76
+ const ready = calls.map((call): RunEvent => ({
77
+ type: "tool-call-ready",
78
+ callId: call.call_id,
79
+ name: call.name,
80
+ input: decodeJson(call.arguments),
81
+ }))
82
+ const [, events] = inStep(s, [...ready, { type: "step-finished", usage: turn.usage }])
83
+ const tools = { ...s.tools, ...Object.fromEntries(calls.map((c) => [c.call_id, c.name])) }
84
+ return [{ ...initial, tools }, events]
85
+ }
86
+
87
+ // A history item already holds the encoded value; the contract shapes it for the wire.
88
+ const outputResult = (config: Config, s: State, o: Items.ToolCallOutput) =>
89
+ Effect.map(config.contract.toolOutput(o, s.tools[o.call_id] ?? ""), (event): Step => [s, [event]])
90
+
91
+ /** Map one loop event onto zero or more `RunEvent`s; tool values are encoded by the contract. */
92
+ export const step =
93
+ (config: Config) =>
94
+ (s: State, event: Source): Effect.Effect<Step, Schema.SchemaError> =>
95
+ Match.value(event).pipe(
96
+ Match.when({ type: "function_call_output" }, (o) => outputResult(config, s, o)),
97
+ Match.tags({
98
+ Partial: (e) => Effect.map(config.contract.toolEvent(e), (r): Step => [s, [r]]),
99
+ Output: (e) => Effect.map(config.contract.toolEvent(e), (r): Step => [s, [r]]),
100
+ }),
101
+ Match.orElse((e) => Effect.succeed(turnStep(config)(s, e))),
102
+ )
103
+
104
+ const turnStep =
105
+ (options: Options) =>
106
+ (s: State, event: Turn.TurnEvent): Step =>
107
+ Match.value(event).pipe(
108
+ Match.tagsExhaustive({
109
+ TextDelta: (e) => inStep(s, [{ type: "text-delta", text: e.text }]),
110
+ RefusalDelta: (e) => inStep(s, [{ type: "refusal-delta", text: e.text }]),
111
+ ReasoningDelta: (e) => reasoning(options)(s, e),
112
+ ToolCallStart: (e) =>
113
+ inStep(s, [{ type: "tool-call-started", callId: e.call_id, name: e.name }]),
114
+ ToolCallArgsDelta: (e) =>
115
+ inStep(
116
+ s,
117
+ options?.toolArgs === true
118
+ ? [{ type: "tool-call-args-delta", callId: e.call_id, delta: e.delta }]
119
+ : [],
120
+ ),
121
+ CitationAdded: (e) => inStep(s, [{ type: "citation", annotation: e.annotation }]),
122
+ ImageOutput: (e) =>
123
+ inStep(s, [
124
+ e.partialIndex === undefined
125
+ ? { type: "image", image: e.image }
126
+ : { type: "image", image: e.image, partialIndex: e.partialIndex },
127
+ ]),
128
+ // Usage arrives on `step-finished`; search progress has no UI event yet.
129
+ UsageUpdate: (): Step => [s, []],
130
+ WebSearchCall: (): Step => [s, []],
131
+ TurnComplete: (e) => turnComplete(s, e.turn),
132
+ }),
133
+ )
134
+
135
+ /**
136
+ * Stream operator: project a loop's events onto `RunEvent`s. A tool value the
137
+ * contract can't encode fails the stream: a contract violation, not data to show.
138
+ */
139
+ export const toRunEvents =
140
+ (config: Config) =>
141
+ <E, R>(self: Stream.Stream<Source, E, R>): Stream.Stream<RunEvent, E | Schema.SchemaError, R> =>
142
+ self.pipe(Stream.mapAccumEffect(() => initial, step(config)))
@@ -0,0 +1,119 @@
1
+ import * as AiError from "@effect-uai/core/AiError"
2
+ import { describe, it } from "@effect/vitest"
3
+ import { Deferred, Effect, Fiber, Stream } from "effect"
4
+ import { expect } from "vitest"
5
+ import * as Run from "./Run.js"
6
+ import { InputId, RunId, type RunEvent, StreamId } from "./RunEvent.js"
7
+
8
+ const r1 = RunId.make("r1")
9
+ const text = (t: string): RunEvent => ({ type: "text-delta", text: t })
10
+ const types = (events: ReadonlyArray<RunEvent>) => events.map((e) => e.type)
11
+
12
+ describe("Run frame", () => {
13
+ const frame = <E>(body: Stream.Stream<RunEvent, E>) =>
14
+ body.pipe(
15
+ Stream.catchCause((cause) => Stream.succeed(Run.failed(cause))),
16
+ Run.start({ runId: r1, inputs: [InputId.make("in_1")] }),
17
+ Run.complete,
18
+ )
19
+
20
+ it.effect("brackets a successful body with run-started and run-completed", () =>
21
+ frame(Stream.make(text("hi"))).pipe(
22
+ Stream.runCollect,
23
+ Effect.map((events) => {
24
+ expect(types(events)).toEqual(["run-started", "text-delta", "run-completed"])
25
+ expect(events[0]).toMatchObject({ runId: "r1", inputs: ["in_1"] })
26
+ }),
27
+ ),
28
+ )
29
+
30
+ it.effect("turns a failure into run-failed with the error's tag", () =>
31
+ frame(
32
+ Stream.make(text("partial")).pipe(
33
+ Stream.concat(Stream.fail(new AiError.RateLimited({ provider: "x", raw: null }))),
34
+ ),
35
+ ).pipe(
36
+ Stream.runCollect,
37
+ Effect.map((events) => {
38
+ expect(types(events)).toEqual(["run-started", "text-delta", "run-failed"])
39
+ expect(events.at(-1)).toEqual({ type: "run-failed", reason: "RateLimited" })
40
+ }),
41
+ ),
42
+ )
43
+
44
+ it.effect("lets the body's own terminal win and end the run", () =>
45
+ frame(
46
+ Stream.fromIterable<RunEvent>([
47
+ text("needs approval"),
48
+ { type: "run-interrupted", interrupts: [] },
49
+ text("never sent"),
50
+ ]),
51
+ ).pipe(
52
+ Stream.runCollect,
53
+ Effect.map((events) =>
54
+ expect(types(events)).toEqual(["run-started", "text-delta", "run-interrupted"]),
55
+ ),
56
+ ),
57
+ )
58
+
59
+ it.effect("ends with run-stopped when the stop signal completes", () =>
60
+ Effect.gen(function* () {
61
+ const stop = yield* Deferred.make<void>()
62
+ const fiber = yield* Stream.make(text("streaming")).pipe(
63
+ Stream.concat(Stream.never),
64
+ Run.stopWhen(Deferred.await(stop)),
65
+ frame,
66
+ Stream.runCollect,
67
+ Effect.forkChild,
68
+ )
69
+ yield* Deferred.succeed(stop, undefined)
70
+ const events = yield* Fiber.join(fiber)
71
+ expect(types(events)).toEqual(["run-started", "text-delta", "run-stopped"])
72
+ }),
73
+ )
74
+ })
75
+
76
+ describe("Run.sequence", () => {
77
+ it.effect("numbers frames per stream and tags run-scoped ones with the open run", () =>
78
+ Stream.fromIterable<RunEvent>([
79
+ { type: "data", name: "before", value: 1 },
80
+ { type: "run-started", runId: r1, inputs: [], protocol: 2 },
81
+ text("hi"),
82
+ { type: "run-completed" },
83
+ { type: "live", name: "after", value: 2 },
84
+ ]).pipe(
85
+ Run.sequence(StreamId.make("st_1"), { from: 10 }),
86
+ Stream.runCollect,
87
+ Effect.map((frames) => {
88
+ expect(frames.map((f) => f.seq)).toEqual([11, 12, 13, 14, 15])
89
+ expect(frames.map((f) => f.runId)).toEqual([undefined, "r1", "r1", "r1", undefined])
90
+ }),
91
+ ),
92
+ )
93
+ })
94
+
95
+ describe("Run.coalesce", () => {
96
+ it.effect("merges adjacent deltas of the same kind and keeps everything else in order", () =>
97
+ Stream.fromIterable<RunEvent>([
98
+ text("Hel"),
99
+ text("lo"),
100
+ { type: "tool-call-args-delta", callId: "c1", delta: `{"a"` },
101
+ { type: "tool-call-args-delta", callId: "c1", delta: ":1}" },
102
+ { type: "tool-call-args-delta", callId: "c2", delta: "{}" },
103
+ { type: "step-finished" },
104
+ text("!"),
105
+ ]).pipe(
106
+ Run.coalesce("50 millis"),
107
+ Stream.runCollect,
108
+ Effect.map((events) =>
109
+ expect(events).toEqual<ReadonlyArray<RunEvent>>([
110
+ text("Hello"),
111
+ { type: "tool-call-args-delta", callId: "c1", delta: `{"a":1}` },
112
+ { type: "tool-call-args-delta", callId: "c2", delta: "{}" },
113
+ { type: "step-finished" },
114
+ text("!"),
115
+ ]),
116
+ ),
117
+ ),
118
+ )
119
+ })