feat: 支持 Agent 运行刷新恢复

- 解耦 Runtime 与浏览器 SSE 订阅并增加 Redis 游标日志

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

- 完善显式取消、owner 丢失、容量限制与故障测试
This commit is contained in:
2026-08-20 11:18:10 +08:00
parent 310fc1fb58
commit fa07134cf8
36 changed files with 4876 additions and 322 deletions

View File

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