bunnyquery 1.3.2 → 1.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunnyquery",
3
- "version": "1.3.2",
3
+ "version": "1.3.5",
4
4
  "description": "Embeddable BunnyQuery AI chat widget + its framework-agnostic chat engine",
5
5
  "main": "bunnyquery.js",
6
6
  "exports": {
@@ -34,6 +34,55 @@ export function isErrorResponseBody(response: any): boolean {
34
34
  return false;
35
35
  }
36
36
 
37
+ // A request that failed because the REQUEST ITSELF is malformed — an unknown or
38
+ // rejected parameter on a 400/422 — will deterministically re-fail if resent
39
+ // unchanged; no session refresh or retry can fix it. Detecting it lets the retry
40
+ // gates refuse to auto-resend (otherwise a 400 whose param name merely contains
41
+ // "token", e.g. `max_output_tokens`, trips the `invalid_request + token` branch
42
+ // of isAuthExpiredError below and loops refresh->resend forever, burning tokens).
43
+ // This is the `_skapi_extract` 400 class. Distinct from auth-expiry, which IS
44
+ // retryable after a token refresh — so 401 (auth) and 429/5xx (transient) are
45
+ // intentionally NOT treated as non-retryable here.
46
+ export function isNonRetryableRequestError(input: any): boolean {
47
+ if (!input || typeof input !== 'object') return false;
48
+
49
+ var status = typeof input.status_code === 'number' ? input.status_code
50
+ : typeof input.status === 'number' ? input.status : undefined;
51
+
52
+ // Collect the error envelope wherever it lives (response, response.body, or a
53
+ // thrown error), mirroring isErrorResponseBody / isAuthExpiredError.
54
+ var param: any = undefined;
55
+ var blobs: string[] = [];
56
+ var sources = [input.error, input.body && input.body.error, input.body, input];
57
+ for (var i = 0; i < sources.length; i++) {
58
+ var e = sources[i];
59
+ if (!e) continue;
60
+ if (typeof e === 'string') { blobs.push(e); continue; }
61
+ if (typeof e !== 'object') continue;
62
+ if (param === undefined && e.param != null) param = e.param;
63
+ if (typeof e.code === 'string') blobs.push(e.code);
64
+ if (typeof e.type === 'string') blobs.push(e.type);
65
+ if (typeof e.message === 'string') blobs.push(e.message);
66
+ }
67
+ var hay = blobs.join(' | ').toLowerCase();
68
+
69
+ // Explicit "unknown / unsupported parameter" is always a malformed request.
70
+ if (hay.indexOf('unknown_parameter') !== -1 || hay.indexOf('unknown parameter') !== -1 ||
71
+ hay.indexOf('unsupported_parameter') !== -1 || hay.indexOf('unsupported parameter') !== -1) {
72
+ return true;
73
+ }
74
+
75
+ // A 400/422 invalid_request that points at a specific rejected field: resending
76
+ // the identical body re-fails. (401 -> isAuthExpiredError; 429/5xx -> transient.)
77
+ var isClientReqStatus = status === 400 || status === 422;
78
+ if (isClientReqStatus && param != null && param !== '') return true;
79
+ if (isClientReqStatus && hay.indexOf('invalid_request') !== -1 &&
80
+ (hay.indexOf('parameter') !== -1 || hay.indexOf('param') !== -1)) {
81
+ return true;
82
+ }
83
+ return false;
84
+ }
85
+
37
86
  export function isAuthExpiredError(input: any): boolean {
38
87
  if (!input) return false;
39
88
  var blobs: string[] = [];
@@ -30,7 +30,7 @@ export * from './prompts';
30
30
 
31
31
  // Pure helpers (Tier-1.5): error detection, token budgeting, link/path
32
32
  // normalization, and history mapping — shared so both consumers stay identical.
33
- export { getErrorMessage, isErrorResponseBody, isAuthExpiredError } from './errors';
33
+ export { getErrorMessage, isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError } from './errors';
34
34
  export * from './budget';
35
35
  export * from './links';
36
36
  export {
@@ -30,7 +30,7 @@ import {
30
30
  OPENAI_RESPONSES_API_URL,
31
31
  type BgTaskEntry,
32
32
  } from './requests';
33
- import { isErrorResponseBody, isAuthExpiredError, getErrorMessage } from './errors';
33
+ import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, getErrorMessage } from './errors';
34
34
  import { buildBoundedChatMessages } from './budget';
35
35
  import { createInlineLinkRegex } from './links';
36
36
  import { mapHistoryListToMessages, extractLastUserTextFromRequest } from './history';
@@ -116,11 +116,15 @@ export class ChatSession {
116
116
  };
117
117
  var run = sendAndPoll()
118
118
  .catch(function (err: any) {
119
- if (isAuthExpiredError(err)) return self.host.refreshSession().then(sendAndPoll);
119
+ // Only auth-expiry is worth a refresh+resend; a malformed-request 400
120
+ // (e.g. the `_skapi_extract`/unknown-parameter class) re-fails identically,
121
+ // so never loop on it — guard against isAuthExpiredError's heuristic
122
+ // misfiring on a param name that merely contains "token".
123
+ if (isAuthExpiredError(err) && !isNonRetryableRequestError(err)) return self.host.refreshSession().then(sendAndPoll);
120
124
  throw err;
121
125
  })
122
126
  .then(function (response: any) {
123
- if (isErrorResponseBody(response) && isAuthExpiredError(response)) {
127
+ if (isErrorResponseBody(response) && isAuthExpiredError(response) && !isNonRetryableRequestError(response)) {
124
128
  return self.host.refreshSession().then(sendAndPoll);
125
129
  }
126
130
  return response;
@@ -348,6 +352,7 @@ export class ChatSession {
348
352
  this.host.notify(); this.enqueueTypewrite(aiIdx, answer, lid);
349
353
  }
350
354
  }
355
+ this._removeStrayPendingAssistants();
351
356
  this.promoteNextQueuedToRunning();
352
357
  this.updateHistoryCache();
353
358
  this.host.notify();
@@ -382,12 +387,14 @@ export class ChatSession {
382
387
  if (thPos2 !== -1) this.state.messages.splice(thPos2, 1);
383
388
  }
384
389
  if (serverId) this.cancelledServerIds.delete(serverId);
390
+ this._removeStrayPendingAssistants();
385
391
  this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottom(true);
386
392
  return;
387
393
  }
388
394
  var targetIdx = this.resolveQueuedUserBubble(serverId);
389
395
  if (targetIdx === undefined) { this.host.notify(); this.updateHistoryCache(); return; }
390
396
  this.insertAtTarget({ role: 'assistant', content: getErrorMessage(err), isError: true }, targetIdx);
397
+ this._removeStrayPendingAssistants();
391
398
  this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottom(true);
392
399
  }
393
400
 
@@ -512,17 +519,52 @@ export class ChatSession {
512
519
  if (pendingIdx === -1) return Promise.resolve();
513
520
  if (latest.isError || !latest.content) {
514
521
  this.state.messages[pendingIdx] = { role: 'assistant', content: latest.content || '', isError: !!latest.isError };
522
+ this._removeStrayPendingAssistants();
515
523
  this.host.notify();
516
524
  this.promoteNextQueuedToRunning();
517
525
  return Promise.resolve();
518
526
  }
519
527
  var lid = this._newLocalId();
520
528
  this.state.messages[pendingIdx] = { role: 'assistant', content: '', isPending: false, _localId: lid };
529
+ this._removeStrayPendingAssistants();
521
530
  this.host.notify();
522
531
  this.promoteNextQueuedToRunning();
523
532
  return this.enqueueTypewrite(pendingIdx, latest.content, lid);
524
533
  }
525
534
 
535
+ // Remove any leftover non-background pending ("Thinking…") assistant bubbles.
536
+ // There is normally at most ONE such bubble at a time (promoteNext* refuses to
537
+ // add a second), so any extra is a duplicate — it appears when a concurrent
538
+ // history refetch re-maps the still-"running" turn into a pending placeholder
539
+ // (with a real _serverItemId) while the local pending bubble (no _serverItemId)
540
+ // is rescued and re-appended (see loadHistory rescue below). Each resolve path
541
+ // only replaces the FIRST pending bubble, so without this a stray "Thinking…"
542
+ // survives next to the reply/error. MUST run AFTER the resolved bubble has been
543
+ // made non-pending and BEFORE promoteNext*() (so a freshly-promoted Thinking,
544
+ // which is added only once no pending assistant remains, is preserved).
545
+ _removeStrayPendingAssistants(): void {
546
+ for (var k = this.state.messages.length - 1; k >= 0; k--) {
547
+ var m = this.state.messages[k];
548
+ if (m.isPending && m.role === 'assistant' && !m.isBackgroundTask) this.state.messages.splice(k, 1);
549
+ }
550
+ }
551
+
552
+ // Drop the pending flags on the resolved turn's USER bubble (preserving its
553
+ // content + background-task marker). Needed because a bg "Indexing:" turn's user
554
+ // bubble carries isPendingInProcess; leaving it set keeps the bubble visually
555
+ // stuck and keeps its bgTaskQueue entry alive forever.
556
+ _clearPendingUserBubble(itemId: string): void {
557
+ var uIdx = this.state.messages.findIndex(function (m) {
558
+ return m.role === 'user' && m._serverItemId === itemId &&
559
+ (m.isPendingInProcess || m.isPendingQueued || m.isSendingToServer);
560
+ });
561
+ if (uIdx === -1) return;
562
+ var u = this.state.messages[uIdx];
563
+ var cleaned: ChatMessage = { role: 'user', content: u.content, _serverItemId: itemId };
564
+ if (u.isBackgroundTask) cleaned.isBackgroundTask = true;
565
+ this.state.messages[uIdx] = cleaned;
566
+ }
567
+
526
568
  // If an immediate-send request for the current cache key is still in flight
527
569
  // (e.g. the view unmounted then remounted mid-request), show the sending
528
570
  // state, await it, then render the reply from the cache. Skipped when the
@@ -557,6 +599,11 @@ export class ChatSession {
557
599
  : ((platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '').trim();
558
600
  var idx = this.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === itemId; });
559
601
  if (idx !== -1) {
602
+ // A bg "Indexing:" turn pushes a user bubble (isPendingInProcess) ALONGSIDE
603
+ // the assistant Thinking; replacing only the assistant leaves that user
604
+ // bubble stuck pending — and drainBgTaskQueue then never clears its queue
605
+ // entry (its stillPending check stays true). Un-pend it here too.
606
+ this._clearPendingUserBubble(itemId);
560
607
  if (isErr) {
561
608
  this.state.messages[idx] = { role: 'assistant', content: answer, isError: true, _serverItemId: itemId };
562
609
  this.host.notify(); this.updateHistoryCache(); return;
@@ -656,7 +703,7 @@ export class ChatSession {
656
703
  var fetchHistory = function () { return getChatHistory({ service: serviceId, owner: owner, platform: platform }, options); };
657
704
 
658
705
  return Promise.resolve().then(fetchHistory).catch(function (err: any) {
659
- if (isAuthExpiredError(err)) return self.host.refreshSession().then(fetchHistory);
706
+ if (isAuthExpiredError(err) && !isNonRetryableRequestError(err)) return self.host.refreshSession().then(fetchHistory);
660
707
  throw err;
661
708
  }).then(function (history: any) {
662
709
  if (token !== self.state.gateRefreshToken) return;
@@ -686,12 +733,23 @@ export class ChatSession {
686
733
  mapped.forEach(function (m: any) { if (m._serverItemId) serverIds[m._serverItemId] = 1; });
687
734
  var locallyCancelled: any = {};
688
735
  self.state.messages.forEach(function (m) { if (m.isCancelled && m._serverItemId) locallyCancelled[m._serverItemId] = 1; });
736
+ // If the freshly-mapped server list ALREADY shows this in-flight turn
737
+ // as a pending placeholder (a non-bg pending assistant, which carries
738
+ // a real _serverItemId), re-pushing the local no-_serverItemId
739
+ // user+Thinking would DUPLICATE it. Each resolve path only clears the
740
+ // first pending bubble, so the duplicate strands a "Thinking…" beside
741
+ // the reply/error. There is at most one in-flight regular turn, so a
742
+ // mapped pending assistant IS this turn — skip the redundant local copy.
743
+ var mappedHasPendingAssistant = mapped.some(function (m: any) {
744
+ return m.isPending && m.role === 'assistant' && !m.isBackgroundTask;
745
+ });
689
746
  var rescued: ChatMessage[] = [];
690
747
  for (var ri = 0; ri < self.state.messages.length; ri++) {
691
748
  var mm = self.state.messages[ri];
692
749
  if (mm.isBackgroundTask) continue;
693
750
  if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
694
751
  if (!mm._serverItemId) {
752
+ if (mappedHasPendingAssistant) continue;
695
753
  if (mm.isSendingToServer || mm.isPendingQueued || mm.isPendingInProcess || mm.isPending) rescued.push(mm);
696
754
  else if (self.state.sending && mm.role === 'user') {
697
755
  var next = self.state.messages[ri + 1];