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,19 +1,32 @@
// @vitest-environment happy-dom
import type { EasyFlowAguiRunOptions } from '../shared/agent-agui/client';
import { useUserStore } from '@easyflow/stores';
import { EventType } from '@ag-ui/client';
import { createPinia, setActivePinia } from 'pinia';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { clearAgentChatBrowserCache } from '#/utils/agent-chat-cache';
import { easyFlowAguiCustomEvent } from '../shared/agent-agui/custom-events';
import { agentChatRuntimeManager } from './agentChatRuntimeManager';
import { sendAgentChat } from './api';
const aguiMocks = vi.hoisted(() => ({
abort: vi.fn(),
run: vi.fn(),
}));
vi.mock('../shared/agent-agui/client', () => ({
EasyFlowAguiClient: class {
abort = aguiMocks.abort;
run = aguiMocks.run;
},
}));
vi.mock('./api', () => ({
generateAgentSessionId: vi.fn(),
sendAgentChat: vi.fn(),
stopAgentChatStream: vi.fn(),
}));
describe('agentChatRuntimeManager', () => {
@@ -27,11 +40,43 @@ describe('agentChatRuntimeManager', () => {
vi.useRealTimers();
});
it('replaces accepted attachments and isolates snapshots by account', async () => {
let callbacks: any;
vi.mocked(sendAgentChat).mockImplementation((_data, options) => {
callbacks = options;
return Promise.resolve() as any;
it('发起请求后立即创建带起始时间的助手轮次', async () => {
vi.useFakeTimers();
vi.setSystemTime(12_000);
aguiMocks.run.mockImplementation(() => new Promise(() => {}));
useUserStore().setUserInfo({
avatar: '',
id: 'timer-user',
loginName: 'timer-user',
nickname: '计时用户',
tenantId: 'tenant-1',
});
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '计时测试',
sessionId: 'timer-session',
});
const assistant = agentChatRuntimeManager
.getSnapshot('timer-session')
?.items.find(
(item) => item.type === 'message' && item.role === 'assistant',
);
expect(assistant).toEqual(
expect.objectContaining({
roundId: expect.any(String),
status: 'streaming',
turnStartedAt: 12_000,
}),
);
});
it('投影 AG-UI 输入确认并按账号隔离快照', async () => {
let runOptions: EasyFlowAguiRunOptions | undefined;
aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => {
runOptions = options;
return new Promise(() => {});
});
const userStore = useUserStore();
const firstAccount = {
@@ -46,78 +91,55 @@ describe('agentChatRuntimeManager', () => {
await agentChatRuntimeManager.start({
agentId: 'agent-1',
documentUploadIds: ['document-upload-1'],
documents: [
{
downloadUrl:
'/api/v1/agent/media/document/content?reference=draft%3Adocument-upload-1',
name: 'draft.docx',
status: 'ready',
uploadId: 'document-upload-1',
},
],
documents: [{ name: 'draft.docx', status: 'ready' }],
images: [
{
name: 'draft.png',
previewUrl: '/api/v1/agent/media/content?reference=draft%3Aupload-1',
previewUrl: '/draft.png',
status: 'ready',
uploadId: 'upload-1',
},
],
prompt: '识别图片',
sessionId: '101',
});
callbacks.onMessage({
data: JSON.stringify({
domain: 'SYSTEM',
payload: {
attachments: [
{
attachmentRef: 'formal:101:201:document:0',
downloadUrl:
'/api/v1/agent/media/document/content?reference=formal:101:201:document:0',
name: 'draft.docx',
readSnapshotId: 'snapshot-1',
size: 2048,
},
],
images: [
{
imageRef: 'formal:101:201:0:png',
name: 'draft.png',
previewUrl:
'/api/v1/agent/media/content?reference=formal:101:201:0:png',
},
],
},
type: 'INPUT_ACCEPTED',
}),
runOptions?.onEvent({
name: easyFlowAguiCustomEvent.inputAccepted,
type: EventType.CUSTOM,
value: {
attachments: [
{
attachmentRef: 'formal:101:201:document:0',
name: 'draft.docx',
readSnapshotId: 'snapshot-1',
},
],
images: [
{
imageRef: 'formal:101:201:0:png',
name: 'draft.png',
previewUrl: '/formal.png',
},
],
},
});
const accepted = agentChatRuntimeManager.getSnapshot('101');
const userMessage = accepted?.items.find(
(item) => item.type === 'message' && item.role === 'user',
);
expect(
userMessage?.type === 'message' ? userMessage.images?.[0] : null,
).toEqual(
expect.objectContaining({
imageRef: 'formal:101:201:0:png',
previewUrl:
'/api/v1/agent/media/content?reference=formal:101:201:0:png',
}),
expect(userMessage?.type === 'message' && userMessage.images?.[0]).toEqual(
expect.objectContaining({ imageRef: 'formal:101:201:0:png' }),
);
expect(
userMessage?.type === 'message' ? userMessage.documents?.[0] : null,
).toEqual(
userMessage?.type === 'message' && userMessage.documents?.[0],
).toEqual(expect.objectContaining({ readSnapshotId: 'snapshot-1' }));
expect(runOptions?.forwardedProps).toEqual(
expect.objectContaining({
attachmentRef: 'formal:101:201:document:0',
readSnapshotId: 'snapshot-1',
status: 'ready',
}),
);
expect(vi.mocked(sendAgentChat).mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
documentUploadIds: ['document-upload-1'],
easyflow: expect.objectContaining({
input: expect.objectContaining({
documentUploadIds: ['document-upload-1'],
}),
}),
}),
);
@@ -125,7 +147,6 @@ describe('agentChatRuntimeManager', () => {
...firstAccount,
id: 'user-2',
loginName: 'other',
nickname: '其他用户',
});
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
@@ -134,12 +155,15 @@ describe('agentChatRuntimeManager', () => {
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
});
it('coalesces streaming notifications and persists terminal state immediately', async () => {
it('合并流式通知并在 AG-UI 终态立即持久化', async () => {
vi.useFakeTimers();
let callbacks: any;
vi.mocked(sendAgentChat).mockImplementation((_data, options) => {
callbacks = options;
return Promise.resolve() as any;
let runOptions: EasyFlowAguiRunOptions | undefined;
let resolveRun: (() => void) | undefined;
aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => {
runOptions = options;
return new Promise<void>((resolve) => {
resolveRun = resolve;
});
});
const account = {
avatar: '',
@@ -160,17 +184,13 @@ describe('agentChatRuntimeManager', () => {
const storageSpy = vi.spyOn(sessionStorage, 'setItem');
for (const delta of ['A', 'B', 'C']) {
callbacks.onMessage({
data: JSON.stringify({
domain: 'LLM',
payload: { delta },
type: 'MESSAGE',
}),
runOptions?.onEvent({
delta,
messageId: 'assistant-1',
type: EventType.TEXT_MESSAGE_CONTENT,
});
}
expect(listener).not.toHaveBeenCalled();
expect(storageSpy).not.toHaveBeenCalled();
expect(
JSON.stringify(
agentChatRuntimeManager.getSnapshot('stream-session')?.items,
@@ -179,21 +199,19 @@ describe('agentChatRuntimeManager', () => {
await vi.advanceTimersByTimeAsync(50);
expect(listener).toHaveBeenCalledTimes(1);
expect(storageSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(250);
expect(storageSpy).toHaveBeenCalledTimes(2);
callbacks.onMessage({
data: JSON.stringify({
domain: 'LLM',
payload: { delta: 'D' },
type: 'MESSAGE',
}),
});
listener.mockClear();
storageSpy.mockClear();
callbacks.onFinished();
runOptions?.onEvent({
runId: 'run-1',
threadId: 'stream-session',
type: EventType.RUN_FINISHED,
});
resolveRun?.();
await Promise.resolve();
await Promise.resolve();
expect(listener).toHaveBeenCalledTimes(1);
expect(storageSpy).toHaveBeenCalledTimes(2);
@@ -205,4 +223,141 @@ describe('agentChatRuntimeManager', () => {
storageSpy.mockRestore();
clearAgentChatBrowserCache(account);
});
it('工具开始事件立即通知页面并保留调用中快照', async () => {
vi.useFakeTimers();
let runOptions: EasyFlowAguiRunOptions | undefined;
aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => {
runOptions = options;
return new Promise(() => {});
});
const account = {
avatar: '',
id: 'tool-status-user',
loginName: 'tool-status-user',
nickname: '工具状态用户',
tenantId: 'tenant-1',
};
useUserStore().setUserInfo(account);
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '生成文件',
sessionId: 'tool-status-session',
});
const listener = vi.fn();
const unsubscribe = agentChatRuntimeManager.subscribe(listener);
runOptions?.onEvent({
toolCallId: 'write-call-1',
toolCallName: 'write_text_file',
type: EventType.TOOL_CALL_START,
});
expect(listener).toHaveBeenCalledTimes(1);
expect(
agentChatRuntimeManager
.getSnapshot('tool-status-session')
?.items.find(
(item) => item.type === 'tool' && item.toolCallId === 'write-call-1',
),
).toEqual(expect.objectContaining({ status: 'running' }));
unsubscribe();
clearAgentChatBrowserCache(account);
});
it('停止后同会话重发不会接收旧运行的迟到事件', async () => {
const runs: EasyFlowAguiRunOptions[] = [];
aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => {
runs.push(options);
return new Promise(() => {});
});
useUserStore().setUserInfo({
avatar: '',
id: 'race-user',
loginName: 'race-user',
nickname: '竞态用户',
tenantId: 'tenant-1',
});
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '旧问题',
sessionId: 'race-session',
});
agentChatRuntimeManager.stop('race-session');
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '新问题',
sessionId: 'race-session',
});
runs[0]?.onEvent({
delta: '旧流迟到正文',
messageId: 'old-assistant',
type: EventType.TEXT_MESSAGE_CONTENT,
});
runs[1]?.onEvent({
delta: '新流正文',
messageId: 'new-assistant',
type: EventType.TEXT_MESSAGE_CONTENT,
});
const snapshot = JSON.stringify(
agentChatRuntimeManager.getSnapshot('race-session')?.items,
);
expect(snapshot).toContain('新流正文');
expect(snapshot).not.toContain('旧流迟到正文');
});
it('用户取消的 AG-UI 终态不会被异步完成回调改写为成功', async () => {
let runOptions: EasyFlowAguiRunOptions | undefined;
let resolveRun: (() => void) | undefined;
aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => {
runOptions = options;
return new Promise<void>((resolve) => {
resolveRun = resolve;
});
});
useUserStore().setUserInfo({
avatar: '',
id: 'cancel-user',
loginName: 'cancel-user',
nickname: '取消用户',
tenantId: 'tenant-1',
});
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '需要审批的任务',
sessionId: 'cancel-session',
});
runOptions?.onEvent({
delta: '准备执行',
messageId: 'assistant-1',
type: EventType.TEXT_MESSAGE_CONTENT,
});
runOptions?.onEvent({
code: 'RUN_CANCELLED',
message: '用户拒绝执行',
runId: 'run-1',
threadId: 'cancel-session',
type: EventType.RUN_ERROR,
});
resolveRun?.();
await Promise.resolve();
await Promise.resolve();
const assistant = agentChatRuntimeManager
.getSnapshot('cancel-session')
?.items.find(
(item) => item.type === 'message' && item.role === 'assistant',
);
expect(assistant).toEqual(
expect.objectContaining({
turnSucceeded: false,
}),
);
});
});