Fix session panel agent grouping

This commit is contained in:
Douglas 2026-08-13 12:08:02 +01:00
parent e28f5f5f67
commit 0572e974aa
3 changed files with 158 additions and 13 deletions

View file

@ -105,12 +105,14 @@ function SectionList({ children }: { children: ReactNode }) {
);
}
function AgentsSection({
function AgentCategorySection({
title,
items,
scope,
headerAction,
onSelect,
}: {
title: string;
items: SessionAgentItem[];
scope: SessionPanelScope;
headerAction?: ReactNode;
@ -134,9 +136,11 @@ function AgentsSection({
onClick={() => onSelect(item)}
>
{item.name ||
t('layout.session-panel-remote-subagent', {
defaultValue: 'Remote subagent',
})}
(item.subagent
? t('layout.session-panel-remote-subagent', {
defaultValue: 'Remote subagent',
})
: t('agents.agent', { defaultValue: 'Agent' }))}
</SidePanelListRow>
)}
/>
@ -144,7 +148,7 @@ function AgentsSection({
return (
<SidePanelAccordionBox
title={t('layout.agents')}
title={title}
titleSuffix={<CountPill count={primary.length} />}
headerAction={headerAction}
defaultOpen={false}
@ -451,6 +455,14 @@ export function SessionActivityPanel({
() => buildProjectSessionPanelData(scopedRuns, skills, connectors),
[connectors, scopedRuns, skills]
);
const agents = useMemo(
() => panelData.agents.filter((agent) => !agent.subagent),
[panelData.agents]
);
const subagents = useMemo(
() => panelData.agents.filter((agent) => agent.subagent),
[panelData.agents]
);
const projectFiles = useProjectOutputFiles(
projectId,
activeTask,
@ -562,14 +574,25 @@ export function SessionActivityPanel({
<div className="relative flex min-h-0 w-full min-w-0 flex-col overflow-hidden">
<div className="scrollbar-always-visible flex min-h-0 min-w-0 flex-col overflow-y-auto overflow-x-hidden">
<SectionList>
{panelData.agents.length > 0 ? (
<AgentsSection
items={panelData.agents}
{agents.length > 0 ? (
<AgentCategorySection
title={t('layout.agents')}
items={agents}
scope={scope}
headerAction={agentHeaderAction}
onSelect={setSelectedAgent}
/>
) : null}
{subagents.length > 0 ? (
<AgentCategorySection
title={t('agents.sub-agents', {
defaultValue: 'Sub Agents',
})}
items={subagents}
scope={scope}
onSelect={setSelectedAgent}
/>
) : null}
{panelData.progress.length > 0 ? (
<ProgressSection
items={panelData.progress}

View file

@ -247,6 +247,8 @@ function collectAgents(
calls: SessionToolCall[]
): SessionAgentItem[] {
const agents = new Map<string, SessionAgentItem>();
const isInternalAgent = (name: string) =>
normalizeContextKey(name) === 'questionconfirmagent';
const put = (
run: ProjectSessionRun,
identity: string,
@ -284,19 +286,24 @@ function collectAgents(
for (const node of run.nodes) {
if (node.kind !== 'activity') continue;
if (node.activityType === 'agent') {
const text = `${node.eventType} ${node.title}`.toLowerCase();
const name = node.agentName || node.title;
if (isInternalAgent(name)) continue;
const text = `${node.eventType} ${node.title} ${name}`.toLowerCase();
const subagent = /sub.?agent|remote/.test(text);
put(
run,
node.agentId || node.agentName || node.title,
node.agentName || node.title,
// Agent UUIDs are process instances and change on every Run. The
// stable semantic name identifies the logical agent in the panel.
name || node.agentId || 'agent',
name,
node.detail || '',
subagent
);
} else if (node.agentId || node.agentName) {
if (isInternalAgent(node.agentName || '')) continue;
put(
run,
node.agentId || node.agentName || 'agent',
node.agentName || node.agentId || 'agent',
node.agentName || '',
'',
false
@ -310,7 +317,11 @@ function collectAgents(
if (!run) continue;
const callText = `${call.toolkitName} ${call.method}`.toLowerCase();
const subagent = /sub.?agent|remote/.test(callText);
const identity = call.agentName || (subagent ? 'remote-subagent' : 'agent');
// Typed tool lifecycle events may not carry agent identity. They are not
// independent agents and must not create a phantom "Remote subagent" row.
if (!call.agentName && !subagent) continue;
if (isInternalAgent(call.agentName)) continue;
const identity = call.agentName || 'remote-subagent';
put(run, identity, call.agentName, '', subagent);
const key = `${subagent ? 'subagent' : 'agent'}:${normalizeContextKey(
identity

View file

@ -67,6 +67,30 @@ function toolNode(
};
}
function agentNode(
runId: string,
eventId: string,
sequence: number,
agentId: string,
agentName: string,
eventType = 'legacy.create_agent',
title = agentName
): ChatActivityNode {
return {
...baseNode(runId, eventId, sequence),
eventType,
legacyStep: eventType.startsWith('legacy.')
? eventType.replace('legacy.', '')
: null,
kind: 'activity',
activityType: 'agent',
status: 'running',
title,
agentId,
agentName,
};
}
function todoActivity(
eventId: string,
sequence: number,
@ -119,6 +143,93 @@ function makeRun(
}
describe('buildProjectSessionPanelData', () => {
it('deduplicates logical agents across Runs and skips anonymous tool frames', () => {
const oldRun = makeRun('run-old', false, [
agentNode(
'run-old',
'question-confirm',
1,
'confirm-agent-id',
'question_confirm_agent'
),
agentNode(
'run-old',
'single-agent-old',
2,
'single-agent-instance-a',
'single_agent'
),
]);
const currentRun = makeRun('run-current', true, [
agentNode(
'run-current',
'single-agent-current',
1,
'single-agent-instance-b',
'single_agent'
),
{
...toolNode(
'run-current',
'named-tool-frame',
2,
'running',
'Registering agent'
),
agentId: undefined,
agentName: 'single_agent',
},
{
...toolNode(
'run-current',
'anonymous-canonical-tool',
3,
'running',
'Tool without agent identity'
),
agentId: undefined,
agentName: undefined,
},
]);
const data = buildProjectSessionPanelData([oldRun, currentRun], []);
expect(data.agents).toMatchObject([
{
id: 'agent:singleagent',
name: 'single_agent',
historical: false,
subagent: false,
},
]);
});
it('classifies remote delegated agents separately from primary agents', () => {
const run = makeRun('run-current', true, [
agentNode(
'run-current',
'primary-agent',
1,
'primary-instance',
'single_agent'
),
agentNode(
'run-current',
'remote-agent',
2,
'remote-instance',
'research_helper',
'agent.remote_started',
'Remote subagent research_helper'
),
]);
expect(buildProjectSessionPanelData([run], []).agents).toMatchObject([
{ name: 'single_agent', type: 'agent', subagent: false },
{ name: 'research_helper', type: 'subagent', subagent: true },
]);
});
it('pairs semantic tool lifecycle events by durable call id', () => {
const run = makeRun('run-1', true, [
toolNode('run-1', 'tool-start', 1, 'running', 'Searching', 'call-1'),