diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts index 88e379dafe7..a210986ef91 100644 --- a/apps/sim/app/api/knowledge/search/route.test.ts +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -700,8 +700,8 @@ describe('Knowledge Search API Route', () => { describe('Optional Query Search', () => { const mockTagDefinitions = [ - { tagSlot: 'tag1', displayName: 'category', fieldType: 'text' }, - { tagSlot: 'tag2', displayName: 'priority', fieldType: 'text' }, + { id: 'tag-category', tagSlot: 'tag1', displayName: 'category', fieldType: 'text' }, + { id: 'tag-priority', tagSlot: 'tag2', displayName: 'priority', fieldType: 'text' }, ] const mockTaggedResults = [ @@ -772,6 +772,321 @@ describe('Knowledge Search API Route', () => { }) }) + it('should resolve tag IDs and derive each filter type and slot server-side', async () => { + const tagOnlyData = { + knowledgeBaseIds: 'kb-123', + tagFilters: [ + { tagId: 'tag-text', value: 'api', fieldType: 'boolean', operator: 'contains' }, + { + tagId: 'tag-number', + value: 10, + valueTo: 20, + fieldType: 'text', + operator: 'between', + }, + { tagId: 'tag-date', value: '2026-07-27', fieldType: 'text', operator: 'lte' }, + { tagId: 'tag-boolean', value: true, fieldType: 'number', operator: 'eq' }, + ], + topK: 10, + } + const tagDefinitions = [ + { id: 'tag-text', tagSlot: 'tag1', displayName: 'category', fieldType: 'text' }, + { id: 'tag-number', tagSlot: 'number1', displayName: 'score', fieldType: 'number' }, + { id: 'tag-date', tagSlot: 'date1', displayName: 'published', fieldType: 'date' }, + { id: 'tag-boolean', tagSlot: 'boolean1', displayName: 'verified', fieldType: 'boolean' }, + ] + + mockCheckKnowledgeBaseAccess.mockResolvedValue({ + hasAccess: true, + knowledgeBase: { + id: 'kb-123', + userId: 'user-123', + name: 'Test KB', + deletedAt: null, + embeddingModel: 'text-embedding-3-small', + }, + }) + mockGetDocumentTagDefinitions.mockResolvedValue(tagDefinitions) + mockHandleTagOnlySearch.mockResolvedValue([]) + + const req = createMockRequest('POST', tagOnlyData) + const response = await POST(req) + + expect(response.status).toBe(200) + expect(mockHandleTagOnlySearch).toHaveBeenCalledWith({ + knowledgeBaseIds: ['kb-123'], + topK: 10, + structuredFilters: [ + { + tagSlot: 'tag1', + fieldType: 'text', + operator: 'contains', + value: 'api', + valueTo: undefined, + }, + { + tagSlot: 'number1', + fieldType: 'number', + operator: 'between', + value: 10, + valueTo: 20, + }, + { + tagSlot: 'date1', + fieldType: 'date', + operator: 'lte', + value: '2026-07-27', + valueTo: undefined, + }, + { + tagSlot: 'boolean1', + fieldType: 'boolean', + operator: 'eq', + value: true, + valueTo: undefined, + }, + ], + }) + }) + + it('should reject a tag ID that does not belong to the selected knowledge base', async () => { + mockCheckKnowledgeBaseAccess.mockResolvedValue({ + hasAccess: true, + knowledgeBase: { + id: 'kb-123', + userId: 'user-123', + name: 'Test KB', + deletedAt: null, + embeddingModel: 'text-embedding-3-small', + }, + }) + mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions) + + const req = createMockRequest('POST', { + knowledgeBaseIds: 'kb-123', + tagFilters: [{ tagId: 'tag-from-another-kb', value: 'api', operator: 'eq' }], + }) + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain( + 'Tag IDs not found in the selected knowledge base: tag-from-another-kb' + ) + expect(mockHandleTagOnlySearch).not.toHaveBeenCalled() + }) + + it('should reject ambiguous filters containing both tag name and tag ID', async () => { + const req = createMockRequest('POST', { + knowledgeBaseIds: 'kb-123', + tagFilters: [ + { + tagName: 'category', + tagId: 'tag-category', + value: 'api', + operator: 'eq', + }, + ], + }) + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toBe('Validation error') + expect(data.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: 'Each tag filter must include exactly one of tagName or tagId', + }), + ]) + ) + }) + + it('should reject a configured filter whose dynamic tag ID resolved empty', async () => { + const req = createMockRequest('POST', { + knowledgeBaseIds: 'kb-123', + query: 'api', + tagFilters: [{ value: 'api', operator: 'eq' }], + }) + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toBe('Validation error') + expect(data.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: 'Each tag filter must include exactly one of tagName or tagId', + }), + ]) + ) + }) + + it('should reject tag ID filters across multiple knowledge bases', async () => { + mockCheckKnowledgeBaseAccess.mockImplementation(async (knowledgeBaseId: string) => ({ + hasAccess: true, + knowledgeBase: { + id: knowledgeBaseId, + userId: 'user-123', + name: 'Test KB', + deletedAt: null, + embeddingModel: 'text-embedding-3-small', + }, + })) + + const req = createMockRequest('POST', { + knowledgeBaseIds: ['kb-123', 'kb-456'], + tagFilters: [{ tagId: 'tag-category', value: 'api', operator: 'eq' }], + }) + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain('Tag ID filters can only search one knowledge base at a time') + expect(mockGetDocumentTagDefinitions).not.toHaveBeenCalled() + }) + + it('should validate the operator against the tag type resolved from the ID', async () => { + mockCheckKnowledgeBaseAccess.mockResolvedValue({ + hasAccess: true, + knowledgeBase: { + id: 'kb-123', + userId: 'user-123', + name: 'Test KB', + deletedAt: null, + embeddingModel: 'text-embedding-3-small', + }, + }) + mockGetDocumentTagDefinitions.mockResolvedValue([ + { id: 'tag-enabled', tagSlot: 'tag7', displayName: 'enabled', fieldType: 'boolean' }, + ]) + + const req = createMockRequest('POST', { + knowledgeBaseIds: 'kb-123', + tagFilters: [{ tagId: 'tag-enabled', value: true, operator: 'contains' }], + }) + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain('Operator "contains" is not valid for boolean tag "enabled"') + expect(mockHandleTagOnlySearch).not.toHaveBeenCalled() + }) + + it.each([ + { + label: 'missing second value', + fieldType: 'number', + valueTo: undefined, + expectedError: 'requires a second value for the "between" operator', + }, + { + label: 'invalid number', + fieldType: 'number', + valueTo: 'not-a-number', + expectedError: 'Invalid second value for "between"', + }, + { + label: 'invalid date', + fieldType: 'date', + valueTo: '2026-02-31', + expectedError: 'Invalid second value for "between"', + }, + ])( + 'should reject $label in an ID-resolved between filter', + async ({ fieldType, valueTo, expectedError }) => { + mockCheckKnowledgeBaseAccess.mockResolvedValue({ + hasAccess: true, + knowledgeBase: { + id: 'kb-123', + userId: 'user-123', + name: 'Test KB', + deletedAt: null, + embeddingModel: 'text-embedding-3-small', + }, + }) + mockGetDocumentTagDefinitions.mockResolvedValue([ + { + id: 'tag-range', + tagSlot: fieldType === 'date' ? 'date1' : 'number1', + displayName: 'range', + fieldType, + }, + ]) + + const req = createMockRequest('POST', { + knowledgeBaseIds: 'kb-123', + tagFilters: [ + { + tagId: 'tag-range', + value: fieldType === 'date' ? '2026-01-01' : 1, + valueTo, + operator: 'between', + }, + ], + }) + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain(expectedError) + expect(mockHandleTagOnlySearch).not.toHaveBeenCalled() + } + ) + + it.each([ + { + label: 'missing second value', + valueTo: undefined, + expectedError: 'requires a second value for the "between" operator', + }, + { + label: 'invalid second value', + valueTo: 'not-a-number', + expectedError: 'Invalid second value for "between"', + }, + ])( + 'should reject $label in a name-resolved between filter', + async ({ valueTo, expectedError }) => { + mockCheckKnowledgeBaseAccess.mockResolvedValue({ + hasAccess: true, + knowledgeBase: { + id: 'kb-123', + userId: 'user-123', + name: 'Test KB', + deletedAt: null, + embeddingModel: 'text-embedding-3-small', + }, + }) + mockGetDocumentTagDefinitions.mockResolvedValue([ + { + id: 'tag-range', + tagSlot: 'number1', + displayName: 'range', + fieldType: 'number', + }, + ]) + + const req = createMockRequest('POST', { + knowledgeBaseIds: 'kb-123', + tagFilters: [ + { + tagName: 'range', + value: 1, + valueTo, + operator: 'between', + }, + ], + }) + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain(expectedError) + expect(mockHandleTagOnlySearch).not.toHaveBeenCalled() + } + ) + it('should perform query + tag combination search', async () => { const combinedData = { knowledgeBaseIds: 'kb-123', diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index aa337524f6a..7797afa3856 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -21,6 +21,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' import { getEmbeddingModelInfo } from '@/lib/knowledge/embedding-models' +import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' import { rerank } from '@/lib/knowledge/reranker' import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' @@ -93,6 +94,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let structuredFilters: StructuredFilter[] = [] if (validatedData.tagFilters && accessibleKbIds.length > 0) { + const hasTagIdFilters = validatedData.tagFilters.some((filter) => Boolean(filter.tagId)) + if (hasTagIdFilters && accessibleKbIds.length > 1) { + return NextResponse.json( + { + error: + 'Tag ID filters can only search one knowledge base at a time. Search those knowledge bases separately.', + }, + { status: 400 } + ) + } + const kbTagDefs = await Promise.all( accessibleKbIds.map(async (kbId) => ({ kbId, @@ -100,17 +112,39 @@ export const POST = withRouteHandler(async (request: NextRequest) => { })) ) - const displayNameToTagDef: Record = {} + const resolvedTagDefinitions = new Map< + string, + { tagSlot: string; fieldType: FilterFieldType; displayName: string } + >() + const getFilterKey = (filter: { tagName?: string; tagId?: string }) => + filter.tagId ? `id:${filter.tagId}` : `name:${filter.tagName}` + for (const { kbId, tagDefs } of kbTagDefs) { - const perKbMap = new Map( + const perKbNameMap = new Map( tagDefs.map((def) => [ def.displayName, - { tagSlot: def.tagSlot, fieldType: def.fieldType }, + { + tagSlot: def.tagSlot, + fieldType: def.fieldType as FilterFieldType, + displayName: def.displayName, + }, + ]) + ) + const perKbIdMap = new Map( + tagDefs.map((def) => [ + def.id, + { + tagSlot: def.tagSlot, + fieldType: def.fieldType as FilterFieldType, + displayName: def.displayName, + }, ]) ) for (const filter of validatedData.tagFilters) { - const current = perKbMap.get(filter.tagName) + const current = filter.tagId + ? perKbIdMap.get(filter.tagId) + : perKbNameMap.get(filter.tagName!) if (!current) { if (accessibleKbIds.length > 1) { return NextResponse.json( @@ -123,7 +157,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { continue } - const existing = displayNameToTagDef[filter.tagName] + const filterKey = getFilterKey(filter) + const existing = resolvedTagDefinitions.get(filterKey) if ( existing && (existing.tagSlot !== current.tagSlot || existing.fieldType !== current.fieldType) @@ -136,7 +171,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - displayNameToTagDef[filter.tagName] = current + resolvedTagDefinitions.set(filterKey, current) } logger.debug(`[${requestId}] Loaded tag definitions for KB ${kbId}`, { @@ -145,47 +180,98 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const undefinedTags: string[] = [] + const invalidTagIds: string[] = [] const typeErrors: string[] = [] + const operatorErrors: string[] = [] for (const filter of validatedData.tagFilters) { - const tagDef = displayNameToTagDef[filter.tagName] + const tagDef = resolvedTagDefinitions.get(getFilterKey(filter)) if (!tagDef) { - undefinedTags.push(filter.tagName) + if (filter.tagId) { + invalidTagIds.push(filter.tagId) + } else if (filter.tagName) { + undefinedTags.push(filter.tagName) + } continue } const validationError = validateTagValue( - filter.tagName, + tagDef.displayName, String(filter.value), tagDef.fieldType ) if (validationError) { typeErrors.push(validationError) } + + const validOperators = getOperatorsForFieldType(tagDef.fieldType) + const operatorIsValid = validOperators.some( + (operator) => operator.value === filter.operator + ) + if (!operatorIsValid) { + operatorErrors.push( + `Operator "${filter.operator}" is not valid for ${tagDef.fieldType} tag "${tagDef.displayName}"` + ) + } + + if (operatorIsValid && filter.operator === 'between') { + const secondValueMissing = + filter.valueTo === undefined || + (typeof filter.valueTo === 'string' && filter.valueTo.trim() === '') + if (secondValueMissing) { + typeErrors.push( + `Tag "${tagDef.displayName}" requires a second value for the "between" operator` + ) + } else { + const secondValueError = validateTagValue( + tagDef.displayName, + String(filter.valueTo), + tagDef.fieldType + ) + if (secondValueError) { + typeErrors.push(`Invalid second value for "between": ${secondValueError}`) + } + } + } } - if (undefinedTags.length > 0 || typeErrors.length > 0) { + if ( + undefinedTags.length > 0 || + invalidTagIds.length > 0 || + typeErrors.length > 0 || + operatorErrors.length > 0 + ) { const errorParts: string[] = [] if (undefinedTags.length > 0) { errorParts.push(buildUndefinedTagsError(undefinedTags)) } + if (invalidTagIds.length > 0) { + errorParts.push( + `Tag IDs not found in the selected knowledge base: ${invalidTagIds.join(', ')}` + ) + } + if (typeErrors.length > 0) { errorParts.push(...typeErrors) } + if (operatorErrors.length > 0) { + errorParts.push(...operatorErrors) + } + return NextResponse.json({ error: errorParts.join('\n') }, { status: 400 }) } structuredFilters = validatedData.tagFilters.map((filter) => { - const tagDef = displayNameToTagDef[filter.tagName]! + const tagDef = resolvedTagDefinitions.get(getFilterKey(filter))! const tagSlot = tagDef.tagSlot const fieldType = tagDef.fieldType logger.debug( - `[${requestId}] Structured filter: ${filter.tagName} -> ${tagSlot} (${fieldType}) ${filter.operator} ${filter.value}` + `[${requestId}] Structured filter: ${filter.tagId ?? filter.tagName} -> ${tagSlot} (${fieldType}) ${filter.operator} ${filter.value}` ) return { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.test.tsx new file mode 100644 index 00000000000..ba97f4eb2a8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.test.tsx @@ -0,0 +1,145 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCopy, mockRefetchTagUsage } = vi.hoisted(() => ({ + mockCopy: vi.fn(), + mockRefetchTagUsage: vi.fn(), +})) + +vi.mock('@sim/emcn', async () => { + const { useState } = await import('react') + + const Container = ({ children }: { children?: ReactNode }) =>
{children}
+ + return { + Button: ({ + children, + variant: _variant, + ...props + }: React.ButtonHTMLAttributes & { variant?: string }) => ( + + ), + ChipCombobox: () => null, + ChipConfirmModal: ({ open, children }: { open: boolean; children?: ReactNode }) => + open ?
{children}
: null, + ChipInput: () => null, + ChipModal: ({ open, children }: { open: boolean; children?: ReactNode }) => + open ?
{children}
: null, + ChipModalBody: Container, + ChipModalField: ({ children, title }: { children?: ReactNode; title?: ReactNode }) => ( +
+ {title} + {children} +
+ ), + ChipModalFooter: Container, + ChipModalHeader: Container, + handleKeyboardActivation: vi.fn(), + Tooltip: { + Root: Container, + Trigger: Container, + Content: Container, + }, + Duplicate: (props: React.SVGProps) => , + Trash: (props: React.SVGProps) => , + useCopyToClipboard: () => { + const [copied, setCopied] = useState(false) + return { + copied, + copy: async (text: string) => { + await mockCopy(text) + setCopied(true) + return true + }, + } + }, + } +}) + +vi.mock('@/app/workspace/[workspaceId]/knowledge/components', () => ({ + getDocumentIcon: () => (props: React.SVGProps) => , +})) + +vi.mock('@/hooks/kb/use-knowledge-base-tag-definitions', () => ({ + useKnowledgeBaseTagDefinitions: () => ({ + tagDefinitions: [ + { + id: 'tag-definition-uuid', + tagSlot: 'tag1', + displayName: 'category', + fieldType: 'text', + }, + ], + }), +})) + +vi.mock('@/hooks/queries/kb/knowledge', () => ({ + useCreateTagDefinition: () => ({ isPending: false, mutateAsync: vi.fn() }), + useDeleteTagDefinition: () => ({ isPending: false, mutateAsync: vi.fn() }), + useTagUsageQuery: () => ({ + data: [ + { + tagName: 'category', + tagSlot: 'tag1', + documentCount: 0, + documents: [], + }, + ], + refetch: mockRefetchTagUsage, + }), +})) + +import { BaseTagsModal } from './base-tags-modal' + +let container: HTMLDivElement +let root: Root + +describe('BaseTagsModal tag ID copy control', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + mockCopy.mockResolvedValue(undefined) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() + }) + + it('copies the tag UUID, shows feedback, and does not open tag usage', async () => { + await act(async () => { + root.render() + }) + + const copyButton = container.querySelector( + 'button[aria-label="Copy category tag ID"]' + ) as HTMLButtonElement + const deleteButton = container.querySelector( + 'button[aria-label="Delete category tag"]' + ) as HTMLButtonElement + + expect(copyButton).toBeTruthy() + expect(deleteButton).toBeTruthy() + expect(copyButton.querySelector('[data-icon="duplicate"]')).toBeTruthy() + expect( + copyButton.compareDocumentPosition(deleteButton) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + + await act(async () => { + copyButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(mockCopy).toHaveBeenCalledWith('tag-definition-uuid') + expect(mockRefetchTagUsage).not.toHaveBeenCalled() + expect(container.textContent).toContain('Copied') + expect(copyButton.querySelector('svg')?.classList.contains('text-[var(--text-success)]')).toBe( + true + ) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx index 161db5369df..9aab374791c 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx @@ -12,10 +12,14 @@ import { ChipModalFooter, ChipModalHeader, type ComboboxOption, + Duplicate, handleKeyboardActivation, + Tooltip, Trash, + useCopyToClipboard, } from '@sim/emcn' import { createLogger } from '@sim/logger' +import { Check } from 'lucide-react' import type { TagUsageData } from '@/lib/api/contracts/knowledge' import { SUPPORTED_FIELD_TYPES, TAG_SLOT_CONFIG } from '@/lib/knowledge/constants' import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' @@ -99,6 +103,8 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM displayName: '', fieldType: 'text', }) + const [copiedTagId, setCopiedTagId] = useState(null) + const { copied, copy } = useCopyToClipboard() const { data: tagUsageData = [], refetch: refetchTagUsage } = useTagUsageQuery(knowledgeBaseId, { enabled: open, @@ -127,6 +133,12 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM setViewDocumentsDialogOpen(true) } + const handleCopyTagId = async (tagId: string) => { + if (await copy(tagId)) { + setCopiedTagId(tagId) + } + } + const openTagCreator = () => { setCreateTagForm({ displayName: '', @@ -291,13 +303,38 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM {usage.documentCount} document{usage.documentCount !== 1 ? 's' : ''}
+ + + + + + {copied && copiedTagId === tag.id ? 'Copied' : 'Copy tag ID'} + + diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.test.tsx new file mode 100644 index 00000000000..fe57cd78935 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.test.tsx @@ -0,0 +1,208 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ChangeEvent } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSetStoreValue, mockStoreState } = vi.hoisted(() => ({ + mockSetStoreValue: vi.fn(), + mockStoreState: { + value: null as string | null, + }, +})) + +const initialFilters = [ + { + id: 'filter-1', + tagName: '', + tagId: 'tag-text', + tagSlot: 'tag1', + fieldType: 'text', + operator: 'contains', + tagValue: 'api', + valueTo: 'secondary', + collapsed: false, + }, +] + +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight', + () => ({ + getActiveWorkflowSearchHighlight: () => undefined, + }) +) + +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate', + () => ({ + useDependsOnGate: () => ({ + dependencyValues: { knowledgeBaseSelector: 'kb-1' }, + }), + }) +) + +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input', + async () => { + const { useState } = await import('react') + + return { + useSubBlockInput: () => { + const [, setFocusCount] = useState(0) + + return { + fieldHelpers: { + getFieldState: () => ({ showTags: false }), + createFieldHandlers: ( + _key: string, + _value: string, + onChange: (value: string) => void + ) => ({ + onChange: (event: ChangeEvent) => onChange(event.target.value), + onKeyDown: vi.fn(), + onDrop: vi.fn(), + onDragOver: vi.fn(), + onFocus: () => setFocusCount((count) => count + 1), + }), + createTagSelectHandler: vi.fn(), + hideFieldDropdowns: vi.fn(), + }, + } + }, + } + } +) + +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider', + () => ({ + useActiveSearchTarget: () => null, + }) +) + +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes', + () => ({ + useAccessibleReferencePrefixes: () => null, + }) +) + +vi.mock('@/hooks/kb/use-knowledge-base-tag-definitions', () => ({ + useKnowledgeBaseTagDefinitions: () => ({ + tagDefinitions: [ + { id: 'tag-text', tagSlot: 'tag1', displayName: 'category', fieldType: 'text' }, + { id: 'tag-number', tagSlot: 'number1', displayName: 'score', fieldType: 'number' }, + ], + isLoading: false, + }), +})) + +vi.mock('@/hooks/kb/use-tag-selection', () => ({ + useTagSelection: () => vi.fn(), +})) + +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value', + () => ({ + useSubBlockValue: () => [mockStoreState.value, mockSetStoreValue], + }) +) + +import { KnowledgeTagFilters } from './knowledge-tag-filters' + +let container: HTMLDivElement +let root: Root + +describe('KnowledgeTagFilters Tag ID editing', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + mockStoreState.value = JSON.stringify(initialFilters) + mockSetStoreValue.mockClear() + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() + }) + + const renderTagFilters = async () => { + await act(async () => { + root.render( + + ) + }) + + return container.querySelector('input[placeholder="Enter tag ID"]') + } + + const changeTagId = async (input: HTMLInputElement, value: string) => { + await act(async () => { + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + valueSetter?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + + const serializedFilters = mockSetStoreValue.mock.lastCall?.[0] as string + return JSON.parse(serializedFilters)[0] + } + + it('preserves values for unresolved edits and resets them once a different literal ID resolves', async () => { + const input = await renderTagFilters() + expect(input).not.toBeNull() + + const unresolvedFilter = await changeTagId(input as HTMLInputElement, 'tag-numbe') + expect(unresolvedFilter).toMatchObject({ + tagId: 'tag-numbe', + operator: 'contains', + tagValue: 'api', + valueTo: 'secondary', + }) + + const resolvedFilter = await changeTagId(input as HTMLInputElement, 'tag-number') + expect(resolvedFilter).toMatchObject({ + tagId: 'tag-number', + tagSlot: 'number1', + fieldType: 'number', + operator: 'eq', + tagValue: '', + }) + expect(resolvedFilter.valueTo).toBeUndefined() + }) + + it('keeps a brand-new Tag ID input focused and accepts its first pasted value', async () => { + mockStoreState.value = null + const input = await renderTagFilters() + expect(input).not.toBeNull() + + await act(async () => { + input?.focus() + }) + + const focusedInput = container.querySelector( + 'input[placeholder="Enter tag ID"]' + ) + expect(focusedInput).toBe(input) + expect(document.activeElement).toBe(input) + + const pastedFilter = await changeTagId(focusedInput as HTMLInputElement, 'tag-text') + expect(pastedFilter).toMatchObject({ + tagId: 'tag-text', + tagSlot: 'tag1', + fieldType: 'text', + operator: 'eq', + }) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx index ba68be1525f..e99358bae07 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx @@ -1,6 +1,6 @@ 'use client' -import { useRef } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { Badge, Button, @@ -14,7 +14,11 @@ import { } from '@sim/emcn' import { generateId } from '@sim/utils/id' import { Plus } from 'lucide-react' -import { FIELD_TYPE_LABELS, getPlaceholderForFieldType } from '@/lib/knowledge/constants' +import { + FIELD_TYPE_LABELS, + getPlaceholderForFieldType, + SUPPORTED_FIELD_TYPES, +} from '@/lib/knowledge/constants' import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' @@ -32,6 +36,7 @@ import { useSubBlockValue } from '../../hooks/use-sub-block-value' interface TagFilter { id: string tagName: string + tagId?: string tagSlot?: string fieldType: FilterFieldType operator: string @@ -55,12 +60,21 @@ interface KnowledgeTagFiltersProps { const createDefaultFilter = (): TagFilter => ({ id: generateId(), tagName: '', + tagId: '', fieldType: 'text', operator: 'eq', tagValue: '', collapsed: false, }) +const ALL_OPERATOR_OPTIONS = Array.from( + new Map( + SUPPORTED_FIELD_TYPES.flatMap((fieldType) => + getOperatorsForFieldType(fieldType).map((operator) => [operator.value, operator]) + ) + ).values() +) + export function KnowledgeTagFilters({ blockId, subBlock, @@ -80,7 +94,12 @@ export function KnowledgeTagFilters({ isPreview, previewContextValues, }) - const knowledgeBaseIdValue = dependencyValues.knowledgeBaseSelector + const canonicalKnowledgeBaseIdValue = previewContextValues?.knowledgeBaseId + const knowledgeBaseIdValue = + typeof canonicalKnowledgeBaseIdValue === 'string' && + canonicalKnowledgeBaseIdValue.trim().length > 0 + ? canonicalKnowledgeBaseIdValue + : dependencyValues.knowledgeBaseSelector const knowledgeBaseId = typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0 ? knowledgeBaseIdValue @@ -101,18 +120,63 @@ export function KnowledgeTagFilters({ disabled, }) - const parseFilters = (filterValue: unknown): TagFilter[] => - parseJsonArrayValue(filterValue).map((f) => ({ - ...f, - fieldType: f.fieldType || 'text', - operator: f.operator || 'eq', - collapsed: f.collapsed ?? false, - })) - const currentValue = isPreview ? previewValue : storeValue - const parsedFilters = parseFilters(currentValue) - const filters: TagFilter[] = parsedFilters.length > 0 ? parsedFilters : [createDefaultFilter()] + const parsedFilters = useMemo( + () => + parseJsonArrayValue(currentValue).map((filter) => ({ + ...filter, + fieldType: filter.fieldType || 'text', + operator: filter.operator || 'eq', + collapsed: filter.collapsed ?? false, + })), + [currentValue] + ) + const defaultFilter = useMemo(createDefaultFilter, []) + const filters: TagFilter[] = parsedFilters.length > 0 ? parsedFilters : [defaultFilter] const isReadOnly = isPreview || disabled + const usesTagIds = subBlock.mode === 'advanced' + const getResolvedTagDefinition = (filter: TagFilter) => + usesTagIds ? tagDefinitions.find((tag) => tag.id === filter.tagId) : undefined + const getEffectiveFieldType = (filter: TagFilter): FilterFieldType => + (getResolvedTagDefinition(filter)?.fieldType || filter.fieldType || 'text') as FilterFieldType + + useEffect(() => { + if (isReadOnly || !usesTagIds || parsedFilters.length === 0) return + + let changed = false + const normalizedFilters = parsedFilters.map((filter) => { + const tagDefinition = tagDefinitions.find((tag) => tag.id === filter.tagId) + if (!tagDefinition) return filter + + const fieldType = tagDefinition.fieldType as FilterFieldType + const validOperators = getOperatorsForFieldType(fieldType) + const operatorIsValid = validOperators.some((operator) => operator.value === filter.operator) + const operator = operatorIsValid ? filter.operator : validOperators[0]?.value || 'eq' + const valueTo = operator === 'between' ? filter.valueTo : undefined + + if ( + filter.tagSlot === tagDefinition.tagSlot && + filter.fieldType === fieldType && + filter.operator === operator && + filter.valueTo === valueTo + ) { + return filter + } + + changed = true + return { + ...filter, + tagSlot: tagDefinition.tagSlot, + fieldType, + operator, + valueTo, + } + }) + + if (changed) { + setStoreValue(JSON.stringify(normalizedFilters)) + } + }, [isReadOnly, parsedFilters, setStoreValue, tagDefinitions, usesTagIds]) /** * Updates the store with new filters @@ -166,6 +230,18 @@ export function KnowledgeTagFilters({ updated.valueTo = undefined } + if (field === 'tagId') { + const tagDef = tagDefinitions.find((t) => t.id === value) + updated.tagSlot = tagDef?.tagSlot + if (tagDef && f.tagId !== value) { + updated.fieldType = tagDef.fieldType as FilterFieldType + const operators = getOperatorsForFieldType(updated.fieldType) + updated.operator = operators[0]?.value || 'eq' + updated.tagValue = '' + updated.valueTo = undefined + } + } + // When field type changes, reset operator and value if (field === 'fieldType') { const operators = getOperatorsForFieldType(value as FilterFieldType) @@ -215,7 +291,9 @@ export function KnowledgeTagFilters({ } if (isPreview) { - const appliedFilters = filters.filter((f) => f.tagName.trim() && f.tagValue.trim()).length + const appliedFilters = filters.filter( + (f) => (usesTagIds ? f.tagId?.trim() : f.tagName.trim()) && f.tagValue.trim() + ).length return (
@@ -244,11 +322,17 @@ export function KnowledgeTagFilters({ >
- {filter.collapsed ? filter.tagName || `Filter ${index + 1}` : `Filter ${index + 1}`} + {filter.collapsed + ? usesTagIds + ? tagDefinitions.find((tag) => tag.id === filter.tagId)?.displayName || + filter.tagId || + `Filter ${index + 1}` + : filter.tagName || `Filter ${index + 1}` + : `Filter ${index + 1}`} - {filter.collapsed && filter.tagName && ( + {filter.collapsed && (usesTagIds ? filter.tagId : filter.tagName) && ( - {FIELD_TYPE_LABELS[filter.fieldType] || 'Text'} + {FIELD_TYPE_LABELS[getEffectiveFieldType(filter)] || 'Text'} )}
@@ -284,10 +368,18 @@ export function KnowledgeTagFilters({ /** * Renders the value input with tag dropdown support */ - const renderValueInput = (filter: TagFilter, field: 'tagValue' | 'valueTo') => { - const fieldValue = field === 'tagValue' ? filter.tagValue : filter.valueTo || '' + const renderConnectedInput = ( + filter: TagFilter, + field: 'tagId' | 'tagValue' | 'valueTo', + placeholder: string + ) => { + const fieldValue = + field === 'tagId' + ? filter.tagId || '' + : field === 'tagValue' + ? filter.tagValue + : filter.valueTo || '' const cellKey = `${filter.id}-${field}` - const placeholder = getPlaceholderForFieldType(filter.fieldType) const filterIndex = filters.findIndex((candidate) => candidate.id === filter.id) const workflowSearchHighlight = getActiveWorkflowSearchHighlight({ activeSearchTarget, @@ -304,7 +396,22 @@ export function KnowledgeTagFilters({ const tagSelectHandler = inputController.fieldHelpers.createTagSelectHandler( cellKey, fieldValue, - (newValue) => handleTagDropdownSelection(filter.id, field, newValue) + (newValue) => { + if (field === 'tagId') { + const updatedFilters = filters.map((candidate) => + candidate.id === filter.id + ? { + ...candidate, + tagId: newValue, + tagSlot: undefined, + } + : candidate + ) + emitTagSelection(JSON.stringify(updatedFilters)) + return + } + handleTagDropdownSelection(filter.id, field, newValue) + } ) return ( @@ -365,6 +472,12 @@ export function KnowledgeTagFilters({ ) } + const renderValueInput = ( + filter: TagFilter, + field: 'tagValue' | 'valueTo', + fieldType: FilterFieldType + ) => renderConnectedInput(filter, field, getPlaceholderForFieldType(fieldType)) + /** * Renders the filter content (tag, operator, value inputs) */ @@ -374,7 +487,12 @@ export function KnowledgeTagFilters({ label: tag.displayName, })) - const operators = getOperatorsForFieldType(filter.fieldType) + const resolvedTagDefinition = getResolvedTagDefinition(filter) + const effectiveFieldType = getEffectiveFieldType(filter) + const operators = + usesTagIds && !resolvedTagDefinition + ? ALL_OPERATOR_OPTIONS + : getOperatorsForFieldType(effectiveFieldType) const operatorOptions: ComboboxOption[] = operators.map((op) => ({ value: op.value, label: op.label, @@ -385,14 +503,18 @@ export function KnowledgeTagFilters({ return (
- - updateFilter(filter.id, 'tagName', value)} - disabled={isReadOnly || isLoading} - placeholder='Select tag' - /> + + {usesTagIds ? ( + renderConnectedInput(filter, 'tagId', 'Enter tag ID') + ) : ( + updateFilter(filter.id, 'tagName', value)} + disabled={isReadOnly || isLoading} + placeholder='Select tag' + /> + )}
@@ -410,12 +532,16 @@ export function KnowledgeTagFilters({ {isBetween ? (
-
{renderValueInput(filter, 'tagValue')}
+
+ {renderValueInput(filter, 'tagValue', effectiveFieldType)} +
to -
{renderValueInput(filter, 'valueTo')}
+
+ {renderValueInput(filter, 'valueTo', effectiveFieldType)} +
) : ( - renderValueInput(filter, 'tagValue') + renderValueInput(filter, 'tagValue', effectiveFieldType) )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.test.tsx new file mode 100644 index 00000000000..e8afc7a7c85 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.test.tsx @@ -0,0 +1,100 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { capturedSubBlockProps, mockSetValue } = vi.hoisted(() => ({ + capturedSubBlockProps: { current: null as Record | null }, + mockSetValue: vi.fn(), +})) + +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block', + () => ({ + SubBlock: (props: Record) => { + capturedSubBlockProps.current = props + return
+ }, + }) +) + +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-dependency-block-type', + () => ({ + DependencyBlockTypeProvider: ({ children }: { children: ReactNode }) => <>{children}, + }) +) + +vi.mock('@/stores/workflows/registry/store', () => ({ + useWorkflowRegistry: { + getState: () => ({ activeWorkflowId: 'workflow-1' }), + }, +})) + +vi.mock('@/stores/workflows/subblock/store', () => ({ + useSubBlockStore: { + subscribe: () => () => undefined, + getState: () => ({ + setValue: mockSetValue, + getValue: () => [], + }), + }, +})) + +import { ToolSubBlockRenderer } from './sub-block-renderer' + +let container: HTMLDivElement +let root: Root + +describe('ToolSubBlockRenderer canonical dependency context', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + capturedSubBlockProps.current = null + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() + }) + + it('passes the scoped canonical dependency value instead of stale raw pair values', async () => { + const rawToolParams = { + knowledgeBaseSelector: 'kb-stale-basic', + manualKnowledgeBaseId: 'kb-active-advanced', + } + const canonicalContext = { + ...rawToolParams, + knowledgeBaseId: 'kb-active-advanced', + } + + await act(async () => { + root.render( + + ) + }) + + expect(capturedSubBlockProps.current?.dependencyContext).toEqual(canonicalContext) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx index 00798f4a976..49ae224bbf4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx @@ -21,6 +21,7 @@ interface ToolSubBlockRendererProps { /** The tool's block type (e.g. `gmail`), so its params' selectors resolve dependencies. */ toolType: string toolParams: Record | undefined + dependencyContextValues?: Record onParamChange: (toolIndex: number, paramId: string, value: string) => void disabled: boolean canonicalToggle?: { @@ -49,6 +50,7 @@ export function ToolSubBlockRenderer({ effectiveParamId, toolType, toolParams, + dependencyContextValues, onParamChange, disabled, canonicalToggle, @@ -129,7 +131,7 @@ export function ToolSubBlockRenderer({ isPreview={false} disabled={disabled} canonicalToggle={canonicalToggle} - dependencyContext={toolParams} + dependencyContext={dependencyContextValues ?? toolParams} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 0b34f353382..03b72e7d73c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -2151,6 +2151,7 @@ export const ToolInput = memo(function ToolInput({ effectiveParamId={effectiveParamId} toolType={tool.type} toolParams={tool.params} + dependencyContextValues={toolContextValues} onParamChange={handleParamChange} disabled={disabled} canonicalToggle={canonicalToggleProp} diff --git a/apps/sim/blocks/blocks/knowledge.ts b/apps/sim/blocks/blocks/knowledge.ts index 659f479dc0d..2cf082beac4 100644 --- a/apps/sim/blocks/blocks/knowledge.ts +++ b/apps/sim/blocks/blocks/knowledge.ts @@ -84,8 +84,20 @@ export const KnowledgeBlock: BlockConfig = { id: 'tagFilters', title: 'Tag Filters', type: 'knowledge-tag-filters', + canonicalParamId: 'tagFilters', placeholder: 'Add tag filters', dependsOn: ['knowledgeBaseSelector'], + mode: 'basic', + condition: { field: 'operation', value: 'search' }, + }, + { + id: 'manualTagFilters', + title: 'Tag Filters', + type: 'knowledge-tag-filters', + canonicalParamId: 'tagFilters', + placeholder: 'Add tag filters by ID', + dependsOn: ['knowledgeBaseSelector'], + mode: 'advanced', condition: { field: 'operation', value: 'search' }, }, { diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts index 2b4aea14ae9..de65bec8a07 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts @@ -578,8 +578,10 @@ export async function copyForkResourceContainers( // schema: the copied documents carry tag VALUES in their slot columns, and both tag-filter // search and documentTags writes resolve display names through these definition rows (a copy // without them 400s / throws on every defined tag). Fresh ids, child KB id, all other columns - // verbatim - nothing persists a tag-definition id (workflow state, documents, and fork - // mappings all reference tags by display name / slot), so no id map is recorded. + // verbatim. Advanced Knowledge tag filters can persist definition ids, but the workflow + // remapper clears literal ID filters when their parent KB moves because tag definitions are + // not a first-class fork mapping resource. Name-based filters and dynamic ID references carry + // over; no tag-definition id map is recorded. if (kbEntryBySourceId.size > 0) { const tagDefinitions = await tx .select() diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-references.test.ts b/apps/sim/ee/workspace-forking/lib/remap/remap-references.test.ts index dbc1a95ac2a..f3cfd711963 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/remap-references.test.ts +++ b/apps/sim/ee/workspace-forking/lib/remap/remap-references.test.ts @@ -1213,7 +1213,17 @@ const knowledgePairBlock = () => id: 'tagFilters', title: 'Tag Filters', type: 'knowledge-tag-filters', + canonicalParamId: 'tagFilters', dependsOn: ['knowledgeBaseSelector'], + mode: 'basic', + }, + { + id: 'manualTagFilters', + title: 'Tag Filters', + type: 'knowledge-tag-filters', + canonicalParamId: 'tagFilters', + dependsOn: ['knowledgeBaseSelector'], + mode: 'advanced', }, { id: 'documentTags', @@ -1275,6 +1285,72 @@ describe('canonical mode policy (fork/promote)', () => { expect(result.knowledgeBaseSelector.value).toBe('') }) + it('clears literal tag IDs when the active basic KB selector is remapped', () => { + vi.mocked(getBlock).mockReturnValue(knowledgePairBlock()) + const transform = createForkBootstrapTransform(resolveCopy as never) + const result = transform( + { + knowledgeBaseSelector: entry('knowledgeBaseSelector', 'knowledge-base-selector', 'kb-src'), + manualKnowledgeBaseId: entry('manualKnowledgeBaseId', 'short-input', ''), + tagFilters: entry('tagFilters', 'knowledge-tag-filters', ''), + manualTagFilters: entry( + 'manualTagFilters', + 'knowledge-tag-filters', + '[{"tagId":"source-tag-definition-id","tagValue":"team"}]' + ), + }, + 'knowledge', + { knowledgeBaseId: 'basic', tagFilters: 'advanced' } + ) + + expect(result.knowledgeBaseSelector.value).toBe('kb-copy') + expect(result.manualTagFilters.value).toBe('') + }) + + it('preserves dynamic tag ID references when the active basic KB selector is remapped', () => { + vi.mocked(getBlock).mockReturnValue(knowledgePairBlock()) + const transform = createForkBootstrapTransform(resolveCopy as never) + const result = transform( + { + knowledgeBaseSelector: entry('knowledgeBaseSelector', 'knowledge-base-selector', 'kb-src'), + manualKnowledgeBaseId: entry('manualKnowledgeBaseId', 'short-input', ''), + tagFilters: entry('tagFilters', 'knowledge-tag-filters', ''), + manualTagFilters: entry( + 'manualTagFilters', + 'knowledge-tag-filters', + '[{"tagId":"","tagValue":"team"}]' + ), + }, + 'knowledge', + { knowledgeBaseId: 'basic', tagFilters: 'advanced' } + ) + + expect(result.knowledgeBaseSelector.value).toBe('kb-copy') + expect(result.manualTagFilters.value).toBe('[{"tagId":"","tagValue":"team"}]') + }) + + it('removes only literal tag IDs from a mixed array when the KB selector is remapped', () => { + vi.mocked(getBlock).mockReturnValue(knowledgePairBlock()) + const transform = createForkBootstrapTransform(resolveCopy as never) + const result = transform( + { + knowledgeBaseSelector: entry('knowledgeBaseSelector', 'knowledge-base-selector', 'kb-src'), + manualKnowledgeBaseId: entry('manualKnowledgeBaseId', 'short-input', ''), + tagFilters: entry('tagFilters', 'knowledge-tag-filters', ''), + manualTagFilters: entry( + 'manualTagFilters', + 'knowledge-tag-filters', + '[{"tagId":"source-tag-definition-id","tagValue":"literal"},{"tagId":"","tagValue":"dynamic"}]' + ), + }, + 'knowledge', + { knowledgeBaseId: 'basic', tagFilters: 'advanced' } + ) + + expect(result.knowledgeBaseSelector.value).toBe('kb-copy') + expect(result.manualTagFilters.value).toBe('[{"tagId":"","tagValue":"dynamic"}]') + }) + it('advanced mode: nothing is detected as a reference (no mapping requirement)', () => { vi.mocked(getBlock).mockReturnValue(knowledgePairBlock()) const scan = scanWorkflowReferences( @@ -1434,6 +1510,157 @@ describe('canonical mode policy (fork/promote)', () => { expect(cleared.params).toEqual({ knowledgeBaseId: '', tagFilters: '' }) }) + it.each([ + { + label: 'clears literal tag IDs', + filterValue: '[{"tagId":"source-tag-definition-id","tagValue":"team"}]', + expected: '', + }, + { + label: 'preserves dynamic tag ID references', + filterValue: '[{"tagId":"","tagValue":"team"}]', + expected: '[{"tagId":"","tagValue":"team"}]', + }, + { + label: 'removes only literal tag IDs from a mixed array', + filterValue: + '[{"tagId":"source-tag-definition-id","tagValue":"literal"},{"tagId":"","tagValue":"dynamic"}]', + expected: '[{"tagId":"","tagValue":"dynamic"}]', + }, + ])('nested tool: $label when its basic KB is remapped', ({ filterValue, expected }) => { + const knowledgeToolConfigs = { + knowledge: { + subBlocks: [ + { + id: 'knowledgeBaseSelector', + title: 'KB', + type: 'knowledge-base-selector', + canonicalParamId: 'knowledgeBaseId', + mode: 'basic', + }, + { + id: 'manualKnowledgeBaseId', + title: 'KB ID', + type: 'short-input', + canonicalParamId: 'knowledgeBaseId', + mode: 'advanced', + }, + { + id: 'tagFilters', + title: 'Tag Filters', + type: 'knowledge-tag-filters', + canonicalParamId: 'tagFilters', + dependsOn: ['knowledgeBaseSelector'], + mode: 'basic', + }, + { + id: 'manualTagFilters', + title: 'Tag Filters', + type: 'knowledge-tag-filters', + canonicalParamId: 'tagFilters', + dependsOn: ['knowledgeBaseSelector'], + mode: 'advanced', + }, + ] as SubBlockConfig[], + }, + } + const result = remapToolBlockResources( + { + type: 'knowledge', + toolId: 'knowledge_search', + params: { + knowledgeBaseId: 'kb-src', + tagFilters: '', + manualTagFilters: filterValue, + }, + }, + { + resolve: (kind, id) => (kind === 'knowledge-base' && id === 'kb-src' ? 'kb-dst' : null), + resolveFileKey: () => null, + clearUnresolved: true, + blockConfigs: knowledgeToolConfigs, + parentCanonicalModes: { + '0:knowledgeBaseId': 'basic', + '0:tagFilters': 'advanced', + }, + toolIndex: 0, + } + ) + + expect(result.params).toEqual({ + knowledgeBaseId: 'kb-dst', + tagFilters: '', + manualTagFilters: expected, + }) + }) + + it('nested tool: clears a dormant raw basic value while preserving the live dynamic advanced value', () => { + const knowledgeToolConfigs = { + knowledge: { + subBlocks: [ + { + id: 'knowledgeBaseSelector', + title: 'KB', + type: 'knowledge-base-selector', + canonicalParamId: 'knowledgeBaseId', + mode: 'basic', + }, + { + id: 'manualKnowledgeBaseId', + title: 'KB ID', + type: 'short-input', + canonicalParamId: 'knowledgeBaseId', + mode: 'advanced', + }, + { + id: 'tagFilters', + title: 'Tag Filters', + type: 'knowledge-tag-filters', + canonicalParamId: 'tagFilters', + dependsOn: ['knowledgeBaseSelector'], + mode: 'basic', + }, + { + id: 'manualTagFilters', + title: 'Tag Filters', + type: 'knowledge-tag-filters', + canonicalParamId: 'tagFilters', + dependsOn: ['knowledgeBaseSelector'], + mode: 'advanced', + }, + ] as SubBlockConfig[], + }, + } + const result = remapToolBlockResources( + { + type: 'knowledge', + toolId: 'knowledge_search', + params: { + knowledgeBaseId: 'kb-src', + tagFilters: '[{"tagName":"stale-basic","tagValue":"team"}]', + manualTagFilters: '[{"tagId":"","tagValue":"team"}]', + }, + }, + { + resolve: (kind, id) => (kind === 'knowledge-base' && id === 'kb-src' ? 'kb-dst' : null), + resolveFileKey: () => null, + clearUnresolved: true, + blockConfigs: knowledgeToolConfigs, + parentCanonicalModes: { + '0:knowledgeBaseId': 'basic', + '0:tagFilters': 'advanced', + }, + toolIndex: 0, + } + ) + + expect(result.params).toEqual({ + knowledgeBaseId: 'kb-dst', + tagFilters: '', + manualTagFilters: '[{"tagId":"","tagValue":"team"}]', + }) + }) + it('preserves a column selection under a COPIED table, clears it under a mapped one', () => { const tableBlock = () => blockWith([ diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts b/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts index 60b2663f57a..592bf752761 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts +++ b/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts @@ -97,13 +97,50 @@ export const REGISTRY_KIND_TO_FORK_KIND: Partial< /** * Dependent subblock types whose values are name/slot-based rather than id-based, so they stay * valid on a COPIED parent (tag definitions are copied verbatim - same names, same slots) and on a - * MAPPED parent (mapping asserts the resources are equivalent). The dependent-clear passes preserve - * these when their parent was remapped to a non-empty target; a CLEARED parent still clears them. + * MAPPED parent (mapping asserts the resources are equivalent). Basic knowledge tag filters are + * also portable; their advanced canonical partner stores workspace-local tag-definition UUIDs and + * is handled separately. A CLEARED parent still clears every dependent. */ -const PRESERVED_NAME_BASED_DEPENDENT_TYPES = new Set([ - 'knowledge-tag-filters', - 'document-tag-entry', -]) +const PRESERVED_NAME_BASED_DEPENDENT_TYPES = new Set(['document-tag-entry']) + +function isPortableNameBasedDependent(config: SubBlockConfig): boolean { + return ( + PRESERVED_NAME_BASED_DEPENDENT_TYPES.has(config.type) || + (config.type === 'knowledge-tag-filters' && config.mode !== 'advanced') + ) +} + +function removeLiteralTagIdFilters( + config: SubBlockConfig | undefined, + value: unknown +): { changed: boolean; value: unknown } { + if (config?.type !== 'knowledge-tag-filters' || config.mode !== 'advanced') { + return { changed: false, value } + } + + let parsed = value + const wasString = typeof value === 'string' + if (typeof value === 'string') { + try { + parsed = JSON.parse(value) + } catch { + return { changed: false, value } + } + } + if (!Array.isArray(parsed)) return { changed: false, value } + + const retainedFilters = parsed.filter((entry) => { + if (!isRecord(entry) || typeof entry.tagId !== 'string') return true + const tagId = entry.tagId.trim() + return tagId.length === 0 || isReference(tagId) || isEnvVarReference(tagId) + }) + if (retainedFilters.length === parsed.length) return { changed: false, value } + if (retainedFilters.length === 0) return { changed: true, value: '' } + return { + changed: true, + value: wasString ? JSON.stringify(retainedFilters) : retainedFilters, + } +} /** * Dependent subblock types preserved ONLY when their parent was remapped via a COPY: a copied @@ -593,14 +630,15 @@ export function remapToolBlockResources( // A verbatim manual-parent dependent is never cleared, even when reachable from a // second (remapped) parent. if (gates.isManualParentDependent(clear.subBlockId)) continue - // Tag fields are name/slot-based, portable onto a copied or mapped-equivalent - // parent - preserve them when the parent remapped to a target instead of clearing. + // Name/slot-based tag fields (document tags and basic tag filters) are portable onto a + // copied or mapped-equivalent parent. Advanced filters contain definition UUIDs and clear + // when they contain literal IDs. // A COPIED parent additionally keeps copy-faithful dependents (column picks - the // copy duplicates the table schema verbatim, so column ids stay valid). if ( parentRemappedNonEmpty && dependentCfg && - (PRESERVED_NAME_BASED_DEPENDENT_TYPES.has(dependentCfg.type) || + (isPortableNameBasedDependent(dependentCfg) || (parentCopied && PRESERVED_UNDER_COPY_DEPENDENT_TYPES.has(dependentCfg.type))) ) { continue @@ -624,8 +662,18 @@ export function remapToolBlockResources( ) { continue } - if (isManualCanonicalValue(dependentCfg, clearKey)) continue - setParam(clearKey, '') + const filteredTagIds = removeLiteralTagIdFilters(dependentCfg, existing) + if ( + dependentCfg?.id === clearKey && + gates.isActiveManualMember(clearKey) && + !filteredTagIds.changed + ) { + continue + } + if (isManualCanonicalValue(dependentCfg, clearKey) && !filteredTagIds.changed) { + continue + } + setParam(clearKey, filteredTagIds.changed ? filteredTagIds.value : '') } } } @@ -1035,12 +1083,13 @@ export function remapForkSubBlocks( * value is non-empty) is preserved along with its own dependents (the tool's * arguments), because mapping asserts the servers are equivalent and * {@link remapForkSubBlocks} already followed the selection onto the target - * server; and the name/slot-based tag fields (`knowledge-tag-filters`, - * `document-tag-entry`) are preserved under any parent remapped to a non-empty - * target - a copy duplicates the tag definitions verbatim and a mapping asserts - * equivalence. A CLEARED parent (unmapped / fork-create) still clears its - * dependents. Children of an unchanged parent are preserved; a no-op for - * unknown block types or when nothing was remapped. + * server; and name/slot-based tag fields (`document-tag-entry` and basic + * `knowledge-tag-filters`) are preserved under any parent remapped to a + * non-empty target. Advanced tag filters use workspace-local definition UUIDs, + * so literal-ID filters clear while dynamic references remain user-owned. A + * CLEARED parent (unmapped / fork-create) still clears its dependents. Children + * of an unchanged parent are preserved; a no-op for unknown block types or when + * nothing was remapped. */ export function clearDependentsOnRemap( subBlocks: SubBlockRecord, @@ -1077,7 +1126,7 @@ export function clearDependentsOnRemap( } // A parent key remapped to a non-empty target (a cleared one is empty post-remap): its - // name/slot-based dependents (tag filters / document tags) stay valid on the target - + // name/slot-based dependents (basic tag filters / document tags) stay valid on the target - // mapping asserts equivalence and a copy duplicates the tag definitions verbatim. const isRemappedToNonEmpty = (key: string): boolean => { const parent = subBlocks[key] @@ -1102,7 +1151,7 @@ export function clearDependentsOnRemap( if (mcpParent && dependent.type === 'mcp-tool-selector') { preservedDependents.add(dependent.id) } - if (nonEmptyParent && PRESERVED_NAME_BASED_DEPENDENT_TYPES.has(dependent.type)) { + if (nonEmptyParent && isPortableNameBasedDependent(dependent)) { preservedDependents.add(dependent.id) } if (copiedParent && PRESERVED_UNDER_COPY_DEPENDENT_TYPES.has(dependent.type)) { @@ -1149,7 +1198,14 @@ export function clearDependentsOnRemap( } // A live manual (advanced) member is user-owned and verbatim by policy - a parent remap // must not blank it (matching how manual values are never remapped). A dormant one may. - if (gates.isActiveManualMember(id)) continue + const subBlockConfig = config.subBlocks.find((subBlock) => subBlock.id === id) + if (gates.isActiveManualMember(id)) { + const filteredTagIds = removeLiteralTagIdFilters(subBlockConfig, existing.value) + if (!filteredTagIds.changed) continue + next ??= { ...subBlocks } + next[id] = { ...existing, value: filteredTagIds.value } + continue + } next ??= { ...subBlocks } next[id] = { ...existing, value: '' } } diff --git a/apps/sim/lib/api/contracts/knowledge/search.ts b/apps/sim/lib/api/contracts/knowledge/search.ts index ea1dff75ce0..926b1e59fde 100644 --- a/apps/sim/lib/api/contracts/knowledge/search.ts +++ b/apps/sim/lib/api/contracts/knowledge/search.ts @@ -1,14 +1,19 @@ import { z } from 'zod' import { DEFAULT_RERANKER_MODEL, rerankerModelSchema } from '@/lib/knowledge/reranker-models' -export const knowledgeSearchTagFilterSchema = z.object({ - tagName: z.string(), - tagSlot: z.string().optional(), - fieldType: z.enum(['text', 'number', 'date', 'boolean']).optional(), - operator: z.string().default('eq'), - value: z.union([z.string(), z.number(), z.boolean()]), - valueTo: z.union([z.string(), z.number()]).optional(), -}) +export const knowledgeSearchTagFilterSchema = z + .object({ + tagName: z.string().min(1).optional(), + tagId: z.string().min(1).optional(), + tagSlot: z.string().optional(), + fieldType: z.enum(['text', 'number', 'date', 'boolean']).optional(), + operator: z.string().default('eq'), + value: z.union([z.string(), z.number(), z.boolean()]), + valueTo: z.union([z.string(), z.number()]).optional(), + }) + .refine((filter) => Boolean(filter.tagName) !== Boolean(filter.tagId), { + message: 'Each tag filter must include exactly one of tagName or tagId', + }) export const knowledgeSearchBodySchema = z .object({ diff --git a/apps/sim/lib/knowledge/types.ts b/apps/sim/lib/knowledge/types.ts index 3a729f0981c..973f3314d3a 100644 --- a/apps/sim/lib/knowledge/types.ts +++ b/apps/sim/lib/knowledge/types.ts @@ -63,6 +63,7 @@ export interface UpdateTagDefinitionData { export interface StructuredFilter { tagName?: string + tagId?: string tagSlot: string fieldType: string operator: string diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts index ff521750ba5..d62b4ca97d5 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts @@ -310,7 +310,7 @@ describe('backfillCanonicalModes', () => { expect(modes.knowledgeBaseId).toBe('advanced') }) - it('should not overwrite existing canonicalModes entries', () => { + it('should preserve existing canonicalModes entries while backfilling new pairs', () => { const input: Record = { b1: makeBlock({ type: 'knowledge', @@ -327,9 +327,41 @@ describe('backfillCanonicalModes', () => { const { blocks, migrated } = backfillCanonicalModes(input) - expect(migrated).toBe(false) + expect(migrated).toBe(true) const modes = blocks.b1.data?.canonicalModes as Record expect(modes.knowledgeBaseId).toBe('advanced') + expect(modes.documentId).toBe('basic') + expect(modes.tagFilters).toBe('basic') + }) + + it('should resolve tag filters to advanced when only manual tag IDs are set', () => { + const input: Record = { + b1: makeBlock({ + type: 'knowledge', + data: {}, + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'search' }, + manualTagFilters: { + id: 'manualTagFilters', + type: 'knowledge-tag-filters', + value: JSON.stringify([ + { + id: 'filter-1', + tagId: 'tag-definition-id', + operator: 'eq', + tagValue: 'docs', + }, + ]), + }, + }, + }), + } + + const { blocks, migrated } = backfillCanonicalModes(input) + + expect(migrated).toBe(true) + const modes = blocks.b1.data?.canonicalModes as Record + expect(modes.tagFilters).toBe('advanced') }) it('should skip blocks with no canonical pairs in their config', () => { diff --git a/apps/sim/lib/workflows/search-replace/indexer.test.ts b/apps/sim/lib/workflows/search-replace/indexer.test.ts index 2fe792b2073..721f70943c0 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.test.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.test.ts @@ -776,7 +776,7 @@ describe('indexWorkflowSearchMatches', () => { expect(matches).toEqual([]) }) - it('indexes only value fields for JSON-backed knowledge tag subblocks', () => { + it('indexes connected ID and value fields for JSON-backed knowledge tag subblocks', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['tag-block-1'] = { id: 'tag-block-1', @@ -793,6 +793,7 @@ describe('indexWorkflowSearchMatches', () => { { id: 'filter-open', tagName: 'Status', + tagId: '', fieldType: 'text', operator: 'eq', tagValue: 'open ticket', @@ -847,6 +848,12 @@ describe('indexWorkflowSearchMatches', () => { expect(valueMatches).toEqual( expect.arrayContaining([ + expect.objectContaining({ + subBlockId: 'tagFilters', + valuePath: [0, 'tagId'], + fieldTitle: 'Tag ID', + searchText: '', + }), expect.objectContaining({ subBlockId: 'tagFilters', valuePath: [0, 'tagValue'], diff --git a/apps/sim/lib/workflows/search-replace/json-value-fields.ts b/apps/sim/lib/workflows/search-replace/json-value-fields.ts index 2b4c8a0dbbf..f92bcfe15f8 100644 --- a/apps/sim/lib/workflows/search-replace/json-value-fields.ts +++ b/apps/sim/lib/workflows/search-replace/json-value-fields.ts @@ -13,6 +13,7 @@ const SEARCHABLE_JSON_ARRAY_VALUE_FIELDS: Partial { ]) }) - it('replaces JSON-backed tag value fields without touching tag metadata', () => { + it('replaces connected JSON-backed tag ID and value fields without touching metadata', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['tag-block-1'] = { id: 'tag-block-1', @@ -1333,6 +1333,7 @@ describe('buildWorkflowSearchReplacePlan', () => { { id: 'filter-open', tagName: 'Status', + tagId: '', fieldType: 'text', operator: 'eq', tagValue: 'open ticket', @@ -1389,6 +1390,7 @@ describe('buildWorkflowSearchReplacePlan', () => { { id: 'filter-open', tagName: 'Status', + tagId: '', fieldType: 'text', operator: 'eq', tagValue: 'resolved ticket', diff --git a/apps/sim/lib/workflows/subblocks/display.test.ts b/apps/sim/lib/workflows/subblocks/display.test.ts index 310af5d3701..85f9041649a 100644 --- a/apps/sim/lib/workflows/subblocks/display.test.ts +++ b/apps/sim/lib/workflows/subblocks/display.test.ts @@ -232,4 +232,23 @@ describe('getDisplayValue', () => { ).toBe('one, two +1') expect(getDisplayValue(['a', 'b'])).toBe('a, b') }) + + it('summarizes basic and advanced knowledge tag filters', () => { + expect( + getDisplayValue( + JSON.stringify([ + { id: 'filter-1', tagName: 'Category', tagValue: 'docs' }, + { id: 'filter-2', tagName: 'Priority', tagValue: 'high' }, + ]) + ) + ).toBe('Category, Priority') + expect( + getDisplayValue( + JSON.stringify([ + { id: 'filter-1', tagId: 'tag-definition-id', tagValue: 'docs' }, + { id: 'filter-2', tagId: '', tagValue: 'high' }, + ]) + ) + ).toBe('tag-definition-id, ') + }) }) diff --git a/apps/sim/lib/workflows/subblocks/display.ts b/apps/sim/lib/workflows/subblocks/display.ts index 26088c25fff..a6174ee1fe9 100644 --- a/apps/sim/lib/workflows/subblocks/display.ts +++ b/apps/sim/lib/workflows/subblocks/display.ts @@ -39,7 +39,8 @@ interface FieldFormat { interface TagFilterItem { id: string - tagName: string + tagName?: string + tagId?: string fieldType?: string operator?: string tagValue: string @@ -114,9 +115,8 @@ const isTagFilterArray = (value: unknown): value is TagFilterItem[] => { return ( typeof firstItem === 'object' && firstItem !== null && - 'tagName' in firstItem && 'tagValue' in firstItem && - typeof firstItem.tagName === 'string' + (typeof firstItem.tagName === 'string' || typeof firstItem.tagId === 'string') ) } @@ -201,10 +201,10 @@ export const getDisplayValue = (value: unknown): string => { } if (isTagFilterArray(parsedValue)) { - const names = parsedValue - .filter((f) => typeof f.tagName === 'string' && f.tagName.trim() !== '') - .map((f) => f.tagName) - return summarizeNames(names) ?? '-' + const identifiers = parsedValue + .map((filter) => filter.tagName?.trim() || filter.tagId?.trim() || '') + .filter(Boolean) + return summarizeNames(identifiers) ?? '-' } if (isDocumentTagArray(parsedValue)) { diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index 0b10c2bf511..c3c6ec4f2b6 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -1681,6 +1681,18 @@ describe('transformBlockTool knowledge-base multi-instance unique IDs', () => { canonicalParamId: 'knowledgeBaseId', mode: 'advanced', }, + { + id: 'tagFilters', + type: 'knowledge-tag-filters', + canonicalParamId: 'tagFilters', + mode: 'basic', + }, + { + id: 'manualTagFilters', + type: 'knowledge-tag-filters', + canonicalParamId: 'tagFilters', + mode: 'advanced', + }, ], tools: { access: ['knowledge_search', 'knowledge_upload_chunk'], @@ -1729,4 +1741,19 @@ describe('transformBlockTool knowledge-base multi-instance unique IDs', () => { const result = await transformKb({}) expect(result?.id).toBe('knowledge_search') }) + + it('executes a nested tool with only its active advanced tag filters', async () => { + const basicFilters = '[{"tagName":"stale-basic","tagValue":"docs"}]' + const advancedFilters = '[{"tagId":"","tagValue":"docs"}]' + const result = await transformKb( + { tagFilters: basicFilters, manualTagFilters: advancedFilters }, + { '0:tagFilters': 'advanced' }, + 0 + ) + + expect(result?.paramsTransform?.(result.params ?? {})).toMatchObject({ + tagFilters: advancedFilters, + }) + expect(result?.paramsTransform?.(result.params ?? {})).not.toHaveProperty('manualTagFilters') + }) }) diff --git a/apps/sim/tools/knowledge/knowledge.test.ts b/apps/sim/tools/knowledge/knowledge.test.ts index 1dd0f287711..bd8a38bf3e8 100644 --- a/apps/sim/tools/knowledge/knowledge.test.ts +++ b/apps/sim/tools/knowledge/knowledge.test.ts @@ -24,6 +24,51 @@ function createMockResponse(data: unknown): Response { describe('Knowledge Tools', () => { describe('knowledgeSearchTool', () => { + describe('request body', () => { + it.each([ + { + mode: 'basic', + filter: { + id: 'filter-basic', + tagName: 'category', + tagId: '', + fieldType: 'text', + operator: 'eq', + tagValue: 'api', + }, + expectedIdentifier: { tagName: 'category' }, + }, + { + mode: 'advanced', + filter: { + id: 'filter-advanced', + tagName: '', + tagId: 'tag-definition-id', + fieldType: 'text', + operator: 'eq', + tagValue: 'api', + }, + expectedIdentifier: { tagId: 'tag-definition-id' }, + }, + ])('normalizes the actual $mode UI filter shape', ({ filter, expectedIdentifier }) => { + const body = knowledgeSearchTool.request.body({ + knowledgeBaseId: 'kb-123', + tagFilters: JSON.stringify([filter]), + }) + + expect(body.tagFilters).toEqual([ + { + ...expectedIdentifier, + tagSlot: '', + fieldType: 'text', + operator: 'eq', + value: 'api', + valueTo: undefined, + }, + ]) + }) + }) + describe('transformResponse', () => { it('should restructure cost information for logging', async () => { const apiResponse = { diff --git a/apps/sim/tools/knowledge/search.ts b/apps/sim/tools/knowledge/search.ts index f3a028c8b5b..ed5c0e951dc 100644 --- a/apps/sim/tools/knowledge/search.ts +++ b/apps/sim/tools/knowledge/search.ts @@ -33,11 +33,13 @@ export const knowledgeSearchTool: ToolConfig = { type: 'array', required: false, visibility: 'user-or-llm', - description: 'Array of tag filters with tagName and tagValue properties', + description: + 'Array of tag filters using either tagName or tagId together with tagValue properties', items: { type: 'object', properties: { tagName: { type: 'string' }, + tagId: { type: 'string' }, tagValue: { type: 'string' }, }, }, diff --git a/apps/sim/tools/params-resolver.test.ts b/apps/sim/tools/params-resolver.test.ts index 7adcfc50be0..07b7e658bb2 100644 --- a/apps/sim/tools/params-resolver.test.ts +++ b/apps/sim/tools/params-resolver.test.ts @@ -42,4 +42,74 @@ describe('buildPreviewContextValues', () => { }) expect(result.knowledgeBaseId).toBe('kb-basic') }) + + it('resolves tag filter canonical pairs without leaking the dormant value', () => { + const tagFilterCanonicalIndex = buildCanonicalIndex([ + { + id: 'tagFilters', + type: 'knowledge-tag-filters', + canonicalParamId: 'tagFilters', + mode: 'basic', + }, + { + id: 'manualTagFilters', + type: 'knowledge-tag-filters', + canonicalParamId: 'tagFilters', + mode: 'advanced', + }, + ] as Parameters[0]) + const tagFilterValues = { + tagFilters: '[{"tagName":"Category","tagValue":"docs"}]', + manualTagFilters: '[{"tagId":"tag-id","tagValue":"docs"}]', + } + + const basic = buildPreviewContextValues(tagFilterValues, { + blockType: 'knowledge', + subBlocks: [], + canonicalIndex: tagFilterCanonicalIndex, + values: tagFilterValues, + overrides: { tagFilters: 'basic' }, + }) + const advanced = buildPreviewContextValues(tagFilterValues, { + blockType: 'knowledge', + subBlocks: [], + canonicalIndex: tagFilterCanonicalIndex, + values: tagFilterValues, + overrides: { tagFilters: 'advanced' }, + }) + + expect(basic.tagFilters).toBe(tagFilterValues.tagFilters) + expect(advanced.tagFilters).toBe(tagFilterValues.manualTagFilters) + }) + + it('exposes the scoped advanced knowledge base under its canonical dependency key', () => { + const knowledgeBaseCanonicalIndex = buildCanonicalIndex([ + { + id: 'knowledgeBaseSelector', + type: 'knowledge-base-selector', + canonicalParamId: 'knowledgeBaseId', + mode: 'basic', + }, + { + id: 'manualKnowledgeBaseId', + type: 'short-input', + canonicalParamId: 'knowledgeBaseId', + mode: 'advanced', + }, + ] as Parameters[0]) + const values = { + knowledgeBaseSelector: 'kb-stale-basic', + manualKnowledgeBaseId: 'kb-active-advanced', + } + + const context = buildPreviewContextValues(values, { + blockType: 'knowledge', + subBlocks: [], + canonicalIndex: knowledgeBaseCanonicalIndex, + values, + overrides: { knowledgeBaseId: 'advanced' }, + }) + + expect(context.knowledgeBaseId).toBe('kb-active-advanced') + }) }) diff --git a/apps/sim/tools/shared/tags.test.ts b/apps/sim/tools/shared/tags.test.ts new file mode 100644 index 00000000000..610b8fa69d8 --- /dev/null +++ b/apps/sim/tools/shared/tags.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { isEmptyTagValue, parseTagFilters } from './tags' + +describe('tag filter parsing', () => { + it('preserves name-based tag filters', () => { + expect( + parseTagFilters([ + { + tagName: 'category', + tagId: '', + tagValue: 'api', + tagSlot: 'tag1', + fieldType: 'text', + operator: 'contains', + }, + ]) + ).toEqual([ + { + tagName: 'category', + tagSlot: 'tag1', + fieldType: 'text', + operator: 'contains', + value: 'api', + valueTo: undefined, + }, + ]) + }) + + it('preserves ID-based tag filters for server-side resolution', () => { + expect( + parseTagFilters([ + { + tagName: '', + tagId: 'tag-definition-id', + tagValue: 42, + fieldType: 'number', + operator: 'gte', + }, + ]) + ).toEqual([ + { + tagId: 'tag-definition-id', + tagSlot: '', + fieldType: 'number', + operator: 'gte', + value: 42, + valueTo: undefined, + }, + ]) + }) + + it('drops filters without a tag name or tag ID', () => { + expect(parseTagFilters([{ tagValue: 'api', operator: 'eq' }])).toEqual([]) + }) + + it('emits exactly one identifier when stale input contains both', () => { + expect( + parseTagFilters([ + { + tagName: 'category', + tagId: 'tag-definition-id', + tagValue: 'api', + }, + ]) + ).toEqual([ + { + tagId: 'tag-definition-id', + tagSlot: '', + fieldType: 'text', + operator: 'eq', + value: 'api', + valueTo: undefined, + }, + ]) + }) + + it('treats a tag ID filter as non-empty', () => { + expect(isEmptyTagValue([{ tagId: 'tag-definition-id', tagValue: '' }])).toBe(false) + }) + + it('ignores an unfinished filter whose identifier is still empty', () => { + const filter = { tagName: '', tagId: '', tagValue: 'api', operator: 'eq' } + + expect(isEmptyTagValue([filter])).toBe(true) + expect(parseTagFilters([filter])).toEqual([]) + }) +}) diff --git a/apps/sim/tools/shared/tags.ts b/apps/sim/tools/shared/tags.ts index 35faeb9995b..a5996c7f1f7 100644 --- a/apps/sim/tools/shared/tags.ts +++ b/apps/sim/tools/shared/tags.ts @@ -12,7 +12,8 @@ export interface DocumentTagEntry { * Tag filter entry format used in search tool. */ interface TagFilterEntry { - tagName: string + tagName?: string + tagId?: string tagSlot?: string tagValue: string | number | boolean fieldType?: string @@ -24,10 +25,9 @@ interface TagFilterEntry { * Checks if a tag value is effectively empty (unfilled/default entry). */ function isEmptyTagEntry(entry: Record): boolean { - if (!entry.tagName || (typeof entry.tagName === 'string' && entry.tagName.trim() === '')) { - return true - } - return false + const hasTagName = typeof entry.tagName === 'string' && entry.tagName.trim().length > 0 + const hasTagId = typeof entry.tagId === 'string' && entry.tagId.trim().length > 0 + return !hasTagName && !hasTagId } /** @@ -139,22 +139,34 @@ export function parseTagFilters(value: unknown): StructuredFilter[] { .filter((filter): filter is Record => { if (typeof filter !== 'object' || filter === null) return false const f = filter as Record - if (!f.tagName || (typeof f.tagName === 'string' && f.tagName.trim() === '')) return false + const hasTagName = typeof f.tagName === 'string' && f.tagName.trim().length > 0 + const hasTagId = typeof f.tagId === 'string' && f.tagId.trim().length > 0 if (f.fieldType === 'boolean') { - return f.tagValue !== undefined + return f.tagValue !== undefined && (hasTagName || hasTagId) } if (f.tagValue === undefined || f.tagValue === null) return false if (typeof f.tagValue === 'string' && f.tagValue.trim().length === 0) return false - return true + return hasTagName || hasTagId + }) + .map((filter) => { + const tagId = + typeof filter.tagId === 'string' && filter.tagId.trim().length > 0 + ? filter.tagId.trim() + : undefined + const tagName = + !tagId && typeof filter.tagName === 'string' && filter.tagName.trim().length > 0 + ? filter.tagName.trim() + : undefined + + return { + ...(tagId ? { tagId } : tagName ? { tagName } : {}), + tagSlot: (filter.tagSlot as string) || '', + fieldType: (filter.fieldType as string) || 'text', + operator: (filter.operator as string) || 'eq', + value: filter.tagValue as string | number | boolean, + valueTo: filter.valueTo as string | number | undefined, + } }) - .map((filter) => ({ - tagName: filter.tagName as string, - tagSlot: (filter.tagSlot as string) || '', - fieldType: (filter.fieldType as string) || 'text', - operator: (filter.operator as string) || 'eq', - value: filter.tagValue as string | number | boolean, - valueTo: filter.valueTo as string | number | undefined, - })) } /**