bunnyquery 1.9.7 → 1.9.10

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.
@@ -29,6 +29,7 @@ import {
29
29
  extractOpenAIText,
30
30
  getChatHistory,
31
31
  POLL_INTERVAL,
32
+ STREAM_POLL_INTERVAL,
32
33
  MAX_CONCURRENT_BG_POLLS,
33
34
  bgIndexingQueueName,
34
35
  isBgIndexingQueue,
@@ -41,12 +42,15 @@ import {
41
42
  type BgTaskEntry,
42
43
  } from './requests';
43
44
  import { isPagedReadFile, isImageVisionFile, isWindowedReadFile } from './office';
44
- import { windowedIndexingEnabled, chatEngineConfig } from './config';
45
- import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, getErrorMessage } from './errors';
45
+ import { windowedIndexingEnabled, liveStreamingEnabled, streamRecoveryEnabled, chatEngineConfig } from './config';
46
+ // The wire-format knowledge. skapi relays the provider's bytes without reading them,
47
+ // so the session hands them straight to this parser and never inspects a frame itself.
48
+ import { createSseParser, type SseParser } from './sse';
49
+ import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, getErrorMessage, isCsrStatusEnvelope } from './errors';
46
50
  import { buildBoundedChatMessages } from './budget';
47
51
  import { createInlineLinkRegex, sanitizeAttachmentLinksForHistory } from './links';
48
52
  import { markImagePreviewStale } from './image_preview';
49
- import { chatCacheKey, indexScopeKey, mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText, probeBgQueue, BG_PROBE_TTL_MS, getSplitChatHistory, shouldRescueInFlightMessage } from './history';
53
+ import { chatCacheKey, indexScopeKey, mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText, probeBgQueue, BG_PROBE_TTL_MS, getSplitChatHistory, shouldRescueInFlightMessage, adoptLocalAnswerIntoPage } from './history';
50
54
  import { wallClockNow } from './time';
51
55
  import { parseAttachmentContent } from './attachment_parsers';
52
56
  import type { ChatHost, ChatState, ChatMessage, ChatIdentity, PinnedDispatchContext } from './host';
@@ -157,6 +161,385 @@ function isPollStopped(res: any): boolean {
157
161
  return !!res && typeof res === 'object' && res.status === 'stopped';
158
162
  }
159
163
 
