@juno-ai/bind 8.0.0 → 10.0.0

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.
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Streaming a turn's assistant text to a live surface, safely across retries.
3
+ *
4
+ * Forwarding deltas is one line in a host's transport. What is not one line —
5
+ * and what every host that streams has to get right independently — is what
6
+ * happens when the attempt that produced those deltas *fails*. The routing
7
+ * executor's answer to a failure is to try again: same endpoint, then the next
8
+ * provider, then the fallback model. Each of those re-renders a turn the user is
9
+ * already reading.
10
+ *
11
+ * Until now the package's only answer was to refuse: a transport that had
12
+ * streamed set `producedOutput` on its failure outcome and the executor clamped
13
+ * to propagate-only (`propagateOnly`). That trades a recoverable failure for a
14
+ * visible one — a 500 on the first provider becomes an error the user sees,
15
+ * purely because the first token had left.
16
+ *
17
+ * There is a better answer whenever the surface can be *retracted*: tell it to
18
+ * discard what it has, then stream the retry cleanly. That is this module. It
19
+ * turns `producedOutput` from "did we emit" into what its own contract already
20
+ * said — **"did we emit somewhere the caller cannot take it back"** — and lets a
21
+ * retractable surface keep the whole fallback chain.
22
+ *
23
+ * ## Lifetime: one stream per turn, and you must call `finish()`
24
+ *
25
+ * Construct it in the function that owns the turn — outside the executor and
26
+ * outside any structured-output retry loop — and call {@link
27
+ * TurnTextStream.finish} when the turn is over, whichever way it ended.
28
+ *
29
+ * Both halves are load-bearing and neither is enforceable from in here. A stream
30
+ * built *inside* the `AttemptFn` never sees a second attempt, so it never resets
31
+ * and `producedOutput` is never true; the single-attempt path — almost all
32
+ * traffic — looks identical to a correct one, and the bug appears only under
33
+ * fallback, as two partial answers glued together. Skipping `finish()` leaves an
34
+ * armed reset undelivered whenever the attempt that *succeeds* emits no text,
35
+ * which is the ordinary shape of a tool-calling turn (`content: null` plus
36
+ * `tool_calls`): the discarded attempt's narration then stays on screen as if it
37
+ * belonged to the turn that replaced it.
38
+ *
39
+ * ## What the host still owns
40
+ *
41
+ * The sink, and the judgement of what goes into it. In particular **reasoning
42
+ * deltas are not decided here**: whether a model's thinking counts as output the
43
+ * user has seen is a product call, and the host expresses it by choosing what it
44
+ * passes to {@link TurnTextStream.observe}.
45
+ *
46
+ * **Tool-call deltas are deliberately not output.** It is tempting to latch on
47
+ * the first byte of any kind, and a host streaming raw chunks may already do
48
+ * that. But a tool call is not a side effect until it is *dispatched*, which
49
+ * happens after the turn completes — so a turn that dies mid-stream having
50
+ * emitted only tool-call bytes has changed nothing the user or a third party can
51
+ * see, and refusing to retry it forfeits a fallback for free. A host with a
52
+ * genuine mid-attempt effect of its own says so with
53
+ * {@link TurnTextStream.markProducedOutput} rather than by widening what counts
54
+ * as text.
55
+ *
56
+ * **`onAssistantMessage` on the turn kernel is a different surface.** It fires
57
+ * once per completed assistant message; these events stream one attempt of one
58
+ * message. Wiring both to the same UI element delivers the same text twice. The
59
+ * division that works: deltas drive the live view, `onAssistantMessage` drives
60
+ * the permanent record (the transcript row, the notification, the third-party
61
+ * post). If they must share one element, treat the persisted message as
62
+ * authoritative and let it supersede the streamed epochs for its `turnId`.
63
+ *
64
+ * ## The race this exists to make survivable
65
+ *
66
+ * On any real transport — a WebSocket through a Durable Object, an SSE relay, a
67
+ * fan-out to several tabs — an in-flight delta from attempt 1 can be *delivered
68
+ * after* attempt 2's reset. A client that renders every text event it receives
69
+ * will then show the wiped attempt's tail glued onto the retry. Every event
70
+ * therefore carries a `turnId`, an `epoch` (which attempt produced it) and a
71
+ * `seq` (monotonic within the turn), and a correct client uses all three:
72
+ *
73
+ * - scope everything to `turnId` — `epoch` and `seq` both restart each turn, so
74
+ * a client that carried "newest epoch" across turns drops every event after
75
+ * the first turn that retried, and a later turn's reset tells it to wipe an
76
+ * earlier, committed message;
77
+ * - **apply events in `seq` order**: drop anything at or below the last `seq`
78
+ * applied, and hold anything that arrives ahead of it until the gap fills;
79
+ * - then drop any event whose `epoch` is older than the newest seen, and clear
80
+ * what is rendered on a `reset`.
81
+ *
82
+ * The `seq` step is not optional decoration on the `epoch` step — it is what
83
+ * makes the epoch step *sound*. Ordering by epoch alone loses a same-epoch
84
+ * reorder: a retry's text arriving before its own reset is accepted and then
85
+ * cleared by the reset that follows, leaving the surface blank. A transport that
86
+ * already guarantees ordered exactly-once delivery collapses the `seq` step to a
87
+ * no-op, but that is a property to assert deliberately rather than assume.
88
+ *
89
+ * ## Why the reset is lazy
90
+ *
91
+ * It fires on the retry's **first byte**, not when the previous attempt failed.
92
+ * A retry that dies before producing anything — no viable endpoint, an immediate
93
+ * 401 — would otherwise have wiped the screen to show nothing. Partial text plus
94
+ * an error is strictly more useful to a reader than a blank space plus an error.
95
+ * A turn where no attempt ever emits therefore emits no events at all.
96
+ *
97
+ * The one thing that must not be lazy is a reset still armed when the turn ends
98
+ * — hence `finish()`, which flushes it. By then the text it retracts is known to
99
+ * have come from an attempt that was thrown away.
100
+ */
101
+ /** Why the surface is being told to discard what it has rendered. */
102
+ export type TurnResetReason =
103
+ /** The previous attempt failed and routing moved on (retry, provider, model). */
104
+ "attempt_failed"
105
+ /** The previous attempt succeeded but its output was rejected and re-asked. */
106
+ | "structured_output_retry";
107
+ /**
108
+ * One event bound for the user's surface.
109
+ *
110
+ * `turnId` is the host's identifier for the turn and scopes everything else:
111
+ * `seq` is monotonic within it and never restarts, so it doubles as an ordering
112
+ * and de-duplication key on a transport that can do neither, and `epoch`
113
+ * identifies the attempt and only ever increases. Both restart on the next turn,
114
+ * which is why the `turnId` is on the wire.
115
+ */
116
+ export type TurnStreamEvent = Readonly<{
117
+ kind: "text";
118
+ turnId: string;
119
+ epoch: number;
120
+ seq: number;
121
+ text: string;
122
+ }> | Readonly<{
123
+ kind: "reset";
124
+ turnId: string;
125
+ epoch: number;
126
+ seq: number;
127
+ reason: TurnResetReason;
128
+ }>;
129
+ /**
130
+ * Where a turn's text goes.
131
+ *
132
+ * `retractable` is the whole decision. It is a property of the *surface*, not of
133
+ * the transport that writes to it: a view that re-renders from the events it is
134
+ * sent is retractable; a chat message already posted through a third-party API,
135
+ * an email, a webhook delivery, and an append-only transcript row are not.
136
+ *
137
+ * **Wire only retractable surfaces here.** A host that must also deliver
138
+ * somewhere permanent should do that from the *completed* turn rather than from
139
+ * the deltas — that composes correctly, whereas declaring a permanent surface
140
+ * retractable silently re-enables the duplication this module exists to prevent.
141
+ * If one sink genuinely fans out to a mix, declare it `false`; the conservative
142
+ * answer costs a fallback, the optimistic one costs the user's trust.
143
+ *
144
+ * **`retractable` is read once, when the stream is created**, and a `readonly`
145
+ * field is no barrier to a getter. Re-reading it would make `producedOutput`
146
+ * non-monotonic: a sink that flipped after the first byte could un-clamp routing
147
+ * *after* the executor had already been told the turn was replayable, and the
148
+ * permanent surface would then get the turn twice.
149
+ *
150
+ * A sink that **buffers** — coalescing deltas over a byte or time window before
151
+ * releasing them — is free to drop a `reset` whose epoch never left that buffer,
152
+ * along with the text it would have wiped. Nothing was rendered, so nothing
153
+ * needs retracting, and the client is spared a no-op flicker. This module cannot
154
+ * do that for the sink because only the sink knows what it has released.
155
+ */
156
+ export interface TurnStreamSink {
157
+ readonly retractable: boolean;
158
+ /**
159
+ * Deliver one event. May throw — a closed socket is ordinary, not
160
+ * exceptional. See {@link TurnTextStream.sinkErrors} for what a throw means.
161
+ */
162
+ emit(event: TurnStreamEvent): void;
163
+ }
164
+ export interface TurnTextStreamOptions {
165
+ readonly sink: TurnStreamSink;
166
+ /**
167
+ * Identifies the turn on the wire. Any value the client can compare for
168
+ * equality and that is unique among the turns it may see concurrently — a
169
+ * message id, a run id plus a turn ordinal. Required rather than defaulted
170
+ * because a client cannot scope `epoch` and `seq` without it, and every
171
+ * plausible default would be wrong for someone.
172
+ *
173
+ * **It is a correlation label, never an authorization boundary.** The client
174
+ * rule says "scope everything to `turnId`", which is about *ordering*, not
175
+ * about deciding whether an event is yours to render. The sink must already be
176
+ * scoped to the intended recipient before anything is emitted — a client that
177
+ * treats a matching `turnId` as evidence an event belongs to it will render
178
+ * whatever arrives on a shared topic, including a `reset` that wipes a
179
+ * committed message.
180
+ */
181
+ readonly turnId: string;
182
+ /**
183
+ * Cap on how many times the surface may be wiped in one turn. Reaching it
184
+ * does not throw and does not stop emission: it makes
185
+ * {@link TurnTextStream.producedOutput} true, so the *next* failure clamps
186
+ * routing and the traversal stops. Emission continues so that an attempt
187
+ * already in flight still reaches the user.
188
+ *
189
+ * There is **no default cap** — a plan with three stages, three candidates and
190
+ * two defect retries can legally wipe the screen more than twenty times, and
191
+ * that is worth bounding, but the tolerable number is a product judgement
192
+ * about flicker that this module cannot make for a host. Guessing one would
193
+ * break a host for whom a rare double-wipe is entirely fine.
194
+ *
195
+ * Because the value gates routing, it also shapes how many endpoints record a
196
+ * failure against the circuit breaker for one user turn. That is a real
197
+ * coupling between a UI judgement and shared telemetry; it is the price of
198
+ * letting the UI decide.
199
+ */
200
+ readonly maxResets?: number | undefined;
201
+ }
202
+ export interface TurnTextStream {
203
+ /**
204
+ * Open a new attempt. Call this at the **top of every attempt**, including the
205
+ * first — that is once per `AttemptFn` invocation, which covers same-endpoint
206
+ * defect retries, provider traversal and model fallback in one place, plus
207
+ * once per structured-output retry, which happens outside the executor and is
208
+ * the caller's loop to instrument.
209
+ *
210
+ * On any attempt following one that produced text, this arms a reset; the
211
+ * reset itself is emitted lazily, when that attempt first produces text (or by
212
+ * {@link finish}, if it never does).
213
+ *
214
+ * An explicitly passed `reason` **sticks** until the reset is delivered, and a
215
+ * later call that omits one will not overwrite it. Without that, the
216
+ * structured-output arm of {@link TurnResetReason} would be unreachable in the
217
+ * composition this module prescribes: the caller's retry loop opens the
218
+ * attempt with a reason, then re-enters the executor, whose `AttemptFn` opens
219
+ * the same pending reset again with the default.
220
+ */
221
+ beginAttempt(reason?: TurnResetReason): void;
222
+ /**
223
+ * Forward one content delta. `null`, `undefined` and `""` are no-ops and do
224
+ * not count as output — a provider sending an empty content field has shown
225
+ * the user nothing.
226
+ */
227
+ observe(text: string | null | undefined): void;
228
+ /**
229
+ * End the turn. **Call it however the turn ended**, including on the success
230
+ * path and on a throw — and tell it which, because the two do opposite things
231
+ * with a reset that is still armed.
232
+ *
233
+ * - `"succeeded"` **flushes** it. An attempt streamed text and failed, the
234
+ * retry succeeded with tool calls and no text at all, so nothing triggered
235
+ * the lazy reset. Without the flush the failed attempt's narration stays on
236
+ * the surface attributed to a turn that never said it, and vanishes only on
237
+ * reload. `content: null` plus `tool_calls` is the ordinary shape of an
238
+ * agent turn, so this is the common case rather than an edge.
239
+ * - `"failed"` **drops** it. Every attempt failed, and the last one died
240
+ * before producing a byte. Flushing here would wipe the screen to show
241
+ * nothing — the reader would get a blank space plus an error where they
242
+ * could have had partial text plus an error, which is the same trade the
243
+ * lazy reset exists to make and must not be undone at the finish line.
244
+ *
245
+ * Idempotent. Afterwards every method is a no-op.
246
+ */
247
+ finish(outcome: "succeeded" | "failed"): void;
248
+ /**
249
+ * Latch {@link producedOutput} for a reason this module cannot see — a
250
+ * provider-side effect, a write a replay would repeat, a second surface the
251
+ * host wrote to itself. Irreversible, and a no-op after {@link finish}: the
252
+ * flag exists for the executor to read on a failure outcome, and once the turn
253
+ * is over there is no outcome left to clamp.
254
+ *
255
+ * It does **not** stop emission. If the attempt that reported the effect goes
256
+ * on to succeed, its text is still the user's answer and must reach them; if
257
+ * it fails, the clamp has already told the executor to stop, so there is no
258
+ * later attempt to suppress.
259
+ *
260
+ * This is the seam for a host migrating off a hand-rolled "any byte arrived"
261
+ * flag. Keep the flag for the effects it genuinely tracks and call this;
262
+ * do not widen {@link observe}.
263
+ */
264
+ markProducedOutput(): void;
265
+ /**
266
+ * Whether output has reached a surface the caller **cannot take back**, which
267
+ * is exactly the flag the routing executor clamps on. Put it straight onto the
268
+ * failure outcome:
269
+ *
270
+ * ```ts
271
+ * return { kind: "failure", error, producedOutput: stream.producedOutput };
272
+ * ```
273
+ *
274
+ * For a retractable sink this stays `false` while a wipe is still available,
275
+ * so routing keeps its whole fallback chain. It latches `true` when the reset
276
+ * budget is spent, when a reset failed to reach the sink, or when the host
277
+ * reports an out-of-band effect. Monotonic: never true then false.
278
+ *
279
+ * **It answers "may routing replay this turn", not "is the reader looking at
280
+ * output".** The executor only reads it on a failure outcome, so the two
281
+ * questions never diverge where it is consumed — but they do diverge off-label:
282
+ * a turn that spent its flicker budget and then *succeeded* reads `true` even
283
+ * though the text it emitted was retracted. Do not source a "did the user see
284
+ * anything" metric from this.
285
+ */
286
+ readonly producedOutput: boolean;
287
+ /** The current attempt's epoch. `-1` before the first {@link beginAttempt}. */
288
+ readonly epoch: number;
289
+ /**
290
+ * Resets *handed to the sink* so far. A reset whose `emit` threw is counted —
291
+ * it consumed the flicker budget, and whether the client saw it is exactly
292
+ * what this module cannot know. See {@link sinkErrors}.
293
+ */
294
+ readonly resetCount: number;
295
+ /**
296
+ * Events whose `emit` threw. Emission continues after a throw rather than
297
+ * tearing down — a momentarily closed socket should not end a model turn that
298
+ * is otherwise fine.
299
+ *
300
+ * Two consequences. On a **non-retractable** surface a throw still counts as
301
+ * output: the sink got far enough to fail, and whether the bytes left first is
302
+ * not knowable from here, so the conservative reading is that they did. On a
303
+ * **retractable** one a failed *text* event leaves the turn replayable, but a
304
+ * failed *reset* does not — the wipe instruction is the one event whose loss
305
+ * cannot be repaired by sending more, so it latches
306
+ * {@link producedOutput} rather than letting a second epoch stack onto a
307
+ * surface that never cleared the first.
308
+ */
309
+ readonly sinkErrors: number;
310
+ }
311
+ export declare function createTurnTextStream(options: TurnTextStreamOptions): TurnTextStream;
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Streaming a turn's assistant text to a live surface, safely across retries.
3
+ *
4
+ * Forwarding deltas is one line in a host's transport. What is not one line —
5
+ * and what every host that streams has to get right independently — is what
6
+ * happens when the attempt that produced those deltas *fails*. The routing
7
+ * executor's answer to a failure is to try again: same endpoint, then the next
8
+ * provider, then the fallback model. Each of those re-renders a turn the user is
9
+ * already reading.
10
+ *
11
+ * Until now the package's only answer was to refuse: a transport that had
12
+ * streamed set `producedOutput` on its failure outcome and the executor clamped
13
+ * to propagate-only (`propagateOnly`). That trades a recoverable failure for a
14
+ * visible one — a 500 on the first provider becomes an error the user sees,
15
+ * purely because the first token had left.
16
+ *
17
+ * There is a better answer whenever the surface can be *retracted*: tell it to
18
+ * discard what it has, then stream the retry cleanly. That is this module. It
19
+ * turns `producedOutput` from "did we emit" into what its own contract already
20
+ * said — **"did we emit somewhere the caller cannot take it back"** — and lets a
21
+ * retractable surface keep the whole fallback chain.
22
+ *
23
+ * ## Lifetime: one stream per turn, and you must call `finish()`
24
+ *
25
+ * Construct it in the function that owns the turn — outside the executor and
26
+ * outside any structured-output retry loop — and call {@link
27
+ * TurnTextStream.finish} when the turn is over, whichever way it ended.
28
+ *
29
+ * Both halves are load-bearing and neither is enforceable from in here. A stream
30
+ * built *inside* the `AttemptFn` never sees a second attempt, so it never resets
31
+ * and `producedOutput` is never true; the single-attempt path — almost all
32
+ * traffic — looks identical to a correct one, and the bug appears only under
33
+ * fallback, as two partial answers glued together. Skipping `finish()` leaves an
34
+ * armed reset undelivered whenever the attempt that *succeeds* emits no text,
35
+ * which is the ordinary shape of a tool-calling turn (`content: null` plus
36
+ * `tool_calls`): the discarded attempt's narration then stays on screen as if it
37
+ * belonged to the turn that replaced it.
38
+ *
39
+ * ## What the host still owns
40
+ *
41
+ * The sink, and the judgement of what goes into it. In particular **reasoning
42
+ * deltas are not decided here**: whether a model's thinking counts as output the
43
+ * user has seen is a product call, and the host expresses it by choosing what it
44
+ * passes to {@link TurnTextStream.observe}.
45
+ *
46
+ * **Tool-call deltas are deliberately not output.** It is tempting to latch on
47
+ * the first byte of any kind, and a host streaming raw chunks may already do
48
+ * that. But a tool call is not a side effect until it is *dispatched*, which
49
+ * happens after the turn completes — so a turn that dies mid-stream having
50
+ * emitted only tool-call bytes has changed nothing the user or a third party can
51
+ * see, and refusing to retry it forfeits a fallback for free. A host with a
52
+ * genuine mid-attempt effect of its own says so with
53
+ * {@link TurnTextStream.markProducedOutput} rather than by widening what counts
54
+ * as text.
55
+ *
56
+ * **`onAssistantMessage` on the turn kernel is a different surface.** It fires
57
+ * once per completed assistant message; these events stream one attempt of one
58
+ * message. Wiring both to the same UI element delivers the same text twice. The
59
+ * division that works: deltas drive the live view, `onAssistantMessage` drives
60
+ * the permanent record (the transcript row, the notification, the third-party
61
+ * post). If they must share one element, treat the persisted message as
62
+ * authoritative and let it supersede the streamed epochs for its `turnId`.
63
+ *
64
+ * ## The race this exists to make survivable
65
+ *
66
+ * On any real transport — a WebSocket through a Durable Object, an SSE relay, a
67
+ * fan-out to several tabs — an in-flight delta from attempt 1 can be *delivered
68
+ * after* attempt 2's reset. A client that renders every text event it receives
69
+ * will then show the wiped attempt's tail glued onto the retry. Every event
70
+ * therefore carries a `turnId`, an `epoch` (which attempt produced it) and a
71
+ * `seq` (monotonic within the turn), and a correct client uses all three:
72
+ *
73
+ * - scope everything to `turnId` — `epoch` and `seq` both restart each turn, so
74
+ * a client that carried "newest epoch" across turns drops every event after
75
+ * the first turn that retried, and a later turn's reset tells it to wipe an
76
+ * earlier, committed message;
77
+ * - **apply events in `seq` order**: drop anything at or below the last `seq`
78
+ * applied, and hold anything that arrives ahead of it until the gap fills;
79
+ * - then drop any event whose `epoch` is older than the newest seen, and clear
80
+ * what is rendered on a `reset`.
81
+ *
82
+ * The `seq` step is not optional decoration on the `epoch` step — it is what
83
+ * makes the epoch step *sound*. Ordering by epoch alone loses a same-epoch
84
+ * reorder: a retry's text arriving before its own reset is accepted and then
85
+ * cleared by the reset that follows, leaving the surface blank. A transport that
86
+ * already guarantees ordered exactly-once delivery collapses the `seq` step to a
87
+ * no-op, but that is a property to assert deliberately rather than assume.
88
+ *
89
+ * ## Why the reset is lazy
90
+ *
91
+ * It fires on the retry's **first byte**, not when the previous attempt failed.
92
+ * A retry that dies before producing anything — no viable endpoint, an immediate
93
+ * 401 — would otherwise have wiped the screen to show nothing. Partial text plus
94
+ * an error is strictly more useful to a reader than a blank space plus an error.
95
+ * A turn where no attempt ever emits therefore emits no events at all.
96
+ *
97
+ * The one thing that must not be lazy is a reset still armed when the turn ends
98
+ * — hence `finish()`, which flushes it. By then the text it retracts is known to
99
+ * have come from an attempt that was thrown away.
100
+ */
101
+ export function createTurnTextStream(options) {
102
+ const { sink, turnId } = options;
103
+ const maxResets = options.maxResets;
104
+ if (maxResets !== undefined &&
105
+ (!Number.isInteger(maxResets) || maxResets < 0)) {
106
+ // `NaN` compares false against everything, so an unvalidated non-finite cap
107
+ // reads as "unbounded" — the exact opposite of the safe failure, on the one
108
+ // option whose whole purpose is to bound something.
109
+ throw new TypeError(`createTurnTextStream: maxResets must be a non-negative integer, got ${String(maxResets)}`);
110
+ }
111
+ if (typeof turnId !== "string" || turnId === "") {
112
+ // Every plausible way to get here — `turnId: obj?.id ?? ""` — collapses
113
+ // every turn of a run into one scope on the client, which is the bug the
114
+ // field was added to prevent.
115
+ throw new TypeError(`createTurnTextStream: turnId must be a non-empty string, got ${JSON.stringify(turnId)}`);
116
+ }
117
+ // Snapshotted, never re-read — see `TurnStreamSink.retractable`.
118
+ const retractable = sink.retractable;
119
+ let epoch = -1;
120
+ let seq = 0;
121
+ let resetCount = 0;
122
+ let sinkErrors = 0;
123
+ let finished = false;
124
+ // Text has reached the sink at least once this turn. Distinct from
125
+ // `producedOutput`, which additionally asks whether it can be taken back.
126
+ let emitted = false;
127
+ // Held rather than sent immediately so an attempt that dies before producing
128
+ // anything leaves the previous text on screen.
129
+ let resetPending = null;
130
+ // The surface is showing an epoch it will never be told to clear — either a
131
+ // reset was refused, or the flicker budget ran out before one could be armed.
132
+ // Both mean the same thing and must behave the same way: stop writing to it.
133
+ // They did not, and the asymmetry spliced two answers together — the budget
134
+ // path armed no reset and then happily appended the next attempt's text on top
135
+ // of the abandoned one.
136
+ let staleSurface = false;
137
+ // Whether a reset was EVER lost. Separate from `staleSurface` because that one
138
+ // clears when a later reset lands, and `producedOutput` must not un-latch.
139
+ let everLostReset = false;
140
+ let externalEffect = false;
141
+ const budgetSpent = () => maxResets !== undefined && resetCount >= maxResets;
142
+ const deliver = (event) => {
143
+ try {
144
+ // Frozen because the type says `Readonly` and a sink that fans out to two
145
+ // consumers would otherwise let the first mutate what the second sees.
146
+ sink.emit(Object.freeze(event));
147
+ return true;
148
+ }
149
+ catch {
150
+ sinkErrors += 1;
151
+ return false;
152
+ }
153
+ };
154
+ const flushReset = () => {
155
+ const reason = resetPending;
156
+ if (reason === null)
157
+ return;
158
+ resetPending = null;
159
+ resetCount += 1;
160
+ const stamp = epoch;
161
+ if (deliver({ kind: "reset", turnId, epoch: stamp, seq: seq++, reason })) {
162
+ // The wipe landed, so whatever was stranded on the surface is gone and it
163
+ // is safe to write again.
164
+ staleSurface = false;
165
+ }
166
+ else {
167
+ staleSurface = true;
168
+ everLostReset = true;
169
+ }
170
+ };
171
+ return {
172
+ beginAttempt(reason) {
173
+ if (finished)
174
+ return;
175
+ // The epoch advances on every attempt, whether or not a reset is owed:
176
+ // it identifies the attempt, and a host correlating logs on it should not
177
+ // see it stall because an earlier attempt happened to stay silent.
178
+ epoch += 1;
179
+ // Nothing on screen yet, so nothing to retract.
180
+ if (!emitted)
181
+ return;
182
+ // A permanent surface cannot honour a retraction, and telling it to is
183
+ // worse than not: a defensive sink reads it as a protocol error and a
184
+ // naive one may delete a message it already posted. Its `producedOutput`
185
+ // latched on the first byte, so routing has already stopped.
186
+ if (!retractable)
187
+ return;
188
+ // No wipe left, so this attempt's text would land on top of the last one
189
+ // rather than replacing it. The executor has already been told to stop
190
+ // (`producedOutput` is true), so anything still driving attempts is a
191
+ // caller-owned loop — a structured-output retry — and appending its output
192
+ // to an abandoned answer is the splice this module exists to prevent.
193
+ if (budgetSpent()) {
194
+ staleSurface = true;
195
+ return;
196
+ }
197
+ // An explicit reason outranks a later default — see `beginAttempt`. Spelt
198
+ // out rather than as a `??` chain: the chain reads as "reason wins" and
199
+ // hides that the middle term is what preserves an EARLIER explicit reason
200
+ // through a later defaulted call, which is the whole point.
201
+ if (reason !== undefined) {
202
+ resetPending = reason;
203
+ }
204
+ else {
205
+ resetPending ??= "attempt_failed";
206
+ }
207
+ },
208
+ observe(text) {
209
+ if (finished)
210
+ return;
211
+ // Checked before the empty-text short-circuit: this guard catches a host
212
+ // that never opened an attempt, and a host whose first chunk happens to be
213
+ // empty must not ship green and then produce indistinguishable retries.
214
+ if (epoch < 0) {
215
+ throw new Error("createTurnTextStream: observe() before beginAttempt() — every attempt must open one, or a retry cannot be told apart from the turn it replaces");
216
+ }
217
+ if (text === null || text === undefined || text === "")
218
+ return;
219
+ // Checked BEFORE the flush, not after: once the surface is stranded,
220
+ // sending it more — including another reset — only compounds the problem.
221
+ // A reset delivered now would clear text the reader is relying on and then
222
+ // be followed by nothing, leaving them with a blank message on a turn that
223
+ // may well succeed. One coherent (if stale) answer beats both a spliced
224
+ // one and an empty one; `sinkErrors` is the host's cue to force a resync.
225
+ if (staleSurface)
226
+ return;
227
+ flushReset();
228
+ // Re-checked: the flush above may have just failed, in which case the
229
+ // surface still shows the epoch it was told to clear and this attempt's
230
+ // text would be glued onto it.
231
+ if (staleSurface)
232
+ return;
233
+ emitted = true;
234
+ // Stamped before `deliver`, so a re-entrant sink that opens another
235
+ // attempt from inside `emit` cannot back-date this delta to a newer one.
236
+ const stamp = epoch;
237
+ deliver({ kind: "text", turnId, epoch: stamp, seq: seq++, text });
238
+ },
239
+ finish(outcome) {
240
+ if (finished)
241
+ return;
242
+ finished = true;
243
+ // A pending reset means an earlier attempt's text is still on screen. On
244
+ // success it has been superseded and must go; on failure it is the best
245
+ // thing the reader has, and wiping it is strictly worse than leaving it.
246
+ if (outcome === "succeeded")
247
+ flushReset();
248
+ else
249
+ resetPending = null;
250
+ },
251
+ markProducedOutput() {
252
+ if (finished)
253
+ return;
254
+ externalEffect = true;
255
+ },
256
+ get producedOutput() {
257
+ if (externalEffect)
258
+ return true;
259
+ if (!emitted)
260
+ return false;
261
+ return !retractable || everLostReset || budgetSpent();
262
+ },
263
+ get epoch() {
264
+ return epoch;
265
+ },
266
+ get resetCount() {
267
+ return resetCount;
268
+ },
269
+ get sinkErrors() {
270
+ return sinkErrors;
271
+ },
272
+ };
273
+ }
@@ -1 +1 @@
1
- export { emptyRunStats, accumulateTurn, accumulateToolCall, accumulateRun, type TranscriptMessage, type AssistantTurnMessage, type WireToolDefinition, type WireToolCall, type TurnTimings, type TurnUsage, type ModelTurnResult, type TurnFn, type StopReason, type RunStats, } from "./turn.js";
1
+ export { emptyRunStats, accumulateTurn, accumulateToolCall, accumulateAuxiliarySpend, accumulateRun, type AuxiliarySpend, type TranscriptMessage, type AssistantTurnMessage, type WireToolDefinition, type WireToolCall, type TurnTimings, type TurnUsage, type ModelTurnResult, type TurnFn, type StopReason, type RunStats, } from "./turn.js";
@@ -1 +1 @@
1
- export { emptyRunStats, accumulateTurn, accumulateToolCall, accumulateRun, } from "./turn.js";
1
+ export { emptyRunStats, accumulateTurn, accumulateToolCall, accumulateAuxiliarySpend, accumulateRun, } from "./turn.js";