From 60df2dfca7a4a78e132d439669a70bb3a3777bc2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 6 Aug 2026 11:46:16 -0700 Subject: [PATCH 1/2] fix(knowledge): align document tag provenance selections with the serialized request The create/upsert document tools counted one provenance selection pair per parseDocumentTags entry, while the write route built targets from the serialized documentTagsData and dropped entries whose value is the empty string. A tag value that is truthy before coercion but stringifies to empty (`[]`, `[null]`, `{ toString: () => '' }`) was therefore counted by the tool and not by the route, and the bundle length check rejected the write with 400. Both sides now read one shared parser over the exact bytes that go on the wire, so their counts cannot diverge. --- .../app/api/knowledge/secret-provenance.ts | 2 +- .../knowledge/secret-provenance-selection.ts | 31 +++++++++ apps/sim/lib/knowledge/secret-provenance.ts | 27 -------- .../tools/knowledge/secret-provenance.test.ts | 66 +++++++++++++++++++ apps/sim/tools/knowledge/secret-provenance.ts | 7 +- 5 files changed, 103 insertions(+), 30 deletions(-) create mode 100644 apps/sim/tools/knowledge/secret-provenance.test.ts diff --git a/apps/sim/app/api/knowledge/secret-provenance.ts b/apps/sim/app/api/knowledge/secret-provenance.ts index fa7fa14de8e..45d45206b4d 100644 --- a/apps/sim/app/api/knowledge/secret-provenance.ts +++ b/apps/sim/app/api/knowledge/secret-provenance.ts @@ -19,13 +19,13 @@ import { importKnowledgePersistedResponseSecretProvenance, type KnowledgeDocumentSourceValue, type KnowledgeDocumentWriteSecretProvenance, - parseKnowledgeDocumentTagProvenanceTargets, } from '@/lib/knowledge/secret-provenance' import { knowledgeDocumentContentSelectionKey, knowledgeDocumentFilenameSelectionKey, knowledgeDocumentTagNameSelectionKey, knowledgeDocumentTagValueSelectionKey, + parseKnowledgeDocumentTagProvenanceTargets, } from '@/lib/knowledge/secret-provenance-selection' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' diff --git a/apps/sim/lib/knowledge/secret-provenance-selection.ts b/apps/sim/lib/knowledge/secret-provenance-selection.ts index 79086bf5eae..8b87a4672f9 100644 --- a/apps/sim/lib/knowledge/secret-provenance-selection.ts +++ b/apps/sim/lib/knowledge/secret-provenance-selection.ts @@ -1,3 +1,34 @@ +export interface KnowledgeDocumentTagProvenanceTarget { + tagName: string + value: unknown +} + +/** + * Parses only tag entries that can causally contribute a persisted tag value. Both the tool that + * builds the request selections and the route that builds the write targets read this one parser, + * so their counts can never diverge. + */ +export function parseKnowledgeDocumentTagProvenanceTargets( + documentTagsData: string | undefined +): KnowledgeDocumentTagProvenanceTarget[] { + if (!documentTagsData) return [] + try { + const parsed: unknown = JSON.parse(documentTagsData) + if (!Array.isArray(parsed)) return [] + return parsed.flatMap((candidate) => { + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return [] + const record = candidate as Record + const tagName = typeof record.tagName === 'string' ? record.tagName.trim() : '' + if (!tagName || record.value === undefined || record.value === null || record.value === '') { + return [] + } + return [{ tagName, value: record.value }] + }) + } catch { + return [] + } +} + export function knowledgeDocumentFilenameSelectionKey(documentIndex: number): string { return `document-filename:${documentIndex}` } diff --git a/apps/sim/lib/knowledge/secret-provenance.ts b/apps/sim/lib/knowledge/secret-provenance.ts index 0ab6ada589b..03802733807 100644 --- a/apps/sim/lib/knowledge/secret-provenance.ts +++ b/apps/sim/lib/knowledge/secret-provenance.ts @@ -56,33 +56,6 @@ export interface KnowledgeDocumentWriteSecretProvenance { }[] } -interface KnowledgeDocumentTagProvenanceTarget { - tagName: string - value: unknown -} - -/** Parses only tag entries that can causally contribute a persisted tag value. */ -export function parseKnowledgeDocumentTagProvenanceTargets( - documentTagsData: string | undefined -): KnowledgeDocumentTagProvenanceTarget[] { - if (!documentTagsData) return [] - try { - const parsed: unknown = JSON.parse(documentTagsData) - if (!Array.isArray(parsed)) return [] - return parsed.flatMap((candidate) => { - if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return [] - const record = candidate as Record - const tagName = typeof record.tagName === 'string' ? record.tagName.trim() : '' - if (!tagName || record.value === undefined || record.value === null || record.value === '') { - return [] - } - return [{ tagName, value: record.value }] - }) - } catch { - return [] - } -} - export type KnowledgeDocumentMetadataField = Exclude< keyof KnowledgeDocumentSourceValue, 'fileUrl' | 'contentHash' diff --git a/apps/sim/tools/knowledge/secret-provenance.test.ts b/apps/sim/tools/knowledge/secret-provenance.test.ts new file mode 100644 index 00000000000..7696aefe82d --- /dev/null +++ b/apps/sim/tools/knowledge/secret-provenance.test.ts @@ -0,0 +1,66 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + knowledgeDocumentContentSelectionKey, + knowledgeDocumentFilenameSelectionKey, + knowledgeDocumentTagNameSelectionKey, + knowledgeDocumentTagValueSelectionKey, + parseKnowledgeDocumentTagProvenanceTargets, +} from '@/lib/knowledge/secret-provenance-selection' +import { selectKnowledgeDocumentWriteSecretProvenance } from '@/tools/knowledge/secret-provenance' +import { formatDocumentTagsForAPI, parseDocumentTags } from '@/tools/shared/tags' + +/** Mirrors the selection keys the knowledge write route derives from the serialized request body. */ +function serverSelectionKeys(documentTags: unknown): string[] { + const { documentTagsData } = formatDocumentTagsForAPI(parseDocumentTags(documentTags)) + return [ + knowledgeDocumentFilenameSelectionKey(0), + knowledgeDocumentContentSelectionKey(0), + ...parseKnowledgeDocumentTagProvenanceTargets(documentTagsData).flatMap((_tag, tagIndex) => [ + knowledgeDocumentTagNameSelectionKey(0, tagIndex), + knowledgeDocumentTagValueSelectionKey(0, tagIndex), + ]), + ] +} + +const EMPTY_STRINGIFYING_TAG_VALUES: readonly [string, unknown][] = [ + ['empty array', []], + ['array of null', [null]], + ['array of undefined', [undefined]], + ['object stringifying to empty', { toString: () => '' }], +] + +describe('selectKnowledgeDocumentWriteSecretProvenance', () => { + it.each(EMPTY_STRINGIFYING_TAG_VALUES)( + 'agrees with the server target count for a tag value that is a %s', + (_label, tagValue) => { + const documentTags = [ + { tagName: 'kept', value: 'value' }, + { tagName: 'dropped', value: tagValue }, + ] + const selections = selectKnowledgeDocumentWriteSecretProvenance({ + name: 'doc.md', + content: 'content', + documentTags, + }) + + expect(selections.map((selection) => selection.key)).toEqual( + serverSelectionKeys(documentTags) + ) + } + ) + + it('keeps tags whose serialized value is non-empty', () => { + const documentTags = { alpha: 'one', beta: 2, gamma: false } + const selections = selectKnowledgeDocumentWriteSecretProvenance({ + name: 'doc.md', + content: 'content', + documentTags, + }) + + expect(selections.map((selection) => selection.key)).toEqual(serverSelectionKeys(documentTags)) + expect(selections).toHaveLength(8) + }) +}) diff --git a/apps/sim/tools/knowledge/secret-provenance.ts b/apps/sim/tools/knowledge/secret-provenance.ts index f9dbfe6c36e..276517b10e7 100644 --- a/apps/sim/tools/knowledge/secret-provenance.ts +++ b/apps/sim/tools/knowledge/secret-provenance.ts @@ -4,9 +4,10 @@ import { knowledgeDocumentFilenameSelectionKey, knowledgeDocumentTagNameSelectionKey, knowledgeDocumentTagValueSelectionKey, + parseKnowledgeDocumentTagProvenanceTargets, } from '@/lib/knowledge/secret-provenance-selection' import { inferDocumentFileInfo } from '@/tools/knowledge/types' -import { parseDocumentTags } from '@/tools/shared/tags' +import { formatDocumentTagsForAPI, parseDocumentTags } from '@/tools/shared/tags' /** Selects each causally independent persisted document field before request serialization. */ export function selectKnowledgeDocumentWriteSecretProvenance(params: { @@ -17,7 +18,9 @@ export function selectKnowledgeDocumentWriteSecretProvenance(params: { const name = typeof params.name === 'string' ? params.name.trim() : '' const content = typeof params.content === 'string' ? params.content.trim() : params.content const filename = inferDocumentFileInfo(name).filename - const tags = parseDocumentTags(params.documentTags) + const tags = parseKnowledgeDocumentTagProvenanceTargets( + formatDocumentTagsForAPI(parseDocumentTags(params.documentTags)).documentTagsData + ) return [ { key: knowledgeDocumentFilenameSelectionKey(0), value: filename }, From 6e07db148e8b294ece7058d4f39283253117568a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 6 Aug 2026 11:55:28 -0700 Subject: [PATCH 2/2] test(knowledge): use as const for the empty-stringifying tag fixture --- apps/sim/tools/knowledge/secret-provenance.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/tools/knowledge/secret-provenance.test.ts b/apps/sim/tools/knowledge/secret-provenance.test.ts index 7696aefe82d..889fe5d1f05 100644 --- a/apps/sim/tools/knowledge/secret-provenance.test.ts +++ b/apps/sim/tools/knowledge/secret-provenance.test.ts @@ -25,12 +25,12 @@ function serverSelectionKeys(documentTags: unknown): string[] { ] } -const EMPTY_STRINGIFYING_TAG_VALUES: readonly [string, unknown][] = [ +const EMPTY_STRINGIFYING_TAG_VALUES = [ ['empty array', []], ['array of null', [null]], ['array of undefined', [undefined]], ['object stringifying to empty', { toString: () => '' }], -] +] as const describe('selectKnowledgeDocumentWriteSecretProvenance', () => { it.each(EMPTY_STRINGIFYING_TAG_VALUES)(