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',