mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
🫂 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:
parent
591f05d2e1
commit
2ac7986947
18 changed files with 956 additions and 217 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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" />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ const boundContentParts = (
|
|||
return {
|
||||
parts: bounded,
|
||||
state: {
|
||||
...state,
|
||||
openTextIdx: rebase(state.openTextIdx),
|
||||
openThinkIdx: rebase(state.openThinkIdx),
|
||||
toolCallIndexById,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 width—not historical step count—bounds 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);
|
||||
/** Reasoning→text 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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -383,6 +383,7 @@ export type SubagentUpdatePhase =
|
|||
| 'run_step'
|
||||
| 'run_step_delta'
|
||||
| 'run_step_completed'
|
||||
| 'run_step_closed'
|
||||
| 'message_delta'
|
||||
| 'reasoning_delta'
|
||||
| 'stop'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue