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,6 +1,9 @@
// @vitest-environment happy-dom
import type { EasyFlowAguiRunOptions } from '../shared/agent-agui/client';
import type {
EasyFlowAguiResumeOptions,
EasyFlowAguiRunOptions,
} from '../shared/agent-agui/client';
import { useUserStore } from '@easyflow/stores';
@@ -15,12 +18,32 @@ import { agentChatRuntimeManager } from './agentChatRuntimeManager';
const aguiMocks = vi.hoisted(() => ({
abort: vi.fn(),
cancel: vi.fn(async () => undefined),
detach: vi.fn(),
resume: vi.fn(),
run: vi.fn(),
}));
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 = aguiMocks.run;
},
}));
@@ -34,6 +57,9 @@ describe('agentChatRuntimeManager', () => {
setActivePinia(createPinia());
sessionStorage.clear();
vi.clearAllMocks();
aguiMocks.cancel.mockResolvedValue(undefined);
aguiMocks.resume.mockImplementation(() => new Promise(() => {}));
aguiMocks.run.mockImplementation(() => new Promise(() => {}));
});
afterEach(() => {
@@ -155,6 +181,163 @@ describe('agentChatRuntimeManager', () => {
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
});
it('刷新恢复已确认输入时重新触发草稿清理回调', async () => {
const account = {
avatar: '',
id: 'accepted-restore-user',
loginName: 'accepted-restore-user',
nickname: '输入恢复用户',
tenantId: 'tenant-1',
};
useUserStore().setUserInfo(account);
const sessionId = 'accepted-restore-session';
sessionStorage.setItem(
`easyflow:agent-chat-runtime:tenant-1:accepted-restore-user:${sessionId}`,
JSON.stringify({
agentId: 'agent-1',
completed: false,
inputAccepted: true,
items: [],
prompt: '刷新前输入',
runId: 'run-accepted-restore',
roundId: 'round-accepted-restore',
sending: true,
sessionId,
startedAt: Date.now(),
updatedAt: Date.now(),
version: 4,
}),
);
aguiMocks.resume.mockImplementation(() => new Promise(() => {}));
const onInputAccepted = vi.fn();
agentChatRuntimeManager.resume(sessionId, onInputAccepted);
expect(onInputAccepted).toHaveBeenCalledTimes(1);
expect(aguiMocks.resume).toHaveBeenCalledWith(
expect.objectContaining({ runId: 'run-accepted-restore' }),
);
clearAgentChatBrowserCache(account);
});
it('硬刷新后从持久化游标续传且保留已投影正文', async () => {
const account = {
avatar: '',
id: 'cursor-restore-user',
loginName: 'cursor-restore-user',
nickname: '游标恢复用户',
tenantId: 'tenant-1',
};
useUserStore().setUserInfo(account);
const sessionId = 'cursor-restore-session';
sessionStorage.setItem(
`easyflow:agent-chat-runtime:tenant-1:cursor-restore-user:${sessionId}`,
JSON.stringify({
agentId: 'agent-1',
completed: false,
inputAccepted: false,
items: [
{
id: 'assistant-cursor',
parts: [
{
content: '刷新前正文',
id: 'text-cursor',
type: 'text',
},
],
role: 'assistant',
roundId: 'round-cursor',
status: 'streaming',
type: 'message',
},
],
lastCursor: 9,
prompt: '游标恢复',
projectionToolArgs: {},
projectionToolNames: {},
runId: 'run-cursor-restore',
roundId: 'round-cursor',
sending: true,
sessionId,
startedAt: Date.now(),
updatedAt: Date.now(),
version: 5,
}),
);
aguiMocks.resume.mockImplementationOnce(
async (options: EasyFlowAguiResumeOptions) => {
expect(options.afterCursor).toBe(9);
options.onEvent({
delta: '续传正文',
messageId: 'assistant-cursor',
type: EventType.TEXT_MESSAGE_CONTENT,
});
options.onCursor?.(10);
options.onEvent({
runId: 'run-cursor-restore',
threadId: sessionId,
type: EventType.RUN_FINISHED,
});
options.onCursor?.(11);
},
);
agentChatRuntimeManager.resume(sessionId);
expect(aguiMocks.resume).toHaveBeenCalledTimes(1);
expect(aguiMocks.resume).toHaveBeenCalledWith(
expect.objectContaining({ afterCursor: 9 }),
);
await vi.waitFor(() => {
expect(agentChatRuntimeManager.getSnapshot(sessionId)).toEqual(
expect.objectContaining({ completed: true, lastCursor: 11 }),
);
});
const timeline = JSON.stringify(
agentChatRuntimeManager.getSnapshot(sessionId)?.items,
);
expect(timeline).toContain('刷新前正文续传正文');
clearAgentChatBrowserCache(account);
});
it('短暂断网超过旧重试窗口后仍按游标恢复到服务端终态', async () => {
vi.useFakeTimers();
const account = {
avatar: '',
id: 'reconnect-user',
loginName: 'reconnect-user',
nickname: '重连用户',
tenantId: 'tenant-1',
};
useUserStore().setUserInfo(account);
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: 'reconnect-session',
type: EventType.RUN_FINISHED,
});
});
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '断网恢复',
sessionId: 'reconnect-session',
});
await vi.advanceTimersByTimeAsync(1000);
expect(aguiMocks.resume).toHaveBeenCalledTimes(3);
expect(agentChatRuntimeManager.getSnapshot('reconnect-session')).toEqual(
expect.objectContaining({ completed: true, sending: false }),
);
clearAgentChatBrowserCache(account);
});
it('合并流式通知并在 AG-UI 终态立即持久化', async () => {
vi.useFakeTimers();
let runOptions: EasyFlowAguiRunOptions | undefined;
@@ -280,13 +463,26 @@ describe('agentChatRuntimeManager', () => {
nickname: '竞态用户',
tenantId: 'tenant-1',
});
aguiMocks.resume.mockImplementationOnce(
async (options: EasyFlowAguiResumeOptions) => {
options.onEvent({
code: 'RUN_CANCELLED',
message: '用户已停止生成',
runId: 'run-test',
threadId: 'race-session',
type: EventType.RUN_ERROR,
});
},
);
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '旧问题',
sessionId: 'race-session',
});
agentChatRuntimeManager.stop('race-session');
await agentChatRuntimeManager.stop('race-session');
await Promise.resolve();
await Promise.resolve();
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '新问题',
@@ -360,4 +556,238 @@ describe('agentChatRuntimeManager', () => {
}),
);
});
it('取消请求失败且原流无终态时保留等待期间事件并立即恢复订阅', async () => {
let rejectCancel: ((error: Error) => void) | undefined;
let resolveRun: (() => void) | undefined;
let runOptions: EasyFlowAguiRunOptions | undefined;
aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => {
runOptions = options;
return new Promise<void>((resolve) => {
resolveRun = resolve;
});
});
aguiMocks.detach.mockImplementationOnce(() => resolveRun?.());
aguiMocks.cancel.mockImplementation(
() =>
new Promise<undefined>((_resolve, reject) => {
rejectCancel = reject;
}),
);
aguiMocks.resume.mockImplementation(() => new Promise(() => {}));
useUserStore().setUserInfo({
avatar: '',
id: 'cancel-failed-user',
loginName: 'cancel-failed-user',
nickname: '取消失败用户',
tenantId: 'tenant-1',
});
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '取消失败恢复',
sessionId: 'cancel-failed-session',
});
const stopPromise = agentChatRuntimeManager.stop('cancel-failed-session');
runOptions?.onEvent({
delta: '取消等待期间正文',
messageId: 'assistant-cancel-failed',
type: EventType.TEXT_MESSAGE_CONTENT,
});
runOptions?.onCursor?.(1);
rejectCancel?.(new Error('取消接口暂时不可用'));
await expect(stopPromise).rejects.toThrow('取消接口暂时不可用');
await Promise.resolve();
await Promise.resolve();
expect(
JSON.stringify(
agentChatRuntimeManager.getSnapshot('cancel-failed-session')?.items,
),
).toContain('取消等待期间正文');
expect(
agentChatRuntimeManager.getSnapshot('cancel-failed-session'),
).toEqual(expect.objectContaining({ sending: true }));
expect(aguiMocks.resume).toHaveBeenCalledTimes(1);
});
it('取消成功后重放服务端终态且不把尚未投影的自然完成改成取消', async () => {
aguiMocks.resume.mockImplementationOnce(
async (options: EasyFlowAguiResumeOptions) => {
options.onEvent({
runId: 'run-test',
threadId: 'cancel-authoritative-session',
type: EventType.RUN_FINISHED,
});
options.onCursor?.(8);
},
);
useUserStore().setUserInfo({
avatar: '',
id: 'cancel-authoritative-user',
loginName: 'cancel-authoritative-user',
nickname: '取消终态仲裁用户',
tenantId: 'tenant-1',
});
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '取消时服务端已经自然完成',
sessionId: 'cancel-authoritative-session',
});
await agentChatRuntimeManager.stop('cancel-authoritative-session');
await Promise.resolve();
await Promise.resolve();
expect(
agentChatRuntimeManager.getSnapshot('cancel-authoritative-session'),
).toEqual(
expect.objectContaining({
completed: true,
lastCursor: 8,
sending: false,
terminalOutcome: 'success',
}),
);
expect(
JSON.stringify(
agentChatRuntimeManager.getSnapshot('cancel-authoritative-session')
?.items,
),
).not.toContain('"turnSucceeded":false');
});
it('取消请求失败但原流已到终态时直接展示权威完成态', async () => {
let rejectCancel: ((error: Error) => void) | undefined;
let resolveRun: (() => void) | undefined;
let runOptions: EasyFlowAguiRunOptions | undefined;
aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => {
runOptions = options;
return new Promise<void>((resolve) => {
resolveRun = resolve;
});
});
aguiMocks.cancel.mockImplementation(
() =>
new Promise<undefined>((_resolve, reject) => {
rejectCancel = reject;
}),
);
useUserStore().setUserInfo({
avatar: '',
id: 'cancel-terminal-user',
loginName: 'cancel-terminal-user',
nickname: '取消终态用户',
tenantId: 'tenant-1',
});
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '取消与完成竞态',
sessionId: 'cancel-terminal-session',
});
const stopPromise = agentChatRuntimeManager.stop('cancel-terminal-session');
runOptions?.onEvent({
runId: 'run-test',
threadId: 'cancel-terminal-session',
type: EventType.RUN_FINISHED,
});
runOptions?.onCursor?.(1);
resolveRun?.();
await Promise.resolve();
rejectCancel?.(new Error('取消接口晚到失败'));
await expect(stopPromise).rejects.toThrow('取消接口晚到失败');
expect(
agentChatRuntimeManager.getSnapshot('cancel-terminal-session'),
).toEqual(
expect.objectContaining({
completed: true,
sending: false,
terminalOutcome: 'success',
}),
);
expect(aguiMocks.resume).not.toHaveBeenCalled();
});
it('首条传输闪断后按最后游标恢复且不会重复投影', async () => {
aguiMocks.run.mockImplementation(
async (options: EasyFlowAguiRunOptions) => {
options.onEvent({
delta: '前半段',
messageId: 'assistant-1',
type: EventType.TEXT_MESSAGE_CONTENT,
});
options.onCursor?.(7);
throw new Error('network reset');
},
);
aguiMocks.resume.mockImplementation(
async (options: EasyFlowAguiResumeOptions) => {
expect(options.afterCursor).toBe(7);
options.onEvent({
delta: '后半段',
messageId: 'assistant-1',
type: EventType.TEXT_MESSAGE_CONTENT,
});
options.onEvent({
runId: 'run-test',
threadId: 'recover-session',
type: EventType.RUN_FINISHED,
});
},
);
useUserStore().setUserInfo({
avatar: '',
id: 'recover-user',
loginName: 'recover-user',
nickname: '恢复用户',
tenantId: 'tenant-1',
});
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '恢复测试',
sessionId: 'recover-session',
});
await vi.waitFor(() => {
expect(aguiMocks.resume).toHaveBeenCalledTimes(1);
expect(agentChatRuntimeManager.getSnapshot('recover-session')).toEqual(
expect.objectContaining({ completed: true, sending: false }),
);
});
const timeline = JSON.stringify(
agentChatRuntimeManager.getSnapshot('recover-session')?.items,
);
expect(timeline).toContain('前半段后半段');
expect(timeline.match(/前半段/g)).toHaveLength(1);
});
it('pagehide 只断开页面传输并保留可恢复运行态', async () => {
aguiMocks.run.mockImplementation(() => new Promise(() => {}));
aguiMocks.resume.mockImplementation(() => new Promise(() => {}));
useUserStore().setUserInfo({
avatar: '',
id: 'reload-user',
loginName: 'reload-user',
nickname: '刷新用户',
tenantId: 'tenant-1',
});
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '刷新测试',
sessionId: 'reload-session',
});
globalThis.dispatchEvent(new Event('pagehide'));
expect(aguiMocks.detach).toHaveBeenCalled();
expect(agentChatRuntimeManager.getSnapshot('reload-session')).toEqual(
expect.objectContaining({ completed: false, sending: true }),
);
globalThis.dispatchEvent(new Event('pageshow'));
expect(aguiMocks.resume).toHaveBeenCalled();
});
});

