Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutStream.test.ts
陈子默 fa07134cf8 feat: 支持 Agent 运行刷新恢复
- 解耦 Runtime 与浏览器 SSE 订阅并增加 Redis 游标日志

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

- 完善显式取消、owner 丢失、容量限制与故障测试
2026-08-20 11:18:10 +08:00

443 lines
13 KiB
TypeScript

// @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<string, unknown>;
knowledgeBindings: Record<string, unknown>[];
skillBindings: Record<string, unknown>[];
toolBindings: Record<string, unknown>[];
};
};
};
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<undefined>((resolve) => {
resolveCancel = resolve;
}),
);
aguiMocks.run.mockImplementation(
() =>
new Promise<void>((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',
);
});
});