Compare commits

...

10 Commits

Author SHA1 Message Date
fd64073148 release: 发布v1.1.0 2026-08-20 11:34:00 +08:00
f63cd9be4d fix: 保留多次上下文整理记录
- 为同一轮多次整理分配独立状态键

- 防止后续整理覆盖已完成的历史记录
2026-08-20 11:24:55 +08:00
407b85c8a9 fix: 移除 Timeline 合成处理状态
- 仅依据真实思考、工具和状态事件展示处理过程

- 更新快速工具调用状态测试
2026-08-20 11:24:41 +08:00
9078ca163e fix: 保证工具调用开始态可见
- 工具开始事件后让出一次有界绘制机会

- 覆盖快速工具调用的状态可见性测试
2026-08-20 11:24:25 +08:00
7f083a9433 fix: 明确 Skill Git 访问策略注入构造器
- 标记生产构造器供 Spring 容器稳定选择

- 补充组件实例化回归测试
2026-08-20 11:24:09 +08:00
7d654c3302 feat: 优化 Agent Studio 试运行交互
- 将内置工具配置拆分为独立页签

- 支持试运行面板平滑居中展开和快捷收起

- 运行和审批期间禁用会话清理
2026-08-20 11:18:37 +08:00
fa07134cf8 feat: 支持 Agent 运行刷新恢复
- 解耦 Runtime 与浏览器 SSE 订阅并增加 Redis 游标日志

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

- 完善显式取消、owner 丢失、容量限制与故障测试
2026-08-20 11:18:10 +08:00
310fc1fb58 fix: 修复 Jcseg 可执行包资源加载
- 将 jcseg-core 标记为 Spring Boot 启动时解包依赖
2026-08-19 22:43:53 +08:00
c96f772f01 fix: 隔离 LibreOffice 并发转换配置
- 为每次 soffice 调用创建独立临时用户配置目录

- 转换结束后自动清理并保持无头安全启动参数
2026-08-19 22:42:25 +08:00
30b2cc36fd fix: 保留 Agent 手动压缩阈值
- 保存和试用时不再覆盖已配置的 Token 压缩门槛
2026-08-19 22:41:17 +08:00
52 changed files with 5574 additions and 667 deletions

View File

@@ -129,8 +129,9 @@ RUN fc-cache -f && \
mkdir -p /app/logs /app/artifacts /app/data && \ mkdir -p /app/logs /app/artifacts /app/data && \
chown -R easyflow:easyflow /app chown -R easyflow:easyflow /app
COPY docker-soffice-wrapper.sh /usr/local/bin/soffice
COPY docker-entrypoint.sh /usr/local/bin/easyflow-entrypoint.sh COPY docker-entrypoint.sh /usr/local/bin/easyflow-entrypoint.sh
RUN chmod 755 /usr/local/bin/easyflow-entrypoint.sh RUN chmod 755 /usr/local/bin/soffice /usr/local/bin/easyflow-entrypoint.sh
VOLUME ["/app/logs", "/app/data"] VOLUME ["/app/logs", "/app/data"]
EXPOSE 8111 EXPOSE 8111

21
docker-soffice-wrapper.sh Normal file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env sh
set -eu
profile_parent="${TMPDIR:-/tmp}"
profile_dir="$(mktemp -d "${profile_parent%/}/easyflow-soffice-XXXXXX")"
cleanup() {
rm -rf -- "$profile_dir"
}
trap cleanup EXIT HUP INT TERM
/usr/bin/soffice \
-env:UserInstallation="file://${profile_dir}" \
--headless \
--safe-mode \
--nologo \
--nodefault \
--nolockcheck \
--norestore \
"$@"

View File

@@ -31,6 +31,7 @@ import tech.easyflow.agent.runtime.AgentChatRequest;
import tech.easyflow.agent.runtime.AgentDraftChatRequest; import tech.easyflow.agent.runtime.AgentDraftChatRequest;
import tech.easyflow.agent.runtime.AgentRunService; import tech.easyflow.agent.runtime.AgentRunService;
import tech.easyflow.agent.runtime.agui.AgentAguiHitlResolveRequest; import tech.easyflow.agent.runtime.agui.AgentAguiHitlResolveRequest;
import tech.easyflow.agent.runtime.agui.AgentAguiRunStatusView;
import tech.easyflow.agent.runtime.composer.AgentComposerDraft; import tech.easyflow.agent.runtime.composer.AgentComposerDraft;
import tech.easyflow.agent.runtime.composer.AgentComposerDraftService; import tech.easyflow.agent.runtime.composer.AgentComposerDraftService;
import tech.easyflow.agent.runtime.composer.AgentComposerSession; import tech.easyflow.agent.runtime.composer.AgentComposerSession;
@@ -319,6 +320,51 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
return agentRunService.chatDraftAgui(input); return agentRunService.chatDraftAgui(input);
} }
/**
* 查询可重连 AG-UI 运行状态。
*
* @param runId 客户端运行 ID
* @return 运行状态
*/
@GetMapping("/agui/run/{runId}/status")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<AgentAguiRunStatusView> getAguiRunStatus(@PathVariable String runId) {
return Result.ok(agentRunService.getAguiRunStatus(runId));
}
/**
* 从指定游标继续订阅 AG-UI 运行事件。
*
* @param runId 客户端运行 ID
* @param after 已消费的最后事件游标
* @return 增量重放 SSE
*/
@GetMapping("/agui/run/{runId}/events")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public SseEmitter subscribeAguiRun(@PathVariable String runId,
@RequestParam(defaultValue = "0") long after) {
return agentRunService.subscribeAguiRun(runId, after);
}
/**
* 显式取消单次 AG-UI 运行。
*
* @param runId 客户端运行 ID
* @return 操作结果
*/
@PostMapping("/agui/run/{runId}/cancel")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<Void> cancelAguiRun(@PathVariable String runId) {
agentRunService.cancelAguiRun(runId);
return Result.ok();
}
/** /**
* 处理 AG-UI 自定义 HITL 兼容桥审批。 * 处理 AG-UI 自定义 HITL 兼容桥审批。
* *

View File

@@ -72,6 +72,21 @@ public class AgentRuntimeProperties {
*/ */
private Duration asyncToolTaskTtl = Duration.ofHours(24); private Duration asyncToolTaskTtl = Duration.ofHours(24);
/**
* AG-UI 可重连运行事件保留时间。
*/
private Duration aguiRunRetention = Duration.ofHours(24);
/**
* AG-UI 事件日志批量刷入 Redis 的间隔。
*/
private Duration aguiJournalFlushInterval = Duration.ofMillis(50);
/**
* AG-UI 重连订阅轮询 Redis 的间隔。
*/
private Duration aguiReplayPollInterval = Duration.ofMillis(100);
/** /**
* 获取 Redis 热态 session 缓存 TTL。 * 获取 Redis 热态 session 缓存 TTL。
* *
@@ -90,6 +105,60 @@ public class AgentRuntimeProperties {
this.sessionCacheTtl = sessionCacheTtl == null ? Duration.ofHours(24) : sessionCacheTtl; this.sessionCacheTtl = sessionCacheTtl == null ? Duration.ofHours(24) : sessionCacheTtl;
} }
/**
* 获取 AG-UI 可重连运行事件保留时间。
*
* @return 事件保留时间
*/
public Duration getAguiRunRetention() {
return aguiRunRetention;
}
/**
* 设置 AG-UI 可重连运行事件保留时间。
*
* @param aguiRunRetention 事件保留时间
*/
public void setAguiRunRetention(Duration aguiRunRetention) {
this.aguiRunRetention = positiveDuration(aguiRunRetention, Duration.ofHours(24));
}
/**
* 获取 AG-UI 事件日志批量刷入 Redis 的间隔。
*
* @return 刷盘间隔
*/
public Duration getAguiJournalFlushInterval() {
return aguiJournalFlushInterval;
}
/**
* 设置 AG-UI 事件日志批量刷入 Redis 的间隔。
*
* @param aguiJournalFlushInterval 刷盘间隔
*/
public void setAguiJournalFlushInterval(Duration aguiJournalFlushInterval) {
this.aguiJournalFlushInterval = positiveDuration(aguiJournalFlushInterval, Duration.ofMillis(50));
}
/**
* 获取 AG-UI 重连订阅轮询 Redis 的间隔。
*
* @return 轮询间隔
*/
public Duration getAguiReplayPollInterval() {
return aguiReplayPollInterval;
}
/**
* 设置 AG-UI 重连订阅轮询 Redis 的间隔。
*
* @param aguiReplayPollInterval 轮询间隔
*/
public void setAguiReplayPollInterval(Duration aguiReplayPollInterval) {
this.aguiReplayPollInterval = positiveDuration(aguiReplayPollInterval, Duration.ofMillis(100));
}
/** /**
* 获取当前 Agent 运行实例 ID。 * 获取当前 Agent 运行实例 ID。
* *
@@ -281,6 +350,10 @@ public class AgentRuntimeProperties {
this.asyncToolTaskTtl = asyncToolTaskTtl == null ? Duration.ofHours(24) : asyncToolTaskTtl; this.asyncToolTaskTtl = asyncToolTaskTtl == null ? Duration.ofHours(24) : asyncToolTaskTtl;
} }
private static Duration positiveDuration(Duration value, Duration fallback) {
return value == null || value.isZero() || value.isNegative() ? fallback : value;
}
private static String defaultInstanceId() { private static String defaultInstanceId() {
String envInstanceId = System.getenv("EASYFLOW_INSTANCE_ID"); String envInstanceId = System.getenv("EASYFLOW_INSTANCE_ID");
if (StringUtils.hasText(envInstanceId)) { if (StringUtils.hasText(envInstanceId)) {

View File

@@ -23,5 +23,10 @@ public enum AgentRuntimeCommandAction {
/** /**
* 取消指定 Agent 在目标节点上的全部运行。 * 取消指定 Agent 在目标节点上的全部运行。
*/ */
CANCEL_AGENT CANCEL_AGENT,
/**
* 取消指定的单次运行。
*/
CANCEL_RUN
} }

View File

