// @vitest-environment happy-dom import type { EasyFlowAguiResumeOptions, EasyFlowAguiRunOptions, } from '../../shared/agent-agui/client'; import type { AgentInfo } from '../types'; import { EventType } from '@ag-ui/client'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { easyFlowAguiCustomEvent } from '../../shared/agent-agui/custom-events'; import { useAgentTryoutStream } from './useAgentTryoutStream'; const aguiMocks = vi.hoisted(() => ({ abort: vi.fn(), cancel: vi.fn(async () => undefined), detach: vi.fn(), resume: vi.fn(), run: vi.fn(), runs: [] as EasyFlowAguiRunOptions[], })); 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(options: EasyFlowAguiRunOptions) { aguiMocks.runs.push(options); return aguiMocks.run(options); } }, })); vi.mock('../api', () => ({ clearAgentDraftSession: vi.fn(async () => ({ errorCode: 0 })), })); describe('useAgentTryoutStream', () => { beforeEach(() => { sessionStorage.clear(); aguiMocks.runs.length = 0; vi.resetAllMocks(); aguiMocks.cancel.mockResolvedValue(undefined); aguiMocks.run.mockImplementation(() => new Promise(() => {})); aguiMocks.resume.mockImplementation(() => new Promise(() => {})); }); it('ignores late events from a stopped draft run after resending', async () => { aguiMocks.resume.mockImplementationOnce( async (options: EasyFlowAguiResumeOptions) => { options.onEvent({ code: 'RUN_CANCELLED', message: '用户已停止生成', runId: 'run-test', threadId: 'draft-race', type: EventType.RUN_ERROR, }); }, ); const stream = useAgentTryoutStream(); const payload = { agent: { id: 'agent-1', name: 'Agent' } as AgentInfo, knowledgeBindings: [], prompt: '旧问题', sessionId: 'draft-race', skillBindings: [], toolBindings: [], }; void stream.sendDraft(payload); await Promise.resolve(); await stream.stop(); void stream.sendDraft({ ...payload, prompt: '新问题' }); await Promise.resolve(); aguiMocks.runs[0]?.onEvent({ delta: '旧流迟到正文', messageId: 'old-assistant', type: EventType.TEXT_MESSAGE_CONTENT, }); aguiMocks.runs[1]?.onEvent({ delta: '新流正文', messageId: 'new-assistant', type: EventType.TEXT_MESSAGE_CONTENT, }); const timeline = JSON.stringify(stream.timelineItems.value); expect(timeline).toContain('新流正文'); expect(timeline).not.toContain('旧流迟到正文'); }); it('sends only the draft runtime snapshot through forwardedProps', async () => { const stream = useAgentTryoutStream(); void stream.sendDraft({ agent: { created: '2026-08-14 10:00:00', displayPublishStatus: 'PUBLISHED', id: 'agent-1', modelId: 'model-1', name: 'Agent', publishedSnapshotJson: { internal: true }, }, knowledgeBindings: [ { agentId: 'agent-1', enabled: true, id: 'knowledge-binding-1', knowledgeId: 'knowledge-1', localId: 'local-knowledge', optionsJson: { topK: 5 }, resourceSnapshot: { internal: true }, resourceSummary: { displayName: '知识库' }, retrievalMode: 'HYBRID', sortNo: 2, }, ], prompt: '问题', sessionId: 'draft-snapshot', skillBindings: [ { agentId: 'agent-1', id: 'skill-binding-1', resourceSummary: { displayName: '合同审查', toolCount: 2, }, skillId: 'skill-1', sortNo: 3, }, ], toolBindings: [ { agentId: 'agent-1', enabled: true, hitlConfigJson: { message: '请确认' }, hitlEnabled: true, id: 'tool-binding-1', localId: 'local-tool', optionsJson: { timeout: 30 }, resourceSnapshot: { internal: true }, resourceSummary: { displayName: '工具' }, sortNo: 1, targetId: 'tool-1', toolName: 'search', toolType: 'PLUGIN', }, ], }); await Promise.resolve(); const forwardedProps = aguiMocks.runs[0]?.forwardedProps as { easyflow: { draft: { agent: Record; knowledgeBindings: Record[]; skillBindings: Record[]; toolBindings: Record[]; }; }; }; expect(forwardedProps.easyflow.draft.agent).toEqual( expect.objectContaining({ id: 'agent-1', modelId: 'model-1', name: 'Agent', }), ); expect(forwardedProps.easyflow.draft.agent).not.toHaveProperty('created'); expect(forwardedProps.easyflow.draft.agent).not.toHaveProperty( 'publishedSnapshotJson', ); expect(forwardedProps.easyflow.draft.toolBindings).toEqual([ { enabled: true, hitlConfigJson: { message: '请确认' }, hitlEnabled: true, id: 'tool-binding-1', optionsJson: { timeout: 30 }, sortNo: 1, targetId: 'tool-1', toolName: 'search', toolType: 'PLUGIN', }, ]); expect(forwardedProps.easyflow.draft.knowledgeBindings).toEqual([ { enabled: true, id: 'knowledge-binding-1', knowledgeId: 'knowledge-1', optionsJson: { topK: 5 }, retrievalMode: 'HYBRID', sortNo: 2, }, ]); expect(forwardedProps.easyflow.draft.skillBindings).toEqual([ { skillId: 'skill-1', sortNo: 3, }, ]); aguiMocks.resume.mockImplementationOnce( async (options: EasyFlowAguiResumeOptions) => { options.onEvent({ code: 'RUN_CANCELLED', message: '用户已停止生成', runId: 'run-test', threadId: 'draft-snapshot', type: EventType.RUN_ERROR, }); }, ); await stream.stop(); }); it('刷新跨过输入确认事件后会幂等清理恢复的草稿', async () => { const payload = { agent: { id: 'agent-accepted', name: 'Agent' } as AgentInfo, knowledgeBindings: [], prompt: '带附件的问题', sessionId: 'draft-accepted-restore', skillBindings: [], toolBindings: [], }; const firstStream = useAgentTryoutStream(); void firstStream.sendDraft(payload); await Promise.resolve(); aguiMocks.runs[0]?.onEvent({ name: easyFlowAguiCustomEvent.inputAccepted, type: EventType.CUSTOM, value: {}, }); aguiMocks.runs[0]?.onCursor?.(4); firstStream.dispose(); const restoredAccepted = vi.fn(); const restoredStream = useAgentTryoutStream(); restoredStream.syncDraftContext( payload, true, payload.sessionId, restoredAccepted, ); await Promise.resolve(); expect(restoredAccepted).toHaveBeenCalledTimes(1); expect(aguiMocks.resume).toHaveBeenCalledWith( expect.objectContaining({ afterCursor: 4, runId: 'run-test' }), ); aguiMocks.resume.mockImplementationOnce( async (options: EasyFlowAguiResumeOptions) => { options.onEvent({ code: 'RUN_CANCELLED', message: '用户已停止生成', runId: 'run-test', threadId: 'draft-accepted-restore', type: EventType.RUN_ERROR, }); }, ); await restoredStream.stop(); }); it('草稿流短暂断网后从最后游标恢复且不伪造本地失败', async () => { vi.useFakeTimers(); 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: 'draft-network-recovery', type: EventType.RUN_FINISHED, }); }); const stream = useAgentTryoutStream(); const runPromise = stream.sendDraft({ agent: { id: 'agent-network', name: 'Agent' } as AgentInfo, knowledgeBindings: [], prompt: '网络恢复', sessionId: 'draft-network-recovery', skillBindings: [], toolBindings: [], }); await vi.advanceTimersByTimeAsync(1000); await runPromise; expect(aguiMocks.resume).toHaveBeenCalledTimes(3); expect(stream.loading.value).toBe(false); expect(JSON.stringify(stream.timelineItems.value)).not.toContain( '试运行失败', ); }); it('停止请求失败时不会解引用已清空运行并会重连服务端运行', async () => { aguiMocks.cancel.mockRejectedValueOnce(new Error('停止接口不可用')); aguiMocks.resume.mockImplementation(() => new Promise(() => {})); const stream = useAgentTryoutStream(); void stream.sendDraft({ agent: { id: 'agent-stop-failed', name: 'Agent' } as AgentInfo, knowledgeBindings: [], prompt: '停止失败恢复', sessionId: 'draft-stop-failed', skillBindings: [], toolBindings: [], }); await Promise.resolve(); await expect(stream.stop()).rejects.toThrow('停止接口不可用'); expect(aguiMocks.detach).toHaveBeenCalled(); expect(aguiMocks.resume).toHaveBeenCalledTimes(1); expect(stream.loading.value).toBe(true); expect(JSON.stringify(stream.timelineItems.value)).toContain( '停止失败恢复', ); stream.dispose(); }); it('运行中拒绝清理会话且不取消服务端运行', async () => { const stream = useAgentTryoutStream(); void stream.sendDraft({ agent: { id: 'agent-clear-failed', name: 'Agent' } as AgentInfo, knowledgeBindings: [], prompt: '应保留的草稿', sessionId: 'draft-clear-failed', skillBindings: [], toolBindings: [], }); await Promise.resolve(); await expect(stream.clearDraftSession()).rejects.toThrow( '试运行进行中,暂时无法清理会话', ); expect(aguiMocks.cancel).not.toHaveBeenCalled(); expect(aguiMocks.resume).not.toHaveBeenCalled(); expect(stream.loading.value).toBe(true); expect(JSON.stringify(stream.timelineItems.value)).toContain( '应保留的草稿', ); stream.dispose(); }); it('取消响应晚于自然完成时保留权威完成态', async () => { let resolveCancel: ((value: undefined) => void) | undefined; let resolveRun: (() => void) | undefined; aguiMocks.cancel.mockImplementation( () => new Promise((resolve) => { resolveCancel = resolve; }), ); aguiMocks.run.mockImplementation( () => new Promise((resolve) => { resolveRun = resolve; }), ); const stream = useAgentTryoutStream(); void stream.sendDraft({ agent: { id: 'agent-stop-race', name: 'Agent' } as AgentInfo, knowledgeBindings: [], prompt: '自然完成优先', sessionId: 'draft-stop-terminal-race', skillBindings: [], toolBindings: [], }); await Promise.resolve(); const stopPromise = stream.stop(); aguiMocks.runs[0]?.onEvent({ runId: 'run-test', threadId: 'draft-stop-terminal-race', type: EventType.RUN_FINISHED, }); aguiMocks.runs[0]?.onCursor?.(1); resolveRun?.(); resolveCancel?.(undefined); await stopPromise; expect(stream.loading.value).toBe(false); expect(JSON.stringify(stream.timelineItems.value)).toContain( '"turnSucceeded":true', ); expect(JSON.stringify(stream.timelineItems.value)).not.toContain( '"turnSucceeded":false', ); }); it('取消成功后重放权威终态且保留尚未投影的自然完成', async () => { aguiMocks.resume.mockImplementationOnce( async (options: EasyFlowAguiResumeOptions) => { options.onEvent({ runId: 'run-test', threadId: 'draft-stop-authoritative', type: EventType.RUN_FINISHED, }); options.onCursor?.(9); }, ); const stream = useAgentTryoutStream(); void stream.sendDraft({ agent: { id: 'agent-stop-authoritative', name: 'Agent' } as AgentInfo, knowledgeBindings: [], prompt: '取消时服务端已经自然完成', sessionId: 'draft-stop-authoritative', skillBindings: [], toolBindings: [], }); await Promise.resolve(); await stream.stop(); expect(stream.loading.value).toBe(false); expect(JSON.stringify(stream.timelineItems.value)).toContain( '"turnSucceeded":true', ); expect(JSON.stringify(stream.timelineItems.value)).not.toContain( '"turnSucceeded":false', ); }); });