/* eslint-disable n8n-local-rules/no-json-parse-json-stringify */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import type { ChatMessageContentChunk } from '@n8n/api-types'; import assert from 'assert'; import { parseMessage, appendChunkToParsedMessageItems } from './parser'; describe(parseMessage, () => { it('should parse text', () => { expect(parseMessage({ type: 'ai', content: 'hello' })).toEqual([ { type: 'text', content: 'hello' }, ]); }); it('should parse non-ai message as text', () => { expect(parseMessage({ type: 'human', content: '' })).toEqual([ { type: 'text', content: '' }, ]); }); it('should parse AI message containing with-buttons JSON', () => { const json = JSON.stringify({ type: 'with-buttons', text: 'Do you want to proceed?', blockUserInput: true, buttons: [ { text: 'Yes', link: 'https://example.com/yes', type: 'primary' }, { text: 'No', link: 'https://example.com/no', type: 'secondary' }, ], }); const result = parseMessage({ type: 'ai', content: json }); expect(result).toEqual([ { type: 'with-buttons', content: 'Do you want to proceed?', blockUserInput: true, buttons: [ { text: 'Yes', link: 'https://example.com/yes', type: 'primary' }, { text: 'No', link: 'https://example.com/no', type: 'secondary' }, ], }, ]); }); it('should return human message content as text even if it looks like with-buttons JSON', () => { const json = JSON.stringify({ type: 'with-buttons', text: 'User sent this', blockUserInput: true, buttons: [], }); const result = parseMessage({ type: 'human', content: json }); expect(result).toEqual([{ type: 'text', content: json }]); }); it('should parse artifact-create command', () => { const content = `Here is a document: My Document md # Hello World This is a test. Done!`; const result = parseMessage({ type: 'ai', content }); expect(result).toEqual([ { type: 'text', content: 'Here is a document:\n\n' }, { type: 'artifact-create', content: ` My Document md # Hello World This is a test. `, command: { title: 'My Document', type: 'md', content: '\n# Hello World\nThis is a test.\n', }, isIncomplete: false, }, { type: 'text', content: '\n\nDone!' }, ]); }); it('should parse artifact-edit command', () => { const content = ` My Document old text new text true `; const result = parseMessage({ type: 'ai', content }); expect(result).toEqual([ { type: 'artifact-edit', content, command: { title: 'My Document', oldString: 'old text', newString: 'new text', replaceAll: true, }, isIncomplete: false, }, ]); }); }); describe(appendChunkToParsedMessageItems, () => { it('should append text chunk to empty items', () => { const result = appendChunkToParsedMessageItems([], 'hello'); expect(result).toEqual([{ type: 'text', content: 'hello' }]); }); it('should append text chunk to existing text', () => { const items: ChatMessageContentChunk[] = [{ type: 'text', content: 'hello' }]; const result = appendChunkToParsedMessageItems(items, ' world'); expect(result).toEqual([{ type: 'text', content: 'hello world' }]); }); it('should preserve whitespace-only chunks like newlines', () => { let items: ChatMessageContentChunk[] = []; items = appendChunkToParsedMessageItems(items, '## Hi'); items = appendChunkToParsedMessageItems(items, '\n\n'); items = appendChunkToParsedMessageItems(items, 'Thanks'); expect(items).toEqual([{ type: 'text', content: '## Hi\n\nThanks' }]); }); describe('with-buttons JSON parsing', () => { it('should parse valid with-buttons JSON', () => { const json = JSON.stringify({ type: 'with-buttons', text: 'Please approve this action', blockUserInput: true, buttons: [ { text: 'Approve', link: 'https://example.com/approve', type: 'primary' }, { text: 'Reject', link: 'https://example.com/reject', type: 'secondary' }, ], }); const result = appendChunkToParsedMessageItems([], json); expect(result).toHaveLength(1); expect(result[0]).toEqual({ type: 'with-buttons', content: 'Please approve this action', blockUserInput: true, buttons: [ { text: 'Approve', link: 'https://example.com/approve', type: 'primary' }, { text: 'Reject', link: 'https://example.com/reject', type: 'secondary' }, ], }); }); it('should not parse with-buttons JSON with empty buttons array', () => { const json = JSON.stringify({ type: 'with-buttons', text: 'No buttons here', blockUserInput: false, buttons: [], }); const result = appendChunkToParsedMessageItems([], json); expect(result).toHaveLength(1); expect(result[0].type).toBe('text'); }); it('should parse with-buttons JSON with blockUserInput false', () => { const json = JSON.stringify({ type: 'with-buttons', text: 'You can still type', blockUserInput: false, buttons: [{ text: 'Click me', link: 'https://example.com', type: 'primary' }], }); const result = appendChunkToParsedMessageItems([], json); expect(result).toHaveLength(1); expect(result[0]).toEqual({ type: 'with-buttons', content: 'You can still type', blockUserInput: false, buttons: [{ text: 'Click me', link: 'https://example.com', type: 'primary' }], }); }); it('should not parse invalid JSON as with-buttons', () => { const invalidJson = '{ invalid json }'; const result = appendChunkToParsedMessageItems([], invalidJson); expect(result).toHaveLength(1); expect(result[0].type).toBe('text'); }); it('should not parse JSON with wrong type field', () => { const json = JSON.stringify({ type: 'something-else', text: 'Not buttons', blockUserInput: true, buttons: [], }); const result = appendChunkToParsedMessageItems([], json); expect(result).toHaveLength(1); expect(result[0].type).toBe('text'); }); it('should not parse JSON missing required fields', () => { const json = JSON.stringify({ type: 'with-buttons', text: 'Missing blockUserInput and buttons', }); const result = appendChunkToParsedMessageItems([], json); expect(result).toHaveLength(1); expect(result[0].type).toBe('text'); }); it('should not parse text that starts with { but is not valid JSON', () => { const text = '{this is not json}'; const result = appendChunkToParsedMessageItems([], text); expect(result).toHaveLength(1); expect(result[0].type).toBe('text'); }); it('should not try to parse text that does not start with {', () => { const text = 'Regular text message'; const result = appendChunkToParsedMessageItems([], text); expect(result).toEqual([{ type: 'text', content: 'Regular text message' }]); }); it('should not parse JSON with invalid button type', () => { const json = JSON.stringify({ type: 'with-buttons', text: 'Invalid button type', blockUserInput: true, buttons: [{ text: 'Bad', link: 'https://example.com', type: 'invalid' }], }); const result = appendChunkToParsedMessageItems([], json); expect(result).toHaveLength(1); expect(result[0].type).toBe('text'); }); }); it('should ignore potential prefix of command', () => { const result = appendChunkToParsedMessageItems([], 'here: { const result1 = appendChunkToParsedMessageItems([], '\nTest'); expect(result2).toEqual([ { type: 'artifact-create', content: '<command:artifact-create>\n<title>Test', command: { title: 'Test', type: '', content: '' }, isIncomplete: true, }, ]); }); it('should handle incomplete artifact-create command', () => { const result = appendChunkToParsedMessageItems([], '<command:artifact-create>\n<title>Test'); expect(result).toEqual([ { type: 'artifact-create', content: '<command:artifact-create>\n<title>Test', command: { title: 'Test', type: '', content: '' }, isIncomplete: true, }, ]); }); it('should handle incomplete artifact-create command with incomplete closing tag', () => { const result = appendChunkToParsedMessageItems([], '<command:artifact-create>\n<title>Test</t'); expect(result).toEqual([ { type: 'artifact-create', content: '<command:artifact-create>\n<title>Test</t', command: { title: 'Test', type: '', content: '' }, isIncomplete: true, }, ]); }); it('should handle incomplete artifact-create command starting in the middle of chunk', () => { const result = appendChunkToParsedMessageItems( [], 'here: <command:artifact-create>\n<title>Test', ); expect(result).toEqual([ { type: 'text', content: 'here: ' }, { type: 'artifact-create', content: '<command:artifact-create>\n<title>Test', command: { title: 'Test', type: '', content: '' }, isIncomplete: true, }, ]); }); it('should complete an incomplete artifact-create command', () => { const items: ChatMessageContentChunk[] = [ { type: 'artifact-create', content: '<command:artifact-create>\n<title>Test', command: { title: 'Test', type: '', content: '' }, isIncomplete: true, }, ]; const result = appendChunkToParsedMessageItems( items, '\nmd\nContent here\n', ); expect(result).toEqual([ { type: 'artifact-create', content: ` Test md Content here `, command: { title: 'Test', type: 'md', content: 'Content here', }, isIncomplete: false, }, ]); }); it('should handle incomplete artifact-edit command', () => { const result = appendChunkToParsedMessageItems( [], '\nMy Doc\n', ); expect(result).toEqual([ { type: 'artifact-edit', content: '\nMy Doc\n', command: { title: 'My Doc', oldString: '', newString: '', replaceAll: false }, isIncomplete: true, }, ]); }); it('should complete an incomplete artifact-edit command', () => { const items: ChatMessageContentChunk[] = [ { type: 'artifact-edit', content: '\nDoc\nold', command: { title: 'Doc', oldString: 'old', newString: '', replaceAll: false }, isIncomplete: true, }, ]; const result = appendChunkToParsedMessageItems( items, '\nnew\nfalse\n', ); expect(result).toEqual([ { type: 'artifact-edit', content: ` Doc old new false `, command: { title: 'Doc', oldString: 'old', newString: 'new', replaceAll: false, }, isIncomplete: false, }, ]); }); it('should handle multiple commands in sequence', () => { const content = ` Doc1 md Content 1 Doc1 Content 1 Updated Content false `; const result = appendChunkToParsedMessageItems([], content); expect(result).toHaveLength(3); expect(result[0].type).toBe('artifact-create'); expect(result[1]).toEqual({ type: 'text', content: '\n' }); expect(result[2].type).toBe('artifact-edit'); }); it('should handle streaming scenario with text before command', () => { let items: ChatMessageContentChunk[] = []; // Chunk 1: Text before command items = appendChunkToParsedMessageItems(items, 'Here is a document:\n\n'); expect(items).toEqual([{ type: 'text', content: 'Here is a document:\n\n' }]); // Chunk 2: Start of command items = appendChunkToParsedMessageItems(items, '\n'); expect(items).toHaveLength(2); expect(items[1].type).toBe('artifact-create'); assert(items[1].type === 'artifact-create'); expect(items[1].isIncomplete).toBe(true); // Chunk 3: Complete the command items = appendChunkToParsedMessageItems( items, 'Test\nmd\nHello\n', ); expect(items).toHaveLength(2); expect(items[1].type).toBe('artifact-create'); assert(items[1].type === 'artifact-create'); expect(items[1].isIncomplete).toBe(false); }); describe('immutability (pure function behavior)', () => { it('should not mutate input items array with incomplete commands', () => { const originalItems: ChatMessageContentChunk[] = [ { type: 'artifact-create', content: '\nTest', command: { title: 'Test', type: '', content: '' }, isIncomplete: true, }, ]; // Deep clone to compare later const clonedOriginal = JSON.parse(JSON.stringify(originalItems)); const result = appendChunkToParsedMessageItems( originalItems, '\nmd\nContent\n', ); // Original should remain unchanged expect(originalItems).toEqual(clonedOriginal); // Result should have the updated item expect(result).toHaveLength(1); assert(result[0].type === 'artifact-create'); expect(result[0].isIncomplete).toBe(false); expect(result[0].command.type).toBe('md'); }); it('should not mutate hidden items', () => { const originalItems: ChatMessageContentChunk[] = [ { type: 'text', content: 'hello' }, { type: 'hidden', content: ''); // Original should remain unchanged expect(originalItems).toEqual(clonedOriginal); // Result should have parsed the command expect(result).toHaveLength(2); expect(result[0].type).toBe('text'); expect(result[1].type).toBe('artifact-create'); }); it('should not mutate text items when appending more text', () => { const originalItems: ChatMessageContentChunk[] = [{ type: 'text', content: 'hello' }]; const clonedOriginal = JSON.parse(JSON.stringify(originalItems)); const result = appendChunkToParsedMessageItems(originalItems, ' world'); // Original should remain unchanged expect(originalItems).toEqual(clonedOriginal); expect(originalItems[0].content).toBe('hello'); // Result should have concatenated text expect(result).toHaveLength(1); expect(result[0].type).toBe('text'); expect(result[0].content).toBe('hello world'); }); }); });