mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-21 06:33:35 +00:00
895 lines
24 KiB
TypeScript
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']);
|
|
});
|
|
});
|