diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx
index 024a0b8e70..888298cdb8 100644
--- a/client/src/components/Chat/Messages/Content/Part.tsx
+++ b/client/src/components/Chat/Messages/Content/Part.tsx
@@ -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 (
+
+ );
+ } 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 (
;
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 } =
diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/BashCall.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/BashCall.test.tsx
index 81c47dbd92..2e6059a309 100644
--- a/client/src/components/Chat/Messages/Content/Parts/__tests__/BashCall.test.tsx
+++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/BashCall.test.tsx
@@ -65,10 +65,16 @@ jest.mock('~/utils', () => ({
cn: (...classes: Array) => classes.filter(Boolean).join(' '),
}));
-const renderBashCall = (args?: string | Record) =>
+const renderBashCall = (args?: string | Record, commandField?: string) =>
render(
-
+
,
);
@@ -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();
+ },
+ );
});
diff --git a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx
index a756c8ac15..e7ef24151b 100644
--- a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx
+++ b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx
@@ -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({
) : (
({
+ ImageGen: () => ,
+ ExecuteCode: () => ,
+ AgentUpdate: () => ,
+ EmptyText: () => ,
+ Reasoning: () => ,
+ Summary: () => ,
+ Text: ({ text }: { text?: string }) => {text}
,
+ SkillCall: () => ,
+ ReadFileCall: () => ,
+ BashCall: ({ commandField }: { commandField?: string }) => (
+
+ ),
+ SubagentCall: () => ,
+}));
+
+jest.mock('../MessageContent', () => ({
+ ErrorMessage: () => ,
+}));
+
+jest.mock('../RetrievalCall', () => ({
+ __esModule: true,
+ default: () => ,
+}));
+
+jest.mock('../AgentHandoff', () => ({
+ __esModule: true,
+ default: () => ,
+}));
+
+jest.mock('../CodeAnalyze', () => ({
+ __esModule: true,
+ default: () => ,
+}));
+
+jest.mock('../Container', () => ({
+ __esModule: true,
+ default: ({ children }: { children?: React.ReactNode }) => {children}
,
+}));
+
+jest.mock('../WebSearch', () => ({
+ __esModule: true,
+ default: () => ,
+}));
+
+jest.mock('../ToolCall', () => ({
+ __esModule: true,
+ default: () => ,
+}));
+
+jest.mock('../Image', () => ({
+ __esModule: true,
+ default: () => ,
+}));
+
+jest.mock('~/utils', () => ({
+ getCachedPreview: jest.fn(),
+}));
+
+const renderPart = (part: TMessageContentParts) =>
+ render();
+
+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();
+ });
+});
diff --git a/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx
index 622ede76b7..706eaf3924 100644
--- a/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx
+++ b/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx
@@ -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: () => ,
+ StackedToolIcons: ({ toolNames }: { toolNames: string[] }) => (
+
+ ),
getMCPServerName: () => '',
}));
@@ -40,7 +42,10 @@ jest.mock('lucide-react', () => ({
jest.mock('~/utils', () => ({
cn: (...classes: Array) => 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 = '{}',
+): 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',
+ );
+ });
});
diff --git a/client/src/components/Chat/Messages/Content/__tests__/ToolIcon.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ToolIcon.test.tsx
index 5d8b3066ee..4befd9df69 100644
--- a/client/src/components/Chat/Messages/Content/__tests__/ToolIcon.test.tsx
+++ b/client/src/components/Chat/Messages/Content/__tests__/ToolIcon.test.tsx
@@ -1,6 +1,10 @@
import { Constants, actionDelimiter } from 'librechat-data-provider';
import { getToolIconType } from '../ToolOutput/ToolIcon';
+jest.mock('~/utils', () => ({
+ cn: (...classes: Array) => 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');
});
});
diff --git a/client/src/components/Chat/Messages/Content/routing.ts b/client/src/components/Chat/Messages/Content/routing.ts
new file mode 100644
index 0000000000..1b07261ae5
--- /dev/null
+++ b/client/src/components/Chat/Messages/Content/routing.ts
@@ -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,
+): 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());
+}
diff --git a/client/src/utils/__tests__/toolLabels.test.ts b/client/src/utils/__tests__/toolLabels.test.ts
index 12fb0dcefa..b22413395e 100644
--- a/client/src/utils/__tests__/toolLabels.test.ts
+++ b/client/src/utils/__tests__/toolLabels.test.ts
@@ -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');
});
diff --git a/client/src/utils/toolLabels.ts b/client/src/utils/toolLabels.ts
index 76607f443b..a519e65382 100644
--- a/client/src/utils/toolLabels.ts
+++ b/client/src/utils/toolLabels.ts
@@ -13,6 +13,7 @@ export const TOOL_FRIENDLY_NAME_KEYS: Record = {
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',