feat: 支持 Agent 运行刷新恢复

- 解耦 Runtime 与浏览器 SSE 订阅并增加 Redis 游标日志

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

- 完善显式取消、owner 丢失、容量限制与故障测试
This commit is contained in:
2026-08-20 11:18:10 +08:00
parent 310fc1fb58
commit fa07134cf8
36 changed files with 4876 additions and 322 deletions

View File

@@ -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',
);
});
});