- 解耦 Runtime 与浏览器 SSE 订阅并增加 Redis 游标日志 - 支持正式聊天与草稿试运行刷新重连和权威终态恢复 - 完善显式取消、owner 丢失、容量限制与故障测试
443 lines
13 KiB
TypeScript
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',
|
|
);
|
|
});
|
|
});
|