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:
Marco Beretta 2026-06-14 03:53:10 +02:00
parent 3c3837bb7d
commit 71427f9149
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
9 changed files with 396 additions and 52 deletions

View file

@ -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>

View file

@ -1,13 +1,22 @@
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, FloatingThinkingBar } from './Thinking';
import { useLocalize, useExpandCollapse } from '~/hooks';
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;
@ -46,12 +55,7 @@ const Reasoning = memo(({ reasoning, isLast }: 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>) => {
e.preventDefault();
@ -133,4 +137,142 @@ const Reasoning = memo(({ reasoning, isLast }: 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;

View file

@ -184,7 +184,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',
)}
@ -207,7 +207,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',
)}

View file

@ -2,7 +2,7 @@ export * from './Attachment';
export * from './OpenAIImageGen';
export { default as Text } from './Text';
export { default as Reasoning } from './Reasoning';
export { default as Reasoning, ReasoningCompact } from './Reasoning';
export { default as EmptyText } from './EmptyText';
export { default as LogContent } from './LogContent';
export { default as ExecuteCode } from './ExecuteCode';

View file

@ -1,6 +1,6 @@
import { useState, useRef, useMemo, useEffect, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { ChevronDown, Users } from 'lucide-react';
import { ChevronDown, Users, Lightbulb } from 'lucide-react';
import { Tools, Constants, ContentTypes, ToolCallTypes } from 'librechat-data-provider';
import type {
TAttachment,
@ -10,12 +10,12 @@ import type {
} from 'librechat-data-provider';
import type { PartWithIndex } from './ParallelContent';
import { useLocalize, useExpandCollapse, scheduleMessageContentLayoutReconcile } from '~/hooks';
import { AttachmentGroup, ReasoningCompact } from './Parts';
import { isBashProgrammaticToolCall } from './routing';
import { cn, getToolDisplayLabel } from '~/utils';
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;
@ -106,15 +106,29 @@ export default function ToolCallGroup({
const mcpIconMap = useMCPIconMap();
const rootRef = useRef<HTMLDivElement | null>(null);
const cancelLayoutReconcileRef = useRef<(() => void) | null>(null);
const count = parts.length;
const toolMetadata = useMemo(() => parts.map((p) => getToolMeta(p.part)), [parts]);
/** `parts` may include interleaved reasoning ("Thoughts") parts that render
* inside the body but are not tools count and summarize only the real tool
* calls so the header reads "Used N tools" and the stacked icons stay clean. */
const toolMetadata = useMemo(
() => parts.map((p) => getToolMeta(p.part)).filter((m): m is ToolMeta => m != null),
[parts],
);
const count = toolMetadata.length;
const allCompleted = useMemo(
() => toolMetadata.every((m) => m?.hasOutput === true),
() => toolMetadata.every((m) => m.hasOutput === true),
[toolMetadata],
);
const toolNames = useMemo(() => toolMetadata.map((m) => m?.name ?? ''), [toolMetadata]);
const iconToolNames = useMemo(() => toolMetadata.map((m) => m?.iconName ?? ''), [toolMetadata]);
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
@ -151,7 +165,10 @@ export default function ToolCallGroup({
}, [toolNames, localize]);
const autoExpand = useRecoilValue(store.autoExpandTools);
const autoCollapse = !autoExpand && count >= 2 && allCompleted;
/** Every group has 1 tool; collapse a completed one by default just like a
* multi-tool group, so a lone tool-with-thinking group (e.g. a skill) stays
* visually consistent with the larger groups around it. */
const autoCollapse = !autoExpand && count >= 1 && allCompleted;
const initialState = initialExpansionState?.userOverride === true ? initialExpansionState : null;
const [isExpanded, setIsExpanded] = useState(
initialState?.isExpanded ?? (autoExpand || !autoCollapse),
@ -221,12 +238,14 @@ export default function ToolCallGroup({
subagentsDone
? localize('com_ui_ran_n_agents', { 0: String(count) })
: localize('com_ui_running_n_agents', { 0: String(count) });
const groupLabel = allSubagents
? getSubagentLabel()
: localize('com_ui_used_n_tools', { 0: String(count) });
const getToolsLabel = () =>
count === 1
? localize('com_ui_used_one_tool')
: localize('com_ui_used_n_tools', { 0: String(count) });
const groupLabel = allSubagents ? getSubagentLabel() : getToolsLabel();
const hasActiveToolCall = useMemo(
() => isSubmitting && toolMetadata.some((m) => m && !m.hasOutput),
() => isSubmitting && toolMetadata.some((m) => !m.hasOutput),
[toolMetadata, isSubmitting],
);
@ -244,7 +263,7 @@ export default function ToolCallGroup({
className="inline-flex w-full items-center gap-2 py-1 text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
onClick={handleToggle}
aria-expanded={isExpanded}
aria-label={groupLabel}
aria-label={hasReasoning ? `${groupLabel}, ${localize('com_ui_thoughts')}` : groupLabel}
>
{allSubagents ? (
/** Subagent groups don't have per-tool icons StackedToolIcons
@ -275,6 +294,9 @@ export default function ToolCallGroup({
{toolNameSummary && !allSubagents && (
<span className="text-xs font-normal text-text-secondary"> {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',
@ -287,9 +309,24 @@ 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>
)}

View file

@ -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();
});
});

View file

@ -1733,6 +1733,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",

View file

@ -0,0 +1,95 @@
import { Constants, ContentTypes } from 'librechat-data-provider';
import type { TMessageContentParts } from 'librechat-data-provider';
import type { PartWithIndex } from '~/components/Chat/Messages/Content/ParallelContent';
import { groupSequentialToolCalls } from '../groupToolCalls';
const toolPart = (id: string, name = 'fetch'): TMessageContentParts =>
({
type: ContentTypes.TOOL_CALL,
[ContentTypes.TOOL_CALL]: { id, name, args: '{}', output: 'done' },
}) as unknown as TMessageContentParts;
const thinkPart = (text = 'reasoning'): TMessageContentParts =>
({ type: ContentTypes.THINK, think: text }) 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: `${Constants.LC_TRANSFER_TO_}agent`,
args: '{}',
},
}) as unknown as TMessageContentParts;
const withIndex = (parts: TMessageContentParts[]): PartWithIndex[] =>
parts.map((part, idx) => ({ part, idx }));
describe('groupSequentialToolCalls', () => {
it('groups two adjacent tool calls', () => {
const result = groupSequentialToolCalls(withIndex([toolPart('a'), toolPart('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([toolPart('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([toolPart('a'), thinkPart(), toolPart('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([thinkPart('lead'), toolPart('a'), toolPart('b'), thinkPart('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 (e.g. a skill)', () => {
const result = groupSequentialToolCalls(withIndex([thinkPart(), toolPart('a'), thinkPart()]));
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([toolPart('a'), textPart()]));
expect(result.map((g) => g.type)).toEqual(['single', 'single']);
});
it('keeps pure reasoning (no tool call) standalone', () => {
const result = groupSequentialToolCalls(withIndex([thinkPart(), 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([toolPart('a'), toolPart('b'), textPart(), thinkPart(), 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([toolPart('a'), toolPart('b'), textPart(), toolPart('c'), toolPart('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']);
});
});

View file

@ -22,30 +22,54 @@ 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
* (e.g. a skill invocation) 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;
}
export function groupSequentialToolCalls(parts: PartWithIndex[]): GroupedPart[] {
const result: GroupedPart[] = [];
let currentGroup: PartWithIndex[] = [];
let currentRun: PartWithIndex[] = [];
const flushGroup = () => {
if (currentGroup.length >= 2) {
result.push({ type: 'tool-group', parts: [...currentGroup] });
const flushRun = () => {
const toolCallCount = countToolCalls(currentRun);
const hasReasoning = currentRun.some((p) => isReasoningPart(p.part));
if (toolCallCount >= 2 || (toolCallCount >= 1 && hasReasoning)) {
result.push({ type: 'tool-group', parts: [...currentRun] });
} else {
for (const p of currentGroup) {
for (const p of currentRun) {
result.push({ type: 'single', part: p });
}
}
currentGroup = [];
currentRun = [];
};
for (const item of parts) {
if (isGroupableToolCall(item.part)) {
currentGroup.push(item);
if (isGroupableToolCall(item.part) || isReasoningPart(item.part)) {
currentRun.push(item);
} else {
flushGroup();
flushRun();
result.push({ type: 'single', part: item });
}
}
flushGroup();
flushRun();
return result;
}