@@ -108,6 +108,9 @@ public class AgentRuntimeCommandConsumer implements MQConsumerHandler {
command.getRequestId(), command.getResumeToken(), command.getReason()); command.getRequestId(), command.getResumeToken(), command.getReason());
} else if (command.getAction() == AgentRuntimeCommandAction.CANCEL_AGENT) { } else if (command.getAction() == AgentRuntimeCommandAction.CANCEL_AGENT) {
agentRunService.cancelAgentLocal(command.getAgentId()); agentRunService.cancelAgentLocal(command.getAgentId());
} else if (command.getAction() == AgentRuntimeCommandAction.CANCEL_RUN) {
agentRunService.cancelRunLocal(
command.getRequestId(), command.getUserId(), command.getReason());
} else { } else {
markFailureQuietly(command, new IllegalArgumentException("不支持的 Agent 远程运行命令")); markFailureQuietly(command, new IllegalArgumentException("不支持的 Agent 远程运行命令"));
LOG.warn("跳过不支持的 Agent 远程运行命令: messageId={}, commandId={}, action={}", LOG.warn("跳过不支持的 Agent 远程运行命令: messageId={}, commandId={}, action={}",

View File

@@ -175,6 +175,21 @@ public class AgentRuntimeCommandProducer {
); );
} }
/**
* 投递远程单次运行取消命令。
*
* @param targetNodeId 目标节点 ID
* @param requestId 内部请求 ID
* @param userId 当前用户 ID
* @param reason 取消原因
*/
public void sendCancelRun(String targetNodeId, String requestId, String userId, String reason) {
sendAndWait(
targetNodeId, requestId, null, null, null,
AgentRuntimeCommandAction.CANCEL_RUN, reason, null, userId
);
}
/** /**
* 投递远程运行命令并等待目标节点确认。 * 投递远程运行命令并等待目标节点确认。
* *

View File

@@ -93,9 +93,13 @@ public class AgentRunRegistry {
*/ */
public void bindSubscription(String requestId, Disposable subscription) { public void bindSubscription(String requestId, Disposable subscription) {
AgentRunContext context = runs.get(requestId); AgentRunContext context = runs.get(requestId);
if (context != null) { if (context == null) {
context.setSubscription(subscription); if (subscription != null && !subscription.isDisposed()) {
subscription.dispose();
}
return;
} }
context.setSubscription(subscription);
} }
/** /**
@@ -152,6 +156,31 @@ public class AgentRunRegistry {
remove(requestId); remove(requestId);
} }
/**
* 显式取消当前节点上的单次运行,并通过原事件处理链生成规范取消终态。
*
* @param requestId 内部请求 ID
* @param userId 当前用户 ID
* @param reason 取消原因
*/
public void cancelRun(String requestId, String userId, String reason) {
if (requestId == null || requestId.isBlank()) {
throw new BusinessException("Agent 运行请求 ID 不能为空");
}
AgentRunContext context = runs.get(requestId);
if (context == null) {
// 取消命令允许因自然终态、重复投递或跨节点竞态而幂等到达。
return;
}
assertOwner(requestId, userId);
context.cancel();
AgentRuntimeEvent event = AgentRuntimeEvent.of(
com.easyagents.agent.runtime.event.AgentRuntimeEventType.CANCELLED);
event.getPayload().put("reason",
reason == null || reason.isBlank() ? "用户已停止生成" : reason);
context.eventConsumer().accept(event);
}
/** /**
* 取消当前节点上指定 Agent 的全部活跃运行。 * 取消当前节点上指定 Agent 的全部活跃运行。
* *
@@ -314,34 +343,80 @@ public class AgentRunRegistry {
if (requestId == null) { if (requestId == null) {
return; return;
} }
RuntimeException cleanupFailure = null;
AgentRunContext context = runs.remove(requestId); AgentRunContext context = runs.remove(requestId);
if (context != null) { if (context != null) {
sessionRuns.remove(context.sessionId(), requestId); sessionRuns.remove(context.sessionId(), requestId);
context.releaseLock(); try {
context.releaseLock();
} catch (RuntimeException exception) {
cleanupFailure = mergeCleanupFailure(
cleanupFailure, exception, requestId, "release distributed lock");
}
// Redis 解锁失败也不能阻止底层模型、工具与订阅资源释放。
context.closeRuntime(); context.closeRuntime();
} }
owners.remove(requestId); owners.remove(requestId);
Set<String> tokens = requestTokens.remove(requestId); Set<String> tokens = requestTokens.remove(requestId);
if (tokens != null) { if (tokens != null) {
tokens.forEach(token -> { for (String token : tokens) {
resumeTokenIndex.remove(token); resumeTokenIndex.remove(token);
if (routeRegistry != null) { if (routeRegistry != null) {
routeRegistry.removeResumeToken(token); try {
routeRegistry.removeResumeToken(token);
} catch (RuntimeException exception) {
cleanupFailure = mergeCleanupFailure(
cleanupFailure, exception, requestId, "remove resume token route");
}
} }
}); }
} }
Set<String> approvals = requestApprovals.remove(requestId); Set<String> approvals = requestApprovals.remove(requestId);
if (approvals != null) { if (approvals != null) {
approvals.forEach(approvalId -> { for (String approvalId : approvals) {
approvalTargets.remove(approvalId); approvalTargets.remove(approvalId);
if (routeRegistry != null) { if (routeRegistry != null) {
routeRegistry.removeApproval(approvalId); try {
routeRegistry.removeApproval(approvalId);
} catch (RuntimeException exception) {
cleanupFailure = mergeCleanupFailure(
cleanupFailure, exception, requestId, "remove approval route");
}
} }
}); }
} }
if (routeRegistry != null) { if (routeRegistry != null) {
routeRegistry.removeRun(requestId); try {
routeRegistry.removeRun(requestId);
} catch (RuntimeException exception) {
cleanupFailure = mergeCleanupFailure(
cleanupFailure, exception, requestId, "remove run route");
}
} }
if (cleanupFailure != null) {
throw cleanupFailure;
}
}
/**
* 合并运行清理异常,同时保留第一个异常作为调用方可见的根因。
*
* @param current 当前已记录异常
* @param next 本次清理异常
* @param requestId 请求 ID
* @param phase 失败阶段
* @return 合并后的首异常
*/
private RuntimeException mergeCleanupFailure(RuntimeException current,
RuntimeException next,
String requestId,
String phase) {
LOG.warn("Agent runtime cleanup failed, requestId={}, phase={}", requestId, phase, next);
if (current == null) {
return next;
}
current.addSuppressed(next);
return current;
} }
/** /**
@@ -684,10 +759,18 @@ public class AgentRunRegistry {
if (subscription == null) { if (subscription == null) {
return; return;
} }
if (finished.get()) {
subscription.dispose();
return;
}
Disposable previous = this.subscription.getAndSet(subscription); Disposable previous = this.subscription.getAndSet(subscription);
if (previous != null && !previous.isDisposed()) { if (previous != null && !previous.isDisposed()) {
previous.dispose(); previous.dispose();
} }
if (finished.get() && this.subscription.compareAndSet(subscription, null)
&& !subscription.isDisposed()) {
subscription.dispose();
}
} }
/** /**

View File

@@ -32,8 +32,15 @@ import tech.easyflow.agent.distributed.AgentRuntimeRoute;
import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry; import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
import tech.easyflow.agent.runtime.event.AgentRunEventRecorder; import tech.easyflow.agent.runtime.event.AgentRunEventRecorder;
import tech.easyflow.agent.runtime.agui.AgentAguiRunInputMapper; import tech.easyflow.agent.runtime.agui.AgentAguiRunInputMapper;
import tech.easyflow.agent.runtime.agui.AgentAguiRunDescriptor;
import tech.easyflow.agent.runtime.agui.AgentAguiRunJournal;
import tech.easyflow.agent.runtime.agui.AgentAguiRunStatus;
import tech.easyflow.agent.runtime.agui.AgentAguiRunStatusView;
import tech.easyflow.agent.runtime.agui.AgentAguiRunStore;
import tech.easyflow.agent.runtime.agui.AgentAguiRunSubscriptionService;
import tech.easyflow.agent.runtime.agui.AgentAguiWireContext; import tech.easyflow.agent.runtime.agui.AgentAguiWireContext;
import tech.easyflow.agent.runtime.agui.AgentAguiHitlResolveRequest; import tech.easyflow.agent.runtime.agui.AgentAguiHitlResolveRequest;
import tech.easyflow.agent.runtime.agui.ResumableAguiSseEmitter;
import tech.easyflow.agent.runtime.artifact.AgentArtifactService; import tech.easyflow.agent.runtime.artifact.AgentArtifactService;
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService; import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
import tech.easyflow.agent.runtime.hitl.ToolApprovalInputProjection; import tech.easyflow.agent.runtime.hitl.ToolApprovalInputProjection;
@@ -154,6 +161,12 @@ public class AgentRunService {
@Resource @Resource
private AgentAguiRunInputMapper agentAguiRunInputMapper; private AgentAguiRunInputMapper agentAguiRunInputMapper;
@Resource @Resource
private AgentAguiRunStore agentAguiRunStore;
@Resource
private AgentAguiRunJournal agentAguiRunJournal;
@Resource
private AgentAguiRunSubscriptionService agentAguiRunSubscriptionService;
@Resource
private AgentBuiltinToolsConfigResolver agentBuiltinToolsConfigResolver; private AgentBuiltinToolsConfigResolver agentBuiltinToolsConfigResolver;
@Resource @Resource
private AgentArtifactService agentArtifactService; private AgentArtifactService agentArtifactService;
@@ -217,10 +230,11 @@ public class AgentRunService {
} }
applyFormalSessionTitle(chatContext, titlePrompt, existingSession); applyFormalSessionTitle(chatContext, titlePrompt, existingSession);
// 执行对话 // 执行对话
return run(agent, chatRequest.getPrompt(), mediaUploads, documentUploads, AgentRunOutput runOutput = createRunOutput(
wireContext, requestId, account, agent, sessionId.toString(), false);
return runAguiAware(agent, chatRequest.getPrompt(), mediaUploads, documentUploads,
account, requestId, traceId, sessionId.toString(), account, requestId, traceId, sessionId.toString(),
ASSISTANT_CODE, chatContext, true, easyFlowAgentSessionStore, ASSISTANT_CODE, chatContext, true, easyFlowAgentSessionStore, runOutput);
createRunOutput(wireContext));
} }
/** /**
@@ -336,10 +350,11 @@ public class AgentRunService {
String titlePrompt = effectivePrompt(draftRequest.getPrompt(), !documentUploads.isEmpty(), !mediaUploads.isEmpty()); String titlePrompt = effectivePrompt(draftRequest.getPrompt(), !documentUploads.isEmpty(), !mediaUploads.isEmpty());
ChatRuntimeContext chatContext = buildChatRuntimeContext( ChatRuntimeContext chatContext = buildChatRuntimeContext(
agent, chatSessionId, titlePrompt, account, DRAFT_ASSISTANT_CODE); agent, chatSessionId, titlePrompt, account, DRAFT_ASSISTANT_CODE);
return run(agent, draftRequest.getPrompt(), mediaUploads, documentUploads, AgentRunOutput runOutput = createRunOutput(
wireContext, requestId, account, agent, runtimeSessionId, true);
return runAguiAware(agent, draftRequest.getPrompt(), mediaUploads, documentUploads,
account, requestId, traceId, runtimeSessionId, account, requestId, traceId, runtimeSessionId,
DRAFT_ASSISTANT_CODE, chatContext, false, draftAgentSessionStore, DRAFT_ASSISTANT_CODE, chatContext, false, draftAgentSessionStore, runOutput);
createRunOutput(wireContext));
} }
private SseEmitter run(Agent agent, private SseEmitter run(Agent agent,
@@ -415,22 +430,110 @@ public class AgentRunService {
submitted = true; submitted = true;
return runOutput.emitter(); return runOutput.emitter();
} finally { } finally {
// 释放锁 if (!submitted) {
if (!submitted && lockHandle != null) { try {
lockHandle.release(); if (lockHandle != null) {
lockHandle.release();
}
} finally {
if (!runOutput.cancelRunOnDisconnect()) {
// 可重连运行已在编译前注册临时 owner未提交到后台时必须同步移除。
agentRuntimeRouteRegistry.removeRun(requestId);
}
}
} }
} }
} }
private AgentRunOutput createRunOutput(AgentAguiWireContext wireContext) { private AgentRunOutput createRunOutput(AgentAguiWireContext wireContext,
String requestId,
LoginAccount account,
Agent agent,
String runtimeSessionId,
boolean draft) {
if (wireContext == null) { if (wireContext == null) {
return new LegacyAgentRunOutput(); return new LegacyAgentRunOutput();
} }
return new AguiAgentRunOutput( long now = System.currentTimeMillis();
wireContext.threadId(), AgentAguiRunDescriptor descriptor = new AgentAguiRunDescriptor(
wireContext.runId(), wireContext.runId(),
wireContext.userMessageId(), requestId,
wireContext.userMessageContent()); wireContext.threadId(),
agent == null || agent.getId() == null ? "" : agent.getId().toString(),
runtimeSessionId,
account.getId().toString(),
account.getTenantId().toString(),
draft,
AgentAguiRunStatus.RUNNING,
0L,
now,
now);
boolean storeCreated = false;
try {
// 在耗时编译和初始化前注册 owner刷新恢复可据节点心跳区分“仍在启动”与 owner 丢失。
agentRuntimeRouteRegistry.registerRun(requestId, descriptor.agentId());
agentAguiRunStore.create(descriptor);
storeCreated = true;
SseEmitter subscriberEmitter = agentAguiRunSubscriptionService.subscribe(descriptor, 0L);
return new AguiAgentRunOutput(
wireContext.threadId(),
wireContext.runId(),
wireContext.userMessageId(),
wireContext.userMessageContent(),
new ResumableAguiSseEmitter(
wireContext.threadId(), wireContext.runId(),
agentAguiRunJournal, subscriberEmitter),
false);
} catch (RuntimeException exception) {
if (storeCreated) {
try {
agentAguiRunJournal.fail(
descriptor.threadId(), descriptor.runId(),
"AGENT_RUN_START_FAILED", "Agent 运行启动失败");
} catch (RuntimeException journalException) {
exception.addSuppressed(journalException);
}
}
try {
agentRuntimeRouteRegistry.removeRun(requestId);
} catch (RuntimeException routeException) {
exception.addSuppressed(routeException);
}
throw exception;
}
}
private SseEmitter runAguiAware(Agent agent,
String prompt,
List<AgentMediaUploadRecord> mediaUploads,
List<AgentDocumentAttachment> documentUploads,
LoginAccount account,
String requestId,
String traceId,
String runtimeSessionId,
String assistantCode,
ChatRuntimeContext chatContext,
boolean persistChatlog,
AgentSessionStore runtimeSessionStore,
AgentRunOutput runOutput) {
try {
return run(agent, prompt, mediaUploads, documentUploads, account, requestId, traceId,
runtimeSessionId, assistantCode, chatContext, persistChatlog, runtimeSessionStore, runOutput);
} catch (RuntimeException exception) {
if (!runOutput.cancelRunOnDisconnect()) {
try {
runOutput.completeWithError(exception);
} catch (RuntimeException outputException) {
exception.addSuppressed(outputException);
}
try {
agentRuntimeRouteRegistry.removeRun(requestId);
} catch (RuntimeException routeException) {
exception.addSuppressed(routeException);
}
}
throw exception;
}
} }
/** /**
@@ -610,6 +713,88 @@ public class AgentRunService {
account.getTenantId() == null ? null : account.getTenantId().toString()); account.getTenantId() == null ? null : account.getTenantId().toString());
} }
/**
* 查询当前用户可访问的 AG-UI 运行状态。
*
* @param runId 客户端运行 ID
* @return 运行公开状态
*/
public AgentAguiRunStatusView getAguiRunStatus(String runId) {
LoginAccount account = requireCurrentLoginAccount();
return AgentAguiRunStatusView.from(reconcileInterruptedRun(
agentAguiRunStore.requireOwned(runId, account)));
}
/**
* 从指定游标订阅当前用户可访问的 AG-UI 运行事件。
*
* @param runId 客户端运行 ID
* @param afterCursor 已消费的最后事件游标
* @return 增量重放 SSE
*/
public SseEmitter subscribeAguiRun(String runId, long afterCursor) {
LoginAccount account = requireCurrentLoginAccount();
AgentAguiRunDescriptor descriptor = reconcileInterruptedRun(
agentAguiRunStore.requireOwned(runId, account));
return agentAguiRunSubscriptionService.subscribe(descriptor, afterCursor);
}
/**
* 显式取消当前用户拥有的单次 AG-UI 运行。
*
* @param runId 客户端运行 ID
*/
public void cancelAguiRun(String runId) {
LoginAccount account = requireCurrentLoginAccount();
AgentAguiRunDescriptor descriptor = reconcileInterruptedRun(
agentAguiRunStore.requireOwned(runId, account));
if (descriptor.status().isTerminal()) {
return;
}
agentAguiRunStore.requestCancellation(runId);
String userId = account.getId().toString();
if (agentRunRegistry.get(descriptor.requestId()) != null) {
cancelRunLocal(descriptor.requestId(), userId, "用户已停止生成");
return;
}
AgentRuntimeRoute route = agentRuntimeRouteRegistry.findOwnerRoute(descriptor.requestId());
if (route == null || route.getNodeId() == null || route.getNodeId().isBlank()) {
// Runtime 可能仍在编译或初始化,启动路径会在注册前后消费 cancel intent。
return;
}
agentRuntimeCommandProducer.sendCancelRun(
route.getNodeId(), descriptor.requestId(), userId, "用户已停止生成");
}
/**
* 在当前节点显式取消指定运行。
*
* @param requestId 内部请求 ID
* @param userId 当前用户 ID
* @param reason 取消原因
*/
public void cancelRunLocal(String requestId, String userId, String reason) {
agentRunRegistry.cancelRun(requestId, userId, reason);
}
private AgentAguiRunDescriptor reconcileInterruptedRun(AgentAguiRunDescriptor descriptor) {
if (descriptor == null || descriptor.status().isTerminal()) {
return descriptor;
}
AgentRuntimeRoute route = agentRuntimeRouteRegistry.findOwnerRoute(descriptor.requestId());
boolean stale = route == null;
if (route != null && route.getBootId() != null && route.getNodeId() != null) {
String currentBootId = agentRuntimeRouteRegistry.currentNodeBootId(route.getNodeId());
stale = currentBootId == null || !route.getBootId().equals(currentBootId);
}
if (!stale || System.currentTimeMillis() - descriptor.updatedAt() < 90_000L) {
return descriptor;
}
agentAguiRunStore.failOwnerLost(descriptor);
AgentAguiRunDescriptor refreshed = agentAguiRunStore.find(descriptor.runId());
return refreshed == null ? descriptor : refreshed;
}
private void clearDraftSessionInternal(String sessionId, String userId, String tenantId) { private void clearDraftSessionInternal(String sessionId, String userId, String tenantId) {
agentRunRegistry.cancelSession(sessionId, userId); agentRunRegistry.cancelSession(sessionId, userId);
draftAgentSessionStore.delete(sessionId); draftAgentSessionStore.delete(sessionId);
@@ -942,10 +1127,20 @@ public class AgentRunService {
} catch (Exception exception) { } catch (Exception exception) {
AgentRunRegistry.AgentRunContext runContext = agentRunRegistry.get(requestId); AgentRunRegistry.AgentRunContext runContext = agentRunRegistry.get(requestId);
if (runContext != null) { if (runContext != null) {
runContext.cancel(); try {
agentRunRegistry.remove(requestId); runContext.cancel();
} catch (RuntimeException cancelException) {
LOG.warn("Cancel Agent runtime after startup failure failed, requestId={}",
requestId, cancelException);
}
try {
agentRunRegistry.remove(requestId);
} catch (RuntimeException removeException) {
LOG.warn("Remove Agent runtime after startup failure failed, requestId={}",
requestId, removeException);
}
} else if (initialLockHandle != null) { } else if (initialLockHandle != null) {
initialLockHandle.release(); releaseRunLockQuietly(initialLockHandle, requestId);
} }
handleRuntimeError( handleRuntimeError(
exception, exception,
@@ -997,6 +1192,15 @@ public class AgentRunService {
assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog); assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
AgentRunLock.Handle lockHandle = initialLockHandle; AgentRunLock.Handle lockHandle = initialLockHandle;
try { try {
if (isAguiCancellationRequested(runOutput)) {
handleRuntimeEvent(cancellationEvent("用户已停止生成"), requestId, runOutput,
answer, assistantAccumulator, legacyThinkingTagParser,
chatContext, finished, persistChatlog);
if (lockHandle != null) {
releaseRunLockQuietly(lockHandle, requestId);
}
return;
}
if (persistChatlog) { if (persistChatlog) {
bindAgentSession(agent, runtimeSessionId, chatContext); bindAgentSession(agent, runtimeSessionId, chatContext);
} }
@@ -1021,6 +1225,7 @@ public class AgentRunService {
runtimeSessionId, runtimeSessionId,
chatContext.getUserId() == null ? null : chatContext.getUserId().toString() chatContext.getUserId() == null ? null : chatContext.getUserId().toString()
); );
Object runtimeEventLock = new Object();
AgentRunRegistry.AgentRunContext runContext = new AgentRunRegistry.AgentRunContext( AgentRunRegistry.AgentRunContext runContext = new AgentRunRegistry.AgentRunContext(
requestId, requestId,
runtimeSessionId, runtimeSessionId,
@@ -1033,15 +1238,40 @@ public class AgentRunService {
persistChatlog, persistChatlog,
owner, owner,
lockHandle, lockHandle,
event -> handleRuntimeEvent(event, requestId, runOutput, answer, event -> {
assistantAccumulator, legacyThinkingTagParser, chatContext, finished, persistChatlog), synchronized (runtimeEventLock) {
error -> handleRuntimeStreamError(error, requestId, runOutput, chatContext, answer, runRuntimeCallbackSafely(
assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog), () -> handleRuntimeEvent(event, requestId, runOutput, answer,
() -> finishRuntimeStream(requestId, runOutput, chatContext, answer, assistantAccumulator, legacyThinkingTagParser,
assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog) chatContext, finished, persistChatlog),
requestId, runOutput, chatContext, finished, persistChatlog);
}
},
error -> {
synchronized (runtimeEventLock) {
runRuntimeCallbackSafely(
() -> handleRuntimeStreamError(error, requestId, runOutput, chatContext, answer,
assistantAccumulator, legacyThinkingTagParser,
finished, persistChatlog),
requestId, runOutput, chatContext, finished, persistChatlog);
}
},
() -> {
synchronized (runtimeEventLock) {
runRuntimeCallbackSafely(
() -> finishRuntimeStream(requestId, runOutput, chatContext, answer,
assistantAccumulator, legacyThinkingTagParser,
finished, persistChatlog),
requestId, runOutput, chatContext, finished, persistChatlog);
}
}
); );
agentRunRegistry.register(runContext); agentRunRegistry.register(runContext);
lockHandle = null; lockHandle = null;
if (isAguiCancellationRequested(runOutput)) {
runContext.eventConsumer().accept(cancellationEvent("用户已停止生成"));
return;
}
if (finished.get()) { if (finished.get()) {
runContext.cancel(); runContext.cancel();
agentRunRegistry.remove(requestId); agentRunRegistry.remove(requestId);
@@ -1056,16 +1286,141 @@ public class AgentRunService {
} catch (Exception e) { } catch (Exception e) {
AgentRunRegistry.AgentRunContext runContext = agentRunRegistry.get(requestId); AgentRunRegistry.AgentRunContext runContext = agentRunRegistry.get(requestId);
if (runContext != null) { if (runContext != null) {
runContext.cancel(); try {
runContext.cancel();
} catch (RuntimeException cancelException) {
LOG.warn("Cancel Agent runtime after initialization failure failed, requestId={}",
requestId, cancelException);
}
}
try {
agentRunRegistry.remove(requestId);
} catch (RuntimeException removeException) {
LOG.warn("Remove Agent runtime after initialization failure failed, requestId={}",
requestId, removeException);
} }
agentRunRegistry.remove(requestId);
if (lockHandle != null) { if (lockHandle != null) {
lockHandle.release(); releaseRunLockQuietly(lockHandle, requestId);
} }
handleRuntimeError(e, requestId, runOutput, chatContext, finished, persistChatlog); handleRuntimeError(e, requestId, runOutput, chatContext, finished, persistChatlog);
} }
} }
/**
* 尽力释放会话运行锁,避免 Redis 故障遮蔽随后必须执行的终态与资源清理。
*
* @param lockHandle 会话锁句柄
* @param requestId 运行请求 ID
*/
private void releaseRunLockQuietly(AgentRunLock.Handle lockHandle, String requestId) {
if (lockHandle == null) {
return;
}
try {
lockHandle.release();
} catch (RuntimeException exception) {
LOG.warn("Release Agent run lock failed, requestId={}", requestId, exception);
}
}
/**
* 执行序列化后的 Runtime 回调,并保证终态路径自身抛错时仍释放运行资源。
*
* @param callback Runtime 事件、错误或完成回调
* @param requestId 运行请求 ID
* @param runOutput 运行输出
* @param chatContext 聊天上下文
* @param finished 共享终态标记
* @param persistChatlog 是否持久化聊天日志
*/
private void runRuntimeCallbackSafely(Runnable callback,
String requestId,
AgentRunOutput runOutput,
ChatRuntimeContext chatContext,
AtomicBoolean finished,
boolean persistChatlog) {
try {
callback.run();
} catch (RuntimeException exception) {
handleRuntimeCallbackFailure(exception, requestId, runOutput,
chatContext, finished, persistChatlog);
}
}
/**
* 强制收口 Runtime 回调内部故障,不再参与已经完成的终态 CAS。
*
* @param error 回调内部故障
* @param requestId 运行请求 ID
* @param runOutput 运行输出
* @param chatContext 聊天上下文
* @param finished 共享终态标记
* @param persistChatlog 是否持久化聊天日志
*/
private void handleRuntimeCallbackFailure(Throwable error,
String requestId,
AgentRunOutput runOutput,
ChatRuntimeContext chatContext,
AtomicBoolean finished,
boolean persistChatlog) {
finished.set(true);
Throwable safeError = error == null
? new BusinessException("Agent 运行终态处理失败")
: error;
LOG.error("Agent runtime callback failed, requestId={}, message={}",
requestId, safeErrorMessage(safeError), safeError);
AgentRunRegistry.AgentRunContext runContext = agentRunRegistry.get(requestId);
if (runContext != null) {
try {
runContext.cancel();
} catch (RuntimeException cancelException) {
LOG.warn("Cancel Agent runtime after callback failure failed, requestId={}",
requestId, cancelException);
}
}
try {
agentRunRegistry.remove(requestId);
} catch (RuntimeException removeException) {
if (runContext != null) {
runContext.closeRuntime();
}
LOG.error("Remove Agent runtime after callback failure failed, requestId={}",
requestId, removeException);
}
try {
cancelPending(requestId, safeErrorMessage(safeError), persistChatlog);
} catch (RuntimeException pendingException) {
LOG.error("Cancel pending approvals after runtime callback failure failed, requestId={}",
requestId, pendingException);
}
if (persistChatlog) {
try {
chatRuntimeManager.recordFailure(chatContext, safeError);
} catch (RuntimeException recordException) {
LOG.error("Record Agent runtime callback failure failed, requestId={}",
requestId, recordException);
}
}
try {
runOutput.completeWithError(safeError);
} catch (RuntimeException outputException) {
LOG.error("Complete Agent runtime output after callback failure failed, requestId={}",
requestId, outputException);
}
}
private boolean isAguiCancellationRequested(AgentRunOutput runOutput) {
return runOutput instanceof AguiAgentRunOutput aguiOutput
&& agentAguiRunStore.isCancellationRequested(aguiOutput.runId());
}
private AgentRuntimeEvent cancellationEvent(String reason) {
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.CANCELLED);
event.getPayload().put("reason",
reason == null || reason.isBlank() ? "用户已停止生成" : reason);
return event;
}
/** /**
* 取消当前节点上指定 Agent 的全部运行。 * 取消当前节点上指定 Agent 的全部运行。
* *
@@ -1125,6 +1480,9 @@ public class AgentRunService {
LegacyThinkingTagParser legacyThinkingTagParser, LegacyThinkingTagParser legacyThinkingTagParser,
AtomicBoolean finished, AtomicBoolean finished,
boolean persistChatlog) { boolean persistChatlog) {
if (!runOutput.cancelRunOnDisconnect()) {
return;
}
Runnable cancelTask = () -> cancelDisconnectedRun(requestId, chatContext, answer, Runnable cancelTask = () -> cancelDisconnectedRun(requestId, chatContext, answer,
assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog); assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
SseEmitter emitter = runOutput.emitter(); SseEmitter emitter = runOutput.emitter();
@@ -1189,6 +1547,15 @@ public class AgentRunService {
if (event == null || event.getEventType() == null) { if (event == null || event.getEventType() == null) {
return; return;
} }
boolean terminalEvent = isTerminalRuntimeEvent(event.getEventType());
if (terminalEvent) {
// 取消请求来自 HTTP 线程,可能与 Reactor 的完成/失败回调并发;终态必须先统一仲裁再投影。
if (!finished.compareAndSet(false, true)) {
return;
}
} else if (finished.get()) {
return;
}
Map<String, Object> artifact = buildArtifactPublishedPayload(event); Map<String, Object> artifact = buildArtifactPublishedPayload(event);
if (artifact != null) { if (artifact != null) {
Map<String, Object> persistedPayload = new LinkedHashMap<>(); Map<String, Object> persistedPayload = new LinkedHashMap<>();
@@ -1373,10 +1740,8 @@ public class AgentRunService {
return; return;
} }
if (event.getEventType() == AgentRuntimeEventType.COMPLETED) { if (event.getEventType() == AgentRuntimeEventType.COMPLETED) {
if (!emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext, emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext,
answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog)) { answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
return;
}
String finalText = stringPayload(event, "text"); String finalText = stringPayload(event, "text");
if (!legacyThinkingTagParser.isLegacyFormatDetected() && finalText != null && !finalText.isBlank()) { if (!legacyThinkingTagParser.isLegacyFormatDetected() && finalText != null && !finalText.isBlank()) {
answer.setLength(0); answer.setLength(0);
@@ -1385,52 +1750,47 @@ public class AgentRunService {
List<Map<String, Object>> citations = buildKnowledgeCitationPayload(event); List<Map<String, Object>> citations = buildKnowledgeCitationPayload(event);
if (!citations.isEmpty()) { if (!citations.isEmpty()) {
if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.CITATIONS, Map.of("items", citations))) { if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.CITATIONS, Map.of("items", citations))) {
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, LOG.debug("Skip completed-run citations after output closed, requestId={}", requestId);
legacyThinkingTagParser, finished, persistChatlog);
return;
} }
} }
if (!runOutput.emitRuntimeEvent(event)) { runOutput.emitRuntimeEvent(event);
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, finishClaimedRun(requestId, runOutput, chatContext, answer,
legacyThinkingTagParser, finished, persistChatlog); assistantAccumulator, persistChatlog, citations);
return;
}
finishIfNeeded(requestId, runOutput, chatContext, answer,
assistantAccumulator, finished, persistChatlog, citations);
return; return;
} }
if (event.getEventType() == AgentRuntimeEventType.CANCELLED) { if (event.getEventType() == AgentRuntimeEventType.CANCELLED) {
if (!emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext, emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext,
answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog)) { answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
return; runOutput.emitRuntimeEvent(event);
} handleClaimedRuntimeCancelled(event, requestId, runOutput, chatContext, answer,
if (!runOutput.emitRuntimeEvent(event)) { assistantAccumulator, persistChatlog);
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
legacyThinkingTagParser, finished, persistChatlog);
return;
}
handleRuntimeCancelled(event, requestId, runOutput, chatContext, answer,
assistantAccumulator, finished, persistChatlog);
return; return;
} }
if (event.getEventType() == AgentRuntimeEventType.FAILED) { if (event.getEventType() == AgentRuntimeEventType.FAILED) {
if (!emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext, emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext,
answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog)) { answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
return; runOutput.emitRuntimeEvent(event);
}
if (!runOutput.emitRuntimeEvent(event)) {
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
legacyThinkingTagParser, finished, persistChatlog);
return;
}
assistantAccumulator.finalizePendingSkillInvocations("FAILED", "技能调用失败"); assistantAccumulator.finalizePendingSkillInvocations("FAILED", "技能调用失败");
if (persistChatlog) { if (persistChatlog) {
recordPartialAssistantIfPresent(chatContext, answer, assistantAccumulator, errorMessage(event)); recordPartialAssistantIfPresent(chatContext, answer, assistantAccumulator, errorMessage(event));
} }
handleRuntimeError(new BusinessException(errorMessage(event)), requestId, runOutput, chatContext, finished, persistChatlog); handleClaimedRuntimeError(new BusinessException(errorMessage(event)), requestId,
runOutput, chatContext, persistChatlog);
} }
} }
/**
* 判断事件是否会收口一次运行。
*
* @param type 运行时事件类型
* @return 完成、取消或失败事件返回 {@code true}
*/
private boolean isTerminalRuntimeEvent(AgentRuntimeEventType type) {
return type == AgentRuntimeEventType.COMPLETED
|| type == AgentRuntimeEventType.CANCELLED
|| type == AgentRuntimeEventType.FAILED;
}
private boolean deferRuntimeOutput(AgentRuntimeEventType type) { private boolean deferRuntimeOutput(AgentRuntimeEventType type) {
return type == AgentRuntimeEventType.MESSAGE_DELTA return type == AgentRuntimeEventType.MESSAGE_DELTA
|| type == AgentRuntimeEventType.REASONING_STARTED || type == AgentRuntimeEventType.REASONING_STARTED
@@ -1584,12 +1944,23 @@ public class AgentRunService {
LegacyThinkingTagParser legacyThinkingTagParser, LegacyThinkingTagParser legacyThinkingTagParser,
AtomicBoolean finished, AtomicBoolean finished,
boolean persistChatlog) { boolean persistChatlog) {
if (!emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext, AgentRunRegistry.AgentRunContext runContext = agentRunRegistry.get(requestId);
answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog)) { if (runContext != null && runContext.isSuspended()) {
LOG.info("Agent runtime stream suspended, keep SSE and runtime active, requestId={}", requestId);
return; return;
} }
finishIfNeeded(requestId, runOutput, chatContext, answer, if (!runOutput.canFinishSuccessfully()) {
assistantAccumulator, finished, persistChatlog); handleRuntimeError(new BusinessException("Agent 事件流缺少完成事件"),
requestId, runOutput, chatContext, finished, persistChatlog);
return;
}
if (!finished.compareAndSet(false, true)) {
return;
}
emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext,
answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
finishClaimedRun(requestId, runOutput, chatContext, answer,
assistantAccumulator, persistChatlog, List.of());
} }
/** /**
@@ -1614,15 +1985,16 @@ public class AgentRunService {
LegacyThinkingTagParser legacyThinkingTagParser, LegacyThinkingTagParser legacyThinkingTagParser,
AtomicBoolean finished, AtomicBoolean finished,
boolean persistChatlog) { boolean persistChatlog) {
if (!emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext, if (!finished.compareAndSet(false, true)) {
answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog)) {
return; return;
} }
emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext,
answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
assistantAccumulator.finalizePendingSkillInvocations("FAILED", "技能调用失败"); assistantAccumulator.finalizePendingSkillInvocations("FAILED", "技能调用失败");
if (persistChatlog) { if (persistChatlog) {
recordPartialAssistantIfPresent(chatContext, answer, assistantAccumulator, safeErrorMessage(error)); recordPartialAssistantIfPresent(chatContext, answer, assistantAccumulator, safeErrorMessage(error));
} }
handleRuntimeError(error, requestId, runOutput, chatContext, finished, persistChatlog); handleClaimedRuntimeError(error, requestId, runOutput, chatContext, persistChatlog);
} }
private void finishIfNeeded(String requestId, private void finishIfNeeded(String requestId,
@@ -1657,16 +2029,54 @@ public class AgentRunService {
if (!finished.compareAndSet(false, true)) { if (!finished.compareAndSet(false, true)) {
return; return;
} }
finishClaimedRun(requestId, runOutput, chatContext, answer,
assistantAccumulator, persistChatlog, citations);
}
/**
* 完成已经赢得终态仲裁的运行。
*
* @param requestId 运行请求 ID
* @param runOutput 运行输出
* @param chatContext 聊天上下文
* @param answer 最终正文
* @param assistantAccumulator 助手结构化累计器
* @param persistChatlog 是否持久化聊天记录
* @param citations 知识引用
*/
private void finishClaimedRun(String requestId,
AgentRunOutput runOutput,
ChatRuntimeContext chatContext,
StringBuilder answer,
ChatAssistantAccumulator assistantAccumulator,
boolean persistChatlog,
List<Map<String, Object>> citations) {
agentRunRegistry.remove(requestId); agentRunRegistry.remove(requestId);
String finalAnswer = answer.toString(); String finalAnswer = answer.toString();
LOG.info("Agent runtime final answer, requestId={}, sessionId={}, answerLength={}, answer={}", LOG.info("Agent runtime final answer, requestId={}, sessionId={}, answerLength={}, answer={}",
requestId, chatContext.getSessionId(), finalAnswer.length(), toVisibleLogText(finalAnswer)); requestId, chatContext.getSessionId(), finalAnswer.length(), toVisibleLogText(finalAnswer));
if (persistChatlog) { if (persistChatlog) {
chatRuntimeManager.recordAssistantCompleted(chatContext, try {
buildAssistantRuntimeMessage(chatContext, finalAnswer, assistantAccumulator, citations)); chatRuntimeManager.recordAssistantCompleted(chatContext,
buildAssistantRuntimeMessage(chatContext, finalAnswer, assistantAccumulator, citations));
} catch (RuntimeException exception) {
handleClaimedRuntimeError(exception, requestId, runOutput, chatContext, true);
return;
}
}
if (!sendDone(runOutput, finalAnswer)) {
BusinessException outputFailure = new BusinessException("Agent 可恢复输出终态写入失败");
cancelPending(requestId, outputFailure.getMessage(), persistChatlog);
LOG.error("Agent run completion output failed, requestId={}, sessionId={}",
requestId, chatContext.getSessionId(), outputFailure);
if (persistChatlog) {
chatRuntimeManager.recordFailure(chatContext, outputFailure);
}
return;
}
if (persistChatlog) {
chatRuntimeManager.recordCompleted(chatContext); chatRuntimeManager.recordCompleted(chatContext);
} }
sendDone(runOutput, finalAnswer);
} }
private void handleRuntimeError(Throwable error, private void handleRuntimeError(Throwable error,
@@ -1678,6 +2088,23 @@ public class AgentRunService {
if (!finished.compareAndSet(false, true)) { if (!finished.compareAndSet(false, true)) {
return; return;
} }
handleClaimedRuntimeError(error, requestId, runOutput, chatContext, persistChatlog);
}
/**
* 失败收口已经赢得终态仲裁的运行。
*
* @param error 运行异常
* @param requestId 运行请求 ID
* @param runOutput 运行输出
* @param chatContext 聊天上下文
* @param persistChatlog 是否持久化聊天记录
*/
private void handleClaimedRuntimeError(Throwable error,
String requestId,
AgentRunOutput runOutput,
ChatRuntimeContext chatContext,
boolean persistChatlog) {
agentRunRegistry.remove(requestId); agentRunRegistry.remove(requestId);
cancelPending(requestId, safeErrorMessage(error), persistChatlog); cancelPending(requestId, safeErrorMessage(error), persistChatlog);
Throwable safeError = error == null ? new BusinessException("Agent 运行失败") : error; Throwable safeError = error == null ? new BusinessException("Agent 运行失败") : error;
@@ -1700,17 +2127,24 @@ public class AgentRunService {
return error.getMessage(); return error.getMessage();
} }
private void handleRuntimeCancelled(AgentRuntimeEvent event, /**
String requestId, * 取消收口已经赢得终态仲裁的运行。
AgentRunOutput runOutput, *
ChatRuntimeContext chatContext, * @param event 取消事件
StringBuilder answer, * @param requestId 运行请求 ID
ChatAssistantAccumulator assistantAccumulator, * @param runOutput 运行输出
AtomicBoolean finished, * @param chatContext 聊天上下文
boolean persistChatlog) { * @param answer 已生成正文
if (!finished.compareAndSet(false, true)) { * @param assistantAccumulator 助手结构化累计器
return; * @param persistChatlog 是否持久化聊天记录
} */
private void handleClaimedRuntimeCancelled(AgentRuntimeEvent event,
String requestId,
AgentRunOutput runOutput,
ChatRuntimeContext chatContext,
StringBuilder answer,
ChatAssistantAccumulator assistantAccumulator,
boolean persistChatlog) {
agentRunRegistry.remove(requestId); agentRunRegistry.remove(requestId);
String reason = errorMessage(event); String reason = errorMessage(event);
assistantAccumulator.finalizePendingSkillInvocations("CANCELLED", "技能调用已停止"); assistantAccumulator.finalizePendingSkillInvocations("CANCELLED", "技能调用已停止");

View File

@@ -0,0 +1,32 @@
package tech.easyflow.agent.runtime.agui;
/**
* AG-UI 可重连运行的公开状态与内部路由描述。
*
* @param runId 客户端运行 ID
* @param requestId 服务端内部请求 ID
* @param threadId AG-UI thread ID
* @param agentId Agent ID
* @param sessionId 运行会话 ID
* @param userId 所属用户 ID
* @param tenantId 所属租户 ID
* @param draft 是否为草稿试运行
* @param status 当前运行状态
* @param lastCursor 已持久化的最后事件游标
* @param createdAt 创建时间戳
* @param updatedAt 最近更新时间戳
*/
public record AgentAguiRunDescriptor(
String runId,
String requestId,
String threadId,
String agentId,
String sessionId,
String userId,
String tenantId,
boolean draft,
AgentAguiRunStatus status,
long lastCursor,
long createdAt,
long updatedAt) {
}

View File

@@ -0,0 +1,330 @@
package tech.easyflow.agent.runtime.agui;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.easyagents.agui.AguiExtendedEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantLock;
import java.util.UUID;
/**
* 将高频 AG-UI delta 在内存中短暂合并并批量写入 Redis。
*
* <p>该组件以每个 runId 的同步缓冲保证事件顺序Redis 故障时保留原批次等待下一次重试,
* 避免每个模型 token 都产生一次网络往返。</p>
*/
@Component
public class AgentAguiRunJournal {
private static final Logger LOG = LoggerFactory.getLogger(AgentAguiRunJournal.class);
private static final int MAX_BUFFERED_EVENTS_PER_RUN = 4096;
private static final long MAX_EVENT_BYTES = 512L * 1024L;
private static final long MAX_BUFFERED_BYTES_PER_RUN = 4L * 1024L * 1024L;
private static final long MAX_BUFFERED_BYTES_GLOBAL = 64L * 1024L * 1024L;
private static final long COMPLETED_RETRY_WINDOW_MILLIS = 5L * 60L * 1000L;
private final AgentAguiRunStore store;
private final ObjectMapper objectMapper;
private final Map<String, RunBuffer> buffers = new ConcurrentHashMap<>();
private final AtomicLong globalBufferedBytes = new AtomicLong();
/**
* 创建 AG-UI 事件日志协调器。
*
* @param store Redis 运行存储
* @param objectMapper JSON 映射器
*/
public AgentAguiRunJournal(AgentAguiRunStore store, ObjectMapper objectMapper) {
this.store = store;
this.objectMapper = objectMapper;
}
/**
* 追加一条已编码协议事件。
*
* @param runId 运行 ID
* @param data 协议事件 JSON
*/
public void append(String runId, String data) {
if (data == null || data.isBlank()) {
throw new IllegalArgumentException("AG-UI 事件不能为空");
}
long dataBytes = utf8Bytes(data);
if (dataBytes > MAX_EVENT_BYTES) {
throw new IllegalStateException("单条 AG-UI 事件超过 512 KiB 安全上限");
}
RunBuffer buffer = buffers.computeIfAbsent(runId, ignored -> new RunBuffer());
try {
synchronized (buffer) {
if (buffer.completed) {
throw new IllegalStateException("AG-UI 运行事件日志已经收口");
}
int pendingEventCount = buffer.pendingBatch == null ? 0 : buffer.pendingBatch.events().size();
if (pendingEventCount + buffer.tailEvents.size() >= MAX_BUFFERED_EVENTS_PER_RUN) {
throw new IllegalStateException("AG-UI 事件日志暂时不可用,请稍后重试");
}
String merged = buffer.tailEvents.isEmpty()
? null
: mergeDelta(buffer.tailEvents.get(buffer.tailEvents.size() - 1), data);
long previousBytes = 0L;
long nextBytes = dataBytes;
if (merged != null) {
String previous = buffer.tailEvents.get(buffer.tailEvents.size() - 1);
previousBytes = utf8Bytes(previous);
nextBytes = utf8Bytes(merged);
if (nextBytes > MAX_EVENT_BYTES) {
throw new IllegalStateException("合并后的 AG-UI 事件超过 512 KiB 安全上限");
}
}
long deltaBytes = nextBytes - previousBytes;
if (buffer.totalBytes() + deltaBytes > MAX_BUFFERED_BYTES_PER_RUN) {
throw new IllegalStateException("单次 AG-UI 运行待写日志超过 4 MiB 安全上限");
}
long globalBytes = globalBufferedBytes.addAndGet(deltaBytes);
if (globalBytes > MAX_BUFFERED_BYTES_GLOBAL) {
globalBufferedBytes.addAndGet(-deltaBytes);
throw new IllegalStateException("AG-UI 待写日志超过 64 MiB 全局安全上限");
}
if (merged == null) {
buffer.tailEvents.add(data);
} else {
buffer.tailEvents.set(buffer.tailEvents.size() - 1, merged);
}
buffer.tailBytes += deltaBytes;
}
} catch (RuntimeException exception) {
removeEmptyBuffer(runId, buffer);
throw exception;
}
}
/**
* 丢弃尚未确认持久化的普通事件,并以独立小批次记录日志故障终态。
*
* <p>该路径保留已经成功写入 Redis 的历史。即使前一批次处于结果不确定状态,
* 新终态也使用新的批次 ID避免幂等判重吞掉故障事件。</p>
*
* @param threadId AG-UI 线程 ID
* @param runId 运行 ID
* @param code 稳定错误码
* @param message 用户可理解的错误信息
*/
public void fail(String threadId, String runId, String code, String message) {
String terminalEvent;
try {
terminalEvent = objectMapper.writeValueAsString(new AguiExtendedEvent.RunError(
threadId, runId, message, code));
} catch (Exception exception) {
throw new IllegalStateException("AG-UI 日志故障终态编码失败", exception);
}
long terminalBytes = utf8Bytes(terminalEvent);
RunBuffer buffer = buffers.computeIfAbsent(runId, ignored -> new RunBuffer());
synchronized (buffer) {
long discardedBytes = buffer.totalBytes();
buffer.pendingBatch = null;
buffer.tailEvents.clear();
buffer.tailBytes = 0L;
if (discardedBytes > 0L) {
globalBufferedBytes.addAndGet(-discardedBytes);
}
buffer.pendingBatch = new PendingBatch(
UUID.randomUUID().toString(), List.of(terminalEvent), terminalBytes);
globalBufferedBytes.addAndGet(terminalBytes);
buffer.completed = true;
buffer.completedAt = System.currentTimeMillis();
}
flush(runId, buffer, true);
}
/**
* 在运行收口前同步尝试刷入该运行的剩余事件。
*
* @param runId 运行 ID
*/
public void flush(String runId) {
RunBuffer buffer = buffers.get(runId);
if (buffer != null) {
flush(runId, buffer, true);
}
}
/**
* 同步刷入终态事件,并在确认缓冲为空后释放本地运行缓冲。
*
* @param runId 运行 ID
*/
public void complete(String runId) {
RunBuffer buffer = buffers.get(runId);
if (buffer == null) {
return;
}
synchronized (buffer) {
buffer.completed = true;
buffer.completedAt = System.currentTimeMillis();
}
flush(runId, buffer, true);
}
/**
* 定期批量写入所有活动运行的待持久化事件。
*/
@Scheduled(fixedDelayString = "${easyflow.agent.runtime.agui-journal-flush-interval:50ms}")
public void flushPending() {
for (Map.Entry<String, RunBuffer> entry : buffers.entrySet()) {
flush(entry.getKey(), entry.getValue(), false);
}
}
private void flush(String runId, RunBuffer buffer, boolean propagateFailure) {
boolean acquired;
if (propagateFailure) {
buffer.flushLock.lock();
acquired = true;
} else {
acquired = buffer.flushLock.tryLock();
}
if (!acquired) {
return;
}
try {
while (true) {
PendingBatch batch;
synchronized (buffer) {
if (buffer.pendingBatch == null && !buffer.tailEvents.isEmpty()) {
buffer.pendingBatch = new PendingBatch(
UUID.randomUUID().toString(),
List.copyOf(buffer.tailEvents),
buffer.tailBytes);
buffer.tailEvents.clear();
buffer.tailBytes = 0L;
}
batch = buffer.pendingBatch;
if (batch == null) {
if (buffer.completed) {
removeBuffer(runId, buffer);
}
return;
}
}
try {
// Redis I/O 必须位于 RunBuffer 锁外,避免阻塞模型事件线程。
store.append(runId, batch.id(), batch.events());
synchronized (buffer) {
if (buffer.pendingBatch == batch) {
buffer.pendingBatch = null;
globalBufferedBytes.addAndGet(-batch.bytes());
}
}
} catch (RuntimeException exception) {
LOG.error("AG-UI 事件日志写入失败: runId={}, eventCount={}",
runId, batch.events().size(), exception);
boolean batchWasReplaced;
synchronized (buffer) {
batchWasReplaced = buffer.pendingBatch != batch;
if (!batchWasReplaced && buffer.completed && buffer.completedAt > 0L
&& System.currentTimeMillis() - buffer.completedAt
>= COMPLETED_RETRY_WINDOW_MILLIS) {
LOG.error("丢弃超过重试窗口的 AG-UI 终态日志缓冲: runId={}, eventCount={}, bytes={}",
runId, buffer.eventCount(), buffer.totalBytes());
removeBuffer(runId, buffer);
}
}
if (batchWasReplaced) {
continue;
}
if (propagateFailure) {
throw exception;
}
return;
}
}
} finally {
buffer.flushLock.unlock();
}
}
private void removeEmptyBuffer(String runId, RunBuffer buffer) {
synchronized (buffer) {
if (buffer.pendingBatch == null && buffer.tailEvents.isEmpty() && !buffer.flushLock.isLocked()) {
buffers.remove(runId, buffer);
}
}
}
private void removeBuffer(String runId, RunBuffer buffer) {
if (!buffers.remove(runId, buffer)) {
return;
}
long remainingBytes = buffer.totalBytes();
buffer.pendingBatch = null;
buffer.tailEvents.clear();
buffer.tailBytes = 0L;
if (remainingBytes > 0L) {
globalBufferedBytes.addAndGet(-remainingBytes);
}
}
private long utf8Bytes(String value) {
return value.getBytes(StandardCharsets.UTF_8).length;
}
private String mergeDelta(String previous, String current) {
try {
JsonNode previousNode = objectMapper.readTree(previous);
JsonNode currentNode = objectMapper.readTree(current);
String type = currentNode.path("type").asText();
if (!type.equals(previousNode.path("type").asText()) || !isMergeable(type)) {
return null;
}
String identityField = "TOOL_CALL_ARGS".equals(type) ? "toolCallId" : "messageId";
if (!currentNode.path(identityField).asText().equals(previousNode.path(identityField).asText())) {
return null;
}
if (!(previousNode instanceof ObjectNode previousObject)) {
return null;
}
previousObject.put("delta",
previousNode.path("delta").asText() + currentNode.path("delta").asText());
return objectMapper.writeValueAsString(previousObject);
} catch (Exception ignored) {
return null;
}
}
private boolean isMergeable(String type) {
return "TEXT_MESSAGE_CONTENT".equals(type)
|| "REASONING_MESSAGE_CONTENT".equals(type)
|| "TOOL_CALL_ARGS".equals(type);
}
private record PendingBatch(String id, List<String> events, long bytes) {
}
private static final class RunBuffer {
private final List<String> tailEvents = new ArrayList<>();
private final ReentrantLock flushLock = new ReentrantLock();
private PendingBatch pendingBatch;
private long tailBytes;
private boolean completed;
private long completedAt;
private int eventCount() {
return tailEvents.size() + (pendingBatch == null ? 0 : pendingBatch.events().size());
}
private long totalBytes() {
return tailBytes + (pendingBatch == null ? 0L : pendingBatch.bytes());
}
}
}

View File

@@ -0,0 +1,36 @@
package tech.easyflow.agent.runtime.agui;
/**
* AG-UI 可重连运行状态。
*/
public enum AgentAguiRunStatus {
/**
* 运行正在生成或等待人工审批。
*/
RUNNING,
/**
* 运行成功完成。
*/
COMPLETED,
/**
* 运行失败。
*/
FAILED,
/**
* 运行被用户显式取消。
*/
CANCELLED;
/**
* 判断当前状态是否已经终止。
*
* @return 已终止时为 true
*/
public boolean isTerminal() {
return this != RUNNING;
}
}

View File

@@ -0,0 +1,36 @@
package tech.easyflow.agent.runtime.agui;
/**
* 提供给前端刷新恢复判断的 AG-UI 运行状态。
*
* @param runId 运行 ID
* @param threadId AG-UI thread ID
* @param status 当前状态
* @param lastCursor 最后事件游标
* @param draft 是否为草稿试运行
* @param updatedAt 最近更新时间戳
*/
public record AgentAguiRunStatusView(
String runId,
String threadId,
AgentAguiRunStatus status,
long lastCursor,
boolean draft,
long updatedAt) {
/**
* 从内部运行描述生成公开状态。
*
* @param descriptor 内部运行描述
* @return 公开状态
*/
public static AgentAguiRunStatusView from(AgentAguiRunDescriptor descriptor) {
return new AgentAguiRunStatusView(
descriptor.runId(),
descriptor.threadId(),
descriptor.status(),
descriptor.lastCursor(),
descriptor.draft(),
descriptor.updatedAt());
}
}

View File

@@ -0,0 +1,369 @@
package tech.easyflow.agent.runtime.agui;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import com.easyagents.agui.AguiExtendedEvent;
import tech.easyflow.agent.config.AgentRuntimeProperties;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.time.Duration;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* 基于 Redis 的 AG-UI 运行元数据与顺序事件日志。
*
* <p>运行 ID 只承担幂等查找作用,所有读取和取消仍必须校验登录用户与租户归属。</p>
*/
@Component
public class AgentAguiRunStore {
private static final String RUN_PREFIX = "easyflow:agent:agui:run:";
private static final String STATUS_FIELD = "status";
private static final int REPLAY_BATCH_SIZE = 256;
private static final Pattern RUN_ID_PATTERN = Pattern.compile("[A-Za-z0-9_-]{1,128}");
private static final long MAX_RUN_EVENT_BYTES = 16L * 1024L * 1024L;
private static final long TERMINAL_EVENT_RESERVE_BYTES = 64L * 1024L;
private static final DefaultRedisScript<Long> APPEND_SCRIPT = new DefaultRedisScript<>("""
local status = redis.call('HGET', KEYS[1], 'status')
if not status then
return -3
end
local lastCursor = tonumber(redis.call('HGET', KEYS[1], 'lastCursor') or '0')
if redis.call('HGET', KEYS[1], 'lastBatchId') == ARGV[1] then
return lastCursor
end
if status ~= 'RUNNING' then
return lastCursor
end
local currentBytes = tonumber(redis.call('HGET', KEYS[1], 'eventBytes') or '0')
local incomingBytes = tonumber(ARGV[5])
local byteLimit = tonumber(ARGV[6])
if ARGV[4] ~= '' then
byteLimit = byteLimit + tonumber(ARGV[7])
end
if currentBytes + incomingBytes > byteLimit then
return -1
end
local eventCount = tonumber(ARGV[8])
for index = 1, eventCount do
redis.call('RPUSH', KEYS[2], ARGV[8 + index])
end
local cursor = redis.call('LLEN', KEYS[2])
redis.call('HSET', KEYS[1],
'updatedAt', ARGV[2],
'lastBatchId', ARGV[1],
'eventBytes', currentBytes + incomingBytes,
'lastCursor', cursor)
if ARGV[4] ~= '' then
redis.call('HSET', KEYS[1], 'status', ARGV[4])
end
redis.call('PEXPIRE', KEYS[1], ARGV[3])
redis.call('PEXPIRE', KEYS[2], ARGV[3])
redis.call('PEXPIRE', KEYS[3], ARGV[3])
return cursor
""", Long.class);
private static final DefaultRedisScript<Long> REQUEST_CANCEL_SCRIPT = new DefaultRedisScript<>("""
if redis.call('HGET', KEYS[1], 'status') ~= 'RUNNING' then
return 0
end
redis.call('HSET', KEYS[1], 'cancelRequested', 'true', 'updatedAt', ARGV[1])
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1
""", Long.class);
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
private final Duration retention;
/**
* 创建 AG-UI 运行存储。
*
* @param redisTemplate Redis 字符串模板
* @param objectMapper JSON 映射器
* @param properties Agent 运行配置
*/
public AgentAguiRunStore(StringRedisTemplate redisTemplate,
ObjectMapper objectMapper,
AgentRuntimeProperties properties) {
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper;
this.retention = properties.getAguiRunRetention();
}
/**
* 在模型运行启动前注册唯一运行元数据。
*
* @param descriptor 初始运行描述
* @throws BusinessException runId 已存在时抛出
*/
public void create(AgentAguiRunDescriptor descriptor) {
String runId = requireRunId(descriptor == null ? null : descriptor.runId());
Boolean reserved = redisTemplate.opsForValue().setIfAbsent(
reservationKey(runId), descriptor.userId(), retention);
if (!Boolean.TRUE.equals(reserved)) {
throw new BusinessException("当前 Agent 运行标识已存在,请勿重复提交");
}
try {
Map<String, String> values = new LinkedHashMap<>();
values.put("runId", runId);
values.put("requestId", value(descriptor.requestId()));
values.put("threadId", value(descriptor.threadId()));
values.put("agentId", value(descriptor.agentId()));
values.put("sessionId", value(descriptor.sessionId()));
values.put("userId", value(descriptor.userId()));
values.put("tenantId", value(descriptor.tenantId()));
values.put("draft", Boolean.toString(descriptor.draft()));
values.put(STATUS_FIELD, AgentAguiRunStatus.RUNNING.name());
values.put("cancelRequested", Boolean.FALSE.toString());
values.put("eventBytes", "0");
values.put("lastBatchId", "");
values.put("lastCursor", "0");
values.put("createdAt", Long.toString(descriptor.createdAt()));
values.put("updatedAt", Long.toString(descriptor.updatedAt()));
meta().putAll(metaKey(runId), values);
redisTemplate.expire(metaKey(runId), retention);
redisTemplate.expire(eventsKey(runId), retention);
} catch (RuntimeException exception) {
redisTemplate.delete(reservationKey(runId));
throw exception;
}
}
/**
* 批量追加严格有序的协议事件。
*
* @param runId 运行 ID
* @param events 已编码 AG-UI 事件
* @return 追加后的最后事件游标
*/
public long append(String runId, String batchId, List<String> events) {
requireRunId(runId);
if (events == null || events.isEmpty()) {
AgentAguiRunDescriptor descriptor = find(runId);
return descriptor == null ? 0L : descriptor.lastCursor();
}
if (batchId == null || batchId.isBlank()) {
throw new IllegalArgumentException("AG-UI 事件批次 ID 不能为空");
}
AgentAguiRunStatus terminalStatus = terminalStatus(events);
long eventBytes = events.stream()
.mapToLong(event -> event.getBytes(StandardCharsets.UTF_8).length)
.sum();
List<String> arguments = new java.util.ArrayList<>(8 + events.size());
arguments.add(batchId);
arguments.add(Long.toString(System.currentTimeMillis()));
arguments.add(Long.toString(retention.toMillis()));
arguments.add(terminalStatus == null ? "" : terminalStatus.name());
arguments.add(Long.toString(eventBytes));
arguments.add(Long.toString(MAX_RUN_EVENT_BYTES));
arguments.add(Long.toString(TERMINAL_EVENT_RESERVE_BYTES));
arguments.add(Integer.toString(events.size()));
arguments.addAll(events);
Long cursor = redisTemplate.execute(
APPEND_SCRIPT,
List.of(metaKey(runId), eventsKey(runId), reservationKey(runId)),
arguments.toArray());
if (cursor == null || cursor == -3L) {
throw new IllegalStateException("AG-UI 运行记录不存在或已过期");
}
if (cursor == -1L) {
throw new IllegalStateException("AG-UI 运行事件超过 16 MiB 安全上限");
}
return cursor;
}
/**
* 读取游标之后的一批事件。
*
* @param runId 运行 ID
* @param afterCursor 已消费的最后游标,零表示从头读取
* @return 按游标升序排列的事件
*/
public List<String> readAfter(String runId, long afterCursor) {
requireRunId(runId);
long start = Math.max(0L, afterCursor);
List<String> values = redisTemplate.opsForList().range(
eventsKey(runId), start, start + REPLAY_BATCH_SIZE - 1L);
return values == null ? List.of() : values;
}
/**
* 查询运行描述。
*
* @param runId 运行 ID
* @return 运行描述;不存在时为 null
*/
public AgentAguiRunDescriptor find(String runId) {
requireRunId(runId);
Map<Object, Object> values = meta().entries(metaKey(runId));
if (values == null || values.isEmpty()) {
return null;
}
return new AgentAguiRunDescriptor(
text(values, "runId"),
text(values, "requestId"),
text(values, "threadId"),
text(values, "agentId"),
text(values, "sessionId"),
text(values, "userId"),
text(values, "tenantId"),
Boolean.parseBoolean(text(values, "draft")),
parseStatus(text(values, STATUS_FIELD)),
number(values, "lastCursor"),
number(values, "createdAt"),
number(values, "updatedAt"));
}
/**
* 查询并校验当前登录账号对运行的访问权。
*
* @param runId 运行 ID
* @param account 当前登录账号
* @return 已授权运行描述
* @throws BusinessException 运行不存在或无访问权时抛出
*/
public AgentAguiRunDescriptor requireOwned(String runId, LoginAccount account) {
AgentAguiRunDescriptor descriptor = find(runId);
if (descriptor == null) {
throw new BusinessException("Agent 运行记录不存在或已过期");
}
String userId = account == null || account.getId() == null ? null : account.getId().toString();
String tenantId = account == null || account.getTenantId() == null ? null : account.getTenantId().toString();
if (!descriptor.userId().equals(userId) || !descriptor.tenantId().equals(tenantId)) {
throw new BusinessException("无权访问该 Agent 运行");
}
return descriptor;
}
/**
* 将无法继续的运行收口为失败状态。
*
* @param runId 运行 ID
*/
public void failOwnerLost(AgentAguiRunDescriptor descriptor) {
if (descriptor == null || descriptor.status().isTerminal()) {
return;
}
try {
String event = objectMapper.writeValueAsString(new AguiExtendedEvent.RunError(
descriptor.threadId(),
descriptor.runId(),
"Agent 运行节点已不可用",
"AGENT_RUN_OWNER_LOST"));
append(descriptor.runId(), "owner-lost-" + UUID.randomUUID(), List.of(event));
} catch (RuntimeException exception) {
throw exception;
} catch (Exception exception) {
throw new IllegalStateException("AG-UI owner-lost 终态编码失败", exception);
}
}
/**
* 记录显式取消意图,供 Runtime 注册前后的启动窗口检查。
*
* @param runId 运行 ID
* @return 本次是否首次接受取消意图
*/
public boolean requestCancellation(String runId) {
requireRunId(runId);
Long result = redisTemplate.execute(
REQUEST_CANCEL_SCRIPT,
List.of(metaKey(runId)),
Long.toString(System.currentTimeMillis()),
Long.toString(retention.toMillis()));
return Long.valueOf(1L).equals(result);
}
/**
* 判断运行是否收到显式取消意图。
*
* @param runId 运行 ID
* @return 已请求取消时为 true
*/
public boolean isCancellationRequested(String runId) {
Object value = meta().get(metaKey(requireRunId(runId)), "cancelRequested");
return Boolean.parseBoolean(value == null ? "false" : value.toString());
}
private HashOperations<String, Object, Object> meta() {
return redisTemplate.opsForHash();
}
private AgentAguiRunStatus terminalStatus(List<String> events) {
AgentAguiRunStatus result = null;
for (String event : events) {
try {
JsonNode value = objectMapper.readTree(event);
String type = value.path("type").asText();
if ("RUN_FINISHED".equals(type)) {
result = AgentAguiRunStatus.COMPLETED;
} else if ("RUN_ERROR".equals(type)) {
result = "RUN_CANCELLED".equals(value.path("code").asText())
? AgentAguiRunStatus.CANCELLED
: AgentAguiRunStatus.FAILED;
}
} catch (Exception exception) {
throw new IllegalStateException("AG-UI 运行事件格式不合法", exception);
}
}
return result;
}
private AgentAguiRunStatus parseStatus(String value) {
try {
return AgentAguiRunStatus.valueOf(value);
} catch (Exception ignored) {
return AgentAguiRunStatus.FAILED;
}
}
private String requireRunId(String runId) {
if (runId == null || !RUN_ID_PATTERN.matcher(runId).matches()) {
throw new BusinessException("Agent 运行 ID 不合法");
}
return runId;
}
private String reservationKey(String runId) {
return runKey(runId, "reserved");
}
private String metaKey(String runId) {
return runKey(runId, "meta");
}
private String eventsKey(String runId) {
return runKey(runId, "events");
}
private String runKey(String runId, String suffix) {
return RUN_PREFIX + "{" + runId + "}:" + suffix;
}
private String value(String value) {
return value == null ? "" : value;
}
private String text(Map<Object, Object> values, String key) {
Object value = values.get(key);
return value == null ? "" : value.toString();
}
private long number(Map<Object, Object> values, String key) {
try {
return Long.parseLong(text(values, key));
} catch (NumberFormatException ignored) {
return 0L;
}
}
}

View File

@@ -0,0 +1,160 @@
package tech.easyflow.agent.runtime.agui;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* 以共享调度器为本节点上的 AG-UI 重连订阅者增量重放 Redis 事件。
*
* <p>所有订阅共享一个短周期任务,不为每个浏览器连接创建阻塞线程。</p>
*/
@Component
public class AgentAguiRunSubscriptionService {
private static final Logger LOG = LoggerFactory.getLogger(AgentAguiRunSubscriptionService.class);
private static final long KEEP_ALIVE_INTERVAL_MILLIS = 15_000L;
private final AgentAguiRunStore store;
private final Map<String, CopyOnWriteArrayList<Subscriber>> subscribers = new ConcurrentHashMap<>();
/**
* 创建 AG-UI 重连订阅服务。
*
* @param store Redis 运行存储
*/
public AgentAguiRunSubscriptionService(AgentAguiRunStore store) {
this.store = store;
}
/**
* 注册一个从指定游标继续的 SSE 订阅。
*
* @param descriptor 已授权运行描述
* @param afterCursor 已消费的最后游标
* @return 重连 SSE 发射器
*/
public SseEmitter subscribe(AgentAguiRunDescriptor descriptor, long afterCursor) {
return subscribe(descriptor, afterCursor, new SseEmitter(0L));
}
/**
* 使用指定发射器注册订阅,供生命周期测试复用同一调度逻辑。
*
* @param descriptor 已授权运行描述
* @param afterCursor 已消费的最后游标
* @param emitter SSE 发射器
* @return 已注册发射器
*/
SseEmitter subscribe(AgentAguiRunDescriptor descriptor,
long afterCursor,
SseEmitter emitter) {
Subscriber subscriber = new Subscriber(emitter, Math.max(0L, afterCursor));
subscribers.computeIfAbsent(descriptor.runId(), ignored -> new CopyOnWriteArrayList<>())
.add(subscriber);
Runnable remove = () -> remove(descriptor.runId(), subscriber);
emitter.onCompletion(remove);
emitter.onTimeout(remove);
emitter.onError(error -> remove.run());
return emitter;
}
/**
* 周期性向全部本地订阅者重放新增事件并发送保活注释。
*/
@Scheduled(fixedDelayString = "${easyflow.agent.runtime.agui-replay-poll-interval:100ms}")
public void dispatch() {
for (Map.Entry<String, CopyOnWriteArrayList<Subscriber>> entry : subscribers.entrySet()) {
dispatch(entry.getKey(), entry.getValue());
}
}
private void dispatch(String runId, CopyOnWriteArrayList<Subscriber> runSubscribers) {
try {
AgentAguiRunDescriptor descriptor = store.find(runId);
if (descriptor == null) {
runSubscribers.forEach(subscriber -> removeAndComplete(runId, subscriber));
return;
}
Map<Long, List<Subscriber>> cursorGroups = new LinkedHashMap<>();
for (Subscriber subscriber : runSubscribers) {
cursorGroups.computeIfAbsent(subscriber.cursor, ignored -> new java.util.ArrayList<>())
.add(subscriber);
}
for (Map.Entry<Long, List<Subscriber>> cursorGroup : cursorGroups.entrySet()) {
long cursor = cursorGroup.getKey();
List<String> events = store.readAfter(runId, cursor);
for (Subscriber subscriber : cursorGroup.getValue()) {
dispatchSubscriber(runId, subscriber, cursor, events, descriptor);
}
}
} catch (RuntimeException exception) {
LOG.error("AG-UI 重连订阅调度失败: runId={}, subscriberCount={}",
runId, runSubscribers.size(), exception);
}
}
private void dispatchSubscriber(String runId,
Subscriber subscriber,
long afterCursor,
List<String> events,
AgentAguiRunDescriptor descriptor) {
try {
for (int index = 0; index < events.size(); index++) {
long cursor = afterCursor + index + 1L;
subscriber.emitter.send(SseEmitter.event().id(Long.toString(cursor)).data(events.get(index)));
subscriber.cursor = cursor;
subscriber.lastWriteAt = System.currentTimeMillis();
}
if (descriptor.status().isTerminal() && subscriber.cursor >= descriptor.lastCursor()) {
removeAndComplete(runId, subscriber);
return;
}
long now = System.currentTimeMillis();
if (events.isEmpty() && now - subscriber.lastWriteAt >= KEEP_ALIVE_INTERVAL_MILLIS) {
subscriber.emitter.send(SseEmitter.event().comment("keepalive"));
subscriber.lastWriteAt = now;
}
} catch (IOException | IllegalStateException exception) {
LOG.debug("移除已断开的 AG-UI 重连订阅: runId={}, cursor={}",
runId, subscriber.cursor, exception);
remove(runId, subscriber);
}
}
private void removeAndComplete(String runId, Subscriber subscriber) {
remove(runId, subscriber);
subscriber.emitter.complete();
}
private void remove(String runId, Subscriber subscriber) {
CopyOnWriteArrayList<Subscriber> values = subscribers.get(runId);
if (values == null) {
return;
}
values.remove(subscriber);
if (values.isEmpty()) {
subscribers.remove(runId, values);
}
}
private static final class Subscriber {
private final SseEmitter emitter;
private long cursor;
private long lastWriteAt = System.currentTimeMillis();
private Subscriber(SseEmitter emitter, long cursor) {
this.emitter = emitter;
this.cursor = cursor;
}
}
}

View File

@@ -4,7 +4,7 @@ package tech.easyflow.agent.runtime.agui;
* 单次 AG-UI 连接的客户端 wire 标识。 * 单次 AG-UI 连接的客户端 wire 标识。
* *
* @param threadId 客户端 thread ID * @param threadId 客户端 thread ID
* @param runId 客户端 run ID用于协议输出 * @param runId 客户端 run ID用于协议输出、幂等查找与刷新重连
* @param userMessageId 客户端本轮用户消息 ID * @param userMessageId 客户端本轮用户消息 ID
* @param userMessageContent 客户端本轮用户消息正文 * @param userMessageContent 客户端本轮用户消息正文
*/ */

View File

@@ -0,0 +1,144 @@
package tech.easyflow.agent.runtime.agui;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 将 AG-UI 运行输出写入可重放日志,同时尽力推送到首次 HTTP 连接。
*
* <p>底层浏览器连接关闭后,本对象仍保持逻辑可写,模型运行只会因终态或显式取消而结束。</p>
*/
public final class ResumableAguiSseEmitter extends ChatSseEmitter {
private static final Logger LOG = LoggerFactory.getLogger(ResumableAguiSseEmitter.class);
private final String threadId;
private final String runId;
private final AgentAguiRunJournal journal;
private final SseEmitter subscriberEmitter;
private final AtomicBoolean completed = new AtomicBoolean(false);
private final AtomicBoolean journalFailed = new AtomicBoolean(false);
/**
* 创建可重连 AG-UI SSE 发射器。
*
* @param threadId AG-UI 线程 ID
* @param runId 运行 ID
* @param journal 可重放事件日志
* @param subscriberEmitter 从游标零开始的首次订阅
*/
public ResumableAguiSseEmitter(String threadId,
String runId,
AgentAguiRunJournal journal,
SseEmitter subscriberEmitter) {
super(0L);
this.threadId = threadId;
this.runId = runId;
this.journal = journal;
this.subscriberEmitter = java.util.Objects.requireNonNull(
subscriberEmitter, "subscriberEmitter cannot be null");
}
/**
* 返回与重连相同的 Redis 游标订阅,首次连接不走旁路直推。
*
* @return 首次订阅 SSE
*/
@Override
public SseEmitter getEmitter() {
return subscriberEmitter;
}
/**
* 先记录事件,再尽力发送给当前浏览器连接。
*
* @param data 已编码 AG-UI 事件
* @return 日志已接受事件时为 true
*/
@Override
public boolean sendData(String data) {
if (completed.get()) {
return false;
}
try {
journal.append(runId, data);
} catch (RuntimeException exception) {
LOG.error("AG-UI 事件日志拒绝写入: runId={}", runId, exception);
journalFailed.set(true);
completed.set(true);
failJournal(exception);
super.complete();
return false;
}
return true;
}
/**
* 刷入尾部事件并关闭首次 HTTP 连接。
*/
@Override
public void complete() {
if (!completed.compareAndSet(false, true)) {
return;
}
try {
journal.complete(runId);
} catch (RuntimeException exception) {
LOG.error("AG-UI 运行终态日志同步写入失败,改写为日志故障终态: runId={}", runId, exception);
journalFailed.set(true);
failJournal(exception);
} finally {
super.complete();
}
}
/**
* 刷入尾部事件并关闭首次 HTTP 连接。
*
* @param error 关闭原因
*/
@Override
public void completeWithError(Throwable error) {
complete();
}
/**
* 返回运行输出逻辑关闭状态,不受首次浏览器连接断开影响。
*
* @return 运行已经收口时为 true
*/
@Override
public boolean isClosed() {
return completed.get();
}
/**
* 判断本次运行是否以可持久恢复的方式完成。
*
* @return 事件日志没有发生写入故障时为 {@code true}
*/
public boolean isJournalCompletionSuccessful() {
return completed.get() && !journalFailed.get();
}
private void failJournal(RuntimeException exception) {
String detail = exception.getMessage() == null ? "" : exception.getMessage();
boolean capacityFailure = detail.contains("安全上限") || detail.contains("16 MiB");
String code = capacityFailure
? "AGENT_RUN_JOURNAL_LIMIT"
: "AGENT_RUN_JOURNAL_UNAVAILABLE";
String message = capacityFailure
? "Agent 输出超过可恢复日志容量,运行已停止"
: "Agent 输出日志暂时不可用,运行已停止";
try {
journal.fail(threadId, runId, code, message);
} catch (RuntimeException terminalException) {
LOG.error("AG-UI 日志故障终态暂未写入,将由后台继续重试: runId={}",
runId, terminalException);
}
}
}

View File

@@ -46,6 +46,15 @@ public interface AgentRunOutput {
return true; return true;
} }
/**
* 判断浏览器 SSE 连接断开时是否应取消底层 Agent 运行。
*
* @return 连接断开需要取消运行时为 true
*/
default boolean cancelRunOnDisconnect() {
return true;
}
/** /**
* 发送协议终态并关闭连接。 * 发送协议终态并关闭连接。
* *

View File

@@ -8,6 +8,7 @@ import com.easyagents.agui.AguiRuntimeEventProjector;
import io.agentscope.core.agui.event.AguiEvent; import io.agentscope.core.agui.event.AguiEvent;
import io.agentscope.core.agui.model.AguiMessage; import io.agentscope.core.agui.model.AguiMessage;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.agent.runtime.agui.ResumableAguiSseEmitter;
import tech.easyflow.agent.runtime.hitl.ToolApprovalInputProjection; import tech.easyflow.agent.runtime.hitl.ToolApprovalInputProjection;
import tech.easyflow.core.chat.protocol.ChatDomain; import tech.easyflow.core.chat.protocol.ChatDomain;
import tech.easyflow.core.chat.protocol.ChatType; import tech.easyflow.core.chat.protocol.ChatType;
@@ -35,6 +36,7 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
private final String clientUserMessageId; private final String clientUserMessageId;
private final String clientUserMessageContent; private final String clientUserMessageContent;
private final ChatSseEmitter delegate; private final ChatSseEmitter delegate;
private final boolean cancelRunOnDisconnect;
private final AguiRuntimeEventProjector projector; private final AguiRuntimeEventProjector projector;
private final AguiProtocolEventEncoder encoder = new AguiProtocolEventEncoder(); private final AguiProtocolEventEncoder encoder = new AguiProtocolEventEncoder();
@@ -56,7 +58,7 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
* @param clientUserMessageId 本轮客户端用户消息 ID * @param clientUserMessageId 本轮客户端用户消息 ID
*/ */
public AguiAgentRunOutput(String threadId, String runId, String clientUserMessageId) { public AguiAgentRunOutput(String threadId, String runId, String clientUserMessageId) {
this(threadId, runId, clientUserMessageId, null, new ChatSseEmitter()); this(threadId, runId, clientUserMessageId, null, new ChatSseEmitter(), true);
} }
/** /**
@@ -72,7 +74,7 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
String runId, String runId,
String clientUserMessageId, String clientUserMessageId,
String clientUserMessageContent) { String clientUserMessageContent) {
this(threadId, runId, clientUserMessageId, clientUserMessageContent, new ChatSseEmitter()); this(threadId, runId, clientUserMessageId, clientUserMessageContent, new ChatSseEmitter(), true);
} }
/** /**
@@ -88,7 +90,7 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
String runId, String runId,
String clientUserMessageId, String clientUserMessageId,
ChatSseEmitter delegate) { ChatSseEmitter delegate) {
this(threadId, runId, clientUserMessageId, null, delegate); this(threadId, runId, clientUserMessageId, null, delegate, true);
} }
/** /**
@@ -106,11 +108,32 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
String clientUserMessageId, String clientUserMessageId,
String clientUserMessageContent, String clientUserMessageContent,
ChatSseEmitter delegate) { ChatSseEmitter delegate) {
this(threadId, runId, clientUserMessageId, clientUserMessageContent, delegate, true);
}
/**
* 使用指定连接生命周期策略创建 AG-UI 输出。
*
* @param threadId 客户端 thread ID
* @param runId 客户端 run ID
* @param clientUserMessageId 本轮客户端用户消息 ID
* @param clientUserMessageContent 本轮客户端用户消息正文
* @param delegate SSE 发射器
* @param cancelRunOnDisconnect 浏览器连接断开时是否取消运行
*/
public AguiAgentRunOutput(
String threadId,
String runId,
String clientUserMessageId,
String clientUserMessageContent,
ChatSseEmitter delegate,
boolean cancelRunOnDisconnect) {
this.threadId = requireText(threadId, "threadId"); this.threadId = requireText(threadId, "threadId");
this.runId = requireText(runId, "runId"); this.runId = requireText(runId, "runId");
this.clientUserMessageId = clientUserMessageId; this.clientUserMessageId = clientUserMessageId;
this.clientUserMessageContent = clientUserMessageContent; this.clientUserMessageContent = clientUserMessageContent;
this.delegate = java.util.Objects.requireNonNull(delegate, "delegate cannot be null"); this.delegate = java.util.Objects.requireNonNull(delegate, "delegate cannot be null");
this.cancelRunOnDisconnect = cancelRunOnDisconnect;
this.projector = new AguiRuntimeEventProjector(threadId, runId); this.projector = new AguiRuntimeEventProjector(threadId, runId);
} }
@@ -119,6 +142,15 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
return delegate.getEmitter(); return delegate.getEmitter();
} }
/**
* 获取公开 AG-UI 运行 ID。
*
* @return 运行 ID
*/
public String runId() {
return runId;
}
@Override @Override
public synchronized boolean emitRuntimeEvent(AgentRuntimeEvent event) { public synchronized boolean emitRuntimeEvent(AgentRuntimeEvent event) {
if (event == null || event.getEventType() == null || delegate.isClosed()) { if (event == null || event.getEventType() == null || delegate.isClosed()) {
@@ -249,6 +281,11 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
return pendingCompletedEvent != null; return pendingCompletedEvent != null;
} }
@Override
public boolean cancelRunOnDisconnect() {
return cancelRunOnDisconnect;
}
@Override @Override
public synchronized boolean finish(String finalText) { public synchronized boolean finish(String finalText) {
if (delegate.isClosed()) { if (delegate.isClosed()) {
@@ -277,6 +314,9 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
} }
} }
delegate.complete(); delegate.complete();
if (delegate instanceof ResumableAguiSseEmitter resumableEmitter) {
return resumableEmitter.isJournalCompletionSuccessful();
}
return true; return true;
} }

View File

@@ -164,6 +164,31 @@ public class AgentRuntimeCommandConsumerTest {
Assert.assertEquals("cmd-cancel", resultRegistry.lastSuccessCommandId); Assert.assertEquals("cmd-cancel", resultRegistry.lastSuccessCommandId);
} }
/**
* 验证跨节点精确取消命令保留请求、用户和原因。
*
* @throws Exception 消息序列化异常
*/
@Test
public void consumerShouldHandleCancelRunCommand() throws Exception {
AgentRuntimeProperties properties = new AgentRuntimeProperties();
properties.setInstanceId("node-a");
RecordingAgentRunService service = new RecordingAgentRunService();
RecordingCommandResultRegistry resultRegistry = new RecordingCommandResultRegistry();
AgentRuntimeCommandConsumer consumer = new AgentRuntimeCommandConsumer(
new ObjectMapper(), properties, new MQProperties(), service, resultRegistry);
AgentRuntimeCommandMessage command = command("cmd-cancel-run", "node-a");
command.setAction(AgentRuntimeCommandAction.CANCEL_RUN);
command.setReason("用户停止");
consumer.handle(List.of(message(command)));
Assert.assertEquals("request-cmd-cancel-run", service.lastCancelledRequestId);
Assert.assertEquals("1", service.lastCancelledUserId);
Assert.assertEquals("用户停止", service.lastReason);
Assert.assertEquals("cmd-cancel-run", resultRegistry.lastSuccessCommandId);
}
private AgentRuntimeCommandMessage command(String commandId, String targetNodeId) { private AgentRuntimeCommandMessage command(String commandId, String targetNodeId) {
AgentRuntimeCommandMessage command = new AgentRuntimeCommandMessage(); AgentRuntimeCommandMessage command = new AgentRuntimeCommandMessage();
command.setCommandId(commandId); command.setCommandId(commandId);
@@ -191,6 +216,8 @@ public class AgentRuntimeCommandConsumerTest {
private String lastRequestId; private String lastRequestId;
private String lastReason; private String lastReason;
private String lastCancelledAgentId; private String lastCancelledAgentId;
private String lastCancelledRequestId;
private String lastCancelledUserId;
private String lastApprovalId; private String lastApprovalId;
@Override @Override
@@ -221,6 +248,13 @@ public class AgentRuntimeCommandConsumerTest {
public void cancelAgentLocal(String agentId) { public void cancelAgentLocal(String agentId) {
lastCancelledAgentId = agentId; lastCancelledAgentId = agentId;
} }
@Override
public void cancelRunLocal(String requestId, String userId, String reason) {
lastCancelledRequestId = requestId;
lastCancelledUserId = userId;
lastReason = reason;
}
} }
private static class RecordingCommandResultRegistry extends AgentRuntimeCommandResultRegistry { private static class RecordingCommandResultRegistry extends AgentRuntimeCommandResultRegistry {

View File

@@ -4,9 +4,13 @@ import com.easyagents.agent.runtime.AgentInitRequest;
import com.easyagents.agent.runtime.AgentResumeRequest; import com.easyagents.agent.runtime.AgentResumeRequest;
import com.easyagents.agent.runtime.AgentRuntime; import com.easyagents.agent.runtime.AgentRuntime;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent; import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Test; import org.junit.Test;
import reactor.core.Disposable;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
import tech.easyflow.agent.runtime.lock.AgentRunLock;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.core.runtime.ChatAssistantAccumulator; import tech.easyflow.core.runtime.ChatAssistantAccumulator;
import tech.easyflow.core.runtime.ChatRuntimeContext; import tech.easyflow.core.runtime.ChatRuntimeContext;
@@ -14,6 +18,10 @@ import tech.easyflow.core.runtime.ChatRuntimeContext;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
/** /**
* Agent 运行态注册表测试。 * Agent 运行态注册表测试。
*/ */
@@ -69,6 +77,39 @@ public class AgentRunRegistryTest {
Assert.assertThrows(BusinessException.class, () -> registry.approve(null, "token-3", "user-1")); Assert.assertThrows(BusinessException.class, () -> registry.approve(null, "token-3", "user-1"));
} }
/**
* 验证分布式锁释放失败时仍会关闭 Runtime 并清理全部本地、远程索引。
*
* @throws Exception Runtime close 方法声明的受检异常
*/
@Test
public void removeShouldFinishCleanupWhenLockReleaseFails() throws Exception {
AgentRunRegistry registry = new AgentRunRegistry();
AgentRuntimeRouteRegistry routeRegistry = mock(AgentRuntimeRouteRegistry.class);
AgentRuntime runtime = mock(AgentRuntime.class);
AgentRunLock.Handle lockHandle = mock(AgentRunLock.Handle.class);
doThrow(new IllegalStateException("redis unavailable")).when(lockHandle).release();
registry.setRouteRegistry(routeRegistry);
AgentRunRegistry.AgentRunContext context = new AgentRunRegistry.AgentRunContext(
"request-cleanup", "session-cleanup", runtime, null,
new ChatRuntimeContext(), new StringBuilder(), new ChatAssistantAccumulator(),
new AtomicBoolean(false), false,
new AgentRunRegistry.RunOwner("agent-1", "session-cleanup", "user-1"),
lockHandle, event -> { }, error -> { }, () -> { });
registry.register(context);
registry.registerResumeToken("request-cleanup", "token-cleanup");
String approvalId = registry.registerApproval("request-cleanup", "token-cleanup");
Assert.assertThrows(IllegalStateException.class, () -> registry.remove("request-cleanup"));
verify(runtime).close();
verify(routeRegistry).removeResumeToken("token-cleanup");
verify(routeRegistry).removeApproval(approvalId);
verify(routeRegistry).removeRun("request-cleanup");
Assert.assertNull(registry.get("request-cleanup"));
Assert.assertThrows(BusinessException.class, () -> registry.resolveApproval(approvalId));
}
/** /**
* 验证运行审批只能由运行发起人处理。 * 验证运行审批只能由运行发起人处理。
*/ */
@@ -144,6 +185,85 @@ public class AgentRunRegistryTest {
Assert.assertNotNull(registry.get("request-10")); Assert.assertNotNull(registry.get("request-10"));
} }
/**
* 验证显式单次取消会校验归属并进入标准取消事件链。
*/
@Test
public void cancelRunShouldEmitNormalizedCancellationForOwner() {
AgentRunRegistry registry = new AgentRunRegistry();
AtomicReference<AgentRuntimeEvent> cancellation = new AtomicReference<>();
AgentRunRegistry.AgentRunContext context = new AgentRunRegistry.AgentRunContext(
"request-11",
"session-11",
new CapturingRuntime(),
null,
new ChatRuntimeContext(),
new StringBuilder(),
new ChatAssistantAccumulator(),
new AtomicBoolean(false),
false,
new AgentRunRegistry.RunOwner("agent-1", "session-11", "user-1"),
null,
cancellation::set,
error -> {
},
() -> {
});
registry.register(context);
Assert.assertThrows(BusinessException.class,
() -> registry.cancelRun("request-11", "user-2", "用户停止"));
registry.cancelRun("request-11", "user-1", "用户停止");
Assert.assertNotNull(cancellation.get());
Assert.assertEquals(AgentRuntimeEventType.CANCELLED, cancellation.get().getEventType());
Assert.assertEquals("用户停止", cancellation.get().getPayload().get("reason"));
}
/**
* 验证自然终态或重复远程命令到达后,显式取消保持幂等。
*/
@Test
public void cancelRunShouldBeIdempotentAfterContextRemoved() {
AgentRunRegistry registry = new AgentRunRegistry();
registry.cancelRun("request-finished", "user-1", "用户停止");
}
/**
* 验证订阅启动后上下文已经移除时,迟到的 Disposable 会立即释放。
*/
@Test
public void bindSubscriptionShouldDisposeWhenContextAlreadyRemoved() {
AgentRunRegistry registry = new AgentRunRegistry();
Disposable subscription = mock(Disposable.class);
registry.bindSubscription("request-removed", subscription);
verify(subscription).dispose();
}
/**
* 验证运行已收口时绑定订阅不会遗留后台模型流。
*/
@Test
public void bindSubscriptionShouldDisposeWhenContextAlreadyFinished() {
AgentRunRegistry registry = new AgentRunRegistry();
AtomicBoolean finished = new AtomicBoolean(true);
AgentRunRegistry.AgentRunContext context = new AgentRunRegistry.AgentRunContext(
"request-finished", "session-finished", new CapturingRuntime(), null,
new ChatRuntimeContext(), new StringBuilder(), new ChatAssistantAccumulator(),
finished, false,
new AgentRunRegistry.RunOwner("agent-1", "session-finished", "user-1"),
null, event -> { }, error -> { }, () -> { });
Disposable subscription = mock(Disposable.class);
registry.register(context);
registry.bindSubscription("request-finished", subscription);
verify(subscription).dispose();
}
private AgentRunRegistry.AgentRunContext context(String requestId, private AgentRunRegistry.AgentRunContext context(String requestId,
String sessionId, String sessionId,
String userId, String userId,

View File

@@ -16,6 +16,7 @@ import org.junit.Assert;
import org.junit.Test; import org.junit.Test;
import org.mockito.Mockito; import org.mockito.Mockito;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.agent.entity.AgentHitlPending; import tech.easyflow.agent.entity.AgentHitlPending;
import tech.easyflow.agent.entity.Agent; import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding; import tech.easyflow.agent.entity.AgentKnowledgeBinding;
@@ -24,6 +25,12 @@ import tech.easyflow.agent.distributed.AgentRuntimeCommandProducer;
import tech.easyflow.agent.distributed.AgentRuntimeRoute; import tech.easyflow.agent.distributed.AgentRuntimeRoute;
import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry; import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
import tech.easyflow.agent.runtime.event.AgentRunEventRecorder; import tech.easyflow.agent.runtime.event.AgentRunEventRecorder;
import tech.easyflow.agent.runtime.agui.AgentAguiRunDescriptor;
import tech.easyflow.agent.runtime.agui.AgentAguiRunJournal;
import tech.easyflow.agent.runtime.agui.AgentAguiRunStatus;
import tech.easyflow.agent.runtime.agui.AgentAguiRunStore;
import tech.easyflow.agent.runtime.agui.AgentAguiRunSubscriptionService;
import tech.easyflow.agent.runtime.agui.AgentAguiWireContext;
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService; import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
import tech.easyflow.agent.runtime.document.AgentDocumentContext; import tech.easyflow.agent.runtime.document.AgentDocumentContext;
import tech.easyflow.agent.runtime.lock.AgentRunLock; import tech.easyflow.agent.runtime.lock.AgentRunLock;
@@ -50,6 +57,11 @@ import java.lang.reflect.Method;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
/** /**
@@ -57,6 +69,154 @@ import java.util.concurrent.atomic.AtomicBoolean;
*/ */
public class AgentRunServiceDraftAndHitlTest { public class AgentRunServiceDraftAndHitlTest {
/**
* 创建用于 owner 恢复测试的运行描述。
*
* @param runId 运行 ID
* @param requestId 请求 ID
* @param updatedAt 最近更新时间
* @return 运行描述
*/
private static AgentAguiRunDescriptor descriptor(String runId, String requestId, long updatedAt) {
return new AgentAguiRunDescriptor(
runId, requestId, "thread-" + runId, "7", "session-" + runId,
"1", "2", false, AgentAguiRunStatus.RUNNING, 0L, updatedAt, updatedAt);
}
/**
* 验证可重连运行会在 Redis store 创建前注册临时 owner 路由。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void createRunOutputShouldRegisterProvisionalOwnerBeforeStore() throws Exception {
AgentRunService service = new AgentRunService();
AgentRuntimeRouteRegistry routeRegistry = Mockito.mock(AgentRuntimeRouteRegistry.class);
AgentAguiRunStore store = Mockito.mock(AgentAguiRunStore.class);
AgentAguiRunJournal journal = Mockito.mock(AgentAguiRunJournal.class);
AgentAguiRunSubscriptionService subscriptions = Mockito.mock(AgentAguiRunSubscriptionService.class);
Mockito.when(subscriptions.subscribe(Mockito.any(AgentAguiRunDescriptor.class), Mockito.eq(0L)))
.thenReturn(new SseEmitter());
setField(service, "agentRuntimeRouteRegistry", routeRegistry);
setField(service, "agentAguiRunStore", store);
setField(service, "agentAguiRunJournal", journal);
setField(service, "agentAguiRunSubscriptionService", subscriptions);
Agent agent = new Agent();
agent.setId(BigInteger.valueOf(7L));
LoginAccount account = new LoginAccount();
account.setId(BigInteger.ONE);
account.setTenantId(BigInteger.TWO);
AgentRunOutput output = invoke(service, "createRunOutput",
new Class<?>[]{AgentAguiWireContext.class, String.class, LoginAccount.class,
Agent.class, String.class, boolean.class},
new AgentAguiWireContext("thread-start", "run-start", "user-start", "启动"),
"request-start", account, agent, "session-start", false);
Assert.assertNotNull(output);
org.mockito.InOrder order = Mockito.inOrder(routeRegistry, store, subscriptions);
order.verify(routeRegistry).registerRun("request-start", "7");
order.verify(store).create(Mockito.any(AgentAguiRunDescriptor.class));
order.verify(subscriptions).subscribe(Mockito.any(AgentAguiRunDescriptor.class), Mockito.eq(0L));
}
/**
* 验证会话锁获取失败时会写入失败终态并移除临时 owner 路由。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void runAguiAwareShouldCloseProvisionalRunWhenLockAcquireFails() throws Exception {
AgentRunService service = new AgentRunService();
AgentRunLock runLock = Mockito.mock(AgentRunLock.class);
AgentRuntimeRouteRegistry routeRegistry = Mockito.mock(AgentRuntimeRouteRegistry.class);
AgentRunOutput output = Mockito.mock(AgentRunOutput.class);
Mockito.when(runLock.acquire(Mockito.any(), Mockito.eq("session-lock-failed")))
.thenThrow(new IllegalStateException("lock unavailable"));
Mockito.when(output.cancelRunOnDisconnect()).thenReturn(false);
setField(service, "agentRunLock", runLock);
setField(service, "agentRuntimeRouteRegistry", routeRegistry);
Agent agent = new Agent();
agent.setId(BigInteger.valueOf(8L));
try {
invoke(service, "runAguiAware",
new Class<?>[]{Agent.class, String.class, List.class, List.class,
LoginAccount.class, String.class, String.class, String.class,
String.class, ChatRuntimeContext.class, boolean.class,
AgentSessionStore.class, AgentRunOutput.class},
agent, "问题", List.of(), List.of(), new LoginAccount(),
"request-lock-failed", "trace-lock-failed", "session-lock-failed",
"AGENT", new ChatRuntimeContext(), true,
Mockito.mock(AgentSessionStore.class), output);
Assert.fail("expected lock failure");
} catch (Exception exception) {
Assert.assertTrue(rootCause(exception) instanceof IllegalStateException);
}
Mockito.verify(output).completeWithError(Mockito.any(IllegalStateException.class));
Mockito.verify(routeRegistry).removeRun("request-lock-failed");
}
/**
* 验证超过启动宽限期但 owner 节点仍存活时不会误判运行丢失。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void reconcileShouldKeepSlowStartingRunWhenOwnerBootIsAlive() throws Exception {
AgentRunService service = new AgentRunService();
AgentRuntimeRouteRegistry routeRegistry = Mockito.mock(AgentRuntimeRouteRegistry.class);
AgentAguiRunStore store = Mockito.mock(AgentAguiRunStore.class);
AgentRuntimeRoute route = new AgentRuntimeRoute();
route.setNodeId("node-alive");
route.setBootId("boot-alive");
Mockito.when(routeRegistry.findOwnerRoute("request-slow")).thenReturn(route);
Mockito.when(routeRegistry.currentNodeBootId("node-alive")).thenReturn("boot-alive");
setField(service, "agentRuntimeRouteRegistry", routeRegistry);
setField(service, "agentAguiRunStore", store);
long old = System.currentTimeMillis() - 120_000L;
AgentAguiRunDescriptor descriptor = descriptor("run-slow", "request-slow", old);
AgentAguiRunDescriptor result = invoke(service, "reconcileInterruptedRun",
new Class<?>[]{AgentAguiRunDescriptor.class}, descriptor);
Assert.assertSame(descriptor, result);
Mockito.verify(store, Mockito.never()).failOwnerLost(Mockito.any());
}
/**
* 验证 owner 启动代心跳消失且超过宽限期后写入明确失败终态。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void reconcileShouldFailRunWhenOwnerBootDisappears() throws Exception {
AgentRunService service = new AgentRunService();
AgentRuntimeRouteRegistry routeRegistry = Mockito.mock(AgentRuntimeRouteRegistry.class);
AgentAguiRunStore store = Mockito.mock(AgentAguiRunStore.class);
AgentRuntimeRoute route = new AgentRuntimeRoute();
route.setNodeId("node-lost");
route.setBootId("boot-lost");
Mockito.when(routeRegistry.findOwnerRoute("request-lost")).thenReturn(route);
Mockito.when(routeRegistry.currentNodeBootId("node-lost")).thenReturn(null);
long old = System.currentTimeMillis() - 120_000L;
AgentAguiRunDescriptor descriptor = descriptor("run-lost", "request-lost", old);
AgentAguiRunDescriptor failed = new AgentAguiRunDescriptor(
descriptor.runId(), descriptor.requestId(), descriptor.threadId(), descriptor.agentId(),
descriptor.sessionId(), descriptor.userId(), descriptor.tenantId(), descriptor.draft(),
AgentAguiRunStatus.FAILED, 1L, descriptor.createdAt(), System.currentTimeMillis());
Mockito.when(store.find("run-lost")).thenReturn(failed);
setField(service, "agentRuntimeRouteRegistry", routeRegistry);
setField(service, "agentAguiRunStore", store);
AgentAguiRunDescriptor result = invoke(service, "reconcileInterruptedRun",
new Class<?>[]{AgentAguiRunDescriptor.class}, descriptor);
Assert.assertSame(failed, result);
Mockito.verify(store).failOwnerLost(descriptor);
}
/** /**
* 验证工具 HITL 事件会映射为显式前端载荷。 * 验证工具 HITL 事件会映射为显式前端载荷。
* *
@@ -418,6 +578,78 @@ public class AgentRunServiceDraftAndHitlTest {
Mockito.verify(output).complete(); Mockito.verify(output).complete();
} }
/**
* 验证可恢复终态写入失败时不会把聊天会话记录为完成。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void finishClaimedRunShouldRecordFailureWhenOutputCompletionFails() throws Exception {
AgentRunService service = new AgentRunService();
RecordingChatRuntimeManager chatRuntimeManager = new RecordingChatRuntimeManager();
setField(service, "agentRunRegistry", new AgentRunRegistry());
setField(service, "chatRuntimeManager", chatRuntimeManager);
AgentRunOutput output = Mockito.mock(AgentRunOutput.class);
Mockito.when(output.finish("最终正文")).thenReturn(false);
invoke(service, "finishClaimedRun",
new Class<?>[]{String.class, AgentRunOutput.class, ChatRuntimeContext.class,
StringBuilder.class, ChatAssistantAccumulator.class, boolean.class, List.class},
"request-output-failed", output, chatContext(), new StringBuilder("最终正文"),
new ChatAssistantAccumulator(), true, List.of());
Assert.assertEquals(1, chatRuntimeManager.recordAssistantCompletedCount);
Assert.assertEquals(1, chatRuntimeManager.recordFailureCount);
Assert.assertEquals(0, chatRuntimeManager.recordCompletedCount);
}
/**
* 验证终态回调内部抛错后强制释放 Registry 并写入失败状态。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void runtimeCallbackFailureShouldForceCleanupAfterTerminalClaim() throws Exception {
AgentRunService service = new AgentRunService();
AgentRunRegistry registry = new AgentRunRegistry();
RecordingChatRuntimeManager chatRuntimeManager = new RecordingChatRuntimeManager();
AgentRunOutput output = Mockito.mock(AgentRunOutput.class);
AtomicBoolean finished = new AtomicBoolean(true);
AgentRunRegistry.AgentRunContext context = new AgentRunRegistry.AgentRunContext(
"request-callback-failed",
"session-callback-failed",
new NoopRuntime(),
output,
chatContext(),
new StringBuilder(),
new ChatAssistantAccumulator(),
finished,
true,
new AgentRunRegistry.RunOwner("agent-1", "session-callback-failed", "user-1"),
null,
event -> {
},
error -> {
},
() -> {
}
);
registry.register(context);
setField(service, "agentRunRegistry", registry);
setField(service, "chatRuntimeManager", chatRuntimeManager);
invoke(service, "runRuntimeCallbackSafely",
new Class<?>[]{Runnable.class, String.class, AgentRunOutput.class,
ChatRuntimeContext.class, AtomicBoolean.class, boolean.class},
(Runnable) () -> {
throw new IllegalStateException("审计写入失败");
}, "request-callback-failed", output, chatContext(), finished, true);
Assert.assertNull(registry.get("request-callback-failed"));
Assert.assertEquals(1, chatRuntimeManager.recordFailureCount);
Mockito.verify(output).completeWithError(Mockito.any(IllegalStateException.class));
}
/** /**
* 验证取消事件作为业务状态收口,不按系统错误发送。 * 验证取消事件作为业务状态收口,不按系统错误发送。
* *
@@ -456,6 +688,73 @@ public class AgentRunServiceDraftAndHitlTest {
Assert.assertEquals(1, chatRuntimeManager.recordFailureCount); Assert.assertEquals(1, chatRuntimeManager.recordFailureCount);
} }
/**
* 验证用户取消与自然完成并发到达时只允许一个终态完成协议投影和聊天记录收口。
*
* @throws Exception 并发任务或反射调用失败时抛出
*/
@Test
public void concurrentCancelAndCompleteShouldShareOneTerminalArbitration() throws Exception {
AgentRunService service = new AgentRunService();
RecordingChatRuntimeManager chatRuntimeManager = new RecordingChatRuntimeManager();
setField(service, "agentRunRegistry", new AgentRunRegistry());
setField(service, "chatRuntimeManager", chatRuntimeManager);
AgentRunOutput output = Mockito.mock(AgentRunOutput.class);
Mockito.when(output.emitRuntimeEvent(Mockito.any())).thenReturn(true);
Mockito.when(output.emitViewEvent(Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(true);
Mockito.when(output.finish(Mockito.anyString())).thenReturn(true);
AtomicBoolean finished = new AtomicBoolean(false);
CountDownLatch start = new CountDownLatch(1);
ExecutorService executor = Executors.newFixedThreadPool(2);
AgentRuntimeEvent completed = AgentRuntimeEvent.of(AgentRuntimeEventType.COMPLETED);
completed.getPayload().put("text", "自然完成正文");
AgentRuntimeEvent cancelled = AgentRuntimeEvent.of(AgentRuntimeEventType.CANCELLED);
cancelled.getPayload().put("reason", "用户停止");
try {
Future<?> completeFuture = executor.submit(() -> invokeTerminalEvent(
service, start, completed, output, finished));
Future<?> cancelFuture = executor.submit(() -> invokeTerminalEvent(
service, start, cancelled, output, finished));
start.countDown();
completeFuture.get(5, TimeUnit.SECONDS);
cancelFuture.get(5, TimeUnit.SECONDS);
} finally {
executor.shutdownNow();
}
Assert.assertTrue(finished.get());
Assert.assertEquals(1,
chatRuntimeManager.recordCompletedCount + chatRuntimeManager.recordFailureCount);
Mockito.verify(output, Mockito.times(1)).emitRuntimeEvent(Mockito.any());
Mockito.verify(output, Mockito.times(1)).finish(Mockito.nullable(String.class));
}
/**
* 等待统一起跑信号后投递指定终态事件。
*
* @param service 被测服务
* @param start 并发起跑信号
* @param event 终态事件
* @param output 运行输出
* @param finished 共享终态标记
*/
private void invokeTerminalEvent(AgentRunService service,
CountDownLatch start,
AgentRuntimeEvent event,
AgentRunOutput output,
AtomicBoolean finished) {
try {
start.await(5, TimeUnit.SECONDS);
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
event, "request-terminal-race", output, new StringBuilder("部分正文"),
new ChatAssistantAccumulator(), chatContext(), finished, true);
} catch (Exception e) {
throw new IllegalStateException("并发终态测试执行失败", e);
}
}
/** /**
* 验证最终知识库引用会保留命中分片原文。 * 验证最终知识库引用会保留命中分片原文。
* *

View File

@@ -0,0 +1,194 @@
package tech.easyflow.agent.runtime.agui;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
/**
* AG-UI 批量事件日志测试。
*/
public class AgentAguiRunJournalTest {
/**
* 验证相邻同消息 delta 会在一次 Redis 批次中合并且顺序不变。
*/
@Test
public void shouldMergeAdjacentDeltasBeforeRedisFlush() {
AgentAguiRunStore store = mock(AgentAguiRunStore.class);
AgentAguiRunJournal journal = new AgentAguiRunJournal(store, new ObjectMapper());
journal.append("run-1", "{\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"m1\",\"delta\":\"\"}");
journal.append("run-1", "{\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"m1\",\"delta\":\"\"}");
journal.append("run-1", "{\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"m1\"}");
journal.flush("run-1");
@SuppressWarnings("unchecked")
ArgumentCaptor<List<String>> events = ArgumentCaptor.forClass(List.class);
verify(store).append(eq("run-1"), anyString(), events.capture());
Assert.assertEquals(2, events.getValue().size());
Assert.assertTrue(events.getValue().get(0).contains("\"delta\":\"你好\""));
Assert.assertTrue(events.getValue().get(1).contains("TEXT_MESSAGE_END"));
}
/**
* 验证同步终态写入失败后,后台刷新仍会保留并重试原批次。
*/
@Test
public void shouldRetryTerminalBatchWithoutDroppingEvents() {
AgentAguiRunStore store = mock(AgentAguiRunStore.class);
AgentAguiRunJournal journal = new AgentAguiRunJournal(store, new ObjectMapper());
String terminal = "{\"type\":\"RUN_FINISHED\",\"runId\":\"run-1\",\"threadId\":\"1\"}";
journal.append("run-1", terminal);
doThrow(new IllegalStateException("redis down"))
.doReturn(1L)
.when(store).append(eq("run-1"), anyString(), org.mockito.ArgumentMatchers.anyList());
try {
journal.complete("run-1");
Assert.fail("首次同步写入应抛出异常");
} catch (IllegalStateException expected) {
Assert.assertEquals("redis down", expected.getMessage());
}
journal.flushPending();
ArgumentCaptor<String> batchIds = ArgumentCaptor.forClass(String.class);
verify(store, times(2)).append(
eq("run-1"), batchIds.capture(), org.mockito.ArgumentMatchers.anyList());
Assert.assertEquals(batchIds.getAllValues().get(0), batchIds.getAllValues().get(1));
}
/**
* 验证结果不确定的批次内容保持冻结,期间到达的新事件使用新批次写入。
*/
@Test
public void shouldKeepAmbiguousBatchImmutableAndFlushTailSeparately() {
AgentAguiRunStore store = mock(AgentAguiRunStore.class);
AgentAguiRunJournal journal = new AgentAguiRunJournal(store, new ObjectMapper());
doThrow(new IllegalStateException("ambiguous timeout"))
.doReturn(1L)
.doReturn(2L)
.when(store).append(eq("run-1"), anyString(), org.mockito.ArgumentMatchers.anyList());
String first = "{\"type\":\"RUN_STARTED\",\"runId\":\"run-1\"}";
String tail = "{\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"m1\"}";
journal.append("run-1", first);
Assert.assertThrows(IllegalStateException.class, () -> journal.flush("run-1"));
journal.append("run-1", tail);
journal.flushPending();
ArgumentCaptor<String> batchIds = ArgumentCaptor.forClass(String.class);
@SuppressWarnings("unchecked")
ArgumentCaptor<List<String>> batches = ArgumentCaptor.forClass(List.class);
verify(store, times(3)).append(eq("run-1"), batchIds.capture(), batches.capture());
Assert.assertEquals(batchIds.getAllValues().get(0), batchIds.getAllValues().get(1));
Assert.assertNotEquals(batchIds.getAllValues().get(1), batchIds.getAllValues().get(2));
Assert.assertEquals(List.of(first), batches.getAllValues().get(0));
Assert.assertEquals(List.of(first), batches.getAllValues().get(1));
Assert.assertEquals(List.of(tail), batches.getAllValues().get(2));
}
/**
* 验证终态完成会等待在途后台刷盘,并在其失败后接管重试全部剩余批次。
*
* @throws Exception 并发任务等待失败时抛出
*/
@Test
public void completeShouldWaitForBackgroundFlushAndPersistAllTailEvents() throws Exception {
AgentAguiRunStore store = mock(AgentAguiRunStore.class);
AgentAguiRunJournal journal = new AgentAguiRunJournal(store, new ObjectMapper());
CountDownLatch firstAppendStarted = new CountDownLatch(1);
CountDownLatch releaseFirstAppend = new CountDownLatch(1);
CountDownLatch completeStarted = new CountDownLatch(1);
AtomicInteger appendCalls = new AtomicInteger();
doAnswer(invocation -> {
int call = appendCalls.incrementAndGet();
if (call == 1) {
firstAppendStarted.countDown();
Assert.assertTrue(releaseFirstAppend.await(5, TimeUnit.SECONDS));
throw new IllegalStateException("first append failed");
}
return (long) call;
}).when(store).append(eq("run-1"), anyString(), org.mockito.ArgumentMatchers.anyList());
String started = "{\"type\":\"RUN_STARTED\",\"runId\":\"run-1\"}";
String finished = "{\"type\":\"RUN_FINISHED\",\"runId\":\"run-1\",\"threadId\":\"1\"}";
journal.append("run-1", started);
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Future<?> background = executor.submit(journal::flushPending);
Assert.assertTrue(firstAppendStarted.await(5, TimeUnit.SECONDS));
journal.append("run-1", finished);
Future<?> completion = executor.submit(() -> {
completeStarted.countDown();
journal.complete("run-1");
});
Assert.assertTrue(completeStarted.await(5, TimeUnit.SECONDS));
Assert.assertFalse(completion.isDone());
releaseFirstAppend.countDown();
background.get(5, TimeUnit.SECONDS);
completion.get(5, TimeUnit.SECONDS);
} finally {
releaseFirstAppend.countDown();
executor.shutdownNow();
}
@SuppressWarnings("unchecked")
ArgumentCaptor<List<String>> batches = ArgumentCaptor.forClass(List.class);
ArgumentCaptor<String> batchIds = ArgumentCaptor.forClass(String.class);
verify(store, times(3)).append(eq("run-1"), batchIds.capture(), batches.capture());
Assert.assertEquals(batchIds.getAllValues().get(0), batchIds.getAllValues().get(1));
Assert.assertEquals(List.of(started), batches.getAllValues().get(1));
Assert.assertEquals(List.of(finished), batches.getAllValues().get(2));
}
/**
* 验证容量故障会丢弃未确认普通事件并以独立小批次写入失败终态。
*/
@Test
public void shouldReplacePendingEventsWithJournalFailureTerminal() {
AgentAguiRunStore store = mock(AgentAguiRunStore.class);
AgentAguiRunJournal journal = new AgentAguiRunJournal(store, new ObjectMapper());
journal.append("run-1", "{\"type\":\"RUN_STARTED\"}");
journal.fail("thread-1", "run-1", "AGENT_RUN_JOURNAL_LIMIT", "输出过长");
@SuppressWarnings("unchecked")
ArgumentCaptor<List<String>> events = ArgumentCaptor.forClass(List.class);
verify(store).append(eq("run-1"), anyString(), events.capture());
Assert.assertEquals(1, events.getValue().size());
Assert.assertTrue(events.getValue().get(0).contains("AGENT_RUN_JOURNAL_LIMIT"));
Assert.assertFalse(events.getValue().get(0).contains("RUN_STARTED"));
}
/**
* 验证异常大的单事件在进入共享内存缓冲前即被拒绝。
*/
@Test
public void shouldRejectOversizedSingleEvent() {
AgentAguiRunJournal journal = new AgentAguiRunJournal(
mock(AgentAguiRunStore.class), new ObjectMapper());
String oversized = "x".repeat(512 * 1024 + 1);
Assert.assertThrows(IllegalStateException.class,
() -> journal.append("run-1", oversized));
}
}

View File

@@ -0,0 +1,124 @@
package tech.easyflow.agent.runtime.agui;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.ListOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.data.redis.core.script.RedisScript;
import tech.easyflow.agent.config.AgentRuntimeProperties;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* AG-UI Redis 运行存储测试。
*/
public class AgentAguiRunStoreTest {
/**
* 验证 runId 原子保留、24 小时 TTL 与完成终态写入。
*/
@Test
public void shouldReserveRunAndPersistTerminalStatusForTwentyFourHours() {
RedisMocks redis = redisMocks();
AgentRuntimeProperties properties = new AgentRuntimeProperties();
properties.setAguiRunRetention(Duration.ofHours(24));
AgentAguiRunStore store = new AgentAguiRunStore(
redis.template, new ObjectMapper(), properties);
long now = System.currentTimeMillis();
store.create(new AgentAguiRunDescriptor(
"run-1", "request-1", "thread-1", "agent-1", "session-1",
"user-1", "tenant-1", true, AgentAguiRunStatus.RUNNING, 0L, now, now));
when(redis.template.execute(any(RedisScript.class), anyList(), any(Object[].class)))
.thenReturn(2L);
store.append("run-1", "batch-1", List.of(
"{\"type\":\"RUN_STARTED\",\"runId\":\"run-1\",\"threadId\":\"thread-1\"}",
"{\"type\":\"RUN_FINISHED\",\"runId\":\"run-1\",\"threadId\":\"thread-1\"}"));
verify(redis.values).setIfAbsent(
eq("easyflow:agent:agui:run:{run-1}:reserved"), eq("user-1"), eq(Duration.ofHours(24)));
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> metadata = ArgumentCaptor.forClass(Map.class);
verify(redis.hashes).putAll(eq("easyflow:agent:agui:run:{run-1}:meta"), metadata.capture());
Assert.assertEquals("0", metadata.getValue().get("lastCursor"));
Assert.assertEquals("false", metadata.getValue().get("cancelRequested"));
@SuppressWarnings("unchecked")
ArgumentCaptor<RedisScript<Long>> script = ArgumentCaptor.forClass(RedisScript.class);
ArgumentCaptor<Object[]> arguments = ArgumentCaptor.forClass(Object[].class);
verify(redis.template).execute(script.capture(), anyList(), arguments.capture());
Assert.assertTrue(script.getValue().getScriptAsString().contains("lastBatchId"));
Assert.assertEquals("batch-1", arguments.getValue()[0]);
Assert.assertEquals(AgentAguiRunStatus.COMPLETED.name(), arguments.getValue()[3]);
}
/**
* 验证重复 runId 在模型启动前即被拒绝。
*/
@Test(expected = BusinessException.class)
public void shouldRejectDuplicateRunId() {
RedisMocks redis = redisMocks();
when(redis.values.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false);
AgentAguiRunStore store = new AgentAguiRunStore(
redis.template, new ObjectMapper(), new AgentRuntimeProperties());
store.create(new AgentAguiRunDescriptor(
"run-1", "request-1", "thread-1", "agent-1", "session-1",
"user-1", "tenant-1", false, AgentAguiRunStatus.RUNNING, 0L, 1L, 1L));
}
/**
* 验证 owner 丢失通过同一原子批次追加明确失败终态。
*/
@Test
public void shouldPersistOwnerLostAsReplayableTerminalEvent() {
RedisMocks redis = redisMocks();
when(redis.template.execute(any(RedisScript.class), anyList(), any(Object[].class)))
.thenReturn(1L);
AgentAguiRunStore store = new AgentAguiRunStore(
redis.template, new ObjectMapper(), new AgentRuntimeProperties());
AgentAguiRunDescriptor descriptor = new AgentAguiRunDescriptor(
"run-1", "request-1", "thread-1", "agent-1", "session-1",
"user-1", "tenant-1", false, AgentAguiRunStatus.RUNNING, 0L, 1L, 1L);
ArgumentCaptor<Object[]> arguments = ArgumentCaptor.forClass(Object[].class);
store.failOwnerLost(descriptor);
verify(redis.template).execute(any(RedisScript.class), anyList(), arguments.capture());
Assert.assertEquals(AgentAguiRunStatus.FAILED.name(), arguments.getValue()[3]);
Assert.assertTrue(arguments.getValue()[8].toString().contains("AGENT_RUN_OWNER_LOST"));
}
@SuppressWarnings("unchecked")
private RedisMocks redisMocks() {
StringRedisTemplate template = mock(StringRedisTemplate.class);
ValueOperations<String, String> values = mock(ValueOperations.class);
HashOperations<String, Object, Object> hashes = mock(HashOperations.class);
ListOperations<String, String> lists = mock(ListOperations.class);
when(template.opsForValue()).thenReturn(values);
when(template.opsForHash()).thenReturn(hashes);
when(template.opsForList()).thenReturn(lists);
when(values.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(true);
return new RedisMocks(template, values, hashes, lists);
}
private record RedisMocks(
StringRedisTemplate template,
ValueOperations<String, String> values,
HashOperations<String, Object, Object> hashes,
ListOperations<String, String> lists) {
}
}

View File

@@ -0,0 +1,50 @@
package tech.easyflow.agent.runtime.agui;
import org.junit.Test;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* AG-UI 重连订阅共享调度测试。
*/
public class AgentAguiRunSubscriptionServiceTest {
/**
* 验证同一 run 的不同游标独立读取窗口,尾部实时订阅不受历史回放阻塞。
*
* @throws Exception SSE mock 发送异常
*/
@Test
public void shouldDispatchDistinctCursorWindowsIndependently() throws Exception {
AgentAguiRunStore store = mock(AgentAguiRunStore.class);
AgentAguiRunSubscriptionService service = new AgentAguiRunSubscriptionService(store);
AgentAguiRunDescriptor descriptor = new AgentAguiRunDescriptor(
"run-1", "request-1", "thread-1", "agent-1", "session-1",
"user-1", "tenant-1", false, AgentAguiRunStatus.RUNNING,
1000L, 1L, 2L);
SseEmitter fromStart = mock(SseEmitter.class);
SseEmitter fromTail = mock(SseEmitter.class);
service.subscribe(descriptor, 0L, fromStart);
service.subscribe(descriptor, 999L, fromTail);
when(store.readAfter("run-1", 0L)).thenReturn(java.util.Collections.nCopies(
256, "{\"type\":\"TEXT_MESSAGE_CONTENT\"}"));
when(store.readAfter("run-1", 999L)).thenReturn(List.of(
"{\"type\":\"TEXT_MESSAGE_CONTENT\",\"delta\":\"tail\"}"));
when(store.find("run-1")).thenReturn(descriptor);
service.dispatch();
verify(store).readAfter("run-1", 0L);
verify(store).readAfter("run-1", 999L);
verify(store).find("run-1");
verify(fromStart, times(256)).send(any(SseEmitter.SseEventBuilder.class));
verify(fromTail).send(any(SseEmitter.SseEventBuilder.class));
}
}

View File

@@ -0,0 +1,77 @@
package tech.easyflow.agent.runtime.agui;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
/**
* 可重连 AG-UI SSE 发射器测试。
*/
public class ResumableAguiSseEmitterTest {
/**
* 验证协议事件先进入日志,且逻辑关闭只发生在运行终态。
*/
@Test
public void shouldKeepLogicalRunOpenUntilExplicitCompletion() {
AgentAguiRunJournal journal = mock(AgentAguiRunJournal.class);
SseEmitter subscriber = new SseEmitter();
ResumableAguiSseEmitter emitter = new ResumableAguiSseEmitter(
"thread-1", "run-1", journal, subscriber);
Assert.assertSame(subscriber, emitter.getEmitter());
Assert.assertTrue(emitter.sendData("{\"type\":\"RUN_STARTED\"}"));
Assert.assertFalse(emitter.isClosed());
verify(journal).append("run-1", "{\"type\":\"RUN_STARTED\"}");
emitter.complete();
Assert.assertTrue(emitter.isClosed());
Assert.assertTrue(emitter.isJournalCompletionSuccessful());
verify(journal).complete("run-1");
}
/**
* 验证日志拒绝普通事件时立即写入可重放故障终态并关闭逻辑输出。
*/
@Test
public void shouldFailRunWhenJournalRejectsEvent() {
AgentAguiRunJournal journal = mock(AgentAguiRunJournal.class);
doThrow(new IllegalStateException("超过 16 MiB 安全上限"))
.when(journal).append("run-1", "{\"type\":\"RUN_STARTED\"}");
ResumableAguiSseEmitter emitter = new ResumableAguiSseEmitter(
"thread-1", "run-1", journal, new SseEmitter());
Assert.assertFalse(emitter.sendData("{\"type\":\"RUN_STARTED\"}"));
Assert.assertTrue(emitter.isClosed());
Assert.assertFalse(emitter.isJournalCompletionSuccessful());
verify(journal).fail(
"thread-1", "run-1", "AGENT_RUN_JOURNAL_LIMIT",
"Agent 输出超过可恢复日志容量,运行已停止");
}
/**
* 验证终态刷盘失败会被调用方观察到,避免上层错误记录为完成态。
*/
@Test
public void shouldExposeTerminalJournalFailure() {
AgentAguiRunJournal journal = mock(AgentAguiRunJournal.class);
doThrow(new IllegalStateException("Redis unavailable"))
.when(journal).complete("run-1");
ResumableAguiSseEmitter emitter = new ResumableAguiSseEmitter(
"thread-1", "run-1", journal, new SseEmitter());
emitter.complete();
Assert.assertTrue(emitter.isClosed());
Assert.assertFalse(emitter.isJournalCompletionSuccessful());
verify(journal).fail(
"thread-1", "run-1", "AGENT_RUN_JOURNAL_UNAVAILABLE",
"Agent 输出日志暂时不可用,运行已停止");
}
}

View File

@@ -6,6 +6,8 @@ import org.junit.Assert;
import org.junit.Test; import org.junit.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.agent.runtime.agui.AgentAguiRunJournal;
import tech.easyflow.agent.runtime.agui.ResumableAguiSseEmitter;
import tech.easyflow.core.chat.protocol.ChatDomain; import tech.easyflow.core.chat.protocol.ChatDomain;
import tech.easyflow.core.chat.protocol.ChatType; import tech.easyflow.core.chat.protocol.ChatType;
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter; import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
@@ -51,6 +53,26 @@ public class AguiAgentRunOutputTest {
verify(emitter).complete(); verify(emitter).complete();
} }
/**
* 验证可恢复日志终态刷盘失败会沿输出边界返回失败。
*/
@Test
public void shouldReportResumableJournalCompletionFailure() {
AgentAguiRunJournal journal = mock(AgentAguiRunJournal.class);
doThrow(new IllegalStateException("Redis unavailable"))
.when(journal).complete("run-1");
ResumableAguiSseEmitter emitter = new ResumableAguiSseEmitter(
"123", "run-1", journal, new SseEmitter());
AguiAgentRunOutput output = new AguiAgentRunOutput(
"123", "run-1", "user-message-1", emitter);
Assert.assertTrue(output.emitRuntimeEvent(
AgentRuntimeEvent.of(AgentRuntimeEventType.COMPLETED)));
Assert.assertFalse(output.finish("final"));
Assert.assertFalse(emitter.isJournalCompletionSuccessful());
}
/** /**
* 验证审批 Custom Event 不泄漏恢复令牌。 * 验证审批 Custom Event 不泄漏恢复令牌。
*/ */

View File

@@ -1,5 +1,6 @@
package tech.easyflow.skill.gitimport; package tech.easyflow.skill.gitimport;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
@@ -28,10 +29,17 @@ public class GitRepositoryAccessPolicy {
* *
* @param properties Git 导入配置 * @param properties Git 导入配置
*/ */
@Autowired
public GitRepositoryAccessPolicy(SkillGitImportProperties properties) { public GitRepositoryAccessPolicy(SkillGitImportProperties properties) {
this(properties, InetAddress::getAllByName); this(properties, InetAddress::getAllByName);
} }
/**
* 创建可替换主机解析器的 Git 地址访问策略。
*
* @param properties Git 导入配置
* @param hostResolver 主机解析器
*/
GitRepositoryAccessPolicy(SkillGitImportProperties properties, HostResolver hostResolver) { GitRepositoryAccessPolicy(SkillGitImportProperties properties, HostResolver hostResolver) {
this.properties = properties; this.properties = properties;
this.hostResolver = hostResolver; this.hostResolver = hostResolver;

View File

@@ -2,6 +2,7 @@ package tech.easyflow.skill.gitimport;
import org.junit.Before; import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
import java.io.IOException; import java.io.IOException;
@@ -11,6 +12,7 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.Set; import java.util.Set;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
/** /**
@@ -30,6 +32,20 @@ public class GitRepositoryAccessPolicyTest {
policy = new GitRepositoryAccessPolicy(properties); policy = new GitRepositoryAccessPolicy(properties);
} }
/**
* Spring 容器应选择生产构造器完成组件实例化。
*/
@Test
public void springContextUsesProductionConstructor() {
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
context.registerBean(SkillGitImportProperties.class, SkillGitImportProperties::new);
context.register(GitRepositoryAccessPolicy.class);
context.refresh();
assertNotNull(context.getBean(GitRepositoryAccessPolicy.class));
}
}
/** /**
* 仓库地址允许省略 .git 后缀并移除结尾斜线。 * 仓库地址允许省略 .git 后缀并移除结尾斜线。
*/ */

View File

@@ -110,6 +110,14 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId> <artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version> <version>${spring-boot.version}</version>
<configuration>
<requiresUnpack>
<dependency>
<groupId>org.lionsoul</groupId>
<artifactId>jcseg-core</artifactId>
</dependency>
</requiresUnpack>
</configuration>
<executions> <executions>
<execution> <execution>
<goals> <goals>

View File

@@ -1,6 +1,9 @@
// @vitest-environment happy-dom // @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'; import { useUserStore } from '@easyflow/stores';
@@ -15,12 +18,32 @@ import { agentChatRuntimeManager } from './agentChatRuntimeManager';
const aguiMocks = vi.hoisted(() => ({ const aguiMocks = vi.hoisted(() => ({
abort: vi.fn(), abort: vi.fn(),
cancel: vi.fn(async () => undefined),
detach: vi.fn(),
resume: vi.fn(),
run: vi.fn(), run: vi.fn(),
})); }));
vi.mock('../shared/agent-agui/client', () => ({ 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 { EasyFlowAguiClient: class {
abort = aguiMocks.abort; abort = aguiMocks.abort;
cancel = aguiMocks.cancel;
detach = aguiMocks.detach;
resume = aguiMocks.resume;
run = aguiMocks.run; run = aguiMocks.run;
}, },
})); }));
@@ -34,6 +57,9 @@ describe('agentChatRuntimeManager', () => {
setActivePinia(createPinia()); setActivePinia(createPinia());
sessionStorage.clear(); sessionStorage.clear();
vi.clearAllMocks(); vi.clearAllMocks();
aguiMocks.cancel.mockResolvedValue(undefined);
aguiMocks.resume.mockImplementation(() => new Promise(() => {}));
aguiMocks.run.mockImplementation(() => new Promise(() => {}));
}); });
afterEach(() => { afterEach(() => {
@@ -155,6 +181,163 @@ describe('agentChatRuntimeManager', () => {
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined(); 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 () => { it('合并流式通知并在 AG-UI 终态立即持久化', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
let runOptions: EasyFlowAguiRunOptions | undefined; let runOptions: EasyFlowAguiRunOptions | undefined;
@@ -280,13 +463,26 @@ describe('agentChatRuntimeManager', () => {
nickname: '竞态用户', nickname: '竞态用户',
tenantId: 'tenant-1', 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({ await agentChatRuntimeManager.start({
agentId: 'agent-1', agentId: 'agent-1',
prompt: '旧问题', prompt: '旧问题',
sessionId: 'race-session', sessionId: 'race-session',
}); });
agentChatRuntimeManager.stop('race-session'); await agentChatRuntimeManager.stop('race-session');
await Promise.resolve();
await Promise.resolve();
await agentChatRuntimeManager.start({ await agentChatRuntimeManager.start({
agentId: 'agent-1', agentId: 'agent-1',
prompt: '新问题', 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, RUNTIME_STORAGE_PREFIX,
} from '#/utils/agent-chat-cache'; } from '#/utils/agent-chat-cache';
import { EasyFlowAguiClient } from '../shared/agent-agui/client'; import {
createAguiRunId,
EasyFlowAguiClient,
} from '../shared/agent-agui/client';
import { import {
applyAguiEventToTimeline, applyAguiEventToTimeline,
createAguiTimelineProjectionState, createAguiTimelineProjectionState,
} from '../shared/agent-agui/projection'; } from '../shared/agent-agui/projection';
import {
isRetryableAguiTransportError,
resumeAguiRunUntilTerminal,
} from '../shared/agent-agui/reconnect';
import { generateAgentSessionId } from './api'; import { generateAgentSessionId } from './api';
interface RuntimeSessionState { interface RuntimeSessionState {
@@ -31,11 +38,19 @@ interface RuntimeSessionState {
completed: boolean; completed: boolean;
error?: string; error?: string;
identity: string; identity: string;
inputAccepted: boolean;
inputAcceptedNotified: boolean;
items: ChatTimelineItem[]; items: ChatTimelineItem[];
lastCursor: number;
prompt: string; prompt: string;
projectionToolArgs: Record<string, string>;
projectionToolNames: Record<string, string>;
runId?: string;
roundId: string; roundId: string;
sending: boolean; sending: boolean;
sessionId: string; sessionId: string;
startedAt: number;
terminalOutcome?: 'failed' | 'success';
updatedAt: number; updatedAt: number;
} }
@@ -44,10 +59,18 @@ interface StoredRuntimeSession {
agentName?: string; agentName?: string;
completed: boolean; completed: boolean;
error?: string; error?: string;
inputAccepted: boolean;
items: ChatTimelineItem[]; items: ChatTimelineItem[];
lastCursor?: number;
prompt: string; prompt: string;
projectionToolArgs?: Record<string, string>;
projectionToolNames?: Record<string, string>;
runId?: string;
roundId: string; roundId: string;
sending: boolean;
sessionId: string; sessionId: string;
startedAt: number;
terminalOutcome?: 'failed' | 'success';
updatedAt: number; updatedAt: number;
version: number; version: number;
} }
@@ -66,7 +89,7 @@ interface StartOptions {
sessionId?: string; sessionId?: string;
} }
const STORAGE_VERSION = 2; const STORAGE_VERSION = 5;
const STREAM_NOTIFY_INTERVAL_MS = 50; const STREAM_NOTIFY_INTERVAL_MS = 50;
const STREAM_PERSIST_INTERVAL_MS = 300; const STREAM_PERSIST_INTERVAL_MS = 300;
const MAX_RUNTIME_SESSIONS_PER_IDENTITY = 10; const MAX_RUNTIME_SESSIONS_PER_IDENTITY = 10;
@@ -76,7 +99,10 @@ const listeners = new Set<() => void>();
const latestSessionIds = new Map<string, string>(); const latestSessionIds = new Map<string, string>();
const persistTimers = new Map<string, ReturnType<typeof setTimeout>>(); const persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
const runClients = new Map<string, EasyFlowAguiClient>(); 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 notifyTimer: ReturnType<typeof setTimeout> | undefined;
let pageDetaching = false;
function clone<T>(value: T): T { function clone<T>(value: T): T {
const serialized = JSON.stringify(value); const serialized = JSON.stringify(value);
@@ -145,10 +171,18 @@ function persistSession(state: RuntimeSessionState) {
agentName: state.agentName, agentName: state.agentName,
completed: state.completed, completed: state.completed,
error: state.error, error: state.error,
inputAccepted: state.inputAccepted,
items: state.items, items: state.items,
lastCursor: state.lastCursor,
prompt: state.prompt, prompt: state.prompt,
projectionToolArgs: state.projectionToolArgs,
projectionToolNames: state.projectionToolNames,
runId: state.runId,
roundId: state.roundId, roundId: state.roundId,
sending: state.sending,
sessionId: state.sessionId, sessionId: state.sessionId,
startedAt: state.startedAt,
terminalOutcome: state.terminalOutcome,
updatedAt: state.updatedAt, updatedAt: state.updatedAt,
version: STORAGE_VERSION, version: STORAGE_VERSION,
}; };
@@ -177,6 +211,7 @@ function removeSessionCache(
state: RuntimeSessionState, state: RuntimeSessionState,
) { ) {
cancelPersistTimer(scopedSessionKey); cancelPersistTimer(scopedSessionKey);
inputAcceptedCallbacks.delete(scopedSessionKey);
sessions.delete(scopedSessionKey); sessions.delete(scopedSessionKey);
try { try {
safeSessionStorage()?.removeItem( safeSessionStorage()?.removeItem(
@@ -262,7 +297,10 @@ function restoreSession(identity: string, sessionId: string) {
return undefined; return undefined;
} }
const parsed = JSON.parse(raw) as StoredRuntimeSession; 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; return undefined;
} }
const restored: RuntimeSessionState = { const restored: RuntimeSessionState = {
@@ -271,11 +309,31 @@ function restoreSession(identity: string, sessionId: string) {
completed: parsed.completed, completed: parsed.completed,
error: parsed.error, error: parsed.error,
identity, identity,
inputAccepted: Boolean(parsed.inputAccepted),
inputAcceptedNotified: false,
items: Array.isArray(parsed.items) ? parsed.items : [], items: Array.isArray(parsed.items) ? parsed.items : [],
lastCursor: Math.max(0, Number(parsed.lastCursor || 0)),
prompt: parsed.prompt, 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, roundId: parsed.roundId,
sending: false, sending: Boolean(parsed.sending && parsed.runId),
sessionId, sessionId,
startedAt: Number(parsed.startedAt || parsed.updatedAt || Date.now()),
terminalOutcome:
parsed.terminalOutcome === 'failed' ||
parsed.terminalOutcome === 'success'
? parsed.terminalOutcome
: undefined,
updatedAt: parsed.updatedAt, updatedAt: parsed.updatedAt,
}; };
sessions.set(scopedSessionKey, restored); sessions.set(scopedSessionKey, restored);
@@ -293,6 +351,45 @@ function upsertState(state: RuntimeSessionState) {
notifyNow(); 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()) { function runningSession(identity = identityScope()) {
return [...sessions.values()].find( return [...sessions.values()].find(
(session) => session.identity === identity && session.sending, (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) => { onAgentChatCacheClear((identity) => {
for (const [key, session] of sessions) { for (const [key, session] of sessions) {
if (session.identity === identity) { if (session.identity === identity) {
cancelPersistTimer(key); cancelPersistTimer(key);
inputAcceptedCallbacks.delete(key);
sessions.delete(key); sessions.delete(key);
} }
} }
@@ -447,6 +627,111 @@ export const agentChatRuntimeManager = {
upsertState(state); 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) { async start(options: StartOptions) {
const identity = identityScope(); const identity = identityScope();
if (!identity) { if (!identity) {
@@ -458,19 +743,29 @@ export const agentChatRuntimeManager = {
} }
const sessionId = await resolveSessionId(options.sessionId); const sessionId = await resolveSessionId(options.sessionId);
const roundId = createRoundId(); const roundId = createRoundId();
const runId = createAguiRunId();
const startedAt = Date.now(); const startedAt = Date.now();
const state: RuntimeSessionState = { const state: RuntimeSessionState = {
agentId: options.agentId, agentId: options.agentId,
agentName: options.agentName, agentName: options.agentName,
completed: false, completed: false,
identity, identity,
inputAccepted: false,
inputAcceptedNotified: false,
items: clone(options.baseItems || []), items: clone(options.baseItems || []),
lastCursor: 0,
prompt: options.prompt, prompt: options.prompt,
projectionToolArgs: {},
projectionToolNames: {},
runId,
roundId, roundId,
sending: true, sending: true,
sessionId, sessionId,
startedAt,
terminalOutcome: undefined,
updatedAt: startedAt, updatedAt: startedAt,
}; };
registerInputAcceptedCallback(state, options.onInputAccepted);
ChatTimelineBuilder.appendUserMessage(state.items, options.prompt, { ChatTimelineBuilder.appendUserMessage(state.items, options.prompt, {
documents: options.documents, documents: options.documents,
images: options.images, images: options.images,
@@ -487,6 +782,35 @@ export const agentChatRuntimeManager = {
const projectionState = createAguiTimelineProjectionState(startedAt); const projectionState = createAguiTimelineProjectionState(startedAt);
const runClient = new EasyFlowAguiClient(); const runClient = new EasyFlowAguiClient();
runClients.set(scopedSessionKey, runClient); 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 void runClient
.run({ .run({
forwardedProps: { forwardedProps: {
@@ -498,35 +822,12 @@ export const agentChatRuntimeManager = {
}, },
}, },
}, },
onEvent(event) { onCursor(cursor) {
const current = sessions.get(sessionKey(identity, sessionId)); lastCursor = Math.max(lastCursor, cursor);
if (!current || current !== state || !current.sending) { captureProjectionCursor(state, projectionState, cursor);
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,
);
}, },
onEvent: projectEvent,
runId,
threadId: sessionId, threadId: sessionId,
url: `/api/v1/agent/${encodeURIComponent(options.agentId)}/agui/run`, url: `/api/v1/agent/${encodeURIComponent(options.agentId)}/agui/run`,
userMessage: { userMessage: {
@@ -535,49 +836,62 @@ export const agentChatRuntimeManager = {
role: 'user', role: 'user',
}, },
}) })
.then(() => { .catch(async (error) => {
const current = sessions.get(scopedSessionKey); if (pageDetaching || !state.sending) {
if (!current || current !== state || !current.sending) {
return; return;
} }
current.sending = false; if (!isRetryableAguiTransportError(error, state.startedAt)) {
current.completed = true; throw error;
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,
});
} }
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) => { .catch((error) => {
const current = sessions.get(scopedSessionKey); 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; return;
} }
current.error = errorMessage(error); finishRuntimeFailure(current, 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);
}) })
.finally(() => { .finally(() => {
if (runClients.get(scopedSessionKey) === runClient) { if (runClients.get(scopedSessionKey) === runClient) {
@@ -588,7 +902,7 @@ export const agentChatRuntimeManager = {
return sessionId; return sessionId;
}, },
stop(sessionId?: string) { async stop(sessionId?: string) {
const identity = identityScope(); const identity = identityScope();
const state = sessionId const state = sessionId
? restoreSession(identity, sessionId) ? restoreSession(identity, sessionId)
@@ -596,16 +910,44 @@ export const agentChatRuntimeManager = {
if (!state || !state.sending) { if (!state || !state.sending) {
return; return;
} }
runClients.get(sessionKey(identity, state.sessionId))?.abort(); const scopedSessionKey = sessionKey(identity, state.sessionId);
runClients.delete(sessionKey(identity, state.sessionId)); if (stoppingSessions.has(scopedSessionKey)) {
state.sending = false; return;
state.completed = true; }
ChatTimelineBuilder.finalize(state.items, { stoppingSessions.add(scopedSessionKey);
roundId: state.roundId, const runClient = runClients.get(scopedSessionKey);
turnFinishedAt: Date.now(), if (state.runId) {
turnSucceeded: false, 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); upsertState(state);
this.resume(state.sessionId);
}, },
subscribe(listener: () => void) { 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, imageUploadIds: composer.images.uploadIds.value,
images: composer.images.readyItems.value.map((item) => ({ ...item })), images: composer.images.readyItems.value.map((item) => ({ ...item })),
onInputAccepted: () => onInputAccepted: markComposerInputAccepted,
composer.markAccepted().catch(() => {
ElMessage.warning('消息已发送,草稿将在过期后自动清理');
}),
prompt: content, prompt: content,
sessionId: composer.sessionId.value, 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() { function chooseAttachmentFiles() {
attachmentFileInputRef.value?.click(); attachmentFileInputRef.value?.click();
} }
@@ -961,7 +964,7 @@ function handlePromptKeydown(event: Event | KeyboardEvent) {
} }
} }
function handleStop() { async function handleStop() {
if (!canStopRuntime.value) { if (!canStopRuntime.value) {
return; return;
} }
@@ -970,7 +973,12 @@ function handleStop() {
currentSessionId.value currentSessionId.value
? currentSessionId.value ? currentSessionId.value
: agentChatRuntimeManager.getLatestSnapshot()?.sessionId; : 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) { if (runningSessionId === currentSessionId.value) {
syncRuntimeSnapshot(currentSessionId.value); syncRuntimeSnapshot(currentSessionId.value);
} else { } else {
@@ -1094,6 +1102,13 @@ async function bootstrap() {
await Promise.all([loadAgents(), loadSessions(), loadKnowledges()]); await Promise.all([loadAgents(), loadSessions(), loadKnowledges()]);
const routeSessionId = String(route.query.sessionId || ''); const routeSessionId = String(route.query.sessionId || '');
if (routeSessionId) { 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); await loadConversation(routeSessionId);
return; return;
} }
@@ -1101,6 +1116,10 @@ async function bootstrap() {
if (latestSnapshot?.items.length) { if (latestSnapshot?.items.length) {
syncRuntimeSnapshot(latestSnapshot.sessionId); syncRuntimeSnapshot(latestSnapshot.sessionId);
await activateComposer(selectedAgentId.value, latestSnapshot.sessionId); await activateComposer(selectedAgentId.value, latestSnapshot.sessionId);
agentChatRuntimeManager.resume(
latestSnapshot.sessionId,
markComposerInputAccepted,
);
return; return;
} }
await activateComposer(selectedAgentId.value); await activateComposer(selectedAgentId.value);

View File

@@ -45,7 +45,6 @@ import AgentCommandBar from './components/AgentCommandBar.vue';
import AgentInspectorPanel from './components/AgentInspectorPanel.vue'; import AgentInspectorPanel from './components/AgentInspectorPanel.vue';
import AgentSkillSelectorDialog from './components/AgentSkillSelectorDialog.vue'; import AgentSkillSelectorDialog from './components/AgentSkillSelectorDialog.vue';
import { useAgentDesignerState } from './composables/useAgentDesignerState'; import { useAgentDesignerState } from './composables/useAgentDesignerState';
import { resolveAgentCompressionTokenThreshold } from './compression-threshold';
import { createMcpToolLoader } from './mcpToolLoader'; import { createMcpToolLoader } from './mcpToolLoader';
const route = useRoute(); const route = useRoute();
@@ -423,25 +422,8 @@ function runValidation() {
return true; return true;
} }
function syncAgentCompressionThreshold() {
const selectedModel = models.value.find(
(model) => model.value === String(state.agent.modelId ?? ''),
);
const memoryConfig = (state.agent.memoryConfigJson ||= {});
const compressionParameter = (memoryConfig.compressionParameter ||= {});
compressionParameter.minCompressionTokenThreshold =
resolveAgentCompressionTokenThreshold({
contextWindowTokens: selectedModel?.raw?.contextWindowTokens,
generationConfig: state.agent.generationConfigJson,
maxOutputTokens: selectedModel?.raw?.maxOutputTokens,
systemPrompt: state.agent.promptConfigJson?.systemPrompt,
toolBindings: state.toolBindings,
});
}
async function handleSave(showMessage = true) { async function handleSave(showMessage = true) {
if (!runValidation()) return false; if (!runValidation()) return false;
syncAgentCompressionThreshold();
saveLoading.value = true; saveLoading.value = true;
try { try {
const agentPayload = buildPayloadAgent({ const agentPayload = buildPayloadAgent({
@@ -556,7 +538,6 @@ async function handleOffline() {
function handleTryout() { function handleTryout() {
if (!runValidation()) return; if (!runValidation()) return;
syncAgentCompressionThreshold();
openTryout(); openTryout();
} }

View File

@@ -1,24 +1,45 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import baseFormSource from './components/AgentBaseForm.vue?raw'; import builtinToolsFormSource from './components/AgentBuiltinToolsForm.vue?raw';
import inspectorSource from './components/AgentInspectorPanel.vue?raw'; import inspectorSource from './components/AgentInspectorPanel.vue?raw';
import tryoutPanelSource from './components/AgentTryoutPanel.vue?raw';
describe('agent Studio responsive layout contract', () => { describe('agent Studio responsive layout contract', () => {
it('keeps the inspector usable at the 768px breakpoint', () => { it('keeps the inspector usable at the 768px breakpoint', () => {
expect(inspectorSource).toContain('@media (max-width: 900px)'); expect(inspectorSource).toContain('@media (max-width: 900px)');
expect(inspectorSource).toMatch( expect(inspectorSource).toMatch(
/@media \(max-width: 900px\)[\s\S]*?left: var\(--space-4\);[\s\S]*?width: auto;/, /@media \(max-width: 900px\)[\s\S]*?inset:[\s\S]*?var\(--space-4\)[\s\S]*?96px;/,
); );
}); });
it('keeps the inspector and builtin tool controls usable at 375px', () => { it('keeps the inspector and builtin tool controls usable at 375px', () => {
expect(inspectorSource).toMatch( expect(inspectorSource).toMatch(
/@media \(max-width: 480px\)[\s\S]*?right: var\(--space-2\);[\s\S]*?left: var\(--space-2\);/, /@media \(max-width: 480px\)[\s\S]*?inset:[\s\S]*?var\(--space-2\)[\s\S]*?96px;/,
); );
expect(baseFormSource).toMatch( expect(builtinToolsFormSource).toMatch(
/@media \(max-width: 480px\)[\s\S]*?--agent-tool-approval-column: 72px;/, /@media \(max-width: 480px\)[\s\S]*?--agent-tool-approval-column: 72px;/,
); );
expect(baseFormSource).toContain('white-space: normal;'); expect(builtinToolsFormSource).toContain('white-space: normal;');
expect(baseFormSource).toContain('-webkit-line-clamp: 2;'); expect(builtinToolsFormSource).toContain('-webkit-line-clamp: 2;');
});
it('supports a centered animated tryout surface without remounting it', () => {
expect(inspectorSource).toContain('label="内置工具"');
expect(inspectorSource).toContain(':expanded="tryoutExpanded"');
expect(inspectorSource).toContain("'is-tryout-expanded'");
expect(inspectorSource).toContain('--motion-duration-medium');
expect(inspectorSource).toContain(
'@media (prefers-reduced-motion: reduce)',
);
expect(tryoutPanelSource).toContain(':aria-pressed="Boolean(expanded)"');
});
it('keeps clear disabled throughout active output and async cleanup', () => {
expect(tryoutPanelSource).toContain(
':disabled="loading || approvalLoading || clearing"',
);
expect(tryoutPanelSource).toContain(
'if (loading.value || approvalLoading.value || clearing.value)',
);
}); });
}); });

View File

@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
/* eslint-disable vue/no-mutating-props */ /* eslint-disable vue/no-mutating-props */
import type { AgentBuiltinToolKey } from '../builtin-tools';
import type { AgentInfo, AgentOption, AgentToolBinding } from '../types'; import type { AgentInfo, AgentOption, AgentToolBinding } from '../types';
import { InfoFilled } from '@element-plus/icons-vue'; import { InfoFilled } from '@element-plus/icons-vue';
@@ -10,7 +9,6 @@ import {
ElIcon, ElIcon,
ElInput, ElInput,
ElInputNumber, ElInputNumber,
ElMessageBox,
ElOption, ElOption,
ElSelect, ElSelect,
ElSwitch, ElSwitch,
@@ -21,7 +19,6 @@ import { resolveAgentCompressionTokenThreshold } from '../compression-threshold'
const props = defineProps<{ const props = defineProps<{
agent: AgentInfo; agent: AgentInfo;
canDisableShellApproval: boolean;
categories: AgentOption[]; categories: AgentOption[];
models: AgentOption[]; models: AgentOption[];
toolBindings: AgentToolBinding[]; toolBindings: AgentToolBinding[];
@@ -35,92 +32,6 @@ const visibilityScopeOptions = [
{ label: '公开', value: 'PUBLIC' }, { label: '公开', value: 'PUBLIC' },
]; ];
const builtinToolOptions: Array<{
description: string;
key: AgentBuiltinToolKey;
label: string;
}> = [
{ description: '读取工作区内的 UTF-8 文本', key: 'read', label: '读取文件' },
{ description: '在工作区内写入或插入文本', key: 'write', label: '写入文件' },
{
description: '以补丁方式安全修改工作区文件',
key: 'patch',
label: '补丁修改',
},
{ description: '执行平台白名单中的受控命令', key: 'shell', label: 'Shell' },
{
description: '将工作区文件发布为可下载产物',
key: 'artifactPublish',
label: '发布产物',
},
];
function builtinTool(key: AgentBuiltinToolKey) {
return props.agent.executionConfigJson!.builtinTools![key];
}
function handleBuiltinToolEnabledChange(key: AgentBuiltinToolKey) {
if (key === 'shell' && !props.canDisableShellApproval) {
builtinTool('shell').approvalRequired = true;
delete props.agent.executionConfigJson!.builtinTools!
.shellApprovalRiskConfirmed;
}
emit('change');
}
async function confirmUnsafeShellExecution() {
try {
await ElMessageBox.confirm(
'关闭后,命令和脚本将以后端服务权限在宿主环境运行,当前没有沙箱隔离。确认继续?',
'关闭 Shell 调用确认',
{
cancelButtonText: '保持开启',
confirmButtonText: '确认关闭',
type: 'warning',
},
);
props.agent.executionConfigJson!.builtinTools!.shellApprovalRiskConfirmed = true;
return true;
} catch {
return false;
}
}
async function confirmBuiltinToolEnabledChange(key: AgentBuiltinToolKey) {
if (
key !== 'shell' ||
builtinTool('shell').enabled ||
builtinTool('shell').approvalRequired
) {
return true;
}
if (!props.canDisableShellApproval) {
builtinTool('shell').approvalRequired = true;
return true;
}
return confirmUnsafeShellExecution();
}
async function confirmShellApprovalChange() {
if (!props.canDisableShellApproval) {
return false;
}
if (!builtinTool('shell').approvalRequired) {
delete props.agent.executionConfigJson!.builtinTools!
.shellApprovalRiskConfirmed;
return true;
}
return confirmUnsafeShellExecution();
}
function handleApprovalChange(key: AgentBuiltinToolKey, value: unknown) {
if (key === 'shell' && value === true) {
delete props.agent.executionConfigJson!.builtinTools!
.shellApprovalRiskConfirmed;
}
emit('change');
}
function handleModelChange(modelId: AgentInfo['modelId']) { function handleModelChange(modelId: AgentInfo['modelId']) {
const selectedModel = props.models.find( const selectedModel = props.models.find(
(model) => model.value === String(modelId ?? ''), (model) => model.value === String(modelId ?? ''),
@@ -268,66 +179,6 @@ function handleModelChange(modelId: AgentInfo['modelId']) {
@input="emit('change')" @input="emit('change')"
/> />
</ElFormItem> </ElFormItem>
<section
class="agent-form__builtin-tools"
aria-labelledby="builtin-tools-title"
>
<div class="agent-form__section-head">
<div>
<div id="builtin-tools-title" class="agent-form__section-title">
内置工具
</div>
<div class="agent-form__section-description">
为当前智能体配置工作区能力
</div>
</div>
<div class="agent-form__tool-columns" aria-hidden="true">
<span>启用</span>
<span>调用前确认</span>
</div>
</div>
<div class="agent-form__tool-list">
<div
v-for="item in builtinToolOptions"
:key="item.key"
class="agent-form__tool-row"
>
<div class="agent-form__tool-copy">
<div class="agent-form__tool-name">{{ item.label }}</div>
<div class="agent-form__tool-description">
{{ item.description }}
</div>
</div>
<ElSwitch
v-model="builtinTool(item.key).enabled"
:aria-label="`启用${item.label}`"
:before-change="() => confirmBuiltinToolEnabledChange(item.key)"
@change="handleBuiltinToolEnabledChange(item.key)"
/>
<ElTooltip
:disabled="item.key !== 'shell' || canDisableShellApproval"
content="Shell 调用确认仅平台超级管理员可以关闭"
effect="light"
placement="top"
>
<span class="agent-form__tool-approval">
<ElSwitch
v-model="builtinTool(item.key).approvalRequired"
:aria-label="`${item.label}调用前确认`"
:before-change="
item.key === 'shell' ? confirmShellApprovalChange : undefined
"
:disabled="
!builtinTool(item.key).enabled ||
(item.key === 'shell' && !canDisableShellApproval)
"
@change="handleApprovalChange(item.key, $event)"
/>
</span>
</ElTooltip>
</div>
</div>
</section>
<div class="agent-form__grid"> <div class="agent-form__grid">
<ElFormItem> <ElFormItem>
<template #label> <template #label>
@@ -396,89 +247,6 @@ function handleModelChange(modelId: AgentInfo['modelId']) {
gap: 8px; gap: 8px;
} }
.agent-form__builtin-tools {
--agent-tool-approval-column: 96px;
--agent-tool-enabled-column: 48px;
margin-bottom: var(--space-4);
}
.agent-form__section-head {
display: grid;
grid-template-columns:
minmax(0, 1fr)
calc(var(--agent-tool-enabled-column) + var(--agent-tool-approval-column));
gap: var(--space-3);
align-items: end;
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--el-border-color-lighter);
}
.agent-form__section-title {
font-size: 14px;
font-weight: 600;
line-height: 22px;
color: var(--el-text-color-primary);
}
.agent-form__section-description,
.agent-form__tool-description,
.agent-form__tool-columns {
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
}
.agent-form__tool-columns {
display: grid;
grid-template-columns: var(--agent-tool-enabled-column) var(
--agent-tool-approval-column
);
text-align: center;
}
.agent-form__tool-list {
display: flex;
flex-direction: column;
}
.agent-form__tool-row {
display: grid;
grid-template-columns:
minmax(0, 1fr) var(--agent-tool-enabled-column)
var(--agent-tool-approval-column);
gap: var(--space-3);
align-items: center;
min-height: 56px;
border-bottom: 1px solid var(--el-border-color-extra-light);
}
.agent-form__tool-copy {
min-width: 0;
}
.agent-form__tool-name {
font-size: 13px;
font-weight: 500;
line-height: 20px;
color: var(--el-text-color-primary);
}
.agent-form__tool-description {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-form__tool-row > :deep(.el-switch),
.agent-form__tool-approval {
justify-self: center;
}
.agent-form__tool-approval {
display: inline-flex;
}
.agent-form__label { .agent-form__label {
display: inline-flex; display: inline-flex;
gap: 4px; gap: 4px;
@@ -510,26 +278,5 @@ function handleModelChange(modelId: AgentInfo['modelId']) {
.agent-form { .agent-form {
padding: var(--space-3); padding: var(--space-3);
} }
.agent-form__builtin-tools {
--agent-tool-approval-column: 72px;
}
.agent-form__section-head,
.agent-form__tool-row {
gap: var(--space-2);
}
.agent-form__tool-row {
min-height: calc(var(--space-8) * 2);
}
.agent-form__tool-description {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
white-space: normal;
}
} }
</style> </style>

View File

@@ -0,0 +1,261 @@
<script setup lang="ts">
import type { AgentBuiltinToolKey } from '../builtin-tools';
import type { AgentInfo } from '../types';
import { ElForm, ElMessageBox, ElSwitch, ElTooltip } from 'element-plus';
const props = defineProps<{
agent: AgentInfo;
canDisableShellApproval: boolean;
}>();
const emit = defineEmits<{ change: [] }>();
const builtinToolOptions: Array<{
description: string;
key: AgentBuiltinToolKey;
label: string;
}> = [
{ description: '读取工作区内的 UTF-8 文本', key: 'read', label: '读取文件' },
{ description: '在工作区内写入或插入文本', key: 'write', label: '写入文件' },
{
description: '以补丁方式安全修改工作区文件',
key: 'patch',
label: '补丁修改',
},
{ description: '执行平台白名单中的受控命令', key: 'shell', label: 'Shell' },
{
description: '将工作区文件发布为可下载产物',
key: 'artifactPublish',
label: '发布产物',
},
];
function builtinTool(key: AgentBuiltinToolKey) {
return props.agent.executionConfigJson!.builtinTools![key];
}
function handleBuiltinToolEnabledChange(key: AgentBuiltinToolKey) {
if (key === 'shell' && !props.canDisableShellApproval) {
builtinTool('shell').approvalRequired = true;
delete props.agent.executionConfigJson!.builtinTools!
.shellApprovalRiskConfirmed;
}
emit('change');
}
async function confirmUnsafeShellExecution() {
try {
await ElMessageBox.confirm(
'关闭后,命令和脚本将以后端服务权限在宿主环境运行,当前没有沙箱隔离。确认继续?',
'关闭 Shell 调用确认',
{
cancelButtonText: '保持开启',
confirmButtonText: '确认关闭',
type: 'warning',
},
);
props.agent.executionConfigJson!.builtinTools!.shellApprovalRiskConfirmed = true;
return true;
} catch {
return false;
}
}
async function confirmBuiltinToolEnabledChange(key: AgentBuiltinToolKey) {
if (
key !== 'shell' ||
builtinTool('shell').enabled ||
builtinTool('shell').approvalRequired
) {
return true;
}
if (!props.canDisableShellApproval) {
builtinTool('shell').approvalRequired = true;
return true;
}
return confirmUnsafeShellExecution();
}
async function confirmShellApprovalChange() {
if (!props.canDisableShellApproval) {
return false;
}
if (!builtinTool('shell').approvalRequired) {
delete props.agent.executionConfigJson!.builtinTools!
.shellApprovalRiskConfirmed;
return true;
}
return confirmUnsafeShellExecution();
}
function handleApprovalChange(key: AgentBuiltinToolKey, value: unknown) {
if (key === 'shell' && value === true) {
delete props.agent.executionConfigJson!.builtinTools!
.shellApprovalRiskConfirmed;
}
emit('change');
}
</script>
<template>
<ElForm class="agent-builtin-tools">
<section aria-labelledby="builtin-tools-description">
<div class="agent-builtin-tools__head">
<div
id="builtin-tools-description"
class="agent-builtin-tools__description"
>
为当前智能体配置工作区能力
</div>
<div class="agent-builtin-tools__columns" aria-hidden="true">
<span>启用</span>
<span>调用前确认</span>
</div>
</div>
<div class="agent-builtin-tools__list">
<div
v-for="item in builtinToolOptions"
:key="item.key"
class="agent-builtin-tools__row"
>
<div class="agent-builtin-tools__copy">
<div class="agent-builtin-tools__name">{{ item.label }}</div>
<div class="agent-builtin-tools__description">
{{ item.description }}
</div>
</div>
<ElSwitch
v-model="builtinTool(item.key).enabled"
:aria-label="`启用${item.label}`"
:before-change="() => confirmBuiltinToolEnabledChange(item.key)"
@change="handleBuiltinToolEnabledChange(item.key)"
/>
<ElTooltip
:disabled="item.key !== 'shell' || canDisableShellApproval"
content="Shell 调用确认仅平台超级管理员可以关闭"
effect="light"
placement="top"
>
<span class="agent-builtin-tools__approval">
<ElSwitch
v-model="builtinTool(item.key).approvalRequired"
:aria-label="`${item.label}调用前确认`"
:before-change="
item.key === 'shell' ? confirmShellApprovalChange : undefined
"
:disabled="
!builtinTool(item.key).enabled ||
(item.key === 'shell' && !canDisableShellApproval)
"
@change="handleApprovalChange(item.key, $event)"
/>
</span>
</ElTooltip>
</div>
</div>
</section>
</ElForm>
</template>
<style scoped>
.agent-builtin-tools {
--agent-tool-approval-column: 96px;
--agent-tool-enabled-column: 48px;
padding: var(--space-4);
}
.agent-builtin-tools__head {
display: grid;
grid-template-columns:
minmax(0, 1fr)
calc(var(--agent-tool-enabled-column) + var(--agent-tool-approval-column));
gap: var(--space-3);
align-items: end;
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--el-border-color-lighter);
}
.agent-builtin-tools__description,
.agent-builtin-tools__columns {
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
}
.agent-builtin-tools__columns {
display: grid;
grid-template-columns: var(--agent-tool-enabled-column) var(
--agent-tool-approval-column
);
text-align: center;
}
.agent-builtin-tools__list {
display: flex;
flex-direction: column;
}
.agent-builtin-tools__row {
display: grid;
grid-template-columns:
minmax(0, 1fr) var(--agent-tool-enabled-column)
var(--agent-tool-approval-column);
gap: var(--space-3);
align-items: center;
min-height: 56px;
border-bottom: 1px solid var(--el-border-color-extra-light);
}
.agent-builtin-tools__copy {
min-width: 0;
}
.agent-builtin-tools__name {
font-size: 13px;
font-weight: 500;
line-height: 20px;
color: var(--el-text-color-primary);
}
.agent-builtin-tools__copy .agent-builtin-tools__description {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-builtin-tools__row > :deep(.el-switch),
.agent-builtin-tools__approval {
justify-self: center;
}
.agent-builtin-tools__approval {
display: inline-flex;
}
@media (max-width: 480px) {
.agent-builtin-tools {
--agent-tool-approval-column: 72px;
padding: var(--space-3);
}
.agent-builtin-tools__head,
.agent-builtin-tools__row {
gap: var(--space-2);
}
.agent-builtin-tools__row {
min-height: calc(var(--space-8) * 2);
}
.agent-builtin-tools__copy .agent-builtin-tools__description {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
white-space: normal;
}
}
</style>

View File

@@ -44,11 +44,13 @@ const emit = defineEmits<{
selectIssue: [nodeId: string]; selectIssue: [nodeId: string];
}>(); }>();
const loadAgentBuiltinToolsForm = () => import('./AgentBuiltinToolsForm.vue');
const loadAgentInteractionForm = () => import('./AgentInteractionForm.vue'); const loadAgentInteractionForm = () => import('./AgentInteractionForm.vue');
const loadAgentKnowledgeForm = () => import('./AgentKnowledgeForm.vue'); const loadAgentKnowledgeForm = () => import('./AgentKnowledgeForm.vue');
const loadAgentSkillInspector = () => import('./AgentSkillInspector.vue'); const loadAgentSkillInspector = () => import('./AgentSkillInspector.vue');
const loadAgentToolForm = () => import('./AgentToolForm.vue'); const loadAgentToolForm = () => import('./AgentToolForm.vue');
const loadAgentTryoutPanel = () => import('./AgentTryoutPanel.vue'); const loadAgentTryoutPanel = () => import('./AgentTryoutPanel.vue');
const AgentBuiltinToolsForm = defineAsyncComponent(loadAgentBuiltinToolsForm);
const AgentInteractionForm = defineAsyncComponent(loadAgentInteractionForm); const AgentInteractionForm = defineAsyncComponent(loadAgentInteractionForm);
const AgentKnowledgeForm = defineAsyncComponent(loadAgentKnowledgeForm); const AgentKnowledgeForm = defineAsyncComponent(loadAgentKnowledgeForm);
const AgentSkillInspector = defineAsyncComponent(loadAgentSkillInspector); const AgentSkillInspector = defineAsyncComponent(loadAgentSkillInspector);
@@ -65,8 +67,9 @@ const selectedKnowledge = computed(() => {
return props.state.knowledgeBindings.find((item) => item.localId === localId); return props.state.knowledgeBindings.find((item) => item.localId === localId);
}); });
const activeBaseTab = ref<'basic' | 'interaction'>('basic'); const activeBaseTab = ref<'basic' | 'builtinTools' | 'interaction'>('basic');
const interactionForm = ref<AgentInteractionFormExpose>(); const interactionForm = ref<AgentInteractionFormExpose>();
const tryoutExpanded = ref(false);
const selectedModel = computed(() => const selectedModel = computed(() =>
props.models.find((item) => item.value === String(props.state.agent.modelId)), props.models.find((item) => item.value === String(props.state.agent.modelId)),
); );
@@ -96,6 +99,19 @@ function handleMoveSkill(skillId: number | string | undefined, offset: -1 | 1) {
emit('moveSkill', skillId, offset); emit('moveSkill', skillId, offset);
} }
function handleCloseTryout() {
tryoutExpanded.value = false;
emit('closeTryout');
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || !tryoutExpanded.value) {
return;
}
event.preventDefault();
tryoutExpanded.value = false;
}
watch( watch(
() => props.issues, () => props.issues,
(issues) => { (issues) => {
@@ -105,6 +121,16 @@ watch(
{ deep: true }, { deep: true },
); );
watch(
() => props.state.panelMode,
(panelMode) => {
if (panelMode !== 'tryout') {
tryoutExpanded.value = false;
}
},
{ flush: 'sync' },
);
const selectedTool = computed(() => { const selectedTool = computed(() => {
if (!props.state.selectedNodeId.startsWith('tool:')) return; if (!props.state.selectedNodeId.startsWith('tool:')) return;
const localId = props.state.selectedNodeId.slice('tool:'.length); const localId = props.state.selectedNodeId.slice('tool:'.length);
@@ -141,8 +167,10 @@ let prefetchHandle: number | undefined;
let prefetchTimer: number | undefined; let prefetchTimer: number | undefined;
onMounted(() => { onMounted(() => {
window.addEventListener('keydown', handleWindowKeydown);
const prefetchHiddenPanels = () => { const prefetchHiddenPanels = () => {
void Promise.allSettled([ void Promise.allSettled([
loadAgentBuiltinToolsForm(),
loadAgentInteractionForm(), loadAgentInteractionForm(),
loadAgentKnowledgeForm(), loadAgentKnowledgeForm(),
loadAgentSkillInspector(), loadAgentSkillInspector(),
@@ -160,6 +188,7 @@ onMounted(() => {
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('keydown', handleWindowKeydown);
if (prefetchHandle !== undefined) { if (prefetchHandle !== undefined) {
window.cancelIdleCallback(prefetchHandle); window.cancelIdleCallback(prefetchHandle);
} }
@@ -170,7 +199,13 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<aside class="agent-inspector"> <aside
class="agent-inspector"
:class="{
'is-tryout': state.panelMode === 'tryout',
'is-tryout-expanded': state.panelMode === 'tryout' && tryoutExpanded,
}"
>
<template v-if="state.panelMode === 'tryout'"> <template v-if="state.panelMode === 'tryout'">
<AgentTryoutPanel <AgentTryoutPanel
:agent="state.agent" :agent="state.agent"
@@ -178,7 +213,9 @@ onBeforeUnmount(() => {
:skill-bindings="state.skillBindings" :skill-bindings="state.skillBindings"
:tool-bindings="state.toolBindings" :tool-bindings="state.toolBindings"
:knowledge-bindings="state.knowledgeBindings" :knowledge-bindings="state.knowledgeBindings"
@close="emit('closeTryout')" :expanded="tryoutExpanded"
@close="handleCloseTryout"
@update:expanded="tryoutExpanded = $event"
/> />
</template> </template>
<template v-else> <template v-else>
@@ -221,7 +258,6 @@ onBeforeUnmount(() => {
<ElTabPane label="基础设置" name="basic"> <ElTabPane label="基础设置" name="basic">
<AgentBaseForm <AgentBaseForm
:agent="state.agent" :agent="state.agent"
:can-disable-shell-approval="canDisableShellApproval"
:categories="categories" :categories="categories"
:models="models" :models="models"
:tool-bindings="state.toolBindings" :tool-bindings="state.toolBindings"
@@ -235,6 +271,13 @@ onBeforeUnmount(() => {
@change="emit('change')" @change="emit('change')"
/> />
</ElTabPane> </ElTabPane>
<ElTabPane label="内置工具" name="builtinTools">
<AgentBuiltinToolsForm
:agent="state.agent"
:can-disable-shell-approval="canDisableShellApproval"
@change="emit('change')"
/>
</ElTabPane>
</ElTabs> </ElTabs>
</template> </template>
<AgentKnowledgeForm <AgentKnowledgeForm
@@ -267,14 +310,16 @@ onBeforeUnmount(() => {
<style scoped> <style scoped>
.agent-inspector { .agent-inspector {
--agent-inspector-expanded-width: 960px;
--agent-inspector-width: min(420px, calc(100vw - 320px));
position: absolute; position: absolute;
top: var(--space-6); inset: var(--space-6) var(--space-6) 96px
right: var(--space-6); calc(100% - var(--space-6) - var(--agent-inspector-width));
bottom: 96px;
z-index: 20; z-index: 20;
box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: min(420px, calc(100vw - 320px));
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
background: var(--el-bg-color); background: var(--el-bg-color);
@@ -285,6 +330,27 @@ onBeforeUnmount(() => {
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
} }
.agent-inspector.is-tryout {
transition:
top var(--motion-duration-medium) var(--motion-ease-standard),
right var(--motion-duration-medium) var(--motion-ease-standard),
bottom var(--motion-duration-medium) var(--motion-ease-standard),
left var(--motion-duration-medium) var(--motion-ease-standard),
border-radius var(--motion-duration-medium) var(--motion-ease-standard),
box-shadow var(--motion-duration-medium) var(--motion-ease-standard);
}
.agent-inspector.is-tryout-expanded {
inset: var(--space-6)
max(
var(--space-6),
calc((100% - var(--agent-inspector-expanded-width)) / 2)
);
z-index: 40;
border-radius: var(--radius-panel);
box-shadow: var(--shadow-float);
}
.agent-inspector__header { .agent-inspector__header {
padding: var(--space-4); padding: var(--space-4);
border-bottom: 1px solid var(--el-border-color-lighter); border-bottom: 1px solid var(--el-border-color-lighter);
@@ -346,17 +412,23 @@ onBeforeUnmount(() => {
@media (max-width: 900px) { @media (max-width: 900px) {
.agent-inspector { .agent-inspector {
top: calc(var(--space-8) + var(--space-8) + var(--space-2)); inset: calc(var(--space-8) + var(--space-8) + var(--space-2)) var(--space-4)
right: var(--space-4); 96px;
left: var(--space-4); }
width: auto;
.agent-inspector.is-tryout-expanded {
inset: var(--space-4);
} }
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.agent-inspector { .agent-inspector {
right: var(--space-2); inset: calc(var(--space-8) + var(--space-8) + var(--space-2)) var(--space-2)
left: var(--space-2); 96px;
}
.agent-inspector.is-tryout-expanded {
inset: var(--space-2);
} }
.agent-inspector__header { .agent-inspector__header {
@@ -367,4 +439,10 @@ onBeforeUnmount(() => {
padding: var(--space-3) var(--space-3) 0; padding: var(--space-3) var(--space-3) 0;
} }
} }
@media (prefers-reduced-motion: reduce) {
.agent-inspector.is-tryout {
transition: none;
}
}
</style> </style>

View File

@@ -23,6 +23,7 @@ import {
import { BrushCleaning } from '@easyflow/icons'; import { BrushCleaning } from '@easyflow/icons';
import { copyTextToClipboard } from '@easyflow/utils'; import { copyTextToClipboard } from '@easyflow/utils';
import { FullScreen } from '@element-plus/icons-vue';
import { ElButton, ElMessage } from 'element-plus'; import { ElButton, ElMessage } from 'element-plus';
import AiChatPanel from '#/components/ai-chat/AiChatPanel.vue'; import AiChatPanel from '#/components/ai-chat/AiChatPanel.vue';
@@ -41,13 +42,17 @@ import AgentWelcomeState from './AgentWelcomeState.vue';
const props = defineProps<{ const props = defineProps<{
agent: AgentInfo; agent: AgentInfo;
expanded?: boolean;
imageEnabled?: boolean; imageEnabled?: boolean;
knowledgeBindings: AgentKnowledgeBinding[]; knowledgeBindings: AgentKnowledgeBinding[];
skillBindings: AgentSkillBinding[]; skillBindings: AgentSkillBinding[];
toolBindings: AgentToolBinding[]; toolBindings: AgentToolBinding[];
}>(); }>();
const emit = defineEmits<{ close: [] }>(); const emit = defineEmits<{
close: [];
'update:expanded': [value: boolean];
}>();
const { const {
loading, loading,
@@ -63,6 +68,7 @@ const {
stop, stop,
} = useAgentTryoutStream(); } = useAgentTryoutStream();
const approvalLoading = ref(false); const approvalLoading = ref(false);
const clearing = ref(false);
const composer = useAgentComposerDraft('DRAFT'); const composer = useAgentComposerDraft('DRAFT');
const loadDraftAgentArtifact = createAgentArtifactLoader(() => ({ const loadDraftAgentArtifact = createAgentArtifactLoader(() => ({
agentId: String(props.agent.id || ''), agentId: String(props.agent.id || ''),
@@ -82,8 +88,19 @@ function getDraftContext() {
}; };
} }
function markComposerInputAccepted() {
return composer.markAccepted().catch(() => {
ElMessage.warning('消息已发送,草稿将在过期后自动清理');
});
}
function syncCurrentDraftContext(restore = false) { function syncCurrentDraftContext(restore = false) {
syncDraftContext(getDraftContext(), restore, composer.sessionId.value); syncDraftContext(
getDraftContext(),
restore,
composer.sessionId.value,
markComposerInputAccepted,
);
} }
async function activateComposer() { async function activateComposer() {
@@ -157,10 +174,7 @@ async function handleSend(prompt: string) {
imageUploadIds: composer.images.uploadIds.value, imageUploadIds: composer.images.uploadIds.value,
images: composer.images.readyItems.value.map((item) => ({ ...item })), images: composer.images.readyItems.value.map((item) => ({ ...item })),
sessionId: composer.sessionId.value, sessionId: composer.sessionId.value,
onAccepted: () => onAccepted: markComposerInputAccepted,
composer.markAccepted().catch(() => {
ElMessage.warning('消息已发送,草稿将在过期后自动清理');
}),
}); });
} }
@@ -217,6 +231,10 @@ function handleSelectNextVariant(item: ChatTimelineMessageItem) {
} }
async function handleClearSession() { async function handleClearSession() {
if (loading.value || approvalLoading.value || clearing.value) {
return;
}
clearing.value = true;
try { try {
await clearDraftSession(); await clearDraftSession();
await composer.clear(); await composer.clear();
@@ -224,6 +242,8 @@ async function handleClearSession() {
ElMessage.success('已清理会话'); ElMessage.success('已清理会话');
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '清理会话失败'); ElMessage.error(error instanceof Error ? error.message : '清理会话失败');
} finally {
clearing.value = false;
} }
} }
@@ -315,11 +335,15 @@ async function handleRemoveDocument(item: ChatDocumentAttachment) {
} }
} }
function handleStop() { async function handleStop() {
if (!loading.value) { if (!loading.value) {
return; return;
} }
stop(); try {
await stop();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '停止失败');
}
} }
async function handleApprove(payload: ChatTimelineToolApprovalPayload) { async function handleApprove(payload: ChatTimelineToolApprovalPayload) {
@@ -384,13 +408,23 @@ async function handleReject(payload: ChatTimelineToolApprovalPayload) {
@close="emit('close')" @close="emit('close')"
> >
<template #headerActions> <template #headerActions>
<ElButton
:icon="FullScreen"
circle
text
:aria-label="expanded ? '收起试运行' : '展开试运行'"
:aria-pressed="Boolean(expanded)"
:title="expanded ? '收起试运行' : '展开试运行'"
@click="emit('update:expanded', !expanded)"
/>
<ElButton <ElButton
:icon="BrushCleaning" :icon="BrushCleaning"
circle circle
text text
:disabled="approvalLoading" :disabled="loading || approvalLoading || clearing"
:loading="clearing"
aria-label="清理会话" aria-label="清理会话"
title="清理会话" :title="loading ? '输出结束后可清理会话' : '清理会话'"
@click="handleClearSession" @click="handleClearSession"
/> />
</template> </template>

View File

@@ -84,7 +84,7 @@ describe('useAgentDesignerState document context budget', () => {
}); });
describe('useAgentDesignerState memory compression', () => { describe('useAgentDesignerState memory compression', () => {
it('keeps only token-based compression settings in state and payload', () => { it('keeps the manually configured token threshold in state and payload', () => {
expect( expect(
createEmptyAgent().memoryConfigJson?.compressionParameter, createEmptyAgent().memoryConfigJson?.compressionParameter,
).not.toHaveProperty('msgThreshold'); ).not.toHaveProperty('msgThreshold');
@@ -98,7 +98,7 @@ describe('useAgentDesignerState memory compression', () => {
enabled: true, enabled: true,
msgThreshold: 12, msgThreshold: 12,
lastKeep: 6, lastKeep: 6,
minCompressionTokenThreshold: 128_000, minCompressionTokenThreshold: 800_000,
}, },
}, },
}); });
@@ -108,7 +108,7 @@ describe('useAgentDesignerState memory compression', () => {
expect(stateCompressionParameter).not.toHaveProperty('msgThreshold'); expect(stateCompressionParameter).not.toHaveProperty('msgThreshold');
expect(stateCompressionParameter?.lastKeep).toBe(6); expect(stateCompressionParameter?.lastKeep).toBe(6);
expect(stateCompressionParameter?.minCompressionTokenThreshold).toBe( expect(stateCompressionParameter?.minCompressionTokenThreshold).toBe(
128_000, 800_000,
); );
const payloadMemoryConfig = designer.buildPayloadAgent().memoryConfigJson; const payloadMemoryConfig = designer.buildPayloadAgent().memoryConfigJson;
@@ -118,7 +118,7 @@ describe('useAgentDesignerState memory compression', () => {
); );
expect( expect(
payloadMemoryConfig?.compressionParameter.minCompressionTokenThreshold, payloadMemoryConfig?.compressionParameter.minCompressionTokenThreshold,
).toBe(128_000); ).toBe(800_000);
}); });
}); });

View File

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

View File

@@ -1,25 +1,50 @@
// @vitest-environment happy-dom // @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 type { AgentInfo } from '../types';
import { EventType } from '@ag-ui/client'; import { EventType } from '@ag-ui/client';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { easyFlowAguiCustomEvent } from '../../shared/agent-agui/custom-events';
import { useAgentTryoutStream } from './useAgentTryoutStream'; import { useAgentTryoutStream } from './useAgentTryoutStream';
const aguiMocks = vi.hoisted(() => ({ const aguiMocks = vi.hoisted(() => ({
abort: vi.fn(), abort: vi.fn(),
cancel: vi.fn(async () => undefined),
detach: vi.fn(),
resume: vi.fn(),
run: vi.fn(),
runs: [] as EasyFlowAguiRunOptions[], runs: [] as EasyFlowAguiRunOptions[],
})); }));
vi.mock('../../shared/agent-agui/client', () => ({ 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 { EasyFlowAguiClient: class {
abort = aguiMocks.abort; abort = aguiMocks.abort;
cancel = aguiMocks.cancel;
detach = aguiMocks.detach;
resume = aguiMocks.resume;
run(options: EasyFlowAguiRunOptions) { run(options: EasyFlowAguiRunOptions) {
aguiMocks.runs.push(options); aguiMocks.runs.push(options);
return new Promise(() => {}); return aguiMocks.run(options);
} }
}, },
})); }));
@@ -32,10 +57,24 @@ describe('useAgentTryoutStream', () => {
beforeEach(() => { beforeEach(() => {
sessionStorage.clear(); sessionStorage.clear();
aguiMocks.runs.length = 0; 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 () => { 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 stream = useAgentTryoutStream();
const payload = { const payload = {
agent: { id: 'agent-1', name: 'Agent' } as AgentInfo, agent: { id: 'agent-1', name: 'Agent' } as AgentInfo,
@@ -48,7 +87,7 @@ describe('useAgentTryoutStream', () => {
void stream.sendDraft(payload); void stream.sendDraft(payload);
await Promise.resolve(); await Promise.resolve();
stream.stop(); await stream.stop();
void stream.sendDraft({ ...payload, prompt: '新问题' }); void stream.sendDraft({ ...payload, prompt: '新问题' });
await Promise.resolve(); await Promise.resolve();
@@ -177,6 +216,227 @@ describe('useAgentTryoutStream', () => {
sortNo: 3, 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 { 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 { clearAgentDraftSession } from '../api';
import { useAgentTryoutRawRounds } from './useAgentTryoutRawRounds'; import { useAgentTryoutRawRounds } from './useAgentTryoutRawRounds';
@@ -96,8 +103,13 @@ interface DraftRuntimeContext {
} }
interface ActiveDraftRun { interface ActiveDraftRun {
cancelling: boolean;
inputAcceptedNotified: boolean;
lastCursor: number;
roundId: string; roundId: string;
runId: string;
sessionId: string; sessionId: string;
startedAt: number;
stopped: boolean; stopped: boolean;
} }
@@ -108,6 +120,7 @@ export function useAgentTryoutStream() {
let activeRoundId = ''; let activeRoundId = '';
let activeSessionId = ''; let activeSessionId = '';
let activeRun: ActiveDraftRun | undefined; let activeRun: ActiveDraftRun | undefined;
let resumeOnAccepted: (() => Promise<void> | void) | undefined;
const aguiClient = new EasyFlowAguiClient(); const aguiClient = new EasyFlowAguiClient();
function errorMessageOf(error: unknown) { function errorMessageOf(error: unknown) {
@@ -141,7 +154,9 @@ export function useAgentTryoutStream() {
payload: DraftRuntimeContext, payload: DraftRuntimeContext,
restore = false, restore = false,
requestedSessionId?: string, requestedSessionId?: string,
onAccepted?: () => Promise<void> | void,
) { ) {
resumeOnAccepted = onAccepted;
const sessionId = const sessionId =
requestedSessionId || resolveDraftSessionId(payload.agent); requestedSessionId || resolveDraftSessionId(payload.agent);
const sessionChanged = activeSessionId !== sessionId; const sessionChanged = activeSessionId !== sessionId;
@@ -156,6 +171,99 @@ export function useAgentTryoutStream() {
} }
if (restore && sessionChanged && !loading.value) { if (restore && sessionChanged && !loading.value) {
rebuildTimeline(); 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) { if (!rawRounds) {
return; return;
} }
activeRoundId = rawRounds.createRound( const rounds = rawRounds;
activeRoundId = rounds.createRound(
payload.prompt, payload.prompt,
payload.images, payload.images,
payload.documents, payload.documents,
); );
const runId = createAguiRunId();
rounds.bindRun(activeRoundId, runId);
const startedAt = Date.now();
const run: ActiveDraftRun = { const run: ActiveDraftRun = {
cancelling: false,
inputAcceptedNotified: false,
lastCursor: 0,
roundId: activeRoundId, roundId: activeRoundId,
runId,
sessionId: activeSessionId, sessionId: activeSessionId,
startedAt,
stopped: false, stopped: false,
}; };
resumeOnAccepted = payload.onAccepted;
activeRun = run; activeRun = run;
rebuildTimeline(); rebuildTimeline();
loading.value = true; loading.value = true;
let accepted = false;
try { try {
await aguiClient.run({ let terminalReceived = true;
forwardedProps: { try {
easyflow: { await aguiClient.run({
draft: { forwardedProps: {
agent: draftAgentTransport(payload.agent), easyflow: {
knowledgeBindings: payload.knowledgeBindings.map((binding) => draft: {
draftKnowledgeBindingTransport(binding), agent: draftAgentTransport(payload.agent),
), knowledgeBindings: payload.knowledgeBindings.map((binding) =>
skillBindings: payload.skillBindings.map((binding) => draftKnowledgeBindingTransport(binding),
draftSkillBindingTransport(binding), ),
), skillBindings: payload.skillBindings.map((binding) =>
toolBindings: payload.toolBindings.map((binding) => draftSkillBindingTransport(binding),
draftToolBindingTransport(binding), ),
), toolBindings: payload.toolBindings.map((binding) =>
}, draftToolBindingTransport(binding),
input: { ),
documentUploadIds: payload.documentUploadIds, },
imageUploadIds: payload.imageUploadIds, input: {
documentUploadIds: payload.documentUploadIds,
imageUploadIds: payload.imageUploadIds,
},
}, },
}, },
}, onCursor(cursor) {
onEvent(event) { run.lastCursor = Math.max(run.lastCursor, cursor);
if (activeRun !== run) return; rounds.updateCursor(run.roundId, run.lastCursor);
const runtimeEvent = rawRounds?.recordEvent(run.roundId, event); },
if (!runtimeEvent) return; onEvent: (event) => projectRunEvent(run, rounds, event),
rawRounds?.projectEvent( runId,
timelineItems.value, threadId: run.sessionId,
run.roundId, url: '/api/v1/agent/agui/run/draft',
runtimeEvent, userMessage: {
() => { content: payload.prompt,
if (accepted) return; id: `user-${run.roundId}`,
accepted = true; role: 'user',
void payload.onAccepted?.(); },
}, });
); } catch (error) {
}, if (!isRetryableAguiTransportError(error, run.startedAt)) {
threadId: run.sessionId, throw error;
url: '/api/v1/agent/agui/run/draft', }
userMessage: { terminalReceived = await resumeActiveRun(run, rounds);
content: payload.prompt, }
id: `user-${run.roundId}`, if (terminalReceived && activeRun === run && !run.stopped) {
role: 'user',
},
});
if (activeRun === run && !run.stopped) {
finishAssistant(); finishAssistant();
markRoundCompleted(run.roundId); markRoundCompleted(run.roundId);
} }
@@ -270,17 +386,17 @@ export function useAgentTryoutStream() {
if (activeRun === run && !run.stopped) { if (activeRun === run && !run.stopped) {
const runError = { const runError = {
message: errorMessageOf(error) || '试运行失败,请稍后再试', message: errorMessageOf(error) || '试运行失败,请稍后再试',
runId: run.roundId, runId: run.runId,
threadId: run.sessionId, threadId: run.sessionId,
type: EventType.RUN_ERROR, type: EventType.RUN_ERROR,
} as AguiEvent; } as AguiEvent;
rawRounds?.recordEvent(run.roundId, runError); rounds.recordEvent(run.roundId, runError);
rawRounds?.projectEvent(timelineItems.value, run.roundId, runError); rounds.projectEvent(timelineItems.value, run.roundId, runError);
finishAssistant(); finishAssistant();
rawRounds?.flush(); rounds.flush();
} }
} finally { } finally {
if (activeRun === run) { if (activeRun === run && !run.cancelling) {
activeRun = undefined; activeRun = undefined;
loading.value = false; loading.value = false;
} }
@@ -325,10 +441,7 @@ export function useAgentTryoutStream() {
async function clearDraftSession() { async function clearDraftSession() {
if (loading.value) { if (loading.value) {
if (activeRun) activeRun.stopped = true; throw new Error('试运行进行中,暂时无法清理会话');
activeRun = undefined;
aguiClient.abort();
loading.value = false;
} }
const sessionId = activeSessionId; const sessionId = activeSessionId;
rawRounds?.clear(); 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) { if (!loading.value) {
return; return;
} }
const stoppedRoundId = activeRun?.roundId || activeRoundId; 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; 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); finishStoppedRun(stoppedRoundId);
} }
function dispose() { function dispose() {
if (loading.value) { if (loading.value) {
const stoppedRoundId = activeRun?.roundId || activeRoundId;
if (activeRun) activeRun.stopped = true;
activeRun = undefined; activeRun = undefined;
aguiClient.abort(); aguiClient.detach();
finishStoppedRun(stoppedRoundId); rawRounds?.flush();
return; return;
} }
rawRounds?.flush(); rawRounds?.flush();

View File

@@ -1,7 +1,8 @@
import { EventType } from '@ag-ui/client'; import { EventType } from '@ag-ui/client';
import { afterEach, describe, expect, it, vi } from 'vitest'; 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', () => ({ vi.mock('#/api/request', () => ({
createEventStreamHeaders: () => ({ 'easyflow-token': 'test-token' }), createEventStreamHeaders: () => ({ 'easyflow-token': 'test-token' }),
@@ -10,7 +11,12 @@ vi.mock('#/api/request', () => ({
function sse(events: unknown[]) { function sse(events: unknown[]) {
return new Response( 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 }, { headers: { 'Content-Type': 'text/event-stream' }, status: 200 },
); );
} }
@@ -18,6 +24,7 @@ function sse(events: unknown[]) {
describe('easyFlowAguiClient', () => { describe('easyFlowAguiClient', () => {
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
vi.useRealTimers();
}); });
it('trims outbound history, tools, context and state at the transport boundary', async () => { it('trims outbound history, tools, context and state at the transport boundary', async () => {
@@ -36,15 +43,22 @@ describe('easyFlowAguiClient', () => {
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
const received: string[] = []; const received: string[] = [];
const cursors: number[] = [];
await new EasyFlowAguiClient().run({ await new EasyFlowAguiClient().run({
forwardedProps: { easyflow: { input: { imageUploadIds: ['image-1'] } } }, 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', threadId: '101',
url: '/api/v1/agent/1/agui/run', url: '/api/v1/agent/1/agui/run',
userMessage: { content: '你好', id: 'user-1', role: 'user' }, userMessage: { content: '你好', id: 'user-1', role: 'user' },
}); });
expect(received).toEqual([EventType.RUN_STARTED, EventType.RUN_FINISHED]); expect(received).toEqual([EventType.RUN_STARTED, EventType.RUN_FINISHED]);
expect(cursors).toEqual([1, 2]);
expect(requestBody).toEqual( expect(requestBody).toEqual(
expect.objectContaining({ expect.objectContaining({
context: [], context: [],
@@ -106,6 +120,32 @@ describe('easyFlowAguiClient', () => {
).rejects.toThrow('缺少终态'); ).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 () => { it('treats a standard cancelled terminal as an accepted stop', async () => {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
@@ -132,4 +172,207 @@ describe('easyFlowAguiClient', () => {
}), }),
).resolves.toBeUndefined(); ).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('yields a paint opportunity before consuming events after tool start', async () => {
vi.useFakeTimers();
let paintCallback: FrameRequestCallback | undefined;
vi.stubGlobal(
'requestAnimationFrame',
vi.fn((callback: FrameRequestCallback) => {
paintCallback = callback;
return 1;
}),
);
vi.stubGlobal(
'fetch',
vi.fn(async () =>
sse([
{ runId: 'run-1', threadId: '101', type: EventType.RUN_STARTED },
{
runId: 'run-1',
threadId: '101',
toolCallId: 'tool-1',
toolCallName: 'write_text_file',
type: EventType.TOOL_CALL_START,
},
{
delta: '{}',
runId: 'run-1',
threadId: '101',
toolCallId: 'tool-1',
type: EventType.TOOL_CALL_ARGS,
},
{
runId: 'run-1',
threadId: '101',
toolCallId: 'tool-1',
type: EventType.TOOL_CALL_END,
},
{
content: 'done',
messageId: 'tool-result-1',
role: 'tool',
runId: 'run-1',
threadId: '101',
toolCallId: 'tool-1',
type: EventType.TOOL_CALL_RESULT,
},
{
runId: 'run-1',
threadId: '101',
type: EventType.RUN_FINISHED,
},
]),
),
);
const received: string[] = [];
const runPromise = new EasyFlowAguiClient().run({
onEvent: (event) => {
received.push(event.type);
},
threadId: '101',
url: '/api/v1/agent/1/agui/run',
userMessage: { content: '生成文件', id: 'user-1', role: 'user' },
});
await vi.advanceTimersByTimeAsync(0);
expect(received).toContain(EventType.TOOL_CALL_START);
expect(received).not.toContain(EventType.TOOL_CALL_RESULT);
paintCallback?.(0);
await vi.advanceTimersByTimeAsync(0);
await runPromise;
expect(received).toContain(EventType.TOOL_CALL_RESULT);
expect(received.at(-1)).toBe(EventType.RUN_FINISHED);
});
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,68 @@
import type { import type { Message } from '@ag-ui/client';
AgentSubscriber,
Message,
RunAgentInput,
State,
} 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'; import { createEventStreamHeaders, resolveApiUrl } from '#/api/request';
export interface EasyFlowAguiRunOptions { export interface EasyFlowAguiRunOptions {
forwardedProps?: Record<string, unknown>; forwardedProps?: Record<string, unknown>;
onEvent: (event: AguiEvent) => void; onCursor?: (cursor: number) => void;
onMessagesChanged?: (messages: ReadonlyArray<Readonly<Message>>) => void; onEvent: (event: AguiEvent) => Promise<void> | void;
onStateChanged?: (state: Readonly<State>) => void; runId?: string;
threadId: string; threadId: string;
url: string; url: string;
userMessage: Message; 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']>; export type AguiEvent = ReturnType<(typeof EventSchemas)['parse']>;
interface ActiveAguiRun { interface ActiveAguiRun {
aborted: boolean; aborted: boolean;
agent: HttpAgent; abort: () => void;
}
const TOOL_START_PAINT_FALLBACK_MS = 50;
/** 带 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 { function toTransportJson<T>(value: T): T {
@@ -32,76 +71,181 @@ function toTransportJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T; return JSON.parse(JSON.stringify(value)) as T;
} }
/**
* 在工具开始事件后让出一次页面绘制机会。
*
* <p>AG-UI 可能在同一批响应数据中连续送达工具开始和结果事件。Vue 虽已收到开始态,
* 浏览器仍可能在首次绘制前就将其覆盖为完成态。这里使用一帧回调并设置有界兜底,
* 既确保前台页面能展示“调用中”,也避免后台标签页暂停动画帧时阻塞事件流。</p>
*
* @returns 页面获得绘制机会后完成的 Promise
*/
function waitForToolStartPaint(): Promise<void> {
return new Promise((resolve) => {
let settled = false;
const complete = () => {
if (settled) return;
settled = true;
clearTimeout(fallbackTimer);
resolve();
};
const fallbackTimer = setTimeout(complete, TOOL_START_PAINT_FALLBACK_MS);
if (typeof globalThis.requestAnimationFrame !== 'function') {
setTimeout(complete, 0);
return;
}
globalThis.requestAnimationFrame(() => {
// requestAnimationFrame 回调发生在绘制前,再让出一个任务周期后继续消费后续事件。
setTimeout(complete, 0);
});
});
}
/** /**
* EasyFlow 的无头 AG-UI 运行客户端。 * EasyFlow 的无头 AG-UI 运行客户端。
* *
* <p>SDK 持有标准 messages/state页面只消费投影回调。每次运行都会在出站边界再次裁剪 * <p>客户端以最小标准 AG-UI 请求体启动运行,页面只消费投影回调。出站边界固定裁剪
* tools、context、state 和历史消息,服务端仍会独立执行同样的安全校验。</p> * tools、context、state 和历史消息,服务端仍会独立执行同样的安全校验。</p>
*/ */
export class EasyFlowAguiClient { export class EasyFlowAguiClient {
private activeRun?: ActiveAguiRun; private activeRun?: ActiveAguiRun;
abort() { 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; if (!this.activeRun) return;
this.activeRun.aborted = true; this.activeRun.aborted = true;
this.activeRun.agent.abortRun(); this.activeRun.abort();
this.activeRun = undefined; this.activeRun = undefined;
} }
async run(options: EasyFlowAguiRunOptions) { async resume(options: EasyFlowAguiResumeOptions) {
this.abort(); this.detach();
const requestUrl = options.url; const controller = new AbortController();
const agent = new EasyFlowHttpAgent({ const activeRun: ActiveAguiRun = {
headers: createEventStreamHeaders(requestUrl), abort: () => controller.abort(),
initialMessages: [options.userMessage], aborted: false,
threadId: options.threadId, };
url: resolveApiUrl(requestUrl),
});
const activeRun: ActiveAguiRun = { aborted: false, agent };
this.activeRun = activeRun; this.activeRun = activeRun;
let terminalReceived = false; let terminalReceived = false;
let cancelledReceived = false; try {
const subscriber: AgentSubscriber = { const requestUrl = `/api/v1/agent/agui/run/${encodeURIComponent(options.runId)}/events?after=${Math.max(0, options.afterCursor || 0)}`;
onEvent: ({ event }) => { 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 ( if (
event.type === EventType.RUN_FINISHED || event.type === EventType.RUN_FINISHED ||
event.type === EventType.RUN_ERROR event.type === EventType.RUN_ERROR
) { ) {
terminalReceived = true; terminalReceived = true;
} }
if ( if (event.type === EventType.TOOL_CALL_START) {
event.type === EventType.RUN_ERROR && await waitForToolStartPaint();
event.code === 'RUN_CANCELLED'
) {
cancelledReceived = true;
} }
options.onEvent(event as AguiEvent); }
}, if (!activeRun.aborted && !terminalReceived) {
onMessagesChanged: ({ messages }) => { throw new Error('Agent 事件流缺少终态,请重试');
options.onMessagesChanged?.(messages); }
}, } catch (error) {
onStateChanged: ({ state }) => { if (activeRun.aborted) return;
options.onStateChanged?.(state); 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 { try {
await agent.runAgent( const response = await fetch(resolveApiUrl(requestUrl), {
{ body: JSON.stringify({
context: [], context: [],
forwardedProps: options.forwardedProps forwardedProps: options.forwardedProps
? toTransportJson(options.forwardedProps) ? toTransportJson(options.forwardedProps)
: undefined, : undefined,
runId: `run_${randomUUID()}`, messages: [toTransportJson(options.userMessage)],
runId: options.runId || createAguiRunId(),
state: {},
threadId: options.threadId,
tools: [], 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 (event.type === EventType.TOOL_CALL_START) {
await waitForToolStartPaint();
}
}
if (!terminalReceived) { if (!terminalReceived) {
if (activeRun.aborted) return; if (activeRun.aborted) return;
throw new Error('Agent 事件流缺少终态,请重试'); throw new Error('Agent 事件流缺少终态,请重试');
} }
} catch (error) { } catch (error) {
if (activeRun.aborted || cancelledReceived) return; if (activeRun.aborted) return;
if (terminalReceived && !(error instanceof EasyFlowAguiProjectionError)) {
return;
}
throw error; throw error;
} finally { } finally {
if (this.activeRun === activeRun) { if (this.activeRun === activeRun) {
@@ -111,17 +255,26 @@ export class EasyFlowAguiClient {
} }
} }
class EasyFlowHttpAgent extends HttpAgent { export function createAguiRunId() {
protected override requestInit(input: RunAgentInput): RequestInit { return `run_${randomUUID()}`;
const latestUserMessage = [...input.messages] }
.reverse()
.find((message) => message.role === 'user'); async function assertSseResponse(response: Response) {
return super.requestInit({ const contentType = response.headers.get('content-type') || '';
...input, if (!response.ok || !contentType.includes('text/event-stream')) {
context: [], throw new EasyFlowAguiHttpError(
messages: latestUserMessage ? [latestUserMessage] : [], await responseError(response),
state: {}, response.status,
tools: [], );
}); }
}
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;
}

View File

@@ -5,7 +5,6 @@ import type {
ChatImageLoader, ChatImageLoader,
ChatTimelineItem, ChatTimelineItem,
ChatTimelineMessageItem, ChatTimelineMessageItem,
ChatTimelineStatusItem,
ChatTimelineToolApprovalPayload, ChatTimelineToolApprovalPayload,
} from './types'; } from './types';
@@ -138,38 +137,6 @@ const hasPendingApproval = computed(() =>
(item.status === 'approving' || item.status === 'pending_approval'), (item.status === 'approving' || item.status === 'pending_approval'),
), ),
); );
const hasActiveProcessIndicator = computed(() =>
props.items.some((item) => {
if (item.type === 'tool') {
return ['approving', 'pending_approval', 'running'].includes(item.status);
}
if (item.type === 'status') {
return item.status === 'running';
}
return (
item.type === 'message' &&
item.parts.some(
(part) => part.type === 'thinking' && part.status === 'thinking',
)
);
}),
);
const continuingStatus = computed<ChatTimelineStatusItem | undefined>(() => {
if (!turnActive.value || hasActiveProcessIndicator.value) {
return undefined;
}
return {
id: `chat-turn-continuing-${props.roundId}`,
icon: 'none',
label: '正在继续处理',
presentation: 'inline',
roundId: props.roundId,
status: 'running',
statusKey: `chat-turn-continuing:${props.roundId}`,
turnStartedAt: turnStartedAt.value,
type: 'status',
};
});
const canCollapse = computed( const canCollapse = computed(
() => () =>
turnSucceeded.value && turnSucceeded.value &&
@@ -327,7 +294,7 @@ function handleNestedLayoutToggle() {
@after-leave="emit('layoutChanged')" @after-leave="emit('layoutChanged')"
> >
<div <div
v-if="expanded && (processItems.length > 0 || continuingStatus)" v-if="expanded && processItems.length > 0"
:id="processId" :id="processId"
class="chat-timeline-turn__process" class="chat-timeline-turn__process"
> >
@@ -362,10 +329,6 @@ function handleNestedLayoutToggle() {
@thinking-toggle="handleNestedLayoutToggle" @thinking-toggle="handleNestedLayoutToggle"
/> />
</template> </template>
<ChatTimelineItemView
v-if="continuingStatus"
:item="continuingStatus"
/>
</div> </div>
</Transition> </Transition>

View File

@@ -96,10 +96,10 @@ describe('chat timeline turn', () => {
expect( expect(
wrapper.findAll('.chat-timeline-item__assistant-avatar'), wrapper.findAll('.chat-timeline-item__assistant-avatar'),
).toHaveLength(0); ).toHaveLength(0);
expect(wrapper.text()).toContain('正在继续处理'); expect(wrapper.text()).not.toContain('正在继续处理');
}); });
it('keeps continuous feedback around a fast automatic tool call', async () => { it('shows the running and completed states of an automatic tool call', async () => {
const assistant: ChatTimelineItem = { const assistant: ChatTimelineItem = {
id: 'assistant-fast-tool', id: 'assistant-fast-tool',
parts: [ parts: [
@@ -132,19 +132,18 @@ describe('chat timeline turn', () => {
}, },
}); });
expect(wrapper.text()).toContain('正在继续处理'); expect(wrapper.text()).not.toContain('调用中');
await wrapper.setProps({ items: [assistant, runningTool] }); await wrapper.setProps({ items: [assistant, runningTool] });
expect(wrapper.text()).toContain('调用中'); expect(wrapper.text()).toContain('调用中');
expect(wrapper.text()).not.toContain('正在继续处理');
await wrapper.setProps({ await wrapper.setProps({
items: [assistant, { ...runningTool, status: 'success' }], items: [assistant, { ...runningTool, status: 'success' }],
}); });
expect(wrapper.text()).toContain('已完成'); expect(wrapper.text()).toContain('已完成');
expect(wrapper.text()).toContain('正在继续处理'); expect(wrapper.text()).not.toContain('正在继续处理');
}); });
it('updates the running duration every second and freezes it on success', async () => { it('updates the running duration every second and freezes it on success', async () => {

View File

@@ -156,6 +156,90 @@ describe('chat timeline builder', () => {
} }
}); });
it('keeps completed memory compression rows immutable across repeated attempts', () => {
const items: ChatTimelineItem[] = [];
const metadata = {
roundId: 'round-1',
statusKey: 'memory-compression:round-1',
};
ChatTimelineBuilder.upsertMemoryCompressionStatus(items, {
...metadata,
phase: 'started',
status: 'running',
});
ChatTimelineBuilder.upsertMemoryCompressionStatus(items, {
...metadata,
compressed: true,
phase: 'completed',
status: 'done',
});
ChatTimelineBuilder.appendThinkingDelta(items, '继续处理', metadata);
ChatTimelineBuilder.upsertMemoryCompressionStatus(items, {
...metadata,
phase: 'started',
status: 'running',
});
const runningStatuses = items.filter((item) => item.type === 'status');
expect(runningStatuses).toHaveLength(2);
expect(runningStatuses[0]).toMatchObject({
label: '已整理上下文',
status: 'done',
statusKey: 'memory-compression:round-1',
});
expect(runningStatuses[1]).toMatchObject({
label: '正在整理上下文',
status: 'running',
statusKey: 'memory-compression:round-1::occurrence:2',
});
const thinkingIndex = items.findIndex(
(item) =>
item.type === 'message' &&
item.parts.some(
(part) => part.type === 'thinking' && part.content === '继续处理',
),
);
const currentStatus = runningStatuses[1];
if (!currentStatus) {
throw new Error('缺少第二次上下文整理状态');
}
expect(items.indexOf(currentStatus)).toBeGreaterThan(thinkingIndex);
ChatTimelineBuilder.upsertMemoryCompressionStatus(items, {
...metadata,
compressed: true,
phase: 'completed',
status: 'done',
});
ChatTimelineBuilder.upsertMemoryCompressionStatus(items, {
...metadata,
phase: 'started',
status: 'running',
});
ChatTimelineBuilder.upsertMemoryCompressionStatus(items, {
...metadata,
compressed: false,
phase: 'completed',
status: 'done',
});
const completedStatuses = items.filter((item) => item.type === 'status');
expect(completedStatuses).toHaveLength(2);
expect(completedStatuses).toEqual([
expect.objectContaining({
label: '已整理上下文',
status: 'done',
statusKey: 'memory-compression:round-1',
}),
expect.objectContaining({
label: '已整理上下文',
status: 'done',
statusKey: 'memory-compression:round-1::occurrence:2',
}),
]);
});
it('updates one Skill invocation row in place and preserves Skill order', () => { it('updates one Skill invocation row in place and preserves Skill order', () => {
const items: ChatTimelineItem[] = []; const items: ChatTimelineItem[] = [];

View File

@@ -228,6 +228,31 @@ function findStatusItem(items: ChatTimelineItem[], statusKey: string) {
); );
} }
function resolveStatusOccurrenceKey(
items: ChatTimelineItem[],
statusKey: string,
roundId?: string,
) {
// 同一 Turn 可多次触发整理;只复用仍在运行的实例,避免改写已完成的历史状态。
const occurrencePrefix = `${statusKey}::occurrence:`;
const occurrences = items.filter(
(item): item is ChatTimelineStatusItem =>
item.type === 'status' &&
(item.statusKey === statusKey ||
item.statusKey.startsWith(occurrencePrefix)) &&
(!roundId || item.roundId === roundId),
);
const running = [...occurrences]
.reverse()
.find((item) => item.status === 'running');
if (running) {
return running.statusKey;
}
return occurrences.length === 0
? statusKey
: `${occurrencePrefix}${occurrences.length + 1}`;
}
function removeStatusItem(items: ChatTimelineItem[], statusKey: string) { function removeStatusItem(items: ChatTimelineItem[], statusKey: string) {
const index = items.findIndex( const index = items.findIndex(
(item) => item.type === 'status' && item.statusKey === statusKey, (item) => item.type === 'status' && item.statusKey === statusKey,
@@ -680,7 +705,11 @@ export const ChatTimelineBuilder = {
payload?.status === 'done' || payload?.phase === 'completed' payload?.status === 'done' || payload?.phase === 'completed'
? 'done' ? 'done'
: 'running'; : 'running';
const statusKey = payload?.statusKey || 'memory-compression'; const statusKey = resolveStatusOccurrenceKey(
items,
payload?.statusKey || 'memory-compression',
payload?.roundId,
);
finishAssistantMessage(items, false, payload?.roundId); finishAssistantMessage(items, false, payload?.roundId);
if (status === 'done' && payload?.compressed === false) { if (status === 'done' && payload?.compressed === false) {
removeStatusItem(items, statusKey); removeStatusItem(items, statusKey);

View File

@@ -15,8 +15,8 @@
</modules> </modules>
<properties> <properties>
<revision>1.1.0-RC</revision> <revision>1.1.0</revision>
<easy-agents.version>1.1.0-RC</easy-agents.version> <easy-agents.version>1.1.0</easy-agents.version>
<java.versionm>17</java.versionm> <java.versionm>17</java.versionm>
<maven.compiler.source>17</maven.compiler.source> <maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target> <maven.compiler.target>17</maven.compiler.target>