feat: 完善 Agent 标准交互与安全运行时
- 接入 AG-UI 运行投影、Turn 时间线和审批隔离 - 增加 Agent Skill 冻结绑定与运行时消费闭环 - 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user