feat: 完善 Agent 标准交互与安全运行时

- 接入 AG-UI 运行投影、Turn 时间线和审批隔离

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

- 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
2026-08-19 22:13:41 +08:00
parent 91d66e636d
commit 4e8640dcaf
241 changed files with 24382 additions and 2777 deletions

View File

@@ -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');
});
});