Skip to content

Commit b8c38c1

Browse files
committed
fix(knowledge): only block deletion reconciliation on a truncated listing
Cursor Bugbot, medium severity. Both connectors set listingCapped whenever takeIndexableWithinCap reported capReached — but capReached means the budget is spent, not that anything was left behind. A source that runs out at exactly maxFiles/maxConversations produces a COMPLETE listing, and marking it partial suppressed shouldReconcileDeletions permanently, so an item deleted at the source could never leave the knowledge base. Extracts the decision as isListingTruncated in connectors/utils.ts, matching decideTaskCap in the Asana connector: truncated only when this page dropped items, or the budget ran out with more pages still available. Both connectors now share it rather than repeating the cap bookkeeping. Mutation-checked: collapsing it back to `return args.capReached` reproduces the reported bug and fails the exhausted-at-exactly-the-cap test.
1 parent be31bd7 commit b8c38c1

4 files changed

Lines changed: 89 additions & 4 deletions

File tree

apps/sim/connectors/sim-conversations/sim-conversations.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
} from '@/connectors/types'
1212
import {
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

apps/sim/connectors/sim-files/sim-files.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
} from '@/connectors/types'
2424
import {
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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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+
})

apps/sim/connectors/utils.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff 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.

0 commit comments

Comments
 (0)