feat: 支持 Agent 运行刷新恢复
- 解耦 Runtime 与浏览器 SSE 订阅并增加 Redis 游标日志 - 支持正式聊天与草稿试运行刷新重连和权威终态恢复 - 完善显式取消、owner 丢失、容量限制与故障测试
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { EasyFlowAguiRunOptions } from '../shared/agent-agui/client';
|
||||
import type {
|
||||
EasyFlowAguiResumeOptions,
|
||||
EasyFlowAguiRunOptions,
|
||||
} from '../shared/agent-agui/client';
|
||||
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
|
||||
@@ -15,12 +18,32 @@ 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;
|
||||
},
|
||||
}));
|
||||
@@ -34,6 +57,9 @@ describe('agentChatRuntimeManager', () => {
|
||||
setActivePinia(createPinia());
|
||||
sessionStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
aguiMocks.cancel.mockResolvedValue(undefined);
|
||||
aguiMocks.resume.mockImplementation(() => new Promise(() => {}));
|
||||
aguiMocks.run.mockImplementation(() => new Promise(() => {}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -155,6 +181,163 @@ describe('agentChatRuntimeManager', () => {
|
||||
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;
|
||||
@@ -280,13 +463,26 @@ describe('agentChatRuntimeManager', () => {
|
||||
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',
|
||||
});
|
||||
agentChatRuntimeManager.stop('race-session');
|
||||
await agentChatRuntimeManager.stop('race-session');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await agentChatRuntimeManager.start({
|
||||
agentId: 'agent-1',
|
||||
prompt: '新问题',
|
||||
@@ -360,4 +556,238 @@ describe('agentChatRuntimeManager', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user