mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 10:02:41 +00:00
feat: Group Reasoning with Tool Calls and Polish Activity UI
- Treat reasoning (Thoughts) as transparent to tool grouping so interleaved thoughts fold into the tool group instead of splitting it - Group a lone tool call that has reasoning (e.g. a skill) so it gets the same collapsible chrome as multi-tool groups - Count only real tool calls for the group header; add a reasoning indicator and a singular 'Used 1 tool' label - Rework the in-group Thoughts panel: tool-row sizing, rounded content, header copy button, and a floating collapse/copy bar - Round the floating thinking-bar buttons - Refine the agent handoff row and instructions panel with consistent spacing and a copy affordance
This commit is contained in:
parent
e1f2298cb8
commit
bf5e844a29
9 changed files with 366 additions and 63 deletions
|
|
@ -1,7 +1,9 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useMemo, 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 MessageIcon from '~/components/Share/MessageIcon';
|
||||
import { useLocalize, useExpandCollapse } from '~/hooks';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
|
|
@ -16,6 +18,7 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
|
|||
const localize = useLocalize();
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const { style: expandStyle, ref: expandRef } = useExpandCollapse(showInfo);
|
||||
|
||||
const targetAgentId = useMemo(() => {
|
||||
|
|
@ -44,9 +47,24 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
|
|||
}, [_args]) as string;
|
||||
|
||||
const hasInfo = useMemo(() => (args?.trim()?.length ?? 0) > 2, [args]);
|
||||
const agentName = targetAgent?.name || localize('com_ui_agent');
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(args);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
},
|
||||
[args],
|
||||
);
|
||||
|
||||
const copyLabel = isCopied
|
||||
? localize('com_ui_copied_to_clipboard')
|
||||
: localize('com_ui_copy_to_clipboard');
|
||||
|
||||
return (
|
||||
<div className="my-1">
|
||||
<div className="my-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
|
|
@ -58,9 +76,9 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
|
|||
disabled={!hasInfo}
|
||||
onClick={hasInfo ? () => setShowInfo(!showInfo) : undefined}
|
||||
aria-expanded={hasInfo ? showInfo : undefined}
|
||||
aria-label={`${localize('com_ui_transferred_to')} ${targetAgent?.name || localize('com_ui_agent')}`}
|
||||
aria-label={`${localize('com_ui_transferred_to')} ${agentName}`}
|
||||
>
|
||||
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full ring-1 ring-border-light">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-full ring-1 ring-border-light">
|
||||
<MessageIcon
|
||||
message={
|
||||
{
|
||||
|
|
@ -72,12 +90,13 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
|
|||
/>
|
||||
</div>
|
||||
<span className="select-none">{localize('com_ui_transferred_to')}</span>
|
||||
<span className="select-none font-medium text-text-primary">
|
||||
{targetAgent?.name || localize('com_ui_agent')}
|
||||
</span>
|
||||
<span className="select-none font-medium text-text-primary">{agentName}</span>
|
||||
{hasInfo && (
|
||||
<ChevronDown
|
||||
className={cn('ml-1 h-3 w-3 transition-transform', showInfo && 'rotate-180')}
|
||||
className={cn(
|
||||
'size-4 shrink-0 translate-y-[1px] text-text-secondary transition-transform duration-200 ease-out',
|
||||
showInfo && 'rotate-180',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -85,11 +104,37 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
|
|||
<div style={expandStyle}>
|
||||
<div className="overflow-hidden" ref={expandRef}>
|
||||
{hasInfo && (
|
||||
<div className="ml-8 mt-2 rounded-lg border border-border-light bg-surface-secondary p-3 text-xs">
|
||||
<div className="mb-1 font-medium text-text-secondary">
|
||||
{localize('com_ui_handoff_instructions')}:
|
||||
<div className="group/handoff my-2 ml-8 rounded-xl border border-border-light bg-surface-secondary p-4 text-xs">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-text-secondary">
|
||||
{localize('com_ui_handoff_instructions')}
|
||||
</span>
|
||||
<TooltipAnchor
|
||||
description={copyLabel}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
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',
|
||||
'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',
|
||||
)}
|
||||
>
|
||||
{isCopied ? (
|
||||
<CheckMark className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<Clipboard size="14" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap text-text-primary">{args}</pre>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap leading-relaxed text-text-primary">
|
||||
{args}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,23 @@
|
|||
import { memo, useMemo, useState, useCallback, useRef, useId } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Lightbulb, ChevronDown } from 'lucide-react';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import { Clipboard, CheckMark, TooltipAnchor } from '@librechat/client';
|
||||
import type { MouseEvent, FocusEvent } from 'react';
|
||||
import { ThinkingContent, ThinkingButton, ThinkingLabel, FloatingThinkingBar } from './Thinking';
|
||||
import { useLocalize, useExpandCollapse, useLazyCollapseBody } from '~/hooks';
|
||||
import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming';
|
||||
import { showThinkingAtom } from '~/store/showThinking';
|
||||
import { fontSizeAtom } from '~/store/fontSize';
|
||||
import { useMessageContext } from '~/Providers';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const stripThinkTags = (reasoning: string): string =>
|
||||
reasoning
|
||||
.replace(/^<think>\s*/, '')
|
||||
.replace(/\s*<\/think>$/, '')
|
||||
.trim();
|
||||
|
||||
type ReasoningProps = {
|
||||
reasoning: string;
|
||||
isLast: boolean;
|
||||
|
|
@ -62,12 +71,7 @@ const Reasoning = memo((props: ReasoningProps) => {
|
|||
const { isSubmitting, isLatestMessage, nextType } = useMessageContext();
|
||||
|
||||
// Strip <think> tags from the reasoning content (modern format)
|
||||
const reasoningText = useMemo(() => {
|
||||
return reasoning
|
||||
.replace(/^<think>\s*/, '')
|
||||
.replace(/\s*<\/think>$/, '')
|
||||
.trim();
|
||||
}, [reasoning]);
|
||||
const reasoningText = useMemo(() => stripThinkTags(reasoning), [reasoning]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
|
|
@ -170,4 +174,142 @@ const Reasoning = memo((props: ReasoningProps) => {
|
|||
);
|
||||
});
|
||||
|
||||
Reasoning.displayName = 'Reasoning';
|
||||
|
||||
type ReasoningCompactProps = {
|
||||
reasoning: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact reasoning row for use INSIDE a ToolCallGroup. Keeps the tool-row
|
||||
* header rhythm (icon + label + chevron) so an interleaved thought reads as a
|
||||
* sibling of the surrounding tool calls, while retaining the standalone
|
||||
* {@link Reasoning} affordances — a hover-revealed copy button on the header and
|
||||
* a floating collapse + copy bar inside the rounded content panel.
|
||||
*/
|
||||
export const ReasoningCompact = memo(({ reasoning, label }: ReasoningCompactProps) => {
|
||||
const contentId = useId();
|
||||
const localize = useLocalize();
|
||||
const fontSize = useAtomValue(fontSizeAtom);
|
||||
const showThinking = useAtomValue(showThinkingAtom);
|
||||
const [isExpanded, setIsExpanded] = useState(showThinking);
|
||||
const [isBarVisible, setIsBarVisible] = useState(false);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { style: expandStyle, ref: expandRef } = useExpandCollapse(isExpanded);
|
||||
|
||||
const reasoningText = useMemo(() => stripThinkTags(reasoning), [reasoning]);
|
||||
|
||||
const handleToggle = useCallback((e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(reasoningText);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
},
|
||||
[reasoningText],
|
||||
);
|
||||
|
||||
const revealBar = useCallback(() => setIsBarVisible(true), []);
|
||||
const hideBar = useCallback(() => {
|
||||
if (!containerRef.current?.contains(document.activeElement)) {
|
||||
setIsBarVisible(false);
|
||||
}
|
||||
}, []);
|
||||
const handleBlur = useCallback((e: FocusEvent) => {
|
||||
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
|
||||
setIsBarVisible(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const copyLabel = isCopied
|
||||
? localize('com_ui_copied_to_clipboard')
|
||||
: localize('com_ui_copy_thoughts_to_clipboard');
|
||||
|
||||
if (!reasoningText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="group/reasoning-compact"
|
||||
onMouseEnter={revealBar}
|
||||
onMouseLeave={hideBar}
|
||||
onFocus={revealBar}
|
||||
onBlur={handleBlur}
|
||||
>
|
||||
<div className="relative my-1.5 flex h-5 shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={contentId}
|
||||
className="inline-flex min-w-0 flex-1 items-center gap-2 text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
|
||||
>
|
||||
<Lightbulb className="size-4 shrink-0 text-text-secondary" aria-hidden="true" />
|
||||
<span className="tool-status-text font-medium">{label}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-4 shrink-0 translate-y-[1px] text-text-secondary transition-transform duration-200 ease-out',
|
||||
isExpanded && 'rotate-180',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<TooltipAnchor
|
||||
description={copyLabel}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
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/reasoning-compact:opacity-100 group-hover/reasoning-compact: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',
|
||||
)}
|
||||
>
|
||||
{isCopied ? (
|
||||
<CheckMark className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Clipboard size="16" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id={contentId}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
aria-hidden={!isExpanded || undefined}
|
||||
style={expandStyle}
|
||||
>
|
||||
<div className="overflow-hidden" ref={expandRef}>
|
||||
<div className="relative my-2 rounded-2xl border border-border-light bg-surface-secondary p-4 pb-9 text-text-secondary">
|
||||
<p className={cn('whitespace-pre-wrap leading-[26px]', fontSize)}>{reasoningText}</p>
|
||||
<FloatingThinkingBar
|
||||
isVisible={isBarVisible && isExpanded}
|
||||
isExpanded={isExpanded}
|
||||
onClick={handleToggle}
|
||||
content={reasoningText}
|
||||
contentId={contentId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ReasoningCompact.displayName = 'ReasoningCompact';
|
||||
|
||||
export default Reasoning;
|
||||
|
|
|
|||
|
|
@ -250,7 +250,7 @@ export const FloatingThinkingBar = memo(
|
|||
aria-expanded={isExpanded}
|
||||
aria-controls={contentId}
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded p-1.5 text-text-tertiary',
|
||||
'flex items-center justify-center rounded-lg p-1.5 text-text-tertiary',
|
||||
'hover:bg-surface-hover hover:text-text-primary',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
|
||||
)}
|
||||
|
|
@ -272,7 +272,7 @@ export const FloatingThinkingBar = memo(
|
|||
onClick={handleCopy}
|
||||
aria-label={copyTooltip}
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded p-1.5 text-text-tertiary',
|
||||
'flex items-center justify-center rounded-lg p-1.5 text-text-tertiary',
|
||||
'hover:bg-surface-hover hover:text-text-primary',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ export * from './OpenAIImageGen';
|
|||
|
||||
export { default as Text } from './Text';
|
||||
export { default as CollapsibleText } from './CollapsibleText';
|
||||
export { default as Reasoning, ReasoningMarker } from './Reasoning';
|
||||
export { default as Reasoning, ReasoningMarker, ReasoningCompact } from './Reasoning';
|
||||
export { default as EmptyText } from './EmptyText';
|
||||
export { default as LogContent } from './LogContent';
|
||||
export { default as ExecuteCode } from './ExecuteCode';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useRef, useMemo, useEffect, useCallback } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Button } from '@librechat/client';
|
||||
import { ChevronDown, MessageCircleQuestion, Users } from 'lucide-react';
|
||||
import { ChevronDown, Lightbulb, MessageCircleQuestion, Users } from 'lucide-react';
|
||||
import { Tools, Constants, ContentTypes, ToolCallTypes } from 'librechat-data-provider';
|
||||
import type {
|
||||
TAttachment,
|
||||
|
|
@ -22,7 +22,7 @@ import { useMCPIconMap, useMCPServerNames } from '~/hooks/MCP';
|
|||
import { isBashProgrammaticToolCall } from './routing';
|
||||
import { ASK_USER_QUESTION } from '~/utils/approval';
|
||||
import { StackedToolIcons } from './ToolOutput';
|
||||
import { AttachmentGroup } from './Parts';
|
||||
import { AttachmentGroup, ReasoningCompact } from './Parts';
|
||||
import { ROW_GLYPH_SLOT } from './rows';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -164,6 +164,14 @@ export default function ToolCallGroup({
|
|||
const toolNames = useMemo(() => toolMetadata.map((m) => m.name), [toolMetadata]);
|
||||
const iconToolNames = useMemo(() => toolMetadata.map((m) => m.iconName), [toolMetadata]);
|
||||
|
||||
/** Reasoning interleaved with the tool calls renders inside the body but is
|
||||
* hidden while collapsed — surface a lightbulb in the header so the summary
|
||||
* hints that the group also contains thoughts. */
|
||||
const hasReasoning = useMemo(
|
||||
() => parts.some((p) => p.part.type === ContentTypes.THINK),
|
||||
[parts],
|
||||
);
|
||||
|
||||
/** Subagent tool calls get their own label verb ("Running/Ran N agents")
|
||||
* since "Used N tools" reads oddly when the "tools" are actually child
|
||||
* agents. `subagentCount === count` ⇒ the group is 100% subagents. */
|
||||
|
|
@ -216,7 +224,10 @@ export default function ToolCallGroup({
|
|||
/** A labeled activity block is summarized by its header, so it collapses
|
||||
* even at a single tool call — agent runs are full of one-call batches,
|
||||
* and leaving those expanded defeats the grouping. */
|
||||
const autoCollapse = !autoExpand && allCompleted && (count >= 2 || activityLabelText.length > 0);
|
||||
/** Every group has >= 1 tool; collapse a completed one by default just like
|
||||
* a multi-tool group, so a lone tool-with-thinking group (a skill, say)
|
||||
* stays visually consistent with the larger groups around it. */
|
||||
const autoCollapse = !autoExpand && allCompleted && (count >= 1 || activityLabelText.length > 0);
|
||||
const suppressAutoExpand = withinActivityPhase && !hasPendingApproval;
|
||||
const initialState = initialExpansionState?.userOverride === true ? initialExpansionState : null;
|
||||
const [isExpanded, setIsExpanded] = useState(
|
||||
|
|
@ -318,7 +329,9 @@ export default function ToolCallGroup({
|
|||
? localize('com_ui_asked_n_questions', { 0: String(count) })
|
||||
: localize('com_ui_asking_n_questions', { 0: String(count) });
|
||||
}
|
||||
return localize('com_ui_used_n_tools', { 0: String(count) });
|
||||
return count === 1
|
||||
? localize('com_ui_used_one_tool')
|
||||
: localize('com_ui_used_n_tools', { 0: String(count) });
|
||||
};
|
||||
/** The generated line wins over the generic category verb — but only once
|
||||
* it exists. An unfilled label part leaves the block rendering exactly as
|
||||
|
|
@ -347,7 +360,7 @@ export default function ToolCallGroup({
|
|||
className="inline-flex h-auto w-full items-center justify-start gap-2 rounded-none bg-transparent p-0 py-1 text-text-secondary hover:bg-transparent hover:text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy focus-visible:ring-offset-0"
|
||||
onClick={handleToggle}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={groupLabel}
|
||||
aria-label={hasReasoning ? `${groupLabel}, ${localize('com_ui_thoughts')}` : groupLabel}
|
||||
>
|
||||
{allSubagents || allAskQuestions ? (
|
||||
/** Homogeneous category groups get a single category glyph instead
|
||||
|
|
@ -393,6 +406,9 @@ export default function ToolCallGroup({
|
|||
· {toolNameSummary}
|
||||
</span>
|
||||
)}
|
||||
{hasReasoning && (
|
||||
<Lightbulb className="size-3.5 shrink-0 text-text-secondary" aria-hidden="true" />
|
||||
)}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-4 shrink-0 text-text-secondary transition-transform duration-200 ease-out',
|
||||
|
|
@ -410,9 +426,20 @@ export default function ToolCallGroup({
|
|||
{shouldRenderBody && (
|
||||
<div className="overflow-hidden" ref={expandRef}>
|
||||
<div className="py-0.5 pl-4">
|
||||
{parts.map(({ part, idx }) =>
|
||||
renderPart(part, idx, isLast && idx === lastContentIdx, handleToolExpand),
|
||||
)}
|
||||
{parts.map(({ part, idx }) => {
|
||||
if (part.type === ContentTypes.THINK) {
|
||||
const think = part.think;
|
||||
const reasoning = typeof think === 'string' ? think : (think?.value ?? '');
|
||||
const label =
|
||||
isSubmitting && idx === lastContentIdx
|
||||
? localize('com_ui_thinking')
|
||||
: localize('com_ui_thoughts');
|
||||
return (
|
||||
<ReasoningCompact key={`reasoning-${idx}`} reasoning={reasoning} label={label} />
|
||||
);
|
||||
}
|
||||
return renderPart(part, idx, isLast && idx === lastContentIdx, handleToolExpand);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ describe('AgentHandoff - A11Y accessibility stubs', () => {
|
|||
args: '{"key":"value"}',
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
const button = screen.getByRole('button', { name: /Transferred to/i });
|
||||
expect(button.tagName).toBe('BUTTON');
|
||||
});
|
||||
|
||||
|
|
@ -69,7 +69,7 @@ describe('AgentHandoff - A11Y accessibility stubs', () => {
|
|||
args: '{"key":"value"}',
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
const button = screen.getByRole('button', { name: /Transferred to/i });
|
||||
expect(button).toHaveAttribute('aria-label');
|
||||
expect(button.getAttribute('aria-label')).toContain('Transferred to');
|
||||
expect(button.getAttribute('aria-label')).toContain('Test Agent');
|
||||
|
|
@ -81,7 +81,7 @@ describe('AgentHandoff - A11Y accessibility stubs', () => {
|
|||
args: '{"key":"value"}',
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
const button = screen.getByRole('button', { name: /Transferred to/i });
|
||||
expect(button.className).toContain('focus-visible:ring-2');
|
||||
});
|
||||
|
||||
|
|
@ -91,7 +91,7 @@ describe('AgentHandoff - A11Y accessibility stubs', () => {
|
|||
args: '',
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
const button = screen.getByRole('button', { name: /Transferred to/i });
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2483,6 +2483,7 @@
|
|||
"com_ui_use_prompt": "Use Prompt",
|
||||
"com_ui_used": "Used",
|
||||
"com_ui_used_n_tools": "Used {{0}} tools",
|
||||
"com_ui_used_one_tool": "Used 1 tool",
|
||||
"com_ui_user": "User",
|
||||
"com_ui_user_group_permissions": "User & Group Permissions",
|
||||
"com_ui_user_provides_key": "Each user provides their own key",
|
||||
|
|
|
|||
|
|
@ -22,26 +22,33 @@ const label = (text: string): TMessageContentParts =>
|
|||
pending: text.length === 0,
|
||||
}) as unknown as TMessageContentParts;
|
||||
|
||||
const textPart = (text = 'answer'): TMessageContentParts =>
|
||||
({ type: ContentTypes.TEXT, text }) as unknown as TMessageContentParts;
|
||||
|
||||
const transferPart = (): TMessageContentParts =>
|
||||
({
|
||||
type: ContentTypes.TOOL_CALL,
|
||||
[ContentTypes.TOOL_CALL]: { id: 'x', name: 'lc_transfer_to_agent', args: '{}' },
|
||||
}) as unknown as TMessageContentParts;
|
||||
|
||||
const withIndex = (parts: TMessageContentParts[]): PartWithIndex[] =>
|
||||
parts.map((part, idx) => ({ part, idx }));
|
||||
|
||||
describe('groupSequentialToolCalls with activity labels', () => {
|
||||
/**
|
||||
* Every batch publishes its reservation the moment the batch ends, so an
|
||||
* empty label is the NORMAL state while generation is in flight. It must
|
||||
* render exactly as the feature-off path does: a lone tool call stays a
|
||||
* single (no group wrapper) and THINK parts stay standalone.
|
||||
* empty label is the NORMAL state while generation is in flight. Reasoning
|
||||
* folds into the run either way, so a lone tool call with its thinking
|
||||
* still groups, just without a labelPart.
|
||||
*/
|
||||
it('leaves a single tool call and its reasoning untouched while the label is empty', () => {
|
||||
it('groups a single tool call with its reasoning while the label is empty', () => {
|
||||
const grouped = groupSequentialToolCalls(
|
||||
withIndex([think('deciding what to search'), toolCall('t1'), label('')]),
|
||||
);
|
||||
|
||||
expect(grouped).toHaveLength(2);
|
||||
expect(grouped[0]).toMatchObject({ type: 'single' });
|
||||
expect(grouped[1]).toMatchObject({ type: 'single' });
|
||||
/** No labelPart anywhere, and crucially no 'tool-group' wrapper. */
|
||||
expect(grouped.some((entry) => entry.type === 'tool-group')).toBe(false);
|
||||
expect(grouped).toHaveLength(1);
|
||||
expect(grouped[0]).toMatchObject({ type: 'tool-group' });
|
||||
expect((grouped[0] as { labelPart?: PartWithIndex }).labelPart).toBeUndefined();
|
||||
});
|
||||
|
||||
/** A blank/failed fill is permanent, and must stay equally invisible. */
|
||||
|
|
@ -198,3 +205,74 @@ describe('groupSequentialToolCalls with activity labels', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('groupSequentialToolCalls reasoning transparency', () => {
|
||||
it('groups two adjacent tool calls', () => {
|
||||
const result = groupSequentialToolCalls(withIndex([toolCall('a'), toolCall('b')]));
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({ type: 'tool-group' });
|
||||
expect(result[0].type === 'tool-group' && result[0].parts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps a single tool call ungrouped', () => {
|
||||
const result = groupSequentialToolCalls(withIndex([toolCall('a')]));
|
||||
expect(result).toEqual([{ type: 'single', part: { part: expect.anything(), idx: 0 } }]);
|
||||
});
|
||||
|
||||
it('absorbs reasoning interleaved between tool calls into one group', () => {
|
||||
const result = groupSequentialToolCalls(
|
||||
withIndex([toolCall('a'), think('mid'), toolCall('b')]),
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('tool-group');
|
||||
expect(result[0].type === 'tool-group' && result[0].parts.map((p) => p.idx)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('absorbs leading and trailing reasoning around a tool run', () => {
|
||||
const result = groupSequentialToolCalls(
|
||||
withIndex([think('lead'), toolCall('a'), toolCall('b'), think('trail')]),
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('tool-group');
|
||||
expect(result[0].type === 'tool-group' && result[0].parts).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('groups a lone tool call wrapped in reasoning', () => {
|
||||
const result = groupSequentialToolCalls(
|
||||
withIndex([think('lead'), toolCall('a'), think('trail')]),
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('tool-group');
|
||||
expect(result[0].type === 'tool-group' && result[0].parts.map((p) => p.idx)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('keeps a lone tool call without reasoning inline', () => {
|
||||
const result = groupSequentialToolCalls(withIndex([toolCall('a'), textPart()]));
|
||||
expect(result.map((g) => g.type)).toEqual(['single', 'single']);
|
||||
});
|
||||
|
||||
it('keeps pure reasoning (no tool call) standalone', () => {
|
||||
const result = groupSequentialToolCalls(withIndex([think('only'), textPart()]));
|
||||
expect(result.map((g) => g.type)).toEqual(['single', 'single']);
|
||||
});
|
||||
|
||||
it('does not pull a thought-then-answer tail into a preceding group', () => {
|
||||
const result = groupSequentialToolCalls(
|
||||
withIndex([toolCall('a'), toolCall('b'), textPart(), think('tail'), textPart('final')]),
|
||||
);
|
||||
expect(result.map((g) => g.type)).toEqual(['tool-group', 'single', 'single', 'single']);
|
||||
});
|
||||
|
||||
it('splits tool runs separated by a non-reasoning part', () => {
|
||||
const result = groupSequentialToolCalls(
|
||||
withIndex([toolCall('a'), toolCall('b'), textPart(), toolCall('c'), toolCall('d')]),
|
||||
);
|
||||
expect(result.map((g) => g.type)).toEqual(['tool-group', 'single', 'tool-group']);
|
||||
});
|
||||
|
||||
it('does not group transfer/handoff tool calls', () => {
|
||||
const result = groupSequentialToolCalls(withIndex([transferPart(), transferPart()]));
|
||||
expect(result.map((g) => g.type)).toEqual(['single', 'single']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -50,6 +50,28 @@ function isGroupableToolCall(part: TMessageContentParts): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
/** Reasoning ("Thoughts") parts are transparent to grouping: a thought
|
||||
* interleaved between tool calls joins the run instead of splitting it, so
|
||||
* reasoning models that think between every call still collapse into a single
|
||||
* tool group. A run becomes a group once it holds >= 2 tool calls, OR a single
|
||||
* tool call accompanied by reasoning, so a lone tool wrapped in thinking
|
||||
* (a skill invocation, say) still gets the grouped chrome with its thoughts
|
||||
* folded in. A run of pure reasoning (no tool call) keeps rendering as its own
|
||||
* standalone card. */
|
||||
function isReasoningPart(part: TMessageContentParts): boolean {
|
||||
return part.type === ContentTypes.THINK;
|
||||
}
|
||||
|
||||
function countToolCalls(parts: PartWithIndex[]): number {
|
||||
let count = 0;
|
||||
for (const { part } of parts) {
|
||||
if (isGroupableToolCall(part)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the label covers ANY `transfer_to_*` handoff call. Transfer
|
||||
* parts are never groupable, so the flush at the handoff card leaves such a
|
||||
|
|
@ -83,10 +105,9 @@ function coversTransferCall(labelPart: TMessageContentParts, allParts: PartWithI
|
|||
* calls) becomes one labeled group — the claude.ai-style hierarchy. Any
|
||||
* other part type breaks the block.
|
||||
*
|
||||
* Legacy behavior is preserved exactly when no ACTIVITY_LABEL part arrives
|
||||
* (feature off, older conversations): the tentative block is re-split so
|
||||
* THINK parts render standalone in their original positions and only runs
|
||||
* of >= 2 tool calls group.
|
||||
* Without an ACTIVITY_LABEL part (feature off, older conversations), the
|
||||
* block still groups: reasoning stays folded in with its tool calls, and a
|
||||
* run groups at >= 2 tool calls or a single call accompanied by reasoning.
|
||||
*/
|
||||
export function groupSequentialToolCalls(parts: PartWithIndex[]): GroupedPart[] {
|
||||
const result: GroupedPart[] = [];
|
||||
|
|
@ -97,26 +118,15 @@ export function groupSequentialToolCalls(parts: PartWithIndex[]): GroupedPart[]
|
|||
let claimStart = 0;
|
||||
|
||||
const flushWithoutLabel = () => {
|
||||
let toolRun: PartWithIndex[] = [];
|
||||
const flushToolRun = () => {
|
||||
if (toolRun.length >= 2) {
|
||||
result.push({ type: 'tool-group', parts: [...toolRun] });
|
||||
} else {
|
||||
for (const p of toolRun) {
|
||||
result.push({ type: 'single', part: p });
|
||||
}
|
||||
}
|
||||
toolRun = [];
|
||||
};
|
||||
for (const p of currentBlock) {
|
||||
if (isGroupableToolCall(p.part)) {
|
||||
toolRun.push(p);
|
||||
} else {
|
||||
flushToolRun();
|
||||
const toolCallCount = countToolCalls(currentBlock);
|
||||
const hasReasoning = currentBlock.some((p) => isReasoningPart(p.part));
|
||||
if (toolCallCount >= 2 || (toolCallCount >= 1 && hasReasoning)) {
|
||||
result.push({ type: 'tool-group', parts: [...currentBlock] });
|
||||
} else {
|
||||
for (const p of currentBlock) {
|
||||
result.push({ type: 'single', part: p });
|
||||
}
|
||||
}
|
||||
flushToolRun();
|
||||
currentBlock = [];
|
||||
claimStart = 0;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue