bunnyquery 1.8.1 → 1.8.3

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.
@@ -28,7 +28,7 @@ import {
28
28
  extractOpenAIText,
29
29
  getChatHistory,
30
30
  POLL_INTERVAL,
31
- BG_INDEXING_QUEUE_SUFFIX,
31
+ bgIndexingQueueName,
32
32
  isBgIndexingQueue,
33
33
  ANTHROPIC_MESSAGES_API_URL,
34
34
  OPENAI_RESPONSES_API_URL,
@@ -42,7 +42,7 @@ import { createInlineLinkRegex } from './links';
42
42
  import { mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText } from './history';
43
43
  import { wallClockNow } from './time';
44
44
  import { parseAttachmentContent } from './attachment_parsers';
45
- import type { ChatHost, ChatState, ChatMessage, PinnedDispatchContext } from './host';
45
+ import type { ChatHost, ChatState, ChatMessage, ChatIdentity, PinnedDispatchContext } from './host';
46
46
  import type { IndexingGroup } from './indexing_groups';
47
47
 
48
48
  function sleep(ms: number): Promise<void> {
@@ -59,6 +59,30 @@ const WORKER_PASS_ADOPT_LIMIT = 20;
59
59
  // that is still alive. Fixed and finite: this must never become a poll.
60
60
  const WORKER_PASS_ADOPT_ATTEMPTS = [0, 2000, 6000];
61
61
 
62
+ // awaitIndexingDrained: how often it re-asks the background-indexing queue, and
63
+ // how many consecutive empty answers it needs before believing the chains are
64
+ // over. More than one for the same reason the adopt ladder above looks twice —
65
+ // pass N+1 is written just after pass N resolves, so a single look lands in that
66
+ // gap and reports an idle queue for a file that is still being read.
67
+ //
68
+ // Two cadences: while work is visibly running there is nothing to be gained by
69
+ // asking often (an indexing pass takes tens of seconds, and a big file can hold
70
+ // the queue for many minutes), but once the queue looks empty the confirming
71
+ // look is all that stands between the user and their answer.
72
+ const INDEXING_DRAIN_BUSY_POLL_MS = 8000;
73
+ const INDEXING_DRAIN_CONFIRM_POLL_MS = 3000;
74
+ const INDEXING_DRAIN_IDLE_LOOKS = 2;
75
+ // Floor on the whole wait. The status index is eventually consistent, so the
76
+ // pass this turn's own upload just enqueued can be missing from the first looks —
77
+ // releasing on those would send the chat ahead of the very files it is asking
78
+ // about. Costs nothing in the normal case, where the queue reports work
79
+ // immediately and the wait is far longer than this anyway.
80
+ const INDEXING_DRAIN_MIN_MS = 8000;
81
+ // Ceiling on that wait. Past it the turn is sent regardless: an answer computed
82
+ // against a partly-indexed file is a poor outcome, but a question that is never
83
+ // asked at all because one chain wedged server-side is a worse one.
84
+ const INDEXING_DRAIN_TIMEOUT_MS = 15 * 60 * 1000;
85
+
62
86
  // requestAnimationFrame / high-res clock, reached through globalThis so the
63
87
  // engine stays DOM-free at the type level (and degrades gracefully in non-DOM
64
88
  // / test environments where these globals are absent).
@@ -107,6 +131,17 @@ export class ChatSession {
107
131
  private _pauseReasons: Set<string>;
108
132
  private _resuming: boolean;
109
133
  private _lidSeq: number;
134
+ private _stageSeq: number;
135
+ /** How many attachment-upload batches are running. uploadingAttachments is a
136
+ * single flag but batches overlap (the composer stays live, so the user can
137
+ * send a second one while the first uploads), and a nested finish must not
138
+ * clear the flag out from under the batch still running. */
139
+ private _uploadBatches: number;
140
+ /** Indexing requests whose ack has not come back yet. Until it does the item
141
+ * is not on the server's queue, so awaitIndexingDrained cannot see it — and
142
+ * would read the gap between "pass N settled" and "pass N+1 accepted" as the
143
+ * file being finished. */
144
+ private _indexDispatchesInFlight: number;
110
145
 
111
146
  constructor(host: ChatHost) {
112
147
  this.host = host;
@@ -133,6 +168,18 @@ export class ChatSession {
133
168
  this._pauseReasons = new Set();
134
169
  this._resuming = false;
135
170
  this._lidSeq = 0;
171
+ this._stageSeq = 0;
172
+ this._uploadBatches = 0;
173
+ this._indexDispatchesInFlight = 0;
174
+ }
175
+
176
+ /** Wrap an indexing-request dispatch so awaitIndexingDrained counts it as
177
+ * live work from the moment it is sent, not from the moment it is acked. */
178
+ trackIndexDispatch<T>(p: Promise<T>): Promise<T> {
179
+ var self = this;
180
+ this._indexDispatchesInFlight += 1;
181
+ var release = function () { self._indexDispatchesInFlight = Math.max(0, self._indexDispatchesInFlight - 1); };
182
+ return p.then(function (v) { release(); return v; }, function (e) { release(); throw e; });
136
183
  }
137
184
 
138
185
  /**
@@ -252,8 +299,15 @@ export class ChatSession {
252
299
  // replay on every later visit to B. Bubbles with no _ownerKey (server
253
300
  // history, bg tasks) are always kept. Single pass: this runs on the
254
301
  // typewriter hot path.
302
+ //
303
+ // Staged bubbles (_stageId) are never cached. What resolves one is an
304
+ // in-page upload; a reload kills that upload but not the cache, so a cached
305
+ // staged bubble would come back as a message that uploads forever. Dropping
306
+ // it costs nothing while the page lives — a history refetch re-rescues it
307
+ // from state.messages, and a dispatch that no longer finds it just appends.
255
308
  this.aiChatHistoryCache[key] = {
256
309
  messages: this.state.messages.filter(function (m) {
310
+ if (m._stageId) return false;
257
311
  return m._ownerKey === undefined || m._ownerKey === key;
258
312
  }),
259
313
  endOfList: this.state.historyEndOfList,
@@ -432,12 +486,177 @@ export class ChatSession {
432
486
  return run;
433
487
  }
434
488
 
489
+ /**
490
+ * Put a turn on screen the INSTANT the user hits Send, before its attachments
491
+ * have finished uploading. Uploads run in the background now (the composer is
492
+ * cleared and stays usable), so without a staged bubble the message would
493
+ * appear only once its files were up — below anything the user sent in the
494
+ * meantime, in an order that never matches what they typed.
495
+ *
496
+ * Staged bubbles carry _useBgQueue because that is where a turn with
497
+ * attachments ultimately dispatches (behind its own indexing tasks). That flag
498
+ * is also what keeps promoteNextQueuedToRunning / resolveQueuedUserBubble off
499
+ * them: those advance the SERVER queue, and a staged turn has no server
500
+ * request behind it yet.
501
+ *
502
+ * Returns the id to hand back as PinnedDispatchContext.stageId at dispatch.
503
+ */
504
+ stageOutgoingMessage(displayText: string): string {
505
+ this._stageSeq += 1;
506
+ var stageId = 'stg_' + this._stageSeq;
507
+ var key = this.getHistoryCacheKey();
508
+ var staged: ChatMessage = {
509
+ role: 'user', content: displayText,
510
+ isPendingQueued: true, isUploadingAttachments: true, isSendingToServer: true,
511
+ _useBgQueue: true, _stageId: stageId, _ts: wallClockNow(),
512
+ };
513
+ if (key) staged._ownerKey = key;
514
+ this.state.messages.push(staged);
515
+ this.host.notify(); this.host.scrollToBottom(true);
516
+ return stageId;
517
+ }
518
+
519
+ private _stageIndex(list: ChatMessage[], stageId?: string): number {
520
+ if (!stageId) return -1;
521
+ for (var i = 0; i < list.length; i++) {
522
+ if (list[i] && list[i]._stageId === stageId) return i;
523
+ }
524
+ return -1;
525
+ }
526
+
527
+ /**
528
+ * Where a staged turn belongs once it is finally sent: BELOW every indexing
529
+ * row, because that is the order it ran in and the order the server history
530
+ * will report on the next load (its request id is newer than every pass it
531
+ * waited for). While its files were uploading it sat above them — the rows
532
+ * are injected as each file's pass starts, after the bubble was staged.
533
+ *
534
+ * Returns the index to insert at, or -1 to leave the turn where it is. Never
535
+ * moves a turn UP: a bubble that already sits below the indexing rows (or a
536
+ * chat with no indexing at all) must not jump backwards over anything.
537
+ */
538
+ private _settledStagePosition(fromIdx: number): number {
539
+ var lastBg = -1;
540
+ for (var i = 0; i < this.state.messages.length; i++) {
541
+ if (this.state.messages[i] && this.state.messages[i].isBackgroundTask) lastBg = i;
542
+ }
543
+ if (lastBg <= fromIdx) return -1;
544
+ // Splicing the staged bubble out first shifts everything after it down one.
545
+ return lastBg;
546
+ }
547
+
548
+ /** The staged turn's files are up; it is now just waiting its place in the
549
+ * queue. Drops the "(Uploading files...)" note back to "(In queue)". */
550
+ markStagedMessageQueued(stageId: string): void {
551
+ var idx = this._stageIndex(this.state.messages, stageId);
552
+ if (idx === -1) return;
553
+ var ex = this.state.messages[idx];
554
+ if (!ex.isUploadingAttachments) return;
555
+ this.state.messages[idx] = Object.assign({}, ex, { isUploadingAttachments: false });
556
+ this.host.notify();
557
+ }
558
+
559
+ /**
560
+ * Resolves once this project's background-indexing queue has nothing left to
561
+ * run, so a chat enqueued right after it is genuinely last.
562
+ *
563
+ * Sending the chat as soon as the uploads finish is not enough, which is the
564
+ * whole reason this exists: indexing a file is a CHAIN, and each pass is only
565
+ * enqueued once the previous one lands (the client mints CONTINUE passes for
566
+ * text/grid files, the worker mints them for PDFs and windowed reads). Every
567
+ * one of those passes therefore queues up BEHIND a chat sent at upload time,
568
+ * and the model answers from a file it has only partly read.
569
+ *
570
+ * The queue is read from the server's status index rather than from
571
+ * bgTaskQueue: that mirror holds only what this client dispatched or adopted,
572
+ * and it stops being maintained once the view unmounts. An empty answer has to
573
+ * repeat before it is believed — see INDEXING_DRAIN_IDLE_LOOKS — and a look
574
+ * that fails counts as busy, so a dropped request delays the turn instead of
575
+ * releasing it early.
576
+ *
577
+ * Reads the identity PINNED at Send time, never a live one: the user may be in
578
+ * another project by now, and this must keep asking about the one they sent
579
+ * from.
580
+ */
581
+ awaitIndexingDrained(identity: ChatIdentity): Promise<'drained' | 'timedout' | 'skipped'> {
582
+ var self = this;
583
+ var svcId = identity && identity.serviceId;
584
+ var platform = identity && identity.platform;
585
+ if (!svcId || (platform !== 'claude' && platform !== 'openai')) return Promise.resolve('skipped' as const);
586
+ var owner = identity.owner;
587
+ var queue = bgIndexingQueueName(identity.userId, svcId);
588
+ var startedAt = nowMs();
589
+ var deadline = startedAt + INDEXING_DRAIN_TIMEOUT_MS;
590
+ var idleLooks = 0;
591
+ var ask = function (status: 'pending' | 'running') {
592
+ return Promise.resolve(getChatHistory(
593
+ { service: svcId, owner: owner, platform: platform as 'claude' | 'openai', queue: queue, status: status },
594
+ { limit: WORKER_PASS_ADOPT_LIMIT },
595
+ )).catch(function () { return null; });
596
+ };
597
+ // Ordinary chats are routed onto this queue too (_isOnBgQueue), and those
598
+ // are not work this turn has to wait behind — the server runs the queue in
599
+ // order, so being enqueued after them is enough.
600
+ var hasLiveIndexing = function (res: any): boolean {
601
+ var list = res && Array.isArray(res.list) ? res.list : [];
602
+ for (var i = 0; i < list.length; i++) {
603
+ var item = list[i];
604
+ if (!item || (item.status !== 'pending' && item.status !== 'running')) continue;
605
+ if (isIndexingRequestText(extractLastUserTextFromRequest(item.request_body))) return true;
606
+ }
607
+ return false;
608
+ };
609
+ return new Promise(function (resolve) {
610
+ var again = function () {
611
+ setTimeout(look, idleLooks > 0 ? INDEXING_DRAIN_CONFIRM_POLL_MS : INDEXING_DRAIN_BUSY_POLL_MS);
612
+ };
613
+ var look = function () {
614
+ if (nowMs() >= deadline) { resolve('timedout'); return; }
615
+ // A pass whose ack is still in flight is not on the queue yet, so no
616
+ // look can see it.
617
+ if (self._indexDispatchesInFlight > 0) { idleLooks = 0; again(); return; }
618
+ Promise.all([ask('running'), ask('pending')]).then(function (res: any[]) {
619
+ var unknown = res[0] === null || res[1] === null;
620
+ if (unknown || hasLiveIndexing(res[0]) || hasLiveIndexing(res[1])) idleLooks = 0;
621
+ else idleLooks += 1;
622
+ if (idleLooks >= INDEXING_DRAIN_IDLE_LOOKS && nowMs() - startedAt >= INDEXING_DRAIN_MIN_MS) {
623
+ resolve('drained'); return;
624
+ }
625
+ again();
626
+ }, function () { idleLooks = 0; again(); });
627
+ };
628
+ look();
629
+ });
630
+ }
631
+
632
+ /**
633
+ * Abandon a staged turn — its uploads failed outright, so nothing will be
634
+ * dispatched. The bubble stays (the user's text is not silently thrown away)
635
+ * but settles into a plain, non-pending message; the caller reports the
636
+ * failure separately.
637
+ */
638
+ settleStagedMessage(stageId: string): void {
639
+ var idx = this._stageIndex(this.state.messages, stageId);
640
+ if (idx === -1) return;
641
+ var ex = this.state.messages[idx];
642
+ var settled: ChatMessage = { role: 'user', content: ex.content };
643
+ if (ex._ownerKey !== undefined) settled._ownerKey = ex._ownerKey;
644
+ if (ex._ts !== undefined) settled._ts = ex._ts;
645
+ this.state.messages[idx] = settled;
646
+ this.host.notify(); this.updateHistoryCache();
647
+ }
648
+
435
649
  // composed = clean display text; composedForLlm carries office-extraction
436
650
  // placeholders for the provider only. useBgQueue routes a post-attachment turn
437
651
  // onto the "-bg" queue so it runs after indexing.
438
652
  dispatchComposedMessage(composed: string, useBgQueue?: boolean, composedForLlm?: string, extractContent?: any, fileUrls?: any, pinned?: PinnedDispatchContext): void {
439
653
  var self = this;
440
- if (!composed) return;
654
+ // This turn may already have a bubble on screen, staged at Send time while
655
+ // its attachments uploaded. Every exit from here has to account for it:
656
+ // dispatching replaces it in place (so it keeps the position the user sent
657
+ // it in), and bailing settles it (so it never sits uploading forever).
658
+ var stageId = pinned ? pinned.stageId : undefined;
659
+ if (!composed) { if (stageId) this.settleStagedMessage(stageId); return; }
441
660
  // A send can be dispatched LONG after the user hit Send (attachment
442
661
  // uploads are awaited first), by which time the live identity may have
443
662
  // moved to another project. The caller pins the identity + system prompt
@@ -445,7 +664,7 @@ export class ChatSession {
445
664
  // question was actually asked of. Falls back to the live read when the
446
665
  // caller doesn't pin (the widget, which has only one project anyway).
447
666
  var id = pinned ? pinned.identity : this.host.getIdentity();
448
- if (id.platform === 'none') return;
667
+ if (id.platform === 'none') { if (stageId) this.settleStagedMessage(stageId); return; }
449
668
 
450
669
  var llmComposed = composedForLlm || composed;
451
670
 
@@ -469,7 +688,13 @@ export class ChatSession {
469
688
  var aiModel = id.model || undefined;
470
689
  var systemPrompt = pinned ? pinned.systemPrompt : this.host.buildSystemPrompt();
471
690
  var userId = id.userId || id.serviceId;
472
- var chatQueue = useBgQueue ? userId + BG_INDEXING_QUEUE_SUFFIX : userId;
691
+ // Same string the indexing passes are enqueued under (bgIndexingQueueName),
692
+ // which is the whole reason this turn ends up behind them: the backend runs
693
+ // different queue names in parallel and only serialises a shared one.
694
+ var chatQueue = useBgQueue ? bgIndexingQueueName(userId) : userId;
695
+ // _stageIndex is -1 when nothing was staged, or when the staged bubble is
696
+ // gone (a remount rebuilds the list from the cache, which never holds staged
697
+ // bubbles); either way the branches below fall back to appending.
473
698
 
474
699
  if (offChat) {
475
700
  // Stage the turn in the pinned chat's cache and dispatch. The
@@ -479,16 +704,28 @@ export class ChatSession {
479
704
  // the pending bubble in this same cache entry when the reply lands.
480
705
  var offHistory = (this.aiChatHistoryCache[key] ? this.aiChatHistoryCache[key].messages : []).filter(function (m) {
481
706
  return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
482
- !m.isCancelled && !m.isBackgroundTask;
707
+ !m.isCancelled && !m.isBackgroundTask && !m.isError;
483
708
  });
484
709
  var offBounded = buildBoundedChatMessages({
485
710
  platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
486
711
  history: offHistory.concat([{ role: 'user', content: llmComposed }]),
487
712
  });
488
713
  var offExisting = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
714
+ var offUser: ChatMessage = { role: 'user', content: composed, _ownerKey: key, _ts: wallClockNow() };
715
+ // The user hit Send here, then navigated away before the uploads
716
+ // finished. The staged bubble belongs to THAT chat, not the one now on
717
+ // screen, and the turn it stood in for is being cached below — so drop
718
+ // it from the live list instead of leaving a bubble that uploads
719
+ // forever in a project it does not belong to.
720
+ var offStage = this._stageIndex(this.state.messages, stageId);
721
+ if (offStage !== -1) {
722
+ if (this.state.messages[offStage]._ts !== undefined) offUser._ts = this.state.messages[offStage]._ts;
723
+ this.state.messages.splice(offStage, 1);
724
+ this.host.notify();
725
+ }
489
726
  this.aiChatHistoryCache[key] = {
490
727
  messages: offExisting.messages.concat([
491
- { role: 'user', content: composed, _ownerKey: key, _ts: wallClockNow() },
728
+ offUser,
492
729
  { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, _ownerKey: key },
493
730
  ]),
494
731
  endOfList: offExisting.endOfList,
@@ -505,7 +742,7 @@ export class ChatSession {
505
742
  if (isQueuedSend) {
506
743
  var resolvedHistory = this.state.messages.filter(function (m) {
507
744
  return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
508
- !m.isCancelled && !m.isBackgroundTask;
745
+ !m.isCancelled && !m.isBackgroundTask && !m.isError;
509
746
  });
510
747
  var boundedQ = buildBoundedChatMessages({
511
748
  platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
@@ -514,7 +751,20 @@ export class ChatSession {
514
751
  var queuedBubble: ChatMessage = { role: 'user', content: composed, isPendingQueued: true, isSendingToServer: true, _ts: wallClockNow() };
515
752
  if (key) queuedBubble._ownerKey = key;
516
753
  if (useBgQueue) queuedBubble._useBgQueue = true;
517
- this.state.messages.push(queuedBubble);
754
+ var qStage = this._stageIndex(this.state.messages, stageId);
755
+ if (qStage !== -1) {
756
+ // Keep the send time the user saw, not the upload's finish time.
757
+ if (this.state.messages[qStage]._ts !== undefined) queuedBubble._ts = this.state.messages[qStage]._ts;
758
+ var qTarget = this._settledStagePosition(qStage);
759
+ if (qTarget === -1) {
760
+ this.state.messages.splice(qStage, 1, queuedBubble);
761
+ } else {
762
+ this.state.messages.splice(qStage, 1);
763
+ this.state.messages.splice(qTarget, 0, queuedBubble);
764
+ }
765
+ } else {
766
+ this.state.messages.push(queuedBubble);
767
+ }
518
768
  this.host.notify(); this.updateHistoryCache(); this.host.scrollToBottom(true);
519
769
 
520
770
  var capturedComposed = composed, capturedPlatform = aiPlatform, capturedKey = key;
@@ -523,9 +773,13 @@ export class ChatSession {
523
773
  // Only ack a bubble that belongs to THIS chat — the search is
524
774
  // positional, so on another project it would stamp this turn's
525
775
  // _serverItemId onto that project's unrelated in-flight bubble.
776
+ // Staged bubbles (_stageId) are skipped for the same reason: they
777
+ // sit EARLIER in the list and match this shape exactly, so a turn
778
+ // whose files are still uploading would take the ack — and the
779
+ // server id — belonging to the turn that actually just sent.
526
780
  var sendingIdx = self.getHistoryCacheKey() !== capturedKey ? -1 : self.state.messages.findIndex(function (m) {
527
781
  return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === 'user' &&
528
- (m._ownerKey === undefined || m._ownerKey === capturedKey);
782
+ !m._stageId && (m._ownerKey === undefined || m._ownerKey === capturedKey);
529
783
  });
530
784
  var serverId = result && typeof result.id === 'string' ? result.id : undefined;
531
785
  if (sendingIdx >= 0) {
@@ -553,19 +807,56 @@ export class ChatSession {
553
807
  // view unmount), then rendered from the cache via typewriteLatestReply. A
554
808
  // later resumePendingRequest() re-renders it if the view remounted while the
555
809
  // request was still in flight.
556
- this.state.messages.push({ role: 'user', content: composed, _ts: wallClockNow(), ...(key ? { _ownerKey: key } : {}) });
557
- this.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, ...(key ? { _ownerKey: key } : {}) });
558
- this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.host.scrollToBottom(true);
559
-
560
- var historyForLlm = this.state.messages.filter(function (m) { return !m.isCancelled && !m.isBackgroundTask; });
561
- if (llmComposed !== composed) {
562
- for (var li = historyForLlm.length - 1; li >= 0; li--) {
563
- if (historyForLlm[li].role === 'user' && historyForLlm[li].content === composed) {
564
- historyForLlm[li] = Object.assign({}, historyForLlm[li], { content: llmComposed });
565
- break;
566
- }
810
+ var immediateUser: ChatMessage = { role: 'user', content: composed, _ts: wallClockNow(), ...(key ? { _ownerKey: key } : {}) };
811
+ var immediatePlaceholder: ChatMessage = { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, ...(key ? { _ownerKey: key } : {}) };
812
+ var iStage = this._stageIndex(this.state.messages, stageId);
813
+ if (iStage !== -1) {
814
+ // Keep the send time the user saw, not the upload's finish time.
815
+ if (this.state.messages[iStage]._ts !== undefined) immediateUser._ts = this.state.messages[iStage]._ts;
816
+ var iTarget = this._settledStagePosition(iStage);
817
+ if (iTarget === -1) {
818
+ this.state.messages.splice(iStage, 1, immediateUser, immediatePlaceholder);
819
+ } else {
820
+ this.state.messages.splice(iStage, 1);
821
+ this.state.messages.splice(iTarget, 0, immediateUser, immediatePlaceholder);
567
822
  }
823
+ } else {
824
+ this.state.messages.push(immediateUser);
825
+ this.state.messages.push(immediatePlaceholder);
568
826
  }
827
+ this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.host.scrollToBottom(true);
828
+
829
+ // Same filter as the offChat and isQueuedSend paths above. It must drop the
830
+ // pending flags too: the `isPending` placeholder pushed two lines up is the
831
+ // in-flight "Thinking..." bubble, and leaving it in sent a trailing
832
+ // `{role:'assistant', content:''}` upstream on EVERY immediate send. That is
833
+ // a last-assistant-turn prefill, which the Claude platform rejects outright,
834
+ // and on both platforms it made the history end on an assistant turn, so
835
+ // prepareOpenAIMessages / prepareClaudeMessages bailed at their
836
+ // `last.role !== 'user'` guard and silently stopped converting attached
837
+ // images into image blocks.
838
+ //
839
+ // `isError` is dropped in all three paths for a different reason: those
840
+ // bubbles are written by THIS client, never by the model. Every site that
841
+ // sets `isError: true` fills content from getErrorMessage() or the literal
842
+ // 'Request was cancelled.', so filtering them discards no model-authored
843
+ // text. Keeping them handed the model a turn it never produced ("The AI
844
+ // provider is temporarily unreachable..."), attributed to itself, because the
845
+ // flag is stripped when buildBoundedChatMessages maps down to {role,content}.
846
+ // The cost is that a retry now follows an unanswered question with no stated
847
+ // reason, which is a true account of what happened rather than a false one.
848
+ //
849
+ // The turn being sent is appended LAST rather than left wherever its bubble
850
+ // sits. A staged turn keeps the position it was sent in, which is NOT the
851
+ // end of the list once a message sent after it (while its files uploaded)
852
+ // has already been answered — and a history that ends on an assistant turn
853
+ // hits exactly the `last.role !== 'user'` bail described above.
854
+ var historyForLlm = this.state.messages.filter(function (m) {
855
+ if (m === immediateUser) return false;
856
+ return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
857
+ !m.isCancelled && !m.isBackgroundTask && !m.isError;
858
+ });
859
+ historyForLlm.push({ role: 'user', content: llmComposed });
569
860
  var bounded = buildBoundedChatMessages({
570
861
  platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
571
862
  history: historyForLlm,
@@ -811,7 +1102,7 @@ export class ChatSession {
811
1102
  if (platform !== 'claude' && platform !== 'openai') return;
812
1103
  var url = platform === 'claude' ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
813
1104
  var queueBase = id.userId || id.serviceId;
814
- var queue = (msg.isBackgroundTask || msg._useBgQueue) ? queueBase + BG_INDEXING_QUEUE_SUFFIX : queueBase;
1105
+ var queue = (msg.isBackgroundTask || msg._useBgQueue) ? bgIndexingQueueName(queueBase) : queueBase;
815
1106
  this.state.messages[idx] = Object.assign({}, msg, { _cancelling: true, _cancelError: undefined });
816
1107
  this.host.notify();
817
1108
  Promise.resolve(this.host.cancelRequest({
@@ -1342,7 +1633,7 @@ export class ChatSession {
1342
1633
  if (!id.serviceId || (platform !== 'claude' && platform !== 'openai')) return;
1343
1634
  if (this.isPollingPaused() || !this.host.isViewMounted()) return;
1344
1635
  var svcId = id.serviceId, owner = id.owner;
1345
- var queue = (id.userId || id.serviceId) + BG_INDEXING_QUEUE_SUFFIX;
1636
+ var queue = bgIndexingQueueName(id.userId, id.serviceId);
1346
1637
  var ask = function (status: 'pending' | 'running') {
1347
1638
  return Promise.resolve(getChatHistory(
1348
1639
  { service: svcId, owner: owner, platform: platform as 'claude' | 'openai', queue: queue, status: status },
@@ -1469,7 +1760,7 @@ export class ChatSession {
1469
1760
  var url = id.platform === 'claude' ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
1470
1761
  Promise.resolve(this.host.cancelRequest({
1471
1762
  url: url, method: 'POST', id: serverId,
1472
- queue: (id.userId || id.serviceId) + BG_INDEXING_QUEUE_SUFFIX,
1763
+ queue: bgIndexingQueueName(id.userId, id.serviceId),
1473
1764
  service: id.serviceId, owner: id.owner,
1474
1765
  })).catch(function () { /* the pass may already have finished; nothing to do */ });
1475
1766
  }
@@ -1618,7 +1909,10 @@ export class ChatSession {
1618
1909
  if (pass > MAX_INDEXING_RESUME_PASSES) return; // give up after the cap
1619
1910
  var id = this.host.getIdentity();
1620
1911
  if (!id || id.platform === 'none' || id.serviceId !== entry.serviceId) return;
1621
- notifyAgentContinueIndexing({
1912
+ // Counted as live work from here, not from the ack: awaitIndexingDrained
1913
+ // asks the SERVER what is queued, and this pass is not queued until the
1914
+ // call below returns. Without it a chat can slip in between two passes.
1915
+ this.trackIndexDispatch(notifyAgentContinueIndexing({
1622
1916
  platform: id.platform as 'claude' | 'openai',
1623
1917
  model: id.model,
1624
1918
  service: id.serviceId,
@@ -1644,7 +1938,7 @@ export class ChatSession {
1644
1938
  });
1645
1939
  self.drainBgTaskQueue();
1646
1940
  }
1647
- }, function (e: any) { console.error('[chat-engine] resume-indexing dispatch failed', e); });
1941
+ }, function (e: any) { console.error('[chat-engine] resume-indexing dispatch failed', e); }));
1648
1942
  } catch (e) { /* best-effort: resume must never break bg-task resolution */ }
1649
1943
  }
1650
1944
 
@@ -1741,6 +2035,13 @@ export class ChatSession {
1741
2035
  if (mm._ownerKey !== undefined && mm._ownerKey !== loadKey) continue;
1742
2036
  if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
1743
2037
  if (!mm._serverItemId) {
2038
+ // A staged turn (files still uploading) has no server request
2039
+ // yet, so nothing in `mapped` can stand for it — rescue it
2040
+ // unconditionally. The mappedHasPendingAssistant skip below is
2041
+ // about a turn the server ALREADY has; applying it here would
2042
+ // delete the user's message mid-upload whenever some other
2043
+ // turn happened to be in flight.
2044
+ if (mm._stageId) { rescued.push(mm); continue; }
1744
2045
  if (mappedHasPendingAssistant) continue;
1745
2046
  if (mm.isSendingToServer || mm.isPendingQueued || mm.isPendingInProcess || mm.isPending) rescued.push(mm);
1746
2047
  else if (self.state.sending && mm.role === 'user') {
@@ -2014,7 +2315,9 @@ export class ChatSession {
2014
2315
  return preIndex.then(function () {
2015
2316
  return parseAttachmentContent(member.file, member.file.name, mime || undefined);
2016
2317
  }).then(function (parsedContent: string | null) {
2017
- return notifyAgentSaveAttachment({
2318
+ // Tracked so a chat waiting on awaitIndexingDrained cannot be sent
2319
+ // in the window between this call and the queue accepting it.
2320
+ return self.trackIndexDispatch(notifyAgentSaveAttachment({
2018
2321
  platform: id.platform as 'claude' | 'openai',
2019
2322
  model: id.model,
2020
2323
  service: id.serviceId,
@@ -2049,7 +2352,7 @@ export class ChatSession {
2049
2352
  att.errorCode = (e && (e.code || (e.body && e.body.code))) || '';
2050
2353
  att.errorDetail = (e && (e.message || (e.body && e.body.message))) || (typeof e === 'string' ? e : '');
2051
2354
  }
2052
- });
2355
+ }));
2053
2356
  });
2054
2357
  });
2055
2358
  });
@@ -2066,14 +2369,24 @@ export class ChatSession {
2066
2369
 
2067
2370
  // Upload all not-yet-done attachments sequentially. Resolves to the full
2068
2371
  // list of { name, url, storagePath } for composing the chat message.
2069
- uploadPendingAttachments(): Promise<Array<{ name: string; url: string; storagePath?: string }>> {
2372
+ //
2373
+ // `batchId` scopes the run to the chips stamped with it at Send time. The
2374
+ // composer stays live during an upload, so by the time this runs the
2375
+ // attachment list can already hold chips the user picked for the NEXT
2376
+ // message — uploading those here would attach them to the wrong turn, and
2377
+ // collecting the previous batch's finished urls would attach files the user
2378
+ // already sent. Omitted (no batch) means every chip, the old behavior.
2379
+ uploadPendingAttachments(batchId?: string): Promise<Array<{ name: string; url: string; storagePath?: string }>> {
2070
2380
  var self = this;
2071
2381
  this.host.resetOverwriteBatch();
2382
+ this._uploadBatches += 1;
2072
2383
  this.state.uploadingAttachments = true;
2073
2384
  this.host.updateComposerControls();
2074
2385
  this.host.renderAttachmentChips();
2075
2386
  var collected: Array<{ name: string; url: string; storagePath?: string }> = [];
2076
- var snapshot = this.state.attachments.slice();
2387
+ var snapshot = this.state.attachments.filter(function (a: any) {
2388
+ return batchId ? a._batchId === batchId : true;
2389
+ });
2077
2390
  var chain: Promise<any> = Promise.resolve();
2078
2391
  snapshot.forEach(function (att: any) {
2079
2392
  chain = chain.then(function () {
@@ -2101,7 +2414,11 @@ export class ChatSession {
2101
2414
  });
2102
2415
  });
2103
2416
  var done = function () {
2104
- self.state.uploadingAttachments = false; self.host.updateComposerControls(); self.host.renderAttachmentChips();
2417
+ self._uploadBatches = Math.max(0, self._uploadBatches - 1);
2418
+ // Only the LAST batch standing clears the flag — a second send made
2419
+ // while this one was uploading is still running.
2420
+ self.state.uploadingAttachments = self._uploadBatches > 0;
2421
+ self.host.updateComposerControls(); self.host.renderAttachmentChips();
2105
2422
  return collected;
2106
2423
  };
2107
2424
  return chain.then(done, done);