Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/agent-chat/agentChatRuntimeManager.test.ts
陈子默 fa07134cf8 feat: 支持 Agent 运行刷新恢复
- 解耦 Runtime 与浏览器 SSE 订阅并增加 Redis 游标日志

- 支持正式聊天与草稿试运行刷新重连和权威终态恢复

- 完善显式取消、owner 丢失、容量限制与故障测试
2026-08-20 11:18:10 +08:00

794 lines
24 KiB
TypeScript

// @vitest-environment happy-dom
import type {
EasyFlowAguiResumeOptions,
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';
const aguiMocks = vi.hoisted(() => ({
abort: vi.fn(),
cancel: vi.fn(async () => undefined),
detach: vi.fn(),
resume: vi.fn(),
run: vi.fn(),
}));
vi.mock('../shared/agent-agui/client', () => ({
createAguiRunId: () => 'run-test',
EasyFlowAguiHttpError: class extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message);
}
},
EasyFlowAguiProjectionError: class extends Error {
constructor(public readonly projectionCause: unknown) {
super('Agent 事件投影失败');
}
},
EasyFlowAguiClient: class {
abort = aguiMocks.abort;
cancel = aguiMocks.cancel;
detach = aguiMocks.detach;
resume = aguiMocks.resume;
run = aguiMocks.run;
},
}));
vi.mock('./api', () => ({
generateAgentSessionId: vi.fn(),
}));
describe('agentChatRuntimeManager', () => {
beforeEach(() => {
setActivePinia(createPinia());
sessionStorage.clear();
vi.clearAllMocks();
aguiMocks.cancel.mockResolvedValue(undefined);
aguiMocks.resume.mockImplementation(() => new Promise(() => {}));
aguiMocks.run.mockImplementation(() => new Promise(() => {}));
});
afterEach(() => {
vi.useRealTimers();
});
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 = {
avatar: '',
id: 'user-1',
loginName: 'admin',
nickname: '管理员',
tenantId: 'tenant-1',
};
userStore.setUserInfo(firstAccount);
await agentChatRuntimeManager.start({
agentId: 'agent-1',
documentUploadIds: ['document-upload-1'],
documents: [{ name: 'draft.docx', status: 'ready' }],
images: [
{
name: 'draft.png',
previewUrl: '/draft.png',
status: 'ready',
},
],
prompt: '识别图片',
sessionId: '101',
});
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]).toEqual(
expect.objectContaining({ imageRef: 'formal:101:201:0:png' }),
);
expect(
userMessage?.type === 'message' && userMessage.documents?.[0],
).toEqual(expect.objectContaining({ readSnapshotId: 'snapshot-1' }));
expect(runOptions?.forwardedProps).toEqual(
expect.objectContaining({
easyflow: expect.objectContaining({
input: expect.objectContaining({
documentUploadIds: ['document-upload-1'],
}),
}),
}),
);
userStore.setUserInfo({
...firstAccount,
id: 'user-2',
loginName: 'other',
});
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
clearAgentChatBrowserCache(firstAccount);
userStore.setUserInfo(firstAccount);
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
});
it('刷新恢复已确认输入时重新触发草稿清理回调', async () => {
const account = {
avatar: '',
id: 'accepted-restore-user',
loginName: 'accepted-restore-user',
nickname: '输入恢复用户',
tenantId: 'tenant-1',
};
useUserStore().setUserInfo(account);
const sessionId = 'accepted-restore-session';
sessionStorage.setItem(
`easyflow:agent-chat-runtime:tenant-1:accepted-restore-user:${sessionId}`,
JSON.stringify({
agentId: 'agent-1',
completed: false,
inputAccepted: true,
items: [],
prompt: '刷新前输入',
runId: 'run-accepted-restore',
roundId: 'round-accepted-restore',
sending: true,
sessionId,
startedAt: Date.now(),
updatedAt: Date.now(),
version: 4,
}),
);
aguiMocks.resume.mockImplementation(() => new Promise(() => {}));
const onInputAccepted = vi.fn();
agentChatRuntimeManager.resume(sessionId, onInputAccepted);
expect(onInputAccepted).toHaveBeenCalledTimes(1);
expect(aguiMocks.resume).toHaveBeenCalledWith(
expect.objectContaining({ runId: 'run-accepted-restore' }),
);
clearAgentChatBrowserCache(account);
});
it('硬刷新后从持久化游标续传且保留已投影正文', async () => {
const account = {
avatar: '',
id: 'cursor-restore-user',
loginName: 'cursor-restore-user',
nickname: '游标恢复用户',
tenantId: 'tenant-1',
};
useUserStore().setUserInfo(account);
const sessionId = 'cursor-restore-session';
sessionStorage.setItem(
`easyflow:agent-chat-runtime:tenant-1:cursor-restore-user:${sessionId}`,
JSON.stringify({
agentId: 'agent-1',
completed: false,
inputAccepted: false,
items: [
{
id: 'assistant-cursor',
parts: [
{
content: '刷新前正文',
id: 'text-cursor',
type: 'text',
},
],
role: 'assistant',
roundId: 'round-cursor',
status: 'streaming',
type: 'message',
},
],
lastCursor: 9,
prompt: '游标恢复',
projectionToolArgs: {},
projectionToolNames: {},
runId: 'run-cursor-restore',
roundId: 'round-cursor',
sending: true,
sessionId,
startedAt: Date.now(),
updatedAt: Date.now(),
version: 5,
}),
);
aguiMocks.resume.mockImplementationOnce(
async (options: EasyFlowAguiResumeOptions) => {
expect(options.afterCursor).toBe(9);
options.onEvent({
delta: '续传正文',
messageId: 'assistant-cursor',
type: EventType.TEXT_MESSAGE_CONTENT,
});
options.onCursor?.(10);
options.onEvent({
runId: 'run-cursor-restore',
threadId: sessionId,
type: EventType.RUN_FINISHED,
});
options.onCursor?.(11);
},
);
agentChatRuntimeManager.resume(sessionId);
expect(aguiMocks.resume).toHaveBeenCalledTimes(1);
expect(aguiMocks.resume).toHaveBeenCalledWith(
expect.objectContaining({ afterCursor: 9 }),
);
await vi.waitFor(() => {
expect(agentChatRuntimeManager.getSnapshot(sessionId)).toEqual(
expect.objectContaining({ completed: true, lastCursor: 11 }),
);
});
const timeline = JSON.stringify(
agentChatRuntimeManager.getSnapshot(sessionId)?.items,
);
expect(timeline).toContain('刷新前正文续传正文');
clearAgentChatBrowserCache(account);
});
it('短暂断网超过旧重试窗口后仍按游标恢复到服务端终态', async () => {
vi.useFakeTimers();
const account = {
avatar: '',
id: 'reconnect-user',
loginName: 'reconnect-user',
nickname: '重连用户',
tenantId: 'tenant-1',
};
useUserStore().setUserInfo(account);
aguiMocks.run.mockRejectedValueOnce(new TypeError('Failed to fetch'));
aguiMocks.resume
.mockRejectedValueOnce(new TypeError('Network unavailable'))
.mockRejectedValueOnce(new TypeError('Network unavailable'))
.mockImplementationOnce(async (options: EasyFlowAguiResumeOptions) => {
await options.onEvent({
runId: 'run-test',
threadId: 'reconnect-session',
type: EventType.RUN_FINISHED,
});
});
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '断网恢复',
sessionId: 'reconnect-session',
});
await vi.advanceTimersByTimeAsync(1000);
expect(aguiMocks.resume).toHaveBeenCalledTimes(3);
expect(agentChatRuntimeManager.getSnapshot('reconnect-session')).toEqual(
expect.objectContaining({ completed: true, sending: false }),
);
clearAgentChatBrowserCache(account);
});
it('合并流式通知并在 AG-UI 终态立即持久化', async () => {
vi.useFakeTimers();
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: '',
id: 'stream-user',
loginName: 'stream-user',
nickname: '流式用户',
tenantId: 'tenant-1',
};
useUserStore().setUserInfo(account);
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '流式测试',
sessionId: 'stream-session',
});
const listener = vi.fn();
const unsubscribe = agentChatRuntimeManager.subscribe(listener);
const storageSpy = vi.spyOn(sessionStorage, 'setItem');
for (const delta of ['A', 'B', 'C']) {
runOptions?.onEvent({
delta,
messageId: 'assistant-1',
type: EventType.TEXT_MESSAGE_CONTENT,
});
}
expect(listener).not.toHaveBeenCalled();
expect(
JSON.stringify(
agentChatRuntimeManager.getSnapshot('stream-session')?.items,
),
).toContain('ABC');
await vi.advanceTimersByTimeAsync(50);
expect(listener).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(250);
expect(storageSpy).toHaveBeenCalledTimes(2);
listener.mockClear();
storageSpy.mockClear();
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);
expect(agentChatRuntimeManager.getSnapshot('stream-session')).toEqual(
expect.objectContaining({ completed: true, sending: false }),
);
unsubscribe();
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',
});
aguiMocks.resume.mockImplementationOnce(
async (options: EasyFlowAguiResumeOptions) => {
options.onEvent({
code: 'RUN_CANCELLED',
message: '用户已停止生成',
runId: 'run-test',
threadId: 'race-session',
type: EventType.RUN_ERROR,
});
},
);
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '旧问题',
sessionId: 'race-session',
});
await agentChatRuntimeManager.stop('race-session');
await Promise.resolve();
await Promise.resolve();
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,
}),
);
});
it('取消请求失败且原流无终态时保留等待期间事件并立即恢复订阅', async () => {
let rejectCancel: ((error: Error) => void) | undefined;
let resolveRun: (() => void) | undefined;
let runOptions: EasyFlowAguiRunOptions | undefined;
aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => {
runOptions = options;
return new Promise<void>((resolve) => {
resolveRun = resolve;
});
});
aguiMocks.detach.mockImplementationOnce(() => resolveRun?.());
aguiMocks.cancel.mockImplementation(
() =>
new Promise<undefined>((_resolve, reject) => {
rejectCancel = reject;
}),
);
aguiMocks.resume.mockImplementation(() => new Promise(() => {}));
useUserStore().setUserInfo({
avatar: '',
id: 'cancel-failed-user',
loginName: 'cancel-failed-user',
nickname: '取消失败用户',
tenantId: 'tenant-1',
});
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '取消失败恢复',
sessionId: 'cancel-failed-session',
});
const stopPromise = agentChatRuntimeManager.stop('cancel-failed-session');
runOptions?.onEvent({
delta: '取消等待期间正文',
messageId: 'assistant-cancel-failed',
type: EventType.TEXT_MESSAGE_CONTENT,
});
runOptions?.onCursor?.(1);
rejectCancel?.(new Error('取消接口暂时不可用'));
await expect(stopPromise).rejects.toThrow('取消接口暂时不可用');
await Promise.resolve();
await Promise.resolve();
expect(
JSON.stringify(
agentChatRuntimeManager.getSnapshot('cancel-failed-session')?.items,
),
).toContain('取消等待期间正文');
expect(
agentChatRuntimeManager.getSnapshot('cancel-failed-session'),
).toEqual(expect.objectContaining({ sending: true }));
expect(aguiMocks.resume).toHaveBeenCalledTimes(1);
});
it('取消成功后重放服务端终态且不把尚未投影的自然完成改成取消', async () => {
aguiMocks.resume.mockImplementationOnce(
async (options: EasyFlowAguiResumeOptions) => {
options.onEvent({
runId: 'run-test',
threadId: 'cancel-authoritative-session',
type: EventType.RUN_FINISHED,
});
options.onCursor?.(8);
},
);
useUserStore().setUserInfo({
avatar: '',
id: 'cancel-authoritative-user',
loginName: 'cancel-authoritative-user',
nickname: '取消终态仲裁用户',
tenantId: 'tenant-1',
});
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '取消时服务端已经自然完成',
sessionId: 'cancel-authoritative-session',
});
await agentChatRuntimeManager.stop('cancel-authoritative-session');
await Promise.resolve();
await Promise.resolve();
expect(
agentChatRuntimeManager.getSnapshot('cancel-authoritative-session'),
).toEqual(
expect.objectContaining({
completed: true,
lastCursor: 8,
sending: false,
terminalOutcome: 'success',
}),
);
expect(
JSON.stringify(
agentChatRuntimeManager.getSnapshot('cancel-authoritative-session')
?.items,
),
).not.toContain('"turnSucceeded":false');
});
it('取消请求失败但原流已到终态时直接展示权威完成态', async () => {
let rejectCancel: ((error: Error) => void) | undefined;
let resolveRun: (() => void) | undefined;
let runOptions: EasyFlowAguiRunOptions | undefined;
aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => {
runOptions = options;
return new Promise<void>((resolve) => {
resolveRun = resolve;
});
});
aguiMocks.cancel.mockImplementation(
() =>
new Promise<undefined>((_resolve, reject) => {
rejectCancel = reject;
}),
);
useUserStore().setUserInfo({
avatar: '',
id: 'cancel-terminal-user',
loginName: 'cancel-terminal-user',
nickname: '取消终态用户',
tenantId: 'tenant-1',
});
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '取消与完成竞态',
sessionId: 'cancel-terminal-session',
});
const stopPromise = agentChatRuntimeManager.stop('cancel-terminal-session');
runOptions?.onEvent({
runId: 'run-test',
threadId: 'cancel-terminal-session',
type: EventType.RUN_FINISHED,
});
runOptions?.onCursor?.(1);
resolveRun?.();
await Promise.resolve();
rejectCancel?.(new Error('取消接口晚到失败'));
await expect(stopPromise).rejects.toThrow('取消接口晚到失败');
expect(
agentChatRuntimeManager.getSnapshot('cancel-terminal-session'),
).toEqual(
expect.objectContaining({
completed: true,
sending: false,
terminalOutcome: 'success',
}),
);
expect(aguiMocks.resume).not.toHaveBeenCalled();
});
it('首条传输闪断后按最后游标恢复且不会重复投影', async () => {
aguiMocks.run.mockImplementation(
async (options: EasyFlowAguiRunOptions) => {
options.onEvent({
delta: '前半段',
messageId: 'assistant-1',
type: EventType.TEXT_MESSAGE_CONTENT,
});
options.onCursor?.(7);
throw new Error('network reset');
},
);
aguiMocks.resume.mockImplementation(
async (options: EasyFlowAguiResumeOptions) => {
expect(options.afterCursor).toBe(7);
options.onEvent({
delta: '后半段',
messageId: 'assistant-1',
type: EventType.TEXT_MESSAGE_CONTENT,
});
options.onEvent({
runId: 'run-test',
threadId: 'recover-session',
type: EventType.RUN_FINISHED,
});
},
);
useUserStore().setUserInfo({
avatar: '',
id: 'recover-user',
loginName: 'recover-user',
nickname: '恢复用户',
tenantId: 'tenant-1',
});
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '恢复测试',
sessionId: 'recover-session',
});
await vi.waitFor(() => {
expect(aguiMocks.resume).toHaveBeenCalledTimes(1);
expect(agentChatRuntimeManager.getSnapshot('recover-session')).toEqual(
expect.objectContaining({ completed: true, sending: false }),
);
});
const timeline = JSON.stringify(
agentChatRuntimeManager.getSnapshot('recover-session')?.items,
);
expect(timeline).toContain('前半段后半段');
expect(timeline.match(/前半段/g)).toHaveLength(1);
});
it('pagehide 只断开页面传输并保留可恢复运行态', async () => {
aguiMocks.run.mockImplementation(() => new Promise(() => {}));
aguiMocks.resume.mockImplementation(() => new Promise(() => {}));
useUserStore().setUserInfo({
avatar: '',
id: 'reload-user',
loginName: 'reload-user',
nickname: '刷新用户',
tenantId: 'tenant-1',
});
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '刷新测试',
sessionId: 'reload-session',
});
globalThis.dispatchEvent(new Event('pagehide'));
expect(aguiMocks.detach).toHaveBeenCalled();
expect(agentChatRuntimeManager.getSnapshot('reload-session')).toEqual(
expect.objectContaining({ completed: false, sending: true }),
);
globalThis.dispatchEvent(new Event('pageshow'));
expect(aguiMocks.resume).toHaveBeenCalled();
});
});