Skip to content

Commit 03a42d6

Browse files
authored
🎒 fix: Carry Subagent Identity Past Streaming (#15795)
* fix: resolve saved subagent display identity * fix: persist explicit subagent execution identity
1 parent 6965f8e commit 03a42d6

17 files changed

Lines changed: 272 additions & 14 deletions

File tree

api/server/controllers/agents/callbacks.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const {
2929
getModelRefusalInfo,
3030
shouldSignalSandboxStart,
3131
getToolInputValidationDetails,
32+
captureSubagentIdentity,
3233
} = require('@librechat/api');
3334
const { processFileCitations } = require('~/server/services/Files/Citations');
3435
const { processCodeOutput, runPreviewFinalize } = require('~/server/services/Files/Code/process');
@@ -764,6 +765,7 @@ function getDefaultHandlers({
764765
subagentAggregatorsByToolCallId.set(key, aggregator);
765766
}
766767
try {
768+
captureSubagentIdentity(aggregator, data);
767769
feedSubagentAggregator(aggregator, data);
768770
} catch (err) {
769771
logger.warn(

api/server/controllers/agents/client.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,9 @@ class AgentClient extends BaseClient {
818818
const aggregator = buffer.get(toolCall.id);
819819
if (!aggregator) continue;
820820
try {
821+
if (aggregator.subagentIdentity != null) {
822+
toolCall.subagentIdentity = aggregator.subagentIdentity;
823+
}
821824
/** `createContentAggregator` returns a sparse array (undefined
822825
* slots for indices that never received content). Strip those
823826
* so the persisted shape is a clean `TMessageContentParts[]`. */

api/server/controllers/agents/client.test.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8016,6 +8016,29 @@ describe('AgentClient - finalizeSubagentContent', () => {
80168016
return map;
80178017
};
80188018

8019+
it.each(['agent', 'graph'])(
8020+
'persists %s identity even when the child has no content',
8021+
async (subagentKind) => {
8022+
const identity = {
8023+
subagentKind,
8024+
subagentAgentId: subagentKind === 'graph' ? 'graph:agent-1' : 'agent-1',
8025+
};
8026+
const buffer = await runSubagentEvents([
8027+
{ ...event('start', undefined), ...identity, subagentType: 'agent-1' },
8028+
{ ...event('error', undefined), ...identity, subagentType: 'agent-1' },
8029+
]);
8030+
const client = makeClient(buffer);
8031+
client.contentParts = [
8032+
{ type: 'tool_call', tool_call: { id: 'unrelated', name: Constants.SUBAGENT } },
8033+
{ type: 'tool_call', tool_call: { id: 'call_sub', name: Constants.SUBAGENT } },
8034+
];
8035+
client.finalizeSubagentContent();
8036+
expect(client.contentParts[0].tool_call.subagentIdentity).toBeUndefined();
8037+
expect(client.contentParts[1].tool_call.subagentIdentity).toEqual(identity);
8038+
expect(buffer.size).toBe(0);
8039+
},
8040+
);
8041+
80198042
it('attaches aggregated subagent_content to the matching subagent tool_call part', async () => {
80208043
const buffer = await runSubagentEvents([
80218044
event('run_step', {

client/src/components/Chat/Messages/Content/Part.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@ const Part = memo(function Part({
306306
runStepStatus={toolCall.runStepStatus}
307307
attachments={attachments}
308308
persistedContent={persistedContent}
309+
subagentIdentity={toolCall.subagentIdentity}
309310
hideAttachments={hideAttachments}
310311
/>
311312
);

client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
useSubagentProgress,
1616
} from '~/components/Chat/Subagents/state';
1717
import { adaptLivePersistedActivity } from '~/components/Chat/Subagents/adapters';
18+
import { resolveSubagentAgentId } from '~/components/Chat/Subagents/identity';
1819
import { useOpenSubagentPanel } from '~/components/Chat/Subagents/surface';
1920
import { MessageContext } from '~/Providers/MessageContext';
2021
import { useShareContext } from '~/Providers/ShareContext';
@@ -46,6 +47,7 @@ interface SubagentCallProps {
4647
* runs recorded before the persistence path landed will not have this
4748
* field; those fall back to the atom (or the raw `output` string). */
4849
persistedContent?: TMessageContentParts[];
50+
subagentIdentity?: PartMetadata['subagentIdentity'];
4951
hideAttachments?: boolean;
5052
}
5153

@@ -166,6 +168,7 @@ export default function SubagentCall({
166168
output,
167169
attachments,
168170
persistedContent,
171+
subagentIdentity,
169172
hideAttachments = false,
170173
}: SubagentCallProps) {
171174
const localize = useLocalize();
@@ -187,12 +190,7 @@ export default function SubagentCall({
187190

188191
const subagentType = progress?.subagentType ?? extractSubagentType(args);
189192
const isSelfSpawn = subagentType === 'self';
190-
/** Avatar lookup for the header icon. We use the child's agent id when
191-
* present (explicit subagents); self-spawn falls back to the agents
192-
* map being unavailable → the Users SVG. The tool UI has a similar
193-
* icon-left-of-label pattern; this reuses `MessageIcon` so the agent's
194-
* configured avatar lands here without a separate image pipeline. */
195-
const subagentAgentId = progress?.subagentAgentId;
193+
const subagentAgentId = resolveSubagentAgentId(progress, subagentIdentity);
196194
const subagentAgent = subagentAgentId ? agentsMap?.[subagentAgentId] : undefined;
197195
/**
198196
* Tri-state status resolution, aligned with `ToolCall.tsx`:
@@ -326,6 +324,7 @@ export default function SubagentCall({
326324
toolCallId,
327325
partIndex,
328326
subagentType,
327+
subagentIdentity,
329328
...(prompt == null ? {} : { prompt }),
330329
...(backgroundHandle == null ? { legacyOutput: output } : {}),
331330
...(persistedContent == null ? {} : { persistedContent }),
@@ -356,6 +355,7 @@ export default function SubagentCall({
356355
runStepStatus,
357356
shareId,
358357
subagentType,
358+
subagentIdentity,
359359
toolCallId,
360360
],
361361
);

client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { RecoilRoot } from 'recoil';
33
import { useAtomValue, useStore } from 'jotai';
44
import { MemoryRouter } from 'react-router-dom';
55
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
6-
import type { SubagentUpdateEvent } from 'librechat-data-provider';
6+
import type { SubagentUpdateEvent, SubagentIdentity } from 'librechat-data-provider';
77
import type {
88
SubagentAggregatorState,
99
SubagentContentPart,
@@ -61,8 +61,17 @@ jest.mock('lucide-react', () => ({
6161
Users: () => <span>users</span>,
6262
}));
6363

64-
jest.mock('~/Providers', () => ({ useAgentsMapContext: () => ({}) }));
65-
jest.mock('~/components/Share/MessageIcon', () => ({ __esModule: true, default: () => null }));
64+
jest.mock('~/Providers', () => ({
65+
useAgentsMapContext: () => ({
66+
'agent-1': { id: 'agent-1', name: 'Analyst One', avatar: { filepath: '/analyst.png' } },
67+
}),
68+
}));
69+
jest.mock('~/components/Share/MessageIcon', () => ({
70+
__esModule: true,
71+
default: ({ agent }: { agent: { name: string; avatar: { filepath: string } } }) => (
72+
<img alt={agent.name} src={agent.avatar.filepath} />
73+
),
74+
}));
6675
jest.mock('~/hooks/MCP', () => ({ useMCPServerNames: () => mockMCPServerNames }));
6776
jest.mock('~/utils', () => ({
6877
...jest.requireActual('~/utils/toolLabels'),
@@ -111,6 +120,7 @@ function renderWithState(args: {
111120
progress?: SubagentProgress | null;
112121
output?: string;
113122
toolArgs?: Record<string, unknown>;
123+
subagentIdentity?: SubagentIdentity;
114124
}) {
115125
const setter = { current: null as null | ((next: SubagentProgress | null) => void) };
116126
let selection: ActiveSubagentPanel | null = null;
@@ -146,6 +156,7 @@ function renderWithState(args: {
146156
isSubmitting={args.isSubmitting ?? false}
147157
args={args.toolArgs ?? { subagent_type: 'self', description: 'compute' }}
148158
output={args.output}
159+
subagentIdentity={args.subagentIdentity}
149160
/>
150161
</MessageContext.Provider>
151162
</RecoilRoot>
@@ -174,6 +185,58 @@ const event = (
174185
});
175186

176187
describe('SubagentCall', () => {
188+
it('keeps the configured name and avatar after live progress is cleared', () => {
189+
const { setProgress } = renderWithState({
190+
toolCallId: 'identity',
191+
initialProgress: 1,
192+
toolArgs: { subagent_type: 'agent-1' },
193+
subagentIdentity: { subagentKind: 'agent', subagentAgentId: 'agent-1' },
194+
progress: progressFromEvents({
195+
subagentRunId: 'child-run',
196+
subagentType: 'agent-1',
197+
subagentAgentId: 'agent-1',
198+
status: 'stop',
199+
events: [],
200+
}),
201+
});
202+
expect(screen.getByText('Analyst One')).toBeInTheDocument();
203+
expect(screen.getByRole('img', { hidden: true })).toHaveAttribute('src', '/analyst.png');
204+
setProgress(null);
205+
expect(screen.getByText('Analyst One')).toBeInTheDocument();
206+
expect(screen.getByRole('img', { hidden: true })).toHaveAttribute('src', '/analyst.png');
207+
});
208+
209+
it.each([undefined, { subagentKind: 'graph' as const, subagentAgentId: 'graph:agent-1' }])(
210+
'does not infer a saved agent from an ambiguous graph or legacy type',
211+
(subagentIdentity) => {
212+
const { getSelection } = renderWithState({
213+
toolCallId: 'graph-identity',
214+
initialProgress: 1,
215+
toolArgs: { subagent_type: 'agent-1' },
216+
output: 'Graph result',
217+
subagentIdentity,
218+
});
219+
expect(screen.queryByText('Analyst One')).not.toBeInTheDocument();
220+
expect(screen.getByText('users')).toBeInTheDocument();
221+
fireEvent.click(screen.getByRole('button', { name: 'Ran agent' }));
222+
expect(getSelection()?.subagentIdentity).toEqual(subagentIdentity);
223+
},
224+
);
225+
226+
it.each(['agent-1', 'missing-agent', 'self'])(
227+
'renders saved identity %s without streaming state',
228+
(agentId) => {
229+
renderWithState({
230+
toolCallId: 'saved-identity',
231+
initialProgress: 1,
232+
toolArgs: { subagent_type: agentId },
233+
subagentIdentity: { subagentKind: 'agent', subagentAgentId: agentId },
234+
});
235+
expect(screen.queryByText('Analyst One') != null).toBe(agentId === 'agent-1');
236+
expect(screen.queryByText('users') != null).toBe(agentId !== 'agent-1');
237+
},
238+
);
239+
177240
it.each([
178241
['Running agent', 0.3, true, 'run_step'],
179242
['Ran agent', 1, false, undefined],

client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,14 @@ jest.mock('~/hooks', () => ({
106106
* runs before the panel's own React handler. Stubbing it made the panel's
107107
* Escape assertions pass in both directions. */
108108
useFocusTrap: jest.requireActual('~/hooks/useFocusTrap').default,
109-
useLocalize: () => (key: string) => key,
109+
useLocalize: () => (key: string, values?: { 0: string }) =>
110+
values == null ? key : `${key}: ${values[0]}`,
110111
useNavigateToConvo: () => ({ navigateToConvo: mockNavigateToConvo }),
111112
}));
112113

113114
jest.mock('~/Providers', () => ({
114115
useAgentsMapContext: () => ({
115-
'agent-1': { id: 'agent-1', name: 'Analyst One' },
116+
'agent-1': { id: 'agent-1', name: 'Analyst One', avatar: { filepath: '/analyst.png' } },
116117
'agent-2': { id: 'agent-2', name: 'Analyst Two' },
117118
}),
118119
}));
@@ -468,6 +469,55 @@ describe('SubagentThreadPanel', () => {
468469
jest.restoreAllMocks();
469470
});
470471

472+
it.each(['agent-1', 'missing-agent', 'self'])(
473+
'resolves the saved foreground identity %s in the panel',
474+
(subagentType) => {
475+
mockUseSubagentThreadQuery.mockReturnValue({ isLoading: false, isError: false });
476+
const foregroundSelection = {
477+
...selection,
478+
durable: undefined,
479+
subagentType,
480+
subagentIdentity: { subagentKind: 'agent' as const, subagentAgentId: subagentType },
481+
};
482+
render(
483+
<Root>
484+
<SubagentThreadPanel selection={foregroundSelection} />
485+
</Root>,
486+
);
487+
const title =
488+
subagentType === 'self'
489+
? 'com_ui_subagent_dialog_title_self'
490+
: `com_ui_subagent_dialog_title: ${subagentType === 'agent-1' ? 'Analyst One' : subagentType}`;
491+
expect(screen.getByRole('heading', { name: title })).toBeInTheDocument();
492+
if (subagentType === 'agent-1') {
493+
expect(screen.getByAltText('Analyst One avatar')).toHaveAttribute('src', '/analyst.png');
494+
}
495+
},
496+
);
497+
498+
it.each([undefined, { subagentKind: 'graph' as const, subagentAgentId: 'graph:agent-1' }])(
499+
'does not resolve graph or legacy panel types as saved agents',
500+
(subagentIdentity) => {
501+
mockUseSubagentThreadQuery.mockReturnValue({ isLoading: false, isError: false });
502+
render(
503+
<Root>
504+
<SubagentThreadPanel
505+
selection={{
506+
...selection,
507+
durable: undefined,
508+
subagentType: 'agent-1',
509+
subagentIdentity,
510+
}}
511+
/>
512+
</Root>,
513+
);
514+
expect(
515+
screen.getByRole('heading', { name: 'com_ui_subagent_dialog_title: agent-1' }),
516+
).toBeInTheDocument();
517+
expect(screen.queryByAltText('Analyst One avatar')).not.toBeInTheDocument();
518+
},
519+
);
520+
471521
it('renders a bounded read-only activity timeline and closes its selection', async () => {
472522
mockUseSubagentThreadQuery.mockReturnValue({
473523
data: completedView,

client/src/components/Chat/Subagents/SubagentThreadPanel.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { useFocusTrap, useLocalize, useNavigateToConvo } from '~/hooks';
5151
import { useParentSubagents } from './ParentSubagentsProvider';
5252
import SubagentConversation from './SubagentConversation';
5353
import { eventSubagentSelection } from './eventSelection';
54+
import { resolveSubagentAgentId } from './identity';
5455
import { useAgentsMapContext } from '~/Providers';
5556
import { isLiveSubagentStatus } from './status';
5657
import { renderAgentAvatar } from '~/utils';
@@ -144,10 +145,14 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
144145
selection.partIndex,
145146
),
146147
);
148+
const foregroundAgentId = resolveSubagentAgentId(progress, selection.subagentIdentity);
149+
const foregroundAgent = foregroundAgentId == null ? undefined : agentsMap?.[foregroundAgentId];
147150
const foregroundTitle =
148151
selection.subagentType === 'self'
149152
? localize('com_ui_subagent_dialog_title_self')
150-
: localize('com_ui_subagent_dialog_title', { 0: selection.subagentType });
153+
: localize('com_ui_subagent_dialog_title', {
154+
0: foregroundAgent?.name || selection.subagentType,
155+
});
151156
const threadId = selection.durable?.threadId ?? '';
152157
const taskId = selection.durable?.taskId ?? '';
153158
const controlIdentity = subagentControlStateKey(selection.parentConversationId, threadId, taskId);
@@ -838,7 +843,8 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
838843
}, [agentsMap, eventSiblings, selection.event, threadId]);
839844
const selectedActorLabel =
840845
actorOptions.find((option) => option.value === threadId)?.label ?? panelTitle;
841-
const selectedActorAgentId = selectedEventActor?.agentId ?? threadView?.agentId;
846+
const selectedActorAgentId =
847+
selectedEventActor?.agentId ?? threadView?.agentId ?? foregroundAgentId;
842848
const selectedActorIcon = renderAgentAvatar(
843849
selectedActorAgentId == null ? undefined : agentsMap?.[selectedActorAgentId],
844850
{ size: 'icon', showBorder: false },
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { resolveSubagentAgentId } from './identity';
2+
3+
describe('resolveSubagentAgentId', () => {
4+
const agent = { subagentKind: 'agent' as const, subagentAgentId: 'agent-1' };
5+
const graph = { subagentKind: 'graph' as const, subagentAgentId: 'agent-1' };
6+
it('prefers live identity and falls back to explicit saved identity', () => {
7+
expect(resolveSubagentAgentId({ ...agent, subagentAgentId: 'agent-2' }, agent)).toBe('agent-2');
8+
expect(resolveSubagentAgentId(null, agent)).toBe('agent-1');
9+
expect(resolveSubagentAgentId(null, undefined)).toBeUndefined();
10+
});
11+
it('never resolves a graph as a saved agent even when their IDs collide', () => {
12+
expect(resolveSubagentAgentId(graph, agent)).toBeUndefined();
13+
expect(resolveSubagentAgentId(null, graph)).toBeUndefined();
14+
});
15+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { SubagentIdentity } from 'librechat-data-provider';
2+
3+
export function resolveSubagentAgentId(
4+
progress: Partial<SubagentIdentity> | null | undefined,
5+
persisted: SubagentIdentity | undefined,
6+
): string | undefined {
7+
if (progress?.subagentKind === 'graph') return undefined;
8+
if (progress?.subagentAgentId) return progress.subagentAgentId;
9+
return persisted?.subagentKind === 'agent' ? persisted.subagentAgentId : undefined;
10+
}

0 commit comments

Comments
 (0)