Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.test.ts
陈子默 4e8640dcaf feat: 完善 Agent 标准交互与安全运行时
- 接入 AG-UI 运行投影、Turn 时间线和审批隔离

- 增加 Agent Skill 冻结绑定与运行时消费闭环

- 增加受控工作区、内置工具和私有 Artifact 生命周期
2026-08-19 22:13:41 +08:00

521 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
ChatTimelineMessageItem,
ChatTimelineStatusItem,
} from '@easyflow/common-ui';
import { describe, expect, it } from 'vitest';
import { recordsToTimelineItems } from './agentTimelineAdapter';
describe('agentTimelineAdapter', () => {
it('restores successful turn duration from persisted message timestamps', () => {
const items = recordsToTimelineItems([
{
created: '2026-08-15T10:00:00Z',
id: 'timer-user',
roundId: 'round-timer',
senderRole: 'user',
contentText: '开始计时',
},
{
created: '2026-08-15T10:00:18Z',
id: 'timer-assistant',
roundId: 'round-timer',
senderRole: 'assistant',
contentText: '计时完成',
contentPayload: {
agentResult: { text: '计时完成' },
terminalStatus: 'COMPLETED',
},
},
]);
const turnItems = items.filter((item) => item.roundId === 'round-timer');
expect(turnItems).not.toHaveLength(0);
expect(
turnItems.every(
(item) =>
item.turnStartedAt === Date.parse('2026-08-15T10:00:00Z') &&
item.turnFinishedAt === Date.parse('2026-08-15T10:00:18Z'),
),
).toBe(true);
});
it('restores available and expired Artifacts with the shared safe projection', () => {
const items = recordsToTimelineItems([
{
id: 'artifact-history',
senderRole: 'assistant',
contentText: '报告已生成',
roundId: 'round-artifact-history',
contentPayload: {
agentResult: { text: '报告已生成' },
artifacts: [
{
artifactId: '01JAVAILABLE',
bucket: 'easyflow-agent-artifacts',
fileName: '报告.pdf',
objectKey: 'formal/private',
sha256: 'b'.repeat(64),
size: 4096,
status: 'AVAILABLE',
},
{
artifactId: '01JEXPIRED',
fileName: '旧报告.xlsx',
status: 'EXPIRED',
},
],
},
},
]);
const artifacts = items.filter((item) => item.type === 'artifact');
expect(artifacts).toHaveLength(2);
expect(artifacts).toEqual([
expect.objectContaining({
downloadUrl: '/api/v1/agent/artifacts/01JAVAILABLE/content',
status: 'available',
}),
expect.objectContaining({
downloadUrl: undefined,
status: 'expired',
}),
]);
expect(JSON.stringify(artifacts)).not.toMatch(
/easyflow-agent-artifacts|objectKey|formal\/private/,
);
});
it('uses refreshed direct Artifact status instead of stale runtime events', () => {
const items = recordsToTimelineItems([
{
id: 'artifact-status-conflict',
senderRole: 'assistant',
contentText: '产物已过期',
roundId: 'round-artifact-status-conflict',
contentPayload: {
artifacts: [
{
artifactId: '01JSTATUSCONFLICT',
fileName: '账本报告.pdf',
status: 'EXPIRED',
},
],
runtimeEvents: [
{
name: 'easyflow.artifact.published',
value: {
artifactId: '01JSTATUSCONFLICT',
fileName: '旧事件报告.pdf',
status: 'AVAILABLE',
},
},
],
},
},
]);
const artifacts = items.filter((item) => item.type === 'artifact');
expect(artifacts).toEqual([
expect.objectContaining({
artifactId: '01JSTATUSCONFLICT',
downloadUrl: undefined,
fileName: '账本报告.pdf',
status: 'expired',
}),
]);
});
it('projects history records to chat timeline items', () => {
const items = recordsToTimelineItems([
{
id: '1',
senderRole: 'user',
contentText: '帮我查一下',
roundId: 'r1',
contentPayload: {
attachments: [
{
attachmentRef: 'formal:r1:document:0',
downloadUrl:
'/api/v1/agent/media/document/content?reference=formal:r1:document:0',
name: '需求说明.docx',
readSnapshotId: 'snapshot-1',
size: 1024,
},
],
},
},
{
id: '2',
senderRole: 'assistant',
contentText: '查到了',
roundId: 'r1',
contentPayload: {
agentResult: {
reasoning: '先检索',
text: '查到了',
knowledgeReferences: [
{
documentName: '手册',
chunkContent: '内容片段',
},
],
},
chains: [
{
id: 'tool-1',
name: 'search',
status: 'TOOL_RESULT',
arguments: { q: 'EasyFlow' },
result: 'ok',
},
],
},
},
]);
expect(
items.some((item) => item.type === 'message' && item.role === 'user'),
).toBe(true);
const user = items.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' && item.role === 'user',
);
expect(user?.documents?.[0]).toEqual(
expect.objectContaining({
attachmentRef: 'formal:r1:document:0',
name: '需求说明.docx',
status: 'ready',
}),
);
const assistant = items.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' && item.role === 'assistant',
);
expect(assistant?.parts.some((part) => part.type === 'thinking')).toBe(
true,
);
expect(assistant?.parts.some((part) => part.type === 'text')).toBe(true);
expect(items.some((item) => item.type === 'tool')).toBe(true);
expect(assistant?.knowledgeItems?.[0]?.documentName).toBe('手册');
expect(
items
.filter((item) => item.type !== 'message' || item.role !== 'user')
.every((item) => item.roundId === 'r1' && item.turnSucceeded === true),
).toBe(true);
});
it('keeps a cancelled partial assistant turn expanded after history restore', () => {
const items = recordsToTimelineItems([
{
id: 'cancelled-assistant',
senderRole: 'assistant',
contentText: '取消前的部分输出',
created: '2026-08-15T10:00:00Z',
roundId: 'cancelled-round',
contentPayload: {
agentResult: { text: '取消前的部分输出' },
terminalStatus: 'CANCELLED',
},
},
]);
expect(
items
.filter((item) => item.roundId === 'cancelled-round')
.every(
(item) =>
item.turnSucceeded === false && item.turnFinishedAt !== undefined,
),
).toBe(true);
});
it('keeps stable ids when history has reasoning, tools and final text', () => {
const items = recordsToTimelineItems([
{
id: '42',
senderRole: 'assistant',
contentText: '最终回答',
roundId: 'r2',
contentPayload: {
agentResult: {
text: '最终回答',
},
chains: [
{
reasoning_content: '先思考',
},
{
id: 'tool-2',
name: 'search',
status: 'TOOL_RESULT',
result: 'ok',
},
],
messageChain: [
{
role: 'assistant',
reasoningContent: '中间思考',
toolCalls: [{ id: 'tool-2', name: 'search', arguments: '{}' }],
},
{
role: 'tool',
toolCallId: 'tool-2',
content: 'ok',
},
],
},
},
]);
const ids = items.map((item) => item.id);
expect(new Set(ids).size).toBe(ids.length);
expect(
items.filter(
(item) => item.type === 'message' && item.role === 'assistant',
),
).toHaveLength(2);
expect(
items.some((item) => item.type === 'tool' && item.status === 'success'),
).toBe(true);
});
it('uses tool display name when restoring aliased MCP tools from history', () => {
const items = recordsToTimelineItems([
{
id: 'mcp-history',
senderRole: 'assistant',
contentText: '已完成',
roundId: 'round-mcp',
contentPayload: {
chains: [
{
id: 'tool-mcp-1',
name: 'mcp_123_search',
toolDisplayName: 'Context MCP - search',
status: 'TOOL_RESULT',
result: 'ok',
},
],
messageChain: [
{
role: 'assistant',
toolCalls: [
{
id: 'tool-mcp-1',
name: 'mcp_123_search',
toolDisplayName: 'Context MCP - search',
arguments: '{}',
},
],
},
{
role: 'tool',
toolCallId: 'tool-mcp-1',
content: 'ok',
},
],
},
},
]);
const tool = items.find((item) => item.type === 'tool');
expect(tool?.toolName).toBe('Context MCP - search');
});
it('hides knowledge retrieval cards when restoring agent chat history', () => {
const items = recordsToTimelineItems([
{
id: '417197643647811584',
senderRole: 'assistant',
contentText:
'根据知识库中的信息2026年暑假的时间安排是7月1日到8月15日。',
roundId: '417197622424633344',
contentPayload: {
chains: [
{
id: 'call_0fc660e9d203416983ccca7e',
name: 'retrieve_knowledge',
result: 'Retrieved 2 relevant document(s)',
status: 'TOOL_RESULT',
arguments: {
query: '暑假时间',
},
},
],
agentResult: {
text: '根据知识库中的信息2026年暑假的时间安排是7月1日到8月15日。',
knowledgeReferences: [
{
chunkContent:
'问题2026 年暑假安排\n答案2026 年7 月 1 日到 8 月 15 日',
documentId: '411358369563336704',
knowledgeName: 'faq',
knowledgeType: 'FAQ',
},
],
},
messageChain: [
{
role: 'assistant',
toolCalls: [
{
id: 'call_0fc660e9d203416983ccca7e',
name: 'retrieve_knowledge',
arguments: '{query=暑假时间}',
},
],
},
{
role: 'tool',
content: 'Retrieved 2 relevant document(s)',
toolCallId: 'call_0fc660e9d203416983ccca7e',
},
{
role: 'assistant',
content:
'根据知识库中的信息2026年暑假的时间安排是7月1日到8月15日。',
},
],
},
},
]);
expect(items.some((item) => item.type === 'tool')).toBe(false);
expect(
items.some(
(item) => item.type === 'status' && item.label === '已检索知识库',
),
).toBe(true);
expect(
items.some(
(item) => item.type === 'message' && item.role === 'assistant',
),
).toBe(true);
const assistant = items.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' && item.role === 'assistant',
);
expect(assistant?.knowledgeItems?.[0]?.knowledgeName).toBe('faq');
});
it('hides internal fragment and context reload tools from history', () => {
const items = recordsToTimelineItems([
{
id: 'internal-tools',
senderRole: 'assistant',
contentText: '已处理',
roundId: 'round-internal',
contentPayload: {
chains: [
{
id: 'fragment-1',
name: '__fragment__',
status: 'TOOL_RESULT',
result: 'fragment',
},
{
id: 'context-1',
name: 'context_reload',
status: 'TOOL_RESULT',
result: 'reload',
},
],
agentResult: {
text: '已处理',
},
messageChain: [
{
role: 'assistant',
toolCalls: [
{ id: 'fragment-1', name: '__fragment__' },
{ id: 'context-1', name: 'context_reload' },
],
},
{
role: 'tool',
toolCallId: 'fragment-1',
content: 'fragment',
},
{
role: 'tool',
toolCallId: 'context-1',
content: 'reload',
},
],
},
},
]);
expect(items.some((item) => item.type === 'tool')).toBe(false);
expect(items.some((item) => item.type === 'status')).toBe(false);
expect(
items.some(
(item) => item.type === 'message' && item.role === 'assistant',
),
).toBe(true);
});
it('restores only terminal Skill invocation states from safe history fields', () => {
const items = recordsToTimelineItems([
{
id: 'skill-history',
senderRole: 'assistant',
contentText: '处理结束',
roundId: 'round-skill-history',
contentPayload: {
agentResult: { text: '处理结束' },
skillInvocationStatuses: [
{
input: { private: true },
path: 'references/private.md',
skillContent: 'private body',
skillDisplayName: '不会恢复的运行态',
status: 'RUNNING',
statusKey: 'skill-invocation:round-skill-history:running',
},
{
configJson: { token: 'secret' },
skillDisplayName: '合同审查助手',
status: 'SUCCESS',
statusKey: 'skill-invocation:round-skill-history:101',
},
{
skillDisplayName: '数据分析助手',
status: 'FAILED',
statusKey: 'skill-invocation:round-skill-history:102',
},
{
skillDisplayName: '流程检查助手',
status: 'CANCELLED',
statusKey: 'skill-invocation:round-skill-history:103',
},
{
skillDisplayName: '规范化助手',
status: 'INCOMPLETE',
statusKey: 'skill-invocation:round-skill-history:104',
},
],
},
},
]);
const skillStatuses = items.filter(
(item): item is ChatTimelineStatusItem =>
item.type === 'status' && item.icon === 'skill',
);
expect(skillStatuses).toHaveLength(4);
expect(skillStatuses.map((item) => item.status)).toEqual([
'done',
'error',
'cancelled',
'incomplete',
]);
expect(JSON.stringify(skillStatuses)).not.toMatch(
/不会恢复的运行态|skillContent|private body|private\.md|configJson|token/,
);
});
});