diff --git a/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx b/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx index bb98eb361f..be8cebf4e0 100644 --- a/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx @@ -1,12 +1,12 @@ import { useMemo } from 'react'; import { FilePenLine, FilePlus2 } from 'lucide-react'; import type { TAttachment } from 'librechat-data-provider'; +import parseJsonField, { parseJsonFieldOccurrences } from './parseJsonField'; import ProgressText from '~/components/Chat/Messages/Content/ProgressText'; import useToolCallState from './useToolCallState'; import useLazyHighlight from './useLazyHighlight'; import CodeWindowHeader from './CodeWindowHeader'; import { AttachmentGroup } from './Attachment'; -import parseJsonField from './parseJsonField'; import { langFromPath } from './ReadFileCall'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -85,12 +85,21 @@ function buildEditArgsPreview(args: ToolCallArgs): string { return formatEditPreview(edits); } - const oldText = parsed ? textValue(parsed.old_text) : parseJsonField(args, 'old_text'); - const newText = parsed ? textValue(parsed.new_text) : parseJsonField(args, 'new_text'); - if (!oldText && !newText) { - return ''; + if (parsed) { + const oldText = textValue(parsed.old_text); + const newText = textValue(parsed.new_text); + return oldText || newText ? formatEditPreview([{ oldText, newText }]) : ''; } - return formatEditPreview([{ oldText, newText }]); + + /** Partial JSON during streaming: pair up field occurrences in document order, covering both single-replacement and batched `edits` args */ + const oldTexts = parseJsonFieldOccurrences(args, 'old_text'); + const newTexts = parseJsonFieldOccurrences(args, 'new_text'); + const editCount = Math.max(oldTexts.length, newTexts.length); + const edits = Array.from({ length: editCount }, (_, index) => ({ + oldText: oldTexts[index] ?? '', + newText: newTexts[index] ?? '', + })).filter((edit) => edit.oldText || edit.newText); + return formatEditPreview(edits); } export default function FileAuthoringCall({ @@ -119,13 +128,16 @@ export default function FileAuthoringCall({ const editArgsPreview = useMemo(() => buildEditArgsPreview(args), [args]); const fileName = filePath.split('/').pop() || filePath; const fileLang = useMemo(() => langFromPath(filePath), [filePath]); + const argsPreview = isCreate ? authoredContent : editArgsPreview; const outputIsDiff = hasDiff(output); - const preview = isCreate ? output || authoredContent : output || editArgsPreview; - const previewIsDiff = outputIsDiff || (!isCreate && !!editArgsPreview && !output); + /** A diff in the output supersedes the args preview — it carries the input with real file context */ + const preview = outputIsDiff ? output : argsPreview || output; + const showOutputSection = !!output && preview !== output; + const previewIsDiff = outputIsDiff || (!isCreate && !!editArgsPreview && preview !== output); let previewLang = 'plaintext'; if (previewIsDiff) { previewLang = 'diff'; - } else if (isCreate && authoredContent && !output) { + } else if (isCreate && authoredContent && preview === authoredContent) { previewLang = fileLang; } @@ -174,6 +186,16 @@ export default function FileAuthoringCall({ {highlighted ?? preview} + {showOutputSection && ( +
+                  {output}
+                
+ )} )} diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx index 08e19f73c3..5565e94919 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx @@ -84,7 +84,7 @@ describe('FileAuthoringCall', () => { expect(screen.getByText(/Use this skill for testing/)).toBeInTheDocument(); }); - it('shows completed create_file output instead of full content args', () => { + it('keeps authored content visible alongside the output after create_file completes', () => { render( { isSubmitting={false} args={{ file_path: 'skills/demo/SKILL.md', - content: '# Demo\n\nLarge generated body should not stay mounted after completion.', + content: '# Demo\n\nLarge generated body stays visible after completion.', }} output="Created skills/demo/SKILL.md (4096 chars)." />, ); expect(screen.getByTestId('progress-text')).toHaveTextContent('Created SKILL.md'); + expect(screen.getByTestId('code-window-header')).toHaveAttribute('data-language', 'SKILL.md'); + expect(screen.getByText(/Large generated body stays visible/)).toBeInTheDocument(); expect(screen.getByText('Created skills/demo/SKILL.md (4096 chars).')).toBeInTheDocument(); - expect(screen.queryByText(/Large generated body/)).not.toBeInTheDocument(); + }); + + it('prefers the output diff over the args preview after edit_file completes', () => { + const output = [ + 'Edited skills/demo/SKILL.md (exact match).', + '', + '--- skills/demo/SKILL.md', + '+++ skills/demo/SKILL.md', + '@@ -1,1 +1,1 @@', + '-old line', + '+new line', + ].join('\n'); + render( + , + ); + + const preview = screen.getByText((_, element) => element?.tagName.toLowerCase() === 'code'); + expect(preview).toHaveTextContent('-old line'); + expect(preview).toHaveTextContent('+new line'); + expect(screen.queryByText(/--- old_text/)).not.toBeInTheDocument(); + }); + + it('shows the attempted input alongside the error output when edit_file fails', () => { + render( + , + ); + + const preview = screen.getByText((_, element) => element?.tagName.toLowerCase() === 'code'); + expect(preview).toHaveTextContent('-missing text'); + expect(preview).toHaveTextContent('+replacement'); + expect(screen.getByText(/matched 0 locations/)).toBeInTheDocument(); }); it('shows edit_file replacement args while the call is in progress', () => { @@ -127,6 +179,64 @@ describe('FileAuthoringCall', () => { expect(preview).toHaveTextContent('+description: New behavior'); }); + it('streams create_file content from partial JSON string args during run_step_delta', () => { + render( + , + ); + + expect(screen.getByTestId('progress-text')).toHaveTextContent('Creating SKILL.md'); + expect(screen.getByTestId('code-window-header')).toHaveAttribute('data-language', 'SKILL.md'); + expect(screen.getByText(/Streaming body so far/)).toBeInTheDocument(); + }); + + it('streams edit_file replacement preview from partial JSON string args', () => { + render( + , + ); + + const preview = screen.getByText((_, element) => element?.tagName.toLowerCase() === 'code'); + expect(preview).toHaveTextContent('-description: Old behavior'); + expect(preview).toHaveTextContent('+description: New beh'); + }); + + it('streams batched edit_file previews from a partial edits array', () => { + const args = + '{"file_path":"skills/demo/SKILL.md","edits":[' + + '{"old_text":"first old","new_text":"first new"},' + + '{"old_text":"second old","new_text":"second n'; + render( + , + ); + + const preview = screen.getByText((_, element) => element?.tagName.toLowerCase() === 'code'); + expect(preview).toHaveTextContent('--- old_text 1'); + expect(preview).toHaveTextContent('-first old'); + expect(preview).toHaveTextContent('+first new'); + expect(preview).toHaveTextContent('--- old_text 2'); + expect(preview).toHaveTextContent('-second old'); + expect(preview).toHaveTextContent('+second n'); + }); + it('shows batched edit_file replacements from edits args while the call is in progress', () => { render( { describe('object args', () => { @@ -100,6 +103,71 @@ describe('parseJsonField', () => { expect(parseJsonField(partial, 'command')).toBe('tab\\there'); }); }); + + describe('in-progress streaming fields — unterminated values', () => { + it('extracts a field whose closing quote has not streamed in yet', () => { + const partial = '{"file_path":"skills/demo/SKILL.md","content":"# Demo\\nline two'; + expect(parseJsonField(partial, 'content')).toBe('# Demo\nline two'); + }); + + it('grows the extracted value as more deltas arrive', () => { + const full = '{"file_path":"a.md","content":"# Title\\n\\nBody text here"}'; + const lengths = [40, 48, 56, full.length]; + const values = lengths.map((len) => parseJsonField(full.slice(0, len), 'content')); + expect(values[values.length - 1]).toBe('# Title\n\nBody text here'); + values.slice(1).forEach((value, index) => { + expect(value.startsWith(values[index])).toBe(true); + }); + }); + + it('drops a dangling backslash from a partially streamed escape', () => { + const partial = '{"content":"line one\\'; + expect(parseJsonField(partial, 'content')).toBe('line one'); + }); + + it('unescapes a complete escaped backslash at the stream edge', () => { + const partial = '{"content":"path C:\\\\'; + expect(parseJsonField(partial, 'content')).toBe('path C:\\'); + }); + + it('handles escaped quotes inside an unterminated value', () => { + const partial = '{"command":"say \\"hi'; + expect(parseJsonField(partial, 'command')).toBe('say "hi'); + }); + + it('returns empty string when the value has not opened yet', () => { + expect(parseJsonField('{"content":', 'content')).toBe(''); + expect(parseJsonField('{"content":"', 'content')).toBe(''); + }); + + it('does not run past a completed field into later args', () => { + const partial = '{"old_text":"keep this","new_text":"and th'; + expect(parseJsonField(partial, 'old_text')).toBe('keep this'); + expect(parseJsonField(partial, 'new_text')).toBe('and th'); + }); + }); +}); + +describe('parseJsonFieldOccurrences', () => { + it('returns empty array for non-string args', () => { + expect(parseJsonFieldOccurrences(undefined, 'old_text')).toEqual([]); + expect(parseJsonFieldOccurrences({ old_text: 'x' }, 'old_text')).toEqual([]); + expect(parseJsonFieldOccurrences('', 'old_text')).toEqual([]); + }); + + it('extracts repeated fields from a partial edits array in document order', () => { + const partial = + '{"file_path":"a.md","edits":[' + + '{"old_text":"first old","new_text":"first new"},' + + '{"old_text":"second old","new_text":"second n'; + expect(parseJsonFieldOccurrences(partial, 'old_text')).toEqual(['first old', 'second old']); + expect(parseJsonFieldOccurrences(partial, 'new_text')).toEqual(['first new', 'second n']); + }); + + it('extracts a single top-level field', () => { + const partial = '{"file_path":"a.md","old_text":"only one'; + expect(parseJsonFieldOccurrences(partial, 'old_text')).toEqual(['only one']); + }); }); describe('areToolCallArgsComplete', () => { diff --git a/client/src/components/Chat/Messages/Content/Parts/parseJsonField.ts b/client/src/components/Chat/Messages/Content/Parts/parseJsonField.ts index cf4f9a3aa4..e177fff1dd 100644 --- a/client/src/components/Chat/Messages/Content/Parts/parseJsonField.ts +++ b/client/src/components/Chat/Messages/Content/Parts/parseJsonField.ts @@ -15,6 +15,24 @@ export function areToolCallArgsComplete(args: ToolCallArgs): boolean { } } +/** Matches `"field":"value"`, tolerating a missing closing quote and a dangling escape at the end of partially streamed args. */ +function fieldRegex(field: string, flags?: string): RegExp { + const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`"${escaped}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)(?:"|\\\\?$)`, flags); +} + +function unescapeJsonString(value: string): string { + return value.replace(/\\(.)/g, (_, c: string) => { + if (c === 'n') { + return '\n'; + } + if (c === '"' || c === '\\') { + return c; + } + return `\\${c}`; + }); +} + /** Extracts a string field from tool call args, handling object, JSON string, and partial-JSON fallback. */ export default function parseJsonField(args: ToolCallArgs, field: string): string { if (typeof args === 'object' && args !== null) { @@ -28,19 +46,17 @@ export default function parseJsonField(args: ToolCallArgs, field: string): strin } catch { // partial JSON during streaming; fall through to regex } - const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const re = new RegExp(`"${escaped}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`); - const match = args?.match(re); + const match = args?.match(fieldRegex(field)); if (!match) { return ''; } - return match[1].replace(/\\(.)/g, (_, c: string) => { - if (c === 'n') { - return '\n'; - } - if (c === '"' || c === '\\') { - return c; - } - return `\\${c}`; - }); + return unescapeJsonString(match[1]); +} + +/** Extracts every occurrence of a string field from partially streamed JSON args, in document order. */ +export function parseJsonFieldOccurrences(args: ToolCallArgs, field: string): string[] { + if (typeof args !== 'string' || args.length === 0) { + return []; + } + return Array.from(args.matchAll(fieldRegex(field, 'g')), (match) => unescapeJsonString(match[1])); }