eigent/test/unit/components/buildProjectSessionPanelData.test.ts
Douglas Lai b439d5ff23
Feat/refactor session side panel (#1853)
Co-authored-by: 4pmtong <web_chentong@163.com>
2026-08-19 01:28:23 +08:00

895 lines
24 KiB
TypeScript

// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import {
buildProjectSessionPanelData,
collectSessionToolCalls,
extractHttpUrls,
mergeProjectFiles,
} from '@/components/Session/SidePanel/sections/buildProjectSessionPanelData';
import type { ProjectSessionRun } from '@/hooks/useProjectSessionOverview';
import { normalizeLegacyChatStep } from '@/lib/projector';
import {
adaptChatProjectionEvent,
type ChatActivityNode,
type ChatArtifactNode,
type ChatPlanNode,
type ChatProjectionNode,
} from '@/lib/projector/chat';
import { describe, expect, it } from 'vitest';
function baseNode(
runId: string,
eventId: string,
runSequence: number
): Omit<ChatProjectionNode, 'kind'> {
return {
id: eventId,
eventId,
projectId: 'project-1',
runId,
createdAt: new Date(runSequence * 1_000).toISOString(),
runSequence,
cloudCursor: null,
eventType: 'test.event',
legacyStep: null,
} as Omit<ChatProjectionNode, 'kind'>;
}
function toolNode(
runId: string,
eventId: string,
sequence: number,
status: ChatActivityNode['status'],
detail: string,
toolCallId?: string
): ChatActivityNode {
return {
...baseNode(runId, eventId, sequence),
kind: 'activity',
activityType: 'tool',
status,
title: 'Notion search',
detail,
agentId: 'agent-1',
agentName: 'Research Agent',
toolkitName: 'MCPToolkit',
methodName: 'notion_search',
toolCallId,
};
}
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,
status: ChatActivityNode['status'],
eventType: string,
legacyStep: string | null = null
): ChatActivityNode {
return {
...baseNode('run-current', eventId, sequence),
eventType,
legacyStep,
kind: 'activity',
activityType: 'tool',
status,
title: 'todo_write',
toolName: 'todo_write',
toolkitName: 'TodoToolkit',
methodName: 'todo_write',
};
}
function todoPlan(
eventId: string,
sequence: number,
title: string
): ChatPlanNode {
return {
...baseNode('run-current', eventId, sequence),
eventType: 'legacy.todo_state',
legacyStep: 'todo_state',
kind: 'plan',
tasks: [{ id: 'todo_1', title, status: 'running' }],
};
}
function skillToolkitNode(
eventId: string,
sequence: number,
status: ChatActivityNode['status'],
methodName: 'list_skills' | 'load_skill',
detail: string
): ChatActivityNode {
const active = status === 'running';
return {
...toolNode('run-current', eventId, sequence, status, detail),
eventType: active ? 'legacy.activate_toolkit' : 'legacy.deactivate_toolkit',
legacyStep: active ? 'activate_toolkit' : 'deactivate_toolkit',
title: detail,
toolkitName: 'SkillToolkit',
methodName,
toolName: undefined,
toolCallId: undefined,
};
}
function makeRun(
runId: string,
isCurrent: boolean,
nodes: ChatProjectionNode[]
): ProjectSessionRun {
return {
runId,
taskId: runId,
status: isCurrent ? 'running' : 'completed',
nodes,
createdAt: isCurrent ? 2_000 : 1_000,
updatedAt: isCurrent ? 20_000 : 10_000,
isCurrent,
};
}
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('collects terminal, browser, and remote execution environments', () => {
const terminal: ChatActivityNode = {
...baseNode('run-current', 'terminal', 1),
kind: 'activity',
activityType: 'terminal',
status: 'running',
title: 'Run shell command',
};
const browser = {
...toolNode('run-current', 'browser', 2, 'running', 'Open page'),
toolkitName: 'BrowserToolkit',
methodName: 'browser_navigate',
};
const remote = agentNode(
'run-current',
'remote',
3,
'remote-agent',
'research_helper',
'agent.remote_started',
'Remote subagent research_helper'
);
expect(
buildProjectSessionPanelData(
[makeRun('run-current', true, [terminal, browser, remote])],
[]
).environments.map((item) => item.label)
).toEqual(['Browser', 'Remote environment', 'Terminal']);
});
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'),
toolNode('run-1', 'tool-end', 2, 'completed', '3 results', 'call-1'),
]);
expect(collectSessionToolCalls([run])).toMatchObject([
{
id: 'call-1',
toolkitName: 'MCPToolkit',
method: 'notion_search',
input: 'Searching',
output: '3 results',
status: 'done',
taskId: 'run-1',
},
]);
});
it('scopes durable call ids to their owning Run', () => {
const current = makeRun('run-current', true, [
toolNode(
'run-current',
'current-start',
1,
'running',
'current input',
'call-1'
),
toolNode(
'run-current',
'current-end',
2,
'completed',
'current output',
'call-1'
),
]);
const historical = makeRun('run-old', false, [
toolNode('run-old', 'old-start', 1, 'running', 'old input', 'call-1'),
toolNode('run-old', 'old-end', 2, 'completed', 'old output', 'call-1'),
]);
expect(collectSessionToolCalls([current, historical])).toMatchObject([
{ taskId: 'run-old', input: 'old input', output: 'old output' },
{
taskId: 'run-current',
input: 'current input',
output: 'current output',
},
]);
});
it('uses FIFO fallback for older tool frames without correlation ids', () => {
const run = makeRun('run-1', true, [
toolNode('run-1', 'start-1', 1, 'running', 'first'),
toolNode('run-1', 'start-2', 2, 'running', 'second'),
toolNode('run-1', 'end-1', 3, 'completed', 'first result'),
toolNode('run-1', 'end-2', 4, 'completed', 'second result'),
]);
expect(collectSessionToolCalls([run])).toMatchObject([
{ input: 'first', output: 'first result', status: 'done' },
{ input: 'second', output: 'second result', status: 'done' },
]);
});
it('projects plans, artifacts and safe URL resources across Runs', () => {
const plan: ChatPlanNode = {
...baseNode('run-current', 'plan', 3),
kind: 'plan',
tasks: [{ id: 'task-1', title: 'Build report', status: 'running' }],
};
const artifact: ChatArtifactNode = {
...baseNode('run-current', 'artifact', 4),
kind: 'artifact',
operation: 'created',
path: 'outputs/report.md',
relativePath: 'outputs/report.md',
name: 'report.md',
};
const current = makeRun('run-current', true, [plan, artifact]);
const historical = makeRun('run-old', false, [
{
...baseNode('run-old', 'message', 1),
kind: 'message',
role: 'assistant',
content: 'Read https://old.example.com/research.',
status: 'complete',
},
]);
const data = buildProjectSessionPanelData([current, historical], []);
expect(data.progress).toMatchObject([
{
taskId: 'run-current',
historical: false,
task: { id: 'task-1', content: 'Build report', status: 'running' },
},
]);
expect(data.files).toMatchObject([
{
id: 'outputs/report.md',
previewable: false,
taskId: 'run-current',
historical: false,
file: { name: 'report.md', artifactChange: 'generated' },
},
]);
expect(data.resources).toMatchObject([
{ taskId: 'run-old', historical: true },
]);
});
it('maps terminal task activity statuses into side-panel task statuses', () => {
const nodes = (
[
['timed-out', 'timed_out'],
['unknown-outcome', 'outcome_unknown'],
['cancelled', 'cancelled'],
] as const
).map(([taskId, status], index): ChatActivityNode => ({
...baseNode('run-current', `task-${taskId}`, index + 1),
kind: 'activity',
activityType: 'task',
status,
title: taskId,
taskId,
}));
expect(
buildProjectSessionPanelData(
[makeRun('run-current', true, nodes)],
[]
).progress.map((item) => item.task.status)
).toEqual(['failed', 'blocked', 'skipped']);
});
it('uses durable artifact identity and only enriches matching project files', () => {
const created: ChatArtifactNode = {
...baseNode('run-current', 'artifact-created', 1),
kind: 'artifact',
operation: 'created',
artifactId: 'artifact-1',
relativePath: 'outputs/report.md',
path: '/private/workspace/outputs/report.md',
name: 'report.md',
};
const updated: ChatArtifactNode = {
...baseNode('run-current', 'artifact-updated', 2),
kind: 'artifact',
operation: 'updated',
artifactId: 'artifact-1',
relativePath: './outputs\\report.md',
path: '/different-machine/workspace/outputs/report.md',
name: 'report.md',
};
const data = buildProjectSessionPanelData(
[makeRun('run-current', true, [created, updated])],
[]
);
const merged = mergeProjectFiles(data.files, [
{
name: 'report.md',
type: 'md',
path: 'https://files.example.test/outputs/report.md',
relativePath: 'outputs/report.md',
artifactId: 'artifact-1',
isRemote: true,
},
{
name: 'unrelated.txt',
type: 'txt',
path: '/workspace/unrelated.txt',
relativePath: 'unrelated.txt',
},
]);
expect(merged).toHaveLength(1);
expect(merged[0]).toMatchObject({
id: 'artifact-1',
previewable: true,
taskId: 'run-current',
file: {
artifactId: 'artifact-1',
relativePath: 'outputs/report.md',
path: 'https://files.example.test/outputs/report.md',
artifactChange: 'changed',
},
});
});
it('does not enrich by basename or an unsafe relative path', () => {
const item = buildProjectSessionPanelData(
[
makeRun('run-current', true, [
{
...baseNode('run-current', 'artifact', 1),
kind: 'artifact',
operation: 'created',
path: 'reports/quarterly/report.md',
relativePath: 'reports/quarterly/report.md',
name: 'report.md',
},
]),
],
[]
).files[0];
const merged = mergeProjectFiles(
[item],
[
{
name: 'report.md',
type: 'md',
path: '/workspace/archive/report.md',
relativePath: 'archive/report.md',
},
{
name: 'report.md',
type: 'md',
path: '/outside/report.md',
relativePath: '../report.md',
},
]
);
expect(merged).toEqual([item]);
});
it('does not turn a display-only basename into durable file identity', () => {
const decision = adaptChatProjectionEvent(
normalizeLegacyChatStep(
{
step: 'write_file',
data: {
file_path: '/Users/me/eigent/proj/reports/summary.md',
},
},
{
projectId: 'project-1',
runId: 'run-current',
sequence: 1,
sourceId: 'legacy-stream',
createdAt: 1_000,
}
)
);
expect(decision).toMatchObject({
kind: 'display',
node: {
kind: 'artifact',
path: 'summary.md',
relativePath: undefined,
},
});
if (decision.kind !== 'display') throw new Error('Expected artifact node');
const data = buildProjectSessionPanelData(
[makeRun('run-current', true, [decision.node])],
[]
);
expect(data.files).toEqual([]);
expect(
mergeProjectFiles(data.files, [
{
name: 'summary.md',
type: 'md',
path: '/workspace/summary.md',
relativePath: 'summary.md',
},
])
).toEqual([]);
});
it('shows a trusted realtime write and converges on its terminal artifact', () => {
const liveDecision = adaptChatProjectionEvent(
normalizeLegacyChatStep(
{
step: 'write_file',
data: {
file_path: '/private/run/reports/summary.md',
relative_path: 'reports/summary.md',
},
},
{
projectId: 'project-1',
runId: 'run-current',
sequence: 1,
sourceId: 'legacy-stream',
createdAt: 1_000,
}
)
);
expect(liveDecision).toMatchObject({
kind: 'display',
node: {
kind: 'artifact',
path: 'reports/summary.md',
relativePath: 'reports/summary.md',
},
});
if (liveDecision.kind !== 'display') {
throw new Error('Expected artifact node');
}
const liveData = buildProjectSessionPanelData(
[makeRun('run-current', true, [liveDecision.node])],
[]
);
expect(liveData.files).toMatchObject([
{
id: 'reports/summary.md',
previewable: false,
taskId: 'run-current',
file: { relativePath: 'reports/summary.md' },
},
]);
const terminalArtifact: ChatArtifactNode = {
...baseNode('run-current', 'artifact-terminal', 2),
eventType: 'artifact.created',
kind: 'artifact',
operation: 'created',
artifactId: 'artifact-summary',
path: 'reports/summary.md',
relativePath: 'reports/summary.md',
name: 'summary.md',
};
const finalized = buildProjectSessionPanelData(
[makeRun('run-current', true, [liveDecision.node, terminalArtifact])],
[]
);
expect(finalized.files).toHaveLength(1);
expect(finalized.files[0]).toMatchObject({
id: 'artifact-summary',
file: {
artifactId: 'artifact-summary',
relativePath: 'reports/summary.md',
},
});
expect(
mergeProjectFiles(finalized.files, [
{
name: 'summary.md',
type: 'md',
path: '/workspace/reports/summary.md',
relativePath: 'reports/summary.md',
},
])
).toMatchObject([
{
previewable: true,
file: { path: '/workspace/reports/summary.md' },
},
]);
});
it('quarantines workspace-loaded todo state without a todo_write call', () => {
const staleStartupPlan: ChatPlanNode = {
...baseNode('run-current', 'stale-todos', 2),
eventType: 'legacy.todo_state',
legacyStep: 'todo_state',
kind: 'plan',
tasks: [
{
id: 'todo_1',
title: 'Task from a different Project',
status: 'completed',
},
],
};
expect(
buildProjectSessionPanelData(
[makeRun('run-current', true, [staleStartupPlan])],
[]
).progress
).toEqual([]);
});
it('accepts a real todo lifecycle without authorizing a later stale state', () => {
const typedPlan: ChatPlanNode = {
...baseNode('run-current', 'typed-plan', 1),
eventType: 'plan.created',
kind: 'plan',
tasks: [
{
id: 'plan-task',
title: 'Typed plan task',
status: 'running',
},
],
};
const run = makeRun('run-current', true, [
typedPlan,
todoActivity('todo-prepared-1', 2, 'running', 'tool.prepared'),
todoActivity('todo-completed-1', 3, 'completed', 'tool.completed'),
todoActivity(
'todo-activate-1',
4,
'running',
'legacy.activate_toolkit',
'activate_toolkit'
),
todoPlan('todo-state-1', 5, 'First current task'),
todoActivity(
'todo-deactivate-1',
6,
'completed',
'legacy.deactivate_toolkit',
'deactivate_toolkit'
),
todoPlan('unpaired-state', 7, 'Must stay quarantined'),
todoActivity('todo-prepared-2', 8, 'running', 'tool.prepared'),
todoPlan('todo-state-2', 9, 'Replacement current task'),
]);
expect(buildProjectSessionPanelData([run], []).progress).toMatchObject([
{ task: { content: 'Typed plan task' } },
{ task: { content: 'Replacement current task' } },
]);
});
it('accepts legacy-only activate_toolkit followed by todo_state', () => {
const run = makeRun('run-current', true, [
todoActivity(
'todo-activate',
1,
'running',
'legacy.activate_toolkit',
'activate_toolkit'
),
todoPlan('todo-state', 2, 'Legacy current task'),
]);
expect(buildProjectSessionPanelData([run], []).progress).toMatchObject([
{ task: { content: 'Legacy current task' } },
]);
});
it('preserves legacy tool lifecycle status through the shipping bridge', () => {
const rawSteps = [
{
step: 'activate_toolkit',
data: {
toolkit_name: 'TodoToolkit',
method_name: 'todo_write',
tool_name: 'todo_write',
message: '{"todos":["first","second"]}',
},
},
{
step: 'todo_state',
data: {
todos: [
{ id: 'todo-1', content: 'First task', status: 'in_progress' },
{ id: 'todo-2', content: 'Second task', status: 'pending' },
],
},
},
{
step: 'deactivate_toolkit',
data: {
toolkit_name: 'TodoToolkit',
method_name: 'todo_write',
tool_name: 'todo_write',
message: 'Todos updated',
},
},
];
const nodes = rawSteps.flatMap((raw, index) => {
const decision = adaptChatProjectionEvent(
normalizeLegacyChatStep(raw, {
projectId: 'project-1',
runId: 'run-current',
sequence: index + 1,
sourceId: 'test-stream',
createdAt: (index + 1) * 1_000,
})
);
return decision.kind === 'display' ? [decision.node] : [];
});
expect(
nodes.map((node) => ('status' in node ? node.status : undefined))
).toEqual(['running', undefined, 'completed']);
const data = buildProjectSessionPanelData(
[makeRun('run-current', true, nodes)],
[]
);
expect(data.progress.map((item) => item.task.content)).toEqual([
'First task',
'Second task',
]);
expect(data.toolCalls).toMatchObject([
{
toolkitName: 'TodoToolkit',
method: 'todo_write',
input: '{"todos":["first","second"]}',
output: 'Todos updated',
status: 'done',
},
]);
});
it('uses connector identity without reading a raw event payload', () => {
const current = makeRun('run-current', true, [
toolNode(
'run-current',
'tool-start',
1,
'running',
'roadmap',
'notion-call'
),
toolNode(
'run-current',
'tool-end',
2,
'completed',
'done',
'notion-call'
),
]);
const data = buildProjectSessionPanelData(
[current],
[],
[
{
service: 'notion',
displayName: 'Notion',
iconUrl: 'https://cdn.example.com/notion.svg',
actions: [{ id: 'notion_search', name: 'Search Notion' }],
},
]
);
expect(data.contextItems).toMatchObject([
{
id: 'connector:notion',
label: 'Notion',
iconUrl: 'https://cdn.example.com/notion.svg',
historical: false,
calls: [{ id: 'notion-call' }],
},
]);
expect(JSON.stringify(data)).not.toContain('__legacy_data');
});
it('shows only explicitly loaded skills and ignores skill discovery', () => {
const availableSkills =
"[{'name': 'skill-security-auditor', 'description': " +
"'Security auditing for code, configs, and infrastructure.'}]";
const run = makeRun('run-current', true, [
skillToolkitNode(
'list-start',
1,
'running',
'list_skills',
JSON.stringify({ message_title: 'List Skills' })
),
skillToolkitNode(
'list-end',
2,
'completed',
'list_skills',
availableSkills
),
skillToolkitNode(
'load-start',
3,
'running',
'load_skill',
JSON.stringify({ name: 'pdf', message_title: 'Load Skill' })
),
skillToolkitNode(
'load-end',
4,
'completed',
'load_skill',
'## Skill: pdf\n\n# PDF Processing Guide'
),
]);
expect(buildProjectSessionPanelData([run], []).contextItems).toMatchObject([
{
id: 'skill:pdf',
label: 'pdf',
category: 'skill',
historical: false,
},
]);
});
it('extracts unique searched URLs without trailing punctuation', () => {
expect(
extractHttpUrls(
'Read https://example.com/a, then https://example.com/a and https://docs.example.com/page).'
)
).toEqual(['https://example.com/a', 'https://docs.example.com/page']);
});
});