feat: 支持 Agent 运行刷新恢复
- 解耦 Runtime 与浏览器 SSE 订阅并增加 Redis 游标日志 - 支持正式聊天与草稿试运行刷新重连和权威终态恢复 - 完善显式取消、owner 丢失、容量限制与故障测试
This commit is contained in:
@@ -1,25 +1,50 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { EasyFlowAguiRunOptions } from '../../shared/agent-agui/client';
|
||||
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 new Promise(() => {});
|
||||
return aguiMocks.run(options);
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -32,10 +57,24 @@ describe('useAgentTryoutStream', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
aguiMocks.runs.length = 0;
|
||||
vi.clearAllMocks();
|
||||
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,
|
||||
@@ -48,7 +87,7 @@ describe('useAgentTryoutStream', () => {
|
||||
|
||||
void stream.sendDraft(payload);
|
||||
await Promise.resolve();
|
||||
stream.stop();
|
||||
await stream.stop();
|
||||
void stream.sendDraft({ ...payload, prompt: '新问题' });
|
||||
await Promise.resolve();
|
||||
|
||||
@@ -177,6 +216,227 @@ describe('useAgentTryoutStream', () => {
|
||||
sortNo: 3,
|
||||
},
|
||||
]);
|
||||
stream.stop();
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user