🎞️ fix: Stream File Authoring Previews from Partial Tool Args (#13634)

* 🌊 fix: Stream File Authoring Previews from Partial Tool Args

* 🧹 chore: Sort Imports in FileAuthoringCall

* 👁️ feat: Keep File Authoring Input Visible After Completion
This commit is contained in:
Danny Avila 2026-06-09 17:44:42 -04:00 • committed by GitHub
parent 793cbd49f0
commit c216b3ce5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 241 additions and 25 deletions

View file

@ -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}
</code>
</pre>
{showOutputSection && (
<pre
className={cn(
'max-h-[300px] overflow-auto whitespace-pre-wrap break-words border-t border-border-light px-3 py-2.5 font-mono text-xs',
hasError ? 'text-red-600 dark:text-red-400' : 'text-text-primary',
)}
>
{output}
</pre>
)}
</div>
)}
</div>

View file

@ -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(
<FileAuthoringCall
toolName="create_file"
@ -92,15 +92,67 @@ describe('FileAuthoringCall', () => {
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(
<FileAuthoringCall
toolName="edit_file"
initialProgress={1}
isSubmitting={false}
args={{
file_path: 'skills/demo/SKILL.md',
old_text: 'old line',
new_text: 'new line',
}}
output={output}
/>,
);
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(
<FileAuthoringCall
toolName="edit_file"
initialProgress={1}
isSubmitting={false}
args={{
file_path: 'skills/demo/SKILL.md',
old_text: 'missing text',
new_text: 'replacement',
}}
output="Error: old_text matched 0 locations in skills/demo/SKILL.md."
/>,
);
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(
<FileAuthoringCall
toolName="create_file"
initialProgress={0.5}
isSubmitting={true}
args={'{"file_path":"skills/demo/SKILL.md","content":"# Demo\\n\\nStreaming body so far'}
output=""
/>,
);
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(
<FileAuthoringCall
toolName="edit_file"
initialProgress={0.5}
isSubmitting={true}
args={
'{"file_path":"skills/demo/SKILL.md","old_text":"description: Old behavior","new_text":"description: New beh'
}
output=""
/>,
);
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(
<FileAuthoringCall
toolName="edit_file"
initialProgress={0.5}
isSubmitting={true}
args={args}
output=""
/>,
);
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(
<FileAuthoringCall

View file

@ -1,4 +1,7 @@
import parseJsonField, { areToolCallArgsComplete } from '../parseJsonField';
import parseJsonField, {
parseJsonFieldOccurrences,
areToolCallArgsComplete,
} from '../parseJsonField';
describe('parseJsonField', () => {
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', () => {

View file

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