Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { getActiveWorkflowSearchHighlight } from '@/app/workspace/[workspaceId]/
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { parseJsonArrayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import type { SubBlockConfig } from '@/blocks/types'
Expand Down Expand Up @@ -98,22 +99,14 @@ export function DocumentTagEntry({

const currentValue = isPreview ? previewValue : storeValue

const parseTags = (tagValue: string | null): DocumentTag[] => {
if (!tagValue) return []
try {
const parsed = JSON.parse(tagValue)
if (!Array.isArray(parsed)) return []
return parsed.map((t: DocumentTag) => ({
...t,
fieldType: t.fieldType || 'text',
collapsed: t.collapsed ?? false,
}))
} catch {
return []
}
}
const parseTags = (tagValue: unknown): DocumentTag[] =>
parseJsonArrayValue<DocumentTag>(tagValue).map((t) => ({
...t,
fieldType: t.fieldType || 'text',
collapsed: t.collapsed ?? false,
}))

const parsedTags = parseTags(currentValue || null)
const parsedTags = parseTags(currentValue)
const tags: DocumentTag[] = parsedTags.length > 0 ? parsedTags : [createDefaultTag()]
const isReadOnly = isPreview || disabled

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/compon
import { getActiveWorkflowSearchHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
import { parseJsonArrayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import type { SubBlockConfig } from '@/blocks/types'
Expand Down Expand Up @@ -100,24 +101,16 @@ export function KnowledgeTagFilters({
disabled,
})

const parseFilters = (filterValue: string | null): TagFilter[] => {
if (!filterValue) return []
try {
const parsed = JSON.parse(filterValue)
if (!Array.isArray(parsed)) return []
return parsed.map((f: TagFilter) => ({
...f,
fieldType: f.fieldType || 'text',
operator: f.operator || 'eq',
collapsed: f.collapsed ?? false,
}))
} catch {
return []
}
}
const parseFilters = (filterValue: unknown): TagFilter[] =>
parseJsonArrayValue<TagFilter>(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 || null)
const parsedFilters = parseFilters(currentValue)
const filters: TagFilter[] = parsedFilters.length > 0 ? parsedFilters : [createDefaultFilter()]
const isReadOnly = isPreview || disabled

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { parseJsonArrayValue } from './utils'

interface TagFilter {
id: string
tagName: string
}

describe('parseJsonArrayValue', () => {
it('parses a JSON string array, the shape edit_workflow now persists', () => {
const filters: TagFilter[] = [{ id: 'f1', tagName: 'Department' }]

expect(parseJsonArrayValue<TagFilter>(JSON.stringify(filters))).toEqual(filters)
})

// Rows written by builds predating the edit_workflow stringify fix still hold raw arrays.
it('passes through an already-parsed array', () => {
const filters: TagFilter[] = [{ id: 'f1', tagName: 'Department' }]

expect(parseJsonArrayValue<TagFilter>(filters)).toEqual(filters)
})

it.each([
['null', null],
['undefined', undefined],
['an empty string', ''],
])('returns an empty array for %s', (_label, value) => {
expect(parseJsonArrayValue(value)).toEqual([])
})

it.each([
['a malformed JSON string', '{not json'],
['a JSON string parsing to null', 'null'],
['a JSON string parsing to an object', '{"a":1}'],
['a JSON string parsing to a number', '5'],
['a bare object', { a: 1 }],
])('returns an empty array rather than throwing for %s', (_label, value) => {
expect(parseJsonArrayValue(value)).toEqual([])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,30 @@ export function resolvePreviewContextValue(raw: unknown): unknown {
}
return raw
}

/**
* Parses a sub-block value that may be stored as a JSON string or as an already-parsed array.
*
* @remarks
* These sub-blocks contract on a JSON string, which is what their components write. Copilot's
* `edit_workflow` persisted them as raw arrays until it was fixed to re-serialize, so rows
* written by older builds still hold an array. Both shapes are accepted on read.
*
* The element type is asserted, not validated -- callers are responsible for defaulting any
* fields a malformed entry may be missing.
*
* @param value - The stored sub-block value, of unknown shape
* @returns The parsed array, or `[]` when the value is absent, unparseable, or not an array
*/
export function parseJsonArrayValue<T>(value: unknown): T[] {
if (!value) return []
let parsed: unknown = value
if (typeof value === 'string') {
try {
parsed = JSON.parse(value)
} catch {
return []
}
}
return Array.isArray(parsed) ? (parsed as T[]) : []
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import { createBlockFromParams } from './builders'
import { createBlockFromParams, normalizeSubblockValue } from './builders'

const agentBlockConfig = {
type: 'agent',
Expand All @@ -20,10 +20,25 @@ const conditionBlockConfig = {
subBlocks: [{ id: 'conditions', type: 'condition-input' }],
}

const knowledgeBlockConfig = {
type: 'knowledge',
name: 'Knowledge',
outputs: {},
subBlocks: [
{ id: 'tagFilters', type: 'knowledge-tag-filters' },
{ id: 'documentTags', type: 'document-tag-entry' },
],
}

const blocksByType: Record<string, unknown> = {
agent: agentBlockConfig,
condition: conditionBlockConfig,
knowledge: knowledgeBlockConfig,
}

vi.mock('@/blocks/registry', () => ({
getAllBlocks: () => [agentBlockConfig, conditionBlockConfig],
getBlock: (type: string) =>
type === 'agent' ? agentBlockConfig : type === 'condition' ? conditionBlockConfig : undefined,
getAllBlocks: () => [agentBlockConfig, conditionBlockConfig, knowledgeBlockConfig],
getBlock: (type: string) => blocksByType[type],
}))

describe('createBlockFromParams', () => {
Expand Down Expand Up @@ -69,4 +84,73 @@ describe('createBlockFromParams', () => {
expect(parsed[0].id).toBe('condition-1-if')
expect(parsed[1].id).toBe('condition-1-else')
})

it('persists knowledge tag subblocks as JSON strings, not raw arrays', () => {
const block = createBlockFromParams('kb-1', {
type: 'knowledge',
name: 'Knowledge 1',
inputs: {
tagFilters: [{ tagName: 'Department', tagSlot: 'tag1', tagValue: 'it' }],
documentTags: [{ tagName: 'Team', tagSlot: 'tag2', value: 'infra' }],
},
triggerMode: false,
})

expect(typeof block.subBlocks.tagFilters.value).toBe('string')
expect(typeof block.subBlocks.documentTags.value).toBe('string')

const filters = JSON.parse(block.subBlocks.tagFilters.value)
expect(filters[0].tagName).toBe('Department')
expect(filters[0].id).toEqual(expect.any(String))
})
})

describe('normalizeSubblockValue', () => {
it.each(['tagFilters', 'documentTags', 'conditions', 'routes'])(
'serializes %s to a JSON string the subblock component can parse',
(key) => {
const result = normalizeSubblockValue(key, [{ id: 'not-a-uuid', title: 'a' }])

expect(typeof result).toBe('string')
expect(JSON.parse(result as string)[0].title).toBe('a')
}
)

it('accepts a JSON string as input and still returns a string', () => {
const result = normalizeSubblockValue('tagFilters', JSON.stringify([{ tagName: 'Department' }]))

expect(typeof result).toBe('string')
expect(JSON.parse(result as string)[0].tagName).toBe('Department')
})

it('leaves array-with-id subblocks that are not string-serialized as raw arrays', () => {
const result = normalizeSubblockValue('inputFormat', [{ id: 'x', name: 'field' }])

expect(Array.isArray(result)).toBe(true)
})

it('passes through subblock keys that need no normalization', () => {
expect(normalizeSubblockValue('systemPrompt', 'hello')).toBe('hello')
})

// Validation treats null as an explicit clear. Coercing it to "[]" would persist a value
// where the caller asked for none, so the agent reads back an empty filter rather than an
// absent one -- the same absent-vs-empty ambiguity that caused the original data loss.
it.each(['tagFilters', 'documentTags', 'conditions', 'routes'])(
'passes a null %s through as a clear rather than serializing it to "[]"',
(key) => {
expect(normalizeSubblockValue(key, null)).toBeNull()
expect(normalizeSubblockValue(key, undefined)).toBeUndefined()
}
)

it('still serializes an explicitly empty array, which clears the field with a value', () => {
expect(normalizeSubblockValue('tagFilters', [])).toBe('[]')
})

it('replaces non-uuid ids so copilot-authored rows match UI-created ones', () => {
const result = normalizeSubblockValue('tagFilters', [{ id: 'filter-1', tagName: 'Department' }])

expect(JSON.parse(result as string)[0].id).not.toBe('filter-1')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -93,15 +93,7 @@ export function createBlockFromParams(
return
}

let sanitizedValue = value

// Normalize array subblocks with id fields (inputFormat, table rows, etc.)
if (shouldNormalizeArrayIds(key)) {
sanitizedValue = normalizeArrayWithIds(value)
if (JSON_STRING_SUBBLOCK_KEYS.has(key)) {
sanitizedValue = JSON.stringify(sanitizedValue)
}
}
let sanitizedValue = normalizeSubblockValue(key, value)

sanitizedValue = normalizeConditionRouterIds(blockId, key, sanitizedValue)

Expand Down Expand Up @@ -274,29 +266,35 @@ const ARRAY_WITH_ID_SUBBLOCK_TYPES = new Set([
* Subblock keys whose UI components expect a JSON string, not a raw array.
* After normalizeArrayWithIds returns an array, these must be re-stringified.
*/
export const JSON_STRING_SUBBLOCK_KEYS = new Set(['conditions', 'routes'])
const JSON_STRING_SUBBLOCK_KEYS = new Set(['conditions', 'routes', 'tagFilters', 'documentTags'])

/**
* Coerces a subblock value to an array, accepting either a raw array or the JSON string
* the string-serialized subblocks persist.
*
* @returns The array, or `null` when the value is not an array and does not parse to one.
* Callers supply their own fallback, which differs by site.
*/
function parseJsonArray(value: unknown): any[] | null {
if (Array.isArray(value)) return value
if (typeof value !== 'string') return null

try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed : null
} catch {
return null
}
}

/**
* Normalizes array subblock values by ensuring each item has a valid UUID.
* The LLM may generate arbitrary IDs like "input-desc-001" or "row-1" which need
* to be converted to proper UUIDs for consistency with UI-created items.
*/
export function normalizeArrayWithIds(value: unknown): any[] {
let arr: any[]

if (Array.isArray(value)) {
arr = value
} else if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
if (!Array.isArray(parsed)) return []
arr = parsed
} catch {
return []
}
} else {
return []
}
function normalizeArrayWithIds(value: unknown): any[] {
const arr = parseJsonArray(value)
if (!arr) return []

return arr.map((item: any) => {
if (!item || typeof item !== 'object') {
Expand All @@ -315,10 +313,30 @@ export function normalizeArrayWithIds(value: unknown): any[] {
/**
* Checks if a subblock key should have its array items normalized with UUIDs.
*/
export function shouldNormalizeArrayIds(key: string): boolean {
function shouldNormalizeArrayIds(key: string): boolean {
return ARRAY_WITH_ID_SUBBLOCK_TYPES.has(key)
}

/**
* Normalizes an array-with-id subblock value, re-serializing it to a JSON string for the
* subblock keys whose UI components read a string rather than a raw array.
*
* Every write path that persists LLM-supplied subblock values must route through this so the
* two concerns cannot drift apart; returns non-array-with-id values untouched.
*
* @remarks
* A nullish value passes through unchanged. `validateValueForSubBlockType` treats null as an
* explicit clear, and coercing it to `"[]"` here would persist a value where the caller asked
* for none -- leaving `sanitizeForCopilot` to show the agent an empty filter rather than an
* absent one, and callers that branch on the field's presence to see it as set.
*/
export function normalizeSubblockValue(key: string, value: unknown): unknown {
if (!shouldNormalizeArrayIds(key)) return value
if (value === null || value === undefined) return value
const normalized = normalizeArrayWithIds(value)
return JSON_STRING_SUBBLOCK_KEYS.has(key) ? JSON.stringify(normalized) : normalized
Comment thread
cursor[bot] marked this conversation as resolved.
}

/**
* Normalizes condition/router branch IDs to use canonical block-scoped format.
* The LLM provides branch structure (if/else-if/else or routes) but should not
Expand All @@ -327,19 +345,8 @@ export function shouldNormalizeArrayIds(key: string): boolean {
export function normalizeConditionRouterIds(blockId: string, key: string, value: unknown): unknown {
if (key !== 'conditions' && key !== 'routes') return value

let parsed: any[]
if (typeof value === 'string') {
try {
parsed = JSON.parse(value)
if (!Array.isArray(parsed)) return value
} catch {
return value
}
} else if (Array.isArray(value)) {
parsed = value
} else {
return value
}
const parsed = parseJsonArray(value)
if (!parsed) return value

let elseIfCounter = 0
const normalized = parsed.map((item, index) => {
Expand Down
Loading
Loading