feat: 完善 Agent 标准交互与安全运行时
- 接入 AG-UI 运行投影、Turn 时间线和审批隔离 - 增加 Agent Skill 冻结绑定与运行时消费闭环 - 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
@@ -1,14 +1,132 @@
|
||||
import type { ChatTimelineMessageItem } from '@easyflow/common-ui';
|
||||
import type {
|
||||
ChatTimelineMessageItem,
|
||||
ChatTimelineStatusItem,
|
||||
} from '@easyflow/common-ui';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
applyAgentSseEnvelope,
|
||||
parseAgentSseMessage,
|
||||
recordsToTimelineItems,
|
||||
} from './agentTimelineAdapter';
|
||||
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([
|
||||
{
|
||||
@@ -82,6 +200,36 @@ describe('agentTimelineAdapter', () => {
|
||||
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', () => {
|
||||
@@ -310,335 +458,63 @@ describe('agentTimelineAdapter', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('parses raw SSE text as message delta', () => {
|
||||
const envelope = parseAgentSseMessage({
|
||||
data: 'hello',
|
||||
event: '',
|
||||
id: '',
|
||||
retry: undefined,
|
||||
});
|
||||
|
||||
expect(envelope).toMatchObject({
|
||||
domain: 'LLM',
|
||||
type: 'MESSAGE',
|
||||
payload: { delta: 'hello' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reconciles streamed text with the canonical final answer', () => {
|
||||
const items: any[] = [];
|
||||
|
||||
for (const delta of ['http://127.0.0.1:39', '0', '/easyflow/file.docx']) {
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'LLM',
|
||||
type: 'MESSAGE',
|
||||
payload: { delta },
|
||||
});
|
||||
}
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'SYSTEM',
|
||||
type: 'DONE',
|
||||
payload: {
|
||||
finalText: 'http://127.0.0.1:39000/easyflow/file.docx',
|
||||
},
|
||||
});
|
||||
|
||||
const assistant = items.find(
|
||||
(item): item is ChatTimelineMessageItem =>
|
||||
item.type === 'message' && item.role === 'assistant',
|
||||
);
|
||||
expect(assistant?.parts[0]?.content).toBe(
|
||||
'http://127.0.0.1:39000/easyflow/file.docx',
|
||||
);
|
||||
expect(assistant?.status).toBe('done');
|
||||
});
|
||||
|
||||
it('applies streaming text, HITL approval and error envelopes', () => {
|
||||
const items: any[] = [];
|
||||
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'LLM',
|
||||
type: 'MESSAGE',
|
||||
payload: { delta: '你好' },
|
||||
});
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'TOOL',
|
||||
type: 'FORM_REQUEST',
|
||||
payload: {
|
||||
requestId: 'req-1',
|
||||
resumeToken: 'token-1',
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'workflow',
|
||||
input: { name: 'demo' },
|
||||
},
|
||||
});
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'ERROR',
|
||||
type: 'ERROR',
|
||||
payload: { message: '失败' },
|
||||
});
|
||||
|
||||
const assistant = items.find(
|
||||
(item): item is ChatTimelineMessageItem =>
|
||||
item.type === 'message' && item.role === 'assistant',
|
||||
);
|
||||
const tool = items.find((item) => item.type === 'tool');
|
||||
const error = items.find((item) => item.type === 'error');
|
||||
|
||||
expect(assistant?.parts[0]?.content).toBe('你好');
|
||||
expect(tool?.status).toBe('pending_approval');
|
||||
expect(tool?.approval?.resumeToken).toBe('token-1');
|
||||
expect(error?.message).toBe('失败');
|
||||
});
|
||||
|
||||
it('keeps async workflow polling events in the original approval card', () => {
|
||||
const items: any[] = [];
|
||||
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'TOOL',
|
||||
type: 'FORM_REQUEST',
|
||||
payload: {
|
||||
input: { user_input: '写一篇小作文' },
|
||||
requestId: 'req-async',
|
||||
resumeToken: 'token-async',
|
||||
toolCallId: 'submit-call-1',
|
||||
toolName: '文档生成',
|
||||
},
|
||||
});
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'TOOL',
|
||||
type: 'TOOL_RESULT',
|
||||
payload: {
|
||||
asyncTool: true,
|
||||
phase: 'submit',
|
||||
sourceToolCallId: 'submit-call-1',
|
||||
status: 'RUNNING',
|
||||
taskId: 'task-1',
|
||||
toolCallId: 'task-1',
|
||||
toolName: '文档生成',
|
||||
},
|
||||
});
|
||||
for (const sourceToolCallId of ['observe-call-1', 'observe-call-2']) {
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'TOOL',
|
||||
type: 'TOOL_CALL',
|
||||
payload: {
|
||||
asyncTool: true,
|
||||
input: { taskId: 'task-1' },
|
||||
phase: 'observe',
|
||||
sourceToolCallId,
|
||||
status: 'RUNNING',
|
||||
taskId: 'task-1',
|
||||
toolCallId: 'task-1',
|
||||
toolName: '文档生成',
|
||||
},
|
||||
});
|
||||
}
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'TOOL',
|
||||
type: 'TOOL_RESULT',
|
||||
payload: {
|
||||
asyncTool: true,
|
||||
phase: 'result',
|
||||
sourceToolCallId: 'result-call-1',
|
||||
status: 'SUCCEEDED',
|
||||
taskId: 'task-1',
|
||||
toolCallId: 'task-1',
|
||||
toolName: '文档生成',
|
||||
},
|
||||
});
|
||||
|
||||
const tools = items.filter((item) => item.type === 'tool');
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0]).toMatchObject({
|
||||
mode: 'approval',
|
||||
status: 'success',
|
||||
taskId: 'task-1',
|
||||
toolCallId: 'task-1',
|
||||
toolName: '文档生成',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps assistant text and approval card when a tool request is rejected', () => {
|
||||
const items: any[] = [];
|
||||
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'LLM',
|
||||
type: 'MESSAGE',
|
||||
payload: { delta: '正在处理' },
|
||||
});
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'TOOL',
|
||||
type: 'FORM_REQUEST',
|
||||
payload: {
|
||||
requestId: 'req-2',
|
||||
resumeToken: 'token-2',
|
||||
toolCallId: 'tool-2',
|
||||
toolName: '审批工具',
|
||||
input: { name: 'demo' },
|
||||
},
|
||||
});
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'TOOL',
|
||||
type: 'FORM_REJECTED',
|
||||
payload: {
|
||||
requestId: 'req-2',
|
||||
resumeToken: 'token-2',
|
||||
toolCallId: 'tool-2',
|
||||
reason: '用户拒绝执行',
|
||||
},
|
||||
});
|
||||
|
||||
const assistant = items.find(
|
||||
(item): item is ChatTimelineMessageItem =>
|
||||
item.type === 'message' && item.role === 'assistant',
|
||||
);
|
||||
const tool = items.find((item) => item.type === 'tool');
|
||||
|
||||
expect(assistant?.parts[0]?.content).toBe('正在处理');
|
||||
expect(items).toHaveLength(2);
|
||||
expect(tool?.status).toBe('rejected');
|
||||
expect(tool?.rejectReason).toBe('用户拒绝执行');
|
||||
});
|
||||
|
||||
it('applies streaming round metadata to assistant messages for action toolbar anchoring', () => {
|
||||
const items: any[] = [];
|
||||
|
||||
applyAgentSseEnvelope(
|
||||
items,
|
||||
it('restores only terminal Skill invocation states from safe history fields', () => {
|
||||
const items = recordsToTimelineItems([
|
||||
{
|
||||
domain: 'LLM',
|
||||
type: 'MESSAGE',
|
||||
payload: { delta: '准备调用工具' },
|
||||
},
|
||||
{ roundId: 'runtime-round-1' },
|
||||
);
|
||||
|
||||
const assistant = items.find(
|
||||
(item): item is ChatTimelineMessageItem =>
|
||||
item.type === 'message' && item.role === 'assistant',
|
||||
);
|
||||
|
||||
expect(assistant?.roundId).toBe('runtime-round-1');
|
||||
expect(assistant?.parts[0]?.content).toBe('准备调用工具');
|
||||
});
|
||||
|
||||
it('updates memory compression status within the current round', () => {
|
||||
const items: any[] = [];
|
||||
|
||||
applyAgentSseEnvelope(
|
||||
items,
|
||||
{
|
||||
domain: 'BUSINESS',
|
||||
type: 'STATUS',
|
||||
payload: {
|
||||
label: '正在整理上下文',
|
||||
phase: 'started',
|
||||
status: 'running',
|
||||
statusKey: 'memory-compression',
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ roundId: 'round-a' },
|
||||
]);
|
||||
|
||||
const skillStatuses = items.filter(
|
||||
(item): item is ChatTimelineStatusItem =>
|
||||
item.type === 'status' && item.icon === 'skill',
|
||||
);
|
||||
applyAgentSseEnvelope(
|
||||
items,
|
||||
{
|
||||
domain: 'BUSINESS',
|
||||
type: 'STATUS',
|
||||
payload: {
|
||||
compressed: true,
|
||||
label: '已整理上下文',
|
||||
phase: 'completed',
|
||||
status: 'done',
|
||||
statusKey: 'memory-compression',
|
||||
},
|
||||
},
|
||||
{ roundId: 'round-a' },
|
||||
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/,
|
||||
);
|
||||
|
||||
const statuses = items.filter((item) => item.type === 'status');
|
||||
expect(statuses).toHaveLength(1);
|
||||
expect(statuses[0]?.label).toBe('已整理上下文');
|
||||
expect(statuses[0]?.status).toBe('done');
|
||||
expect(statuses[0]?.statusKey).toBe('memory-compression:round-a');
|
||||
});
|
||||
|
||||
it('keeps memory compression statuses isolated by round', () => {
|
||||
const items: any[] = [];
|
||||
|
||||
applyAgentSseEnvelope(
|
||||
items,
|
||||
{
|
||||
domain: 'BUSINESS',
|
||||
type: 'STATUS',
|
||||
payload: {
|
||||
compressed: true,
|
||||
label: '已整理上下文',
|
||||
phase: 'completed',
|
||||
status: 'done',
|
||||
statusKey: 'memory-compression',
|
||||
},
|
||||
},
|
||||
{ roundId: 'round-a' },
|
||||
);
|
||||
applyAgentSseEnvelope(
|
||||
items,
|
||||
{
|
||||
domain: 'BUSINESS',
|
||||
type: 'STATUS',
|
||||
payload: {
|
||||
compressed: false,
|
||||
label: '无需压缩上下文',
|
||||
phase: 'completed',
|
||||
status: 'done',
|
||||
statusKey: 'memory-compression',
|
||||
},
|
||||
},
|
||||
{ roundId: 'round-b' },
|
||||
);
|
||||
|
||||
const statuses = items.filter((item) => item.type === 'status');
|
||||
expect(statuses).toHaveLength(1);
|
||||
expect(statuses[0]?.statusKey).toBe('memory-compression:round-a');
|
||||
expect(statuses[0]?.label).toBe('已整理上下文');
|
||||
});
|
||||
|
||||
it('does not show no-compression status before a later compression run', () => {
|
||||
const items: any[] = [];
|
||||
|
||||
applyAgentSseEnvelope(
|
||||
items,
|
||||
{
|
||||
domain: 'BUSINESS',
|
||||
type: 'STATUS',
|
||||
payload: {
|
||||
compressed: false,
|
||||
label: '无需压缩上下文',
|
||||
phase: 'completed',
|
||||
status: 'done',
|
||||
statusKey: 'memory-compression',
|
||||
},
|
||||
},
|
||||
{ roundId: 'round-a' },
|
||||
);
|
||||
applyAgentSseEnvelope(
|
||||
items,
|
||||
{
|
||||
domain: 'BUSINESS',
|
||||
type: 'STATUS',
|
||||
payload: {
|
||||
label: '正在整理上下文',
|
||||
phase: 'started',
|
||||
status: 'running',
|
||||
statusKey: 'memory-compression',
|
||||
},
|
||||
},
|
||||
{ roundId: 'round-a' },
|
||||
);
|
||||
|
||||
const statuses = items.filter((item) => item.type === 'status');
|
||||
expect(statuses).toHaveLength(1);
|
||||
expect(statuses[0]?.label).toBe('正在整理上下文');
|
||||
expect(statuses[0]?.status).toBe('running');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user