bunnyquery 1.8.5 → 1.8.8

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.
@@ -4,7 +4,7 @@
4
4
  * formatIndexingLabel callback) so the engine touches neither localStorage nor
5
5
  * view-specific display formatting. projectId is passed for link sanitization.
6
6
  */
7
- import { extractClaudeText, extractOpenAIText, INDEXING_COMPLETE_MARKER, EMPTY_INDEXING_REPLY } from './requests';
7
+ import { extractClaudeText, extractOpenAIText, INDEXING_COMPLETE_MARKER, EMPTY_INDEXING_REPLY, getChatHistory, bgIndexingQueueName } from './requests';
8
8
  import { isErrorResponseBody, getErrorMessage } from './errors';
9
9
  import { sanitizeAttachmentLinksForHistory } from './links';
10
10
 
@@ -80,6 +80,513 @@ export function parseIndexingRequestText(userText: any): IndexingRequestRef | nu
80
80
  };
81
81
  }
82
82
 
83
+ // One page of the status probe below. Matches ChatSession's own
84
+ // WORKER_PASS_ADOPT_LIMIT: the queue holds one live pass per file (plus a
85
+ // handful pending), so a FULL page means the answer may be incomplete and the
86
+ // probe reports checked=false rather than letting a miss read as "finished".
87
+ const LIVE_INDEX_PROBE_LIMIT = 20;
88
+
89
+ // ─── Shared bg-queue probe (write-through memo) ──────────────────────────────
90
+ // The SAME two queries — getChatHistory({queue, status: 'pending'|'running'})
91
+ // — are fired by four independent callers: the session's live-index snapshot,
92
+ // its worker-pass adoption ladder, its drain wait, and fetchLiveIndexingKeys
93
+ // (the db-files page's badge probe). Navigating chat → files re-asked the queue
94
+ // the chat had answered seconds earlier.
95
+ //
96
+ // This memo dedupes them WITHOUT weakening anyone's evidence:
97
+ // • progress loops (adopt ladder, drain wait) call with maxAgeMs 0 — they
98
+ // always fetch fresh, because their whole point is to observe a CHANGE —
99
+ // but their answers write through into the memo;
100
+ // • snapshot readers pass a maxAge and reuse a young answer.
101
+ // Every entry carries `at`, the time the underlying fetch actually ran. A
102
+ // caller that needs two INDEPENDENT looks (dbfile's green-confirm ladder) must
103
+ // compare `at`, never its own receipt time: a memo re-serve has the same `at`
104
+ // and therefore counts as the single look it really is.
105
+ export const BG_PROBE_TTL_MS = 4000;
106
+ type BgProbeEntry = { result: any; at: number };
107
+ const bgProbeCache: { [key: string]: BgProbeEntry } = {};
108
+ const bgProbeInflight: { [key: string]: Promise<BgProbeEntry> } = {};
109
+
110
+ export function probeBgQueue(
111
+ params: {
112
+ service: string;
113
+ owner: string;
114
+ platform: 'claude' | 'openai';
115
+ queue: string;
116
+ status: 'pending' | 'running';
117
+ limit: number;
118
+ },
119
+ opts?: { maxAgeMs?: number },
120
+ ): Promise<BgProbeEntry> {
121
+ const key = [params.service, params.owner, params.platform, params.queue, params.status, params.limit].join('|');
122
+ const maxAge = opts && typeof opts.maxAgeMs === 'number' ? opts.maxAgeMs : 0;
123
+ const cached = bgProbeCache[key];
124
+ if (maxAge > 0 && cached && Date.now() - cached.at < maxAge) {
125
+ return Promise.resolve(cached);
126
+ }
127
+ const inflight = bgProbeInflight[key];
128
+ if (inflight) return inflight;
129
+ const p = Promise.resolve(getChatHistory(
130
+ { service: params.service, owner: params.owner, platform: params.platform, queue: params.queue, status: params.status },
131
+ { limit: params.limit, fetchMore: false },
132
+ )).then(function (result: any) {
133
+ const entry: BgProbeEntry = { result: result, at: Date.now() };
134
+ bgProbeCache[key] = entry;
135
+ return entry;
136
+ });
137
+ bgProbeInflight[key] = p;
138
+ // Clear the in-flight slot on either outcome; failures are NOT cached.
139
+ p.then(function () { delete bgProbeInflight[key]; }, function () { delete bgProbeInflight[key]; });
140
+ return p;
141
+ }
142
+
143
+ /**
144
+ * One bounded look at the background-indexing queue: which files still have a
145
+ * pass pending or running? This is the same negative signal ChatSession's
146
+ * display layer relies on - for a worker-driven (auto_continue) run, only the
147
+ * queue can say the run is over, because the worker enqueues continuation
148
+ * passes the client never dispatched.
149
+ *
150
+ * Returns every storage path AND file name found on live passes (both, because
151
+ * older prompts may lack the storage-path line), plus `checked`: false when a
152
+ * page came back full, in which case absence from `keys` proves nothing and
153
+ * the caller must keep whatever state it already had.
154
+ *
155
+ * SCOPE: the probed queue is "<userId>-bg" - THIS user's dispatches only. A
156
+ * chain launched by another collaborator or a widget end-user lives on their
157
+ * queue and is invisible here, so "idle" must never be read as "nobody is
158
+ * indexing this file", only as "this user's runs are over". The durable done::
159
+ * marker (indexDoneUniqueId) is the cross-user signal.
160
+ */
161
+ export async function fetchLiveIndexingKeys(params: {
162
+ service: string;
163
+ owner: string;
164
+ platform: 'claude' | 'openai';
165
+ /** Same value the dispatch used - see bgIndexingQueueName. */
166
+ userId?: string;
167
+ }): Promise<{ keys: Set<string>; checked: boolean; at: number }> {
168
+ const queue = bgIndexingQueueName(params.userId, params.service);
169
+ const base = { service: params.service, owner: params.owner, platform: params.platform, queue };
170
+ const [pending, running] = await Promise.all([
171
+ probeBgQueue({ ...base, status: 'pending', limit: LIVE_INDEX_PROBE_LIMIT }, { maxAgeMs: BG_PROBE_TTL_MS }),
172
+ probeBgQueue({ ...base, status: 'running', limit: LIVE_INDEX_PROBE_LIMIT }, { maxAgeMs: BG_PROBE_TTL_MS }),
173
+ ]);
174
+ const keys = new Set<string>();
175
+ let truncated = false;
176
+ for (const entry of [pending, running]) {
177
+ const res = entry.result;
178
+ const list: any[] = (res && Array.isArray((res as any).list)) ? (res as any).list : [];
179
+ if (list.length >= LIVE_INDEX_PROBE_LIMIT) truncated = true;
180
+ for (const item of list) {
181
+ const text = extractLastUserTextFromRequest(item && item.request_body);
182
+ if (!text || !isIndexingRequestText(text)) continue;
183
+ const ref = parseIndexingRequestText(text);
184
+ if (!ref) continue;
185
+ if (ref.path) keys.add(ref.path);
186
+ if (ref.name) keys.add(ref.name);
187
+ }
188
+ }
189
+ // `at` = when the OLDER of the two underlying fetches ran. Consumers that
190
+ // need two independent looks (dbfile's green ladder) key on this, so a
191
+ // memo re-serve is correctly seen as the same single look.
192
+ return { keys, checked: !truncated, at: Math.min(pending.at, running.at) };
193
+ }
194
+
195
+ // ─── Split history fetch ─────────────────────────────────────────────────────
196
+ // One conversation, two queues: ordinary chat lives on the user's queue (or a
197
+ // legacy/random one), indexing passes on "<userId>-bg" — whose stored request
198
+ // bodies carry whole file windows and dominated every history page. The split
199
+ // fetches the SURFACE (id-prefix listing minus the bg queue, full bodies) and
200
+ // the BG queue (exact, compact stubs — the lambda stubs ONLY indexing-shaped
201
+ // items, ordinary chats deferred onto the bg queue keep full bodies) and
202
+ // returns a getChatHistory-shaped page.
203
+ //
204
+ // This is v2, redesigned around the 2026-08-11 review findings:
205
+ // • The SDK ignores explicit fetchOptions.startKeyHistory — paging rides its
206
+ // internal per-params-hash cursors. So ALL split state lives HERE, module-
207
+ // level, keyed by (service|owner|platform|userId): the bg overflow buffer,
208
+ // the undelivered surface page, end flags. Callers keep echoing
209
+ // startKeyHistory opaquely; it is bookkeeping only, on both sides.
210
+ // • Pages TILE exactly: each page emits only items at-or-newer-than the
211
+ // surface page's oldest item (the boundary); bg items older than that are
212
+ // BUFFERED for the next page. Consumers' raw prepend, retainedOlder
213
+ // pruning and clear-horizon early-end all rely on this invariant.
214
+ // • Retry-safe: the fetched surface page is held as pendingSurface until the
215
+ // merged page is actually returned, and every bg page lands in the buffer
216
+ // the moment it arrives — a mid-call failure + caller retry resumes with
217
+ // nothing skipped (the SDK's internal cursors have already advanced; the
218
+ // state carries what they advanced past).
219
+ // • Empty-but-not-end pages are eliminated SERVER-side (the lambda re-queries
220
+ // past fully-filtered windows). A defensive client cap remains.
221
+ //
222
+ // Backwards-safe: an old backend ignores queue_exclude/compact — the surface
223
+ // then includes bg items with bodies (exactly today's single fetch), the bg
224
+ // call returns duplicates, and dedup-by-id (surface wins) degrades to current
225
+ // behavior plus one redundant query, never to data loss.
226
+ // Per-call bg fetches. Depth is LAZY: the consumers merge prepended pages by
227
+ // timestamp, so older stubs can arrive on any later page and still land at
228
+ // their true position — there is no need to drain the bg queue eagerly, and
229
+ // doing so is exactly what made first paint slow (bg windows are ~1MB of RAW
230
+ // rows, i.e. a handful of stubs per round trip). Two fetches keep the nearby
231
+ // stubs in the same paint; scroll-up/viewport-fill pages pull the rest.
232
+ const BG_COVERAGE_MAX_PAGES = 2;
233
+
234
+ type SplitHistoryState = {
235
+ /** Fetched bg items not yet emitted (older than the last page's boundary). */
236
+ bgBuffer: any[];
237
+ bgEnd: boolean;
238
+ /** Whether the bg chain has fetched at least once (first call resets the
239
+ * SDK's internal bg cursor with fetchMore:false; later calls continue). */
240
+ bgStarted: boolean;
241
+ surfaceEnd: boolean;
242
+ /** Surface page fetched but not yet delivered in a returned merged page —
243
+ * reused on retry so the SDK's advanced cursor cannot skip it. Stamped
244
+ * with the fetchMore mode it was fetched under: a page-1 fetch abandoned
245
+ * mid-coverage must never be replayed as an OLDER page (or vice versa). */
246
+ pendingSurface: { list: any[]; endOfList: boolean; startKeyHistory: any[]; forFetchMore: boolean } | null;
247
+ /** Surface items fetched but HELD BACK because the bg coverage loop's hop
248
+ * cap fired before the bg side reached this page's boundary: emitting
249
+ * them would let the uncovered bg stubs land on a LATER page above
250
+ * strictly-newer surface turns. Prepended to the next page's surface. */
251
+ surfaceCarry: any[];
252
+ lastSurfaceKeys: any[];
253
+ /** Newest bg item id this chain has ever seen. The head refresh compares it
254
+ * against the fresh head page: a FULL head page that no longer contains it
255
+ * means more than one page of bg rows landed while nobody was polling, and
256
+ * the walk must reopen or the gap is unfetchable forever. */
257
+ newestBgId: string;
258
+ };
259
+ const splitHistoryStates: { [key: string]: SplitHistoryState } = {};
260
+ // Per-key serialization: a token-bumped reload can start a NEW split call
261
+ // while an old one is mid-flight, and both would interleave on the SDK's
262
+ // shared internal cursor chains (silently skipping pages). The newcomer waits
263
+ // for the incumbent to settle; its stale result is discarded by the callers'
264
+ // own token guards, and the newcomer's fetchMore:false reset then starts from
265
+ // a clean chain.
266
+ const splitHistoryLocks: { [key: string]: Promise<void> } = {};
267
+
268
+ function freshSplitState(): SplitHistoryState {
269
+ return { bgBuffer: [], bgEnd: false, bgStarted: false, surfaceEnd: false, pendingSurface: null, surfaceCarry: [], lastSurfaceKeys: [], newestBgId: '' };
270
+ }
271
+
272
+ /** Track the newest bg id the chain has seen (ids are creation-ordered). */
273
+ function noteBgIds(state: SplitHistoryState, list: any[]): void {
274
+ for (const it of list) {
275
+ const id = it && typeof it.id === 'string' ? it.id : '';
276
+ if (id && id > state.newestBgId) state.newestBgId = id;
277
+ }
278
+ }
279
+
280
+ /** Test hook: drop split-fetch state (all keys, or one). */
281
+ export function __resetSplitHistoryState(key?: string): void {
282
+ if (key !== undefined) { delete splitHistoryStates[key]; delete splitHistoryLocks[key]; return; }
283
+ for (const k in splitHistoryStates) delete splitHistoryStates[k];
284
+ for (const k in splitHistoryLocks) delete splitHistoryLocks[k];
285
+ }
286
+
287
+ const createdOf = (it: any): number => {
288
+ const c = Number(it && it.created);
289
+ return isFinite(c) && c > 0 ? c : NaN;
290
+ };
291
+ const oldestCreated = (lst: any[]): number => {
292
+ let m = Infinity;
293
+ for (const it of lst) {
294
+ const c = createdOf(it);
295
+ if (!isNaN(c) && c < m) m = c;
296
+ }
297
+ return m; // Infinity when no item carries a usable timestamp
298
+ };
299
+
300
+ // A pure-bg band can outlast the LAMBDA's own re-query cap (its raw windows
301
+ // are 1MB and inline indexing bodies run to ~300KB, so one lambda call may
302
+ // cross only a few dozen rows) — so the client must ALSO loop past
303
+ // empty-but-not-end surface pages, or the fill loops upstream read them as
304
+ // exhausted and strand older history (and an empty page 1 would blank the
305
+ // chat). Each hop here is one more lambda call.
306
+ const SURFACE_EMPTY_MAX_PAGES = 10;
307
+
308
+ export type SplitHistoryResult = {
309
+ list: any[];
310
+ endOfList: boolean;
311
+ startKeyHistory: any[];
312
+ /** True when this chat had never been walked in this session — the first
313
+ * paint. Consumers gate the "Loading indexing history" hint on it: a
314
+ * mid-walk tab return restarts the walk for cursor safety but must stay
315
+ * silent (flashing the hint on every return was the reported bug). */
316
+ firstLoad?: boolean;
317
+ /** Present only when `deferBg` was requested AND bg work remains: resolves
318
+ * with the stub batch fetched in the background (the per-key lock is held
319
+ * until it settles, so no other history call can interleave). The caller
320
+ * merges the batch by timestamp — the same path older pages use. */
321
+ bgPending?: Promise<{ list: any[]; endOfList: boolean }>;
322
+ };
323
+
324
+ export async function getSplitChatHistory(
325
+ params: { service: string; owner: string; platform: 'claude' | 'openai'; userId?: string },
326
+ fetchOptions: Record<string, any>,
327
+ /** Test seam: replaces getChatHistory. Not for production callers. */
328
+ _fetchImpl?: typeof getChatHistory,
329
+ ): Promise<SplitHistoryResult> {
330
+ const key = [params.service, params.owner, params.platform, params.userId || ''].join('|');
331
+ const prev = splitHistoryLocks[key] || Promise.resolve();
332
+ // The lock resolves when the WHOLE call — including a deferred bg batch —
333
+ // has settled, so queued calls never interleave with background work. A
334
+ // call that returns WITHOUT a bgPending (or throws) releases immediately;
335
+ // a deferred call releases in the bg batch's own finally.
336
+ let releaseLock: () => void;
337
+ const lockTail = new Promise<void>((r) => { releaseLock = r; });
338
+ const run = () => _getSplitChatHistoryLocked(key, params, fetchOptions, releaseLock!, _fetchImpl);
339
+ const p = prev.then(run, run);
340
+ p.then((res: any) => { if (!res || !res.bgPending) releaseLock(); }, () => releaseLock());
341
+ splitHistoryLocks[key] = p.then(() => lockTail, () => lockTail);
342
+ return p;
343
+ }
344
+
345
+ async function _getSplitChatHistoryLocked(
346
+ key: string,
347
+ params: { service: string; owner: string; platform: 'claude' | 'openai'; userId?: string },
348
+ fetchOptions: Record<string, any>,
349
+ releaseLock: () => void,
350
+ _fetchImpl?: typeof getChatHistory,
351
+ ): Promise<SplitHistoryResult> {
352
+ const fetch = _fetchImpl || getChatHistory;
353
+ const bgQueue = bgIndexingQueueName(params.userId, params.service);
354
+ const base = { service: params.service, owner: params.owner, platform: params.platform };
355
+ const fetchMore = !!(fetchOptions && fetchOptions.fetchMore);
356
+ const limit = fetchOptions && fetchOptions.limit;
357
+
358
+ // HEAD refresh: a repeat first-page call on a chain that already walked BOTH
359
+ // queues to their end (tab return, poll-side page-1 refresh). Page 1 must be
360
+ // re-fetched for new settles, but what the chain LEARNED about the older
361
+ // tail — "all history fetched" — survives. Wiping it here was the tab-return
362
+ // bug: every return re-pulled the bg stub pages from scratch, reported
363
+ // endOfList false, and that un-gated the viewport fill into re-walking every
364
+ // older page the user had already exhausted. Only a FULLY-ended chain is
365
+ // safe to preserve: its fetchMore path short-circuits without touching the
366
+ // SDK cursors this page-1 refresh rewinds. A mid-walk chain still restarts.
367
+ // True when this chat has never been walked in this session at all — the
368
+ // consumers gate the "Loading indexing history" hint on it, so a mid-walk
369
+ // tab return (which restarts the walk for cursor safety) stays SILENT.
370
+ const firstLoad = !splitHistoryStates[key];
371
+ let headRefresh = false;
372
+ if (!splitHistoryStates[key]) {
373
+ splitHistoryStates[key] = freshSplitState();
374
+ } else if (!fetchMore) {
375
+ const prev = splitHistoryStates[key];
376
+ if (prev.surfaceEnd && prev.bgEnd) {
377
+ headRefresh = true;
378
+ prev.pendingSurface = null;
379
+ prev.surfaceCarry = [];
380
+ prev.bgBuffer = []; // fully drained by construction; defensive
381
+ } else {
382
+ // Mid-walk chain: the page-1 refresh genuinely restarts the walk
383
+ // (the underlying fetchMore:false calls below reset the SDK's own
384
+ // cursors to match), exactly as before.
385
+ splitHistoryStates[key] = freshSplitState();
386
+ }
387
+ }
388
+ const state = splitHistoryStates[key];
389
+
390
+ // A held page fetched under the OTHER mode is not this call's page —
391
+ // discard it rather than replay page 1 as an "older" page (or vice versa).
392
+ if (state.pendingSurface && state.pendingSurface.forFetchMore !== fetchMore) {
393
+ state.pendingSurface = null;
394
+ }
395
+
396
+ // ── surface page ────────────────────────────────────────────────────────
397
+ if (!state.pendingSurface) {
398
+ // The ended-chain short-circuit serves FETCHMORE only: a head refresh
399
+ // re-fetches page 1 for real (new settles live there), and the
400
+ // preserved surfaceEnd keeps every later fetchMore off the wire.
401
+ if (state.surfaceEnd && !headRefresh) {
402
+ state.pendingSurface = { list: [], endOfList: true, startKeyHistory: state.lastSurfaceKeys, forFetchMore: fetchMore };
403
+ } else {
404
+ const sOpts: any = { fetchMore };
405
+ if (limit) sOpts.limit = limit;
406
+ let s = await fetch({ ...base, queue_exclude: bgQueue }, sOpts);
407
+ // Loop past empty-but-not-end pages (see SURFACE_EMPTY_MAX_PAGES).
408
+ let hops = 0;
409
+ while (s && !s.endOfList && !((s.list || []).length) && hops < SURFACE_EMPTY_MAX_PAGES) {
410
+ hops++;
411
+ const nOpts: any = { fetchMore: true };
412
+ if (limit) nOpts.limit = limit;
413
+ s = await fetch({ ...base, queue_exclude: bgQueue }, nOpts);
414
+ }
415
+ state.pendingSurface = {
416
+ list: (s && Array.isArray(s.list)) ? s.list : [],
417
+ endOfList: !!(s && s.endOfList),
418
+ startKeyHistory: (s && Array.isArray(s.startKeyHistory)) ? s.startKeyHistory : [],
419
+ forFetchMore: fetchMore,
420
+ };
421
+ }
422
+ }
423
+ const surface = state.pendingSurface;
424
+
425
+ // ── deferred bg (first-paint mode) ──────────────────────────────────────
426
+ // The caller paints the CONVERSATION from this return immediately; the
427
+ // stub fetch happens behind the resolved promise, under the same lock.
428
+ // Anything already buffered ships now (it costs nothing).
429
+ if (fetchOptions && fetchOptions.deferBg && (!state.bgEnd || headRefresh)) {
430
+ const surfaceList0 = state.surfaceCarry.length ? state.surfaceCarry.concat(surface.list) : surface.list.slice();
431
+ state.surfaceCarry = [];
432
+ const emitNow: any[] = surfaceList0.concat(state.bgBuffer);
433
+ state.bgBuffer = [];
434
+ // A head refresh must not let page 1's "more pages exist" clobber the
435
+ // preserved end-knowledge: those pages are the tail already walked.
436
+ if (!headRefresh) state.surfaceEnd = surface.endOfList;
437
+ state.lastSurfaceKeys = surface.startKeyHistory;
438
+ state.pendingSurface = null;
439
+ const bgPending = (async () => {
440
+ try {
441
+ const batch: any[] = [];
442
+ if (headRefresh) {
443
+ // One HEAD page only: the walked tail is complete; this
444
+ // catches bg rows that settled/minted while nobody was
445
+ // polling. bgEnd/bgStarted are normally left alone — the
446
+ // end-knowledge survives, and the coverage loop (gated on
447
+ // !bgEnd) never touches the cursor this rewinds. ONE
448
+ // exception: a FULL head page that no longer contains the
449
+ // newest bg id this chain had seen means more than a page
450
+ // of bg rows landed while hidden — the gap is unreachable
451
+ // unless the walk reopens (id-dedup makes re-walking the
452
+ // tail harmless).
453
+ const bOpts: any = { fetchMore: false };
454
+ if (limit) bOpts.limit = limit;
455
+ const b = await fetch({ ...base, queue: bgQueue, queue_exact: true, compact: true }, bOpts);
456
+ const bList: any[] = (b && Array.isArray(b.list)) ? b.list : [];
457
+ for (const it of bList) { if (it && typeof it === 'object') (it as any)._fromBgChain = true; batch.push(it); }
458
+ const prevNewest = state.newestBgId;
459
+ noteBgIds(state, bList);
460
+ if (prevNewest && !(b && b.endOfList) &&
461
+ !bList.some((it: any) => it && it.id === prevNewest)) {
462
+ state.bgEnd = false;
463
+ state.bgStarted = true;
464
+ }
465
+ } else {
466
+ let hops = 0;
467
+ while (!state.bgEnd && hops < BG_COVERAGE_MAX_PAGES) {
468
+ hops++;
469
+ const bOpts: any = { fetchMore: state.bgStarted };
470
+ if (limit) bOpts.limit = limit;
471
+ const b = await fetch({ ...base, queue: bgQueue, queue_exact: true, compact: true }, bOpts);
472
+ state.bgStarted = true;
473
+ const bList: any[] = (b && Array.isArray(b.list)) ? b.list : [];
474
+ for (const it of bList) { if (it && typeof it === 'object') (it as any)._fromBgChain = true; batch.push(it); }
475
+ noteBgIds(state, bList);
476
+ state.bgEnd = !!(b && b.endOfList);
477
+ if (!bList.length && !state.bgEnd) break;
478
+ if (state.bgEnd) break;
479
+ }
480
+ }
481
+ return { list: batch, endOfList: state.surfaceEnd && state.bgEnd };
482
+ } finally {
483
+ releaseLock();
484
+ }
485
+ })();
486
+ return {
487
+ list: emitNow,
488
+ // A head-refreshed ended chain KNOWS it is still ended — reporting
489
+ // the hardcoded false here was what un-gated the fill loop on every
490
+ // tab return. Mid-walk it computes to false exactly as before (this
491
+ // branch is only entered with bgEnd false then); the bg batch still
492
+ // carries the final word for that case.
493
+ endOfList: state.surfaceEnd && state.bgEnd,
494
+ startKeyHistory: surface.startKeyHistory,
495
+ firstLoad,
496
+ bgPending,
497
+ };
498
+ }
499
+
500
+ // Carried-over surface items (held back by an uncovered boundary on the
501
+ // previous page) lead this page's surface list.
502
+ const surfaceList = state.surfaceCarry.length ? state.surfaceCarry.concat(surface.list) : surface.list.slice();
503
+
504
+ // The tiling line: everything at-or-newer than this is emitted now, older
505
+ // bg items wait in the buffer. A finished surface (endOfList) opens the
506
+ // line all the way (-Infinity) so the bg side drains over the next pages.
507
+ // An empty surface page past the loop above (a pathological pure-bg band
508
+ // beyond both the lambda's and our own hop caps) yields Infinity — no bg
509
+ // drain, an empty page, and the consumers' empty-page guards take over.
510
+ const boundary = surface.endOfList ? -Infinity : oldestCreated(surfaceList);
511
+
512
+ // ── bg coverage ─────────────────────────────────────────────────────────
513
+ if (headRefresh) {
514
+ // Non-deferred head refresh: the same single HEAD page the deferred
515
+ // branch fetches, under the same rules — new settles are caught, the
516
+ // end-knowledge (bgEnd/bgStarted) is left untouched, EXCEPT when a full
517
+ // head page no longer contains the newest bg id this chain had seen:
518
+ // more than a page landed while hidden, and the walk must reopen or the
519
+ // gap is unfetchable (id-dedup makes re-walking the tail harmless).
520
+ const hOpts: any = { fetchMore: false };
521
+ if (limit) hOpts.limit = limit;
522
+ const hb = await fetch({ ...base, queue: bgQueue, queue_exact: true, compact: true }, hOpts);
523
+ const hbList: any[] = (hb && Array.isArray(hb.list)) ? hb.list : [];
524
+ for (const it of hbList) { if (it && typeof it === 'object') (it as any)._fromBgChain = true; state.bgBuffer.push(it); }
525
+ const prevNewestH = state.newestBgId;
526
+ noteBgIds(state, hbList);
527
+ if (prevNewestH && !(hb && hb.endOfList) &&
528
+ !hbList.some((it: any) => it && it.id === prevNewestH)) {
529
+ state.bgEnd = false;
530
+ state.bgStarted = true;
531
+ }
532
+ } else if (boundary !== Infinity || surface.endOfList) {
533
+ let hops = 0;
534
+ while (!state.bgEnd && hops < BG_COVERAGE_MAX_PAGES) {
535
+ const bufOldest = state.bgBuffer.length ? oldestCreated(state.bgBuffer) : Infinity;
536
+ if (state.bgBuffer.length && bufOldest <= boundary) break;
537
+ hops++;
538
+ const bOpts: any = { fetchMore: state.bgStarted };
539
+ if (limit) bOpts.limit = limit;
540
+ const b = await fetch({ ...base, queue: bgQueue, queue_exact: true, compact: true }, bOpts);
541
+ state.bgStarted = true;
542
+ const bList: any[] = (b && Array.isArray(b.list)) ? b.list : [];
543
+ // Buffer IMMEDIATELY: the SDK cursor has advanced; this is what a
544
+ // retry resumes from. Tagged as bg-chain items so the consumers'
545
+ // surface-frontier logic (retention boundary, clear-horizon) can
546
+ // tell deep stubs from the conversation's own paging frontier.
547
+ for (const it of bList) { if (it && typeof it === 'object') (it as any)._fromBgChain = true; state.bgBuffer.push(it); }
548
+ noteBgIds(state, bList);
549
+ state.bgEnd = !!(b && b.endOfList);
550
+ if (!bList.length && !state.bgEnd) break; // defensive: server loops past empties
551
+ if (state.bgEnd) break;
552
+ }
553
+ }
554
+
555
+ // ── emit ────────────────────────────────────────────────────────────────
556
+ // Everything fetched ships NOW — surface items are never withheld and the
557
+ // bg buffer drains into every page. Ordering across pages is the
558
+ // consumers' job (stable timestamp merge on prepend), which is what
559
+ // removed the old tiling/withholding machinery: it delayed the
560
+ // CONVERSATION behind a full bg drain on every first visit.
561
+ const emitSurface: any[] = surfaceList;
562
+ state.surfaceCarry = [];
563
+ const emitBg: any[] = state.bgBuffer;
564
+ state.bgBuffer = [];
565
+
566
+ // Dedup by item id, surface copy (full bodies) winning — the old-backend
567
+ // degradation path, and a guard against any range overlap.
568
+ const seen: { [id: string]: boolean } = {};
569
+ for (const it of emitSurface) { if (it && typeof it.id === 'string') seen[it.id] = true; }
570
+ const merged = emitSurface.concat(emitBg.filter((it: any) => !(it && typeof it.id === 'string' && seen[it.id])));
571
+
572
+ // ── deliver ─────────────────────────────────────────────────────────────
573
+ // Same head-refresh rule as the deferred branch: page 1's own "more pages
574
+ // exist" must not clobber a preserved end-knowledge.
575
+ if (!headRefresh) state.surfaceEnd = surface.endOfList;
576
+ state.lastSurfaceKeys = surface.startKeyHistory;
577
+ state.pendingSurface = null;
578
+
579
+ return {
580
+ list: merged,
581
+ endOfList: state.surfaceEnd && state.bgEnd && state.bgBuffer.length === 0 && state.surfaceCarry.length === 0,
582
+ // Bookkeeping only (both the consumers and the SDK treat it opaquely);
583
+ // the real cursors are the SDK's internal ones plus this module's state.
584
+ startKeyHistory: surface.startKeyHistory,
585
+ firstLoad,
586
+ };
587
+ }
588
+
589
+
83
590
  export type MapHistoryOptions = {
84
591
  clearedAt: number;
85
592
  projectId: string;
@@ -100,9 +607,19 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
100
607
  var isFailed = item && item.status === 'failed';
101
608
  var response = isFailed ? (item.error != null ? item.error : item.response_body)
102
609
  : (item && item.response_body != null ? item.response_body : item && item.error);
103
- var userText = extractLastUserTextFromRequest(requestBody);
104
- var assistantText = isPending ? '' : ((extractAssistantText(response) || '').trim() || '');
105
- var isErrorResponse = !isPending && (isFailed || isErrorResponseBody(response));
610
+ // COMPACT listing stubs (bg-queue pages fetched with `compact: true`):
611
+ // bodies never left the server; the label line, the response head, and
612
+ // the completion-marker bit arrive as dedicated fields. Everything the
613
+ // COLLAPSED row needs is here; expanding a row fetches the real bodies
614
+ // per item (buildHistoryItemFullId point lookups) and remaps.
615
+ var isCompact = !!(item && item.compact);
616
+ var userText = isCompact
617
+ ? (typeof item.request_text === 'string' ? item.request_text : '')
618
+ : extractLastUserTextFromRequest(requestBody);
619
+ var assistantText = isPending ? '' : (isCompact
620
+ ? ((typeof item.response_text === 'string' ? item.response_text : '').trim())
621
+ : ((extractAssistantText(response) || '').trim() || ''));
622
+ var isErrorResponse = !isPending && (isFailed || (!isCompact && isErrorResponseBody(response)));
106
623
  // Record the completion marker, then STRIP it — both, and in that order.
107
624
  // Recording gives the display layer a structured signal instead of a substring
108
625
  // search over model prose. Stripping matches the live resolution path: without
@@ -112,8 +629,12 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
112
629
  //
113
630
  // Gated on _isBgTask: only an INDEXING pass has a protocol token to hide. An
114
631
  // ordinary reply that merely mentions it keeps its own words.
115
- var reportedComplete = !!(item && item._isBgTask) && !isErrorResponse && !!assistantText &&
116
- assistantText.indexOf(INDEXING_COMPLETE_MARKER) !== -1;
632
+ var reportedComplete = !!(item && item._isBgTask) && !isErrorResponse && (isCompact
633
+ ? item.response_complete_marker === true
634
+ : (!!assistantText && assistantText.indexOf(INDEXING_COMPLETE_MARKER) !== -1));
635
+ // Still gated on the recorded marker (i.e. an INDEXING pass): an ordinary
636
+ // reply that merely mentions the token keeps its own words. A compact
637
+ // head can carry the literal token too, so the strip covers both forms.
117
638
  if (reportedComplete) assistantText = assistantText.split(INDEXING_COMPLETE_MARKER).join('').trim();
118
639
  var serverItemId = item && typeof item.id === 'string' && item.id ? item.id : undefined;
119
640
  // A USER bubble shows when the request was made (`created`); an ASSISTANT
@@ -152,9 +673,11 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
152
673
  displayContent = sanitizeAttachmentLinksForHistory(userText, opts.projectId);
153
674
  }
154
675
  var userMsg: any = { role: 'user', content: displayContent };
676
+ if (item._fromBgChain) userMsg._fromBgChain = true;
155
677
  if (isInProcess) userMsg.isPendingInProcess = true;
156
678
  if (isQueued) userMsg.isPendingQueued = true;
157
679
  if (isCancelledItem) userMsg.isCancelled = true;
680
+ if (isCompact) userMsg._compact = true;
158
681
  if (item._isBgTask) userMsg.isBackgroundTask = true;
159
682
  if (indexFile) userMsg._indexFile = indexFile;
160
683
  if (item._isOnBgQueue) userMsg._useBgQueue = true;
@@ -165,12 +688,17 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
165
688
  if (isCancelledItem) { /* no assistant bubble */ }
166
689
  else if (isInProcess) {
167
690
  var ph: any = { role: 'assistant', content: '', isPending: true, isPendingInProcess: true };
691
+ // _ts matters even on a placeholder: the consumers' merge and any
692
+ // display fallback must be able to place it without its user bubble.
693
+ if (userTs !== undefined) ph._ts = userTs;
694
+ if (item._fromBgChain) ph._fromBgChain = true;
168
695
  if (item._isBgTask) ph.isBackgroundTask = true;
169
696
  if (serverItemId !== undefined) { ph._serverItemId = serverItemId; runningItemIds.push(serverItemId); }
170
697
  mapped.push(ph);
171
698
  } else if (isQueued) { /* no assistant placeholder */ }
