🖥️ style: Render Bash PTC Calls With Bash UI (#13046)

* fix: Render bash PTC calls with bash UI

* fix: Group bash execution tools consistently
This commit is contained in:
Danny Avila 2026-05-10 14:09:04 -04:00 committed by GitHub
parent c3ec23f9b8
commit 1e9d0cbd0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 250 additions and 24 deletions

View file

@ -30,6 +30,7 @@ import Container from './Container';
import WebSearch from './WebSearch';
import ToolCall from './ToolCall';
import Image from './Image';
import { isBashProgrammaticToolCall } from './routing';
type PartProps = {
part?: TMessageContentParts;
@ -132,7 +133,19 @@ const Part = memo(function Part({
const isToolCall =
'args' in toolCall && (!toolCall.type || toolCall.type === ToolCallTypes.TOOL_CALL);
if (
if (isToolCall && isBashProgrammaticToolCall(toolCall.name, toolCall.args)) {
return (
<BashCall
args={toolCall.args}
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
attachments={attachments}
commandField="code"
hideAttachments={hideAttachments}
/>
);
} else if (
isToolCall &&
(toolCall.name === Tools.execute_code ||
toolCall.name === Constants.PROGRAMMATIC_TOOL_CALLING ||
@ -211,7 +224,7 @@ const Part = memo(function Part({
hideAttachments={hideAttachments}
/>
);
} else if (isToolCall && toolCall.name === 'bash_tool') {
} else if (isToolCall && toolCall.name === Tools.bash_tool) {
return (
<BashCall
args={toolCall.args}

View file

@ -18,6 +18,7 @@ export default function BashCall({
args,
output = '',
attachments,
commandField = 'command',
hideAttachments = false,
}: {
initialProgress: number;
@ -25,10 +26,11 @@ export default function BashCall({
args?: string | Record<string, unknown>;
output?: string;
attachments?: TAttachment[];
commandField?: string;
hideAttachments?: boolean;
}) {
const localize = useLocalize();
const command = useMemo(() => parseJsonField(args, 'command'), [args]);
const command = useMemo(() => parseJsonField(args, commandField), [args, commandField]);
const isWritingCommand = !command || !areToolCallArgsComplete(args);
const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } =

View file

@ -65,10 +65,16 @@ jest.mock('~/utils', () => ({
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
}));
const renderBashCall = (args?: string | Record<string, unknown>) =>
const renderBashCall = (args?: string | Record<string, unknown>, commandField?: string) =>
render(
<RecoilRoot>
<BashCall initialProgress={0.1} isSubmitting={true} args={args} output="" />
<BashCall
initialProgress={0.1}
isSubmitting={true}
args={args}
output=""
commandField={commandField}
/>
</RecoilRoot>,
);
@ -95,4 +101,13 @@ describe('BashCall status text', () => {
expect(screen.getByTestId('progress-text')).toHaveTextContent('Running command');
},
);
it.each(['{"code":"echo hi"}', { code: 'echo hi' }])(
'can read bash PTC code args as the command: %s',
(args) => {
renderBashCall(args, 'code');
expect(screen.getByTestId('progress-text')).toHaveTextContent('Running command');
expect(screen.getByText(/echo hi/)).toBeInTheDocument();
},
);
});

View file

@ -1,7 +1,7 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { ChevronDown, Users } from 'lucide-react';
import { Constants, ContentTypes, ToolCallTypes } from 'librechat-data-provider';
import { Tools, Constants, ContentTypes, ToolCallTypes } from 'librechat-data-provider';
import type {
TAttachment,
TMessageContentParts,
@ -15,9 +15,11 @@ import { StackedToolIcons } from './ToolOutput';
import { useMCPIconMap } from '~/hooks/MCP';
import { AttachmentGroup } from './Parts';
import store from '~/store';
import { isBashProgrammaticToolCall } from './routing';
interface ToolMeta {
name: string;
iconName: string;
hasOutput: boolean;
}
@ -39,21 +41,31 @@ function getToolMeta(part: TMessageContentParts): ToolMeta | null {
* so the group header flips from "Running N agents" to "Ran N
* agents" on completion even when the child returned no text. */
const completed = !!tc.output || tc.progress === 1;
return { name: tc.name ?? '', hasOutput: completed };
const name = tc.name ?? '';
const iconName = isBashProgrammaticToolCall(name, tc.args) ? Tools.bash_tool : name;
return { name, iconName, hasOutput: completed };
}
if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) {
const ci = (toolCall as { code_interpreter?: { outputs?: unknown[] } }).code_interpreter;
return { name: 'code_interpreter', hasOutput: (ci?.outputs?.length ?? 0) > 0 };
return {
name: 'code_interpreter',
iconName: 'code_interpreter',
hasOutput: (ci?.outputs?.length ?? 0) > 0,
};
}
if (toolCall.type === ToolCallTypes.RETRIEVAL || toolCall.type === ToolCallTypes.FILE_SEARCH) {
return { name: 'file_search', hasOutput: !!(toolCall as { output?: string }).output };
return {
name: 'file_search',
iconName: 'file_search',
hasOutput: !!(toolCall as { output?: string }).output,
};
}
if (toolCall.type === ToolCallTypes.FUNCTION && ToolCallTypes.FUNCTION in toolCall) {
const fn = (toolCall as FunctionToolCall).function;
return { name: fn.name, hasOutput: !!fn.output };
return { name: fn.name, iconName: fn.name, hasOutput: !!fn.output };
}
return null;
@ -86,6 +98,7 @@ export default function ToolCallGroup({
[toolMetadata],
);
const toolNames = useMemo(() => toolMetadata.map((m) => m?.name ?? ''), [toolMetadata]);
const iconToolNames = useMemo(() => toolMetadata.map((m) => m?.iconName ?? ''), [toolMetadata]);
/** Subagent tool calls get their own label verb ("Running/Ran N agents")
* since "Used N tools" reads oddly when the "tools" are actually child
@ -182,7 +195,7 @@ export default function ToolCallGroup({
</div>
) : (
<StackedToolIcons
toolNames={toolNames}
toolNames={iconToolNames}
mcpIconMap={mcpIconMap}
maxIcons={4}
isAnimating={!allCompleted && isSubmitting}

View file

@ -51,11 +51,7 @@ export function getToolIconType(name: string): ToolIconType {
if (name.includes(Constants.mcp_delimiter)) {
return 'mcp';
}
if (
name === 'execute_code' ||
name === Constants.PROGRAMMATIC_TOOL_CALLING ||
name === Constants.BASH_PROGRAMMATIC_TOOL_CALLING
) {
if (name === 'execute_code' || name === Constants.PROGRAMMATIC_TOOL_CALLING) {
return 'execute_code';
}
if (name === 'web_search') {
@ -76,7 +72,7 @@ export function getToolIconType(name: string): ToolIconType {
if (name === 'read_file') {
return 'read_file';
}
if (name === 'bash_tool') {
if (name === 'bash_tool' || name === Constants.BASH_PROGRAMMATIC_TOOL_CALLING) {
return 'bash_tool';
}
if (name.startsWith(Constants.LC_TRANSFER_TO_)) {

View file

@ -0,0 +1,104 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { Constants, ContentTypes } from 'librechat-data-provider';
import type { TMessageContentParts } from 'librechat-data-provider';
import Part from '../Part';
jest.mock('../Parts', () => ({
ImageGen: () => <div data-testid="image-gen" />,
ExecuteCode: () => <div data-testid="execute-code" />,
AgentUpdate: () => <div data-testid="agent-update" />,
EmptyText: () => <div data-testid="empty-text" />,
Reasoning: () => <div data-testid="reasoning" />,
Summary: () => <div data-testid="summary" />,
Text: ({ text }: { text?: string }) => <div data-testid="text">{text}</div>,
SkillCall: () => <div data-testid="skill-call" />,
ReadFileCall: () => <div data-testid="read-file-call" />,
BashCall: ({ commandField }: { commandField?: string }) => (
<div data-testid="bash-call" data-command-field={commandField ?? 'command'} />
),
SubagentCall: () => <div data-testid="subagent-call" />,
}));
jest.mock('../MessageContent', () => ({
ErrorMessage: () => <div data-testid="error-message" />,
}));
jest.mock('../RetrievalCall', () => ({
__esModule: true,
default: () => <div data-testid="retrieval-call" />,
}));
jest.mock('../AgentHandoff', () => ({
__esModule: true,
default: () => <div data-testid="agent-handoff" />,
}));
jest.mock('../CodeAnalyze', () => ({
__esModule: true,
default: () => <div data-testid="code-analyze" />,
}));
jest.mock('../Container', () => ({
__esModule: true,
default: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
}));
jest.mock('../WebSearch', () => ({
__esModule: true,
default: () => <div data-testid="web-search" />,
}));
jest.mock('../ToolCall', () => ({
__esModule: true,
default: () => <div data-testid="tool-call" />,
}));
jest.mock('../Image', () => ({
__esModule: true,
default: () => <div data-testid="image" />,
}));
jest.mock('~/utils', () => ({
getCachedPreview: jest.fn(),
}));
const renderPart = (part: TMessageContentParts) =>
render(<Part part={part} isSubmitting={false} showCursor={false} isCreatedByUser={false} />);
const toolCallPart = (name: string, args = '{"code":"echo hi"}'): TMessageContentParts =>
({
type: ContentTypes.TOOL_CALL,
[ContentTypes.TOOL_CALL]: {
id: 'call_1',
name,
args,
output: 'hi',
progress: 1,
},
}) as unknown as TMessageContentParts;
describe('Part tool renderer selection', () => {
it('routes bash PTC tool calls through the BashCall renderer', () => {
renderPart(toolCallPart(Constants.BASH_PROGRAMMATIC_TOOL_CALLING));
expect(screen.getByTestId('bash-call')).toHaveAttribute('data-command-field', 'code');
expect(screen.queryByTestId('execute-code')).not.toBeInTheDocument();
});
it('routes default run_tools_with_code PTC calls through the BashCall renderer', () => {
renderPart(toolCallPart(Constants.PROGRAMMATIC_TOOL_CALLING));
expect(screen.getByTestId('bash-call')).toHaveAttribute('data-command-field', 'code');
expect(screen.queryByTestId('execute-code')).not.toBeInTheDocument();
});
it('keeps Python PTC calls on the ExecuteCode renderer', () => {
renderPart(
toolCallPart(Constants.PROGRAMMATIC_TOOL_CALLING, '{"lang":"py","code":"print(1)"}'),
);
expect(screen.getByTestId('execute-code')).toBeInTheDocument();
expect(screen.queryByTestId('bash-call')).not.toBeInTheDocument();
});
});

View file

@ -1,6 +1,6 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { ContentTypes } from 'librechat-data-provider';
import { Tools, Constants, ContentTypes } from 'librechat-data-provider';
import type { TAttachment, TMessageContentParts } from 'librechat-data-provider';
import { render, screen } from '@testing-library/react';
import ToolCallGroup from '../ToolCallGroup';
@ -29,7 +29,9 @@ jest.mock('~/hooks/MCP', () => ({
}));
jest.mock('../ToolOutput', () => ({
StackedToolIcons: () => <span data-testid="stacked-icons" />,
StackedToolIcons: ({ toolNames }: { toolNames: string[] }) => (
<span data-testid="stacked-icons" data-tool-names={toolNames.join(',')} />
),
getMCPServerName: () => '',
}));
@ -40,7 +42,10 @@ jest.mock('lucide-react', () => ({
jest.mock('~/utils', () => ({
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
getToolDisplayLabel: (name: string) => name,
getToolDisplayLabel: (name: string) =>
['execute_code', 'bash_tool', 'run_tools_with_code', 'run_tools_with_bash'].includes(name)
? 'Code'
: name,
}));
jest.mock('../Parts', () => ({
@ -49,13 +54,18 @@ jest.mock('../Parts', () => ({
),
}));
const makePart = (id: string, output = 'done'): TMessageContentParts =>
const makePart = (
id: string,
output = 'done',
name = 'fetch_image',
args: string | Record<string, unknown> = '{}',
): TMessageContentParts =>
({
type: ContentTypes.TOOL_CALL,
[ContentTypes.TOOL_CALL]: {
id,
name: 'fetch_image',
args: '{}',
name,
args,
output,
},
}) as unknown as TMessageContentParts;
@ -143,4 +153,31 @@ describe('ToolCallGroup image hoisting', () => {
const collapsible = outer.querySelector('[style]');
expect(collapsible?.contains(attachmentGroup)).toBe(false);
});
it('summarizes mixed bash PTC and bash_tool calls as one Code tool family', () => {
renderGroup({
...baseProps,
parts: [
{
part: makePart('t1', 'ptc done', Constants.PROGRAMMATIC_TOOL_CALLING, {
code: 'echo via ptc',
}),
idx: 0,
},
{
part: makePart('t2', 'bash done', Tools.bash_tool, {
command: 'echo via bash',
}),
idx: 1,
},
],
});
expect(screen.getByText('— Code')).toBeInTheDocument();
expect(screen.queryByText(/Code, bash_tool/)).not.toBeInTheDocument();
expect(screen.getByTestId('stacked-icons')).toHaveAttribute(
'data-tool-names',
'bash_tool,bash_tool',
);
});
});

View file

@ -1,6 +1,10 @@
import { Constants, actionDelimiter } from 'librechat-data-provider';
import { getToolIconType } from '../ToolOutput/ToolIcon';
jest.mock('~/utils', () => ({
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
}));
describe('getToolIconType - ACTN-01: Action delimiter detection', () => {
it('returns "action" for tool name containing actionDelimiter', () => {
const toolName = `get_weather${actionDelimiter}weather---api---com`;
@ -25,7 +29,6 @@ describe('getToolIconType - ACTN-01: Action delimiter detection', () => {
it('returns correct types for existing tool names', () => {
expect(getToolIconType('execute_code')).toBe('execute_code');
expect(getToolIconType(Constants.PROGRAMMATIC_TOOL_CALLING)).toBe('execute_code');
expect(getToolIconType(Constants.BASH_PROGRAMMATIC_TOOL_CALLING)).toBe('execute_code');
expect(getToolIconType('web_search')).toBe('web_search');
expect(getToolIconType('image_gen_oai')).toBe('image_gen');
expect(getToolIconType('image_edit_oai')).toBe('image_gen');
@ -47,15 +50,21 @@ describe('getToolIconType - SKILL-01: Skill tool icon types', () => {
expect(getToolIconType('bash_tool')).toBe('bash_tool');
});
it('returns "bash_tool" for bash PTC tool calls', () => {
expect(getToolIconType(Constants.BASH_PROGRAMMATIC_TOOL_CALLING)).toBe('bash_tool');
});
it('skill types take priority over the "generic" fallback', () => {
expect(getToolIconType('skill')).not.toBe('generic');
expect(getToolIconType('read_file')).not.toBe('generic');
expect(getToolIconType('bash_tool')).not.toBe('generic');
expect(getToolIconType(Constants.BASH_PROGRAMMATIC_TOOL_CALLING)).not.toBe('generic');
});
it('skill types take priority over the "action" fallback', () => {
expect(getToolIconType('skill')).not.toBe('action');
expect(getToolIconType('read_file')).not.toBe('action');
expect(getToolIconType('bash_tool')).not.toBe('action');
expect(getToolIconType(Constants.BASH_PROGRAMMATIC_TOOL_CALLING)).not.toBe('action');
});
});

View file

@ -0,0 +1,23 @@
import { Constants } from 'librechat-data-provider';
import parseJsonField from './Parts/parseJsonField';
const PYTHON_PROGRAMMATIC_LANGS = new Set(['py', 'python']);
export function isBashProgrammaticToolCall(
name: string | undefined,
args?: string | Record<string, unknown>,
): boolean {
if (name === Constants.BASH_PROGRAMMATIC_TOOL_CALLING) {
return true;
}
if (name !== Constants.PROGRAMMATIC_TOOL_CALLING) {
return false;
}
const lang =
parseJsonField(args, 'lang') ||
parseJsonField(args, 'runtime') ||
parseJsonField(args, 'language');
return !PYTHON_PROGRAMMATIC_LANGS.has(lang.toLowerCase());
}

View file

@ -1,3 +1,4 @@
import { Constants } from 'librechat-data-provider';
import { parseToolName, getToolDisplayLabel, TOOL_FRIENDLY_NAME_KEYS } from '../toolLabels';
describe('parseToolName', () => {
@ -61,6 +62,18 @@ describe('getToolDisplayLabel', () => {
);
});
it('returns the code translation key for bash PTC tool calls', () => {
expect(getToolDisplayLabel(Constants.BASH_PROGRAMMATIC_TOOL_CALLING, identityLocalize)).toBe(
TOOL_FRIENDLY_NAME_KEYS[Constants.BASH_PROGRAMMATIC_TOOL_CALLING],
);
});
it('returns the code translation key for bash_tool calls', () => {
expect(getToolDisplayLabel('bash_tool', identityLocalize)).toBe(
TOOL_FRIENDLY_NAME_KEYS.bash_tool,
);
});
it('returns the raw name for an unknown native tool', () => {
expect(getToolDisplayLabel('custom_tool', identityLocalize)).toBe('custom_tool');
});

View file

@ -13,6 +13,7 @@ export const TOOL_FRIENDLY_NAME_KEYS: Record<string, TranslationKeys> = {
execute_code: 'com_ui_tool_name_code',
run_tools_with_code: 'com_ui_tool_name_code',
run_tools_with_bash: 'com_ui_tool_name_code',
bash_tool: 'com_ui_tool_name_code',
web_search: 'com_ui_tool_name_web_search',
image_gen_oai: 'com_ui_tool_name_image_gen',
image_edit_oai: 'com_ui_tool_name_image_edit',