// @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 () => { let resolveRun: (() => void) | undefined; let runOptions: EasyFlowAguiRunOptions | undefined; aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => { runOptions = options; return new Promise((resolve) => { resolveRun = resolve; }); }); useUserStore().setUserInfo({ avatar: '', id: 'retry-user', loginName: 'retry-user', nickname: '重试用户', tenantId: 'tenant-1', }); await agentChatRuntimeManager.start({ agentId: 'agent-1', prompt: '介绍文档', sessionId: 'retry-session', }); runOptions?.onEvent({ name: easyFlowAguiCustomEvent.inputAccepted, type: EventType.CUSTOM, value: {}, }); runOptions?.onEvent({ code: 'MODEL_ERROR', message: 'Retries exhausted: 2/2', runId: 'run-test', threadId: 'retry-session', type: EventType.RUN_ERROR, }); resolveRun?.(); await Promise.resolve(); await Promise.resolve(); const snapshot = agentChatRuntimeManager.getSnapshot('retry-session'); expect(snapshot).toEqual( expect.objectContaining({ error: '模型连接异常', retryContextReady: true, terminalOutcome: 'failed', }), ); expect(snapshot?.items.find((item) => item.type === 'error')).toEqual( expect.objectContaining({ message: '模型连接异常' }), ); }); 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((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((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((resolve) => { resolveRun = resolve; }); }); aguiMocks.detach.mockImplementationOnce(() => resolveRun?.()); aguiMocks.cancel.mockImplementation( () => new Promise((_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((resolve) => { resolveRun = resolve; }); }); aguiMocks.cancel.mockImplementation( () => new Promise((_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(); }); });