- 接入 AG-UI 运行投影、Turn 时间线和审批隔离 - 增加 Agent Skill 冻结绑定与运行时消费闭环 - 增加受控工作区、内置工具和私有 Artifact 生命周期
521 lines
15 KiB
TypeScript
521 lines
15 KiB
TypeScript
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/,
|
||
);
|
||
});
|
||
});
|