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..889fe5d1f05 --- /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 = [ + ['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)( + '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 },