feat: 全新智能体功能

- 基于先进智能体框架,增加智能体编排功能
- 增加智能体聊天,并对接持久化
This commit is contained in:
2026-05-25 11:42:48 +08:00
parent 6c3d98eaac
commit 72df00f25b
168 changed files with 22045 additions and 400 deletions

View File

@@ -0,0 +1,59 @@
package tech.easyflow.agent.publish;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
import tech.easyflow.agent.service.AgentToolBindingService;
import java.lang.reflect.Proxy;
import java.math.BigInteger;
import java.util.concurrent.atomic.AtomicInteger;
/**
* {@link AgentApprovalSubjectHandler} 单元测试。
*/
public class AgentApprovalSubjectHandlerTest {
/**
* 审批删除 Agent 前必须同步清理工具绑定和知识库绑定,避免留下孤儿数据。
*/
@Test
public void beforeRemoveShouldCleanAgentBindings() {
AtomicInteger toolRemoveCalls = new AtomicInteger();
AtomicInteger knowledgeRemoveCalls = new AtomicInteger();
AgentToolBindingService toolBindingService = proxy(AgentToolBindingService.class, toolRemoveCalls);
AgentKnowledgeBindingService knowledgeBindingService = proxy(AgentKnowledgeBindingService.class, knowledgeRemoveCalls);
AgentApprovalSubjectHandler handler = new AgentApprovalSubjectHandler(
null,
new ObjectMapper(),
null,
toolBindingService,
knowledgeBindingService,
null
);
handler.beforeRemove(BigInteger.valueOf(1001));
Assert.assertEquals(1, toolRemoveCalls.get());
Assert.assertEquals(1, knowledgeRemoveCalls.get());
}
@SuppressWarnings("unchecked")
private static <T> T proxy(Class<T> type, AtomicInteger removeCalls) {
return (T) Proxy.newProxyInstance(
type.getClassLoader(),
new Class<?>[]{type},
(proxy, method, args) -> {
if ("remove".equals(method.getName()) && args != null && args.length == 1) {
removeCalls.incrementAndGet();
return true;
}
if (method.getReturnType() == boolean.class || method.getReturnType() == Boolean.class) {
return false;
}
return null;
}
);
}
}

View File

