Skip to content

Commit 07af501

Browse files
j15zclaude
andauthored
fix(copilot): let edit_workflow set knowledge-base tag filters, and stop it clearing them (#5546)
* fix(copilot): persist KB tag subblocks as JSON strings from edit_workflow The edit_workflow tool normalizes array-with-id subblocks (via normalizeArrayWithIds) but only re-stringifies the keys listed in JSON_STRING_SUBBLOCK_KEYS. `tagFilters` (knowledge-tag-filters) and `documentTags` (document-tag-entry) were missing, so agent-authored tag filters were stored as raw JSON arrays while those UI components read their value with JSON.parse (expecting a string). The result: an agent edit to a Knowledge block's tag filter persisted correctly but rendered as an empty filter in the editor (JSON.parse on an array throws -> []). - Add `tagFilters` and `documentTags` to JSON_STRING_SUBBLOCK_KEYS so edit_workflow stores them in the same shape the UI writes. - Make both components' parsers tolerate an already-parsed array on read, self-healing values already persisted in the broken (array) shape. Search execution was unaffected (parseTagFilters accepts arrays), so the value was never lost — only the editor render and round-trip were broken. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(copilot): expose KB tag definitions in VFS meta.json Surface each knowledge base's defined tags (displayName -> tagSlot) inline in its meta.json via serializeKBMeta, loaded in one batched query (loadKbTagDefinitions), so the agent can bind a knowledge-tag filter to a real tag slot instead of guessing a tag name it cannot otherwise see. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(copilot): stringify KB tag subblocks on the nested-node edit path The nested-node merge path normalized array-with-id subblocks but never re-serialized the JSON_STRING_SUBBLOCK_KEYS, so editing a block nested in a loop/parallel container still persisted tagFilters/documentTags (and conditions/routes) as raw arrays -- the exact shape the subblock components cannot JSON.parse. Route all four write paths through a single normalizeSubblockValue helper so the normalize and re-stringify steps cannot drift apart again, and extract the duplicated string-or-array read logic into parseJsonArrayValue. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(copilot): tighten subblock serialization helpers Derive KbTagDefinitionSummary from the canonical TagDefinition instead of restating its fields, make parseJsonArrayValue generic so callers drop their `as T[]` casts, and unexport the three builders helpers that no longer have consumers outside the module now that normalizeSubblockValue fronts them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(copilot): stop stripping tagFilters/documentTags from the agent's workflow view sanitizeForCopilot dropped `tagFilters` and `documentTags` from the workflow state the agent reads (workflows/{name}/state.json), while edit_workflow is allowed to write both. The field was therefore write-only: on a follow-up edit the agent read back an absent field, concluded no filter was set, and cleared the user's tag filter. The redaction was introduced for workflow *export* (#1628) and is already enforced there by sanitizeWorkflowForSharing's key list. The duplicate in the copilot-only sanitizeSubBlocks was redundant for export and destructive for the agent. Removes it and pins the contract with a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(copilot): reject malformed KB tag values instead of clearing the filter `knowledge-tag-filters` and `document-tag-entry` had no arm in the `edit_workflow` input validator, so they fell through to the pass-through default. Any non-array value the agent supplied -- a double-encoded JSON string, an object, an unparseable string -- reached `normalizeSubblockValue`, where `normalizeArrayWithIds` coerces unparseable input to `[]`. The write path then persisted `"[]"` over the tag filter the user had configured. `condition-input` and `router-input` already guard against exactly this and return an actionable error to the model. Extend that arm to cover the two KB subblock types. It keys on subblock type, so the unrelated `tagFilters` short-input on the Algolia block is unaffected. `null`/`undefined` and empty arrays still clear the field, so intentional clears keep working. Also wrap `loadKbTagDefinitions` in try/catch. Tag definitions are an optional meta.json enrichment, but the query ran inside the top-level `Promise.all`, so a transient failure would reject the entire workspace VFS materialize and leave the agent unable to read any file. Now it degrades to a meta.json without tag definitions, matching the sibling materializers. Adds regression tests for both, plus the first tests for `parseJsonArrayValue`, the helper that keeps pre-fix raw-array rows readable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(copilot): collapse duplicate JSON-array parsing in edit-workflow builders `normalizeArrayWithIds` and `normalizeConditionRouterIds` each hand-rolled the same "accept a raw array or the JSON string these subblocks persist" parse. Extract `parseJsonArray`, which returns null when the value is neither, so each caller keeps its own distinct fallback: `[]` for the former, the untouched original value for the latter. Behavior-preserving. An empty array is truthy, so `[]` and `"[]"` still parse through rather than hitting either fallback. `validation.ts` has a third copy, but `builders.ts` already imports from it, so sharing the helper across the two would introduce an import cycle. Left as is. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(copilot): specify tag name and legal operators in KB meta.json `tagDefinitions` exposed `displayName`, but a `tagFilters` entry must carry the key `tagName`. An entry written with `displayName` passes validation and persists, then filters nothing -- a silent failure. Rename the field at the serializer boundary; the DB column is untouched. Also emit the operators legal for each tag's `fieldType`, reusing `getOperatorsForFieldType`. `between` is valid for number and date but not for text or boolean, and the agent has no way to infer that. An unrecognized fieldType yields an empty list rather than throwing. Still unspecified, and deliberately out of scope: a filter entry's value key is `tagValue` (but `value` on documentTags), and `between` needs `valueTo`. Those describe the subblock entry shape, not the knowledge base, so meta.json is the wrong place for them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(copilot): pass a nullish subblock clear through instead of serializing "[]" `validateValueForSubBlockType` accepts null as an explicit clear, but `normalizeSubblockValue` then ran it through `normalizeArrayWithIds`, which coerces any non-array to `[]`, and persisted the string "[]". No data is lost either way -- "[]" and an absent field both mean "no filters". But it left the field present when the caller asked for it to be unset, so `sanitizeForCopilot` showed the agent an empty filter rather than an absent one, contradicting the absent-means-unset invariant the sanitizer documents. It also made Algolia's `if (params.tagFilters)` see a set value, since "[]" is truthy. An explicitly empty array still serializes to "[]" -- clearing with a value is distinct from clearing by omission. Reported by Cursor Bugbot on #5546. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e83c0be commit 07af501

15 files changed

Lines changed: 537 additions & 110 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry.tsx

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { getActiveWorkflowSearchHighlight } from '@/app/workspace/[workspaceId]/
2121
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
2222
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
2323
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
24+
import { parseJsonArrayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
2425
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
2526
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
2627
import type { SubBlockConfig } from '@/blocks/types'
@@ -98,22 +99,14 @@ export function DocumentTagEntry({
9899

99100
const currentValue = isPreview ? previewValue : storeValue
100101

101-
const parseTags = (tagValue: string | null): DocumentTag[] => {
102-
if (!tagValue) return []
103-
try {
104-
const parsed = JSON.parse(tagValue)
105-
if (!Array.isArray(parsed)) return []
106-
return parsed.map((t: DocumentTag) => ({
107-
...t,
108-
fieldType: t.fieldType || 'text',
109-
collapsed: t.collapsed ?? false,
110-
}))
111-
} catch {
112-
return []
113-
}
114-
}
102+
const parseTags = (tagValue: unknown): DocumentTag[] =>
103+
parseJsonArrayValue<DocumentTag>(tagValue).map((t) => ({
104+
...t,
105+
fieldType: t.fieldType || 'text',
106+
collapsed: t.collapsed ?? false,
107+
}))
115108

116-
const parsedTags = parseTags(currentValue || null)
109+
const parsedTags = parseTags(currentValue)
117110
const tags: DocumentTag[] = parsedTags.length > 0 ? parsedTags : [createDefaultTag()]
118111
const isReadOnly = isPreview || disabled
119112

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/compon
2121
import { getActiveWorkflowSearchHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
2222
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
2323
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
24+
import { parseJsonArrayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
2425
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
2526
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
2627
import type { SubBlockConfig } from '@/blocks/types'
@@ -100,24 +101,16 @@ export function KnowledgeTagFilters({
100101
disabled,
101102
})
102103

103-
const parseFilters = (filterValue: string | null): TagFilter[] => {
104-
if (!filterValue) return []
105-
try {
106-
const parsed = JSON.parse(filterValue)
107-
if (!Array.isArray(parsed)) return []
108-
return parsed.map((f: TagFilter) => ({
109-
...f,
110-
fieldType: f.fieldType || 'text',
111-
operator: f.operator || 'eq',
112-
collapsed: f.collapsed ?? false,
113-
}))
114-
} catch {
115-
return []
116-
}
117-
}
104+
const parseFilters = (filterValue: unknown): TagFilter[] =>
105+
parseJsonArrayValue<TagFilter>(filterValue).map((f) => ({
106+
...f,
107+
fieldType: f.fieldType || 'text',
108+
operator: f.operator || 'eq',
109+
collapsed: f.collapsed ?? false,
110+
}))
118111

119112
const currentValue = isPreview ? previewValue : storeValue
120-
const parsedFilters = parseFilters(currentValue || null)
113+
const parsedFilters = parseFilters(currentValue)
121114
const filters: TagFilter[] = parsedFilters.length > 0 ? parsedFilters : [createDefaultFilter()]
122115
const isReadOnly = isPreview || disabled
123116

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { parseJsonArrayValue } from './utils'
6+
7+
interface TagFilter {
8+
id: string
9+
tagName: string
10+
}
11+
12+
describe('parseJsonArrayValue', () => {
13+
it('parses a JSON string array, the shape edit_workflow now persists', () => {
14+
const filters: TagFilter[] = [{ id: 'f1', tagName: 'Department' }]
15+
16+
expect(parseJsonArrayValue<TagFilter>(JSON.stringify(filters))).toEqual(filters)
17+
})
18+
19+
// Rows written by builds predating the edit_workflow stringify fix still hold raw arrays.
20+
it('passes through an already-parsed array', () => {
21+
const filters: TagFilter[] = [{ id: 'f1', tagName: 'Department' }]
22+
23+
expect(parseJsonArrayValue<TagFilter>(filters)).toEqual(filters)
24+
})
25+
26+
it.each([
27+
['null', null],
28+
['undefined', undefined],
29+
['an empty string', ''],
30+
])('returns an empty array for %s', (_label, value) => {
31+
expect(parseJsonArrayValue(value)).toEqual([])
32+
})
33+
34+
it.each([
35+
['a malformed JSON string', '{not json'],
36+
['a JSON string parsing to null', 'null'],
37+
['a JSON string parsing to an object', '{"a":1}'],
38+
['a JSON string parsing to a number', '5'],
39+
['a bare object', { a: 1 }],
40+
])('returns an empty array rather than throwing for %s', (_label, value) => {
41+
expect(parseJsonArrayValue(value)).toEqual([])
42+
})
43+
})

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,30 @@ export function resolvePreviewContextValue(raw: unknown): unknown {
1616
}
1717
return raw
1818
}
19+
20+
/**
21+
* Parses a sub-block value that may be stored as a JSON string or as an already-parsed array.
22+
*
23+
* @remarks
24+
* These sub-blocks contract on a JSON string, which is what their components write. Copilot's
25+
* `edit_workflow` persisted them as raw arrays until it was fixed to re-serialize, so rows
26+
* written by older builds still hold an array. Both shapes are accepted on read.
27+
*
28+
* The element type is asserted, not validated -- callers are responsible for defaulting any
29+
* fields a malformed entry may be missing.
30+
*
31+
* @param value - The stored sub-block value, of unknown shape
32+
* @returns The parsed array, or `[]` when the value is absent, unparseable, or not an array
33+
*/
34+
export function parseJsonArrayValue<T>(value: unknown): T[] {
35+
if (!value) return []
36+
let parsed: unknown = value
37+
if (typeof value === 'string') {
38+
try {
39+
parsed = JSON.parse(value)
40+
} catch {
41+
return []
42+
}
43+
}
44+
return Array.isArray(parsed) ? (parsed as T[]) : []
45+
}

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.test.ts

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it, vi } from 'vitest'
5-
import { createBlockFromParams } from './builders'
5+
import { createBlockFromParams, normalizeSubblockValue } from './builders'
66

77
const agentBlockConfig = {
88
type: 'agent',
@@ -20,10 +20,25 @@ const conditionBlockConfig = {
2020
subBlocks: [{ id: 'conditions', type: 'condition-input' }],
2121
}
2222

23+
const knowledgeBlockConfig = {
24+
type: 'knowledge',
25+
name: 'Knowledge',
26+
outputs: {},
27+
subBlocks: [
28+
{ id: 'tagFilters', type: 'knowledge-tag-filters' },
29+
{ id: 'documentTags', type: 'document-tag-entry' },
30+
],
31+
}
32+
33+
const blocksByType: Record<string, unknown> = {
34+
agent: agentBlockConfig,
35+
condition: conditionBlockConfig,
36+
knowledge: knowledgeBlockConfig,
37+
}
38+
2339
vi.mock('@/blocks/registry', () => ({
24-
getAllBlocks: () => [agentBlockConfig, conditionBlockConfig],
25-
getBlock: (type: string) =>
26-
type === 'agent' ? agentBlockConfig : type === 'condition' ? conditionBlockConfig : undefined,
40+
getAllBlocks: () => [agentBlockConfig, conditionBlockConfig, knowledgeBlockConfig],
41+
getBlock: (type: string) => blocksByType[type],
2742
}))
2843

2944
describe('createBlockFromParams', () => {
@@ -69,4 +84,73 @@ describe('createBlockFromParams', () => {
6984
expect(parsed[0].id).toBe('condition-1-if')
7085
expect(parsed[1].id).toBe('condition-1-else')
7186
})
87+
88+
it('persists knowledge tag subblocks as JSON strings, not raw arrays', () => {
89+
const block = createBlockFromParams('kb-1', {
90+
type: 'knowledge',
91+
name: 'Knowledge 1',
92+
inputs: {
93+
tagFilters: [{ tagName: 'Department', tagSlot: 'tag1', tagValue: 'it' }],
94+
documentTags: [{ tagName: 'Team', tagSlot: 'tag2', value: 'infra' }],
95+
},
96+
triggerMode: false,
97+
})
98+
99+
expect(typeof block.subBlocks.tagFilters.value).toBe('string')
100+
expect(typeof block.subBlocks.documentTags.value).toBe('string')
101+
102+
const filters = JSON.parse(block.subBlocks.tagFilters.value)
103+
expect(filters[0].tagName).toBe('Department')
104+
expect(filters[0].id).toEqual(expect.any(String))
105+
})
106+
})
107+
108+
describe('normalizeSubblockValue', () => {
109+
it.each(['tagFilters', 'documentTags', 'conditions', 'routes'])(
110+
'serializes %s to a JSON string the subblock component can parse',
111+
(key) => {
112+
const result = normalizeSubblockValue(key, [{ id: 'not-a-uuid', title: 'a' }])
113+
114+
expect(typeof result).toBe('string')
115+
expect(JSON.parse(result as string)[0].title).toBe('a')
116+
}
117+
)
118+
119+
it('accepts a JSON string as input and still returns a string', () => {
120+
const result = normalizeSubblockValue('tagFilters', JSON.stringify([{ tagName: 'Department' }]))
121+
122+
expect(typeof result).toBe('string')
123+
expect(JSON.parse(result as string)[0].tagName).toBe('Department')
124+
})
125+
126+
it('leaves array-with-id subblocks that are not string-serialized as raw arrays', () => {
127+
const result = normalizeSubblockValue('inputFormat', [{ id: 'x', name: 'field' }])
128+
129+
expect(Array.isArray(result)).toBe(true)
130+
})
131+
132+
it('passes through subblock keys that need no normalization', () => {
133+
expect(normalizeSubblockValue('systemPrompt', 'hello')).toBe('hello')
134+
})
135+
136+
// Validation treats null as an explicit clear. Coercing it to "[]" would persist a value
137+
// where the caller asked for none, so the agent reads back an empty filter rather than an
138+
// absent one -- the same absent-vs-empty ambiguity that caused the original data loss.
139+
it.each(['tagFilters', 'documentTags', 'conditions', 'routes'])(
140+
'passes a null %s through as a clear rather than serializing it to "[]"',
141+
(key) => {
142+
expect(normalizeSubblockValue(key, null)).toBeNull()
143+
expect(normalizeSubblockValue(key, undefined)).toBeUndefined()
144+
}
145+
)
146+
147+
it('still serializes an explicitly empty array, which clears the field with a value', () => {
148+
expect(normalizeSubblockValue('tagFilters', [])).toBe('[]')
149+
})
150+
151+
it('replaces non-uuid ids so copilot-authored rows match UI-created ones', () => {
152+
const result = normalizeSubblockValue('tagFilters', [{ id: 'filter-1', tagName: 'Department' }])
153+
154+
expect(JSON.parse(result as string)[0].id).not.toBe('filter-1')
155+
})
72156
})

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts

Lines changed: 47 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,7 @@ export function createBlockFromParams(
9393
return
9494
}
9595

96-
let sanitizedValue = value
97-
98-
// Normalize array subblocks with id fields (inputFormat, table rows, etc.)
99-
if (shouldNormalizeArrayIds(key)) {
100-
sanitizedValue = normalizeArrayWithIds(value)
101-
if (JSON_STRING_SUBBLOCK_KEYS.has(key)) {
102-
sanitizedValue = JSON.stringify(sanitizedValue)
103-
}
104-
}
96+
let sanitizedValue = normalizeSubblockValue(key, value)
10597

10698
sanitizedValue = normalizeConditionRouterIds(blockId, key, sanitizedValue)
10799

@@ -274,29 +266,35 @@ const ARRAY_WITH_ID_SUBBLOCK_TYPES = new Set([
274266
* Subblock keys whose UI components expect a JSON string, not a raw array.
275267
* After normalizeArrayWithIds returns an array, these must be re-stringified.
276268
*/
277-
export const JSON_STRING_SUBBLOCK_KEYS = new Set(['conditions', 'routes'])
269+
const JSON_STRING_SUBBLOCK_KEYS = new Set(['conditions', 'routes', 'tagFilters', 'documentTags'])
270+
271+
/**
272+
* Coerces a subblock value to an array, accepting either a raw array or the JSON string
273+
* the string-serialized subblocks persist.
274+
*
275+
* @returns The array, or `null` when the value is not an array and does not parse to one.
276+
* Callers supply their own fallback, which differs by site.
277+
*/
278+
function parseJsonArray(value: unknown): any[] | null {
279+
if (Array.isArray(value)) return value
280+
if (typeof value !== 'string') return null
281+
282+
try {
283+
const parsed = JSON.parse(value)
284+
return Array.isArray(parsed) ? parsed : null
285+
} catch {
286+
return null
287+
}
288+
}
278289

279290
/**
280291
* Normalizes array subblock values by ensuring each item has a valid UUID.
281292
* The LLM may generate arbitrary IDs like "input-desc-001" or "row-1" which need
282293
* to be converted to proper UUIDs for consistency with UI-created items.
283294
*/
284-
export function normalizeArrayWithIds(value: unknown): any[] {
285-
let arr: any[]
286-
287-
if (Array.isArray(value)) {
288-
arr = value
289-
} else if (typeof value === 'string') {
290-
try {
291-
const parsed = JSON.parse(value)
292-
if (!Array.isArray(parsed)) return []
293-
arr = parsed
294-
} catch {
295-
return []
296-
}
297-
} else {
298-
return []
299-
}
295+
function normalizeArrayWithIds(value: unknown): any[] {
296+
const arr = parseJsonArray(value)
297+
if (!arr) return []
300298

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

320+
/**
321+
* Normalizes an array-with-id subblock value, re-serializing it to a JSON string for the
322+
* subblock keys whose UI components read a string rather than a raw array.
323+
*
324+
* Every write path that persists LLM-supplied subblock values must route through this so the
325+
* two concerns cannot drift apart; returns non-array-with-id values untouched.
326+
*
327+
* @remarks
328+
* A nullish value passes through unchanged. `validateValueForSubBlockType` treats null as an
329+
* explicit clear, and coercing it to `"[]"` here would persist a value where the caller asked
330+
* for none -- leaving `sanitizeForCopilot` to show the agent an empty filter rather than an
331+
* absent one, and callers that branch on the field's presence to see it as set.
332+
*/
333+
export function normalizeSubblockValue(key: string, value: unknown): unknown {
334+
if (!shouldNormalizeArrayIds(key)) return value
335+
if (value === null || value === undefined) return value
336+
const normalized = normalizeArrayWithIds(value)
337+
return JSON_STRING_SUBBLOCK_KEYS.has(key) ? JSON.stringify(normalized) : normalized
338+
}
339+
322340
/**
323341
* Normalizes condition/router branch IDs to use canonical block-scoped format.
324342
* The LLM provides branch structure (if/else-if/else or routes) but should not
@@ -327,19 +345,8 @@ export function shouldNormalizeArrayIds(key: string): boolean {
327345
export function normalizeConditionRouterIds(blockId: string, key: string, value: unknown): unknown {
328346
if (key !== 'conditions' && key !== 'routes') return value
329347

330-
let parsed: any[]
331-
if (typeof value === 'string') {
332-
try {
333-
parsed = JSON.parse(value)
334-
if (!Array.isArray(parsed)) return value
335-
} catch {
336-
return value
337-
}
338-
} else if (Array.isArray(value)) {
339-
parsed = value
340-
} else {
341-
return value
342-
}
348+
const parsed = parseJsonArray(value)
349+
if (!parsed) return value
343350

344351
let elseIfCounter = 0
345352
const normalized = parsed.map((item, index) => {

0 commit comments

Comments
 (0)