- {isSubmitting
- ? localize('com_ui_subagent_no_result_yet')
- : localize('com_ui_subagent_empty_result')}
+ {localize('com_ui_subagent_empty_result')}
);
} else {
- const last = parts.length - 1;
body = (
-
{activity.prompt != null &&
}
- {activity.activityTruncated === true && (
+ {activityTruncated && (
{localize('com_ui_subagent_thread_history_truncated')}
diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx
index cf6a5c3679..73b9fdd9fc 100644
--- a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx
+++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx
@@ -210,6 +210,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
>
diff --git a/client/src/components/Chat/Subagents/adapters.test.ts b/client/src/components/Chat/Subagents/adapters.test.ts
index bb983d9fb9..be737f6f0e 100644
--- a/client/src/components/Chat/Subagents/adapters.test.ts
+++ b/client/src/components/Chat/Subagents/adapters.test.ts
@@ -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',
diff --git a/client/src/components/Chat/Subagents/adapters.ts b/client/src/components/Chat/Subagents/adapters.ts
index b6abb4a525..3f1bec25d3 100644
--- a/client/src/components/Chat/Subagents/adapters.ts
+++ b/client/src/components/Chat/Subagents/adapters.ts
@@ -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
;
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)),
+ ),
};
}
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index 17be14a65f..4fea8b4c9e 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -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",
diff --git a/client/src/store/subagents.ts b/client/src/store/subagents.ts
index 897bfb9260..4940a39bc2 100644
--- a/client/src/store/subagents.ts
+++ b/client/src/store/subagents.ts
@@ -202,6 +202,7 @@ const boundContentParts = (
return {
parts: bounded,
state: {
+ ...state,
openTextIdx: rebase(state.openTextIdx),
openThinkIdx: rebase(state.openThinkIdx),
toolCallIndexById,
diff --git a/client/src/utils/__tests__/subagentContent.test.ts b/client/src/utils/__tests__/subagentContent.test.ts
index d2792e6ab4..dd8782dcfc 100644
--- a/client/src/utils/__tests__/subagentContent.test.ts
+++ b/client/src/utils/__tests__/subagentContent.test.ts
@@ -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({
diff --git a/client/src/utils/subagentContent.ts b/client/src/utils/subagentContent.ts
index 712e3ee822..393c67278e 100644
--- a/client/src/utils/subagentContent.ts
+++ b/client/src/utils/subagentContent.ts
@@ -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,
+ stepId: string,
+ phase: AssistantTextPhase | undefined,
+): Record => {
+ 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;
+ /** 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;
}
@@ -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,
diff --git a/e2e/specs/mock/subagent-activity.spec.ts b/e2e/specs/mock/subagent-activity.spec.ts
index f3258617df..97512266f5 100644
--- a/e2e/specs/mock/subagent-activity.spec.ts
+++ b/e2e/specs/mock/subagent-activity.spec.ts
@@ -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);
diff --git a/packages/data-provider/src/types/runs.ts b/packages/data-provider/src/types/runs.ts
index e8498bdd74..a3ffd26dda 100644
--- a/packages/data-provider/src/types/runs.ts
+++ b/packages/data-provider/src/types/runs.ts
@@ -383,6 +383,7 @@ export type SubagentUpdatePhase =
| 'run_step'
| 'run_step_delta'
| 'run_step_completed'
+ | 'run_step_closed'
| 'message_delta'
| 'reasoning_delta'
| 'stop'