File tree Expand file tree Collapse file tree
Expand file tree Collapse file tree Original file line number Diff line number Diff line change @@ -11,6 +11,7 @@ import type {
1111} from '@/connectors/types'
1212import {
1313 CONNECTOR_MAX_FILE_BYTES ,
14+ isListingTruncated ,
1415 isSkippedDocument ,
1516 markSkipped ,
1617 parseTagDate ,
@@ -285,8 +286,20 @@ export const simConversationsConnector: ConnectorConfig = {
285286 syncContext . simConversationsIndexed = indexedSoFar + indexableCount
286287
287288 if ( capReached ) {
288- // The listing stops short of the source, so it cannot be used to infer deletions.
289- syncContext . listingCapped = true
289+ /**
290+ * Only a genuinely truncated listing blocks deletion reconciliation — see the
291+ * matching note in the files connector. Exhausting the source at exactly
292+ * `maxConversations` is a complete listing.
293+ */
294+ if (
295+ isListingTruncated ( {
296+ capReached,
297+ droppedFromPage : documents . length < items . length ,
298+ morePagesAvailable : pageFilled ,
299+ } )
300+ ) {
301+ syncContext . listingCapped = true
302+ }
290303 return { documents, hasMore : false }
291304 }
292305
Original file line number Diff line number Diff line change @@ -23,6 +23,7 @@ import type {
2323} from '@/connectors/types'
2424import {
2525 CONNECTOR_MAX_FILE_BYTES ,
26+ isListingTruncated ,
2627 isSkippedDocument ,
2728 markSkipped ,
2829 parseMultiValue ,
@@ -381,8 +382,23 @@ export const simFilesConnector: ConnectorConfig = {
381382 syncContext . simFilesIndexed = indexedSoFar + indexableCount
382383
383384 if ( capReached ) {
384- // The listing stops short of the source, so it cannot be used to infer deletions.
385- syncContext . listingCapped = true
385+ /**
386+ * Hitting the cap is not the same as truncating. If the source ran out at
387+ * exactly `maxFiles` — nothing dropped from this page and no further page —
388+ * the listing is complete and safe to reconcile deletions against. Marking it
389+ * capped anyway would suppress reconciliation forever, so a file deleted at the
390+ * source would never leave the knowledge base. Mirrors `decideTaskCap`'s
391+ * `droppedFromPage || (hitLimit && morePagesAvailable)` in the Asana connector.
392+ */
393+ if (
394+ isListingTruncated ( {
395+ capReached,
396+ droppedFromPage : documents . length < items . length ,
397+ morePagesAvailable : pageFilled ,
398+ } )
399+ ) {
400+ syncContext . listingCapped = true
401+ }
386402 return { documents, hasMore : false }
387403 }
388404
Original file line number Diff line number Diff line change 1+ /**
2+ * @vitest -environment node
3+ */
4+ import { describe , expect , it } from 'vitest'
5+ import { isListingTruncated } from '@/connectors/utils'
6+
7+ /**
8+ * `capReached` means the budget is spent, not that anything was left behind.
9+ * Conflating the two suppresses deletion reconciliation on a complete listing, so a
10+ * source deletion could never propagate to the knowledge base.
11+ */
12+ describe ( 'isListingTruncated' , ( ) => {
13+ it ( 'is not truncated when the source runs out exactly at the cap' , ( ) => {
14+ expect (
15+ isListingTruncated ( { capReached : true , droppedFromPage : false , morePagesAvailable : false } )
16+ ) . toBe ( false )
17+ } )
18+
19+ it ( 'is truncated when this page had to drop items' , ( ) => {
20+ expect (
21+ isListingTruncated ( { capReached : true , droppedFromPage : true , morePagesAvailable : false } )
22+ ) . toBe ( true )
23+ } )
24+
25+ it ( 'is truncated when the budget ran out with more pages left' , ( ) => {
26+ expect (
27+ isListingTruncated ( { capReached : true , droppedFromPage : false , morePagesAvailable : true } )
28+ ) . toBe ( true )
29+ } )
30+
31+ /** A full page with budget to spare is just pagination, not truncation. */
32+ it ( 'is not truncated when more pages remain but the cap was not reached' , ( ) => {
33+ expect (
34+ isListingTruncated ( { capReached : false , droppedFromPage : false , morePagesAvailable : true } )
35+ ) . toBe ( false )
36+ } )
37+ } )
Original file line number Diff line number Diff line change @@ -229,6 +229,25 @@ export function takeIndexableWithinCap<T>(
229229 return { documents, indexableCount, capReached : alreadyIndexed + indexableCount >= max }
230230}
231231
232+ /**
233+ * Whether a capped listing actually stopped short of the source.
234+ *
235+ * `takeIndexableWithinCap` reports `capReached` — the budget is spent — which is NOT
236+ * the same as truncation. A source that runs out at exactly the cap yields a complete
237+ * listing, and marking it truncated would suppress deletion reconciliation forever,
238+ * so an item deleted at the source could never leave the knowledge base.
239+ *
240+ * Same rule as `decideTaskCap` in the Asana connector: truncated only when this page
241+ * dropped items, or the budget ran out with more pages still available.
242+ */
243+ export function isListingTruncated ( args : {
244+ capReached : boolean
245+ droppedFromPage : boolean
246+ morePagesAvailable : boolean
247+ } ) : boolean {
248+ return args . droppedFromPage || ( args . capReached && args . morePagesAvailable )
249+ }
250+
232251/**
233252 * Raised by a connector when a file exceeds its size cap mid-download — i.e. the
234253 * listing did not report a size, so the limit is only discovered while streaming.
You can’t perform that action at this time.
0 commit comments