@@ -0,0 +1,192 @@
package tech.easyflow.agent.runtime;
import com.easyagents.agent.runtime.AgentInitRequest;
import com.easyagents.agent.runtime.AgentResumeRequest;
import com.easyagents.agent.runtime.AgentRuntime;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import org.junit.Assert;
import org.junit.Test;
import reactor.core.publisher.Flux;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.core.runtime.ChatAssistantAccumulator;
import tech.easyflow.core.runtime.ChatRuntimeContext;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
/**
* Agent 运行态注册表测试。
*/
public class AgentRunRegistryTest {
/**
* 验证批准请求会恢复当前运行时。
*/
@Test
public void approveShouldResumeRuntime() {
AgentRunRegistry registry = new AgentRunRegistry();
CapturingRuntime runtime = new CapturingRuntime();
registry.register(context("request-1", "session-1", "user-1", runtime));
registry.registerResumeToken("request-1", "token-1");
registry.approve("request-1", "token-1", "user-1");
AgentResumeRequest request = runtime.resumeRequest.get();
Assert.assertNotNull(request);
Assert.assertTrue(request.isApproved());
Assert.assertEquals("token-1", request.getResumeToken().getValue());
}
/**
* 验证拒绝请求支持通过恢复令牌反查运行时。
*/
@Test
public void rejectShouldResolveRequestByResumeToken() {
AgentRunRegistry registry = new AgentRunRegistry();
CapturingRuntime runtime = new CapturingRuntime();
registry.register(context("request-2", "session-2", "user-1", runtime));
registry.registerResumeToken("request-2", "token-2");
registry.reject(null, "token-2", "user-1", "denied");
AgentResumeRequest request = runtime.resumeRequest.get();
Assert.assertNotNull(request);
Assert.assertFalse(request.isApproved());
Assert.assertEquals("denied", request.getRejectReason());
}
/**
* 验证运行结束后清理运行态与恢复令牌索引。
*/
@Test
public void removeShouldClearRuntimeAndResumeTokens() {
AgentRunRegistry registry = new AgentRunRegistry();
registry.register(context("request-3", "session-3", "user-1", new CapturingRuntime()));
registry.registerResumeToken("request-3", "token-3");
registry.remove("request-3");
Assert.assertThrows(BusinessException.class, () -> registry.approve(null, "token-3", "user-1"));
}
/**
* 验证运行审批只能由运行发起人处理。
*/
@Test
public void approveShouldRejectDifferentOwner() {
AgentRunRegistry registry = new AgentRunRegistry();
CapturingRuntime runtime = new CapturingRuntime();
registry.register(context("request-4", "session-4", "user-1", runtime));
registry.registerResumeToken("request-4", "token-4");
Assert.assertThrows(BusinessException.class, () -> registry.approve("request-4", "token-4", "user-2"));
Assert.assertNull(runtime.resumeRequest.get());
registry.approve("request-4", "token-4", "user-1");
Assert.assertTrue(runtime.resumeRequest.get().isApproved());
}
/**
* 验证传入 requestId 时仍会校验恢复令牌归属,避免错误令牌打断挂起运行。
*/
@Test
public void approveShouldRejectTokenNotBelongingToRequest() {
AgentRunRegistry registry = new AgentRunRegistry();
CapturingRuntime runtime = new CapturingRuntime();
registry.register(context("request-5", "session-5", "user-1", runtime));
Assert.assertThrows(BusinessException.class,
() -> registry.approve("request-5", "wrong-token", "user-1"));
Assert.assertNull(runtime.resumeRequest.get());
}
/**
* 验证同一会话同一时刻只允许一个运行态。
*/
@Test
public void registerShouldRejectActiveRunInSameSession() {
AgentRunRegistry registry = new AgentRunRegistry();
registry.register(context("request-6", "session-6", "user-1", new CapturingRuntime()));
Assert.assertThrows(BusinessException.class,
() -> registry.register(context("request-7", "session-6", "user-1", new CapturingRuntime())));
}
/**
* 验证按会话清理会取消当前运行并释放同会话运行锁。
*/
@Test
public void cancelSessionShouldRemoveActiveRun() {
AgentRunRegistry registry = new AgentRunRegistry();
AgentRunRegistry.AgentRunContext context = context("request-8", "session-8", "user-1", new CapturingRuntime());
registry.register(context);
registry.cancelSession("session-8");
Assert.assertNull(registry.get("request-8"));
registry.register(context("request-9", "session-8", "user-1", new CapturingRuntime()));
Assert.assertNotNull(registry.get("request-9"));
}
/**
* 验证按会话清理时会校验运行归属。
*/
@Test
public void cancelSessionShouldRejectDifferentOwner() {
AgentRunRegistry registry = new AgentRunRegistry();
registry.register(context("request-10", "session-10", "user-1", new CapturingRuntime()));
Assert.assertThrows(BusinessException.class, () -> registry.cancelSession("session-10", "user-2"));
Assert.assertNotNull(registry.get("request-10"));
}
private AgentRunRegistry.AgentRunContext context(String requestId,
String sessionId,
String userId,
AgentRuntime runtime) {
return new AgentRunRegistry.AgentRunContext(
requestId,
sessionId,
runtime,
null,
new ChatRuntimeContext(),
new StringBuilder(),
new ChatAssistantAccumulator(),
new AtomicBoolean(false),
false,
new AgentRunRegistry.RunOwner("agent-1", sessionId, userId),
null,
event -> {
},
error -> {
},
() -> {
}
);
}
private static final class CapturingRuntime implements AgentRuntime {
private final AtomicReference<AgentResumeRequest> resumeRequest = new AtomicReference<>();
@Override
public void init(AgentInitRequest request) {
// 测试桩无需初始化。
}
@Override
public Flux<AgentRuntimeEvent> stream(com.easyagents.agent.runtime.message.AgentMessage userMessage) {
return Flux.empty();
}
@Override
public Flux<AgentRuntimeEvent> resume(AgentResumeRequest request) {
resumeRequest.set(request);
return Flux.empty();
}
}
}

