diff --git a/apps/sim/lib/workflows/editing/json-literal-refs.test.ts b/apps/sim/lib/workflows/editing/json-literal-refs.test.ts index 4627a111bc0..33717e16ea4 100644 --- a/apps/sim/lib/workflows/editing/json-literal-refs.test.ts +++ b/apps/sim/lib/workflows/editing/json-literal-refs.test.ts @@ -63,6 +63,20 @@ const MOCK_BLOCKS = vi.hoisted( ], outputs: {}, }, + response: { + type: 'response', + category: 'blocks', + subBlocks: [ + { id: 'dataMode', type: 'dropdown' }, + { + id: 'data', + type: 'code', + language: 'json', + condition: { field: 'dataMode', value: 'json' }, + }, + ], + outputs: {}, + }, function: { type: 'function', category: 'blocks', @@ -135,7 +149,74 @@ describe('collectUnquotedJsonStringReferences', () => { value: [''], }) expect(findings[0]?.reason).toMatch(/^unquoted-json-string: /) - expect(findings[0]?.reason).toContain('""') + }) + + /** + * Quoting is only safe for text without a double quote, backslash, or control + * character, since the text is inserted raw; free text such as a model reply has + * to be built into JSON by a Function block instead. + */ + it('advises quoting only for plain text and a Function block for free text', () => { + const [finding] = collectUnquotedJsonStringReferences( + graph({ + start: START, + insert: insertRow('{"order_id": }'), + }) + ) + expect(finding?.reason).toContain('""') + expect(finding?.reason).toMatch( + /double quote, backslash, line break, or other control character/ + ) + expect(finding?.reason).toMatch(/Function block/) + expect(finding?.reason).not.toMatch(/Quote each one/) + }) + + /** + * A field that is exactly one reference is the referenced value itself: the + * Response block returns text it cannot parse as-is and the API block sends a + * text body raw, while quoting it would turn JSON text into a JSON string. + */ + it('accepts a field made only of string references, with no JSON around them', () => { + const findings = collectUnquotedJsonStringReferences( + graph({ + writer: { type: 'agent', name: 'Writer' }, + reply: { + type: 'response', + name: 'Reply', + subBlocks: { dataMode: { value: 'json' }, data: { value: '' } }, + }, + call: { + type: 'api', + name: 'Post', + subBlocks: { body: { value: ' \n' } }, + }, + signed: { + type: 'api', + name: 'Post Signed', + subBlocks: { body: { value: '\n\n' } }, + }, + }) + ) + expect(findings).toHaveLength(0) + }) + + it('still flags a string reference that sits inside JSON text around it', () => { + const findings = collectUnquotedJsonStringReferences( + graph({ + writer: { type: 'agent', name: 'Writer' }, + reply: { + type: 'response', + name: 'Reply', + subBlocks: { dataMode: { value: 'json' }, data: { value: '[]' } }, + }, + call: { + type: 'api', + name: 'Post', + subBlocks: { body: { value: '{"note": }' } }, + }, + }) + ) + expect(findings.map((finding) => finding.blockId)).toEqual(['reply', 'call']) }) it('flags every unquoted string reference in the field once, including agent text', () => { diff --git a/apps/sim/lib/workflows/editing/lint.ts b/apps/sim/lib/workflows/editing/lint.ts index b714bc002f2..032c5ebd979 100644 --- a/apps/sim/lib/workflows/editing/lint.ts +++ b/apps/sim/lib/workflows/editing/lint.ts @@ -758,13 +758,26 @@ export function collectDanglingBlockOutputReferences( * `block.path` bodies of the reference tokens in JSON text that sit outside * every string literal, tokenized with the runtime's own reference scanner. * Tokens inside a literal, escaped quotes included, are already strings in the - * parsed document. + * parsed document. A field made only of references has no JSON around them: + * the block receives the referenced text itself (Response returns text it + * cannot parse as-is, API sends it as a raw body), and quoting would only turn + * JSON text into a JSON string, so none of its tokens are reported. */ function unquotedJsonReferenceTokens(json: string): string[] { + const tokens = findWorkflowReferenceTokens(json) + let outsideTokens = '' + let previousEnd = 0 + for (const token of tokens) { + outsideTokens += json.slice(previousEnd, token.start) + previousEnd = token.end + } + outsideTokens += json.slice(previousEnd) + if (outsideTokens.trim() === '') return [] + const unquoted: string[] = [] let cursor = 0 let inString = false - for (const token of findWorkflowReferenceTokens(json)) { + for (const token of tokens) { for (; cursor < token.start; cursor++) { const char = json[cursor] if (inString && char === '\\') cursor++ @@ -784,7 +797,9 @@ function unquotedJsonReferenceTokens(json: string): string[] { * Outside Function code a reference is replaced by its raw text, so * `{"id": }` becomes `{"id": ord-1}` and the block fails to * parse it at run time, while lint, deploy, and every earlier run of a draft - * that never reached the block stay clean. Only references whose declared + * that never reached the block stay clean. No JSON-aware escaping exists + * outside Function code, so quoting is only safe for text that can never hold a + * quote, backslash, or control character. Only references whose declared * output type is `string` are reported: numbers, booleans, and objects already * resolve to JSON values, and an undeclared type cannot be judged here. */ @@ -835,7 +850,7 @@ export function collectUnquotedJsonStringReferences( field, value, kind: 'block-output', - reason: `unquoted-json-string: these references resolve to text, which is inserted without quotes, so the field is not valid JSON at run time unless the text is itself JSON. Quote each one, e.g. "${value[0]}".`, + reason: `unquoted-json-string: these references resolve to text, which is inserted raw, so the field is not valid JSON at run time unless the text is itself JSON. Quoting, e.g. "${value[0]}", works only for text that never contains a double quote, backslash, line break, or other control character, such as an id. For free text such as a model reply, build the JSON in a Function block, which reads references as values, and set this field to only that block's result reference.`, }) } }