164
+
165
+ /* ── live streaming: what is safe to show, and where to resume ──────────────
166
+ *
167
+ * A streamed answer is rendered while it is still GROWING, which is a different
168
+ * problem from the typewriter's. The typewriter already holds the whole string and
169
+ * only has to avoid stopping inside a region it knows the bounds of. Here the end
170
+ * of the string is not the end of the answer: a `[` may be the start of a link
171
+ * whose url has not arrived, and three backticks may be the start of a file fence
172
+ * whose whole point is that it renders as a download chip rather than as prose.
173
+ *
174
+ * So the rule is the mirror image of the typewriter's: instead of extending the
175
+ * reveal past an atomic region, hold it BACK to before whatever the next bytes
176
+ * could still turn into one. The tail is only ever a paint or two behind, and the
177
+ * settle re-renders the authoritative text in full anyway, so nothing is lost by
178
+ * being late and quite a lot is lost by being early (a chip minted from half a
179
+ * url, a fence opener shown as three literal backticks, a link whose href changes
180
+ * under the reader's pointer).
181
+ */
182
+
183
+ /** How far back from the end an unclosed '[' is still read as "a link about to
184
+ * complete". Beyond this it is ordinary prose that happens to contain a bracket,
185
+ * and treating it as a pending link would freeze the reveal for the rest of the
186
+ * answer: "see [the appendix" never closes, and without a window the reader would
187
+ * watch a bubble that stopped growing at the bracket until the turn settled. */
188
+ const LIVE_PENDING_LINK_WINDOW = 512;
189
+
190
+ /**
191
+ * How many unfinalized streamed turns ONE history load reads back out of the chunk
192
+ * store on its own.
193
+ *
194
+ * Each read drains a whole turn's chunks, so this is a real request budget, not a
195
+ * render one. Two, newest first, because that is the shape the case actually has:
196
+ * a turn is left unfinalized by the tab going away mid-answer, and a user does that
197
+ * to the turn they were watching, not to twenty of them. A page holding more keeps
198
+ * the rest marked and picks them up on the next load (each recovery finalizes what
199
+ * it read, so the backlog strictly shrinks), or the host offers them on demand
200
+ * through recoverStreamedAnswer().
201
+ */
202
+ const STREAM_RECOVERY_PER_LOAD = 2;
203
+
204
+ /**
205
+ * The prefix of a still-arriving answer that is safe to render as markdown.
206
+ *
207
+ * Four cuts, each taking the earliest position that could still change meaning:
208
+ * 1. an UNCLOSED ``` fence (odd number of markers) - everything from its opener;
209
+ * 2. an UNCLOSED inline link on the last line - `[label` with no `]`, or
210
+ * `[label](url` with no `)`, from its `[`;
211
+ * 3. a trailing bare url or `src::` token, from its first character, because a
212
+ * link is minted from whatever is there and a growing url means a chip whose
213
+ * href changes on every paint;
214
+ * 4. an unclosed inline-code span on the last line (odd backtick count).
215
+ *
216
+ * Deliberately NOT covered: emphasis markers, half-written table rows and list
217
+ * bullets. Those degrade to a flicker of STYLING, which self-corrects on the next
218
+ * paint; the four above degrade to a wrong link, a wrong chip, or prose shown where
219
+ * a fence was meant, none of which the reader can tell from the real thing.
220
+ */
221
+ export function liveSafePrefix(text: string): string {
222
+ if (!text) return '';
223
+ var cut = text.length;
224
+
225
+ // 1. Fence markers come in pairs. An odd count means the last one opened a block
226
+ // that has not closed, and a file fence in particular is the download-chip
227
+ // syntax: shown half-arrived it is three backticks and a filename in prose.
228
+ var fenceAt = -1, fences = 0, from = 0, hit;
229
+ for (;;) {
230
+ hit = text.indexOf('```', from);
231
+ if (hit === -1) break;
232
+ fences++; fenceAt = hit; from = hit + 3;
233
+ }
234
+ if (fences % 2 === 1 && fenceAt !== -1) cut = fenceAt;
235
+
236
+ // Rules 2 to 4 look at the last line of what SURVIVED rule 1: an inline link and
237
+ // an inline-code span cannot span a newline (createInlineLinkRegex's label class
238
+ // excludes it), so the last line is the whole of what can still be growing.
239
+ var head = text.slice(0, cut);
240
+ var lineStart = head.lastIndexOf('\n') + 1;
241
+ var line = head.slice(lineStart);
242
+
243
+ // 2. An inline link whose url has not arrived. Windowed (see the constant): a
244
+ // bracket far behind the write head belongs to prose, not to a pending link.
245
+ var open = line.lastIndexOf('[');
246
+ if (open !== -1 && (line.length - open) <= LIVE_PENDING_LINK_WINDOW) {
247
+ var rest = line.slice(open);
248
+ var close = rest.indexOf(']');
249
+ if (close === -1) {
250
+ cut = lineStart + open;
251
+ } else if (rest.charAt(close + 1) === '(' && rest.indexOf(')', close + 1) === -1) {
252
+ cut = lineStart + open;
253
+ }
254
+ }
255
+
256
+ // 3. A trailing bare url / src:: token. Only once it is RECOGNISABLE as one: the
257
+ // few characters of a half-written scheme ("https:/") render as plain text and
258
+ // linkify nothing, and the monotonic guard in the painter means the reveal
259
+ // simply stops there until the whole url has landed rather than retreating.
260
+ var tokStart = line.length;
261
+ while (tokStart > 0 && !/\s/.test(line.charAt(tokStart - 1))) tokStart--;
262
+ var tok = line.slice(tokStart);
263
+ if (tok && /^(?:https?:\/\/|src::)/i.test(tok)) {
264
+ var tokCut = lineStart + tokStart;
265
+ if (tokCut < cut) cut = tokCut;
266
+ }
267
+
268
+ // 4. An unclosed inline-code span. Skipped on a line carrying a fence marker,
269
+ // where the backticks are rule 1's business: a CLOSING fence line ("```") has
270
+ // an odd count of its own, and cutting there would hide the very marker that
271
+ // closes the block and leave an open fence on screen.
272
+ if (line.indexOf('```') === -1) {
273
+ var ticks = 0, lastTick = -1;
274
+ for (var i = 0; i < line.length; i++) {
275
+ if (line.charAt(i) === '`') { ticks++; lastTick = i; }
276
+ }
277
+ if (ticks % 2 === 1 && lastTick !== -1) {
278
+ var tickCut = lineStart + lastTick;
279
+ if (tickCut < cut) cut = tickCut;
280
+ }
281
+ }
282
+
283
+ if (cut >= text.length) return text;
284
+ if (cut < 0) cut = 0;
285
+ return text.slice(0, cut);
286
+ }
287
+
288
+ /** Length of the shared leading run of two strings, never splitting a surrogate
289
+ * pair: a resume index landing between the halves of an astral character would
290
+ * paint a lone surrogate, which renders as a replacement glyph. */
291
+ function commonPrefixLength(a: string, b: string): number {
292
+ var n = Math.min(a.length, b.length), i = 0;
293
+ while (i < n && a.charCodeAt(i) === b.charCodeAt(i)) i++;
294
+ if (i > 0) {
295
+ var prev = a.charCodeAt(i - 1);
296
+ if (prev >= 0xd800 && prev <= 0xdbff) i--;
297
+ }
298
+ return i;
299
+ }
300
+
301
+ /**
302
+ * Where the typewriter should START revealing `fullText`, given what a live stream
303
+ * has already painted into the bubble.
304
+ *
305
+ * The point is that the settle must not replay an answer the reader has already
306
+ * watched arrive: the authoritative text REPLACES the live text (it is the only
307
+ * source of truth), but the characters the two agree on are already on screen and
308
+ * retyping them from zero is the one thing that would make streaming look worse
309
+ * than not streaming.
310
+ *
311
+ * `regions` are the typewriter's own atomic regions. A resume index landing inside
312
+ * one is pushed FORWARD to its end rather than back to its start: forward reveals
313
+ * the link or fence whole, which is the policy those regions exist to enforce, and
314
+ * backward would make the bubble shrink at the exact moment the answer settles.
315
+ *
316
+ * LEADING WHITESPACE IS NORMALISED FIRST, and that is not a nicety. The two strings
317
+ * come from two places that disagree about it by design: the painter writes the
318
+ * parser's `text` UNTRIMMED (currentText says why: trimming a render feed would
319
+ * remove a leading newline and then hand it back when the next delta lands), while
320
+ * every settle path trims, exactly as it trims a buffered answer. And the extractor
321
+ * joins text blocks with '\n', so a model that opens an empty text block before its
322
+ * first tool call, which Claude routinely does, produces a painted answer starting
323
+ * with a newline the authoritative one does not have. Compared raw, the two agree on
324
+ * NOTHING (their first characters differ), the resume index is 0, and the reader
325
+ * watches the entire answer they just read be retyped from zero. Which is the one
326
+ * thing streaming was supposed to stop happening.
327
+ */
328
+ export function typewriterResumeIndex(
329
+ painted: string,
330
+ fullText: string,
331
+ regions: Array<{ start: number; end: number }>,
332
+ ): number {
333
+ if (!painted || !fullText) return 0;
334
+ // Only when the authoritative text has none of its own: that is the case where
335
+ // the difference can only be the trim, so dropping it aligns the two. If
336
+ // fullText DOES start with whitespace (a caller that passes untrimmed text) the
337
+ // two are already in the same frame and cutting painted would misalign them.
338
+ if (/^\s/.test(painted) && !/^\s/.test(fullText)) {
339
+ painted = painted.replace(/^\s+/, '');
340
+ if (!painted) return 0;
341
+ }
342
+ var i = commonPrefixLength(painted, fullText);
343
+ if (i <= 0) return 0;
344
+ if (i >= fullText.length) return fullText.length;
345
+ for (var changed = true; changed;) {
346
+ changed = false;
347
+ for (var k = 0; k < regions.length; k++) {
348
+ var r = regions[k];
349
+ if (i > r.start && i < r.end) { i = r.end; changed = true; }
350
+ }
351
+ }
352
+ return i > fullText.length ? fullText.length : i;
353
+ }
354
+
355
+ /**
356
+ * THE KEEP POLICY, in one place, for every path that can reach csr-finalize.
357
+ *
358
+ * WHY IT IS A FUNCTION AND NOT A LINE IN EACH CALLER. Finalizing does two things in
359
+ * one call: it stores what you hand it as the row's permanent answer, and it
360
+ * DELETES the chunks it was assembled from. Chunks are the only copy of a streamed
361
+ * answer until that call, and there is no way to release them without also storing
362
+ * something, so "may this be kept?" is the single decision that separates a
363
+ * recoverable turn from a permanently truncated one. It was answered in two places
364
+ * that then disagreed: the live settle refused to finalize a failed or cancelled
365
+ * turn (its partial text is the only copy there is, and both ways of releasing it
366
+ * cost something real), while the recovery path computed the same question from
367
+ * parse completeness ALONE - so recovering a failed row finalized it and released
368
+ * exactly the chunks the live policy exists to keep. Two halves of one fix, pulling
369
+ * opposite ways. One predicate, consulted by both, is the fix for that.
370
+ *
371
+ * The three terms, and what each of them is protecting:
372
+ *
373
+ * THE ROW'S OWN STATUS wins over anything the bytes say. 'failed' means the
374
+ * destination's account of the turn is the error, not the text that arrived
375
+ * before it; 'cancelled' means the user's Stop said to discard the half answer,
376
+ * so writing it into history as the kept version resurrects exactly what the stop
377
+ * was for; 'stopped' is a poll that was ended, which says nothing about the turn
378
+ * at all. Pass undefined when the status is genuinely not known (the caller is
379
+ * looking only at bytes); pass the status whenever there is one, because a caller
380
+ * that omits a status it HAS is asking the wrong question.
381
+ *
382
+ * `errored` covers the same refusal expressed by the bytes rather than by the
383
+ * row: an `error` frame, a response.failed, a terminal Response with an error
384
+ * payload. See sse.ts's answerComplete for why a terminal event is not the same
385
+ * claim as a finished answer.
386
+ *
387
+ * `answerComplete` (NOT `complete`) is the completeness half. A degraded chunk
388
+ * read - the poller degrades to "no chunks this tick, more=true" on any transient
389
+ * chunk-table error, and caps one read at 500k characters - hands a settle a
390
+ * stream that stopped mid-answer while the ROW settles 'resolved' on top of it,
391
+ * because the row's status describes the destination's request and not our read
392
+ * of it. Anything short of a finished answer leaves the chunks exactly where they
393
+ * are, which is what they are for: the turn stays re-readable through
394
+ * clientSecretRequestStream and a later load recovers it in full.
395
+ *
396
+ * `unframed` is the one exception to needing a terminal event, and it is not a
397
+ * loophole: bytes that were never SSE carry no events at all and none is ever
398
+ * coming, so there it IS the row's status that says the response finished - which
399
+ * is why this is only ever reached with a 'resolved' row or with no status to
400
+ * contradict it.
401
+ *
402
+ * Exported so the two clients cannot answer it a third way.
403
+ */
404
+ export function mayKeepStreamedAnswer(snap: any, rowStatus?: string | null): boolean {
405
+ // An absent status is "not known", not "not resolved": a caller reading bytes
406
+ // alone has no row to consult. A status that IS present must be the resolved one.
407
+ if (rowStatus !== undefined && rowStatus !== null && rowStatus !== '' && rowStatus !== 'resolved') return false;
408
+ if (!snap || typeof snap !== 'object') return false;
409
+ if (snap.errored) return false;
410
+ if (snap.answerComplete) return true;
411
+ if (snap.unframed) return true;
412
+ return false;
413
+ }
414
+
415
+ /**
416
+ * WHAT A VIEW SHOULD DRAW FOR A TURN WHOSE ANSWER IS STILL IN THE CHUNK STORE.
417
+ *
418
+ * One predicate, on the barrel, because the alternative is each client deciding
419
+ * for itself when a spinner is honest - and the two clients have forked on
420
+ * smaller things than this. Returns:
421
+ *
422
+ * '' not this state at all. Either the bubble is not marked, or it HAS
423
+ * content (the merge adopted a local answer onto it, or a recovery
424
+ * wrote a truncated one in), in which case there is text to render
425
+ * and the recovery, if any, is a background correction the reader
426
+ * does not need to be told about.
427
+ * 'active' a chunk read is in flight or queued. Draw the loader: this is the
428
+ * only phase in which something really is coming.
429
+ * 'failed' the last read failed. Draw the failure and an ask-again control.
430
+ * 'idle' marked, and nothing is fetching it. Draw an ask-for-it control.
431
+ *
432
+ * THE FAILURE THIS EXISTS TO STOP. Recovery is capped at STREAM_RECOVERY_PER_LOAD
433
+ * per history load, so on a page holding several unfinalized turns the third and
434
+ * later ones are marked and queued for nobody; a failed read likewise leaves the
435
+ * marker on deliberately (it is the only thing keeping the answer reachable) with
436
+ * no attempt behind it. Both used to take the same branch as a live pending turn,
437
+ * so those bubbles spun forever with nothing driving them and no way for the
438
+ * reader to resolve them - while the answer sat in the chunk table the whole time,
439
+ * one recoverStreamedAnswer() call away.
440
+ *
441
+ * A bubble with no `_serverItemId` returns '' on purpose: there is no id to hand
442
+ * recoverStreamedAnswer, so an affordance would be a button that cannot work.
443
+ * Unreachable today (the mapper only ever marks a row it has an id for), stated so
444
+ * that it stays unreachable rather than becoming a dead control.
445
+ */
446
+ export function streamRecoveryPhase(msg: any): '' | 'active' | 'failed' | 'idle' {
447
+ if (!msg || !msg._streamPending || msg.content || !msg._serverItemId) return '';
448
+ if (msg._streamRecovery === 'active') return 'active';
449
+ if (msg._streamRecovery === 'failed') return 'failed';
450
+ return 'idle';
451
+ }
452
+
453
+ /**
454
+ * The words for the two phases a reader has to act on. Here rather than in each
455
+ * client for the same reason as the phase itself: two clients wording the same
456
+ * state differently is how one of them ends up saying something untrue.
457
+ *
458
+ * Neither string claims the answer is lost. It is not: the row is unfinalized, so
459
+ * the chunks are retained until somebody finalizes them, and that is exactly why
460
+ * asking again is worth offering.
461
+ */
462
+ export function streamRecoveryLabels(phase: string): { note: string; action: string } {
463
+ if (phase === 'failed') {
464
+ return { note: 'Could not load this answer.', action: 'Try again' };
465
+ }
466
+ return { note: 'This answer was not saved with the conversation.', action: 'Load answer' };
467
+ }
468
+
469
+ /* isCsrStatusEnvelope - the predicate that tells a STREAMED turn apart at settle,
470
+ * because a streamed row stores nothing and hands back { id, status, in_queue, ... }
471
+ * instead - now lives in errors.ts and is imported above.
472
+ *
473
+ * It moved because it was written twice: once here for the settle, and (implicitly,
474
+ * one level too shallow) in the error readers. A streamed FAILURE is that same
475
+ * envelope with the provider's error nested inside it, so the two had to agree on
476
+ * what an envelope is before the error readers could see through one. See
477
+ * csrEnvelopeError. */
478
+
479
+ /** Floor on the gap between two live paints of the same bubble. The transport
480
+ * already paces the answer at about one write per second, so this is not the
481
+ * rhythm - it is what stops a BURST (a poll tick carrying a dozen chunks, or a
482
+ * re-attach replaying the whole answer at once) from turning into a dozen
483
+ * re-renders of the same bubble in the same frame. */
484
+ const LIVE_PAINT_MIN_MS = 250;
485
+ /** Largest growth, in characters, that a single live paint will ANIMATE rather than
486
+ * land whole. The transport paces at about one chunk a second and a model writes
487
+ * roughly 160 characters in that time, so an ordinary arrival is far under this and
488
+ * types. A replay (reopening a chat, recovering an unfinalized turn) delivers the
489
+ * whole answer in one feed and is far over it, so it lands at once instead of
490
+ * retyping a finished message at the reader. */
491
+ const LIVE_TYPE_MAX_STEP = 1200;
492
+
493
+ /**
494
+ * The identity a streamed turn was DISPATCHED under, pinned by the caller.
495
+ *
496
+ * Same reason _callProviderFor takes projectId/owner explicitly: a turn can be
497
+ * acked after the user has moved to another project or platform, and a live
498
+ * getIdentity() read at that moment describes where the user is now, not where the
499
+ * turn came from. Every field optional so a caller can pin what it knows and let
500
+ * the rest fall back to the live read.
501
+ */
502
+ export type StreamDispatchContext = {
503
+ platform?: string;
504
+ projectId?: string;
505
+ owner?: string;
506
+ /** History cache key (chatCacheKey) of the chat the turn belongs to. */
507
+ ownerKey?: string;
508
+ };
509
+
510
+ /** One in-flight streamed turn. Keyed by server item id in ChatSession.liveStreams. */
511
+ type LiveStreamState = {
512
+ /** Server item id: the turn's identity everywhere, including on its bubbles. */
513
+ id: string;
514
+ /** History cache key the turn belongs to. Painting is skipped while another
515
+ * chat is on screen; parsing and finalizing are not. */
516
+ ownerKey: string;
517
+ platform: 'claude' | 'openai';
518
+ projectId: string;
519
+ owner: string;
520
+ parser: SseParser;
521
+ /** What is currently on screen, so a paint can be skipped when the safe prefix
522
+ * has not grown (see the monotonic guard in _paintLiveStream). */
523
+ painted: string;
524
+ /** A first paint has happened, so the one notify() has been spent. */
525
+ started: boolean;
526
+ /** At least one chunk arrived. False means the row never streamed, so there is
527
+ * nothing to assemble and nothing to finalize. */
528
+ fed: boolean;
529
+ ended: boolean;
530
+ timer: any;
531
+ lastPaintAt: number;
532
+ /** The assembled provider body, built once at end(). */
533
+ finalBody: any;
534
+ /** csr-finalize has been fired for this turn. Guards the double settle
535
+ * (onResponse and the promise) from storing the same body twice. */
536
+ finalized?: boolean;
537
+ /** How many chunks each of skapi's two transports carried FIRST. Counted, never
538
+ * acted on: both deliver into the same sink by design, so this is the only way
539
+ * a host can tell a websocket-delivered answer from a polled one. */
540
+ transport: { socket: number; poll: number };
541
+ };
542
+
160
543
  export class ChatSession {
161
544
  host: ChatHost;
162
545
  state: ChatState;
@@ -625,12 +1008,77 @@ export class ChatSession {
625
1008
  * and they are the ones bounded by MAX_CONCURRENT_BG_POLLS, so adding probes there would spend
626
1009
  * the request budget the cap exists to protect.
627
1010
  */
628
- attachForegroundPoll(source: any, itemId: string, opts?: any): any {
629
- return this._fgPollWithEarlyProbe(source, itemId, opts);
1011
+ attachForegroundPoll(source: any, itemId: string, opts?: any, ctx?: StreamDispatchContext): any {
1012
+ return this._fgPollWithEarlyProbe(source, itemId, opts, ctx);
630
1013
  }
631
1014
 
632
- private _fgPollWithEarlyProbe(source: any, itemId: string, opts?: any): any {
1015
+ private _fgPollWithEarlyProbe(source: any, itemId: string, opts?: any, ctx?: StreamDispatchContext): any {
633
1016
  var self = this;
1017
+
1018
+ // LIVE STREAMING takes over the whole method when it is on, rather than adding
1019
+ // a branch to the probe race, and both halves of that are deliberate.
1020
+ //
1021
+ // It attaches a sink to EVERY foreground poll, not only the one a fresh
1022
+ // dispatch makes: skapi's reader sends `since: 0` on its first tick, so a poll
1023
+ // re-attached after a reload or a tab return REPLAYS the turn from its first
1024
+ // byte. That is the only recovery a streamed turn has, because its row holds a
1025
+ // status and no body until csr-finalize stores one - attaching without a sink
1026
+ // settles it on that envelope and the answer is gone for good. A row that never
1027
+ // streamed simply hands back no chunks, and the substitution below is then a
1028
+ // no-op, so the cost of asking is one unused cursor.
1029
+ //
1030
+ // And it SKIPS the early-probe ladder, because a point lookup cannot answer for
1031
+ // a streamed row (there is no stored body to look up) yet would settle the race
1032
+ // on that empty envelope and stop the poll - killing the delivery of the very
1033
+ // answer it was trying to fetch early. What replaces it is the cadence: a
1034
+ // streaming poll's tick IS the delivery, so it runs at STREAM_POLL_INTERVAL.
1035
+ // A foreground row that turns out not to have streamed loses the 400ms probe
1036
+ // and gets a 1s interval instead of a 3s one, which is the trade.
1037
+ var live = this._beginLiveStream(itemId, ctx);
1038
+ if (live) {
1039
+ var inner = opts || {};
1040
+ var callerResponse = typeof inner.onResponse === 'function' ? inner.onResponse : null;
1041
+ var callerError = typeof inner.onError === 'function' ? inner.onError : null;
1042
+ // Both wrappers are needed and neither is redundant: a poll attached to a
1043
+ // HISTORY ITEM honours the onResponse passed here (that is how the re-attach
1044
+ // path resolves a turn), while a poll attached to a fresh DISPATCH ack takes
1045
+ // its callbacks from the original clientSecretRequest and reports only
1046
+ // through the promise. Substituting in one place would leave the other
1047
+ // reading the envelope.
1048
+ var streamOpts = Object.assign({}, inner, {
1049
+ onStream: function (chunk: string, _seq: number, via?: 'socket' | 'poll') {
1050
+ self._feedLiveStream(live, chunk, via);
1051
+ },
1052
+ onResponse: function (res: any) {
1053
+ var effective = res;
1054
+ if (isPollStopped(res)) self._closeLiveStream(live, false);
1055
+ else effective = self._settleLiveStream(live, res);
1056
+ if (callerResponse) callerResponse(effective);
1057
+ },
1058
+ onError: function (err: any) {
1059
+ self._closeLiveStream(live, false);
1060
+ if (callerError) callerError(err);
1061
+ },
1062
+ });
1063
+ var lp = source.poll(Object.assign({ latency: STREAM_POLL_INTERVAL }, streamOpts));
1064
+ var stopLp = lp && typeof lp.stop === 'function' ? lp.stop.bind(lp) : null;
1065
+ var wrapped: any = Promise.resolve(lp).then(function (res: any) {
1066
+ // A stop is not a result: the turn may still be running server side, so
1067
+ // the stream is discarded rather than assembled and finalized.
1068
+ if (isPollStopped(res)) { self._closeLiveStream(live, false); return res; }
1069
+ return self._settleLiveStream(live, res);
1070
+ }, function (err: any) {
1071
+ self._closeLiveStream(live, false);
1072
+ throw err;
1073
+ });
1074
+ // _trackPoll and the cancel path both reach for .stop, so the wrapper carries it.
1075
+ wrapped.stop = function () {
1076
+ self._closeLiveStream(live, false);
1077
+ if (stopLp) stopLp();
1078
+ };
1079
+ return wrapped;
1080
+ }
1081
+
634
1082
  // Callbacks ride along on the interval path exactly as before; the probe path below
635
1083
  // fires onResponse itself, because a caller that only reacts through the callback
636
1084
  // (the history drain does) would otherwise never learn the probe won.
@@ -717,6 +1165,975 @@ export class ChatSession {
717
1165
  return n;
718
1166
  }
719
1167
 
1168
+ // --- live streaming ----------------------------------------------------
1169
+ //
1170
+ // A streamed turn's answer NEVER reaches the polling row: the relay appends the
1171
+ // destination's raw bytes to a chunk table and the row settles with a status and
1172
+ // nothing else. So for a streamed turn this parser is not a nicety that makes the
1173
+ // wait prettier, it is the only place the answer exists until csr-finalize stores
1174
+ // one. Three things follow, and all three are load-bearing:
1175
+ //
1176
+ // 1. EVERY foreground poll gets a sink while streaming is on, not just the one
1177
+ // the dispatch attaches. A tab return, a reload, a resumePolling all
1178
+ // re-attach a poll to a still-running item, and skapi's reader sends
1179
+ // `since: 0` on its first tick, so a fresh sink REPLAYS the whole stream from
1180
+ // the beginning. Attaching without one settles that turn on an envelope and
1181
+ // the user's answer is gone.
1182
+ // 2. The parser is keyed by SERVER ITEM ID, and so is the bubble it paints into.
1183
+ // A history refetch replaces the local pending bubble with the server's copy
1184
+ // of the same turn; that copy carries the same _serverItemId, so the next
1185
+ // paint finds it and carries on. Nothing has to be rescued and nothing can be
1186
+ // painted twice.
1187
+ // 3. The stream is never the source of truth. At settle the parser's ASSEMBLED
1188
+ // body (byte equivalent to what a buffered call returns) goes through the
1189
+ // same extractClaudeText / extractOpenAIText the buffered path uses, and a
1190
+ // row that does hold a stored body wins outright.
1191
+ //
1192
+ // Background polls never get a sink, and that is safe because nothing on the bg
1193
+ // queue ever streams: an indexing pass must not (the worker READS its reply), and
1194
+ // a chat turn sent with attachments is deliberately left buffered for exactly the
1195
+ // reason point 1 gives, since the re-attach loop would poll it as a background
1196
+ // item and hand it no reader. See chatStreamWiring. A sink there would also spend
1197
+ // the request budget MAX_CONCURRENT_BG_POLLS exists to protect.
1198
+
1199
+ /** Live streams by server item id. One per in-flight streamed turn. */
1200
+ private liveStreams: { [itemId: string]: LiveStreamState } = {};
1201
+
1202
+ /**
1203
+ * Open (or re-open) the live stream for `itemId`, or null when this poll must
1204
+ * not carry one.
1205
+ *
1206
+ * Re-entrant on purpose: an auth-refresh retry re-dispatches the SAME turn under
1207
+ * a NEW id, and a re-attach after a tab return replays an existing id from seq 0.
1208
+ * Either way the bytes about to arrive are a whole stream, so an existing entry
1209
+ * is discarded and a fresh parser takes its place - feeding a replay into the old
1210
+ * parser would concatenate the answer with itself.
1211
+ *
1212
+ * `ctx` IS THE TURN'S OWN IDENTITY, and every caller that has one passes it.
1213
+ * This used to read the LIVE getIdentity(), which is a bug of exactly the kind
1214
+ * _callProviderFor documents and threads its own parameters to avoid: the user
1215
+ * hits Send, then switches project or platform inside the ack round trip, and the
1216
+ * stream that opens for the OLD turn is stamped with the NEW identity. What that
1217
+ * costs is not cosmetic - `platform` picks which url csr-finalize is addressed
1218
+ * with and which extractor reads the assembled body, `projectId`/`owner` scope
1219
+ * the finalize itself, and `ownerKey` decides which chat the answer is painted
1220
+ * into. Get them from the live read at the wrong moment and the turn is finalized
1221
+ * against the wrong service (so its answer is never stored), parsed with the
1222
+ * wrong provider's extractor, or painted into a conversation it does not belong
1223
+ * to. The live read stays only as the fallback for a caller with nothing pinned.
1224
+ */
1225
+ private _beginLiveStream(itemId: string, ctx?: StreamDispatchContext): LiveStreamState | null {
1226
+ if (!liveStreamingEnabled()) return null;
1227
+ if (!itemId) {
1228
+ // A streamed turn with no id is unreadable: the id is what the cursor polls,
1229
+ // what the bubble is found by, and what csr-finalize addresses. skapi stamps
1230
+ // one on every queued ack, so this should be unreachable - and if it is ever
1231
+ // reached the turn's answer is genuinely lost, which is worth a line in the
1232
+ // console rather than a silent empty bubble.
1233
+ console.warn('[chat-engine] live streaming is on but the dispatch reported no item id');
1234
+ return null;
1235
+ }
1236
+ // Pinned values win over the live read, field by field: a caller may know the
1237
+ // platform the turn was sent on without knowing which chat key it belongs to.
1238
+ var pinnedPlatform = ctx && (ctx.platform === 'claude' || ctx.platform === 'openai') ? ctx.platform : undefined;
1239
+ var ident = (pinnedPlatform && ctx && ctx.projectId !== undefined && ctx.owner !== undefined && ctx.ownerKey !== undefined)
1240
+ ? null : this.host.getIdentity();
1241
+ var platform = pinnedPlatform || (ident ? ident.platform : undefined);
1242
+ if (platform !== 'claude' && platform !== 'openai') return null;
1243
+ var projectId = ctx && ctx.projectId !== undefined ? ctx.projectId : (ident ? ident.projectId : '');
1244
+ var owner = ctx && ctx.owner !== undefined ? ctx.owner : (ident ? ident.owner : '');
1245
+ var ownerKey = ctx && ctx.ownerKey !== undefined ? ctx.ownerKey : this.getHistoryCacheKey();
1246
+ var prev = this.liveStreams[itemId];
1247
+ if (prev) this._closeLiveStream(prev, false);
1248
+ var st: LiveStreamState = {
1249
+ id: itemId,
1250
+ ownerKey: ownerKey,
1251
+ platform: platform,
1252
+ projectId: projectId,
1253
+ owner: owner,
1254
+ parser: createSseParser(),
1255
+ painted: '',
1256
+ started: false,
1257
+ fed: false,
1258
+ ended: false,
1259
+ timer: null,
1260
+ lastPaintAt: 0,
1261
+ finalBody: null,
1262
+ transport: { socket: 0, poll: 0 },
1263
+ };
1264
+ this.liveStreams[itemId] = st;
1265
+ return st;
1266
+ }
1267
+
1268
+ /** The chunk sink handed to skapi's poll. Raw relayed text, in order, never parsed
1269
+ * here: the parser owns the grammar and this owns the pacing. */
1270
+ private _feedLiveStream(st: LiveStreamState, chunk: string, via?: 'socket' | 'poll'): void {
1271
+ if (st.ended || typeof chunk !== 'string' || !chunk) return;
1272
+ st.fed = true;
1273
+ // Counted before the early-outs below, so a chunk that only joins an already
1274
+ // scheduled paint still tells the host which transport brought it.
1275
+ if (via === 'socket') st.transport.socket++;
1276
+ else if (via === 'poll') st.transport.poll++;
1277
+ st.parser.feed(chunk);
1278
+ if (st.timer) return; // a paint is already scheduled; this chunk joins it
1279
+ var self = this;
1280
+ // One paint per burst, floored at LIVE_PAINT_MIN_MS. A poll tick can deliver a
1281
+ // dozen chunks at once (and a replay delivers the whole answer at once), and
1282
+ // painting per chunk is exactly the 60-renders-a-second the per-bubble refresh
1283
+ // exists to avoid. The transport already paces at about 1/s, so this floor is
1284
+ // only there to survive a burst, not to set the rhythm.
1285
+ // The FIRST paint is immediate: the floor is there to survive a burst, and
1286
+ // spending it on the opening tokens would delay the one moment the whole
1287
+ // feature is for. (It also cannot be measured from lastPaintAt = 0, since
1288
+ // nowMs() is time since page load and is itself small early on.)
1289
+ var wait = st.lastPaintAt ? Math.max(0, LIVE_PAINT_MIN_MS - (nowMs() - st.lastPaintAt)) : 0;
1290
+ st.timer = setTimeout(function () { st.timer = null; self._paintLiveStream(st); }, wait);
1291
+ }
1292
+
1293
+ /**
1294
+ * Write the safe prefix of the answer so far into the turn's bubble.
1295
+ *
1296
+ * notify() is spent EXACTLY ONCE per turn, on the first paint, because that is a
1297
+ * state change the per-bubble refresh cannot express: the bubble stops being a
1298
+ * "Thinking..." spinner and becomes text. Every paint after it goes through
1299
+ * refreshMessageBubble, which is what keeps a growing answer from rebuilding the
1300
+ * whole display list once a second.
1301
+ */
1302
+ private _paintLiveStream(st: LiveStreamState): void {
1303
+ if (st.ended) return;
1304
+ st.lastPaintAt = nowMs();
1305
+ // Another project (or another platform) is on screen. The turn keeps parsing -
1306
+ // its answer is still being assembled and will still be finalized - but nothing
1307
+ // is painted, because the index would point into a different chat's list.
1308
+ if (this.getHistoryCacheKey() !== st.ownerKey) return;
1309
+ var idx = this._liveTargetIndex(st.id);
1310
+ if (idx === -1) return;
1311
+ var msg = this.state.messages[idx];
1312
+ if (!msg) return;
1313
+ var snap = st.parser.snapshot();
1314
+ var next = liveSafePrefix(snap.text);
1315
+ // MONOTONIC, and this is the guard that makes liveSafePrefix's cuts safe to
1316
+ // make: a cut can shorten the safe prefix (a '[' arrives, a url starts), and
1317
+ // repainting shorter would have the answer visibly retreat. What was painted
1318
+ // was safe when it was painted, so it stays until there is MORE to show.
1319
+ if (next.length <= st.painted.length) return;
1320
+ var prev = st.painted;
1321
+ st.painted = next;
1322
+
1323
+ // TYPE THE NEW TEXT IN, DO NOT JUMP TO IT.
1324
+ //
1325
+ // This used to assign msg.content directly, which is why a streamed answer
1326
+ // arrived as a series of lumps: the transport paces at about one chunk a
1327
+ // second, so a whole second of text appeared at once, and the only animation a
1328
+ // reader saw was the settle typewriting whatever was left. The point of
1329
+ // streaming is that the answer looks like it is being written, so each arrival
1330
+ // is revealed at the same rate the settle uses. enqueueTypewrite is sequential,
1331
+ // so consecutive chunks queue behind one another and read as one continuous
1332
+ // stream rather than racing each other into the same bubble.
1333
+ //
1334
+ // EXCEPT WHEN THE TEXT ARRIVES ALL AT ONCE, which is the replay path: reopening
1335
+ // a chat, or recovering an unfinalized turn, feeds the entire answer in one go.
1336
+ // Animating that would slowly retype an old message every time it is opened,
1337
+ // which is not a stream, it is a delay. So a jump larger than a live burst
1338
+ // could plausibly be lands whole, exactly as it did before.
1339
+ // SIZE DECIDES, NOT WHICH PAINT IT IS. The first paint of a live turn is a small
1340
+ // step from empty and should type, which is the moment the whole feature exists
1341
+ // for. The first paint of a REPLAY is the entire answer from empty and must not:
1342
+ // gating on "is this the first paint" gets both of those wrong, because they are
1343
+ // the same paint. Only the size tells them apart.
1344
+ var grew = next.length - prev.length;
1345
+ var animate = grew > 0 && grew <= LIVE_TYPE_MAX_STEP;
1346
+
1347
+ if (animate) {
1348
+ // IDENTITY, NOT INDEX. typewriteIntoIndex re-finds its bubble by _localId
1349
+ // and falls back to the raw index when there is none, and that fallback is
1350
+ // unsafe here: a live reveal is in flight for seconds, and anything that
1351
+ // REPLACES the bubble meanwhile (a recovery landing the whole answer, a
1352
+ // history page swapping the list) leaves the stale animation to write its
1353
+ // older text into whatever now occupies that index. Observed: a recovered
1354
+ // answer being overwritten, a character at a time, by the truncation it had
1355
+ // just replaced. Minting an id here restores the bail the re-find exists to
1356
+ // provide, so a superseded reveal writes nothing.
1357
+ if (!msg._localId) msg._localId = this._newLocalId();
1358
+ // Left at its previous text and typed forward from there. The bubble is
1359
+ // never blanked: on a first paint prev is empty anyway, and on a later one
1360
+ // blanking would flash the answer away and retype it.
1361
+ if (!msg._streaming) { msg._streaming = true; this.host.notify(); }
1362
+ this.enqueueTypewrite(idx, next, msg._localId, prev);
1363
+ } else {
1364
+ msg.content = next;
1365
+ if (!msg._streaming) { msg._streaming = true; this.host.notify(); }
1366
+ else this.host.refreshMessageBubble(idx);
1367
+ }
1368
+ // ARRIVAL, not a user action: follow only a reader who is still pinned.
1369
+ this.host.scrollToBottomIfSticky();
1370
+ this._reportLiveStream(st, st.started ? 'update' : 'start', snap, next);
1371
+ st.started = true;
1372
+ }
1373
+
1374
+ /** The bubble a live stream paints into: the turn's pending assistant placeholder,
1375
+ * found by server item id. Not by _localId, deliberately - a history refetch
1376
+ * replaces the local copy with the server's, and only the id survives that. */
1377
+ private _liveTargetIndex(itemId: string): number {
1378
+ return this.state.messages.findIndex(function (m) {
1379
+ return !!m && m.role === 'assistant' && !m.isBackgroundTask &&
1380
+ m._serverItemId === itemId && (!!m.isPending || !!m._streaming);
1381
+ });
1382
+ }
1383
+
1384
+ /** Hand the host its optional observation update. Guarded: this runs on the paint
1385
+ * path, and a throwing hook must not cost the user the rest of their answer. */
1386
+ private _reportLiveStream(st: LiveStreamState, phase: 'start' | 'update' | 'end', snap: any, text: string): void {
1387
+ var hook = chatEngineConfig().onLiveStreamUpdate;
1388
+ if (!hook) return;
1389
+ try {
1390
+ hook({
1391
+ serverItemId: st.id, ownerKey: st.ownerKey, phase: phase, text: text,
1392
+ thinkingText: (snap && snap.thinkingText) || '',
1393
+ toolNames: (snap && snap.toolNames) ? snap.toolNames.slice() : [],
1394
+ complete: !!(snap && snap.complete),
1395
+ // Reported alongside `complete`, never instead of it: a host drawing
1396
+ // "still arriving" wants complete, a host drawing "this answer is
1397
+ // partial" wants this one, and an `error` frame is the case where the
1398
+ // two disagree. See sse.ts answerComplete.
1399
+ answerComplete: !!(snap && snap.answerComplete),
1400
+ errored: !!(snap && snap.errored),
1401
+ transport: { socket: st.transport.socket, poll: st.transport.poll },
1402
+ });
1403
+ } catch (e) { console.warn('[chat-engine] onLiveStreamUpdate threw', e); }
1404
+ }
1405
+
1406
+ /** Stop painting and (when the turn really ended) assemble the body. `finished`
1407
+ * is false for a stream being discarded rather than settled: a retry replacing
1408
+ * it, or a stop, neither of which has an answer to assemble. */
1409
+ private _closeLiveStream(st: LiveStreamState, finished: boolean): void {
1410
+ // A settle arrives TWICE (the poll's onResponse, then the promise it resolves),
1411
+ // so everything that must happen once hangs off this rather than off `ended`
1412
+ // being read after it is set.
1413
+ var first = !st.ended;
1414
+ if (st.timer) { clearTimeout(st.timer); st.timer = null; }
1415
+ if (first) {
1416
+ st.ended = true;
1417
+ if (finished && st.fed) {
1418
+ st.parser.end();
1419
+ st.finalBody = st.parser.finalBody();
1420
+ }
1421
+ }
1422
+ if (this.liveStreams[st.id] === st) delete this.liveStreams[st.id];
1423
+ // Told BEFORE the ownerKey check below, and only when a 'start' was reported:
1424
+ // a host that drew a "thinking..." or "querying..." row off this stream has to
1425
+ // learn it is over even when the reader has moved to another chat, and telling
1426
+ // it about an end it never saw begin would be noise.
1427
+ if (first && st.started) this._reportLiveStream(st, 'end', st.parser.snapshot(), '');
1428
+ // The bubble stops being a live one from here. Its painted content is left
1429
+ // alone ON PURPOSE: it is what the typewriter resumes from a moment later, and
1430
+ // blanking it would put the answer the reader just watched arrive back to zero.
1431
+ if (this.getHistoryCacheKey() !== st.ownerKey) return;
1432
+ var idx = this._liveTargetIndex(st.id);
1433
+ if (idx !== -1 && this.state.messages[idx] && this.state.messages[idx]._streaming) {
1434
+ this.state.messages[idx]._streaming = false;
1435
+ }
1436
+ }
1437
+
1438
+ /**
1439
+ * Settle a streamed turn: end the parse, decide the body the rest of the session
1440
+ * will read, and release the chunks.
1441
+ *
1442
+ * The substitution is one-directional and never a merge. A response that is a
1443
+ * real stored body (a buffered turn, or a streamed one somebody already
1444
+ * finalized) is returned untouched, because that is the destination's own answer
1445
+ * and the stream is not entitled to overwrite it. Only a STATUS ENVELOPE - the
1446
+ * shape a streamed row settles as, having stored nothing - is replaced, and then
1447
+ * by the assembled body, which every caller downstream reads with the same
1448
+ * extractor it uses for a buffered reply. Idempotent, because it is reached both
1449
+ * through the poll's onResponse and through the promise it resolves.
1450
+ */
1451
+ private _settleLiveStream(st: LiveStreamState, response: any): any {
1452
+ this._closeLiveStream(st, true);
1453
+ if (!isCsrStatusEnvelope(response)) return response;
1454
+ // RESOLVED only, and this is not caution, it is correctness. 'cancelled' is the
1455
+ // envelope the user's own Stop produces, and _isCancelledPollResult downstream
1456
+ // is what settles the turn as stopped: hand it an assembled body instead and a
1457
+ // cancelled turn renders the half answer the stop was meant to discard. Any
1458
+ // other status ('failed' with the destination's error, or a shape this does not
1459
+ // know) is likewise the authoritative account of the turn, and the stream is
1460
+ // never entitled to overwrite one.
1461
+ if (response.status !== 'resolved') return response;
1462
+ // MARKED BEFORE THE finalBody BAIL, and the order is the whole of the fix for
1463
+ // a settle that read NOTHING. A poll whose chunk reads degraded for its entire
1464
+ // life (the poller returns "no chunks this tick, more=true" on any transient
1465
+ // chunk-table error) settles on a resolved row with nothing fed and nothing
1466
+ // assembled. The turn then renders "No text response received from AI
1467
+ // provider" and, with the note skipped, that sentence is adopted over the
1468
+ // row's own empty copy on the next history load and clears the marker that
1469
+ // would have gone back for the answer: unreachable, with every byte of it
1470
+ // still in the chunk table. A turn that painted nothing is EXACTLY as
1471
+ // recoverable as one that was never polled at all - same row, same chunks,
1472
+ // same reason - so it leaves the same note and gets the same marker back.
1473
+ if (!this._mayFinalize(st)) this._rec().incomplete[st.id] = true;
1474
+ if (st.finalBody == null) return response;
1475
+ // An INCOMPLETE parse is still the best answer there is right now - it is what
1476
+ // the reader has been watching arrive - so it is handed downstream and shown.
1477
+ // What it is not is FINISHED, and the difference has to outlive this call:
1478
+ // without the note, the next history refetch would meet a bubble with text in
1479
+ // it, adopt that text over the row's own empty copy, and clear the very marker
1480
+ // that would have gone back for the rest. So the id is remembered, the chunks
1481
+ // are left alone (see _finalizeStreamedTurn), and the recovery replaces this
1482
+ // text with the whole answer on the next load. The note itself is taken above,
1483
+ // before the bail, because a settle that assembled NOTHING needs it just as
1484
+ // badly and used to return before reaching it.
1485
+ this._finalizeStreamedTurn(st);
1486
+ return st.finalBody;
1487
+ }
1488
+
1489
+ /**
1490
+ * May this parse be STORED as the turn's permanent answer?
1491
+ *
1492
+ * THE FAILURE THIS PREVENTS. Finalizing does two things at once: it stores what
1493
+ * you give it as the row's result, and it DELETES the chunks it was assembled
1494
+ * from. So finalizing a truncated parse is not a cosmetic loss, it is the
1495
+ * permanent one: the truncation becomes the stored answer and the only copy of
1496
+ * the missing part is deleted in the same call. And a truncated parse is a shape
1497
+ * this repo has already paid for - a degraded chunk read (the poller degrades to
1498
+ * "no chunks this tick, more=true" on any transient chunk-table error, and caps
1499
+ * a long answer at 500k characters per response) can hand the settle a stream
1500
+ * that stopped mid-answer. The row can settle 'resolved' on top of that, because
1501
+ * the ROW's status describes the destination's request, not the client's read of
1502
+ * it.
1503
+ *
1504
+ * THE POLICY ITSELF IS mayKeepStreamedAnswer (top of this file), shared with the
1505
+ * recovery path so the two cannot drift apart again - they did, and the drift was
1506
+ * silent: the live settle refused a failed turn while the recovery finalized one.
1507
+ * What is local to this method is only the two things the free function cannot
1508
+ * know: that there is an assembled body at all, and that this call site is
1509
+ * reached only on a row that settled 'resolved' (the caller returns before it
1510
+ * otherwise), which is the status it therefore states.
1511
+ *
1512
+ * The test the policy applies is deliberately NOT `complete`: a terminal event
1513
+ * arrived and the answer finished are two claims, and an `error` frame satisfies
1514
+ * the first while truncating the second. See sse.ts's answerComplete.
1515
+ */
1516
+ private _mayFinalize(st: LiveStreamState): boolean {
1517
+ if (st.finalBody == null) return false;
1518
+ return mayKeepStreamedAnswer(st.parser.snapshot(), 'resolved');
1519
+ }
1520
+
1521
+ /**
1522
+ * Store the assembled body as the version history keeps, which is also what
1523
+ * releases this request's chunks.
1524
+ *
1525
+ * The ASSEMBLED BODY and not the extracted text, because the row is read back by
1526
+ * mapHistoryListToMessages through extractClaudeText / extractOpenAIText: storing
1527
+ * the provider's own document is what makes a streamed turn indistinguishable
1528
+ * from a buffered one on the next load, with no branch anywhere in the mapper.
1529
+ *
1530
+ * BEST EFFORT, and loudly so: the answer is already on screen and already in the
1531
+ * history cache by the time this fires. A failure costs the chunks (they stay,
1532
+ * and the turn stays re-readable) and a row that reads back empty, never the
1533
+ * user's answer in front of them.
1534
+ *
1535
+ * WHAT IS DELIBERATELY NEVER FINALIZED, because finalize is also the only way to
1536
+ * release chunks and it is tempting to reach for it as a cleanup:
1537
+ *
1538
+ * - an INCOMPLETE parse (see _mayFinalize). Storing a truncation makes it
1539
+ * permanent AND deletes the part that was missing from it. A stream killed by
1540
+ * an `error` frame is one of these however terminal it looks: the frame ends
1541
+ * the stream, so `complete` is true, while the text is only what arrived
1542
+ * before the error. That is why the gate reads answerComplete.
1543
+ * - a FAILED turn. Its chunks hold the part of the answer that did arrive,
1544
+ * which is the only copy of that text there is, and the two ways to release
1545
+ * them both cost something real: storing the partial makes a truncated answer
1546
+ * the turn's permanent history AND masks the failure on read (csr-poll hands
1547
+ * back a finalized body before it ever looks at the row's error, so the turn
1548
+ * would read back as a clean short answer), while storing the error throws
1549
+ * the partial away outright. Keeping them costs storage on rows that produced
1550
+ * bytes and then failed, which is rare - a failure before the first byte (a
1551
+ * wrong API key, the common case) has no chunks to keep - and the poller
1552
+ * hands those chunks back alongside the error on every later read, so nothing
1553
+ * is stranded, only retained. Retention is the honest trade here; deletion is
1554
+ * not reversible.
1555
+ * - a CANCELLED turn, for the same reason plus one: the user's Stop means the
1556
+ * half answer is to be discarded, so writing it into history as the kept
1557
+ * version would resurrect exactly what the stop was for.
1558
+ */
1559
+ private _finalizeStreamedTurn(st: LiveStreamState): void {
1560
+ if (st.finalized) return;
1561
+ if (!this._mayFinalize(st)) return;
1562
+ var fin = chatEngineConfig().clientSecretRequestFinalize;
1563
+ if (!fin || st.finalBody == null) return;
1564
+ st.finalized = true;
1565
+ var url = st.platform === 'openai' ? OPENAI_RESPONSES_API_URL : ANTHROPIC_MESSAGES_API_URL;
1566
+ try {
1567
+ Promise.resolve(fin(st.id, st.finalBody, {
1568
+ url: url, method: 'POST', service: st.projectId, owner: st.owner,
1569
+ })).catch(function (err: any) {
1570
+ console.warn('[chat-engine] clientSecretRequestFinalize failed', err);
1571
+ });
1572
+ } catch (e) {
1573
+ console.warn('[chat-engine] clientSecretRequestFinalize threw', e);
1574
+ }
1575
+ }
1576
+
1577
+ /** Painted-but-unsettled live text on a bubble, for the typewriter to resume from.
1578
+ * A pending assistant placeholder is created with content '' by every path that
1579
+ * makes one, so non-empty content on one can only have been painted here. */
1580
+ private _paintedTextAt(idx: number): string {
1581
+ var m = idx >= 0 ? this.state.messages[idx] : undefined;
1582
+ if (!m || m.role !== 'assistant' || typeof m.content !== 'string') return '';
1583
+ return m.content;
1584
+ }
1585
+
1586
+ // --- recovering a streamed turn nobody finalized ------------------------
1587
+ //
1588
+ // ONE QUESTION, TWO BUGS. A streamed row's answer is not on the row: it is in the
1589
+ // chunk store until csr-finalize copies a version onto it. So a history page can
1590
+ // carry a row that is TERMINAL AND EMPTY, and everything downstream has to know
1591
+ // what that means. Read as "this turn answered nothing" it produces two separate
1592
+ // disasters that look unrelated:
1593
+ //
1594
+ // 1. A row that settled while no poll was attached (closed tab, discarded
1595
+ // background tab, slept device) is never finalized, so it is terminal and
1596
+ // empty FOREVER and the mapper emitted no assistant bubble for it. The
1597
+ // answer is gone from the conversation with every byte of it still stored.
1598
+ // 2. A first-page refetch landing between the row going 'resolved' and finalize
1599
+ // storing the body sees the same terminal-and-empty row for a turn that is
1600
+ // on screen right now, and the merge - believing the server - drops the
1601
+ // local bubble holding the answer. Reachable on every single streamed turn,
1602
+ // since the window is a poll interval plus a round trip and a refetch fires
1603
+ // on visibilitychange.
1604
+ //
1605
+ // Both are the same question: what should the merge believe when the server copy
1606
+ // is authoritative but empty? The answer is that such a copy is UNKNOWN, not
1607
+ // empty (mapHistoryListToMessages marks it `_streamPending`), and one rule covers
1608
+ // both cases: AN UNKNOWN ANSWER NEVER OVERWRITES A KNOWN ONE, AND AN UNKNOWN ONE
1609
+ // LEFT OVER IS RESOLVED BY READING THE CHUNKS BACK. Bug 2 falls out of the first
1610
+ // half (_adoptLocalAnswers, below), bug 1 out of the second.
1611
+ //
1612
+ // WHAT THE RECOVERY COSTS, and how that is bounded. A replay is a full read of
1613
+ // one turn's chunks - the SDK pages internally until the store is drained, so it
1614
+ // is one round trip for a short answer and a handful for a long one. A history
1615
+ // page could in principle hold several such rows, so:
1616
+ // * it NEVER blocks the load. It is scheduled after the page has been rendered
1617
+ // and runs on its own, and the bubble it will fill is already on screen.
1618
+ // * it is SERIAL. One replay at a time, so a page holding five of them spends
1619
+ // one connection, not five, and the newest turn (the one the reader is
1620
+ // looking at) is filled first.
1621
+ // * it is CAPPED per load (STREAM_RECOVERY_PER_LOAD), newest first. Older ones
1622
+ // keep their marker and are recovered on a later load, or on demand through
1623
+ // the public recoverStreamedAnswer().
1624
+ // * it is ONCE PER ROW, ever: a successful recovery FINALIZES what it read, so
1625
+ // the row gains a stored body and the next load sees an ordinary turn. Even
1626
+ // when finalizing is impossible (no hook, a failed call), the id is
1627
+ // remembered for the session so a re-render cannot loop on it.
1628
+
1629
+ private _streamRecovery?: {
1630
+ /** Streams whose parse ended without a terminal event, so the text handed
1631
+ * downstream is not known to be the whole answer. See _settleLiveStream. */
1632
+ incomplete: { [id: string]: true };
1633
+ /** Rows this session has already tried to read back, so a repeated history
1634
+ * load (every visibilitychange fires one) cannot re-read the same chunks
1635
+ * forever. Deliberately NOT consulted by a user-driven retry: see the
1636
+ * `manual` argument to _readBackStreamedTurn. */
1637
+ attempted: { [id: string]: true };
1638
+ /** Rows whose read is in flight RIGHT NOW. Separate from `attempted`, which
1639
+ * outlives the request and survives a success: this one is the "something is
1640
+ * driving that bubble" fact the view renders its loader from, so it has to
1641
+ * come off however the read ends. */
1642
+ inflight: { [id: string]: true };
1643
+ /** Rows whose last read FAILED. The marker stays on for these (the answer is
1644
+ * still reachable), so without this the view cannot tell "not tried yet"
1645
+ * from "tried and could not read it" and has to word them the same. */
1646
+ failed: { [id: string]: true };
1647
+ queue: Array<{ id: string; ownerKey: string; platform: 'claude' | 'openai'; projectId: string; owner: string }>;
1648
+ running: boolean;
1649
+ };
1650
+
1651
+ /** The recovery bookkeeping, created on first touch.
1652
+ *
1653
+ * LAZY, not constructor-initialised, and for a concrete reason: ChatSession is
1654
+ * also built with Object.create(ChatSession.prototype) by the engine's own test
1655
+ * harnesses, which drive one method against a hand-built state rather than a
1656
+ * whole session. A field only the constructor creates is undefined there, and
1657
+ * the method that reaches for it throws, turning a test of the settle into a
1658
+ * crash about bookkeeping. */
1659
+ private _rec() {
1660
+ if (!this._streamRecovery) this._streamRecovery = { incomplete: {}, attempted: {}, inflight: {}, failed: {}, queue: [], running: false };
1661
+ return this._streamRecovery;
1662
+ }
1663
+
1664
+ /**
1665
+ * Put this session's fetching state onto the turn's bubble, so a view can tell a
1666
+ * loader that means something from one that means nothing.
1667
+ *
1668
+ * ONLY EVER ONTO A STILL-MARKED BUBBLE. Once `_streamPending` is off the turn has
1669
+ * an answer (or was proven to have none) and this says nothing about it; writing
1670
+ * it there would leave a stale 'active' on a settled bubble forever.
1671
+ *
1672
+ * host.notify() is what redraws the widget, whose renderer is imperative. It is a
1673
+ * no-op in agent.vue, whose state is a Vue reactive() - the property write above
1674
+ * is what redraws there. Both are covered by doing both, and neither is a
1675
+ * substitute for the other.
1676
+ */
1677
+ private _markRecoveryPhase(itemId: string, phase: 'active' | 'failed' | null): void {
1678
+ var changed = false;
1679
+ for (var i = 0; i < this.state.messages.length; i++) {
1680
+ var m: any = this.state.messages[i];
1681
+ if (!m || m.role !== 'assistant' || m._serverItemId !== itemId || !m._streamPending) continue;
1682
+ var next = phase === null ? undefined : phase;
1683
+ if (m._streamRecovery === next) continue;
1684
+ if (next === undefined) delete m._streamRecovery; else m._streamRecovery = next;
1685
+ changed = true;
1686
+ }
1687
+ if (changed) this.host.notify();
1688
+ }
1689
+
1690
+ /**
1691
+ * Let LOCAL answers survive a freshly-mapped page whose copies of them are
1692
+ * authoritative-but-empty. Call with the page BEFORE it replaces or merges into
1693
+ * state.messages; mutates the page's bubbles in place.
1694
+ *
1695
+ * The adoption itself is history.ts's adoptLocalAnswerIntoPage (shared, so the
1696
+ * clients' own mappers cannot fork it). What lives here is the one thing the
1697
+ * pure function cannot know: whether the local text is the WHOLE answer. Text
1698
+ * left by a stream that ended without a terminal event is not, so that bubble
1699
+ * keeps its marker and gets read back even though it has content - otherwise a
1700
+ * truncated answer would adopt itself over the row and never be corrected.
1701
+ */
1702
+ private _adoptLocalAnswers(mapped: ChatMessage[], loadKey?: string): void {
1703
+ if (!mapped || !mapped.length) return;
1704
+ var pendingIncoming: ChatMessage[] = [];
1705
+ for (var i = 0; i < mapped.length; i++) {
1706
+ if (mapped[i] && (mapped[i] as any)._streamPending) pendingIncoming.push(mapped[i]);
1707
+ }
1708
+ if (!pendingIncoming.length) return;
1709
+ var locals: { [key: string]: ChatMessage } = {};
1710
+ for (var j = 0; j < this.state.messages.length; j++) {
1711
+ var lm = this.state.messages[j];
1712
+ if (!lm || lm.role !== 'assistant' || !lm._serverItemId) continue;
1713
+ // The same ownership predicate every other merge step uses, against the
1714
+ // load's SNAPSHOTTED key rather than a live read: a project switch mid-fetch
1715
+ // leaves another chat's transcript in state.messages, and ids are unique per
1716
+ // row, but a bubble stamped for another chat must never cross into this one.
1717
+ if (lm._ownerKey !== undefined && loadKey !== undefined && lm._ownerKey !== loadKey) continue;
1718
+ // FIRST wins: a page can only hold one assistant bubble per turn, and if the
1719
+ // local list somehow holds two the older one is the one the merge would
1720
+ // have kept.
1721
+ if (locals[lm._serverItemId] === undefined) locals[lm._serverItemId] = lm;
1722
+ }
1723
+ for (var k = 0; k < pendingIncoming.length; k++) {
1724
+ var inc = pendingIncoming[k];
1725
+ var id = inc._serverItemId;
1726
+ if (!id) continue;
1727
+ var local = locals[id];
1728
+ if (!local) continue;
1729
+ if (!adoptLocalAnswerIntoPage(inc, local)) continue;
1730
+ // Adopted text that is not known to be complete keeps the marker: the read
1731
+ // back is what turns it into the whole answer.
1732
+ if (this._rec().incomplete[id]) inc._streamPending = true;
1733
+ }
1734
+ // CARRY THIS SESSION'S FETCHING STATE ONTO THE FRESH PAGE. `_streamRecovery`
1735
+ // describes the client, not the turn, so the mapper cannot know it and a
1736
+ // freshly mapped bubble arrives without it - which would render a read that is
1737
+ // in flight right now as "nothing is fetching this, press to load", and then
1738
+ // flip back a second later when the read lands. Every load path runs this
1739
+ // (the engine's loadHistory and agent.vue's fork both call it, on the page,
1740
+ // before it merges), so this is where the page learns what is already running.
1741
+ for (var p = 0; p < pendingIncoming.length; p++) {
1742
+ var pi: any = pendingIncoming[p];
1743
+ if (!pi._streamPending || !pi._serverItemId) continue;
1744
+ var phase = this._recoveryPhaseFor(pi._serverItemId);
1745
+ if (phase === null) delete pi._streamRecovery; else pi._streamRecovery = phase;
1746
+ }
1747
+ }
1748
+
1749
+ /**
1750
+ * This session's fetching state for one turn, from the bookkeeping rather than
1751
+ * from any bubble. A queued entry counts as 'active': it is committed to be read,
1752
+ * serially, and the reader has no way to tell "being read" from "next in line"
1753
+ * apart from the wait.
1754
+ */
1755
+ private _recoveryPhaseFor(itemId: string): 'active' | 'failed' | null {
1756
+ var rec = this._rec();
1757
+ if (rec.inflight[itemId]) return 'active';
1758
+ for (var i = 0; i < rec.queue.length; i++) if (rec.queue[i].id === itemId) return 'active';
1759
+ if (rec.failed[itemId]) return 'failed';
1760
+ return null;
1761
+ }
1762
+
1763
+ /**
1764
+ * PUBLIC DELEGATE, for a client that maps and merges its own history page.
1765
+ *
1766
+ * agent.vue keeps a forked mapper and a forked first-page merge (its mount path
1767
+ * runs them, while resumePolling routes through loadHistory below), so both
1768
+ * paths are live for the SAME row inside one component. Adoption is part of the
1769
+ * merge contract, not an optional extra: without it that fork erases a streamed
1770
+ * answer off the screen on every turn, which is the whole of MAJOR 3.
1771
+ *
1772
+ * Exposed rather than reimplemented because the rule needs the session's own
1773
+ * `incomplete` set, which the pure helper (history.ts adoptLocalAnswerIntoPage)
1774
+ * cannot see. A client that reached for the helper alone would adopt a TRUNCATED
1775
+ * answer over the row and clear the marker that would have gone back for the
1776
+ * rest - a fork that reads as correct and loses text.
1777
+ *
1778
+ * Call it exactly where loadHistory does: on the freshly mapped page, after
1779
+ * applyHydratedBodies and BEFORE the page replaces or merges into state.messages.
1780
+ */
1781
+ adoptLocalAnswers(mapped: ChatMessage[], loadKey?: string): void {
1782
+ this._adoptLocalAnswers(mapped, loadKey);
1783
+ }
1784
+
1785
+ /**
1786
+ * Queue the on-screen turns whose answer is only in the chunk store, newest
1787
+ * first, and start draining. Never blocks and never throws.
1788
+ *
1789
+ * `ownerKey` is the chat the queue entries belong to, snapshotted by the caller:
1790
+ * a recovery that lands after the user has moved on writes into that chat's
1791
+ * cache, never into whatever list is on screen by then.
1792
+ */
1793
+ private _scheduleStreamRecovery(ownerKey: string, platform: 'claude' | 'openai', projectId: string, owner: string): void {
1794
+ if (!streamRecoveryEnabled()) return;
1795
+ var rec = this._rec();
1796
+ var wanted: string[] = [];
1797
+ for (var i = this.state.messages.length - 1; i >= 0; i--) {
1798
+ var m = this.state.messages[i];
1799
+ if (!m || m.role !== 'assistant' || !(m as any)._streamPending || !m._serverItemId) continue;
1800
+ var id = m._serverItemId;
1801
+ if (rec.attempted[id]) continue;
1802
+ // A turn with a live stream attached is being delivered right now; reading
1803
+ // its chunks in parallel would spend a second full read to arrive at the
1804
+ // same answer the poll is already assembling.
1805
+ if (this.liveStreams[id]) continue;
1806
+ if (rec.queue.some(function (e) { return e.id === id; })) continue;
1807
+ wanted.push(id);
1808
+ if (wanted.length >= STREAM_RECOVERY_PER_LOAD) break;
1809
+ }
1810
+ if (!wanted.length) return;
1811
+ for (var w = 0; w < wanted.length; w++) {
1812
+ rec.queue.push({ id: wanted[w], ownerKey: ownerKey, platform: platform, projectId: projectId, owner: owner });
1813
+ // Marked as the queue is built, NOT as each read starts. Everything above
1814
+ // the cap is committed from this moment and its bubble may honestly spin;
1815
+ // everything the cap left behind is marked and has nobody, and its bubble
1816
+ // must say so rather than spin on a promise this scheduler never made.
1817
+ this._markRecoveryPhase(wanted[w], 'active');
1818
+ }
1819
+ this._drainStreamRecovery();
1820
+ }
1821
+
1822
+ /**
1823
+ * PUBLIC DELEGATE, the other half of what a forked history path needs.
1824
+ *
1825
+ * Same reason as adoptLocalAnswers: agent.vue's mount path never calls
1826
+ * loadHistory, so without this its pages would MARK unfinalized streamed turns
1827
+ * and then never read them back - CRITICAL 1 left unfixed on the client's
1828
+ * primary path, with the marker making it look handled.
1829
+ *
1830
+ * Takes the load's SNAPSHOTTED identity rather than reading it live, and that is
1831
+ * the reason this exists instead of the caller looping over recoverStreamedAnswer:
1832
+ * that one reads getIdentity() at call time (right, for an on-demand affordance
1833
+ * the user just clicked), which after a project switch racing the load would
1834
+ * finalize the turn against the project they switched TO. Call it AFTER the page
1835
+ * is rendered and the loading flags are cleared - it must never hold up the
1836
+ * conversation it belongs to.
1837
+ */
1838
+ scheduleStreamRecovery(ownerKey: string, platform: 'claude' | 'openai', projectId: string, owner: string): void {
1839
+ this._scheduleStreamRecovery(ownerKey, platform, projectId, owner);
1840
+ }
1841
+
1842
+ /** Serial drain of the recovery queue. Each entry is one full chunk read. */
1843
+ private _drainStreamRecovery(): void {
1844
+ var rec = this._rec();
1845
+ if (rec.running) return;
1846
+ var next = rec.queue.shift();
1847
+ if (!next) return;
1848
+ rec.running = true;
1849
+ var self = this;
1850
+ this._readBackStreamedTurn(next.id, next.ownerKey, next.platform, next.projectId, next.owner)
1851
+ .catch(function () { /* every failure is already handled inside */ })
1852
+ .then(function () {
1853
+ self._rec().running = false;
1854
+ self._drainStreamRecovery();
1855
+ });
1856
+ }
1857
+
1858
+ /**
1859
+ * Read one unfinalized streamed turn back out of the chunk store and put its
1860
+ * answer where the turn's answer belongs.
1861
+ *
1862
+ * Public because the cap above is deliberately small: a host that wants to offer
1863
+ * "load the rest" on an older recoverable turn calls this with its
1864
+ * `_serverItemId`, and gets the same path the automatic recovery uses. Safe to
1865
+ * call for an id that turns out not to be recoverable, and safe to call twice -
1866
+ * a second call while the first is still in flight is a no-op.
1867
+ *
1868
+ * THIS IS THE USER ASKING, and that is why it passes `manual`. The automatic
1869
+ * recovery refuses a row it has already tried, so that a re-render, or the
1870
+ * history load that every visibilitychange fires, cannot loop on the same
1871
+ * chunks. A click is neither of those: it is one bounded request that a person
1872
+ * asked for, and applying the loop guard to it made the affordance a button that
1873
+ * silently did nothing for exactly the rows most likely to have it - every row
1874
+ * an earlier read touched and could not settle.
1875
+ */
1876
+ recoverStreamedAnswer(itemId: string): Promise<void> {
1877
+ if (!itemId) return Promise.resolve();
1878
+ var id = this.host.getIdentity();
1879
+ var platform = id && id.platform === 'openai' ? 'openai' : 'claude';
1880
+ return this._readBackStreamedTurn(itemId, this.getHistoryCacheKey(), platform as 'claude' | 'openai', id ? id.projectId : '', id ? id.owner : '', true);
1881
+ }
1882
+
1883
+ private _readBackStreamedTurn(itemId: string, ownerKey: string, platform: 'claude' | 'openai', projectId: string, owner: string, manual?: boolean): Promise<void> {
1884
+ var cfg = chatEngineConfig();
1885
+ var read = cfg.clientSecretRequestStream;
1886
+ if (!read || !itemId) return Promise.resolve();
1887
+ // IN FLIGHT is the only refusal a user-driven retry accepts, and it is about
1888
+ // this request rather than about the history of the row: two reads of the same
1889
+ // chunks at once spend a second full read to arrive at the same answer.
1890
+ if (this._rec().inflight[itemId]) return Promise.resolve();
1891
+ if (!manual && this._rec().attempted[itemId]) {
1892
+ // Refused, so nothing is fetching this after all. The scheduler filters
1893
+ // attempted ids out before it queues them, so reaching here means the row
1894
+ // was attempted AFTER being queued and its bubble is already wearing the
1895
+ // 'active' it was promised - which nothing would ever take off again.
1896
+ this._markRecoveryPhase(itemId, null);
1897
+ return Promise.resolve();
1898
+ }
1899
+ // Marked BEFORE the request, not after: a second load firing while this one is
1900
+ // in flight must not start a second read of the same chunks.
1901
+ this._rec().attempted[itemId] = true;
1902
+ this._rec().inflight[itemId] = true;
1903
+ // A retry is not still-failed. Cleared before the request so the bubble stops
1904
+ // offering "try again" the instant it is being tried.
1905
+ delete this._rec().failed[itemId];
1906
+ this._markRecoveryPhase(itemId, 'active');
1907
+ var self = this;
1908
+ var url = platform === 'openai' ? OPENAI_RESPONSES_API_URL : ANTHROPIC_MESSAGES_API_URL;
1909
+ // A parser of its own, fed from seq 0. Deliberately NOT registered in
1910
+ // liveStreams: this turn is over, nothing will paint into it, and a live
1911
+ // stream entry would make _beginLiveStream discard a genuinely live one that
1912
+ // happened to share the id after an auth-refresh retry.
1913
+ var parser = createSseParser();
1914
+ var fed = false;
1915
+ return Promise.resolve(read(itemId, {
1916
+ url: url, method: 'POST', service: projectId, owner: owner, since: 0,
1917
+ onStream: function (chunk: string) {
1918
+ if (typeof chunk !== 'string' || !chunk) return;
1919
+ fed = true;
1920
+ parser.feed(chunk);
1921
+ },
1922
+ })).then(function (res: any) {
1923
+ // A STOPPED READ IS NOT AN ANSWER, and it is not an envelope either, which
1924
+ // is exactly how it used to be mistaken for one. skapi's stop resolves the
1925
+ // read with a frozen { id, status: 'stopped' } - no in_queue, so
1926
+ // isCsrStatusEnvelope says no - and "not an envelope" was taken to mean
1927
+ // "the stored body". That object extracts to no text, so the turn was read
1928
+ // as "there was nothing here", its bubble was DELETED and its marker with
1929
+ // it, and the answer became unreachable because a read was cancelled. A
1930
+ // stop says nothing whatsoever about the turn: the chunks are untouched and
1931
+ // the row is still unfinalized, so the only correct move is to change
1932
+ // nothing and stay recoverable. The attempt is forgotten so the next
1933
+ // history load can try again.
1934
+ if (isPollStopped(res)) {
1935
+ delete self._rec().attempted[itemId];
1936
+ delete self._rec().inflight[itemId];
1937
+ // Back to 'idle', not to 'failed': nothing failed and nothing is coming.
1938
+ // The bubble stops spinning and starts offering to be asked again, which
1939
+ // is the truthful account of a read somebody stopped.
1940
+ self._markRecoveryPhase(itemId, null);
1941
+ return;
1942
+ }
1943
+ // The SDK resolves with a STORED BODY when the row turned out to be
1944
+ // finalized after all (somebody else's tab got there first), and with a
1945
+ // status envelope when it replayed chunks. Both are usable: the stored body
1946
+ // is the answer, the replay's assembled body is the answer.
1947
+ var envelope = isCsrStatusEnvelope(res);
1948
+ var body: any = null;
1949
+ if (res && !envelope) {
1950
+ body = res;
1951
+ } else if (fed) {
1952
+ parser.end();
1953
+ body = parser.finalBody();
1954
+ }
1955
+ var snap = parser.snapshot();
1956
+ // A body that came off the ROW is already stored, so re-storing it would be
1957
+ // a round trip that changes nothing; only an assembled one is new.
1958
+ var fromRow = !!(res && !envelope);
1959
+ // THE SAME KEEP POLICY THE LIVE SETTLE USES, not a second computation of
1960
+ // it. This used to ask parse completeness alone, so a row the live path
1961
+ // would never have finalized - one the destination FAILED, one killed by an
1962
+ // error frame - was finalized here instead, releasing precisely the chunks
1963
+ // that policy exists to keep. The row's own status is handed over with the
1964
+ // bytes: a replay of a failed row comes back as a 'failed' envelope, and
1965
+ // that is the authoritative account of the turn.
1966
+ var rowStatus = envelope && typeof res.status === 'string' ? res.status : undefined;
1967
+ // A DEGRADED READ IS NOT A FACT ABOUT THE TURN. csr-poll answers `more: true`
1968
+ // when a cap, or a transient chunk-table error, stopped the read short of the
1969
+ // end of the partition, which is the throttle shape this repo has already paid
1970
+ // for once. What came back is then a prefix of the answer, or nothing at all,
1971
+ // and neither says anything about what the turn actually contains. Treated as
1972
+ // an answer it is silent data loss twice over: an empty degraded read spliced
1973
+ // the bubble out as "this turn was empty", and a partial one cleared the
1974
+ // marker, so the truncation was adopted as the turn's answer and nothing ever
1975
+ // went back for the rest. `store` is already safe here (an unterminated parse
1976
+ // fails mayKeepStreamedAnswer), so this only has to keep the turn RECOVERABLE.
1977
+ var degraded = !!(envelope && res && res.more === true);
1978
+ var store = !fromRow && !degraded && body != null && mayKeepStreamedAnswer(snap, rowStatus);
1979
+ // The read is over however it settles below. Cleared BEFORE the apply, which
1980
+ // usually replaces the bubble outright: leaving it set would strand 'active'
1981
+ // on the one case the apply cannot touch (the reader has moved to another
1982
+ // chat), and that bubble would spin on its owner's next visit.
1983
+ delete self._rec().inflight[itemId];
1984
+ delete self._rec().failed[itemId];
1985
+ self._markRecoveryPhase(itemId, null);
1986
+ if (degraded) {
1987
+ // Forget the attempt so the next history load queues this row again,
1988
+ // exactly as the read-failed arm below does. Same reason: nothing
1989
+ // conclusive is known, so nothing may be made permanent.
1990
+ delete self._rec().attempted[itemId];
1991
+ }
1992
+ self._applyRecoveredAnswer(itemId, ownerKey, platform, projectId, owner, body, store, degraded);
1993
+ }, function (err: any) {
1994
+ // THE READ ITSELF FAILED, which is a different fact from "I read it and
1995
+ // there was nothing there", and the two must not settle the same way. This
1996
+ // used to clear the marker, which left the bubble empty on screen with
1997
+ // nothing on it for the recovery to find again: the answer was unreachable
1998
+ // for the rest of the session because one request 500'd. Nothing is known,
1999
+ // so nothing is changed - the CHUNKS are untouched, the row is still
2000
+ // unfinalized, the marker stays on and keeps rendering as "being fetched",
2001
+ // and the attempt is forgotten so the next history load queues it again.
2002
+ // (The "there was nothing there" case is _applyRecoveredAnswer's, and it
2003
+ // still drops the bubble, because an empty row really does mean an empty
2004
+ // turn.)
2005
+ //
2006
+ // What DID change is who is fetching it: nobody. Recorded so the view can
2007
+ // say "could not load this answer, try again" instead of drawing the same
2008
+ // loader a live turn draws - a spinner with nothing behind it is a promise
2009
+ // the client cannot keep, and this one used to be kept up for the rest of
2010
+ // the session.
2011
+ console.warn('[chat-engine] could not read back a streamed turn', itemId, err);
2012
+ delete self._rec().attempted[itemId];
2013
+ delete self._rec().inflight[itemId];
2014
+ self._rec().failed[itemId] = true;
2015
+ self._markRecoveryPhase(itemId, 'failed');
2016
+ });
2017
+ }
2018
+
2019
+ /**
2020
+ * Write a recovered answer into the turn's bubble (or into the owning chat's
2021
+ * cache when the reader has moved on), then store it as the version history
2022
+ * keeps.
2023
+ *
2024
+ * FINALIZING IS WHAT MAKES THIS RUN ONCE. It copies the answer onto the row and
2025
+ * releases the chunks, so the next load reads an ordinary turn and no recovery is
2026
+ * scheduled for it ever again, by anyone, in any tab. `store` is the caller's
2027
+ * decision and carries two gates at once: mayKeepStreamedAnswer, the SAME keep
2028
+ * policy the live settle applies (an incomplete, errored or failed read is shown
2029
+ * but never stored, because storing it would make the truncation permanent and
2030
+ * delete the part that was missing), and whether the body is new at all (one
2031
+ * that came off the row is already stored).
2032
+ */
2033
+ private _applyRecoveredAnswer(
2034
+ itemId: string, ownerKey: string, platform: 'claude' | 'openai',
2035
+ projectId: string, owner: string, body: any, store: boolean, degraded?: boolean,
2036
+ ): void {
2037
+ var text = '';
2038
+ var isErr = isErrorResponseBody(body);
2039
+ if (body != null && !isErr) {
2040
+ text = ((platform === 'openai' ? extractOpenAIText(body) : extractClaudeText(body)) || '').trim();
2041
+ }
2042
+ if (!text && !isErr) {
2043
+ if (degraded) {
2044
+ // The read was cut short, so "no text" is a fact about the READ and not
2045
+ // about the turn. Dropping the bubble here would delete an answer whose
2046
+ // every byte is still sitting in the chunk table. Change nothing and stay
2047
+ // recoverable, exactly as a read that threw does.
2048
+ return;
2049
+ }
2050
+ // Nothing there. Either the row never streamed after all, or its chunks are
2051
+ // genuinely empty. Drop the marker and let the bubble go, which leaves the
2052
+ // list looking exactly as it did before any of this existed.
2053
+ this._clearStreamPendingMark(itemId, ownerKey, true);
2054
+ return;
2055
+ }
2056
+ var reply: ChatMessage = isErr
2057
+ ? { role: 'assistant', content: getErrorMessage(body), isError: true, _serverItemId: itemId }
2058
+ : { role: 'assistant', content: text, _serverItemId: itemId };
2059
+ if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
2060
+ // The reader is in another chat. Settle it where it belongs and touch
2061
+ // nothing on screen - state.messages is a different conversation's list.
2062
+ this._applyReplyToCache(ownerKey, reply, itemId);
2063
+ } else {
2064
+ var idx = -1;
2065
+ for (var i = 0; i < this.state.messages.length; i++) {
2066
+ var m = this.state.messages[i];
2067
+ if (m && m.role === 'assistant' && m._serverItemId === itemId) { idx = i; break; }
2068
+ }
2069
+ if (idx === -1) {
2070
+ // The bubble is gone (a clear, a project switch that replaced the list).
2071
+ // The cache is still the right place for it.
2072
+ this._applyReplyToCache(ownerKey, reply, itemId);
2073
+ } else {
2074
+ var prev = this.state.messages[idx];
2075
+ if (prev._ts !== undefined) reply._ts = prev._ts;
2076
+ if (prev._ownerKey !== undefined) reply._ownerKey = prev._ownerKey;
2077
+ this.state.messages[idx] = reply;
2078
+ this.updateHistoryCache();
2079
+ this.host.notify();
2080
+ }
2081
+ }
2082
+ // The marker comes off for an INCOMPLETE read, but NOT for a DEGRADED one, and
2083
+ // the difference is which side the incompleteness is on. A stream that genuinely
2084
+ // died mid-answer has chunks that will be incomplete forever, so leaving the
2085
+ // marker on would re-read the whole thing on every tab return to arrive at the
2086
+ // same partial: what the reader has is what there is. A DEGRADED read is the
2087
+ // other case entirely, the READER stopped early and the rest of the answer is
2088
+ // still in the chunk table, so clearing here would show a truncation, let
2089
+ // _adoptLocalAnswers carry it forward as the turn's answer, and leave nothing
2090
+ // to go back for the missing part.
2091
+ if (!degraded) delete this._rec().incomplete[itemId];
2092
+ if (!store || body == null || isErr) return;
2093
+ var fin = chatEngineConfig().clientSecretRequestFinalize;
2094
+ if (!fin) return;
2095
+ var url = platform === 'openai' ? OPENAI_RESPONSES_API_URL : ANTHROPIC_MESSAGES_API_URL;
2096
+ try {
2097
+ Promise.resolve(fin(itemId, body, { url: url, method: 'POST', service: projectId, owner: owner }))
2098
+ .catch(function (err: any) {
2099
+ // The answer is on screen and in the cache; only the chunks are left
2100
+ // behind, and the row stays re-readable, which is what they are for.
2101
+ console.warn('[chat-engine] finalize of a recovered turn failed', err);
2102
+ });
2103
+ } catch (e) {
2104
+ console.warn('[chat-engine] finalize of a recovered turn threw', e);
2105
+ }
2106
+ }
2107
+
2108
+ /**
2109
+ * Take the "answer is elsewhere" marker off a turn once it is settled one way or
2110
+ * the other. `drop` removes an assistant bubble that turned out to have no answer
2111
+ * at all, which restores exactly the list the mapper used to produce for such a
2112
+ * row (none), rather than leaving a permanently empty bubble behind.
2113
+ *
2114
+ * ONLY EVER CALLED FOR A TURN THAT WAS ACTUALLY READ. The marker is the one thing
2115
+ * that keeps an unrecovered answer reachable, so it comes off only on the strength
2116
+ * of an answer (the recovery wrote one) or of a read that came back empty. A read
2117
+ * that FAILED, or one that was STOPPED, knows neither, and taking the marker off
2118
+ * on either of those is how a bubble ends up empty forever with its answer still
2119
+ * in the chunk table. `drop` is likewise never passed for a bubble that HAS
2120
+ * content: an empty row is an empty turn, a failed read is not.
2121
+ */
2122
+ private _clearStreamPendingMark(itemId: string, ownerKey: string, drop: boolean): void {
2123
+ if (ownerKey && this.getHistoryCacheKey() !== ownerKey) return;
2124
+ var changed = false;
2125
+ for (var i = this.state.messages.length - 1; i >= 0; i--) {
2126
+ var m = this.state.messages[i];
2127
+ if (!m || m.role !== 'assistant' || m._serverItemId !== itemId) continue;
2128
+ if (!(m as any)._streamPending) continue;
2129
+ if (drop && !m.content) { this.state.messages.splice(i, 1); changed = true; continue; }
2130
+ (m as any)._streamPending = false;
2131
+ changed = true;
2132
+ }
2133
+ if (changed) { this.updateHistoryCache(); this.host.notify(); }
2134
+ }
2135
+
2136
+
720
2137
  /**
721
2138
  * Stop and forget one item's poll. Used after a cancel: the row is either gone
722
2139
  * (cancelled while queued) or flagged cancelled (cancelled while running), so
@@ -1107,7 +2524,13 @@ export class ChatSession {
1107
2524
  // so this reports every time, not once.
1108
2525
  if (typeof params.onItemId === 'function') params.onItemId(initial.id);
1109
2526
  }
1110
- var dp = self._fgPollWithEarlyProbe(initial, initial.id);
2527
+ // The turn's OWN identity, not a live read: this dispatch already
2528
+ // pinned projectId/owner (see _callProviderFor) and the ack can land
2529
+ // after the user has moved elsewhere. params.key is that chat's
2530
+ // history cache key, which is what the painter compares against.
2531
+ var dp = self._fgPollWithEarlyProbe(initial, initial.id, undefined, {
2532
+ platform: params.aiPlatform, projectId: params.projectId, owner: params.owner, ownerKey: params.key,
2533
+ });
1111
2534
  if (initial.id) self._trackPoll(initial.id, 'fg', dp);
1112
2535
  return dp;
1113
2536
  }
@@ -1631,7 +3054,13 @@ export class ChatSession {
1631
3054
  if (result && result.poll && (result.status === 'pending' || result.status === 'running')) {
1632
3055
  // Track this queued item's poll so a remount/refetch dedups
1633
3056
  // against it instead of attaching a duplicate history poll.
1634
- var qp = self._fgPollWithEarlyProbe(result, serverId);
3057
+ // Pinned exactly like the request itself (id.projectId/id.owner were
3058
+ // snapshotted before the send, capturedKey before the ack): the user
3059
+ // can switch project inside this round trip, and a live identity read
3060
+ // here would finalize this turn against the project they switched TO.
3061
+ var qp = self._fgPollWithEarlyProbe(result, serverId, undefined, {
3062
+ platform: capturedPlatform, projectId: id.projectId, owner: id.owner, ownerKey: capturedKey,
3063
+ });
1635
3064
  if (serverId) self._trackPoll(serverId, 'fg', qp);
1636
3065
  return qp
1637
3066
  .then(function (res: any) { if (isPollStopped(res)) return; return self.onQueuedSendResponse(capturedComposed, res, capturedPlatform, serverId, capturedKey); })
@@ -1976,8 +3405,19 @@ export class ChatSession {
1976
3405
  answer = (answer || '').trim() || 'No text response received from AI provider.';
1977
3406
  var lid = this._newLocalId();
1978
3407
  if (targetIdx >= 0 && this.state.messages[targetIdx] && this.state.messages[targetIdx].isPending) {
1979
- this.state.messages[targetIdx] = { role: 'assistant', content: '', _localId: lid };
1980
- this.host.notify(); this.enqueueTypewrite(targetIdx, answer, lid);
3408
+ // Same resume as the immediate-send path: keep what the stream painted
3409
+ // and let the typewriter carry on from where the two texts diverge.
3410
+ var qPainted = this._paintedTextAt(targetIdx);
3411
+ // Same identity carry-over as the immediate-send settle above, for the same
3412
+ // reason: a rebuilt bubble with no `_serverItemId` is invisible to
3413
+ // _adoptLocalAnswers, so a refetch in the finalize window adopts the row's
3414
+ // empty answer over what the reader already read.
3415
+ var prevQ: any = this.state.messages[targetIdx] || {};
3416
+ var qSettled: any = { role: 'assistant', content: qPainted, _localId: lid };
3417
+ if (prevQ._serverItemId) qSettled._serverItemId = prevQ._serverItemId;
3418
+ if (prevQ._ownerKey) qSettled._ownerKey = prevQ._ownerKey;
3419
+ this.state.messages[targetIdx] = qSettled;
3420
+ this.host.notify(); this.enqueueTypewrite(targetIdx, answer, lid, qPainted);
1981
3421
  } else if (targetIdx >= 0) {
1982
3422
  this.state.messages.splice(targetIdx, 0, { role: 'assistant', content: '', _localId: lid });
1983
3423
  this.host.notify(); this.enqueueTypewrite(targetIdx, answer, lid);
@@ -2265,7 +3705,14 @@ export class ChatSession {
2265
3705
  // renders self-throttles to what the machine can actually paint.
2266
3706
  // * rAF paces us to the browser's paint cycle and pauses in background
2267
3707
  // tabs, so we never queue work faster than it can be drawn.
2268
- typewriteIntoIndex(idx: number, fullText: string, localId?: string): Promise<void> {
3708
+ //
3709
+ // `paintedText` is what a LIVE STREAM already put in this bubble. The reveal
3710
+ // starts from the point the two texts stop agreeing rather than from zero: the
3711
+ // authoritative answer still replaces the live one character for character (it is
3712
+ // the only source of truth, and this method writes fullText and nothing else), but
3713
+ // retyping a paragraph the reader has just watched arrive is the one thing that
3714
+ // would make a streamed turn look worse than an unstreamed one.
3715
+ typewriteIntoIndex(idx: number, fullText: string, localId?: string, paintedText?: string): Promise<void> {
2269
3716
  var self = this;
2270
3717
  if (!fullText) return Promise.resolve();
2271
3718
 
@@ -2286,7 +3733,10 @@ export class ChatSession {
2286
3733
  regions.sort(function (a, b) { return a.start - b.start; });
2287
3734
 
2288
3735
  this.state.typing = true; this.state.typingAbort = false;
2289
- var i = 0;
3736
+ // Resume point, computed against the SAME regions the reveal below honours, so
3737
+ // a resume landing inside a link or a fence starts at that region's end and the
3738
+ // region is never on screen half-revealed.
3739
+ var i = paintedText ? typewriterResumeIndex(paintedText, fullText, regions) : 0;
2290
3740
  var last = nowMs();
2291
3741
 
2292
3742
  return new Promise<void>(function (resolve) {
@@ -2300,16 +3750,31 @@ export class ChatSession {
2300
3750
  if (done) return;
2301
3751
  done = true;
2302
3752
  cleanup();
2303
- if (!self.state.typingAbort) {
2304
- // Re-find the bubble by localId and, if it's gone, bail WITHOUT
2305
- // writing — never fall back to the numeric idx, which a concurrent
2306
- // array mutation may have repurposed for an unrelated message
2307
- // (mirrors frame()'s currentIdx===-1 bail below).
2308
- var fi = localId ? self.state.messages.findIndex(function (mm) { return mm._localId === localId; }) : idx;
2309
- if (fi !== -1) {
2310
- var t = self.state.messages[fi];
2311
- if (t) { t.content = fullText; self.host.refreshMessageBubble(fi); }
2312
- }
3753
+ // THE AUTHORITATIVE TEXT ALWAYS WINS, ABORT OR NOT. This write used to
3754
+ // be skipped on abort, which is a rule that made sense when the bubble
3755
+ // held a PREFIX of fullText: stopping early left the reader with less
3756
+ // of the right answer, and the list was about to be replaced anyway.
3757
+ // Live streaming broke that premise. The bubble now starts out holding
3758
+ // the text the STREAM painted, which is a different string from the
3759
+ // authoritative one (the parser's untrimmed render feed, cut at a safe
3760
+ // reveal boundary, possibly a truncation of a degraded read), and
3761
+ // skipping the write leaves THAT as the turn's rendered answer. An
3762
+ // abort is a reason to stop animating; it is never a reason to keep the
3763
+ // provisional text over the settled one.
3764
+ //
3765
+ // Re-find the bubble by localId and, if it's gone, bail WITHOUT
3766
+ // writing, and never fall back to the numeric idx, which a concurrent
3767
+ // array mutation may have repurposed for an unrelated message
3768
+ // (mirrors frame()'s currentIdx===-1 bail below). That bail is what
3769
+ // keeps this safe on the two paths that abort: a history page that
3770
+ // REPLACED the list has already dropped this bubble (and any copy of it
3771
+ // the merge adopted carries the same _localId, which is precisely the
3772
+ // bubble that should receive the answer), and a view teardown has no
3773
+ // bubble on screen to disturb.
3774
+ var fi = localId ? self.state.messages.findIndex(function (mm) { return mm._localId === localId; }) : idx;
3775
+ if (fi !== -1) {
3776
+ var t = self.state.messages[fi];
3777
+ if (t) { t.content = fullText; self.host.refreshMessageBubble(fi); }
2313
3778
  }
2314
3779
  self.state.typing = false;
2315
3780
  resolve();
@@ -2360,7 +3825,7 @@ export class ChatSession {
2360
3825
  }
2361
3826
 
2362
3827
  private typewriterQueue: Promise<any> = Promise.resolve();
2363
- enqueueTypewrite(idx: number, fullText: string, localId?: string): Promise<any> {
3828
+ enqueueTypewrite(idx: number, fullText: string, localId?: string, paintedText?: string): Promise<any> {
2364
3829
  var self = this;
2365
3830
  // Stamp the reply bubble's display time as it starts revealing. This is the
2366
3831
  // single chokepoint every typed reply (immediate, queued, and bg-resolution)
@@ -2368,7 +3833,14 @@ export class ChatSession {
2368
3833
  // timestamp; a history reload later replaces it with the server `updated`.
2369
3834
  var target = this.state.messages[idx];
2370
3835
  if (target && target._ts === undefined) target._ts = wallClockNow();
2371
- this.typewriterQueue = this.typewriterQueue.then(function () { return self.typewriteIntoIndex(idx, fullText, localId); });
3836
+ // SELF-HEALING, because this is now reached from the live paint path too. The
3837
+ // queue is a class field, so it is only initialised by the constructor; any
3838
+ // caller holding a ChatSession built another way (the engine tests drive
3839
+ // Object.create(ChatSession.prototype) deliberately, to exercise one method
3840
+ // without a host) previously never touched it, and now does. An uninitialised
3841
+ // queue would throw here and take the whole answer down over bookkeeping.
3842
+ if (!this.typewriterQueue) this.typewriterQueue = Promise.resolve();
3843
+ this.typewriterQueue = this.typewriterQueue.then(function () { return self.typewriteIntoIndex(idx, fullText, localId, paintedText); });
2372
3844
  return this.typewriterQueue;
2373
3845
  }
2374
3846
 
@@ -2398,12 +3870,32 @@ export class ChatSession {
2398
3870
  this.promoteNextQueuedToRunning();
2399
3871
  return Promise.resolve();
2400
3872
  }
3873
+ // Whatever the live stream painted into this bubble stays on screen across the
3874
+ // swap and becomes the typewriter's starting point. Blanking it here would be a
3875
+ // visible reset of an answer the reader already read: the bubble would go empty
3876
+ // and retype itself, and worse, it can sit empty for a while, because
3877
+ // enqueueTypewrite lands BEHIND any typewriter still running.
3878
+ var painted = this._paintedTextAt(pendingIdx);
2401
3879
  var lid = this._newLocalId();
2402
- this.state.messages[pendingIdx] = { role: 'assistant', content: '', isPending: false, _localId: lid };
3880
+ // CARRY THE IDENTITY FIELDS ACROSS THE REBUILD. This replaces the bubble object
3881
+ // wholesale, and a fresh literal has neither `_serverItemId` nor `_ownerKey`. That
3882
+ // is not cosmetic on a streamed turn: `_adoptLocalAnswers` finds the local donor
3883
+ // for an incoming server copy BY `_serverItemId`, and a streamed row settles with
3884
+ // no body of its own, so a first-page refetch landing between the settle and
3885
+ // csr-finalize finds no donor, adopts the server's empty answer over the text the
3886
+ // reader is looking at, and the answer disappears off the screen. That race is the
3887
+ // whole reason adoptLocalAnswerIntoPage exists, and dropping the id here is what
3888
+ // re-opened it. `_ownerKey` goes with it so the settled bubble stays stamped to
3889
+ // this chat instead of waiting for the next history load to restamp it.
3890
+ var prevSettled: any = this.state.messages[pendingIdx] || {};
3891
+ var settled: any = { role: 'assistant', content: painted, isPending: false, _localId: lid };
3892
+ if (prevSettled._serverItemId) settled._serverItemId = prevSettled._serverItemId;
3893
+ if (prevSettled._ownerKey) settled._ownerKey = prevSettled._ownerKey;
3894
+ this.state.messages[pendingIdx] = settled;
2403
3895
  this._removeStrayPendingAssistants();
2404
3896
  this.host.notify();
2405
3897
  this.promoteNextQueuedToRunning();
2406
- return this.enqueueTypewrite(pendingIdx, latest.content, lid);
3898
+ return this.enqueueTypewrite(pendingIdx, latest.content, lid, painted);
2407
3899
  }
2408
3900
 
2409
3901
  // Remove leftover non-background pending ("Thinking…") assistant bubbles: the
@@ -2434,6 +3926,13 @@ export class ChatSession {
2434
3926
  for (var k = this.state.messages.length - 1; k >= 0; k--) {
2435
3927
  var m = this.state.messages[k];
2436
3928
  if (!m || !m.isPending || m.role !== 'assistant' || m.isBackgroundTask) continue;
3929
+ // A bubble with a live stream running into it is never a stray, whatever its
3930
+ // user bubble looks like: deleting it would leave the stream painting into
3931
+ // nothing and throw away the answer already on screen. (A QUEUED turn's user
3932
+ // bubble is still pending while it streams, so _isLiveImmediatePlaceholder
3933
+ // below does not cover this one.) _streaming is cleared at settle, so this
3934
+ // never keeps a bubble the sweep should have taken.
3935
+ if (m._streaming) continue;
2437
3936
  if (this._isLiveImmediatePlaceholder(k)) continue;
2438
3937
  this.state.messages.splice(k, 1);
2439
3938
  }
@@ -2662,9 +4161,13 @@ export class ChatSession {
2662
4161
  this.state.messages[idx] = { role: 'assistant', content: stripMarker(text) || EMPTY_INDEXING_REPLY, isBackgroundTask: true, _serverItemId: itemId, ...(reportedComplete ? { _indexComplete: true } : {}) };
2663
4162
  this.host.notify(); this.updateHistoryCache(); return;
2664
4163
  }
4164
+ // Same live-stream resume as the two send paths: a streamed turn re-attached
4165
+ // after a reload settles HERE, and its bubble is already carrying the text
4166
+ // the reader watched arrive.
4167
+ var hPainted = this._paintedTextAt(idx);
2665
4168
  var lid = this._newLocalId();
2666
- this.state.messages[idx] = { role: 'assistant', content: '', _localId: lid, _serverItemId: itemId };
2667
- this.host.notify(); this.enqueueTypewrite(idx, text, lid);
4169
+ this.state.messages[idx] = { role: 'assistant', content: hPainted, _localId: lid, _serverItemId: itemId };
4170
+ this.host.notify(); this.enqueueTypewrite(idx, text, lid, hPainted);
2668
4171
  this.updateHistoryCache(); return;
2669
4172
  }
2670
4173
  var userIdx = this.state.messages.findIndex(function (m) {
@@ -3509,6 +5012,14 @@ export class ChatSession {
3509
5012
  // fresh stubs, and without this a bubble the user already expanded
3510
5013
  // would silently revert to its 200-char head.
3511
5014
  self.applyHydratedBodies(mapped);
5015
+ // AN UNKNOWN ANSWER NEVER OVERWRITES A KNOWN ONE. Before either merge
5016
+ // below, any page bubble whose row is terminal-but-empty takes the answer
5017
+ // the local list already holds for that turn (and its live state, if the
5018
+ // turn is still running). Without this, a refetch landing in the window
5019
+ // between a streamed row resolving and its finalize storing the body erases
5020
+ // the answer from the screen, on every streamed turn, since a refetch
5021
+ // fires on visibilitychange. See _adoptLocalAnswers.
5022
+ self._adoptLocalAnswers(mapped, loadKey);
3512
5023
 
3513
5024
  // Set when a first-page refresh re-prepended already-loaded older pages,
3514
5025
  // so the cursor reset below knows not to rewind to page 1's boundary.
@@ -3755,6 +5266,13 @@ export class ChatSession {
3755
5266
  self.updateHistoryCache();
3756
5267
  self.host.notify();
3757
5268
 
5269
+ // AND AN UNKNOWN ANSWER LEFT OVER IS RESOLVED BY READING THE CHUNKS BACK.
5270
+ // Fired AFTER the page is rendered and the flags are cleared, never before:
5271
+ // a streamed turn nobody finalized is a turn whose answer is sitting in the
5272
+ // chunk store, and going to fetch it must not hold up the conversation it
5273
+ // belongs to. Bounded and serial: see _scheduleStreamRecovery.
5274
+ self._scheduleStreamRecovery(loadKey, platform, projectId, owner);
5275
+
3758
5276
  // ── deferred indexing-stub batch (first-paint split) ──────────────
3759
5277
  // The conversation is already on screen; when the stub batch lands it
3760
5278
  // is classified/mapped like any page and STRIP-THEN-MERGED: existing
@@ -3953,7 +5471,13 @@ export class ChatSession {
3953
5471
  };
3954
5472
  var pp = isBg
3955
5473
  ? item.poll(Object.assign({ latency: POLL_INTERVAL }, pollOpts))
3956
- : self._fgPollWithEarlyProbe(item, capturedId, pollOpts);
5474
+ // Pinned to the chat this LOAD is for, snapshotted at its start
5475
+ // (loadKey/platform/projectId/owner), never a live read: a load can
5476
+ // still be attaching polls after the user has moved on, and a stream
5477
+ // stamped with where they moved to would paint into that chat.
5478
+ : self._fgPollWithEarlyProbe(item, capturedId, pollOpts, {
5479
+ platform: platform, projectId: projectId, owner: owner, ownerKey: loadKey,
5480
+ });
3957
5481
  // Anything on the BACKGROUND queue is pausable, not just items whose
3958
5482
  // prompt text we recognise as an indexing task. _isBgTask vs
3959
5483
  // _isOnBgQueue is a DISPLAY distinction ("Indexing: file" vs a normal