mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-03 22:32:42 +00:00
🖥️ 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:
parent
c3ec23f9b8
commit
1e9d0cbd0d
11 changed files with 250 additions and 24 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 } =
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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_)) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
23
client/src/components/Chat/Messages/Content/routing.ts
Normal file
23
client/src/components/Chat/Messages/Content/routing.ts
Normal 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());
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue