📡 feat: Stream Detached Subagent Activity (#15111)

* feat: stream detached subagent activity

* fix: annotate activity stream limits

* fix: isolate subagent activity imports

* fix: harden detached subagent activity lifecycle

* test: cover synchronous activity transport failure

* test: include required subagent activity identity

* fix: identify and reconnect subagent activity events

* fix: bound subagent activity lifecycles

* fix: close subagent activity handoff races

* fix: bind and synchronize activity subscriptions

* fix: detect fresh activity attachment

* fix: complete activity synchronization handoff

* fix: bind activity sync and failure circuits

* fix: expose subscription-bound synchronization

* fix: fence activity reconnect publications

* test: make detached timeout settlement deterministic

* fix: fence Redis activity attachments

* fix: close failed activity streams

* perf: reuse fenced activity frontier

* style: sort subagent thread imports

* fix: preserve queued subagent activity

* test: type activity publication counter

* fix: disconnect subagent activity subscriber

* fix: close background activity lifecycle gaps

* fix: preserve streamed activity spacing

* fix: preserve bounded live subagent activity

* fix: merge durable subagent activity safely

* fix: model detached activity coverage

* fix: type detached activity inputs

* fix: order overlapping subagent activity

* chore: sort activity test imports

* fix: buffer subagent activity handoff gaps

* fix: flush activity after parent close

* fix: advance closed activity suffixes

* fix: preserve detached activity ordering

* fix: close detached activity delivery races

* fix: bound shared Redis subscriber readiness

* fix: expire shared Redis subscription readiness

* fix: clean up late Redis subscriptions

* fix: preserve late Redis subscription fallback
This commit is contained in:
Danny Avila 2026-08-22 09:45:27 -04:00 committed by GitHub
parent 3ebef4c84e
commit d3e70159ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 4125 additions and 110 deletions

View file

@ -13,12 +13,23 @@ import { initSubagentAggregatorState, initSubagentTickerState } from '~/utils/su
import SubagentThreadPanel from './SubagentThreadPanel';
const mockUseSubagentThreadQuery = jest.fn();
const mockUseSubagentActivityStream = jest.fn();
const mockApprovalProviderMounted = jest.fn();
const mockApprovalProviderUnmounted = jest.fn();
let mockIsMobile = false;
jest.mock('~/data-provider', () => ({
useSubagentThreadQuery: (...args: unknown[]) => mockUseSubagentThreadQuery(...args),
subagentThreadHasTaskEvidence: (view: SubagentThreadView | undefined, taskId: string): boolean =>
view?.messages.some(
(message) =>
message.messageId === `${taskId}:user` || message.messageId === `${taskId}:assistant`,
) === true,
}));
jest.mock('~/data-provider/Subagents/useSubagentActivityStream', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseSubagentActivityStream(...args),
}));
jest.mock('~/hooks', () => ({
@ -156,6 +167,7 @@ describe('SubagentThreadPanel', () => {
'child-thread',
'task',
);
expect(mockUseSubagentActivityStream).toHaveBeenCalledWith(selection, false);
expect(screen.getByText('Research child')).toBeInTheDocument();
expect(screen.getByText('Investigate the release.')).toBeInTheDocument();
expect(screen.getByText('The release is ready.')).toBeInTheDocument();
@ -205,6 +217,46 @@ describe('SubagentThreadPanel', () => {
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-state', 'ready');
});
it('renders newer detached progress instead of a dispatch-time parent snapshot', () => {
mockUseSubagentThreadQuery.mockReturnValue({
data: { ...completedView, status: 'running', activity: [] },
isLoading: false,
isError: false,
isReadinessPending: false,
});
const progressKey = subagentProgressKey(
selection.parentMessageId,
selection.toolCallId,
selection.partIndex,
);
const detachedSelection: ActiveSubagentPanel = {
...selection,
persistedContent: [
{ type: ContentTypes.TEXT, text: 'Dispatch-time snapshot.' },
] as TMessageContentParts[],
};
render(
<RecoilRoot
initializeState={({ set }) =>
set(subagentProgressByToolCallId(progressKey), {
subagentRunId: 'child-run',
subagentType: 'researcher',
status: 'message_delta',
contentParts: [{ type: ContentTypes.TEXT, text: 'latest detached text.' }],
aggregatorState: initSubagentAggregatorState(),
tickerState: initSubagentTickerState(),
coverage: 'suffix',
})
}
>
<SubagentThreadPanel selection={detachedSelection} />
</RecoilRoot>,
);
expect(screen.getByText('Dispatch-time snapshot.latest detached text.')).toBeInTheDocument();
});
it('resets invocation-scoped approval state when the selected card changes', () => {
mockUseSubagentThreadQuery.mockReturnValue({
data: undefined,
@ -249,6 +301,57 @@ describe('SubagentThreadPanel', () => {
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-state', 'loading');
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-status', 'dispatched');
expect(mockUseSubagentActivityStream).toHaveBeenLastCalledWith(selection, true);
});
it('opens live activity before the durable child becomes addressable', () => {
mockUseSubagentThreadQuery.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
isReadinessPending: true,
});
const { rerender } = render(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
expect(mockUseSubagentActivityStream).toHaveBeenLastCalledWith(selection, true);
mockUseSubagentThreadQuery.mockReturnValue({
data: { ...completedView, status: 'running' },
isLoading: false,
isError: false,
isReadinessPending: false,
});
rerender(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
expect(mockUseSubagentActivityStream).toHaveBeenLastCalledWith(selection, true);
});
it('keeps streaming when terminal thread state belongs to an older task', () => {
mockUseSubagentThreadQuery.mockReturnValue({
data: {
...completedView,
messages: [{ ...completedView.messages[1], messageId: 'older-task:assistant' }],
},
isLoading: false,
isError: false,
isReadinessPending: false,
});
render(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
expect(mockUseSubagentActivityStream).toHaveBeenLastCalledWith(selection, true);
});
it('surfaces a durable read failure after the readiness window', () => {

View file

@ -8,9 +8,10 @@ import {
subagentProgressByToolCallId,
subagentProgressKey,
} from '~/store/subagents';
import useSubagentActivityStream from '~/data-provider/Subagents/useSubagentActivityStream';
import { subagentThreadHasTaskEvidence, useSubagentThreadQuery } from '~/data-provider';
import { adaptDurableThreadActivity, adaptLivePersistedActivity } from './adapters';
import ApprovalProvider from '~/components/Chat/Messages/Content/ApprovalContext';
import { useSubagentThreadQuery } from '~/data-provider';
import { useFocusTrap, useLocalize } from '~/hooks';
import SubagentActivity from './SubagentActivity';
@ -35,6 +36,13 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
threadId,
taskId,
);
const durableTerminal =
subagentThreadHasTaskEvidence(data, taskId) &&
(data?.status === 'completed' ||
data?.status === 'failed' ||
data?.status === 'interrupted' ||
data?.status === 'cancelled');
useSubagentActivityStream(selection, !durableTerminal);
const detachedLiveSubmitting =
selection.durable != null &&
progress != null &&
@ -73,6 +81,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
prompt: selection.prompt,
progress,
persistedContent: selection.persistedContent,
isDetached: selection.durable != null,
legacyOutput: selection.legacyOutput,
// A detached parent tool step closes as soon as dispatch succeeds;
// its terminal status does not describe the still-running child.

View file

@ -45,6 +45,123 @@ describe('child activity adapters', () => {
]);
});
it('merges a forward-only detached suffix with the partial parent snapshot', () => {
const activity = adaptLivePersistedActivity({
title: 'researcher',
progress: {
subagentRunId: 'run',
subagentType: 'researcher',
status: 'message_delta',
contentParts: [{ type: ContentTypes.TEXT, text: 'latest detached text.' }],
aggregatorState: initSubagentAggregatorState(),
tickerState: initSubagentTickerState(),
coverage: 'suffix',
},
persistedContent: [
{ type: ContentTypes.TEXT, text: 'Dispatch-time snapshot; ' },
] as TMessageContentParts[],
initialProgress: 1,
isSubmitting: true,
isDetached: true,
});
expect(activity.items).toEqual([
{ type: 'writing', text: 'Dispatch-time snapshot; latest detached text.' },
]);
});
it('uses a complete parent-stream projection without duplicating persistence', () => {
const activity = adaptLivePersistedActivity({
title: 'researcher',
progress: {
subagentRunId: 'run',
subagentType: 'researcher',
status: 'message_delta',
contentParts: [{ type: ContentTypes.TEXT, text: 'Complete live text.' }],
aggregatorState: initSubagentAggregatorState(),
tickerState: initSubagentTickerState(),
coverage: 'complete',
},
persistedContent: [{ type: ContentTypes.TEXT, text: 'Complete ' }] as TMessageContentParts[],
initialProgress: 1,
isSubmitting: true,
isDetached: true,
});
expect(activity.items).toEqual([{ type: 'writing', text: 'Complete live text.' }]);
});
it('appends coincident text in a forward-only suffix', () => {
const activity = adaptLivePersistedActivity({
title: 'researcher',
progress: {
subagentRunId: 'run',
subagentType: 'researcher',
status: 'message_delta',
contentParts: [{ type: ContentTypes.TEXT, text: 'ha' }],
aggregatorState: initSubagentAggregatorState(),
tickerState: initSubagentTickerState(),
coverage: 'suffix',
},
persistedContent: [{ type: ContentTypes.TEXT, text: 'ha' }] as TMessageContentParts[],
initialProgress: 1,
isSubmitting: true,
isDetached: true,
});
expect(activity.items).toEqual([{ type: 'writing', text: 'haha' }]);
});
it('preserves persisted tool fields when a sparse completion is the live suffix', () => {
const activity = adaptLivePersistedActivity({
title: 'researcher',
progress: {
subagentRunId: 'run',
subagentType: 'researcher',
status: 'run_step_completed',
contentParts: [
{
type: ContentTypes.TOOL_CALL,
tool_call: {
id: 'tool-1',
name: '',
args: '{}',
output: 'Found it.',
progress: 1,
},
},
],
aggregatorState: initSubagentAggregatorState(),
tickerState: initSubagentTickerState(),
coverage: 'suffix',
},
persistedContent: [
{
type: ContentTypes.TOOL_CALL,
tool_call: {
id: 'tool-1',
name: 'search',
args: '{"query":"release"}',
progress: 0.1,
},
},
] as unknown as TMessageContentParts[],
initialProgress: 1,
isSubmitting: true,
isDetached: true,
});
expect(activity.items).toEqual([
expect.objectContaining({
type: 'tool',
name: 'search',
input: '{"query":"release"}',
output: 'Found it.',
status: 'completed',
}),
]);
});
it('rehydrates the selected detached task from its sanitized durable activity', () => {
const view: SubagentThreadView = {
threadId: 'thread',

View file

@ -102,6 +102,50 @@ const publicActivityToChildActivity = (items: SubagentActivityItem[]): ChildActi
};
});
/** Merge activity whose transport explicitly declares it is a forward-only
* suffix. Complete parent-stream projections bypass this function entirely. */
const mergePersistedAndLiveActivity = (
persisted: ChildActivityItem[],
live: ChildActivityItem[],
): ChildActivityItem[] => {
if (persisted.length === 0) return live;
if (live.length === 0) return persisted;
const merged = [...persisted];
for (const item of live) {
if (item.type === 'tool') {
const existingIndex = merged.findIndex(
(candidate) => candidate.type === 'tool' && candidate.toolCallId === item.toolCallId,
);
if (existingIndex >= 0) {
const existing = merged[existingIndex] as Extract<ChildActivityItem, { type: 'tool' }>;
const next = { ...existing, ...item };
if (item.name === '') next.name = existing.name;
if (
existing.input != null &&
(item.input == null || item.input === '' || item.input === '{}')
) {
next.input = existing.input;
}
merged[existingIndex] = next;
} else {
merged.push(item);
}
continue;
}
const previous = merged.at(-1);
if (previous?.type !== item.type) {
merged.push(item);
continue;
}
const previousText = previous.text ?? '';
const nextText = item.text ?? '';
merged[merged.length - 1] = { ...item, text: `${previousText}${nextText}` };
}
return merged;
};
const liveStatus = ({
progress,
initialProgress,
@ -131,17 +175,22 @@ export function adaptLivePersistedActivity(input: {
initialProgress: number;
isSubmitting: boolean;
runStepStatus?: PartMetadata['runStepStatus'];
isDetached?: boolean;
reasoningVisibility?: 'visible' | 'marker';
approvalVisibility?: 'visible' | 'hidden';
}): ChildActivity {
const persisted = input.persistedContent ?? [];
const live = (input.progress?.contentParts ?? []) as TMessageContentParts[];
const parts = persisted.length > 0 ? persisted : live;
const items = contentPartsToActivity(
parts,
input.reasoningVisibility ?? 'visible',
input.approvalVisibility ?? 'visible',
);
const reasoningVisibility = input.reasoningVisibility ?? 'visible';
const approvalVisibility = input.approvalVisibility ?? 'visible';
const persistedItems = contentPartsToActivity(persisted, reasoningVisibility, approvalVisibility);
const liveItems = contentPartsToActivity(live, reasoningVisibility, approvalVisibility);
let items = persistedItems.length > 0 ? persistedItems : liveItems;
if (input.isDetached === true && input.progress?.coverage === 'suffix') {
items = mergePersistedAndLiveActivity(persistedItems, liveItems);
} else if (input.isDetached === true && liveItems.length > 0) {
items = liveItems;
}
if (items.length === 0 && input.legacyOutput != null && input.legacyOutput !== '') {
items.push({ type: 'writing', text: input.legacyOutput });
}

View file

@ -2,6 +2,7 @@ import { renderHook } from '@testing-library/react';
import type { SubagentThreadView } from 'librechat-data-provider';
import {
isSubagentReadinessPending,
subagentThreadHasTaskEvidence,
subagentThreadRefetchInterval,
useSubagentThreadQuery,
} from './queries';
@ -46,6 +47,16 @@ describe('subagent thread refresh policy', () => {
expect(subagentThreadRefetchInterval(prior, 1_000, 1_000, 'new-task')).toBe(false);
});
it('associates terminal state only with evidence from the selected task', () => {
const prior = {
...view('completed'),
messages: [{ messageId: 'old-task:assistant' }],
} as SubagentThreadView;
expect(subagentThreadHasTaskEvidence(prior, 'new-task')).toBe(false);
expect(subagentThreadHasTaskEvidence(prior, 'old-task')).toBe(true);
});
it('stops polling an older API view once the exact task response exists', () => {
const rollingDeployView = {
...view('running'),

View file

@ -13,20 +13,22 @@ const isTerminal = (status: SubagentThreadView['status']): boolean =>
status === 'interrupted' ||
status === 'cancelled';
export const subagentThreadHasTaskEvidence = (
view: SubagentThreadView | undefined,
taskId: string,
): boolean =>
view?.messages.some(
(message) =>
message.messageId === `${taskId}:user` || message.messageId === `${taskId}:assistant`,
) === true;
export const subagentThreadRefetchInterval = (
view: SubagentThreadView | undefined,
readinessDeadline: number,
now = Date.now(),
expectedTaskId?: string,
): number | false => {
if (
expectedTaskId != null &&
!view?.messages.some(
(message) =>
message.messageId === `${expectedTaskId}:user` ||
message.messageId === `${expectedTaskId}:assistant`,
)
) {
if (expectedTaskId != null && !subagentThreadHasTaskEvidence(view, expectedTaskId)) {
return now < readinessDeadline ? ACTIVE_THREAD_REFRESH_MS : false;
}
// During a rolling deploy, an older replica can return a thread-wide status

View file

@ -0,0 +1,303 @@
import React from 'react';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil';
import { ContentTypes, QueryKeys, StepEvents } from 'librechat-data-provider';
import type { ActiveSubagentPanel } from '~/store/subagents';
import {
subagentParentStreamOpenByToolCallId,
subagentProgressByToolCallId,
subagentProgressKey,
takeRegisteredSubagentProgressKeys,
} from '~/store/subagents';
import useSubagentActivityStream from './useSubagentActivityStream';
type Listener = (event: MessageEvent) => void;
type MockStream = {
url: string;
options: { method?: string; headers?: Record<string, string> };
listeners: Record<string, Listener>;
close: jest.Mock;
emit: (type: string, data: unknown) => void;
};
const streams: MockStream[] = [];
jest.mock('sse.js', () => ({
SSE: jest.fn().mockImplementation((url: string, options: MockStream['options']) => {
const listeners: Record<string, Listener> = {};
const stream: MockStream = {
url,
options,
listeners,
close: jest.fn(),
emit: (type, data) => listeners[type]?.({ data: JSON.stringify(data) } as MessageEvent),
};
streams.push(stream);
return {
addEventListener: (type: string, listener: Listener) => {
listeners[type] = listener;
},
close: stream.close,
};
}),
}));
const mockInvalidateQueries = jest.fn();
const mockQueryClient = { invalidateQueries: mockInvalidateQueries };
jest.mock('@tanstack/react-query', () => ({
...jest.requireActual('@tanstack/react-query'),
useQueryClient: () => mockQueryClient,
}));
jest.mock('~/hooks/AuthContext', () => ({
useAuthContext: () => ({ token: 'token-1', isAuthenticated: true }),
}));
const selection: ActiveSubagentPanel = {
host: 'conversation',
parentConversationId: 'parent conversation',
parentMessageId: 'parent-message',
toolCallId: 'tool-call',
partIndex: 1,
subagentType: 'researcher',
initialProgress: 1,
isSubmitting: false,
durable: { threadId: 'child/thread', taskId: 'task?1' },
};
const wrapper = ({ children }: { children: React.ReactNode }) => (
<RecoilRoot>{children}</RecoilRoot>
);
describe('useSubagentActivityStream', () => {
beforeEach(() => {
streams.length = 0;
mockInvalidateQueries.mockClear();
takeRegisteredSubagentProgressKeys();
});
it('opens one authorized task stream and closes after terminal delivery', () => {
const { result, unmount } = renderHook(
() => {
useSubagentActivityStream(selection);
return useRecoilValue(
subagentProgressByToolCallId(
subagentProgressKey(
selection.parentMessageId,
selection.toolCallId,
selection.partIndex,
),
),
);
},
{ wrapper },
);
expect(streams).toHaveLength(1);
expect(streams[0]?.url).toContain(
'/api/convos/parent%20conversation/subagents/child%2Fthread/tasks/task%3F1/activity',
);
expect(streams[0]?.options.headers).toEqual({ Authorization: 'Bearer token-1' });
act(() => {
streams[0]?.emit('message', {
event: StepEvents.ON_SUBAGENT_UPDATE,
data: {
runId: 'root',
parentRunId: 'parent',
subagentRunId: 'child',
activityEventId: 'task-1:0',
activitySequence: 0,
subagentType: 'researcher',
subagentKind: 'agent',
subagentAgentId: 'agent-1',
parentToolCallId: 'tool-call',
depth: 1,
ancestry: ['parent'],
phase: 'message_delta',
data: { delta: { content: [{ type: 'text', text: 'Live child output' }] } },
timestamp: '2026-08-21T20:00:00.000Z',
},
});
streams[0]?.emit('message', {
final: true,
subagentActivity: true,
status: 'completed',
});
});
expect(result.current?.contentParts).toEqual([{ type: 'text', text: 'Live child output' }]);
expect(result.current?.coverage).toBe('complete');
expect(takeRegisteredSubagentProgressKeys()).toEqual([
subagentProgressKey(selection.parentMessageId, selection.toolCallId, selection.partIndex),
]);
expect(streams[0]?.close).toHaveBeenCalledTimes(1);
expect(mockInvalidateQueries).toHaveBeenCalledWith([
QueryKeys.subagentThread,
'parent conversation',
'child/thread',
'task?1',
]);
unmount();
expect(streams[0]?.close).toHaveBeenCalledTimes(1);
});
it('accepts an exact task-stream update when older providers omit the optional tool-call id', () => {
const { result } = renderHook(
() => {
useSubagentActivityStream(selection);
return useRecoilValue(
subagentProgressByToolCallId(
subagentProgressKey(
selection.parentMessageId,
selection.toolCallId,
selection.partIndex,
),
),
);
},
{ wrapper },
);
act(() => {
streams[0]?.emit('message', {
event: StepEvents.ON_SUBAGENT_UPDATE,
data: {
runId: 'root',
parentRunId: 'parent',
subagentRunId: 'child',
subagentType: 'researcher',
subagentKind: 'agent',
depth: 1,
ancestry: [],
phase: 'message_delta',
data: { delta: { content: [{ type: 'text', text: 'Compatible update' }] } },
timestamp: '2026-08-21T20:00:00.000Z',
},
});
});
expect(result.current?.contentParts).toEqual([{ type: 'text', text: 'Compatible update' }]);
});
it('buffers the first detached suffix while the parent stream is still open', () => {
const activeSelection = { ...selection, isSubmitting: true };
const key = subagentProgressKey(
activeSelection.parentMessageId,
activeSelection.toolCallId,
activeSelection.partIndex,
);
const { result } = renderHook(
() => {
useSubagentActivityStream(activeSelection);
return {
progress: useRecoilValue(subagentProgressByToolCallId(key)),
parentOpen: useRecoilValue(subagentParentStreamOpenByToolCallId(key)),
closeParent: useSetRecoilState(subagentParentStreamOpenByToolCallId(key)),
};
},
{ wrapper },
);
act(() => {
streams[0]?.emit('message', {
event: StepEvents.ON_SUBAGENT_UPDATE,
data: {
runId: 'root',
parentRunId: 'parent',
subagentRunId: 'child',
activityEventId: 'task-1:5',
activitySequence: 5,
subagentType: 'researcher',
subagentKind: 'agent',
subagentAgentId: 'agent-1',
parentToolCallId: 'tool-call',
depth: 1,
ancestry: [],
phase: 'message_delta',
data: { delta: { content: [{ type: ContentTypes.TEXT, text: 'suffix' }] } },
timestamp: '2026-08-21T20:00:00.000Z',
},
});
});
expect(result.current.parentOpen).toBe(true);
expect(result.current.progress?.contentParts).toEqual([]);
expect(result.current.progress?.pendingSequencedEvents).toHaveLength(1);
act(() => result.current.closeParent(false));
expect(result.current.parentOpen).toBe(false);
expect(result.current.progress?.contentParts).toEqual([
{ type: ContentTypes.TEXT, text: 'suffix' },
]);
expect(result.current.progress?.pendingSequencedEvents).toBeUndefined();
});
it('reconnects with bounded backoff after a transient stream error', () => {
jest.useFakeTimers();
const { unmount } = renderHook(() => useSubagentActivityStream(selection), { wrapper });
act(() => streams[0]?.emit('error', {}));
expect(streams[0]?.close).toHaveBeenCalledTimes(1);
expect(streams).toHaveLength(1);
act(() => jest.advanceTimersByTime(500));
expect(streams).toHaveLength(2);
unmount();
expect(streams[1]?.close).toHaveBeenCalledTimes(1);
jest.useRealTimers();
});
it('preserves reconnect backoff after a stream-unavailable envelope', () => {
jest.useFakeTimers();
const { unmount } = renderHook(() => useSubagentActivityStream(selection), { wrapper });
act(() => streams[0]?.emit('error', {}));
act(() => jest.advanceTimersByTime(500));
expect(streams).toHaveLength(2);
act(() => {
streams[1]?.emit('message', { error: 'Subagent activity stream unavailable' });
streams[1]?.emit('error', {});
jest.advanceTimersByTime(999);
});
expect(streams).toHaveLength(2);
act(() => jest.advanceTimersByTime(1));
expect(streams).toHaveLength(3);
unmount();
jest.useRealTimers();
});
it('keeps one forward-only stream across metadata-only selection updates', () => {
const { rerender } = renderHook(({ value }) => useSubagentActivityStream(value), {
initialProps: { value: selection },
wrapper,
});
expect(streams).toHaveLength(1);
rerender({
value: {
...selection,
persistedContent: [{ type: ContentTypes.TEXT, text: 'New snapshot.' }],
durable: { ...selection.durable! },
},
});
expect(streams).toHaveLength(1);
expect(streams[0]?.close).not.toHaveBeenCalled();
});
it('never opens the private task stream for shares or foreground children', () => {
const { rerender } = renderHook(({ value }) => useSubagentActivityStream(value), {
initialProps: { value: { ...selection, host: 'share' } as ActiveSubagentPanel },
wrapper,
});
expect(streams).toHaveLength(0);
rerender({ value: { ...selection, host: 'conversation', durable: undefined } });
expect(streams).toHaveLength(0);
});
});

View file

@ -0,0 +1,167 @@
import { useEffect, useRef } from 'react';
import { SSE } from 'sse.js';
import { useQueryClient } from '@tanstack/react-query';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { QueryKeys, StepEvents, apiBaseUrl } from 'librechat-data-provider';
import type { SubagentUpdateEvent } from 'librechat-data-provider';
import type { ActiveSubagentPanel } from '~/store/subagents';
import {
closeParentSubagentProgress,
reduceSubagentProgress,
registerSubagentProgressKey,
subagentParentStreamOpenByToolCallId,
subagentProgressByToolCallId,
subagentProgressKey,
} from '~/store/subagents';
import { useAuthContext } from '~/hooks/AuthContext';
type ActivityEnvelope = {
event?: unknown;
data?: unknown;
final?: unknown;
subagentActivity?: unknown;
};
const INITIAL_RECONNECT_MS = 500;
const MAX_RECONNECT_MS = 5_000;
const isSubagentUpdate = (value: unknown): value is SubagentUpdateEvent => {
if (value == null || typeof value !== 'object') return false;
const event = value as Partial<SubagentUpdateEvent>;
return (
typeof event.subagentRunId === 'string' &&
typeof event.subagentType === 'string' &&
(event.activityEventId == null || typeof event.activityEventId === 'string') &&
(event.activitySequence == null ||
(Number.isSafeInteger(event.activitySequence) && event.activitySequence >= 0)) &&
(event.parentToolCallId == null || typeof event.parentToolCallId === 'string') &&
typeof event.phase === 'string'
);
};
/** Live-only enhancement for the selected durable child; the durable query remains canonical. */
export default function useSubagentActivityStream(
selection: ActiveSubagentPanel,
enabled = true,
): void {
const { token, isAuthenticated } = useAuthContext();
const queryClient = useQueryClient();
const key = subagentProgressKey(
selection.parentMessageId,
selection.toolCallId,
selection.partIndex,
);
const setProgress = useSetRecoilState(subagentProgressByToolCallId(key));
const parentStreamOpen = useRecoilValue(subagentParentStreamOpenByToolCallId(key));
const setParentStreamOpen = useSetRecoilState(subagentParentStreamOpenByToolCallId(key));
const parentStreamOpenRef = useRef(parentStreamOpen);
const durable = selection.durable;
const threadId = durable?.threadId;
const taskId = durable?.taskId;
useEffect(() => {
parentStreamOpenRef.current = parentStreamOpen;
if (!parentStreamOpen) {
setProgress(closeParentSubagentProgress);
}
}, [parentStreamOpen, setProgress]);
useEffect(() => {
if (!selection.isSubmitting) return;
registerSubagentProgressKey(key);
parentStreamOpenRef.current = true;
setParentStreamOpen(true);
}, [key, selection.isSubmitting, setParentStreamOpen]);
useEffect(() => {
if (
selection.host !== 'conversation' ||
threadId == null ||
taskId == null ||
!enabled ||
!isAuthenticated ||
token == null
) {
return;
}
const queryKey = [QueryKeys.subagentThread, selection.parentConversationId, threadId, taskId];
const endpoint = `${apiBaseUrl()}/api/convos/${encodeURIComponent(selection.parentConversationId)}/subagents/${encodeURIComponent(threadId)}/tasks/${encodeURIComponent(taskId)}/activity`;
let stream: SSE | undefined;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let retryAttempt = 0;
let disposed = false;
let terminal = false;
const closeCurrent = () => {
const current = stream;
stream = undefined;
current?.close();
};
const connect = () => {
retryTimer = undefined;
if (disposed || terminal) return;
const next = new SSE(endpoint, {
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
});
stream = next;
next.addEventListener('message', (message: MessageEvent) => {
if (stream !== next || disposed) return;
let envelope: ActivityEnvelope;
try {
envelope = JSON.parse(message.data) as ActivityEnvelope;
} catch {
return;
}
if (envelope.final === true && envelope.subagentActivity === true) {
terminal = true;
closeCurrent();
void queryClient.invalidateQueries(queryKey);
return;
}
const event = envelope.data;
if (envelope.event !== StepEvents.ON_SUBAGENT_UPDATE || !isSubagentUpdate(event)) {
return;
}
if (event.parentToolCallId != null && event.parentToolCallId !== selection.toolCallId) {
return;
}
retryAttempt = 0;
registerSubagentProgressKey(key);
setProgress((previous) =>
reduceSubagentProgress(previous, [event], 'detached', parentStreamOpenRef.current),
);
});
next.addEventListener('error', () => {
if (stream !== next || disposed || terminal || retryTimer != null) return;
closeCurrent();
const delay = Math.min(INITIAL_RECONNECT_MS * 2 ** retryAttempt, MAX_RECONNECT_MS);
retryAttempt += 1;
retryTimer = setTimeout(connect, delay);
});
};
connect();
return () => {
disposed = true;
if (retryTimer != null) clearTimeout(retryTimer);
closeCurrent();
};
}, [
enabled,
isAuthenticated,
key,
queryClient,
selection.host,
selection.parentConversationId,
selection.parentMessageId,
selection.partIndex,
selection.toolCallId,
setProgress,
taskId,
threadId,
token,
]);
}

View file

@ -3532,6 +3532,42 @@ describe('useStepHandler', () => {
expect(getProgress('call_keep')).not.toBeNull();
});
it('uses parent stream closure to release a detached sequence waiting at handoff', () => {
const { result, getProgress } = renderStepHandlerWithReader();
const { submission } = seedResponseWithSubagentToolCalls(result, ['call_handoff']);
act(() => {
(result.current as any).stepHandler(
{
event: StepEvents.ON_SUBAGENT_UPDATE,
data: makeUpdate({
parentToolCallId: 'call_handoff',
activityEventId: 'task:5',
activitySequence: 5,
phase: 'message_delta',
data: { delta: { content: [{ type: ContentTypes.TEXT, text: 'suffix' }] } },
}),
},
submission,
);
});
expect(getProgress('call_handoff')).toEqual(
expect.objectContaining({ contentParts: [], pendingSequencedEvents: [expect.any(Object)] }),
);
act(() => {
(result.current as any).clearStepMaps();
});
expect(getProgress('call_handoff')).toEqual(
expect.objectContaining({
contentParts: [{ type: ContentTypes.TEXT, text: 'suffix' }],
lastActivitySequence: 5,
}),
);
});
});
/**

View file

@ -23,14 +23,14 @@ import type {
import type { SetterOrUpdater } from 'recoil';
import type { AnnounceOptions } from '~/common';
import {
foldSubagentEvent,
foldSubagentEventIntoTicker,
initSubagentAggregatorState,
initSubagentTickerState,
} from '~/utils/subagentContent';
import {
closeParentSubagentProgress,
listRegisteredSubagentProgressKeys,
reduceSubagentProgress,
registerSubagentProgressKey,
subagentParentStreamOpenByToolCallId,
subagentProgressByToolCallId,
subagentProgressKey,
takeRegisteredSubagentProgressKeys,
sandboxStartingByToolCallId,
} from '~/store';
import { isAskUserQuestionPart, isAnsweredAskUserQuestionPart } from '~/utils/approval';
@ -183,13 +183,6 @@ export default function useStepHandler({
const pendingSubagentBuffer = useRef(
new Map<string, { parentMessageId: string; events: SubagentUpdateEvent[] }>(),
);
/**
* Tracked atom keys so `clearStepMaps` can reset them. Without this, each
* subagent invocation leaks an `events: SubagentUpdateEvent[]` array in the
* `atomFamily` atoms persist for the app lifetime.
*/
const knownSubagentAtomKeys = useRef(new Set<string>());
const getCurrentMessages = useCallback(
(messages: TMessage[]) => {
const freshMessages = getMessages();
@ -276,35 +269,11 @@ export default function useStepHandler({
}
const toApply = pending ? [...pending.events, payload] : [payload];
knownSubagentAtomKeys.current.add(invocationKey);
set(subagentProgressByToolCallId(invocationKey), (prev) => {
/** Fold the batch into both aggregators. Pure functions they
* return a new reference only when something actually changed,
* so React bails out of unnecessary re-renders downstream. */
let contentParts = prev?.contentParts ?? [];
let aggregatorState = prev?.aggregatorState ?? initSubagentAggregatorState();
let tickerState = prev?.tickerState ?? initSubagentTickerState();
for (const event of toApply) {
({ parts: contentParts, state: aggregatorState } = foldSubagentEvent(
contentParts,
aggregatorState,
event,
));
tickerState = foldSubagentEventIntoTicker(tickerState, event);
}
const last = toApply[toApply.length - 1];
return {
subagentRunId: payload.subagentRunId,
subagentType: payload.subagentType,
subagentAgentId: payload.subagentAgentId ?? prev?.subagentAgentId,
contentParts,
aggregatorState,
tickerState,
status: last.phase,
latestLabel: last.label ?? prev?.latestLabel,
};
});
registerSubagentProgressKey(invocationKey);
set(subagentParentStreamOpenByToolCallId(invocationKey), true);
set(subagentProgressByToolCallId(invocationKey), (prev) =>
reduceSubagentProgress(prev, toApply, 'parent', true),
);
},
[resolveSubagentInvocationKey],
);
@ -323,10 +292,21 @@ export default function useStepHandler({
const resetSubagentAtoms = useRecoilCallback(
({ reset }) =>
(): void => {
for (const invocationKey of knownSubagentAtomKeys.current) {
for (const invocationKey of takeRegisteredSubagentProgressKeys()) {
reset(subagentProgressByToolCallId(invocationKey));
reset(subagentParentStreamOpenByToolCallId(invocationKey));
}
},
[],
);
const closeParentSubagentStreams = useRecoilCallback(
({ set }) =>
(): void => {
for (const invocationKey of listRegisteredSubagentProgressKeys()) {
set(subagentParentStreamOpenByToolCallId(invocationKey), false);
set(subagentProgressByToolCallId(invocationKey), closeParentSubagentProgress);
}
knownSubagentAtomKeys.current.clear();
},
[],
);
@ -1437,6 +1417,7 @@ export default function useStepHandler({
subagentRunToInvocationKey.current.clear();
claimedSubagentInvocationKeys.current.clear();
pendingSubagentBuffer.current.clear();
closeParentSubagentStreams();
/** Unlike subagent atoms below, sandbox-starting flags are transient
* status with no audit value reset them at this boundary so an
* interrupted cold boot can't leak a stale "starting" label onto a
@ -1450,7 +1431,7 @@ export default function useStepHandler({
* persisted `subagent_content` takes over for historical messages
* once the conversation is saved, and we prevent unbounded
* atomFamily growth across multi-conversation sessions. */
}, [cancelPendingDeltaFlush, resetSandboxAtoms]);
}, [cancelPendingDeltaFlush, closeParentSubagentStreams, resetSandboxAtoms]);
/**
* Sync a message into the step handler's messageMap.

View file

@ -0,0 +1,371 @@
import { ContentTypes } from 'librechat-data-provider';
import type { SubagentUpdateEvent } from 'librechat-data-provider';
import { closeParentSubagentProgress, reduceSubagentProgress } from './subagents';
const update = (overrides: Partial<SubagentUpdateEvent> = {}): SubagentUpdateEvent => ({
runId: 'root-run',
parentRunId: 'parent-run',
subagentRunId: 'child-run',
activityEventId: 'activity-1',
subagentType: 'researcher',
subagentKind: 'agent',
subagentAgentId: 'agent-1',
parentToolCallId: 'tool-call',
depth: 1,
ancestry: [],
phase: 'message_delta',
data: { delta: { content: [{ type: 'text', text: 'Working.' }] } },
label: 'Drafting the report',
timestamp: '2026-08-21T20:00:00.000Z',
...overrides,
});
describe('reduceSubagentProgress', () => {
it('folds an event delivered by both parent and detached streams only once', () => {
const event = update();
const first = reduceSubagentProgress(null, [event]);
const replay = reduceSubagentProgress(first, [event]);
expect(replay).toBe(first);
expect(first?.contentParts).toEqual([{ type: ContentTypes.TEXT, text: 'Working.' }]);
expect(first?.tickerState.lines).toHaveLength(1);
});
it('preserves equal chunks that carry distinct host event identities', () => {
const progress = reduceSubagentProgress(null, [
update({ activityEventId: 'activity-1', activitySequence: 0 }),
update({ activityEventId: 'activity-2', activitySequence: 1 }),
]);
expect(progress?.contentParts).toEqual([{ type: ContentTypes.TEXT, text: 'Working.Working.' }]);
});
it('marks an accepted run-start frame complete regardless of its delivery transport', () => {
const progress = reduceSubagentProgress(
null,
[update({ activitySequence: 0 })],
'detached',
false,
);
expect(progress?.coverage).toBe('complete');
});
it('orders a same-batch overlap by the host sequence before folding', () => {
const progress = reduceSubagentProgress(
null,
[
update({
activityEventId: 'activity-2',
activitySequence: 2,
data: { delta: { content: [{ type: ContentTypes.TEXT, text: 'second' }] } },
}),
update({
activityEventId: 'activity-1',
activitySequence: 1,
data: { delta: { content: [{ type: ContentTypes.TEXT, text: 'first ' }] } },
}),
],
'detached',
false,
);
expect(progress?.contentParts).toEqual([{ type: ContentTypes.TEXT, text: 'first second' }]);
expect(progress?.lastActivitySequence).toBe(2);
});
it('rejects older overlap events and duplicates beyond the replay-key window', () => {
const initial = reduceSubagentProgress(
null,
[
update({
activityEventId: 'activity-300',
activitySequence: 300,
data: { delta: { content: [{ type: ContentTypes.TEXT, text: 'latest' }] } },
}),
],
'detached',
false,
);
const delayed = reduceSubagentProgress(initial, [
update({
activityEventId: 'activity-1',
activitySequence: 1,
data: { delta: { content: [{ type: ContentTypes.TEXT, text: 'old' }] } },
}),
update({
activityEventId: 'activity-300',
activitySequence: 300,
data: { delta: { content: [{ type: ContentTypes.TEXT, text: 'duplicate' }] } },
}),
]);
expect(delayed).toBe(initial);
expect(delayed?.contentParts).toEqual([{ type: ContentTypes.TEXT, text: 'latest' }]);
});
it('buffers a detached frame until a lagging parent delivers the missing sequence', () => {
const detached = reduceSubagentProgress(
null,
[
update({
activityEventId: 'activity-1',
activitySequence: 1,
data: { delta: { content: [{ type: ContentTypes.TEXT, text: 'second' }] } },
}),
],
'detached',
true,
);
expect(detached?.contentParts).toEqual([]);
expect(detached?.pendingSequencedEvents).toHaveLength(1);
const ordered = reduceSubagentProgress(detached, [
update({
activityEventId: 'activity-0',
activitySequence: 0,
data: { delta: { content: [{ type: ContentTypes.TEXT, text: 'first ' }] } },
}),
]);
expect(ordered?.contentParts).toEqual([{ type: ContentTypes.TEXT, text: 'first second' }]);
expect(ordered?.pendingSequencedEvents).toBeUndefined();
expect(ordered?.lastActivitySequence).toBe(1);
expect(ordered?.coverage).toBe('complete');
});
it('uses parent stream closure as the fence for a detached suffix with no earlier frame', () => {
const waiting = reduceSubagentProgress(
null,
[
update({
activityEventId: 'activity-5',
activitySequence: 5,
data: { delta: { content: [{ type: ContentTypes.TEXT, text: 'suffix' }] } },
}),
],
'detached',
true,
);
const closed = closeParentSubagentProgress(waiting);
expect(closed?.contentParts).toEqual([{ type: ContentTypes.TEXT, text: 'suffix' }]);
expect(closed?.pendingSequencedEvents).toBeUndefined();
expect(closed?.lastActivitySequence).toBe(5);
const afterMissedFrames = reduceSubagentProgress(
closed,
[
update({
activityEventId: 'activity-8',
activitySequence: 8,
data: { delta: { content: [{ type: ContentTypes.TEXT, text: ' resumed' }] } },
}),
],
'detached',
false,
);
expect(afterMissedFrames?.contentParts).toEqual([
{ type: ContentTypes.TEXT, text: 'suffix resumed' },
]);
expect(afterMissedFrames?.lastActivitySequence).toBe(8);
});
it('bounds future sequence buffering while an earlier parent frame is missing', () => {
const waiting = reduceSubagentProgress(
null,
Array.from({ length: 140 }, (_, index) =>
update({
activityEventId: `activity-${index + 1}`,
activitySequence: index + 1,
data: { delta: { content: [{ type: ContentTypes.TEXT, text: 'x'.repeat(2048) }] } },
}),
),
'detached',
true,
);
expect(waiting?.pendingSequencedEvents?.length).toBeLessThanOrEqual(100);
expect(
new TextEncoder().encode(JSON.stringify(waiting?.pendingSequencedEvents)).byteLength,
).toBeLessThanOrEqual(128 * 1024);
});
it('accepts the missing expected frame even when the future-frame buffer is full', () => {
const waiting = reduceSubagentProgress(
null,
Array.from({ length: 100 }, (_, index) =>
update({
activityEventId: `activity-${index + 1}`,
activitySequence: index + 1,
data: { delta: { content: [{ type: ContentTypes.TEXT, text: 'x' }] } },
}),
),
'detached',
true,
);
expect(waiting?.pendingSequencedEvents).toHaveLength(100);
const ordered = reduceSubagentProgress(waiting, [
update({
activityEventId: 'activity-0',
activitySequence: 0,
data: { delta: { content: [{ type: ContentTypes.TEXT, text: 'first-' }] } },
}),
]);
expect(ordered?.contentParts).toEqual([
{ type: ContentTypes.TEXT, text: `first-${'x'.repeat(100)}` },
]);
expect(ordered?.pendingSequencedEvents).toBeUndefined();
expect(ordered?.lastActivitySequence).toBe(100);
expect(ordered?.coverage).toBe('complete');
});
it('preserves legacy unsequenced foreground updates', () => {
const progress = reduceSubagentProgress(null, [
update({ activityEventId: undefined, activitySequence: undefined }),
update({ activityEventId: undefined, activitySequence: undefined }),
]);
expect(progress?.contentParts).toEqual([{ type: ContentTypes.TEXT, text: 'Working.Working.' }]);
expect(progress?.lastActivitySequence).toBeUndefined();
});
it('preserves a reasoning activity marker without retaining private reasoning text', () => {
const progress = reduceSubagentProgress(
null,
[
update({
activitySequence: 0,
phase: 'reasoning_delta',
data: { delta: { content: [{ type: ContentTypes.THINK, think: 'private' }] } },
label: 'Reasoning',
}),
],
'detached',
false,
);
expect(progress?.contentParts).toEqual([{ type: ContentTypes.THINK, think: '…' }]);
expect(progress?.tickerState.lines).toEqual([
expect.objectContaining({ kind: 'reasoning', body: '…' }),
]);
expect(JSON.stringify(progress)).not.toContain('private');
});
it('preserves visible reasoning on the authoritative parent delivery path', () => {
const progress = reduceSubagentProgress(null, [
update({
activitySequence: 0,
phase: 'reasoning_delta',
data: { delta: { content: [{ type: ContentTypes.THINK, think: 'Visible reasoning' }] } },
label: 'Reasoning',
}),
]);
expect(progress?.contentParts).toEqual([
{ type: ContentTypes.THINK, think: 'Visible reasoning' },
]);
});
it('bounds accumulated live text to the durable activity byte budget', () => {
const progress = reduceSubagentProgress(null, [
update({
activityEventId: 'large-activity',
data: { delta: { content: [{ type: 'text', text: 'x'.repeat(96 * 1024) }] } },
}),
]);
expect(
new TextEncoder().encode(JSON.stringify(progress?.contentParts)).byteLength,
).toBeLessThanOrEqual(64 * 1024);
expect(progress?.contentParts[0]).toEqual(expect.objectContaining({ type: ContentTypes.TEXT }));
});
it('retains an encoded-byte-bounded singleton containing escaped text', () => {
const progress = reduceSubagentProgress(null, [
update({
activityEventId: 'escaped-activity',
data: { delta: { content: [{ type: 'text', text: '\\"'.repeat(48 * 1024) }] } },
}),
]);
expect(progress?.contentParts).toHaveLength(1);
expect(progress?.contentParts[0]).toEqual(expect.objectContaining({ type: ContentTypes.TEXT }));
expect(
new TextEncoder().encode(JSON.stringify(progress?.contentParts)).byteLength,
).toBeLessThanOrEqual(64 * 1024);
});
it('retains an encoded-byte-bounded singleton tool projection', () => {
const progress = reduceSubagentProgress(null, [
update({
activityEventId: 'escaped-tool-start',
phase: 'run_step',
data: {
stepDetails: {
type: 'tool_calls',
tool_calls: [{ id: 'tool', name: 'search', args: '\\"'.repeat(48 * 1024) }],
},
},
}),
update({
activityEventId: 'escaped-tool-complete',
phase: 'run_step_completed',
data: {
result: {
type: 'tool_call',
tool_call: {
id: 'tool',
name: 'search',
output: '\\\\'.repeat(48 * 1024),
progress: 1,
},
},
},
}),
]);
expect(progress?.contentParts).toHaveLength(1);
expect(progress?.contentParts[0]).toEqual(
expect.objectContaining({ type: ContentTypes.TOOL_CALL }),
);
expect(
new TextEncoder().encode(JSON.stringify(progress?.contentParts)).byteLength,
).toBeLessThanOrEqual(64 * 1024);
});
it('keeps only the newest bounded activity and continues folding afterward', () => {
const toolEvents = Array.from({ length: 120 }, (_, index) =>
update({
activityEventId: `tool-${index}`,
phase: 'run_step',
data: {
stepDetails: {
type: 'tool_calls',
tool_calls: [{ id: `call-${index}`, name: 'search', args: { index } }],
},
},
}),
);
const bounded = reduceSubagentProgress(null, toolEvents);
const continued = reduceSubagentProgress(bounded, [
update({
activityEventId: 'after-bound',
phase: 'message_delta',
data: { delta: { content: [{ type: 'text', text: 'Final answer.' }] } },
}),
]);
expect(bounded?.contentParts).toHaveLength(100);
expect(continued?.contentParts).toHaveLength(100);
expect(continued?.contentParts.at(-1)).toEqual({
type: ContentTypes.TEXT,
text: 'Final answer.',
});
expect(continued?.tickerState.lines.length).toBeLessThanOrEqual(100);
});
});

View file

@ -1,14 +1,22 @@
import { atom, atomFamily } from 'recoil';
import { ContentTypes } from 'librechat-data-provider';
import type {
PartMetadata,
SubagentUpdatePhase,
TMessageContentParts,
SubagentUpdateEvent,
} from 'librechat-data-provider';
import type {
SubagentAggregatorState,
SubagentContentPart,
SubagentTickerState,
} from '~/utils/subagentContent';
import {
foldSubagentEvent,
foldSubagentEventIntoTicker,
initSubagentAggregatorState,
initSubagentTickerState,
} from '~/utils/subagentContent';
/**
* Progress bucket captured per subagent tool call. Populated as
@ -19,8 +27,8 @@ import type {
* Both the panel content and the ticker are aggregated *incrementally*
* into the atom as each envelope arrives the atom never keeps the raw
* event array. A long-running subagent can emit thousands of deltas
* without the state growing past what its structural output (N text
* runs + M tool calls + a bounded tail preview) needs.
* without retaining the raw event stream. The folded activity is also
* capped by item count and encoded size to match the durable public view.
*/
export interface SubagentProgress {
/** Child run id from the SDK — unique per spawn; one tool_call may only have one. */
@ -42,8 +50,203 @@ export interface SubagentProgress {
status: SubagentUpdatePhase;
/** Convenience: last event's `label` for quick ticker display. */
latestLabel?: string;
/** Bounded replay fence for events that overlap parent and detached SSE delivery. */
recentEventKeys?: string[];
/** Highest host sequence folded for this child run. Older overlap frames are ignored. */
lastActivitySequence?: number;
/** Bounded future frames waiting for an earlier sequence at the parent/detached handoff. */
pendingSequencedEvents?: SubagentUpdateEvent[];
/** Whether the folded events cover the run from its beginning or only the
* forward-only suffix observed after opening a detached task stream. */
coverage?: 'complete' | 'suffix';
}
const MAX_RECENT_EVENT_KEYS = 256;
const MAX_PENDING_SEQUENCE_EVENTS = 100;
const MAX_PENDING_SEQUENCE_BYTES = 128 * 1024;
const MAX_LIVE_ACTIVITY_ITEMS = 100;
const MAX_LIVE_ACTIVITY_BYTES = 64 * 1024;
const MAX_SINGLE_ACTIVITY_ENCODED_BYTES = MAX_LIVE_ACTIVITY_BYTES - 2;
const MAX_SINGLE_ACTIVITY_TEXT_BYTES = 60 * 1024;
const REDACTED_REASONING_MARKER = '…';
const encodedBytes = (value: unknown): number =>
new TextEncoder().encode(JSON.stringify(value)).byteLength;
const truncateUtf8 = (value: string, maxBytes: number, keepTail = false): string => {
if (new TextEncoder().encode(value).byteLength <= maxBytes) return value;
const chars = [...value];
let low = 0;
let high = chars.length;
while (low < high) {
const mid = Math.ceil((low + high) / 2);
const candidate = keepTail ? chars.slice(-mid).join('') : chars.slice(0, mid).join('');
if (new TextEncoder().encode(candidate).byteLength <= maxBytes) low = mid;
else high = mid - 1;
}
return keepTail ? chars.slice(-low).join('') : chars.slice(0, low).join('');
};
const fitStringField = <T>(
value: string,
candidate: (bounded: string) => T,
maxBytes: number,
keepTail = false,
): T => {
const chars = [...value];
let low = 0;
let high = chars.length;
let result = candidate('');
while (low < high) {
const mid = Math.ceil((low + high) / 2);
const bounded = keepTail ? chars.slice(-mid).join('') : chars.slice(0, mid).join('');
const next = candidate(bounded);
if (encodedBytes(next) <= maxBytes) {
low = mid;
result = next;
} else {
high = mid - 1;
}
}
return result;
};
const boundSingletonPart = (part: SubagentContentPart): SubagentContentPart => {
if (part.type === ContentTypes.TEXT) {
const rawBounded = truncateUtf8(part.text, MAX_SINGLE_ACTIVITY_TEXT_BYTES, true);
return fitStringField(
rawBounded,
(text) => ({ ...part, text }),
MAX_SINGLE_ACTIVITY_ENCODED_BYTES,
true,
);
}
if (part.type === ContentTypes.THINK) {
const rawBounded = truncateUtf8(part.think, MAX_SINGLE_ACTIVITY_TEXT_BYTES, true);
return fitStringField(
rawBounded,
(think) => ({ ...part, think }),
MAX_SINGLE_ACTIVITY_ENCODED_BYTES,
true,
);
}
let bounded: SubagentContentPart = {
...part,
tool_call: {
...part.tool_call,
args: truncateUtf8(part.tool_call.args, 24 * 1024),
...(part.tool_call.output == null
? {}
: { output: truncateUtf8(part.tool_call.output, 24 * 1024, true) }),
},
};
if (encodedBytes(bounded) <= MAX_SINGLE_ACTIVITY_ENCODED_BYTES) return bounded;
const fitToolField = (field: 'output' | 'args' | 'name' | 'id' | 'type', keepTail = false) => {
if (bounded.type !== ContentTypes.TOOL_CALL) return;
const current = bounded;
const value = current.tool_call[field];
if (typeof value !== 'string') return;
bounded = fitStringField(
value,
(nextValue) => ({
...current,
tool_call: { ...current.tool_call, [field]: nextValue },
}),
MAX_SINGLE_ACTIVITY_ENCODED_BYTES,
keepTail,
) as SubagentContentPart;
};
fitToolField('output', true);
if (encodedBytes(bounded) > MAX_SINGLE_ACTIVITY_ENCODED_BYTES) fitToolField('args');
if (encodedBytes(bounded) > MAX_SINGLE_ACTIVITY_ENCODED_BYTES) fitToolField('name');
if (encodedBytes(bounded) > MAX_SINGLE_ACTIVITY_ENCODED_BYTES) fitToolField('id');
if (encodedBytes(bounded) > MAX_SINGLE_ACTIVITY_ENCODED_BYTES) fitToolField('type');
return bounded;
};
const boundContentParts = (
parts: SubagentContentPart[],
state: SubagentAggregatorState,
): { parts: SubagentContentPart[]; state: SubagentAggregatorState } => {
const start = Math.max(0, parts.length - MAX_LIVE_ACTIVITY_ITEMS);
let offset = parts.length;
let totalBytes = 2;
let bounded: SubagentContentPart[] = [];
for (let index = parts.length - 1; index >= start; index -= 1) {
const partBytes = encodedBytes(parts[index]);
const separatorBytes = bounded.length === 0 ? 0 : 1;
if (totalBytes + separatorBytes + partBytes > MAX_LIVE_ACTIVITY_BYTES) {
if (bounded.length === 0) {
bounded = [boundSingletonPart(parts[index])];
offset = index;
}
break;
}
bounded.unshift(parts[index]);
offset = index;
totalBytes += separatorBytes + partBytes;
}
if (encodedBytes(bounded) > MAX_LIVE_ACTIVITY_BYTES) {
bounded = [];
offset = parts.length;
}
const rebase = (index: number | null): number | null =>
index != null && index >= offset && index - offset < bounded.length ? index - offset : null;
const toolCallIndexById = Object.fromEntries(
bounded.flatMap((part, index) =>
part.type === ContentTypes.TOOL_CALL ? [[part.tool_call.id, index]] : [],
),
);
return {
parts: bounded,
state: {
openTextIdx: rebase(state.openTextIdx),
openThinkIdx: rebase(state.openThinkIdx),
toolCallIndexById,
},
};
};
const boundTickerState = (state: SubagentTickerState): SubagentTickerState => {
const start = Math.max(0, state.lines.length - MAX_LIVE_ACTIVITY_ITEMS);
let offset = state.lines.length;
let totalBytes = 2;
const lines = [] as SubagentTickerState['lines'];
for (let index = state.lines.length - 1; index >= start; index -= 1) {
const lineBytes = encodedBytes(state.lines[index]);
const separatorBytes = lines.length === 0 ? 0 : 1;
if (totalBytes + separatorBytes + lineBytes > MAX_LIVE_ACTIVITY_BYTES) break;
lines.unshift(state.lines[index]);
offset = index;
totalBytes += separatorBytes + lineBytes;
}
const rebase = (index: number | null): number | null =>
index != null && index >= offset ? index - offset : null;
return {
...state,
lines,
textLineIdx: rebase(state.textLineIdx),
thinkLineIdx: rebase(state.thinkLineIdx),
};
};
const hashString = (value: string): string => {
let hash = 2166136261;
for (let index = 0; index < value.length; index++) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(36);
};
const eventKey = (event: SubagentUpdateEvent): string | undefined => {
const activityEventId = event.activityEventId?.trim();
if (!activityEventId) return undefined;
return hashString(`${event.subagentRunId}\u0000${activityEventId}`);
};
/** One child invocation selected for the shared read-only activity panel. */
export type ActiveSubagentPanel = {
host: 'conversation' | 'share';
@ -82,3 +285,237 @@ export const subagentProgressByToolCallId = atomFamily<SubagentProgress | null,
key: 'subagentProgressByToolCallId',
default: null,
});
/** Parent delivery remains authoritative until its ordered SSE close boundary. */
export const subagentParentStreamOpenByToolCallId = atomFamily<boolean, string>({
key: 'subagentParentStreamOpenByToolCallId',
default: false,
});
/**
* Invocation atoms populated by either the parent generation stream or the selected detached
* task stream. The conversation host drains this registry on navigation so both transports share
* one cleanup boundary instead of leaking detached-only atom-family members for the app lifetime.
*/
const registeredSubagentProgressKeys = new Set<string>();
export function registerSubagentProgressKey(key: string): void {
registeredSubagentProgressKeys.add(key);
}
export function takeRegisteredSubagentProgressKeys(): string[] {
const keys = [...registeredSubagentProgressKeys];
registeredSubagentProgressKeys.clear();
return keys;
}
export function listRegisteredSubagentProgressKeys(): string[] {
return [...registeredSubagentProgressKeys];
}
const validActivitySequence = (value: number | undefined): value is number =>
Number.isSafeInteger(value) && value != null && value >= 0;
const foldAcceptedSubagentEvents = (
previous: SubagentProgress | null,
events: SubagentUpdateEvent[],
source: 'parent' | 'detached',
pendingSequencedEvents: SubagentUpdateEvent[],
): SubagentProgress | null => {
if (events.length === 0) {
if (previous == null) {
const first = pendingSequencedEvents[0];
if (first == null) return null;
return {
subagentRunId: first.subagentRunId,
subagentType: first.subagentType,
subagentAgentId: first.subagentAgentId,
contentParts: [],
aggregatorState: initSubagentAggregatorState(),
tickerState: initSubagentTickerState(),
status: first.phase,
recentEventKeys: [],
pendingSequencedEvents,
coverage: source === 'detached' ? 'suffix' : 'complete',
};
}
if (
(previous.pendingSequencedEvents == null && pendingSequencedEvents.length === 0) ||
(previous.pendingSequencedEvents?.length === pendingSequencedEvents.length &&
previous.pendingSequencedEvents.every(
(event, index) => event === pendingSequencedEvents[index],
))
) {
return previous;
}
return {
...previous,
...(pendingSequencedEvents.length === 0 ? {} : { pendingSequencedEvents }),
};
}
const recentEventKeys = [...(previous?.recentEventKeys ?? [])];
for (const event of events) {
const key = eventKey(event);
if (key != null) recentEventKeys.push(key);
}
const boundedEventKeys = recentEventKeys.slice(-MAX_RECENT_EVENT_KEYS);
let contentParts = previous?.contentParts ?? [];
let aggregatorState = previous?.aggregatorState ?? initSubagentAggregatorState();
let tickerState = previous?.tickerState ?? initSubagentTickerState();
for (const event of events) {
const foldEvent =
event.phase === 'reasoning_delta' &&
event.data == null &&
aggregatorState.openThinkIdx == null
? {
...event,
data: {
delta: {
content: [{ type: ContentTypes.THINK, think: REDACTED_REASONING_MARKER }],
},
},
}
: event;
({ parts: contentParts, state: aggregatorState } = foldSubagentEvent(
contentParts,
aggregatorState,
foldEvent,
));
tickerState = foldSubagentEventIntoTicker(tickerState, foldEvent);
}
({ parts: contentParts, state: aggregatorState } = boundContentParts(
contentParts,
aggregatorState,
));
tickerState = boundTickerState(tickerState);
const last = events[events.length - 1];
const lastActivitySequence = [...events]
.reverse()
.map((event) => event.activitySequence)
.find(validActivitySequence);
const effectiveActivitySequence = lastActivitySequence ?? previous?.lastActivitySequence;
const acceptedRunStart = events.some((event) => event.activitySequence === 0);
return {
subagentRunId: last.subagentRunId,
subagentType: last.subagentType,
subagentAgentId: last.subagentAgentId ?? previous?.subagentAgentId,
contentParts,
aggregatorState,
tickerState,
status: last.phase,
latestLabel: last.label ?? previous?.latestLabel,
recentEventKeys: boundedEventKeys,
...(effectiveActivitySequence == null
? {}
: { lastActivitySequence: effectiveActivitySequence }),
...(pendingSequencedEvents.length === 0 ? {} : { pendingSequencedEvents }),
coverage: acceptedRunStart
? 'complete'
: (previous?.coverage ?? (source === 'detached' ? 'suffix' : 'complete')),
};
};
/** Parent SSE close is an ordering fence: all its earlier frames have already been handled. */
export function closeParentSubagentProgress(
previous: SubagentProgress | null,
): SubagentProgress | null {
if (previous?.pendingSequencedEvents == null || previous.pendingSequencedEvents.length === 0) {
return previous;
}
const pending = [...previous.pendingSequencedEvents].sort(
(left, right) => (left.activitySequence ?? 0) - (right.activitySequence ?? 0),
);
return foldAcceptedSubagentEvents(
{ ...previous, pendingSequencedEvents: undefined },
pending,
previous.coverage === 'suffix' ? 'detached' : 'parent',
[],
);
}
/** Shared reducer for foreground chat SSE and task-scoped detached activity SSE. */
export function reduceSubagentProgress(
previous: SubagentProgress | null,
events: SubagentUpdateEvent[],
source: 'parent' | 'detached' = 'parent',
waitForEarlierSequences = source === 'parent',
): SubagentProgress | null {
if (events.length === 0) return previous;
const recentEventKeys = [...(previous?.recentEventKeys ?? [])];
const seen = new Set(recentEventKeys);
const sequenced = events.every(
(event) => Number.isSafeInteger(event.activitySequence) && (event.activitySequence ?? -1) >= 0,
);
const orderedEvents = sequenced
? [...events].sort((left, right) =>
(left.activitySequence ?? 0) === (right.activitySequence ?? 0)
? 0
: (left.activitySequence ?? 0) - (right.activitySequence ?? 0),
)
: events;
const sameRun = previous?.subagentRunId === orderedEvents[0]?.subagentRunId;
const lastActivitySequence = sameRun ? previous.lastActivitySequence : undefined;
const pending = sameRun ? [...(previous.pendingSequencedEvents ?? [])] : [];
const pendingSequences = new Set(
pending.map((event) => event.activitySequence).filter(validActivitySequence),
);
const directEvents: SubagentUpdateEvent[] = [];
let expected = lastActivitySequence == null ? 0 : lastActivitySequence + 1;
if (!waitForEarlierSequences && lastActivitySequence == null) {
const firstSequence = [...pending, ...orderedEvents]
.map((event) => event.activitySequence)
.filter(validActivitySequence)
.sort((left, right) => left - right)[0];
if (firstSequence != null) expected = firstSequence;
}
const sanitizeSequencedEvent = (event: SubagentUpdateEvent): SubagentUpdateEvent =>
source === 'detached' && event.phase === 'reasoning_delta'
? { ...event, data: undefined }
: event;
const drainPending = () => {
pending.sort((left, right) => (left.activitySequence ?? 0) - (right.activitySequence ?? 0));
while (pending[0]?.activitySequence === expected) {
const event = pending.shift();
if (event == null) break;
pendingSequences.delete(expected);
directEvents.push(event);
expected += 1;
}
};
drainPending();
for (const event of orderedEvents) {
const sequence = event.activitySequence;
const key = eventKey(event);
if (key != null && seen.has(key)) continue;
if (validActivitySequence(sequence)) {
if (sequence < expected || pendingSequences.has(sequence)) continue;
const pendingEvent = sanitizeSequencedEvent(event);
if (sequence === expected) {
directEvents.push(pendingEvent);
expected += 1;
drainPending();
} else if (
pending.length < MAX_PENDING_SEQUENCE_EVENTS &&
encodedBytes([...pending, pendingEvent]) <= MAX_PENDING_SEQUENCE_BYTES
) {
pending.push(pendingEvent);
pendingSequences.add(sequence);
}
} else {
if (key != null) seen.add(key);
directEvents.push(event);
}
}
drainPending();
if (
!waitForEarlierSequences &&
pending[0]?.activitySequence != null &&
pending[0].activitySequence > expected
) {
expected = pending[0].activitySequence;
drainPending();
}
return foldAcceptedSubagentEvents(previous, directEvents, source, pending);
}

View file

@ -269,6 +269,25 @@ describe('buildSubagentTickerLines', () => {
expect(lines[0]).toEqual({ kind: 'writing', body: 'Hello world' });
});
it('retains meaningful text across a whitespace-heavy stream', () => {
const lines = buildSubagentTickerLines([
makeEvent({
phase: 'message_delta',
data: { delta: { content: [{ type: 'text', text: 'Visible' }] } },
}),
makeEvent({
phase: 'message_delta',
data: { delta: { content: [{ type: 'text', text: ' \n'.repeat(1200) }] } },
}),
makeEvent({
phase: 'message_delta',
data: { delta: { content: [{ type: 'text', text: 'again' }] } },
}),
]);
expect(lines[0]).toEqual({ kind: 'writing', body: 'Visible again' });
});
it('truncates the writing body to the tail when it grows past the cap', () => {
const longText = 'x'.repeat(1000);
const lines = buildSubagentTickerLines([

View file

@ -294,8 +294,8 @@ export interface SubagentTickerState {
textLineIdx: number | null;
/** Index of the in-flight 'reasoning' line. */
thinkLineIdx: number | null;
/** Raw message-delta accumulator truncated into `writing.body` but
* preserved so subsequent deltas extend the running preview. */
/** Whitespace-normalized message-delta accumulator. A trailing separator is
* retained so chunk boundaries still render as one word boundary. */
textBuffer: string;
thinkBuffer: string;
}
@ -317,12 +317,20 @@ export function initSubagentTickerState(): SubagentTickerState {
* CSS ellipsis double-eliding would render a stray dot character
* right next to the "Writing:" / "Reasoning:" label. */
const PREVIEW_MAX_CHARS = 300;
const PREVIEW_BUFFER_MAX_CHARS = PREVIEW_MAX_CHARS * 4;
const truncatePreview = (input: string): string => {
const normalized = input.replace(/\s+/g, ' ').trim();
if (normalized.length <= PREVIEW_MAX_CHARS) return normalized;
return normalized.slice(-PREVIEW_MAX_CHARS);
};
const appendPreviewBuffer = (buffer: string, chunk: string): string => {
const normalized = `${buffer}${chunk}`.replace(/\s+/g, ' ').trimStart();
return normalized.length <= PREVIEW_BUFFER_MAX_CHARS
? normalized
: normalized.slice(-PREVIEW_BUFFER_MAX_CHARS);
};
const SNIPPET_MAX_CHARS = 48;
/** Short head-truncation for tool args/output caller labels what each
* side is. Whitespace collapsed so multi-line outputs stay one line. */
@ -396,7 +404,7 @@ export function foldSubagentEventIntoTicker(
state.thinkLineIdx != null || state.thinkBuffer
? { ...state, thinkLineIdx: null, thinkBuffer: '' }
: state;
const textBuffer = afterClose.textBuffer + chunk;
const textBuffer = appendPreviewBuffer(afterClose.textBuffer, chunk);
const body = truncatePreview(textBuffer);
const line: SubagentTickerLine = { kind: 'writing', body };
if (afterClose.textLineIdx == null) {
@ -416,7 +424,7 @@ export function foldSubagentEventIntoTicker(
state.textLineIdx != null || state.textBuffer
? { ...state, textLineIdx: null, textBuffer: '' }
: state;
const thinkBuffer = afterClose.thinkBuffer + chunk;
const thinkBuffer = appendPreviewBuffer(afterClose.thinkBuffer, chunk);
const body = truncatePreview(thinkBuffer);
const line: SubagentTickerLine = { kind: 'reasoning', body };
if (afterClose.thinkLineIdx == null) {
@ -447,7 +455,7 @@ export function foldSubagentEventIntoTicker(
typeof tc?.name === 'string' && tc.name.length > 0,
);
if (named.length === 0) return afterClose;
const toolNames = named.map((tc) => tc.name);
const toolNames = named.slice(0, 16).map((tc) => truncateSnippet(tc.name));
const argsSnippet = named.length === 1 ? summarizeArgs(named[0].args) : undefined;
const line: SubagentTickerLine = {
kind: 'using_tool',
@ -464,7 +472,7 @@ export function foldSubagentEventIntoTicker(
const outputSnippet = tc.output != null ? summarizeOutput(tc.output) : undefined;
const line: SubagentTickerLine = {
kind: 'tool_complete',
toolName: tc.name,
toolName: truncateSnippet(tc.name),
...(outputSnippet ? { outputSnippet } : {}),
};
return { ...state, lines: state.lines.concat(line) };
@ -474,7 +482,7 @@ export function foldSubagentEventIntoTicker(
const data = event.data as ErrorData | undefined;
const line: SubagentTickerLine = {
kind: 'error',
...(data?.message ? { message: data.message } : {}),
...(data?.message ? { message: truncatePreview(data.message) } : {}),
};
return { ...state, lines: state.lines.concat(line) };
}