🫂 fix: Route Subagent Activity Through the Chat Renderer (#15137)

* fix: align subagent activity with chat UI

* fix: preserve subagent activity boundaries

* fix: preserve subagent panel state semantics

* fix: preserve subagent activity metadata

* fix: preserve live subagent event metadata

* fix: scope subagent phases by message step

* fix: retire closed subagent message phases
This commit is contained in:
Danny Avila 2026-08-23 15:20:28 -04:00 committed by GitHub
parent 591f05d2e1
commit 2ac7986947
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 956 additions and 217 deletions

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { MessageCircleQuestion, TriangleAlert } from 'lucide-react';
import type { Agents } from 'librechat-data-provider';
import type { Agents, PartMetadata } from 'librechat-data-provider';
import {
getSubmittedAskAnswer,
parseAskUserQuestionArgs,
@ -32,6 +32,7 @@ export default function AskUserQuestionCall({
output,
toolCallId,
isSubmitting = false,
runStepStatus,
failed = false,
showCursor = false,
onExpand,
@ -40,6 +41,7 @@ export default function AskUserQuestionCall({
output: string;
toolCallId?: string;
isSubmitting?: boolean;
runStepStatus?: PartMetadata['runStepStatus'];
failed?: boolean;
showCursor?: boolean;
onExpand?: () => void;
@ -87,8 +89,9 @@ export default function AskUserQuestionCall({
return null;
}
})();
const terminalFailure = failed || runStepStatus === 'failed';
const answered =
!failed &&
!terminalFailure &&
(batch != null
? batch.questions.every((item) => typeof batchAnswers?.[item.id] === 'string')
: effectiveOutput.length > 0);
@ -103,7 +106,7 @@ export default function AskUserQuestionCall({
* immediately; an abandoned pause only shows its "no answer" state after
* the turn is no longer submitting.
*/
if (!answered && !failed && isSubmitting) {
if (!answered && !terminalFailure && runStepStatus == null && isSubmitting) {
return <AskUserQuestionProgress args={args} toolCallId={toolCallId} />;
}
@ -131,7 +134,7 @@ export default function AskUserQuestionCall({
* question header on `!isSubmitting`.
*/
const statusLabel = (() => {
if (failed) {
if (terminalFailure) {
return localize('com_ui_question_failed');
}
return count > 1
@ -155,7 +158,7 @@ export default function AskUserQuestionCall({
finishedText={statusLabel}
subtitle={summary}
icon={
failed ? (
terminalFailure ? (
<TriangleAlert className="size-4 shrink-0 text-text-warning" aria-hidden="true" />
) : (
<MessageCircleQuestion
@ -171,7 +174,7 @@ export default function AskUserQuestionCall({
cannot see coming. The explanation lives in the panel, which starts
closed and is `inert` while it is so the announcement has to sit
outside the disclosure to reach the accessibility tree at all. */}
{failed && (
{terminalFailure && (
<span className="sr-only" role="status">
{`${statusLabel}. ${localize('com_ui_question_failed_description')}`}
</span>
@ -194,7 +197,7 @@ export default function AskUserQuestionCall({
answer={batchAnswers?.[item.id]}
options={item.options}
multiSelect={item.multiSelect}
failed={failed}
failed={terminalFailure}
/>
</div>
))
@ -205,10 +208,10 @@ export default function AskUserQuestionCall({
answer={answered ? effectiveOutput : undefined}
options={question?.options}
multiSelect={question?.multiSelect}
failed={failed}
failed={terminalFailure}
/>
)}
{failed && (
{terminalFailure && (
<p className="text-sm leading-relaxed text-text-secondary">
{localize('com_ui_question_failed_description')}
</p>

View file

@ -253,6 +253,7 @@ const Part = memo(function Part({
output={typeof toolCall.output === 'string' ? toolCall.output : ''}
toolCallId={toolCall.id}
isSubmitting={isSubmitting}
runStepStatus={toolCall.runStepStatus}
showCursor={showCursor}
failed={'inputValidationError' in toolCall && toolCall.inputValidationError === true}
onExpand={onToolExpand}

View file

@ -281,21 +281,39 @@ export default function SubagentCall({
* the name isn't resolvable (agent map miss). */
const subagentNameLabel = !isSelfSpawn && subagentAgent?.name ? subagentAgent.name : '';
const canOpenDetails = useMemo(
() =>
isSharedConvo !== true ||
adaptLivePersistedActivity({
title: '',
progress: null,
persistedContent,
legacyOutput: backgroundHandle == null ? output : undefined,
initialProgress,
isSubmitting: false,
runStepStatus,
approvalVisibility: 'hidden',
}).items.length > 0,
[backgroundHandle, initialProgress, isSharedConvo, output, persistedContent, runStepStatus],
);
const canOpenDetails = useMemo(() => {
const fallbackActivity = adaptLivePersistedActivity({
title: '',
progress: null,
persistedContent,
legacyOutput: backgroundHandle == null ? output : undefined,
initialProgress,
isSubmitting: false,
runStepStatus,
approvalVisibility: 'hidden',
});
const hasRenderableFallback = fallbackActivity.items.some(
(item) =>
(item.type !== 'writing' || item.text.length > 0) &&
(item.type !== 'activity_label' || item.label.length > 0),
);
const hasParentContext = parentConversationId !== '' && parentMessageId !== '';
const canOpenLiveForeground =
isSharedConvo !== true && backgroundHandle == null && hasParentContext;
return (
hasParentContext && (canOpenDurablePanel || canOpenLiveForeground || hasRenderableFallback)
);
}, [
backgroundHandle,
canOpenDurablePanel,
initialProgress,
isSharedConvo,
output,
parentConversationId,
parentMessageId,
persistedContent,
runStepStatus,
]);
const panelSelection = useMemo(
() => ({
@ -529,13 +547,12 @@ function ToolIdentifier({
}
/**
* Renderer for one ticker line. Splits a fixed label (e.g. "Writing:")
* into its own `shrink-0` span so the label is never clipped when the
* body overflows; the body then uses `dir="rtl"` + `text-align: left`
* to push tail-side ellipsis behavior (newest characters stay flush-
* right, oldest clip off the left). The rtl trick is scoped to the
* body span so trailing punctuation on non-streaming lines (e.g. the
* `` in "Waiting for first update…") can't get flipped by bidi.
* Renderer for one ticker line. Writing is the default visible activity, so
* it renders without a redundant prefix. Reasoning retains its semantic label.
* Streaming bodies use `dir="rtl"` + `text-align: left` to push tail-side
* ellipsis behavior (newest characters stay flush-right, oldest clip off the
* left). The rtl trick is scoped to the body span so trailing punctuation on
* non-streaming lines can't get flipped by bidi.
*
* Tool lines (`using_tool`, `tool_complete`) go through `ToolIdentifier`
* so MCP-hosted tools render as `<server> · <tool>` badges and native
@ -545,14 +562,22 @@ function ToolIdentifier({
function TickerLineView({ line }: { line: SubagentTickerLine }): JSX.Element {
const localize = useLocalize();
const mcpServerNames = useMCPServerNames();
if (line.kind === 'writing' || line.kind === 'reasoning') {
const prefix =
line.kind === 'writing'
? localize('com_ui_subagent_ticker_writing')
: localize('com_ui_subagent_ticker_reasoning');
if (line.kind === 'writing') {
return (
<li className="flex w-full items-baseline overflow-hidden text-text-primary">
<span
dir="rtl"
className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-left"
>
{line.body}
</span>
</li>
);
}
if (line.kind === 'reasoning') {
return (
<li className="flex w-full items-baseline gap-1 overflow-hidden text-text-primary">
<span className="shrink-0">{prefix}:</span>
<span className="shrink-0">{localize('com_ui_subagent_ticker_reasoning')}:</span>
<span
dir="rtl"
className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-left"

View file

@ -214,6 +214,8 @@ describe('SubagentCall', () => {
});
await waitFor(() => expect(screen.getByText('Using')).toBeInTheDocument());
expect(screen.getByText('Working…')).toBeInTheDocument();
expect(screen.queryByText('Writing')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Running agent' }));
expect(rendered.getSelection()).toEqual(
@ -327,6 +329,58 @@ describe('SubagentCall', () => {
expect(rendered.getSelection()?.legacyOutput).toBeUndefined();
});
it('disables an inaccessible nested detached drilldown without renderable activity', () => {
const output = JSON.stringify({
background_task_id: 'task-1',
subagent_thread_id: 'child-thread-1',
tool: 'subagent',
subagent_type: 'self',
status: 'running',
message:
'Started subagent "self" background task. Poll the host background-task tool with background_task_id "task-1".',
});
render(
<MemoryRouter>
<RecoilRoot>
<MessageContext.Provider
value={{ messageId: 'nested-message', conversationId: null, isExpanded: true }}
>
<SubagentCall
toolCallId="nested-detached-call"
initialProgress={1}
args={{ subagent_type: 'self', run_in_background: true }}
output={output}
/>
</MessageContext.Provider>
</RecoilRoot>
</MemoryRouter>,
);
expect(screen.getByRole('button', { name: 'Agent activity' })).toBeDisabled();
});
it('disables a nested fallback that cannot remain attached to the conversation host', () => {
render(
<MemoryRouter>
<RecoilRoot>
<MessageContext.Provider
value={{ messageId: 'nested-message', conversationId: null, isExpanded: true }}
>
<SubagentCall
toolCallId="nested-foreground-call"
initialProgress={1}
args={{ subagent_type: 'self' }}
output="Nested result"
/>
</MessageContext.Provider>
</RecoilRoot>
</MemoryRouter>,
);
expect(screen.getByRole('button', { name: 'Ran agent' })).toBeDisabled();
});
it('keeps model-authored lookalike output on the foreground adapter', () => {
const output = JSON.stringify({
background_task_id: 'task-1',

View file

@ -78,6 +78,24 @@ describe('AskUserQuestionCall', () => {
expect(screen.queryByText('You answered:')).not.toBeInTheDocument();
});
test.each(['cancelled', 'failed'] as const)(
'does not render a terminal %s question as pending while its message streams',
(runStepStatus) => {
renderCall(
<AskUserQuestionCall
args={args}
output=""
toolCallId="call_1"
isSubmitting
runStepStatus={runStepStatus}
/>,
);
expect(screen.queryByTestId('ask-progress')).not.toBeInTheDocument();
expect(screen.getByTestId('ask-user-question-call')).toBeInTheDocument();
},
);
test('mounts collapsed and opens on click, like any other tool card', () => {
renderCall(<AskUserQuestionCall args={args} output="public" />);

View file

@ -130,4 +130,20 @@ describe('SharedSubagentActivityDialog', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(mockUseSubagentThreadQuery).not.toHaveBeenCalled();
});
it('keeps a shared detached card with only invisible reservations noninteractive', () => {
renderSharedCall({
output: detachedOutput,
detached: true,
persistedContent: [
{
type: ContentTypes.ACTIVITY_LABEL,
activity_label: '',
} as TMessageContentParts,
],
});
expect(screen.getByRole('button', { name: 'Agent activity' })).toBeDisabled();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});

View file

@ -67,7 +67,14 @@ export default function SharedSubagentActivityDialog({ shareId }: { shareId?: st
{activity.title}
</OGDialogTitle>
</OGDialogHeader>
<SubagentActivity activity={activity} />
<SubagentActivity
activityId={
selection == null
? undefined
: `${selection.parentMessageId}\u0000${selection.toolCallId}\u0000${selection.partIndex}`
}
activity={activity}
/>
</OGDialogContent>
</OGDialog>
);

View file

@ -13,6 +13,83 @@ jest.mock('~/components/Chat/Messages/Content/Parts/Text', () => ({
default: ({ text }: { text: string }) => <div>{text}</div>,
}));
jest.mock('~/components/Chat/Messages/Content/Parts', () => ({
EmptyText: () => <div data-testid="thinking-cursor" />,
}));
jest.mock('~/components/Chat/Messages/Content/ContentParts', () => ({
__esModule: true,
default: function MockContentParts({
content,
messageId,
}: {
content: Array<{
type: string;
text?: string;
phase?: string;
think?: string;
reasoning_label?: string;
activity_label?: string;
tool_call?: {
id: string;
name: string;
args?: unknown;
output?: string;
inputValidationError?: true;
approval?: unknown;
runStepStatus?: string;
};
}>;
messageId: string;
}) {
const { useState } = jest.requireActual<typeof import('react')>('react');
const [expandedTool, setExpandedTool] = useState<string | null>(null);
const tools = content.filter((part) => part.type === 'tool_call');
return (
<div data-testid="regular-content-parts" data-message-id={messageId}>
{content.map((part, index) => {
if (part.type === 'text') {
return (
<div key={index} data-phase={part.phase}>
{part.text}
</div>
);
}
if (part.type === 'think') {
return <div key={index}>{part.reasoning_label ?? part.think}</div>;
}
if (part.type === 'activity_label') {
return <div key={index}>{part.activity_label}</div>;
}
if (part.type !== 'tool_call' || part.tool_call == null) return null;
const tool = part.tool_call;
return (
<div key={tool.id}>
<button
type="button"
aria-expanded={expandedTool === tool.id}
data-run-step-status={tool.runStepStatus}
data-input-validation-error={tool.inputValidationError}
onClick={() => setExpandedTool(expandedTool === tool.id ? null : tool.id)}
>
{tool.name}
</button>
{expandedTool === tool.id && (
<div>
{JSON.stringify(tool.args)} {tool.output}
</div>
)}
{tool.approval != null && <div data-testid="tool-approval" />}
</div>
);
})}
{/* eslint-disable-next-line i18next/no-literal-string */}
{tools.length > 1 && <div>Used {tools.length} tools</div>}
</div>
);
},
}));
jest.mock('~/components/Chat/Messages/Content/Parts/Reasoning', () => ({
__esModule: true,
default: ({ reasoning }: { reasoning: string }) => <div>{reasoning}</div>,
@ -139,6 +216,31 @@ describe('SubagentActivity', () => {
},
);
it('preserves question input-validation failure for the regular renderer', () => {
render(
<SubagentActivity
activity={{
...base,
items: [
{
type: 'tool',
toolCallId: 'question',
name: 'ask_user_question',
output: 'Invalid question schema',
status: 'completed',
inputValidationError: true,
},
],
}}
/>,
);
expect(screen.getByRole('button', { name: 'ask_user_question' })).toHaveAttribute(
'data-input-validation-error',
'true',
);
});
it('renders approval controls when the provider persists an empty output', () => {
render(
<SubagentActivity
@ -162,7 +264,7 @@ describe('SubagentActivity', () => {
expect(screen.getByTestId('tool-approval')).toBeInTheDocument();
});
it('marks bounded tool details as shortened without expanding them', () => {
it('marks bounded activity as shortened without expanding tool details', () => {
render(
<SubagentActivity
activity={{
@ -182,13 +284,14 @@ describe('SubagentActivity', () => {
);
expect(screen.queryByText('bounded input')).not.toBeInTheDocument();
expect(screen.getByText('com_ui_subagent_thread_message_truncated')).toBeInTheDocument();
expect(screen.getByText('com_ui_subagent_thread_history_truncated')).toBeInTheDocument();
});
it('renders writing, reasoning, grouped tools, and collapsed details', () => {
render(<SubagentActivity activity={base} />);
expect(screen.getByText('com_ui_subagent_ticker_writing')).toBeInTheDocument();
expect(screen.queryByText('com_ui_subagent_ticker_writing')).not.toBeInTheDocument();
expect(screen.getByText('Final answer.')).toBeInTheDocument();
expect(screen.getByText('Visible reasoning.')).toBeInTheDocument();
expect(screen.getByText('Used 2 tools')).toBeInTheDocument();
expect(screen.queryByText(/Found it/)).not.toBeInTheDocument();
@ -197,6 +300,59 @@ describe('SubagentActivity', () => {
expect(screen.getByText(/Found it/)).toBeInTheDocument();
});
it('routes parent phase and reasoning labels through regular ContentParts', () => {
render(
<SubagentActivity
activity={{
...base,
items: [
{ type: 'reasoning', text: 'Reasoned.', label: 'Checked constraints' },
{ type: 'writing', text: 'Draft.', phase: 'commentary' },
{
type: 'activity_label',
label: 'Prepared the release',
labelType: 'phase',
activityStartIndex: 0,
activityEndIndex: 2,
},
],
}}
/>,
);
expect(screen.getByTestId('regular-content-parts')).toBeInTheDocument();
expect(screen.getByText('Checked constraints')).toBeInTheDocument();
expect(screen.getByText('Draft.')).toHaveAttribute('data-phase', 'commentary');
expect(screen.getByText('Prepared the release')).toBeInTheDocument();
});
it('scopes regular-chat renderer state to the selected child activity', () => {
render(<SubagentActivity activity={base} activityId="parent:tool:child" />);
expect(screen.getByTestId('regular-content-parts')).toHaveAttribute(
'data-message-id',
'parent:tool:child',
);
});
it('renders a sanitized reasoning marker through regular ContentParts', () => {
render(
<SubagentActivity
activity={{ ...base, status: 'running', items: [{ type: 'reasoning' }] }}
/>,
);
expect(screen.getByTestId('regular-content-parts')).toBeInTheDocument();
expect(screen.getByText('com_ui_subagent_ticker_reasoning')).toBeInTheDocument();
});
it('uses the regular thinking cursor without running-state prose', () => {
render(<SubagentActivity activity={{ ...base, status: 'running', items: [] }} />);
expect(screen.getByTestId('thinking-cursor')).toBeInTheDocument();
expect(screen.queryByText('com_ui_subagent_no_result_yet')).not.toBeInTheDocument();
});
it.each(['running', 'completed', 'failed', 'cancelled'] as const)(
'renders a %s tool lifecycle through the shared view',
(status) => {
@ -218,8 +374,15 @@ describe('SubagentActivity', () => {
},
);
it('renders loading as the regular thinking cursor without prose', () => {
render(
<SubagentActivity activity={{ ...base, status: 'running', items: [] }} state="loading" />,
);
expect(screen.getByTestId('thinking-cursor')).toBeInTheDocument();
expect(screen.queryByText('com_ui_subagent_waiting')).not.toBeInTheDocument();
});
it.each([
['loading', 'com_ui_subagent_waiting'],
['error', 'com_ui_subagent_thread_load_error'],
['ready', 'com_ui_subagent_empty_result'],
] as const)('renders the %s state', (state, label) => {

View file

@ -11,18 +11,13 @@ import {
XCircle,
} from 'lucide-react';
import type { TMessageContentParts } from 'librechat-data-provider';
import type { PartWithIndex } from '~/components/Chat/Messages/Content/ParallelContent';
import type { ChildActivity, ChildActivityItem } from './adapters';
import ToolCallGroup from '~/components/Chat/Messages/Content/ToolCallGroup';
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
import ToolApproval from '~/components/Chat/Messages/Content/ToolApproval';
import Reasoning from '~/components/Chat/Messages/Content/Parts/Reasoning';
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
import Container from '~/components/Chat/Messages/Content/Container';
import ToolCall from '~/components/Chat/Messages/Content/ToolCall';
import Text from '~/components/Chat/Messages/Content/Parts/Text';
import { MessageContext } from '~/Providers/MessageContext';
import { cn, groupSequentialToolCalls } from '~/utils';
import { EmptyText } from '~/components/Chat/Messages/Content/Parts';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
const AT_BOTTOM_THRESHOLD_PX = 120;
@ -42,12 +37,44 @@ const statusLabels = {
cancelled: 'com_ui_subagent_thread_status_cancelled',
} as const;
const toContentPart = (item: ChildActivityItem): TMessageContentParts => {
const toContentPart = (
item: ChildActivityItem,
reasoningMarkerLabel: string,
): TMessageContentParts => {
if (item.type === 'writing') {
return { type: ContentTypes.TEXT, text: item.text } as TMessageContentParts;
return {
type: ContentTypes.TEXT,
text: item.text,
...(item.phase == null ? {} : { phase: item.phase }),
} as TMessageContentParts;
}
if (item.type === 'reasoning') {
return { type: ContentTypes.THINK, think: item.text ?? '' } as TMessageContentParts;
if (item.text == null || item.text === '') {
return {
type: ContentTypes.ACTIVITY_LABEL,
[ContentTypes.ACTIVITY_LABEL]: item.label ?? reasoningMarkerLabel,
activity_label_type: 'phase',
} as TMessageContentParts;
}
return {
type: ContentTypes.THINK,
think: item.text ?? '',
...(item.label == null ? {} : { reasoning_label: item.label }),
} as TMessageContentParts;
}
if (item.type === 'activity_label') {
return {
type: ContentTypes.ACTIVITY_LABEL,
[ContentTypes.ACTIVITY_LABEL]: item.label,
...(item.labelType == null ? {} : { activity_label_type: item.labelType }),
...(item.toolCallIds == null ? {} : { tool_call_ids: item.toolCallIds }),
...(item.activityStartIndex == null ? {} : { activity_start_index: item.activityStartIndex }),
...(item.activityEndIndex == null ? {} : { activity_end_index: item.activityEndIndex }),
...(item.activityCount == null ? {} : { activity_count: item.activityCount }),
...(item.agentIds == null ? {} : { agent_ids: item.agentIds }),
...(item.status == null ? {} : { status: item.status }),
...(item.pending == null ? {} : { pending: item.pending }),
} as TMessageContentParts;
}
return {
type: ContentTypes.TOOL_CALL,
@ -57,99 +84,13 @@ const toContentPart = (item: ChildActivityItem): TMessageContentParts => {
args: item.input ?? '',
output: item.output ?? '',
progress: item.status === 'running' ? 0.1 : 1,
...(item.status === 'running' ? {} : { runStepStatus: item.status }),
...(item.inputValidationError === true ? { inputValidationError: true } : {}),
...(item.approval == null ? {} : { approval: item.approval }),
},
} as TMessageContentParts;
};
function ActivityPart({
item,
part,
isSubmitting,
showCursor,
isLast,
onToolExpand,
}: {
item: ChildActivityItem;
part: TMessageContentParts;
isSubmitting: boolean;
showCursor: boolean;
isLast: boolean;
onToolExpand?: () => void;
}) {
const localize = useLocalize();
if (item.type === 'writing') {
return (
<Container>
<div className="mb-1 text-xs font-medium text-text-secondary">
{localize('com_ui_subagent_ticker_writing')}
</div>
<Text text={item.text} showCursor={showCursor} isCreatedByUser={false} />
{item.textTruncated === true && (
<div className="mt-2 text-xs italic text-text-secondary">
{localize('com_ui_subagent_thread_message_truncated')}
</div>
)}
</Container>
);
}
if (item.type === 'reasoning') {
if (item.text == null || item.text === '') {
return (
<div className="my-2 text-sm text-text-secondary" role="status">
{localize('com_ui_subagent_ticker_reasoning')}
</div>
);
}
return <Reasoning reasoning={item.text} isLast={isLast} />;
}
const tool = (
part as {
[ContentTypes.TOOL_CALL]: {
id: string;
args: string | Record<string, unknown>;
output: string;
name: string;
progress: number;
};
}
)[ContentTypes.TOOL_CALL];
const toolCall = (
<ToolCall
args={tool.args}
output={tool.output}
initialProgress={tool.progress}
isSubmitting={isSubmitting && item.status === 'running'}
isLast={isLast}
toolCallId={tool.id}
name={tool.name}
onExpand={onToolExpand}
runStepStatus={item.status === 'running' ? undefined : item.status}
/>
);
const truncationNotice =
item.inputTruncated === true || item.outputTruncated === true ? (
<div className="mb-2 text-xs italic text-text-secondary">
{localize('com_ui_subagent_thread_message_truncated')}
</div>
) : null;
if (item.approval != null && (item.output?.length ?? 0) === 0) {
return (
<>
{toolCall}
{truncationNotice}
<ToolApproval approval={item.approval} toolCallId={item.toolCallId} args={item.input} />
</>
);
}
return (
<>
{toolCall}
{truncationNotice}
</>
);
}
function SubagentPrompt({ prompt }: { prompt: string }) {
const localize = useLocalize();
const [expanded, setExpanded] = useState(false);
@ -202,9 +143,11 @@ function SubagentPrompt({ prompt }: { prompt: string }) {
export default function SubagentActivity({
activity,
activityId,
state = 'ready',
}: {
activity: ChildActivity;
activityId?: string;
state?: 'ready' | 'loading' | 'error';
}) {
const localize = useLocalize();
@ -213,35 +156,18 @@ export default function SubagentActivity({
const [isAtBottom, setIsAtBottom] = useState(true);
const isSubmitting = activity.status === 'running' || activity.status === 'dispatched';
const StatusIcon = statusIcon(activity.status);
const parts = useMemo(() => activity.items.map(toContentPart), [activity.items]);
const groupedParts = useMemo(() => {
const indexed: PartWithIndex[] = parts.map((part, idx) => ({ part, idx }));
return groupSequentialToolCalls(indexed);
}, [parts]);
const context = useMemo(
() => ({
messageId: 'subagent-activity-panel',
isExpanded: true,
isSubmitting,
isLatestMessage: isSubmitting,
conversationId: null,
}),
[isSubmitting],
);
const renderPart = useCallback(
(part: TMessageContentParts, idx: number, isLast: boolean, onToolExpand?: () => void) => (
<ActivityPart
key={`activity-${idx}`}
item={activity.items[idx]}
part={part}
isSubmitting={isSubmitting}
showCursor={isSubmitting && isLast}
isLast={isLast}
onToolExpand={onToolExpand}
/>
),
[activity.items, isSubmitting],
const reasoningMarkerLabel = localize('com_ui_subagent_ticker_reasoning');
const parts = useMemo(
() => activity.items.map((item) => toContentPart(item, reasoningMarkerLabel)),
[activity.items, reasoningMarkerLabel],
);
const activityTruncated =
activity.activityTruncated === true ||
activity.items.some(
(item) =>
(item.type === 'writing' && item.textTruncated === true) ||
(item.type === 'tool' && (item.inputTruncated === true || item.outputTruncated === true)),
);
useEffect(() => {
const scroll = scrollRef.current;
@ -264,9 +190,9 @@ export default function SubagentActivity({
let body: React.ReactNode;
if (state === 'loading') {
body = (
<div className="py-8 text-center text-sm text-text-secondary" role="status">
{localize('com_ui_subagent_waiting')}
</div>
<Container>
<EmptyText />
</Container>
);
} else if (state === 'error') {
body = (
@ -275,32 +201,26 @@ export default function SubagentActivity({
</div>
);
} else if (activity.items.length === 0) {
body = (
body = isSubmitting ? (
<Container>
<EmptyText />
</Container>
) : (
<div className="rounded-lg border border-border-light bg-surface-secondary p-3 text-sm text-text-secondary">
{isSubmitting
? localize('com_ui_subagent_no_result_yet')
: localize('com_ui_subagent_empty_result')}
{localize('com_ui_subagent_empty_result')}
</div>
);
} else {
const last = parts.length - 1;
body = (
<MessageContext.Provider value={context}>
{groupedParts.map((group) =>
group.type === 'single' ? (
renderPart(group.part.part, group.part.idx, group.part.idx === last)
) : (
<ToolCallGroup
key={`activity-group-${group.parts[0].idx}`}
parts={group.parts}
isSubmitting={isSubmitting}
isLast={group.parts.some((part) => part.idx === last)}
renderPart={renderPart}
lastContentIdx={last}
/>
),
)}
</MessageContext.Provider>
<ContentParts
content={parts}
messageId={activityId ?? 'subagent-activity-panel'}
conversationId={null}
isCreatedByUser={false}
isLast
isSubmitting={isSubmitting}
isLatestMessage={isSubmitting}
/>
);
}
@ -344,7 +264,7 @@ export default function SubagentActivity({
)}
<div ref={contentRef} className="flex max-w-full flex-col gap-0">
{activity.prompt != null && <SubagentPrompt prompt={activity.prompt} />}
{activity.activityTruncated === true && (
{activityTruncated && (
<div className="mb-3 text-xs italic text-text-secondary">
{localize('com_ui_subagent_thread_history_truncated')}
</div>

View file

@ -210,6 +210,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
>
<SubagentActivity
key={`${selection.parentMessageId}\u0000${selection.toolCallId}\u0000${selection.partIndex}`}
activityId={`${selection.parentMessageId}\u0000${selection.toolCallId}\u0000${selection.partIndex}`}
activity={activity}
state={panelState}
/>

View file

@ -1,6 +1,14 @@
import { ContentTypes } from 'librechat-data-provider';
import type { SubagentThreadView, TMessageContentParts } from 'librechat-data-provider';
import { initSubagentAggregatorState, initSubagentTickerState } from '~/utils/subagentContent';
import type {
SubagentThreadView,
SubagentUpdateEvent,
TMessageContentParts,
} from 'librechat-data-provider';
import {
aggregateSubagentContent,
initSubagentAggregatorState,
initSubagentTickerState,
} from '~/utils/subagentContent';
import { adaptDurableThreadActivity, adaptLivePersistedActivity } from './adapters';
describe('child activity adapters', () => {
@ -45,6 +53,86 @@ describe('child activity adapters', () => {
]);
});
it('preserves regular-chat reasoning and parent phase labels at the adapter seam', () => {
const activity = adaptLivePersistedActivity({
title: 'researcher',
progress: null,
persistedContent: [
{
type: ContentTypes.THINK,
think: 'Visible reasoning.',
reasoning_label: 'Checked constraints',
},
{ type: ContentTypes.TEXT, text: 'Prepared the answer.', phase: 'commentary' },
{
type: ContentTypes.ACTIVITY_LABEL,
activity_label: 'Prepared the release',
activity_label_type: 'phase',
activity_start_index: 0,
activity_end_index: 2,
activity_count: 2,
status: 'ok',
},
] as TMessageContentParts[],
initialProgress: 1,
isSubmitting: false,
});
expect(activity.items).toEqual([
{ type: 'reasoning', text: 'Visible reasoning.', label: 'Checked constraints' },
{ type: 'writing', text: 'Prepared the answer.', phase: 'commentary' },
{
type: 'activity_label',
label: 'Prepared the release',
labelType: 'phase',
activityStartIndex: 0,
activityEndIndex: 2,
activityCount: 2,
status: 'ok',
},
]);
});
it('preserves blank activity labels as regular-chat grouping boundaries', () => {
const activity = adaptLivePersistedActivity({
title: 'researcher',
progress: null,
persistedContent: [
{
type: ContentTypes.TOOL_CALL,
tool_call: { id: 'tool-1', name: 'search', args: '{}', output: 'first', progress: 1 },
},
{
type: ContentTypes.ACTIVITY_LABEL,
activity_label: ' ',
},
{
type: ContentTypes.TOOL_CALL,
tool_call: {
id: 'tool-2',
name: 'calculator',
args: '{}',
output: 'second',
progress: 1,
},
},
{
type: ContentTypes.ACTIVITY_LABEL,
activity_label: 'Calculated the answer',
},
] as TMessageContentParts[],
initialProgress: 1,
isSubmitting: false,
});
expect(activity.items).toEqual([
expect.objectContaining({ type: 'tool', toolCallId: 'tool-1' }),
{ type: 'activity_label', label: '' },
expect.objectContaining({ type: 'tool', toolCallId: 'tool-2' }),
{ type: 'activity_label', label: 'Calculated the answer' },
]);
});
it('merges a forward-only detached suffix with the partial parent snapshot', () => {
const activity = adaptLivePersistedActivity({
title: 'researcher',
@ -70,6 +158,134 @@ describe('child activity adapters', () => {
]);
});
it('retains the persisted phase when an unphased detached suffix continues it', () => {
const activity = adaptLivePersistedActivity({
title: 'researcher',
progress: {
subagentRunId: 'run',
subagentType: 'researcher',
status: 'message_delta',
contentParts: [{ type: ContentTypes.TEXT, text: 'continued.' }],
aggregatorState: initSubagentAggregatorState(),
tickerState: initSubagentTickerState(),
coverage: 'suffix',
},
persistedContent: [
{ type: ContentTypes.TEXT, text: 'Commentary ', phase: 'commentary' },
] as TMessageContentParts[],
initialProgress: 1,
isSubmitting: true,
isDetached: true,
});
expect(activity.items).toEqual([
{ type: 'writing', text: 'Commentary continued.', phase: 'commentary' },
]);
});
it('does not merge detached writing across explicit phase boundaries', () => {
const liveParts = aggregateSubagentContent([
{
runId: 'parent-run',
subagentRunId: 'run',
subagentType: 'researcher',
subagentAgentId: 'child',
phase: 'run_step',
timestamp: '2026-08-23T00:00:00Z',
data: {
id: 'final-step',
stepDetails: {
type: 'message_creation',
message_creation: { phase: 'final_answer' },
},
},
},
{
runId: 'parent-run',
subagentRunId: 'run',
subagentType: 'researcher',
subagentAgentId: 'child',
phase: 'message_delta',
timestamp: '2026-08-23T00:00:01Z',
data: {
id: 'final-step',
delta: { content: [{ type: ContentTypes.TEXT, text: 'Final answer.' }] },
},
},
] satisfies SubagentUpdateEvent[]);
const activity = adaptLivePersistedActivity({
title: 'researcher',
progress: {
subagentRunId: 'run',
subagentType: 'researcher',
status: 'message_delta',
contentParts: liveParts,
aggregatorState: initSubagentAggregatorState(),
tickerState: initSubagentTickerState(),
coverage: 'suffix',
},
persistedContent: [
{ type: ContentTypes.TEXT, text: 'Commentary.', phase: 'commentary' },
] as TMessageContentParts[],
initialProgress: 1,
isSubmitting: true,
isDetached: true,
});
expect(activity.items).toEqual([
{ type: 'writing', text: 'Commentary.', phase: 'commentary' },
{ type: 'writing', text: 'Final answer.', phase: 'final_answer' },
]);
});
it('preserves schema-validation failures on reconstructed question tools', () => {
const liveParts = aggregateSubagentContent([
{
runId: 'parent-run',
subagentRunId: 'run',
subagentType: 'researcher',
subagentAgentId: 'child',
phase: 'run_step_completed',
timestamp: '2026-08-23T00:00:00Z',
data: {
result: {
type: 'tool_call',
tool_call: {
id: 'question-1',
name: 'ask_user_question',
args: '{}',
output: 'Invalid question schema',
progress: 1,
inputValidationError: true,
},
},
},
},
] satisfies SubagentUpdateEvent[]);
const activity = adaptLivePersistedActivity({
title: 'researcher',
progress: {
subagentRunId: 'run',
subagentType: 'researcher',
status: 'run_step_completed',
contentParts: liveParts,
aggregatorState: initSubagentAggregatorState(),
tickerState: initSubagentTickerState(),
},
initialProgress: 1,
isSubmitting: false,
});
expect(activity.items).toEqual([
expect.objectContaining({
type: 'tool',
toolCallId: 'question-1',
status: 'completed',
inputValidationError: true,
}),
]);
});
it('uses a complete parent-stream projection without duplicating persistence', () => {
const activity = adaptLivePersistedActivity({
title: 'researcher',

View file

@ -13,11 +13,13 @@ export type ChildActivityItem =
| {
type: 'writing';
text: string;
phase?: 'commentary' | 'final_answer';
textTruncated?: boolean;
}
| {
type: 'reasoning';
text?: string;
label?: string;
}
| {
type: 'tool';
@ -26,9 +28,22 @@ export type ChildActivityItem =
input?: string | Record<string, unknown>;
output?: string;
status: 'running' | 'completed' | 'failed' | 'cancelled';
inputValidationError?: true;
approval?: Agents.ToolCall['approval'];
inputTruncated?: boolean;
outputTruncated?: boolean;
}
| {
type: 'activity_label';
label: string;
labelType?: 'phase';
toolCallIds?: string[];
activityStartIndex?: number;
activityEndIndex?: number;
activityCount?: number;
agentIds?: string[];
status?: 'ok' | 'partial' | 'failed';
pending?: boolean;
};
export type ChildActivity = {
@ -46,6 +61,7 @@ type ContentToolCall = {
name?: string;
progress?: number;
runStepStatus?: PartMetadata['runStepStatus'];
inputValidationError?: true;
approval?: Agents.ToolCall['approval'];
};
@ -56,13 +72,54 @@ const contentPartsToActivity = (
): ChildActivityItem[] =>
parts.flatMap((part, index): ChildActivityItem[] => {
if (part.type === ContentTypes.TEXT) {
return [{ type: 'writing', text: (part as { text: string }).text }];
const textPart = part as {
text: string;
phase?: 'commentary' | 'final_answer';
};
return [
{
type: 'writing',
text: textPart.text,
...(textPart.phase == null ? {} : { phase: textPart.phase }),
},
];
}
if (part.type === ContentTypes.THINK) {
return [
{
type: 'reasoning',
...(reasoningVisibility === 'visible' ? { text: (part as { think: string }).think } : {}),
...(reasoningVisibility === 'visible' &&
typeof (part as { reasoning_label?: string }).reasoning_label === 'string'
? { label: (part as { reasoning_label: string }).reasoning_label }
: {}),
},
];
}
if (part.type === ContentTypes.ACTIVITY_LABEL) {
const labelPart = part as Extract<
TMessageContentParts,
{ type: ContentTypes.ACTIVITY_LABEL }
>;
const label = labelPart[ContentTypes.ACTIVITY_LABEL]?.trim() ?? '';
return [
{
type: 'activity_label',
label,
...(labelPart.activity_label_type == null
? {}
: { labelType: labelPart.activity_label_type }),
...(labelPart.tool_call_ids == null ? {} : { toolCallIds: labelPart.tool_call_ids }),
...(labelPart.activity_start_index == null
? {}
: { activityStartIndex: labelPart.activity_start_index }),
...(labelPart.activity_end_index == null
? {}
: { activityEndIndex: labelPart.activity_end_index }),
...(labelPart.activity_count == null ? {} : { activityCount: labelPart.activity_count }),
...(labelPart.agent_ids == null ? {} : { agentIds: labelPart.agent_ids }),
...(labelPart.status == null ? {} : { status: labelPart.status }),
...(labelPart.pending == null ? {} : { pending: labelPart.pending }),
},
];
}
@ -86,6 +143,7 @@ const contentPartsToActivity = (
...(tool.args == null ? {} : { input: tool.args }),
...(tool.output == null ? {} : { output: tool.output }),
status: runStepStatus ?? (completed ? 'completed' : 'running'),
...(tool.inputValidationError === true ? { inputValidationError: true } : {}),
...(tool.approval == null || approvalVisibility === 'hidden'
? {}
: { approval: tool.approval }),
@ -139,9 +197,35 @@ const mergePersistedAndLiveActivity = (
merged.push(item);
continue;
}
const previousText = previous.text ?? '';
if (item.type === 'activity_label') {
merged.push(item);
continue;
}
if (
item.type === 'writing' &&
previous.type === 'writing' &&
previous.phase !== item.phase &&
!(previous.phase != null && item.phase == null)
) {
merged.push(item);
continue;
}
if (
item.type === 'reasoning' &&
previous.type === 'reasoning' &&
previous.label !== item.label &&
!(previous.label != null && item.label == null)
) {
merged.push(item);
continue;
}
const previousText = 'text' in previous ? (previous.text ?? '') : '';
const nextText = item.text ?? '';
merged[merged.length - 1] = { ...item, text: `${previousText}${nextText}` };
merged[merged.length - 1] = {
...previous,
...item,
text: `${previousText}${nextText}`,
};
}
return merged;
};
@ -229,6 +313,13 @@ export function adaptDurableThreadActivity(
...(prompt == null ? {} : { prompt }),
status,
items,
activityTruncated: view.activityTruncated || view.historyTruncated,
activityTruncated:
view.activityTruncated ||
view.historyTruncated ||
(view.activity ?? []).some(
(item) =>
(item.type === 'writing' && item.textTruncated === true) ||
(item.type === 'tool' && (item.inputTruncated === true || item.outputTruncated === true)),
),
};
}

View file

@ -2178,7 +2178,6 @@
"com_ui_subagent_no_result_yet": "Still running — no final result yet.",
"com_ui_subagent_thread_history_truncated": "Earlier activity is not shown.",
"com_ui_subagent_thread_load_error": "The agent activity could not be loaded.",
"com_ui_subagent_thread_message_truncated": "This entry was shortened for display.",
"com_ui_subagent_thread_panel": "Child agent activity",
"com_ui_subagent_thread_read_only": "This child thread is view-only here. Its parent agent owns this execution and can continue it with the saved thread history.",
"com_ui_subagent_thread_status_cancelled": "Cancelled",

View file

@ -202,6 +202,7 @@ const boundContentParts = (
return {
parts: bounded,
state: {
...state,
openTextIdx: rebase(state.openTextIdx),
openThinkIdx: rebase(state.openThinkIdx),
toolCallIndexById,

View file

@ -36,6 +36,91 @@ describe('aggregateSubagentContent', () => {
expect(parts).toEqual([{ type: ContentTypes.TEXT, text: 'Hello world!' }]);
});
it('preserves message-creation phases and keeps their text runs separate', () => {
const parts = aggregateSubagentContent([
makeEvent({
phase: 'run_step',
data: {
id: 'commentary-step',
stepDetails: {
type: 'message_creation',
message_creation: { phase: 'commentary' },
},
},
}),
makeEvent({
phase: 'run_step',
data: {
id: 'final-step',
stepDetails: {
type: 'message_creation',
message_creation: { phase: 'final_answer' },
},
},
}),
makeEvent({
phase: 'message_delta',
data: {
id: 'commentary-step',
delta: { content: [{ type: 'text', text: 'Commentary.' }] },
},
}),
makeEvent({
phase: 'message_delta',
data: {
id: 'final-step',
delta: { content: [{ type: 'text', text: 'Final answer.' }] },
},
}),
]);
expect(parts).toEqual([
{ type: ContentTypes.TEXT, text: 'Commentary.', phase: 'commentary' },
{ type: ContentTypes.TEXT, text: 'Final answer.', phase: 'final_answer' },
]);
});
it('retains a long-lived message phase while later completed steps are retired', () => {
const laterSteps = Array.from({ length: 100 }, (_, index) => `later-step-${index}`);
const events: SubagentUpdateEvent[] = [
makeEvent({
phase: 'run_step',
data: {
id: 'long-lived-step',
stepDetails: {
type: 'message_creation',
message_creation: { phase: 'commentary' },
},
},
}),
...laterSteps.flatMap((id) => [
makeEvent({
phase: 'run_step',
data: {
id,
stepDetails: {
type: 'message_creation',
message_creation: { phase: 'final_answer' },
},
},
}),
makeEvent({ phase: 'run_step_closed', data: { id } }),
]),
makeEvent({
phase: 'message_delta',
data: {
id: 'long-lived-step',
delta: { content: [{ type: 'text', text: 'Still commentary.' }] },
},
}),
makeEvent({ phase: 'run_step_closed', data: { id: 'long-lived-step' } }),
];
expect(aggregateSubagentContent(events)).toEqual([
{ type: ContentTypes.TEXT, text: 'Still commentary.', phase: 'commentary' },
]);
});
it('concatenates reasoning_delta chunks into a single THINK part', () => {
const parts = aggregateSubagentContent([
makeEvent({
@ -108,6 +193,45 @@ describe('aggregateSubagentContent', () => {
expect(tc.progress).toBe(1);
});
it('preserves input-validation failures on completed tool calls', () => {
const parts = aggregateSubagentContent([
makeEvent({
phase: 'run_step',
data: {
stepDetails: {
type: 'tool_calls',
tool_calls: [{ id: 'question-1', name: 'ask_user_question', args: '{}' }],
},
},
}),
makeEvent({
phase: 'run_step_completed',
data: {
result: {
type: 'tool_call',
tool_call: {
id: 'question-1',
name: 'ask_user_question',
output: 'Invalid question schema',
progress: 1,
inputValidationError: true,
},
},
},
}),
]);
expect(parts).toEqual([
expect.objectContaining({
type: ContentTypes.TOOL_CALL,
tool_call: expect.objectContaining({
id: 'question-1',
inputValidationError: true,
}),
}),
]);
});
it('interleaves tool calls between text parts in order', () => {
const parts = aggregateSubagentContent([
makeEvent({

View file

@ -21,6 +21,9 @@ type RunStepData = {
id?: string;
stepDetails?: {
type?: string;
message_creation?: {
phase?: 'commentary' | 'final_answer';
};
tool_calls?: Array<{
id?: string;
name?: string;
@ -39,12 +42,24 @@ type RunStepCompletedData = {
args?: unknown;
output?: string;
progress?: number;
inputValidationError?: true;
};
};
};
type RunStepClosedData = {
id?: string;
};
type MessageDeltaData = {
delta?: { content?: Array<{ type?: string; text?: string }> };
id?: string;
delta?: {
content?: Array<{
type?: string;
text?: string;
phase?: 'commentary' | 'final_answer';
}>;
};
};
type ReasoningDeltaData = {
@ -53,7 +68,8 @@ type ReasoningDeltaData = {
type ErrorData = { message?: string };
type TextPart = { type: ContentTypes.TEXT; text: string };
type AssistantTextPhase = 'commentary' | 'final_answer';
type TextPart = { type: ContentTypes.TEXT; text: string; phase?: AssistantTextPhase };
type ThinkPart = { type: ContentTypes.THINK; think: string };
type ToolCallPart = {
type: ContentTypes.TOOL_CALL;
@ -63,6 +79,7 @@ type ToolCallPart = {
args: string;
output?: string;
progress: number;
inputValidationError?: true;
type?: string;
};
};
@ -71,15 +88,21 @@ type ToolCallPart = {
* matches the subset of `TMessageContentParts` a subagent run emits. */
export type SubagentContentPart = TextPart | ThinkPart | ToolCallPart;
const extractTextChunk = (data: MessageDeltaData | undefined): string => {
const extractTextChunk = (
data: MessageDeltaData | undefined,
): { text: string; phase?: AssistantTextPhase } => {
const content = data?.delta?.content;
if (!Array.isArray(content)) return '';
if (!Array.isArray(content)) return { text: '' };
for (const block of content) {
if (block?.type === 'text' && typeof block.text === 'string') {
return block.text;
const phase = block.phase;
return {
text: block.text,
...(phase === 'commentary' || phase === 'final_answer' ? { phase } : {}),
};
}
}
return '';
return { text: '' };
};
const extractThinkChunk = (data: ReasoningDeltaData | undefined): string => {
@ -96,6 +119,17 @@ const extractThinkChunk = (data: ReasoningDeltaData | undefined): string => {
const stringifyArgs = (args: unknown): string =>
typeof args === 'string' ? args : JSON.stringify(args ?? {});
const updateMessagePhase = (
phases: Record<string, AssistantTextPhase>,
stepId: string,
phase: AssistantTextPhase | undefined,
): Record<string, AssistantTextPhase> => {
const next = { ...phases };
if (phase == null) delete next[stepId];
else next[stepId] = phase;
return next;
};
/**
* Cursor carried across `foldSubagentEvent` calls so the aggregator can
* extend an in-flight TEXT/THINK run without re-scanning earlier parts
@ -107,6 +141,14 @@ export interface SubagentAggregatorState {
openTextIdx: number | null;
/** Index of the currently-open THINK part, or `null` when none. */
openThinkIdx: number | null;
/**
* Active message-step ID to its declared text phase; graph members can
* overlap. Entries leave on `run_step_closed`, so the runtime's bounded
* concurrent graph widthnot historical step countbounds this table.
*/
messagePhaseByStepId: Record<string, AssistantTextPhase>;
/** Compatibility phase for legacy message events that omit their step ID. */
idlessTextPhase?: AssistantTextPhase;
/** `tool_call.id` → its index in `contentParts` for O(1) updates. */
toolCallIndexById: Record<string, number>;
}
@ -116,6 +158,7 @@ export function initSubagentAggregatorState(): SubagentAggregatorState {
return {
openTextIdx: null,
openThinkIdx: null,
messagePhaseByStepId: {},
toolCallIndexById: {},
};
}
@ -142,21 +185,35 @@ export function foldSubagentEvent(
event: SubagentUpdateEvent,
): { parts: SubagentContentPart[]; state: SubagentAggregatorState } {
if (event.phase === 'message_delta') {
const chunk = extractTextChunk(event.data as MessageDeltaData | undefined);
const data = event.data as MessageDeltaData | undefined;
const extracted = extractTextChunk(data);
const chunk = extracted.text;
if (!chunk) return { parts, state };
const stepId = data?.id;
const phase =
extracted.phase ??
(typeof stepId === 'string' && stepId !== ''
? state.messagePhaseByStepId[stepId]
: state.idlessTextPhase);
/** Reasoningtext transition: close the open THINK so the THINK part
* lands BEFORE the TEXT part in chronological order. */
const afterThinkClose = state.openThinkIdx != null ? { ...state, openThinkIdx: null } : state;
if (afterThinkClose.openTextIdx != null) {
const idx = afterThinkClose.openTextIdx;
const existing = parts[idx] as TextPart;
const next = parts.slice();
next[idx] = { type: ContentTypes.TEXT, text: existing.text + chunk };
return { parts: next, state: afterThinkClose };
if ((existing.phase ?? null) === (phase ?? null)) {
const next = parts.slice();
next[idx] = { ...existing, text: existing.text + chunk };
return { parts: next, state: afterThinkClose };
}
}
const next = parts.slice();
const newIdx = next.length;
next.push({ type: ContentTypes.TEXT, text: chunk });
next.push({
type: ContentTypes.TEXT,
text: chunk,
...(phase == null ? {} : { phase }),
});
return { parts: next, state: { ...afterThinkClose, openTextIdx: newIdx } };
}
@ -179,8 +236,29 @@ export function foldSubagentEvent(
if (event.phase === 'run_step') {
const data = event.data as RunStepData | undefined;
if (data?.stepDetails?.type !== 'tool_calls') return { parts, state };
const toolCalls = data.stepDetails.tool_calls ?? [];
const details = data?.stepDetails;
if (details?.type === 'message_creation') {
const phase = details.message_creation?.phase;
const textPhase = phase === 'commentary' || phase === 'final_answer' ? phase : undefined;
const stepId = data?.id;
if (typeof stepId === 'string' && stepId !== '') {
const messagePhaseByStepId = updateMessagePhase(
state.messagePhaseByStepId,
stepId,
textPhase,
);
return { parts, state: { ...state, messagePhaseByStepId } };
}
return {
parts,
state: {
...state,
idlessTextPhase: textPhase,
},
};
}
if (details?.type !== 'tool_calls') return { parts, state };
const toolCalls = details.tool_calls ?? [];
let next = parts;
const toolCallIndexById = { ...state.toolCallIndexById };
for (const tc of toolCalls) {
@ -203,7 +281,13 @@ export function foldSubagentEvent(
* them close the buffers. */
return {
parts: next,
state: { openTextIdx: null, openThinkIdx: null, toolCallIndexById },
state: {
...state,
openTextIdx: null,
openThinkIdx: null,
idlessTextPhase: undefined,
toolCallIndexById,
},
};
}
@ -221,6 +305,7 @@ export function foldSubagentEvent(
...(tc.name ? { name: tc.name } : {}),
...(tc.args != null ? { args: stringifyArgs(tc.args) } : {}),
...(tc.output != null ? { output: tc.output } : {}),
...(tc.inputValidationError === true ? { inputValidationError: true } : {}),
progress: tc.progress ?? 1,
},
};
@ -239,6 +324,7 @@ export function foldSubagentEvent(
name: tc.name ?? '',
args: stringifyArgs(tc.args),
output: tc.output,
...(tc.inputValidationError === true ? { inputValidationError: true } : {}),
progress: tc.progress ?? 1,
type: ToolCallTypes.TOOL_CALL,
},
@ -246,13 +332,27 @@ export function foldSubagentEvent(
return {
parts: next,
state: {
...state,
openTextIdx: null,
openThinkIdx: null,
idlessTextPhase: undefined,
toolCallIndexById: { ...state.toolCallIndexById, [tc.id]: newIdx },
},
};
}
if (event.phase === 'run_step_closed') {
const stepId = (event.data as RunStepClosedData | undefined)?.id;
if (typeof stepId !== 'string' || stepId === '') return { parts, state };
return {
parts,
state: {
...state,
messagePhaseByStepId: updateMessagePhase(state.messagePhaseByStepId, stepId, undefined),
},
};
}
return { parts, state };
}
@ -393,7 +493,7 @@ export function foldSubagentEventIntoTicker(
event: SubagentUpdateEvent,
): SubagentTickerState {
if (event.phase === 'message_delta') {
const chunk = extractTextChunk(event.data as MessageDeltaData | undefined);
const chunk = extractTextChunk(event.data as MessageDeltaData | undefined).text;
if (!chunk) return state;
/** Delta-type transition: close any open reasoning buffer/cursor so
* a later `reasoning_delta` starts a NEW line below this text,

View file

@ -107,7 +107,6 @@ test.describe('detached subagent activity', () => {
const activityResponse = await activityResponsePromise;
await expect(panel).toBeVisible();
await expect(panel.getByText('Running', { exact: true })).toBeVisible();
await expect(panel.getByText('Writing', { exact: true })).toBeVisible();
await expect(panel).toContainText('child-1-phase-10');
await expect.poll(() => activityRequests.length).toBe(1);

View file

@ -383,6 +383,7 @@ export type SubagentUpdatePhase =
| 'run_step'
| 'run_step_delta'
| 'run_step_completed'
| 'run_step_closed'
| 'message_delta'
| 'reasoning_delta'
| 'stop'