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]));
}