172
699
  else if (isErrorResponse) {
173
700
  var em: any = { role: 'assistant', content: getErrorMessage(response), isError: true };
701
+ if (item._fromBgChain) em._fromBgChain = true;
174
702
  if (item._isBgTask) em.isBackgroundTask = true;
175
703
  if (serverItemId !== undefined) em._serverItemId = serverItemId;
176
704
  if (replyTs !== undefined) em._ts = replyTs;
@@ -183,12 +711,24 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
183
711
  // Safe db-only sanitize (forAssistant) so a volatile db url the model
184
712
  // emitted renders as a re-mintable `_expired_.url` link, not a dead one.
185
713
  var okm: any = { role: 'assistant', content: sanitizeAttachmentLinksForHistory(assistantText, opts.projectId, true) || EMPTY_INDEXING_REPLY };
714
+ if (item._fromBgChain) okm._fromBgChain = true;
186
715
  if (item._isBgTask) okm.isBackgroundTask = true;
716
+ if (isCompact) okm._compact = true;
187
717
  if (serverItemId !== undefined) okm._serverItemId = serverItemId;
188
718
  if (replyTs !== undefined) okm._ts = replyTs;
189
719
  if (reportedComplete) okm._indexComplete = true;
190
720
  mapped.push(okm);
191
721
  }