View File

@@ -0,0 +1,660 @@
package tech.easyflow.agent.runtime;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
import com.easyagents.agent.runtime.message.AgentMessage;
import com.easyagents.agent.runtime.message.AgentMessageRole;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.runtime.lock.AgentRunLock;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.core.chat.protocol.ChatDomain;
import tech.easyflow.core.chat.protocol.ChatEnvelope;
import tech.easyflow.core.chat.protocol.ChatType;
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
import tech.easyflow.core.runtime.ChatAssistantAccumulator;
import tech.easyflow.core.runtime.ChatRuntimeContext;
import tech.easyflow.core.runtime.ChatRuntimeManager;
import tech.easyflow.core.runtime.ChatRuntimeMessage;
import java.lang.reflect.Method;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Agent 草稿试用与 HITL 事件映射测试。
*/
public class AgentRunServiceDraftAndHitlTest {
/**
* 验证工具 HITL 事件会映射为显式前端载荷。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void buildToolHitlPayloadShouldExposeStableFieldsWithoutPrompt() throws Exception {
AgentRunService service = new AgentRunService();
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED);
event.setToolCallId("call-1");
event.getPayload().put("resumeToken", "token-1");
event.getPayload().put("sessionId", "session-1");
event.getPayload().put("agentId", "agent-1");
event.getPayload().put("toolName", "search");
event.getPayload().put("toolType", "PLUGIN");
event.getPayload().put("approvalPrompt", "不应透出");
event.getPayload().put("toolInput", Map.of("keyword", "EasyFlow"));
event.getPayload().put("approvalMetadata", Map.of(
"risk", "low",
"prompt", "不应透出",
"toolType", "WORKFLOW"
));
AgentToolHitlPayload payload = invoke(service, "buildToolHitlPayload",
new Class<?>[]{String.class, AgentRuntimeEvent.class}, "request-1", event);
Assert.assertEquals("request-1", payload.getRequestId());
Assert.assertEquals("token-1", payload.getResumeToken());
Assert.assertEquals("session-1", payload.getSessionId());
Assert.assertEquals("agent-1", payload.getAgentId());
Assert.assertEquals("call-1", payload.getToolCallId());
Assert.assertEquals("search", payload.getToolName());
Assert.assertEquals("PLUGIN", payload.getToolType());
Assert.assertEquals("EasyFlow", payload.getInput().get("keyword"));
Assert.assertEquals("low", payload.getMetadata().get("risk"));
Assert.assertEquals("PLUGIN", payload.getMetadata().get("toolType"));
Assert.assertFalse(payload.getMetadata().containsKey("prompt"));
}
/**
* 验证工具事件发送给前端时会携带稳定工具调用 ID。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void buildToolEventPayloadShouldExposeRuntimeToolCallId() throws Exception {
AgentRunService service = new AgentRunService();
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_RESULT);
event.setToolCallId("call-runtime");
event.getPayload().put("toolName", "search");
event.getPayload().put("text", "ok");
Map<String, Object> payload = invoke(service, "buildToolEventPayload",
new Class<?>[]{AgentRuntimeEvent.class}, event);
Assert.assertEquals("call-runtime", payload.get("toolCallId"));
Assert.assertEquals("search", payload.get("toolName"));
Assert.assertEquals("ok", payload.get("text"));
}
/**
* 验证思考事件会优先读取 reasoning 字段。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void stringPayloadShouldExposeReasoningValue() throws Exception {
AgentRunService service = new AgentRunService();
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.REASONING_DELTA);
event.getPayload().put("reasoning", "思考中");
String reasoning = invoke(service, "stringPayload",
new Class<?>[]{AgentRuntimeEvent.class, String.class}, event, "reasoning");
String fallback = invoke(service, "firstText",
new Class<?>[]{String.class, String.class}, reasoning, "正文");
Assert.assertEquals("思考中", fallback);
}
/**
* 验证思考事件会作为增量发送给前端。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void handleRuntimeEventShouldSendReasoningDeltaAsThinkingEnvelope() throws Exception {
AgentRunService service = new AgentRunService();
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.REASONING_DELTA);
event.getPayload().put("reasoning", "思考增量");
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
event, "request-1", emitter, new StringBuilder(), new ChatAssistantAccumulator(),
chatContext(), new AtomicBoolean(false), false);
Assert.assertEquals(1, emitter.envelopes.size());
Assert.assertEquals(ChatDomain.LLM, emitter.envelopes.get(0).getDomain());
Assert.assertEquals(ChatType.THINKING, emitter.envelopes.get(0).getType());
@SuppressWarnings("unchecked")
Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
Assert.assertEquals("思考增量", payload.get("reasoning"));
Assert.assertEquals("思考增量", payload.get("delta"));
}
/**
* 验证正文事件会作为增量发送给前端并累计到持久化缓冲。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void handleRuntimeEventShouldSendMessageDeltaAndAccumulateAnswer() throws Exception {
AgentRunService service = new AgentRunService();
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
StringBuilder answer = new StringBuilder();
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.MESSAGE_DELTA);
event.getPayload().put("text", "正文增量");
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
event, "request-1", emitter, answer, new ChatAssistantAccumulator(),
chatContext(), new AtomicBoolean(false), false);
Assert.assertEquals("正文增量", answer.toString());
Assert.assertEquals(1, emitter.envelopes.size());
Assert.assertEquals(ChatDomain.LLM, emitter.envelopes.get(0).getDomain());
Assert.assertEquals(ChatType.MESSAGE, emitter.envelopes.get(0).getType());
@SuppressWarnings("unchecked")
Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
Assert.assertEquals("正文增量", payload.get("delta"));
}
/**
* 验证自动上下文压缩事件会作为业务状态发送给前端。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void handleRuntimeEventShouldSendMemoryCompressionAsStatusEnvelope() throws Exception {
AgentRunService service = new AgentRunService();
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.MEMORY_COMPRESSION_STARTED);
event.getPayload().put("statusKey", "memory-compression");
event.getPayload().put("status", "running");
event.getPayload().put("label", "正在整理上下文");
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
event, "request-1", emitter, new StringBuilder(), new ChatAssistantAccumulator(),
chatContext(), new AtomicBoolean(false), false);
Assert.assertEquals(1, emitter.envelopes.size());
Assert.assertEquals(ChatDomain.BUSINESS, emitter.envelopes.get(0).getDomain());
Assert.assertEquals(ChatType.STATUS, emitter.envelopes.get(0).getType());
@SuppressWarnings("unchecked")
Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
Assert.assertEquals("memory-compression", payload.get("statusKey"));
Assert.assertEquals("running", payload.get("status"));
Assert.assertEquals("正在整理上下文", payload.get("label"));
}
/**
* 验证完成事件不会再次发送正文消息,只用于最终收口。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void handleRuntimeEventShouldNotSendMessageEnvelopeOnCompleted() throws Exception {
AgentRunService service = new AgentRunService();
setField(service, "agentRunRegistry", new AgentRunRegistry());
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
StringBuilder answer = new StringBuilder("流式正文");
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.COMPLETED);
event.getPayload().put("text", "最终正文");
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
event, "request-1", emitter, answer, new ChatAssistantAccumulator(),
chatContext(), new AtomicBoolean(false), false);
Assert.assertEquals("最终正文", answer.toString());
Assert.assertTrue(emitter.envelopes.stream().noneMatch(envelope ->
envelope.getDomain() == ChatDomain.LLM && envelope.getType() == ChatType.MESSAGE));
Assert.assertTrue(emitter.envelopes.stream().anyMatch(envelope ->
envelope.getDomain() == ChatDomain.SYSTEM && envelope.getType() == ChatType.DONE));
}
/**
* 验证挂起事件后自然完成不会关闭 SSE 或清理运行态。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void finishIfNeededShouldKeepRuntimeWhenSuspended() throws Exception {
AgentRunService service = new AgentRunService();
AgentRunRegistry registry = new AgentRunRegistry();
setField(service, "agentRunRegistry", registry);
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
AtomicBoolean finished = new AtomicBoolean(false);
AgentRunRegistry.AgentRunContext runContext = new AgentRunRegistry.AgentRunContext(
"request-suspended",
"session-suspended",
new NoopRuntime(),
emitter,
chatContext(),
new StringBuilder(),
new ChatAssistantAccumulator(),
finished,
false,
new AgentRunRegistry.RunOwner("agent-1", "session-suspended", "user-1"),
null,
event -> {
},
error -> {
},
() -> {
}
);
registry.register(runContext);
runContext.markSuspended();
invoke(service, "finishIfNeeded",
new Class<?>[]{String.class, ChatSseEmitter.class, ChatRuntimeContext.class, StringBuilder.class,
ChatAssistantAccumulator.class, AtomicBoolean.class, boolean.class},
"request-suspended", emitter, chatContext(), new StringBuilder(),
new ChatAssistantAccumulator(), finished, false);
Assert.assertFalse(finished.get());
Assert.assertNotNull(registry.get("request-suspended"));
Assert.assertTrue(emitter.envelopes.isEmpty());
}
/**
* 验证取消事件作为业务状态收口,不按系统错误发送。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void handleRuntimeEventShouldSendCancelledAsStatusAndDone() throws Exception {
AgentRunService service = new AgentRunService();
setField(service, "agentRunRegistry", new AgentRunRegistry());
RecordingChatRuntimeManager chatRuntimeManager = new RecordingChatRuntimeManager();
setField(service, "chatRuntimeManager", chatRuntimeManager);
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
StringBuilder answer = new StringBuilder("取消前正文");
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.CANCELLED);
event.getPayload().put("reason", "用户拒绝执行");
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
event, "request-1", emitter, answer, new ChatAssistantAccumulator(),
chatContext(), new AtomicBoolean(false), true);
Assert.assertEquals(2, emitter.envelopes.size());
Assert.assertEquals(ChatDomain.BUSINESS, emitter.envelopes.get(0).getDomain());
Assert.assertEquals(ChatType.STATUS, emitter.envelopes.get(0).getType());
@SuppressWarnings("unchecked")
Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
Assert.assertEquals("agent-cancelled", payload.get("statusKey"));
Assert.assertEquals("cancelled", payload.get("status"));
Assert.assertEquals("用户拒绝执行", payload.get("message"));
Assert.assertEquals(ChatDomain.SYSTEM, emitter.envelopes.get(1).getDomain());
Assert.assertEquals(ChatType.DONE, emitter.envelopes.get(1).getType());
Assert.assertEquals(1, chatRuntimeManager.recordAssistantCompletedCount);
Assert.assertEquals("取消前正文", chatRuntimeManager.lastAssistantMessage.getContentText());
Assert.assertEquals(1, chatRuntimeManager.recordFailureCount);
}
/**
* 验证最终知识库引用会保留命中分片原文。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void buildKnowledgeCitationPayloadShouldExposeChunkContent() throws Exception {
AgentRunService service = new AgentRunService();
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.COMPLETED);
AgentMessage message = AgentMessage.text(AgentMessageRole.ASSISTANT, "answer");
AgentKnowledgeReference reference = new AgentKnowledgeReference();
reference.setKnowledgeId("kb-1");
reference.setKnowledgeName("学生事务 FAQ");
reference.setDocumentId("faq-1");
reference.setDocumentName("faq-1.md");
reference.setChunkId("chunk-1");
reference.setChunkContent("暑假安排原文");
reference.setScore(0.91D);
reference.getMetadata().put("knowledgeType", "FAQ");
reference.getMetadata().put("faqCollection", true);
message.setKnowledgeReferences(List.of(reference));
event.setMessage(message);
List<Map<String, Object>> payload = invoke(service, "buildKnowledgeCitationPayload",
new Class<?>[]{AgentRuntimeEvent.class}, event);
Assert.assertEquals(1, payload.size());
Assert.assertEquals("学生事务 FAQ", payload.get(0).get("knowledgeName"));
Assert.assertEquals("暑假安排原文", payload.get(0).get("chunkContent"));
Assert.assertEquals("FAQ", payload.get(0).get("knowledgeType"));
Assert.assertEquals(Boolean.TRUE, payload.get(0).get("faqCollection"));
}
/**
* 验证未保存草稿会生成临时 Agent ID并把绑定指向该运行 ID。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void buildDraftAgentShouldGenerateRuntimeIdForUnsavedAgent() throws Exception {
AgentRunService service = new AgentRunService();
AgentDraftChatRequest request = new AgentDraftChatRequest();
Agent agent = new Agent();
agent.setModelId(BigInteger.valueOf(10));
request.setAgent(agent);
AgentToolBinding toolBinding = new AgentToolBinding();
toolBinding.setToolType("PLUGIN");
toolBinding.setTargetId(BigInteger.valueOf(20));
toolBinding.setResourceSnapshot(Map.of("name", "client-forged"));
toolBinding.setResourceSummary(Map.of("name", "client-forged"));
request.setToolBindings(List.of(toolBinding));
AgentKnowledgeBinding knowledgeBinding = new AgentKnowledgeBinding();
knowledgeBinding.setKnowledgeId(BigInteger.valueOf(30));
knowledgeBinding.setResourceSnapshot(Map.of("title", "client-forged"));
knowledgeBinding.setResourceSummary(Map.of("title", "client-forged"));
request.setKnowledgeBindings(List.of(knowledgeBinding));
LoginAccount account = new LoginAccount();
account.setId(BigInteger.ONE);
account.setTenantId(BigInteger.valueOf(2));
account.setDeptId(BigInteger.valueOf(3));
Agent draftAgent = invoke(service, "buildDraftAgent",
new Class<?>[]{AgentDraftChatRequest.class, LoginAccount.class}, request, account);
Assert.assertNotNull(draftAgent.getId());
Assert.assertEquals(BigInteger.valueOf(2), draftAgent.getTenantId());
Assert.assertEquals(draftAgent.getId(), draftAgent.getToolBindings().get(0).getAgentId());
Assert.assertEquals(draftAgent.getId(), draftAgent.getKnowledgeBindings().get(0).getAgentId());
Assert.assertTrue(draftAgent.getToolBindings().get(0).getEnabled());
Assert.assertTrue(draftAgent.getKnowledgeBindings().get(0).getEnabled());
Assert.assertTrue(draftAgent.getToolBindings().get(0).getResourceSnapshot().isEmpty());
Assert.assertTrue(draftAgent.getToolBindings().get(0).getResourceSummary().isEmpty());
Assert.assertTrue(draftAgent.getKnowledgeBindings().get(0).getResourceSnapshot().isEmpty());
Assert.assertTrue(draftAgent.getKnowledgeBindings().get(0).getResourceSummary().isEmpty());
}
/**
* 验证正式聊天在获取运行锁失败时不会提前写入用户消息。
*
* <p>运行锁是 AgentScope session 与 chatlog 的一致性入口。若同会话并发请求抢锁失败,
* 必须在 prepareSession 和 recordUserMessage 之前失败,避免 chatlog 出现没有真实运行的用户消息。</p>
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void runShouldAcquireLockBeforePersistingUserMessage() throws Exception {
AgentRunService service = new AgentRunService();
RecordingChatRuntimeManager chatRuntimeManager = new RecordingChatRuntimeManager();
setField(service, "chatRuntimeManager", chatRuntimeManager);
setField(service, "agentRunLock", new RejectingAgentRunLock());
Agent agent = new Agent();
agent.setId(BigInteger.valueOf(100));
ChatRuntimeContext context = chatContext();
Exception thrown = Assert.assertThrows(Exception.class, () -> invoke(service, "run",
new Class<?>[]{Agent.class, String.class, String.class, String.class, String.class,
String.class, ChatRuntimeContext.class, boolean.class},
agent, "你好", "request-lock", "trace-lock", "session-lock", "AGENT", context, true));
Assert.assertTrue(rootCause(thrown) instanceof BusinessException);
Assert.assertEquals(0, chatRuntimeManager.prepareSessionCount);
Assert.assertEquals(0, chatRuntimeManager.recordUserMessageCount);
}
/**
* 验证正式聊天会在会话准备完成后向前端返回真实会话 ID。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void sendSessionCreatedShouldExposeSessionId() throws Exception {
AgentRunService service = new AgentRunService();
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
Boolean sent = invoke(service, "sendSessionCreated",
new Class<?>[]{ChatSseEmitter.class, BigInteger.class}, emitter, BigInteger.valueOf(123));
Assert.assertTrue(sent);
Assert.assertEquals(1, emitter.envelopes.size());
Assert.assertEquals(ChatDomain.SYSTEM, emitter.envelopes.get(0).getDomain());
Assert.assertEquals(ChatType.SESSION_CREATED, emitter.envelopes.get(0).getType());
@SuppressWarnings("unchecked")
Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
Assert.assertEquals("123", payload.get("sessionId"));
}
/**
* 验证正式聊天只有新会话首轮会自动设置默认标题。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void applyFormalSessionTitleShouldOnlyUseFirstPromptForNewSession() throws Exception {
AgentRunService service = new AgentRunService();
ChatRuntimeContext newContext = chatContext();
ChatRuntimeContext existingContext = chatContext();
ChatSessionSummary existingSession = new ChatSessionSummary();
existingSession.setId(BigInteger.valueOf(123));
existingSession.setTitle("用户改过的标题");
existingSession.setMessageCount(2);
invoke(service, "applyFormalSessionTitle",
new Class<?>[]{ChatRuntimeContext.class, String.class, ChatSessionSummary.class},
newContext, "第一句话", null);
invoke(service, "applyFormalSessionTitle",
new Class<?>[]{ChatRuntimeContext.class, String.class, ChatSessionSummary.class},
existingContext, "后续消息", existingSession);
Assert.assertEquals("第一句话", newContext.getSessionTitle());
Assert.assertNull(existingContext.getSessionTitle());
}
/**
* 验证正式聊天 SSE 断开后会取消运行并保存断开前已输出的 assistant 内容。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void handleRuntimeEventShouldCancelAndRecordFailureWhenSseDisconnected() throws Exception {
AgentRunService service = new AgentRunService();
AgentRunRegistry registry = new AgentRunRegistry();
RecordingChatRuntimeManager chatRuntimeManager = new RecordingChatRuntimeManager();
setField(service, "agentRunRegistry", registry);
setField(service, "chatRuntimeManager", chatRuntimeManager);
AtomicBoolean finished = new AtomicBoolean(false);
ChatRuntimeContext context = chatContext();
AgentRunRegistry.AgentRunContext runContext = new AgentRunRegistry.AgentRunContext(
"request-disconnected",
"session-disconnected",
new NoopRuntime(),
new FailingChatSseEmitter(),
context,
new StringBuilder(),
new ChatAssistantAccumulator(),
finished,
true,
new AgentRunRegistry.RunOwner("agent-1", "session-disconnected", "user-1"),
null,
ignored -> {
},
ignored -> {
},
() -> {
}
);
registry.register(runContext);
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.MESSAGE_DELTA);
event.getPayload().put("text", "断连前正文");
StringBuilder answer = new StringBuilder();
ChatAssistantAccumulator assistantAccumulator = new ChatAssistantAccumulator();
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
event, "request-disconnected", new FailingChatSseEmitter(), answer,
assistantAccumulator, context, finished, true);
Assert.assertTrue(finished.get());
Assert.assertNull(registry.get("request-disconnected"));
Assert.assertEquals(1, chatRuntimeManager.recordAssistantCompletedCount);
Assert.assertEquals("断连前正文", chatRuntimeManager.lastAssistantMessage.getContentText());
Assert.assertEquals(1, chatRuntimeManager.recordFailureCount);
Assert.assertEquals(0, chatRuntimeManager.recordCompletedCount);
}
@SuppressWarnings("unchecked")
private <T> T invoke(Object target, String methodName, Class<?>[] parameterTypes, Object... args) throws Exception {
Method method = target.getClass().getDeclaredMethod(methodName, parameterTypes);
method.setAccessible(true);
return (T) method.invoke(target, args);
}
private void setField(Object target, String fieldName, Object value) throws Exception {
java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
private Class<?>[] runtimeEventParameterTypes() {
return new Class<?>[]{AgentRuntimeEvent.class, String.class, ChatSseEmitter.class, StringBuilder.class,
ChatAssistantAccumulator.class,
ChatRuntimeContext.class, AtomicBoolean.class, boolean.class};
}
private ChatRuntimeContext chatContext() {
ChatRuntimeContext context = new ChatRuntimeContext();
context.setAssistantId(BigInteger.valueOf(100));
context.setAssistantName("Agent");
context.setUserId(BigInteger.valueOf(101));
context.setUserName("用户");
return context;
}
private Throwable rootCause(Throwable throwable) {
Throwable current = throwable;
while (current.getCause() != null) {
current = current.getCause();
}
return current;
}
/**
* 记录发送内容的 SSE 测试桩。
*/
private static class RecordingChatSseEmitter extends ChatSseEmitter {
private final List<ChatEnvelope<?>> envelopes = new java.util.ArrayList<>();
@Override
public boolean send(ChatEnvelope<?> envelope) {
envelopes.add(envelope);
return true;
}
@Override
public boolean sendDone(ChatEnvelope<?> envelope) {
envelopes.add(envelope);
return true;
}
}
private static class FailingChatSseEmitter extends ChatSseEmitter {
@Override
public boolean send(ChatEnvelope<?> envelope) {
return false;
}
@Override
public boolean sendDone(ChatEnvelope<?> envelope) {
return false;
}
}
private static class NoopRuntime implements com.easyagents.agent.runtime.AgentRuntime {
@Override
public void init(com.easyagents.agent.runtime.AgentInitRequest request) {
// 测试桩无需初始化。
}
@Override
public reactor.core.publisher.Flux<AgentRuntimeEvent> stream(AgentMessage userMessage) {
return reactor.core.publisher.Flux.empty();
}
@Override
public reactor.core.publisher.Flux<AgentRuntimeEvent> resume(com.easyagents.agent.runtime.AgentResumeRequest request) {
return reactor.core.publisher.Flux.empty();
}
}
/**
* 记录 chatlog 写入动作的测试桩。
*/
private static class RecordingChatRuntimeManager implements ChatRuntimeManager {
private int prepareSessionCount;
private int recordUserMessageCount;
private int recordAssistantCompletedCount;
private int recordFailureCount;
private int recordCompletedCount;
private ChatRuntimeMessage lastAssistantMessage;
@Override
public void prepareSession(ChatRuntimeContext context) {
prepareSessionCount++;
}
@Override
public void recordUserMessage(ChatRuntimeContext context, ChatRuntimeMessage message) {
recordUserMessageCount++;
}
@Override
public void recordAssistantDelta(ChatRuntimeContext context, ChatRuntimeMessage message) {
// 测试桩无需记录。
}
@Override
public void recordAssistantCompleted(ChatRuntimeContext context, ChatRuntimeMessage message) {
recordAssistantCompletedCount++;
lastAssistantMessage = message;
}
@Override
public void recordFailure(ChatRuntimeContext context, Throwable throwable) {
recordFailureCount++;
}
@Override
public void recordCompleted(ChatRuntimeContext context) {
recordCompletedCount++;
}
@Override
public List<ChatRuntimeMessage> loadMessages(ChatRuntimeContext context, int limit) {
return List.of();
}
}
/**
* 模拟同会话运行锁已被占用的测试桩。
*/
private static class RejectingAgentRunLock implements AgentRunLock {
@Override
public Handle acquire(BigInteger agentId, String sessionId) {
throw new BusinessException("当前 Agent 会话已有运行中的请求,请稍后再试");
}
}
}