View File

@@ -18,11 +18,18 @@ import {
RUNTIME_STORAGE_PREFIX,
} from '#/utils/agent-chat-cache';
import { EasyFlowAguiClient } from '../shared/agent-agui/client';
import {
createAguiRunId,
EasyFlowAguiClient,
} from '../shared/agent-agui/client';
import {
applyAguiEventToTimeline,
createAguiTimelineProjectionState,
} from '../shared/agent-agui/projection';
import {
isRetryableAguiTransportError,
resumeAguiRunUntilTerminal,
} from '../shared/agent-agui/reconnect';
import { generateAgentSessionId } from './api';
interface RuntimeSessionState {
@@ -31,11 +38,19 @@ interface RuntimeSessionState {
completed: boolean;
error?: string;
identity: string;
inputAccepted: boolean;
inputAcceptedNotified: boolean;
items: ChatTimelineItem[];
lastCursor: number;
prompt: string;
projectionToolArgs: Record<string, string>;
projectionToolNames: Record<string, string>;
runId?: string;
roundId: string;
sending: boolean;
sessionId: string;
startedAt: number;
terminalOutcome?: 'failed' | 'success';
updatedAt: number;
}
@@ -44,10 +59,18 @@ interface StoredRuntimeSession {
agentName?: string;
completed: boolean;
error?: string;
inputAccepted: boolean;
items: ChatTimelineItem[];
lastCursor?: number;
prompt: string;
projectionToolArgs?: Record<string, string>;
projectionToolNames?: Record<string, string>;
runId?: string;
roundId: string;
sending: boolean;
sessionId: string;
startedAt: number;
terminalOutcome?: 'failed' | 'success';
updatedAt: number;
version: number;
}
@@ -66,7 +89,7 @@ interface StartOptions {
sessionId?: string;
}
const STORAGE_VERSION = 2;
const STORAGE_VERSION = 5;
const STREAM_NOTIFY_INTERVAL_MS = 50;
const STREAM_PERSIST_INTERVAL_MS = 300;
const MAX_RUNTIME_SESSIONS_PER_IDENTITY = 10;
@@ -76,7 +99,10 @@ const listeners = new Set<() => void>();
const latestSessionIds = new Map<string, string>();
const persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
const runClients = new Map<string, EasyFlowAguiClient>();
const stoppingSessions = new Set<string>();
const inputAcceptedCallbacks = new Map<string, () => Promise<void> | void>();
let notifyTimer: ReturnType<typeof setTimeout> | undefined;
let pageDetaching = false;
function clone<T>(value: T): T {
const serialized = JSON.stringify(value);
@@ -145,10 +171,18 @@ function persistSession(state: RuntimeSessionState) {
agentName: state.agentName,
completed: state.completed,
error: state.error,
inputAccepted: state.inputAccepted,
items: state.items,
lastCursor: state.lastCursor,
prompt: state.prompt,
projectionToolArgs: state.projectionToolArgs,
projectionToolNames: state.projectionToolNames,
runId: state.runId,
roundId: state.roundId,
sending: state.sending,
sessionId: state.sessionId,
startedAt: state.startedAt,
terminalOutcome: state.terminalOutcome,
updatedAt: state.updatedAt,
version: STORAGE_VERSION,
};
@@ -177,6 +211,7 @@ function removeSessionCache(
state: RuntimeSessionState,
) {
cancelPersistTimer(scopedSessionKey);
inputAcceptedCallbacks.delete(scopedSessionKey);
sessions.delete(scopedSessionKey);
try {
safeSessionStorage()?.removeItem(
@@ -262,7 +297,10 @@ function restoreSession(identity: string, sessionId: string) {
return undefined;
}
const parsed = JSON.parse(raw) as StoredRuntimeSession;
if (parsed.version !== STORAGE_VERSION || parsed.sessionId !== sessionId) {
if (
![3, 4, STORAGE_VERSION].includes(parsed.version) ||
parsed.sessionId !== sessionId
) {
return undefined;
}
const restored: RuntimeSessionState = {
@@ -271,11 +309,31 @@ function restoreSession(identity: string, sessionId: string) {
completed: parsed.completed,
error: parsed.error,
identity,
inputAccepted: Boolean(parsed.inputAccepted),
inputAcceptedNotified: false,
items: Array.isArray(parsed.items) ? parsed.items : [],
lastCursor: Math.max(0, Number(parsed.lastCursor || 0)),
prompt: parsed.prompt,
projectionToolArgs:
parsed.projectionToolArgs &&
typeof parsed.projectionToolArgs === 'object'
? parsed.projectionToolArgs
: {},
projectionToolNames:
parsed.projectionToolNames &&
typeof parsed.projectionToolNames === 'object'
? parsed.projectionToolNames
: {},
runId: parsed.runId,
roundId: parsed.roundId,
sending: false,
sending: Boolean(parsed.sending && parsed.runId),
sessionId,
startedAt: Number(parsed.startedAt || parsed.updatedAt || Date.now()),
terminalOutcome:
parsed.terminalOutcome === 'failed' ||
parsed.terminalOutcome === 'success'
? parsed.terminalOutcome
: undefined,
updatedAt: parsed.updatedAt,
};
sessions.set(scopedSessionKey, restored);
@@ -293,6 +351,45 @@ function upsertState(state: RuntimeSessionState) {
notifyNow();
}
function notifyInputAccepted(state: RuntimeSessionState) {
if (state.inputAcceptedNotified) {
return;
}
const callback = inputAcceptedCallbacks.get(
sessionKey(state.identity, state.sessionId),
);
if (!callback) {
return;
}
state.inputAcceptedNotified = true;
void callback();
}
function registerInputAcceptedCallback(
state: RuntimeSessionState,
callback?: () => Promise<void> | void,
) {
if (callback) {
inputAcceptedCallbacks.set(
sessionKey(state.identity, state.sessionId),
callback,
);
}
if (state.inputAccepted) {
notifyInputAccepted(state);
}
}
function acceptInput(
state: RuntimeSessionState,
payload: Record<string, unknown>,
) {
replaceAcceptedAttachments(state.items, state.roundId, payload);
state.inputAccepted = true;
persistSession(state);
notifyInputAccepted(state);
}
function runningSession(identity = identityScope()) {
return [...sessions.values()].find(
(session) => session.identity === identity && session.sending,
@@ -409,10 +506,93 @@ function replaceAcceptedAttachments(
}
}
function resetRoundForReplay(state: RuntimeSessionState) {
state.items = state.items.filter(
(item) =>
item.roundId !== state.roundId ||
(item.type === 'message' && item.role === 'user'),
);
ChatTimelineBuilder.ensureAssistantTurn(state.items, {
id: `turn-${state.roundId}`,
roundId: state.roundId,
turnStartedAt: state.startedAt,
});
state.completed = false;
state.error = undefined;
state.sending = true;
state.terminalOutcome = undefined;
}
function restoreProjectionState(state: RuntimeSessionState) {
const projectionState = createAguiTimelineProjectionState(state.startedAt);
projectionState.toolArgs = new Map(Object.entries(state.projectionToolArgs));
projectionState.toolNames = new Map(
Object.entries(state.projectionToolNames),
);
return projectionState;
}
function captureProjectionCursor(
state: RuntimeSessionState,
projectionState: ReturnType<typeof createAguiTimelineProjectionState>,
cursor: number,
) {
state.lastCursor = Math.max(state.lastCursor, cursor);
state.projectionToolArgs = Object.fromEntries(projectionState.toolArgs);
state.projectionToolNames = Object.fromEntries(projectionState.toolNames);
}
function finishRuntimeSuccess(state: RuntimeSessionState) {
state.sending = false;
state.completed = true;
const cancelled = state.items.some(
(item) =>
item.roundId === state.roundId &&
item.turnFinishedAt !== undefined &&
item.turnSucceeded === false,
);
if (!cancelled) {
ChatTimelineBuilder.finalize(state.items, {
roundCompleted: true,
roundId: state.roundId,
turnFinishedAt: Date.now(),
turnSucceeded: true,
});
}
upsertState(state);
}
function finishRuntimeFailure(state: RuntimeSessionState, error: unknown) {
state.error = errorMessage(error);
state.sending = false;
state.completed = true;
const last = state.items[state.items.length - 1];
if (last?.type !== 'error') {
ChatTimelineBuilder.appendError(state.items, state.error, {
roundId: state.roundId,
});
}
ChatTimelineBuilder.finalize(state.items, {
roundId: state.roundId,
turnFinishedAt: Date.now(),
turnSucceeded: false,
});
upsertState(state);
}
function observeTerminalEvent(state: RuntimeSessionState, eventType: string) {
if (eventType === EventType.RUN_FINISHED) {
state.terminalOutcome = 'success';
} else if (eventType === EventType.RUN_ERROR) {
state.terminalOutcome = 'failed';
}
}
onAgentChatCacheClear((identity) => {
for (const [key, session] of sessions) {
if (session.identity === identity) {
cancelPersistTimer(key);
inputAcceptedCallbacks.delete(key);
sessions.delete(key);
}
}
@@ -447,6 +627,111 @@ export const agentChatRuntimeManager = {
upsertState(state);
},
resume(sessionId?: string, onInputAccepted?: () => Promise<void> | void) {
const identity = identityScope();
const state = sessionId
? restoreSession(identity, sessionId)
: runningSession(identity);
if (state) {
registerInputAcceptedCallback(state, onInputAccepted);
}
if (!state?.sending || !state.runId) {
return;
}
if (state.terminalOutcome) {
finishRuntimeSuccess(state);
return;
}
const scopedSessionKey = sessionKey(identity, state.sessionId);
if (runClients.has(scopedSessionKey)) {
return;
}
if (state.lastCursor === 0) {
resetRoundForReplay(state);
}
upsertState(state);
const projectionState = restoreProjectionState(state);
const runClient = new EasyFlowAguiClient();
runClients.set(scopedSessionKey, runClient);
void resumeAguiRunUntilTerminal({
client: runClient,
onEvent(event) {
const current = sessions.get(scopedSessionKey);
if (!current || current !== state || !current.sending) {
return;
}
if (
event.type === EventType.RUN_ERROR &&
event.code !== 'RUN_CANCELLED'
) {
current.error = event.message || '发送失败,请稍后再试';
}
observeTerminalEvent(current, event.type);
applyAguiEventToTimeline(
current.items,
event,
{
onInputAccepted(payload) {
acceptInput(current, payload);
},
roundId: current.roundId,
startedAt: current.startedAt,
},
projectionState,
);
scheduleStateUpdate(current, event.type === EventType.TOOL_CALL_START);
},
initialCursor: state.lastCursor,
onCursor(cursor) {
captureProjectionCursor(state, projectionState, cursor);
},
runId: state.runId,
shouldContinue: () => {
const current = sessions.get(scopedSessionKey);
return Boolean(
runClients.get(scopedSessionKey) === runClient &&
!pageDetaching &&
current &&
current === state &&
current.sending,
);
},
startedAt: state.startedAt,
})
.then((terminalReceived) => {
const current = sessions.get(scopedSessionKey);
if (
terminalReceived &&
runClients.get(scopedSessionKey) === runClient &&
!pageDetaching &&
!stoppingSessions.has(scopedSessionKey) &&
current &&
current === state &&
current.sending
) {
finishRuntimeSuccess(current);
}
})
.catch((error) => {
const current = sessions.get(scopedSessionKey);
if (
runClients.get(scopedSessionKey) === runClient &&
!pageDetaching &&
!stoppingSessions.has(scopedSessionKey) &&
current &&
current === state &&
current.sending
) {
finishRuntimeFailure(current, error);
}
})
.finally(() => {
if (runClients.get(scopedSessionKey) === runClient) {
runClients.delete(scopedSessionKey);
}
});
},
async start(options: StartOptions) {
const identity = identityScope();
if (!identity) {
@@ -458,19 +743,29 @@ export const agentChatRuntimeManager = {
}
const sessionId = await resolveSessionId(options.sessionId);
const roundId = createRoundId();
const runId = createAguiRunId();
const startedAt = Date.now();
const state: RuntimeSessionState = {
agentId: options.agentId,
agentName: options.agentName,
completed: false,
identity,
inputAccepted: false,
inputAcceptedNotified: false,
items: clone(options.baseItems || []),
lastCursor: 0,
prompt: options.prompt,
projectionToolArgs: {},
projectionToolNames: {},
runId,
roundId,
sending: true,
sessionId,
startedAt,
terminalOutcome: undefined,
updatedAt: startedAt,
};
registerInputAcceptedCallback(state, options.onInputAccepted);
ChatTimelineBuilder.appendUserMessage(state.items, options.prompt, {
documents: options.documents,
images: options.images,
@@ -487,6 +782,35 @@ export const agentChatRuntimeManager = {
const projectionState = createAguiTimelineProjectionState(startedAt);
const runClient = new EasyFlowAguiClient();
runClients.set(scopedSessionKey, runClient);
let lastCursor = 0;
const projectEvent: Parameters<EasyFlowAguiClient['run']>[0]['onEvent'] = (
event,
) => {
const current = sessions.get(sessionKey(identity, sessionId));
if (!current || current !== state || !current.sending) {
return;
}
if (
event.type === EventType.RUN_ERROR &&
event.code !== 'RUN_CANCELLED'
) {
current.error = event.message || '发送失败,请稍后再试';
}
observeTerminalEvent(current, event.type);
applyAguiEventToTimeline(
current.items,
event,
{
onInputAccepted(payload) {
acceptInput(current, payload);
},
roundId,
startedAt,
},
projectionState,
);
scheduleStateUpdate(current, event.type === EventType.TOOL_CALL_START);
};
void runClient
.run({
forwardedProps: {
@@ -498,35 +822,12 @@ export const agentChatRuntimeManager = {
},
},
},
onEvent(event) {
const current = sessions.get(sessionKey(identity, sessionId));
if (!current || current !== state || !current.sending) {
return;
}
if (
event.type === EventType.RUN_ERROR &&
event.code !== 'RUN_CANCELLED'
) {
current.error = event.message || '发送失败,请稍后再试';
}
applyAguiEventToTimeline(
current.items,
event,
{
onInputAccepted(payload) {
replaceAcceptedAttachments(current.items, roundId, payload);
void options.onInputAccepted?.();
},
roundId,
startedAt,
},
projectionState,
);
scheduleStateUpdate(
current,
event.type === EventType.TOOL_CALL_START,
);
onCursor(cursor) {
lastCursor = Math.max(lastCursor, cursor);
captureProjectionCursor(state, projectionState, cursor);
},
onEvent: projectEvent,
runId,
threadId: sessionId,
url: `/api/v1/agent/${encodeURIComponent(options.agentId)}/agui/run`,
userMessage: {
@@ -535,49 +836,62 @@ export const agentChatRuntimeManager = {
role: 'user',
},
})
.then(() => {
const current = sessions.get(scopedSessionKey);
if (!current || current !== state || !current.sending) {
.catch(async (error) => {
if (pageDetaching || !state.sending) {
return;
}
current.sending = false;
current.completed = true;
const cancelled = current.items.some(
(item) =>
item.roundId === roundId &&
item.turnFinishedAt !== undefined &&
item.turnSucceeded === false,
);
if (!cancelled) {
ChatTimelineBuilder.finalize(current.items, {
roundCompleted: true,
roundId,
turnFinishedAt: Date.now(),
turnSucceeded: true,
});
if (!isRetryableAguiTransportError(error, state.startedAt)) {
throw error;
}
upsertState(current);
return resumeAguiRunUntilTerminal({
client: runClient,
initialCursor: lastCursor,
onCursor(cursor) {
captureProjectionCursor(state, projectionState, cursor);
},
onEvent: projectEvent,
runId,
shouldContinue: () => {
const current = sessions.get(scopedSessionKey);
return Boolean(
runClients.get(scopedSessionKey) === runClient &&
!pageDetaching &&
current &&
current === state &&
current.sending,
);
},
startedAt,
});
})
.then((terminalReceived = true) => {
const current = sessions.get(scopedSessionKey);
if (
!terminalReceived ||
runClients.get(scopedSessionKey) !== runClient ||
pageDetaching ||
stoppingSessions.has(scopedSessionKey) ||
!current ||
current !== state ||
!current.sending
) {
return;
}
finishRuntimeSuccess(current);
})
.catch((error) => {
const current = sessions.get(scopedSessionKey);
if (!current || current !== state || !current.sending) {
if (
runClients.get(scopedSessionKey) !== runClient ||
pageDetaching ||
stoppingSessions.has(scopedSessionKey) ||
!current ||
current !== state ||
!current.sending
) {
return;
}
current.error = errorMessage(error);
current.sending = false;
current.completed = true;
const last = current.items[current.items.length - 1];
if (last?.type !== 'error') {
ChatTimelineBuilder.appendError(current.items, current.error, {
roundId,
});
}
ChatTimelineBuilder.finalize(current.items, {
roundId,
turnFinishedAt: Date.now(),
turnSucceeded: false,
});
upsertState(current);
finishRuntimeFailure(current, error);
})
.finally(() => {
if (runClients.get(scopedSessionKey) === runClient) {
@@ -588,7 +902,7 @@ export const agentChatRuntimeManager = {
return sessionId;
},
stop(sessionId?: string) {
async stop(sessionId?: string) {
const identity = identityScope();
const state = sessionId
? restoreSession(identity, sessionId)
@@ -596,16 +910,44 @@ export const agentChatRuntimeManager = {
if (!state || !state.sending) {
return;
}
runClients.get(sessionKey(identity, state.sessionId))?.abort();
runClients.delete(sessionKey(identity, state.sessionId));
state.sending = false;
state.completed = true;
ChatTimelineBuilder.finalize(state.items, {
roundId: state.roundId,
turnFinishedAt: Date.now(),
turnSucceeded: false,
});
const scopedSessionKey = sessionKey(identity, state.sessionId);
if (stoppingSessions.has(scopedSessionKey)) {
return;
}
stoppingSessions.add(scopedSessionKey);
const runClient = runClients.get(scopedSessionKey);
if (state.runId) {
try {
await (runClient || new EasyFlowAguiClient()).cancel(state.runId);
} catch (error) {
stoppingSessions.delete(scopedSessionKey);
runClient?.detach();
if (runClients.get(scopedSessionKey) === runClient) {
runClients.delete(scopedSessionKey);
}
if (state.terminalOutcome) {
finishRuntimeSuccess(state);
} else {
state.error = errorMessage(error);
upsertState(state);
this.resume(state.sessionId);
}
throw error;
}
} else {
runClient?.detach();
}
stoppingSessions.delete(scopedSessionKey);
if (runClients.get(scopedSessionKey) === runClient) {
runClients.delete(scopedSessionKey);
}
if (state.terminalOutcome) {
finishRuntimeSuccess(state);
return;
}
// 取消接口成功只说明服务端已接受命令,最终结果仍以可重放事件流中的权威终态为准。
upsertState(state);
this.resume(state.sessionId);
},
subscribe(listener: () => void) {
@@ -615,3 +957,28 @@ export const agentChatRuntimeManager = {
};
},
};
if (typeof globalThis.addEventListener === 'function') {
globalThis.addEventListener('pagehide', () => {
pageDetaching = true;
for (const client of runClients.values()) {
client.detach();
}
runClients.clear();
stoppingSessions.clear();
for (const state of sessions.values()) {
if (state.sending) {
persistSession(state);
}
}
});
globalThis.addEventListener('pageshow', () => {
if (!pageDetaching) return;
pageDetaching = false;
for (const state of sessions.values()) {
if (state.sending) {
agentChatRuntimeManager.resume(state.sessionId);
}
}
});
}

View File

@@ -711,10 +711,7 @@ async function sendContent(rawContent: string) {
})),
imageUploadIds: composer.images.uploadIds.value,
images: composer.images.readyItems.value.map((item) => ({ ...item })),
onInputAccepted: () =>
composer.markAccepted().catch(() => {
ElMessage.warning('消息已发送,草稿将在过期后自动清理');
}),
onInputAccepted: markComposerInputAccepted,
prompt: content,
sessionId: composer.sessionId.value,
});
@@ -752,6 +749,12 @@ async function activateComposer(agentId: string, sessionId?: string) {
}
}
function markComposerInputAccepted() {
return composer.markAccepted().catch(() => {
ElMessage.warning('消息已发送,草稿将在过期后自动清理');
});
}
function chooseAttachmentFiles() {
attachmentFileInputRef.value?.click();
}
@@ -961,7 +964,7 @@ function handlePromptKeydown(event: Event | KeyboardEvent) {
}
}
function handleStop() {
async function handleStop() {
if (!canStopRuntime.value) {
return;
}
@@ -970,7 +973,12 @@ function handleStop() {
currentSessionId.value
? currentSessionId.value
: agentChatRuntimeManager.getLatestSnapshot()?.sessionId;
agentChatRuntimeManager.stop(runningSessionId);
try {
await agentChatRuntimeManager.stop(runningSessionId);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '停止失败');
return;
}
if (runningSessionId === currentSessionId.value) {
syncRuntimeSnapshot(currentSessionId.value);
} else {
@@ -1094,6 +1102,13 @@ async function bootstrap() {
await Promise.all([loadAgents(), loadSessions(), loadKnowledges()]);
const routeSessionId = String(route.query.sessionId || '');
if (routeSessionId) {
const runtimeSnapshot = agentChatRuntimeManager.getSnapshot(routeSessionId);
if (runtimeSnapshot?.sending) {
syncRuntimeSnapshot(routeSessionId);
await activateComposer(selectedAgentId.value, routeSessionId);
agentChatRuntimeManager.resume(routeSessionId, markComposerInputAccepted);
return;
}
await loadConversation(routeSessionId);
return;
}
@@ -1101,6 +1116,10 @@ async function bootstrap() {
if (latestSnapshot?.items.length) {
syncRuntimeSnapshot(latestSnapshot.sessionId);
await activateComposer(selectedAgentId.value, latestSnapshot.sessionId);
agentChatRuntimeManager.resume(
latestSnapshot.sessionId,
markComposerInputAccepted,
);
return;
}
await activateComposer(selectedAgentId.value);

View File

@@ -82,8 +82,19 @@ function getDraftContext() {
};
}
function markComposerInputAccepted() {
return composer.markAccepted().catch(() => {
ElMessage.warning('消息已发送,草稿将在过期后自动清理');
});
}
function syncCurrentDraftContext(restore = false) {
syncDraftContext(getDraftContext(), restore, composer.sessionId.value);
syncDraftContext(
getDraftContext(),
restore,
composer.sessionId.value,
markComposerInputAccepted,
);
}
async function activateComposer() {
@@ -157,10 +168,7 @@ async function handleSend(prompt: string) {
imageUploadIds: composer.images.uploadIds.value,
images: composer.images.readyItems.value.map((item) => ({ ...item })),
sessionId: composer.sessionId.value,
onAccepted: () =>
composer.markAccepted().catch(() => {
ElMessage.warning('消息已发送,草稿将在过期后自动清理');
}),
onAccepted: markComposerInputAccepted,
});
}
@@ -315,11 +323,15 @@ async function handleRemoveDocument(item: ChatDocumentAttachment) {
}
}
function handleStop() {
async function handleStop() {
if (!loading.value) {
return;
}
stop();
try {
await stop();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '停止失败');
}
}
async function handleApprove(payload: ChatTimelineToolApprovalPayload) {

View File

@@ -263,4 +263,112 @@ describe('useAgentTryoutRawRounds', () => {
expect(restored.currentVariant(roundId)?.status).toBe('error');
expect(JSON.stringify(restored.buildTimelineItems())).toContain('调用失败');
});
it('刷新后保留运行标识并可重置当前变体进行权威重放', () => {
const sessionId = 'resume-running';
const store = useAgentTryoutRawRounds({ mode: 'draft', sessionId });
const roundId = store.createRound('长回答');
store.bindRun(roundId, 'run-resume');
store.recordEvent(
roundId,
event({
delta: '刷新前片段',
messageId: 'assistant-1',
type: EventType.TEXT_MESSAGE_CONTENT,
}),
);
store.flush();
const restored = useAgentTryoutRawRounds({ mode: 'draft', sessionId });
expect(restored.recoverableRun()).toEqual(
expect.objectContaining({
roundId,
runId: 'run-resume',
}),
);
restored.resetRuntimeEvents(roundId);
expect(restored.currentVariant(roundId)?.runtimeEvents).toEqual([]);
expect(restored.currentVariant(roundId)?.status).toBe('running');
});
it('不恢复终态后超过二十四小时的草稿影子记录', () => {
vi.useFakeTimers();
vi.setSystemTime(2 * 24 * 60 * 60 * 1000);
const sessionId = 'expired-draft';
sessionStorage.setItem(
`easyflow:agent-tryout-raw-rounds:draft:${sessionId}`,
JSON.stringify({
rounds: [
{
createdAt: 1,
prompt: '过期问题',
roundId: 'expired-round',
selectedVariantIndex: 1,
status: 'completed',
updatedAt: 1,
variants: [
{
createdAt: 1,
runId: 'run-expired',
runtimeEvents: [],
status: 'completed',
updatedAt: 1,
variantIndex: 1,
},
],
},
],
sessionId,
version: 4,
}),
);
const restored = useAgentTryoutRawRounds({ mode: 'draft', sessionId });
expect(restored.recoverableRun()).toBeUndefined();
expect(restored.buildTimelineItems()).toEqual([]);
});
it('运行中影子超过二十四小时仍保留到服务端确认终态', () => {
vi.useFakeTimers();
vi.setSystemTime(2 * 24 * 60 * 60 * 1000);
const sessionId = 'old-running-draft';
sessionStorage.setItem(
`easyflow:agent-tryout-raw-rounds:draft:${sessionId}`,
JSON.stringify({
rounds: [
{
createdAt: 1,
prompt: '仍在运行的问题',
roundId: 'old-running-round',
selectedVariantIndex: 1,
status: 'running',
updatedAt: 1,
variants: [
{
createdAt: 1,
runId: 'run-old-running',
runtimeEvents: [],
status: 'running',
updatedAt: 1,
variantIndex: 1,
},
],
},
],
sessionId,
version: 4,
}),
);
const restored = useAgentTryoutRawRounds({ mode: 'draft', sessionId });
expect(restored.recoverableRun()).toEqual(
expect.objectContaining({
roundId: 'old-running-round',
runId: 'run-old-running',
}),
);
});
});

View File

@@ -11,6 +11,7 @@ import { ChatTimelineBuilder } from '@easyflow/common-ui';
import { EventSchemas, EventType } from '@ag-ui/client';
import { easyFlowAguiCustomEvent } from '../../shared/agent-agui/custom-events';
import {
applyAguiEventToTimeline,
createAguiTimelineProjectionState,
@@ -21,6 +22,9 @@ type AgentTryoutRoundStatus = 'completed' | 'error' | 'running';
interface AgentTryoutRawVariant {
createdAt: number;
inputAccepted: boolean;
lastCursor: number;
runId?: string;
runtimeEvents: AgentTryoutRuntimeEvent[];
status: AgentTryoutRoundStatus;
updatedAt: number;
@@ -45,13 +49,21 @@ interface AgentTryoutRawSessionRecord {
version: number;
}
const STORAGE_VERSION = 3;
const STORAGE_VERSION = 4;
const MAX_ROUNDS = 50;
const MAX_VARIANTS = 10;
const STORAGE_PREFIX = 'easyflow:agent-tryout-raw-rounds';
const PERSIST_DEBOUNCE_MS = 500;
const DRAFT_RETENTION_MS = 24 * 60 * 60 * 1000;
const memorySessions = new Map<string, AgentTryoutRawRound[]>();
function shouldRetainRound(round: AgentTryoutRawRound) {
return (
round.status === 'running' ||
Date.now() - round.updatedAt < DRAFT_RETENTION_MS
);
}
function createRoundId() {
return `round-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
}
@@ -79,6 +91,8 @@ function createVariant(variantIndex: number): AgentTryoutRawVariant {
const now = Date.now();
return {
createdAt: now,
inputAccepted: false,
lastCursor: 0,
runtimeEvents: [],
status: 'running',
updatedAt: now,
@@ -106,6 +120,16 @@ function normalizeVariant(value: any, index: number): AgentTryoutRawVariant {
: [];
return {
createdAt: Number(value.createdAt || Date.now()),
inputAccepted: Boolean(
value.inputAccepted ||
runtimeEvents.some(
(event: AgentTryoutRuntimeEvent) =>
event.type === EventType.CUSTOM &&
event.name === easyFlowAguiCustomEvent.inputAccepted,
),
),
lastCursor: Math.max(0, Number(value.lastCursor || 0)),
runId: typeof value.runId === 'string' ? value.runId : undefined,
runtimeEvents,
status:
value.status === 'completed' || value.status === 'error'
@@ -163,7 +187,9 @@ function restoreSession(mode: string, sessionId: string) {
const key = storageKey(mode, sessionId);
const memoryRecords = memorySessions.get(key);
if (memoryRecords) {
return memoryRecords.map((item) => clone(item));
const retained = memoryRecords.filter((round) => shouldRetainRound(round));
memorySessions.set(key, retained);
return retained.map((item) => clone(item));
}
const storage = safeSessionStorage();
if (!storage) return [];
@@ -178,6 +204,7 @@ function restoreSession(mode: string, sessionId: string) {
? parsed.rounds
.map((item) => normalizeRound(item))
.filter((item): item is AgentTryoutRawRound => item !== undefined)
.filter((round) => shouldRetainRound(round))
: [];
memorySessions.set(key, clone(rounds));
return rounds;
@@ -196,7 +223,9 @@ function persistSession(
// 同一份 JSON 同时用于解除 Proxy、内存快照和 sessionStorage
// 避免对完整草稿会话重复做全量序列化。
const serialized = JSON.stringify({
rounds: rounds.slice(-MAX_ROUNDS),
rounds: rounds
.filter((round) => shouldRetainRound(round))
.slice(-MAX_ROUNDS),
sessionId,
version: STORAGE_VERSION,
} satisfies AgentTryoutRawSessionRecord);
@@ -401,11 +430,76 @@ export function useAgentTryoutRawRounds(options: {
return round ? selectedVariant(round) : undefined;
}
function bindRun(roundId: string, runId: string) {
const round = rounds.get(roundId);
const variant = round && selectedVariant(round);
if (!round || !variant) return;
variant.runId = runId;
variant.lastCursor = 0;
variant.status = 'running';
round.status = 'running';
variant.updatedAt = Date.now();
round.updatedAt = variant.updatedAt;
persistNow();
}
function recoverableRun() {
const round = [...sortedRounds(rounds)].reverse().find((candidate) => {
const variant = selectedVariant(candidate);
return candidate.status === 'running' && Boolean(variant?.runId);
});
const variant = round && selectedVariant(round);
return round && variant?.runId
? {
inputAccepted: variant.inputAccepted,
lastCursor: variant.lastCursor,
roundId: round.roundId,
runId: variant.runId,
startedAt: variant.createdAt,
}
: undefined;
}
function isInputAccepted(roundId: string) {
const round = rounds.get(roundId);
return Boolean(round && selectedVariant(round)?.inputAccepted);
}
function updateCursor(roundId: string, cursor: number) {
const round = rounds.get(roundId);
const variant = round && selectedVariant(round);
if (!round || !variant || !Number.isSafeInteger(cursor) || cursor <= 0) {
return;
}
variant.lastCursor = Math.max(variant.lastCursor, cursor);
variant.updatedAt = Date.now();
round.updatedAt = variant.updatedAt;
schedulePersist();
}
function resetRuntimeEvents(roundId: string) {
const round = rounds.get(roundId);
const variant = round && selectedVariant(round);
if (!round || !variant) return;
variant.runtimeEvents = [];
variant.lastCursor = 0;
variant.status = 'running';
round.status = 'running';
liveProjectionStates.delete(`${roundId}:${variant.variantIndex}`);
persistNow();
}
function recordEvent(roundId: string, event: AgentTryoutRuntimeEvent) {
const round = rounds.get(roundId);
const variant = round && selectedVariant(round);
if (!round || !variant) return undefined;
appendRuntimeEvent(variant, event);
const inputAcceptedEvent =
event.type === EventType.CUSTOM &&
event.name === easyFlowAguiCustomEvent.inputAccepted;
if (inputAcceptedEvent) {
variant.inputAccepted = true;
}
if (event.type === EventType.RUN_FINISHED) {
variant.status = 'completed';
round.status = 'completed';
@@ -417,7 +511,8 @@ export function useAgentTryoutRawRounds(options: {
round.updatedAt = variant.updatedAt;
if (
event.type === EventType.RUN_FINISHED ||
event.type === EventType.RUN_ERROR
event.type === EventType.RUN_ERROR ||
inputAcceptedEvent
) {
persistNow();
} else {
@@ -545,6 +640,7 @@ export function useAgentTryoutRawRounds(options: {
}
return {
bindRun,
buildTimelineItems,
canSwitch,
clear,
@@ -554,10 +650,14 @@ export function useAgentTryoutRawRounds(options: {
currentVariant,
failRound,
getPrompt,
isInputAccepted,
projectEvent,
recoverableRun,
recordEvent,
resetRuntimeEvents,
regenerateRound,
selectVariant,
updateCursor,
flush: persistNow,
};
}

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

View File

@@ -19,7 +19,14 @@ import { ChatTimelineBuilder } from '@easyflow/common-ui';
import { EventType } from '@ag-ui/client';
import { EasyFlowAguiClient } from '../../shared/agent-agui/client';
import {
createAguiRunId,
EasyFlowAguiClient,
} from '../../shared/agent-agui/client';
import {
isRetryableAguiTransportError,
resumeAguiRunUntilTerminal,
} from '../../shared/agent-agui/reconnect';
import { clearAgentDraftSession } from '../api';
import { useAgentTryoutRawRounds } from './useAgentTryoutRawRounds';
@@ -96,8 +103,13 @@ interface DraftRuntimeContext {
}
interface ActiveDraftRun {
cancelling: boolean;
inputAcceptedNotified: boolean;
lastCursor: number;
roundId: string;
runId: string;
sessionId: string;
startedAt: number;
stopped: boolean;
}
@@ -108,6 +120,7 @@ export function useAgentTryoutStream() {
let activeRoundId = '';
let activeSessionId = '';
let activeRun: ActiveDraftRun | undefined;
let resumeOnAccepted: (() => Promise<void> | void) | undefined;
const aguiClient = new EasyFlowAguiClient();
function errorMessageOf(error: unknown) {
@@ -141,7 +154,9 @@ export function useAgentTryoutStream() {
payload: DraftRuntimeContext,
restore = false,
requestedSessionId?: string,
onAccepted?: () => Promise<void> | void,
) {
resumeOnAccepted = onAccepted;
const sessionId =
requestedSessionId || resolveDraftSessionId(payload.agent);
const sessionChanged = activeSessionId !== sessionId;
@@ -156,6 +171,99 @@ export function useAgentTryoutStream() {
}
if (restore && sessionChanged && !loading.value) {
rebuildTimeline();
void resumeDraftRun();
}
}
function notifyInputAccepted(run: ActiveDraftRun) {
if (run.inputAcceptedNotified || !resumeOnAccepted) {
return;
}
run.inputAcceptedNotified = true;
void resumeOnAccepted();
}
function projectRunEvent(
run: ActiveDraftRun,
rounds: NonNullable<typeof rawRounds>,
event: AguiEvent,
) {
if (activeRun !== run) return;
const runtimeEvent = rounds.recordEvent(run.roundId, event);
if (!runtimeEvent) return;
rounds.projectEvent(timelineItems.value, run.roundId, runtimeEvent, () =>
notifyInputAccepted(run),
);
}
function resumeActiveRun(
run: ActiveDraftRun,
rounds: NonNullable<typeof rawRounds>,
) {
return resumeAguiRunUntilTerminal({
client: aguiClient,
initialCursor: run.lastCursor,
onCursor: (cursor) => {
run.lastCursor = Math.max(run.lastCursor, cursor);
rounds.updateCursor(run.roundId, run.lastCursor);
},
onEvent: (event) => projectRunEvent(run, rounds, event),
runId: run.runId,
shouldContinue: () => activeRun === run && !run.stopped,
startedAt: run.startedAt,
});
}
async function resumeDraftRun() {
const rounds = rawRounds;
const recovery = rounds?.recoverableRun();
if (!rounds || !recovery || activeRun) {
return;
}
const run: ActiveDraftRun = {
cancelling: false,
inputAcceptedNotified: false,
lastCursor: recovery.lastCursor,
roundId: recovery.roundId,
runId: recovery.runId,
sessionId: activeSessionId,
startedAt: recovery.startedAt,
stopped: false,
};
activeRun = run;
activeRoundId = run.roundId;
if (run.lastCursor === 0) {
rounds.resetRuntimeEvents(run.roundId);
}
rebuildTimeline();
loading.value = true;
try {
if (recovery.inputAccepted) {
notifyInputAccepted(run);
}
const terminalReceived = await resumeActiveRun(run, rounds);
if (terminalReceived && activeRun === run && !run.stopped) {
finishAssistant();
markRoundCompleted(run.roundId);
}
} catch (error) {
if (activeRun === run && !run.stopped) {
const runError = {
message: errorMessageOf(error) || '试运行恢复失败,请稍后再试',
runId: run.runId,
threadId: run.sessionId,
type: EventType.RUN_ERROR,
} as AguiEvent;
rounds.recordEvent(run.roundId, runError);
rounds.projectEvent(timelineItems.value, run.roundId, runError);
finishAssistant();
rounds.flush();
}
} finally {
if (activeRun === run && !run.cancelling) {
activeRun = undefined;
loading.value = false;
}
}
}
@@ -203,66 +311,74 @@ export function useAgentTryoutStream() {
if (!rawRounds) {
return;
}
activeRoundId = rawRounds.createRound(
const rounds = rawRounds;
activeRoundId = rounds.createRound(
payload.prompt,
payload.images,
payload.documents,
);
const runId = createAguiRunId();
rounds.bindRun(activeRoundId, runId);
const startedAt = Date.now();
const run: ActiveDraftRun = {
cancelling: false,
inputAcceptedNotified: false,
lastCursor: 0,
roundId: activeRoundId,
runId,
sessionId: activeSessionId,
startedAt,
stopped: false,
};
resumeOnAccepted = payload.onAccepted;
activeRun = run;
rebuildTimeline();
loading.value = true;
let accepted = false;
try {
await aguiClient.run({
forwardedProps: {
easyflow: {
draft: {
agent: draftAgentTransport(payload.agent),
knowledgeBindings: payload.knowledgeBindings.map((binding) =>
draftKnowledgeBindingTransport(binding),
),
skillBindings: payload.skillBindings.map((binding) =>
draftSkillBindingTransport(binding),
),
toolBindings: payload.toolBindings.map((binding) =>
draftToolBindingTransport(binding),
),
},
input: {
documentUploadIds: payload.documentUploadIds,
imageUploadIds: payload.imageUploadIds,
let terminalReceived = true;
try {
await aguiClient.run({
forwardedProps: {
easyflow: {
draft: {
agent: draftAgentTransport(payload.agent),
knowledgeBindings: payload.knowledgeBindings.map((binding) =>
draftKnowledgeBindingTransport(binding),
),
skillBindings: payload.skillBindings.map((binding) =>
draftSkillBindingTransport(binding),
),
toolBindings: payload.toolBindings.map((binding) =>
draftToolBindingTransport(binding),
),
},
input: {
documentUploadIds: payload.documentUploadIds,
imageUploadIds: payload.imageUploadIds,
},
},
},
},
onEvent(event) {
if (activeRun !== run) return;
const runtimeEvent = rawRounds?.recordEvent(run.roundId, event);
if (!runtimeEvent) return;
rawRounds?.projectEvent(
timelineItems.value,
run.roundId,
runtimeEvent,
() => {
if (accepted) return;
accepted = true;
void payload.onAccepted?.();
},
);
},
threadId: run.sessionId,
url: '/api/v1/agent/agui/run/draft',
userMessage: {
content: payload.prompt,
id: `user-${run.roundId}`,
role: 'user',
},
});
if (activeRun === run && !run.stopped) {
onCursor(cursor) {
run.lastCursor = Math.max(run.lastCursor, cursor);
rounds.updateCursor(run.roundId, run.lastCursor);
},
onEvent: (event) => projectRunEvent(run, rounds, event),
runId,
threadId: run.sessionId,
url: '/api/v1/agent/agui/run/draft',
userMessage: {
content: payload.prompt,
id: `user-${run.roundId}`,
role: 'user',
},
});
} catch (error) {
if (!isRetryableAguiTransportError(error, run.startedAt)) {
throw error;
}
terminalReceived = await resumeActiveRun(run, rounds);
}
if (terminalReceived && activeRun === run && !run.stopped) {
finishAssistant();
markRoundCompleted(run.roundId);
}
@@ -270,17 +386,17 @@ export function useAgentTryoutStream() {
if (activeRun === run && !run.stopped) {
const runError = {
message: errorMessageOf(error) || '试运行失败,请稍后再试',
runId: run.roundId,
runId: run.runId,
threadId: run.sessionId,
type: EventType.RUN_ERROR,
} as AguiEvent;
rawRounds?.recordEvent(run.roundId, runError);
rawRounds?.projectEvent(timelineItems.value, run.roundId, runError);
rounds.recordEvent(run.roundId, runError);
rounds.projectEvent(timelineItems.value, run.roundId, runError);
finishAssistant();
rawRounds?.flush();
rounds.flush();
}
} finally {
if (activeRun === run) {
if (activeRun === run && !run.cancelling) {
activeRun = undefined;
loading.value = false;
}
@@ -325,10 +441,7 @@ export function useAgentTryoutStream() {
async function clearDraftSession() {
if (loading.value) {
if (activeRun) activeRun.stopped = true;
activeRun = undefined;
aguiClient.abort();
loading.value = false;
throw new Error('试运行进行中,暂时无法清理会话');
}
const sessionId = activeSessionId;
rawRounds?.clear();
@@ -339,24 +452,59 @@ export function useAgentTryoutStream() {
}
}
function stop() {
function recoverAfterCancelFailure(run: ActiveDraftRun) {
run.cancelling = false;
run.stopped = false;
aguiClient.detach();
if (activeRun === run) {
activeRun = undefined;
}
loading.value = false;
rebuildTimeline();
void resumeDraftRun();
}
async function stop() {
if (!loading.value) {
return;
}
const stoppedRoundId = activeRun?.roundId || activeRoundId;
if (activeRun) activeRun.stopped = true;
const run = activeRun;
if (run) {
if (run.cancelling) {
return;
}
run.cancelling = true;
run.stopped = true;
try {
await aguiClient.cancel(run.runId);
} catch (error) {
recoverAfterCancelFailure(run);
throw error;
}
run.cancelling = false;
}
activeRun = undefined;
aguiClient.abort();
if (run && rawRounds?.currentVariant(run.roundId)?.status !== 'running') {
loading.value = false;
rebuildTimeline();
return;
}
if (run) {
// 取消接口成功后重新订阅服务端日志,以 RUN_FINISHED/RUN_ERROR 决定最终展示状态。
loading.value = false;
rebuildTimeline();
await resumeDraftRun();
return;
}
finishStoppedRun(stoppedRoundId);
}
function dispose() {
if (loading.value) {
const stoppedRoundId = activeRun?.roundId || activeRoundId;
if (activeRun) activeRun.stopped = true;
activeRun = undefined;
aguiClient.abort();
finishStoppedRun(stoppedRoundId);
aguiClient.detach();
rawRounds?.flush();
return;
}
rawRounds?.flush();

View File

@@ -1,7 +1,8 @@
import { EventType } from '@ag-ui/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { EasyFlowAguiClient } from './client';
import { EasyFlowAguiClient, EasyFlowAguiProjectionError } from './client';
import { isRetryableAguiTransportError } from './reconnect';
vi.mock('#/api/request', () => ({
createEventStreamHeaders: () => ({ 'easyflow-token': 'test-token' }),
@@ -10,7 +11,12 @@ vi.mock('#/api/request', () => ({
function sse(events: unknown[]) {
return new Response(
events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(''),
events
.map(
(event, index) =>
`id: ${index + 1}\ndata: ${JSON.stringify(event)}\n\n`,
)
.join(''),
{ headers: { 'Content-Type': 'text/event-stream' }, status: 200 },
);
}
@@ -36,15 +42,22 @@ describe('easyFlowAguiClient', () => {
vi.stubGlobal('fetch', fetchMock);
const received: string[] = [];
const cursors: number[] = [];
await new EasyFlowAguiClient().run({
forwardedProps: { easyflow: { input: { imageUploadIds: ['image-1'] } } },
onEvent: (event) => received.push(event.type),
onEvent: (event) => {
received.push(event.type);
},
onCursor: (cursor) => {
cursors.push(cursor);
},
threadId: '101',
url: '/api/v1/agent/1/agui/run',
userMessage: { content: '你好', id: 'user-1', role: 'user' },
});
expect(received).toEqual([EventType.RUN_STARTED, EventType.RUN_FINISHED]);
expect(cursors).toEqual([1, 2]);
expect(requestBody).toEqual(
expect.objectContaining({
context: [],
@@ -106,6 +119,32 @@ describe('easyFlowAguiClient', () => {
).rejects.toThrow('缺少终态');
});
it('marks deterministic event projection failures as non-retryable', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
sse([{ runId: 'run-1', threadId: '101', type: EventType.RUN_STARTED }]),
),
);
const run = new EasyFlowAguiClient().run({
onEvent: () => {
throw new Error('projection failed');
},
threadId: '101',
url: '/api/v1/agent/1/agui/run',
userMessage: { content: '你好', id: 'user-1', role: 'user' },
});
await expect(run).rejects.toBeInstanceOf(EasyFlowAguiProjectionError);
expect(
isRetryableAguiTransportError(
new EasyFlowAguiProjectionError(new Error('projection failed')),
Date.now(),
),
).toBe(false);
});
it('treats a standard cancelled terminal as an accepted stop', async () => {
vi.stubGlobal(
'fetch',
@@ -132,4 +171,132 @@ describe('easyFlowAguiClient', () => {
}),
).resolves.toBeUndefined();
});
it('does not hide projection errors raised by a cancelled terminal', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
sse([
{
code: 'RUN_CANCELLED',
message: '用户停止',
runId: 'run-1',
threadId: '101',
type: EventType.RUN_ERROR,
},
]),
),
);
await expect(
new EasyFlowAguiClient().run({
onEvent: () => {
throw new Error('cancel projection failed');
},
threadId: '101',
url: '/api/v1/agent/1/agui/run',
userMessage: { content: '你好', id: 'user-1', role: 'user' },
}),
).rejects.toBeInstanceOf(EasyFlowAguiProjectionError);
});
it('replays a completed run from the server journal after refresh', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(
[
'id: 1\ndata: {"type":"RUN_STARTED","threadId":"101","runId":"run-resume"}\n\n',
'id: 2\ndata: {"type":"TEXT_MESSAGE_START","messageId":"assistant-1","role":"assistant"}\n\n',
'id: 3\ndata: {"type":"TEXT_MESSAGE_CONTENT","messageId":"assistant-1","delta":"刷新后恢复"}\n\n',
'id: 4\ndata: {"type":"TEXT_MESSAGE_END","messageId":"assistant-1"}\n\n',
'id: 5\ndata: {"type":"RUN_FINISHED","threadId":"101","runId":"run-resume"}\n\n',
].join(''),
{ headers: { 'Content-Type': 'text/event-stream' }, status: 200 },
),
),
);
const received: string[] = [];
const cursors: number[] = [];
await new EasyFlowAguiClient().resume({
onCursor: (cursor) => {
cursors.push(cursor);
},
onEvent: (event) => {
received.push(event.type);
},
runId: 'run-resume',
});
expect(received).toEqual([
EventType.RUN_STARTED,
EventType.TEXT_MESSAGE_START,
EventType.TEXT_MESSAGE_CONTENT,
EventType.TEXT_MESSAGE_END,
EventType.RUN_FINISHED,
]);
expect(cursors).toEqual([1, 2, 3, 4, 5]);
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/run-resume/events?after=0'),
expect.objectContaining({ method: 'GET' }),
);
});
it('uses the explicit cancel endpoint instead of treating detach as cancel', async () => {
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ errorCode: 0 }), {
headers: { 'Content-Type': 'application/json' },
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
await new EasyFlowAguiClient().cancel('run-cancel');
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/run-cancel/cancel'),
expect.objectContaining({ method: 'POST' }),
);
});
it('keeps the active stream attached when the cancel request fails', async () => {
let runSignal: AbortSignal | undefined;
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
if (url.includes('/cancel')) {
return Promise.resolve(
new Response(JSON.stringify({ message: '取消服务暂不可用' }), {
headers: { 'Content-Type': 'application/json' },
status: 503,
}),
);
}
runSignal = init?.signal as AbortSignal;
return new Promise<Response>((_resolve, reject) => {
runSignal?.addEventListener('abort', () =>
reject(new DOMException('Aborted', 'AbortError')),
);
});
});
vi.stubGlobal('fetch', fetchMock);
const client = new EasyFlowAguiClient();
const runPromise = client.run({
onEvent: () => undefined,
runId: 'run-cancel-failure',
threadId: '101',
url: '/api/v1/agent/1/agui/run',
userMessage: { content: '你好', id: 'user-1', role: 'user' },
});
await expect(client.cancel('run-cancel-failure')).rejects.toThrow(
'取消服务暂不可用',
);
expect(runSignal?.aborted).toBe(false);
client.detach();
expect(runSignal?.aborted).toBe(true);
await runPromise;
});
});

View File

@@ -1,29 +1,66 @@
import type {
AgentSubscriber,
Message,
RunAgentInput,
State,
} from '@ag-ui/client';
import type { Message } from '@ag-ui/client';
import { EventSchemas, EventType, HttpAgent, randomUUID } from '@ag-ui/client';
import { EventSchemas, EventType, randomUUID } from '@ag-ui/client';
import { events } from 'fetch-event-stream';
import { createEventStreamHeaders, resolveApiUrl } from '#/api/request';
export interface EasyFlowAguiRunOptions {
forwardedProps?: Record<string, unknown>;
onEvent: (event: AguiEvent) => void;
onMessagesChanged?: (messages: ReadonlyArray<Readonly<Message>>) => void;
onStateChanged?: (state: Readonly<State>) => void;
onCursor?: (cursor: number) => void;
onEvent: (event: AguiEvent) => Promise<void> | void;
runId?: string;
threadId: string;
url: string;
userMessage: Message;
}
export interface EasyFlowAguiResumeOptions {
afterCursor?: number;
onCursor?: (cursor: number) => void;
onEvent: (event: AguiEvent) => Promise<void> | void;
runId: string;
}
export type AguiEvent = ReturnType<(typeof EventSchemas)['parse']>;
interface ActiveAguiRun {
aborted: boolean;
agent: HttpAgent;
abort: () => void;
}
/** 带 HTTP 状态码的 AG-UI 传输异常。 */
export class EasyFlowAguiHttpError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message);
this.name = 'EasyFlowAguiHttpError';
}
}
/** 页面事件投影失败,重放同一游标无法自行恢复。 */
export class EasyFlowAguiProjectionError extends Error {
constructor(public readonly projectionCause: unknown) {
super(
projectionCause instanceof Error
? projectionCause.message
: 'Agent 事件投影失败',
);
this.name = 'EasyFlowAguiProjectionError';
}
}
async function projectEvent(
onEvent: (event: AguiEvent) => Promise<void> | void,
event: AguiEvent,
) {
try {
await onEvent(event);
} catch (error) {
throw new EasyFlowAguiProjectionError(error);
}
}
function toTransportJson<T>(value: T): T {
@@ -35,73 +72,142 @@ function toTransportJson<T>(value: T): T {
/**
* EasyFlow 的无头 AG-UI 运行客户端。
*
* <p>SDK 持有标准 messages/state页面只消费投影回调。每次运行都会在出站边界再次裁剪
* <p>客户端以最小标准 AG-UI 请求体启动运行,页面只消费投影回调。出站边界固定裁剪
* tools、context、state 和历史消息,服务端仍会独立执行同样的安全校验。</p>
*/
export class EasyFlowAguiClient {
private activeRun?: ActiveAguiRun;
abort() {
this.detach();
}
async cancel(runId: string) {
const requestUrl = `/api/v1/agent/agui/run/${encodeURIComponent(runId)}/cancel`;
const response = await fetch(resolveApiUrl(requestUrl), {
headers: createEventStreamHeaders(requestUrl),
method: 'POST',
});
if (!response.ok) {
throw new EasyFlowAguiHttpError(
await responseError(response),
response.status,
);
}
const body = (await response.json()) as {
errorCode?: number;
message?: string;
};
if (body.errorCode !== undefined && body.errorCode !== 0) {
throw new Error(body.message || '停止 Agent 运行失败');
}
this.detach();
}
detach() {
if (!this.activeRun) return;
this.activeRun.aborted = true;
this.activeRun.agent.abortRun();
this.activeRun.abort();
this.activeRun = undefined;
}
async run(options: EasyFlowAguiRunOptions) {
this.abort();
const requestUrl = options.url;
const agent = new EasyFlowHttpAgent({
headers: createEventStreamHeaders(requestUrl),
initialMessages: [options.userMessage],
threadId: options.threadId,
url: resolveApiUrl(requestUrl),
});
const activeRun: ActiveAguiRun = { aborted: false, agent };
async resume(options: EasyFlowAguiResumeOptions) {
this.detach();
const controller = new AbortController();
const activeRun: ActiveAguiRun = {
abort: () => controller.abort(),
aborted: false,
};
this.activeRun = activeRun;
let terminalReceived = false;
let cancelledReceived = false;
const subscriber: AgentSubscriber = {
onEvent: ({ event }) => {
try {
const requestUrl = `/api/v1/agent/agui/run/${encodeURIComponent(options.runId)}/events?after=${Math.max(0, options.afterCursor || 0)}`;
const response = await fetch(resolveApiUrl(requestUrl), {
headers: createEventStreamHeaders(requestUrl),
method: 'GET',
signal: controller.signal,
});
await assertSseResponse(response);
for await (const message of events(response, controller.signal)) {
if (activeRun.aborted || !message.data) continue;
const event = EventSchemas.parse(JSON.parse(message.data));
await projectEvent(options.onEvent, event as AguiEvent);
const cursor = Number(message.id || 0);
if (Number.isSafeInteger(cursor) && cursor > 0) {
options.onCursor?.(cursor);
}
if (
event.type === EventType.RUN_FINISHED ||
event.type === EventType.RUN_ERROR
) {
terminalReceived = true;
}
if (
event.type === EventType.RUN_ERROR &&
event.code === 'RUN_CANCELLED'
) {
cancelledReceived = true;
}
options.onEvent(event as AguiEvent);
},
onMessagesChanged: ({ messages }) => {
options.onMessagesChanged?.(messages);
},
onStateChanged: ({ state }) => {
options.onStateChanged?.(state);
},
}
if (!activeRun.aborted && !terminalReceived) {
throw new Error('Agent 事件流缺少终态,请重试');
}
} catch (error) {
if (activeRun.aborted) return;
throw error;
} finally {
if (this.activeRun === activeRun) {
this.activeRun = undefined;
}
}
}
async run(options: EasyFlowAguiRunOptions) {
this.detach();
const requestUrl = options.url;
const controller = new AbortController();
const activeRun: ActiveAguiRun = {
abort: () => controller.abort(),
aborted: false,
};
this.activeRun = activeRun;
let terminalReceived = false;
try {
await agent.runAgent(
{
const response = await fetch(resolveApiUrl(requestUrl), {
body: JSON.stringify({
context: [],
forwardedProps: options.forwardedProps
? toTransportJson(options.forwardedProps)
: undefined,
runId: `run_${randomUUID()}`,
messages: [toTransportJson(options.userMessage)],
runId: options.runId || createAguiRunId(),
state: {},
threadId: options.threadId,
tools: [],
},
subscriber,
);
}),
headers: createEventStreamHeaders(requestUrl),
method: 'POST',
signal: controller.signal,
});
await assertSseResponse(response);
for await (const message of events(response, controller.signal)) {
if (activeRun.aborted || !message.data) continue;
const event = EventSchemas.parse(JSON.parse(message.data));
if (
event.type === EventType.RUN_FINISHED ||
event.type === EventType.RUN_ERROR
) {
terminalReceived = true;
}
await projectEvent(options.onEvent, event as AguiEvent);
const cursor = Number(message.id || 0);
if (Number.isSafeInteger(cursor) && cursor > 0) {
options.onCursor?.(cursor);
}
}
if (!terminalReceived) {
if (activeRun.aborted) return;
throw new Error('Agent 事件流缺少终态,请重试');
}
} catch (error) {
if (activeRun.aborted || cancelledReceived) return;
if (activeRun.aborted) return;
if (terminalReceived && !(error instanceof EasyFlowAguiProjectionError)) {
return;
}
throw error;
} finally {
if (this.activeRun === activeRun) {
@@ -111,17 +217,26 @@ export class EasyFlowAguiClient {
}
}
class EasyFlowHttpAgent extends HttpAgent {
protected override requestInit(input: RunAgentInput): RequestInit {
const latestUserMessage = [...input.messages]
.reverse()
.find((message) => message.role === 'user');
return super.requestInit({
...input,
context: [],
messages: latestUserMessage ? [latestUserMessage] : [],
state: {},
tools: [],
});
export function createAguiRunId() {
return `run_${randomUUID()}`;
}
async function assertSseResponse(response: Response) {
const contentType = response.headers.get('content-type') || '';
if (!response.ok || !contentType.includes('text/event-stream')) {
throw new EasyFlowAguiHttpError(
await responseError(response),
response.status,
);
}
}
async function responseError(response: Response) {
try {
const body = await response.clone().json();
return body?.message || body?.error || `HTTP ${response.status}`;
} catch {
const text = await response.text();
return text.trim() || `HTTP ${response.status}`;
}
}

View File

@@ -0,0 +1,93 @@
import type { AguiEvent } from './client';
import {
EasyFlowAguiClient,
EasyFlowAguiHttpError,
EasyFlowAguiProjectionError,
} from './client';
const INITIAL_RECONNECT_DELAY_MS = 300;
const MAX_RECONNECT_DELAY_MS = 5000;
const RUN_CREATION_GRACE_MS = 30_000;
interface ResumeAguiRunOptions {
client: EasyFlowAguiClient;
initialCursor?: number;
onCursor?: (cursor: number) => void;
onEvent: (event: AguiEvent) => Promise<void> | void;
runId: string;
shouldContinue: () => boolean;
startedAt: number;
}
function wait(delay: number) {
return new Promise((resolve) => setTimeout(resolve, delay));
}
/**
* 判断传输异常是否允许通过运行日志继续恢复。
*
* 404 仅在 POST 尚未建立运行记录的短窗口内重试;鉴权、参数和协议解析错误立即交给页面处理。
*/
export function isRetryableAguiTransportError(
error: unknown,
startedAt: number,
) {
if (error instanceof EasyFlowAguiProjectionError) {
return false;
}
if (error instanceof EasyFlowAguiHttpError) {
if (error.status === 404) {
return Date.now() - startedAt < RUN_CREATION_GRACE_MS;
}
return (
error.status === 408 ||
error.status === 425 ||
error.status === 429 ||
error.status >= 500
);
}
if (error instanceof SyntaxError) {
return false;
}
if (error instanceof Error && error.name === 'ZodError') {
return false;
}
return true;
}
/**
* 以服务端游标持续恢复 AG-UI 运行,直至收到权威终态或页面主动分离。
*
* 退避间隔有上限,网络故障期间保留可恢复态,不会用本地错误覆盖仍在执行的服务端运行。
*/
export async function resumeAguiRunUntilTerminal(
options: ResumeAguiRunOptions,
) {
let cursor = Math.max(0, options.initialCursor || 0);
let delay = INITIAL_RECONNECT_DELAY_MS;
while (options.shouldContinue()) {
try {
await options.client.resume({
afterCursor: cursor,
onCursor(nextCursor) {
cursor = Math.max(cursor, nextCursor);
options.onCursor?.(cursor);
},
onEvent: options.onEvent,
runId: options.runId,
});
return options.shouldContinue();
} catch (error) {
if (
!options.shouldContinue() ||
!isRetryableAguiTransportError(error, options.startedAt)
) {
throw error;
}
await wait(delay);
delay = Math.min(delay * 2, MAX_RECONNECT_DELAY_MS);
}
}
return false;
}