192
722
  });
723
+ // Stamp the CHAT every bubble belongs to. Every cross-project guard in the
724
+ // consumers reads `_ownerKey` and is written "undefined means unknown, keep
725
+ // it" — and the mapper never set it, so SERVER-history bubbles (which is all
726
+ // of them, including every indexing row) passed every one of those filters
727
+ // unchallenged. That is what let one project's transcript survive on screen
728
+ // into another project and be persisted under its key.
729
+ if (opts.projectId) {
730
+ var ownerKey = opts.projectId + '#' + platform;
731
+ for (var oi = 0; oi < mapped.length; oi++) mapped[oi]._ownerKey = ownerKey;
732
+ }
193
733
  return { messages: mapped, runningItemIds: runningItemIds };
194
734
  }
@@ -97,6 +97,10 @@ export interface ChatMessage {
97
97
  * which is how an 88-page file once "finished" at page 15. */
98
98
  _indexComplete?: boolean;
99
99
  _useBgQueue?: boolean;
100
+ /** Mapped from an item delivered by the bg chain of the split history fetch
101
+ * (stubs or deferred chats). Surface-frontier logic (retention boundary,
102
+ * clear-horizon) skips these — their ids reach arbitrarily deep. */
103
+ _fromBgChain?: boolean;
100
104
  /** Local id of a turn STAGED at Send time while its attachments upload. The
101
105
  * bubble exists before any server request does, so it is never matched by
102
106
  * _serverItemId and is never promoted/cancelled by the queue machinery —
@@ -143,6 +147,9 @@ export interface ChatState {
143
147
  typingAbort: boolean;
144
148
  loadingHistory: boolean;
145
149
  loadingOlderHistory: boolean;
150
+ /** A deferred bg stub batch (first-paint split fetch) is still in flight;
151
+ * views show a small 'loading indexing history' hint while true. */
152
+ bgHistoryLoading: boolean;
146
153
  historyEndOfList: boolean;
147
154
  historyStartKeyHistory: string[];
148
155
  historyRequestToken: number;