diff --git a/client/src/components/Chat/Messages/Content/AgentHandoff.tsx b/client/src/components/Chat/Messages/Content/AgentHandoff.tsx index 6a7d0bf6ba..7afb2bd1d5 100644 --- a/client/src/components/Chat/Messages/Content/AgentHandoff.tsx +++ b/client/src/components/Chat/Messages/Content/AgentHandoff.tsx @@ -1,9 +1,10 @@ -import React, { useMemo, useState, useCallback } from 'react'; +import React, { useEffect, useId, useMemo, useRef, useState, useCallback } from 'react'; import { ChevronDown } from 'lucide-react'; import { EModelEndpoint, Constants } from 'librechat-data-provider'; import { Clipboard, CheckMark, TooltipAnchor } from '@librechat/client'; import type { TMessage } from 'librechat-data-provider'; import type { MouseEvent } from 'react'; +import { unescapeJsonString } from './Parts/parseJsonField'; import MessageIcon from '~/components/Share/MessageIcon'; import { toolPanelSpacingClassName } from './disclosure'; import { useLocalize, useExpandCollapse } from '~/hooks'; @@ -15,13 +16,87 @@ interface AgentHandoffProps { args: string | Record; } +interface HandoffField { + key?: string; + value: string; +} + +const PARTIAL_STRING_FIELD = /^\s*\{\s*"([^"\\]+)"\s*:\s*"((?:[^"\\]|\\.)*)/s; + +function formatValue(value: unknown): string { + if (typeof value === 'string') { + return value.trim(); + } + if (value == null) { + return ''; + } + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return String(value); + } + try { + return JSON.stringify(value, null, 2); + } catch { + return ''; + } +} + +function fieldsFromRecord(record: Record): HandoffField[] { + return Object.entries(record).reduce((fields, [key, rawValue]) => { + const value = formatValue(rawValue); + if (value) { + fields.push({ key, value }); + } + return fields; + }, []); +} + +function parseHandoffFields(args: string | Record): HandoffField[] { + if (typeof args !== 'string') { + return fieldsFromRecord(args); + } + + const trimmed = args.trim(); + if (!trimmed) { + return []; + } + + try { + const parsed: unknown = JSON.parse(trimmed); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return fieldsFromRecord(parsed as Record); + } + const value = formatValue(parsed); + return value ? [{ value }] : []; + } catch { + const partialField = trimmed.match(PARTIAL_STRING_FIELD); + if (partialField) { + const value = unescapeJsonString(partialField[2]); + return value ? [{ key: partialField[1], value }] : []; + } + return trimmed.startsWith('{') ? [] : [{ value: trimmed }]; + } +} + +function fieldLabel(key: string): string { + const words = key + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .trim(); + return words ? words.charAt(0).toUpperCase() + words.slice(1) : key; +} + const AgentHandoff: React.FC = ({ name, args: _args = '' }) => { const localize = useLocalize(); const agentsMap = useAgentsMapContext(); const [showInfo, setShowInfo] = useState(false); const [isCopied, setIsCopied] = useState(false); + const copiedTimerRef = useRef>(); + const contentId = useId(); + const headingId = `${contentId}-heading`; const { style: expandStyle, ref: expandRef } = useExpandCollapse(showInfo); + useEffect(() => () => clearTimeout(copiedTimerRef.current), []); + const targetAgentId = useMemo(() => { if (typeof name !== 'string' || !name.startsWith(Constants.LC_TRANSFER_TO_)) { return null; @@ -36,28 +111,34 @@ const AgentHandoff: React.FC = ({ name, args: _args = '' }) = return agentsMap[targetAgentId]; }, [agentsMap, targetAgentId]); - const args = useMemo(() => { - if (typeof _args === 'string') { - return _args; - } - try { - return JSON.stringify(_args, null, 2); - } catch { - return ''; - } - }, [_args]) as string; - - const hasInfo = useMemo(() => (args?.trim()?.length ?? 0) > 2, [args]); + const fields = useMemo(() => parseHandoffFields(_args), [_args]); + const copyText = useMemo( + () => + fields.length === 1 + ? fields[0].value + : fields + .map(({ key, value }) => `${key ? `${fieldLabel(key)}\n` : ''}${value}`) + .join('\n\n'), + [fields], + ); + const hasInfo = fields.length > 0; const agentName = targetAgent?.name || localize('com_ui_agent'); const handleCopy = useCallback( (e: MouseEvent) => { e.stopPropagation(); - navigator.clipboard.writeText(args); - setIsCopied(true); - setTimeout(() => setIsCopied(false), 2000); + navigator.clipboard.writeText(copyText).then( + () => { + clearTimeout(copiedTimerRef.current); + setIsCopied(true); + copiedTimerRef.current = setTimeout(() => setIsCopied(false), 2000); + }, + () => { + setIsCopied(false); + }, + ); }, - [args], + [copyText], ); const copyLabel = isCopied @@ -77,6 +158,7 @@ const AgentHandoff: React.FC = ({ name, args: _args = '' }) = disabled={!hasInfo} onClick={hasInfo ? () => setShowInfo(!showInfo) : undefined} aria-expanded={hasInfo ? showInfo : undefined} + aria-controls={hasInfo ? contentId : undefined} aria-label={`${localize('com_ui_transferred_to')} ${agentName}`} >
@@ -102,17 +184,21 @@ const AgentHandoff: React.FC = ({ name, args: _args = '' }) = /> )} -
+
{hasInfo && ( -
-
- +
+ {localize('com_ui_handoff_instructions')} = ({ name, args: _args = '' }) = aria-label={copyLabel} className={cn( 'flex shrink-0 items-center justify-center rounded-lg p-1 text-text-tertiary transition-opacity duration-150', - 'opacity-0 group-focus-within/handoff:opacity-100 group-hover/handoff:opacity-100', + 'opacity-60 group-focus-within/handoff:opacity-100 group-hover/handoff:opacity-100', 'hover:bg-surface-hover hover:text-text-primary', 'focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy', )} @@ -138,10 +224,27 @@ const AgentHandoff: React.FC = ({ name, args: _args = '' }) = } />
-
-                {args}
-              
-
+ {fields.length === 1 ? ( +

+ {fields[0].value} +

+ ) : ( +
+ {fields.map(({ key, value }, index) => ( +
+ {key && ( +
+ {fieldLabel(key)} +
+ )} +
+ {value} +
+
+ ))} +
+ )} + )}
diff --git a/client/src/components/Chat/Messages/Content/__tests__/AgentHandoff.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/AgentHandoff.test.tsx index 3b65e2d7bd..22c8fde76d 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/AgentHandoff.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/AgentHandoff.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { RecoilRoot } from 'recoil'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import AgentHandoff from '../AgentHandoff'; jest.mock('~/hooks', () => ({ @@ -9,6 +9,8 @@ jest.mock('~/hooks', () => ({ com_ui_transferred_to: 'Transferred to', com_ui_agent: 'Agent', com_ui_handoff_instructions: 'Handoff instructions', + com_ui_copy_to_clipboard: 'Copy to clipboard', + com_ui_copied_to_clipboard: 'Copied to clipboard', }; return translations[key] || key; }, @@ -52,7 +54,7 @@ const renderAgentHandoff = (props: { , ); -describe('AgentHandoff - A11Y accessibility stubs', () => { +describe('AgentHandoff', () => { it('A11Y-01: renders a semantic button element when hasInfo is true', () => { renderAgentHandoff({ name: 'lc_transfer_to_agent-123', @@ -85,7 +87,7 @@ describe('AgentHandoff - A11Y accessibility stubs', () => { expect(button.className).toContain('focus-visible:ring-2'); }); - it('A11Y-03: disabled button is rendered when hasInfo is false', () => { + it('A11Y-04: disabled button is rendered when hasInfo is false', () => { renderAgentHandoff({ name: 'lc_transfer_to_agent-123', args: '', @@ -94,4 +96,80 @@ describe('AgentHandoff - A11Y accessibility stubs', () => { const button = screen.getByRole('button', { name: /Transferred to/i }); expect(button).toBeDisabled(); }); + + it('renders the handoff instruction as prose instead of JSON', () => { + const { container } = renderAgentHandoff({ + name: 'lc_transfer_to_agent-123', + args: JSON.stringify({ + instructions: 'Review the rollout, validate retention, and report blockers.', + }), + }); + + fireEvent.click(screen.getByRole('button', { name: /Transferred to/i })); + + expect( + screen.getByText('Review the rollout, validate retention, and report blockers.'), + ).toBeInTheDocument(); + expect(container.querySelector('pre')).not.toBeInTheDocument(); + expect(screen.queryByText(/"instructions"/)).not.toBeInTheDocument(); + }); + + it('copies the readable instruction rather than its JSON wrapper', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + renderAgentHandoff({ + name: 'lc_transfer_to_agent-123', + args: JSON.stringify({ instructions: 'Review the rollout.' }), + }); + + fireEvent.click(screen.getByRole('button', { name: /Transferred to/i })); + fireEvent.click(screen.getByRole('button', { name: 'Copy to clipboard' })); + + expect(writeText).toHaveBeenCalledWith('Review the rollout.'); + expect(await screen.findByRole('button', { name: 'Copied to clipboard' })).toBeInTheDocument(); + }); + + it('supports object args and a custom handoff prompt key', () => { + renderAgentHandoff({ + name: 'lc_transfer_to_agent-123', + args: { work_items: 'Verify the migration plan.' }, + }); + + fireEvent.click(screen.getByRole('button', { name: /Transferred to/i })); + + expect(screen.getByText('Verify the migration plan.')).toBeInTheDocument(); + expect(screen.queryByText('Work items')).not.toBeInTheDocument(); + }); + + it('labels each value when a legacy handoff contains multiple fields', () => { + renderAgentHandoff({ + name: 'lc_transfer_to_agent-123', + args: JSON.stringify({ + work_items: 'Verify the migration plan.', + priority: 'High', + }), + }); + + fireEvent.click(screen.getByRole('button', { name: /Transferred to/i })); + + expect(screen.getByText('Work items')).toBeInTheDocument(); + expect(screen.getByText('Verify the migration plan.')).toBeInTheDocument(); + expect(screen.getByText('Priority')).toBeInTheDocument(); + expect(screen.getByText('High')).toBeInTheDocument(); + }); + + it('renders a partially streamed instruction without exposing JSON syntax', () => { + renderAgentHandoff({ + name: 'lc_transfer_to_agent-123', + args: '{"instructions":"Review the rollout and report', + }); + + fireEvent.click(screen.getByRole('button', { name: /Transferred to/i })); + + expect(screen.getByText('Review the rollout and report')).toBeInTheDocument(); + expect(screen.queryByText(/"instructions"/)).not.toBeInTheDocument(); + }); });