import { EventType } from '@ag-ui/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { EasyFlowAguiClient, EasyFlowAguiProjectionError } from './client'; import { isRetryableAguiTransportError } from './reconnect'; vi.mock('#/api/request', () => ({ createEventStreamHeaders: () => ({ 'easyflow-token': 'test-token' }), resolveApiUrl: (url: string) => `http://localhost${url}`, })); function sse(events: unknown[]) { return new Response( events .map( (event, index) => `id: ${index + 1}\ndata: ${JSON.stringify(event)}\n\n`, ) .join(''), { headers: { 'Content-Type': 'text/event-stream' }, status: 200 }, ); } describe('easyFlowAguiClient', () => { afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); }); it('trims outbound history, tools, context and state at the transport boundary', async () => { let requestBody: Record | undefined; const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { requestBody = JSON.parse(String(init?.body)); return sse([ { runId: 'run-1', threadId: '101', type: EventType.RUN_STARTED }, { runId: 'run-1', threadId: '101', type: EventType.RUN_FINISHED, }, ]); }); vi.stubGlobal('fetch', fetchMock); const received: string[] = []; const cursors: number[] = []; await new EasyFlowAguiClient().run({ forwardedProps: { easyflow: { input: { imageUploadIds: ['image-1'] } } }, onEvent: (event) => { received.push(event.type); }, onCursor: (cursor) => { cursors.push(cursor); }, threadId: '101', url: '/api/v1/agent/1/agui/run', userMessage: { content: '你好', id: 'user-1', role: 'user' }, }); expect(received).toEqual([EventType.RUN_STARTED, EventType.RUN_FINISHED]); expect(cursors).toEqual([1, 2]); expect(requestBody).toEqual( expect.objectContaining({ context: [], messages: [{ content: '你好', id: 'user-1', role: 'user' }], state: {}, tools: [], }), ); }); it('converts proxied forwardedProps to transport JSON before the SDK clones input', async () => { let requestBody: Record | undefined; vi.stubGlobal( 'fetch', vi.fn(async (_url: string, init?: RequestInit) => { requestBody = JSON.parse(String(init?.body)); return sse([ { runId: 'run-1', threadId: 'draft-1', type: EventType.RUN_STARTED }, { runId: 'run-1', threadId: 'draft-1', type: EventType.RUN_FINISHED, }, ]); }), ); const forwardedProps = new Proxy( { easyflow: { draft: { agent: { id: 'agent-1' } } } }, {}, ); await expect( new EasyFlowAguiClient().run({ forwardedProps, onEvent: () => undefined, threadId: 'draft-1', url: '/api/v1/agent/agui/run/draft', userMessage: { content: '你好', id: 'user-1', role: 'user' }, }), ).resolves.toBeUndefined(); expect(requestBody?.forwardedProps).toEqual(forwardedProps); }); it('rejects a clean EOF without RUN_FINISHED or RUN_ERROR', async () => { vi.stubGlobal( 'fetch', vi.fn(async () => sse([{ runId: 'run-1', threadId: '101', type: EventType.RUN_STARTED }]), ), ); await expect( new EasyFlowAguiClient().run({ onEvent: () => undefined, threadId: '101', url: '/api/v1/agent/1/agui/run', userMessage: { content: '你好', id: 'user-1', role: 'user' }, }), ).rejects.toThrow('缺少终态'); }); it('marks deterministic event projection failures as non-retryable', async () => { vi.stubGlobal( 'fetch', vi.fn(async () => sse([{ runId: 'run-1', threadId: '101', type: EventType.RUN_STARTED }]), ), ); const run = new EasyFlowAguiClient().run({ onEvent: () => { throw new Error('projection failed'); }, threadId: '101', url: '/api/v1/agent/1/agui/run', userMessage: { content: '你好', id: 'user-1', role: 'user' }, }); await expect(run).rejects.toBeInstanceOf(EasyFlowAguiProjectionError); expect( isRetryableAguiTransportError( new EasyFlowAguiProjectionError(new Error('projection failed')), Date.now(), ), ).toBe(false); }); it('treats a standard cancelled terminal as an accepted stop', async () => { vi.stubGlobal( 'fetch', vi.fn(async () => sse([ { runId: 'run-1', threadId: '101', type: EventType.RUN_STARTED }, { code: 'RUN_CANCELLED', message: '用户拒绝执行', runId: 'run-1', threadId: '101', type: EventType.RUN_ERROR, }, ]), ), ); await expect( new EasyFlowAguiClient().run({ onEvent: () => undefined, threadId: '101', url: '/api/v1/agent/1/agui/run', userMessage: { content: '你好', id: 'user-1', role: 'user' }, }), ).resolves.toBeUndefined(); }); it('does not hide projection errors raised by a cancelled terminal', async () => { vi.stubGlobal( 'fetch', vi.fn(async () => sse([ { code: 'RUN_CANCELLED', message: '用户停止', runId: 'run-1', threadId: '101', type: EventType.RUN_ERROR, }, ]), ), ); await expect( new EasyFlowAguiClient().run({ onEvent: () => { throw new Error('cancel projection failed'); }, threadId: '101', url: '/api/v1/agent/1/agui/run', userMessage: { content: '你好', id: 'user-1', role: 'user' }, }), ).rejects.toBeInstanceOf(EasyFlowAguiProjectionError); }); it('yields a paint opportunity before consuming events after tool start', async () => { vi.useFakeTimers(); let paintCallback: FrameRequestCallback | undefined; vi.stubGlobal( 'requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { paintCallback = callback; return 1; }), ); vi.stubGlobal( 'fetch', vi.fn(async () => sse([ { runId: 'run-1', threadId: '101', type: EventType.RUN_STARTED }, { runId: 'run-1', threadId: '101', toolCallId: 'tool-1', toolCallName: 'write_text_file', type: EventType.TOOL_CALL_START, }, { delta: '{}', runId: 'run-1', threadId: '101', toolCallId: 'tool-1', type: EventType.TOOL_CALL_ARGS, }, { runId: 'run-1', threadId: '101', toolCallId: 'tool-1', type: EventType.TOOL_CALL_END, }, { content: 'done', messageId: 'tool-result-1', role: 'tool', runId: 'run-1', threadId: '101', toolCallId: 'tool-1', type: EventType.TOOL_CALL_RESULT, }, { runId: 'run-1', threadId: '101', type: EventType.RUN_FINISHED, }, ]), ), ); const received: string[] = []; const runPromise = new EasyFlowAguiClient().run({ onEvent: (event) => { received.push(event.type); }, threadId: '101', url: '/api/v1/agent/1/agui/run', userMessage: { content: '生成文件', id: 'user-1', role: 'user' }, }); await vi.advanceTimersByTimeAsync(0); expect(received).toContain(EventType.TOOL_CALL_START); expect(received).not.toContain(EventType.TOOL_CALL_RESULT); paintCallback?.(0); await vi.advanceTimersByTimeAsync(0); await runPromise; expect(received).toContain(EventType.TOOL_CALL_RESULT); expect(received.at(-1)).toBe(EventType.RUN_FINISHED); }); it('replays a completed run from the server journal after refresh', async () => { vi.stubGlobal( 'fetch', vi.fn( async () => new Response( [ 'id: 1\ndata: {"type":"RUN_STARTED","threadId":"101","runId":"run-resume"}\n\n', 'id: 2\ndata: {"type":"TEXT_MESSAGE_START","messageId":"assistant-1","role":"assistant"}\n\n', 'id: 3\ndata: {"type":"TEXT_MESSAGE_CONTENT","messageId":"assistant-1","delta":"刷新后恢复"}\n\n', 'id: 4\ndata: {"type":"TEXT_MESSAGE_END","messageId":"assistant-1"}\n\n', 'id: 5\ndata: {"type":"RUN_FINISHED","threadId":"101","runId":"run-resume"}\n\n', ].join(''), { headers: { 'Content-Type': 'text/event-stream' }, status: 200 }, ), ), ); const received: string[] = []; const cursors: number[] = []; await new EasyFlowAguiClient().resume({ onCursor: (cursor) => { cursors.push(cursor); }, onEvent: (event) => { received.push(event.type); }, runId: 'run-resume', }); expect(received).toEqual([ EventType.RUN_STARTED, EventType.TEXT_MESSAGE_START, EventType.TEXT_MESSAGE_CONTENT, EventType.TEXT_MESSAGE_END, EventType.RUN_FINISHED, ]); expect(cursors).toEqual([1, 2, 3, 4, 5]); expect(fetch).toHaveBeenCalledWith( expect.stringContaining('/run-resume/events?after=0'), expect.objectContaining({ method: 'GET' }), ); }); it('uses the explicit cancel endpoint instead of treating detach as cancel', async () => { const fetchMock = vi.fn( async () => new Response(JSON.stringify({ errorCode: 0 }), { headers: { 'Content-Type': 'application/json' }, status: 200, }), ); vi.stubGlobal('fetch', fetchMock); await new EasyFlowAguiClient().cancel('run-cancel'); expect(fetchMock).toHaveBeenCalledWith( expect.stringContaining('/run-cancel/cancel'), expect.objectContaining({ method: 'POST' }), ); }); it('keeps the active stream attached when the cancel request fails', async () => { let runSignal: AbortSignal | undefined; const fetchMock = vi.fn((url: string, init?: RequestInit) => { if (url.includes('/cancel')) { return Promise.resolve( new Response(JSON.stringify({ message: '取消服务暂不可用' }), { headers: { 'Content-Type': 'application/json' }, status: 503, }), ); } runSignal = init?.signal as AbortSignal; return new Promise((_resolve, reject) => { runSignal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), ); }); }); vi.stubGlobal('fetch', fetchMock); const client = new EasyFlowAguiClient(); const runPromise = client.run({ onEvent: () => undefined, runId: 'run-cancel-failure', threadId: '101', url: '/api/v1/agent/1/agui/run', userMessage: { content: '你好', id: 'user-1', role: 'user' }, }); await expect(client.cancel('run-cancel-failure')).rejects.toThrow( '取消服务暂不可用', ); expect(runSignal?.aborted).toBe(false); client.detach(); expect(runSignal?.aborted).toBe(true); await runPromise; }); });