feat: 完善 Agent 标准交互与安全运行时
- 接入 AG-UI 运行投影、Turn 时间线和审批隔离 - 增加 Agent Skill 冻结绑定与运行时消费闭环 - 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
package tech.easyflow.ai.easyagentsflow.repository;
|
||||
|
||||
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||
import com.easyagents.flow.core.node.ConfirmNode;
|
||||
import com.easyagents.flow.core.parser.ChainParser;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.node.WorkflowNode;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Agent Workflow 冻结快照兼容性测试。
|
||||
*/
|
||||
public class AgentWorkflowSnapshotFactoryTest {
|
||||
|
||||
/**
|
||||
* 验证快照只保留 Runtime 白名单字段并使用准备后的内容。
|
||||
*/
|
||||
@Test
|
||||
public void shouldBuildWhitelistedSnapshotFromPreparedContent() {
|
||||
ChainDefinition definition = new ChainDefinition();
|
||||
Workflow workflow = workflow();
|
||||
AgentWorkflowSnapshotFactory factory = factory(definition);
|
||||
|
||||
Map<String, Object> snapshot = factory.snapshot(workflow);
|
||||
|
||||
Assert.assertEquals(workflow.getId(), snapshot.get("id"));
|
||||
Assert.assertEquals("prepared-content", snapshot.get("content"));
|
||||
Assert.assertEquals(6, snapshot.size());
|
||||
Assert.assertFalse(snapshot.containsKey("tenantId"));
|
||||
Assert.assertFalse(snapshot.containsKey("publishedSnapshotJson"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Skill 或 Agent 发布投影会提前拒绝子工作流节点。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRejectSubWorkflowNode() {
|
||||
ChainDefinition definition = new ChainDefinition();
|
||||
definition.addNode(new WorkflowNode());
|
||||
|
||||
assertConflict(factory(definition), "子工作流节点");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Skill 或 Agent 发布投影会提前拒绝内部确认节点。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRejectConfirmNode() {
|
||||
ChainDefinition definition = new ChainDefinition();
|
||||
definition.addNode(new ConfirmNode());
|
||||
|
||||
assertConflict(factory(definition), "内部确认节点");
|
||||
}
|
||||
|
||||
private AgentWorkflowSnapshotFactory factory(ChainDefinition definition) {
|
||||
ChainParser parser = mock(ChainParser.class);
|
||||
WorkflowDatacenterContentService contentService = mock(WorkflowDatacenterContentService.class);
|
||||
when(contentService.prepareContent("raw-content")).thenReturn("prepared-content");
|
||||
when(parser.parse("prepared-content")).thenReturn(definition);
|
||||
return new AgentWorkflowSnapshotFactory(parser, contentService);
|
||||
}
|
||||
|
||||
private Workflow workflow() {
|
||||
Workflow workflow = new Workflow();
|
||||
workflow.setId(BigInteger.ONE);
|
||||
workflow.setTitle("合同审查");
|
||||
workflow.setDescription("审查合同风险");
|
||||
workflow.setEnglishName("contract_review");
|
||||
workflow.setRevision(3);
|
||||
workflow.setContent("raw-content");
|
||||
workflow.setTenantId(BigInteger.TEN);
|
||||
workflow.setPublishedSnapshotJson(Map.of("secret", "hidden"));
|
||||
return workflow;
|
||||
}
|
||||
|
||||
private void assertConflict(AgentWorkflowSnapshotFactory factory, String message) {
|
||||
try {
|
||||
factory.snapshot(workflow());
|
||||
Assert.fail("Expected incompatible workflow to be rejected");
|
||||
} catch (BusinessException exception) {
|
||||
Assert.assertEquals(409, exception.getHttpStatus());
|
||||
Assert.assertTrue(exception.getMessage(), exception.getMessage().contains(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.plugin.workflow.binding.WorkflowPluginBindingService;
|
||||
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
|
||||
import tech.easyflow.ai.service.ResourceOfflineImpactService;
|
||||
import tech.easyflow.ai.service.AgentResourceReferenceService;
|
||||
import tech.easyflow.ai.service.WorkflowScheduleReferenceProvider;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.ai.vo.OfflineImpactBindingVo;
|
||||
@@ -50,6 +51,7 @@ public class WorkflowApprovalSubjectHandlerTest {
|
||||
offlineImpactService,
|
||||
mock(WorkflowPluginBindingService.class),
|
||||
mock(WorkflowPluginSnapshotResolver.class),
|
||||
mock(AgentResourceReferenceService.class),
|
||||
new ObjectMapper(),
|
||||
List.of(scheduleReferenceProvider)
|
||||
);
|
||||
@@ -87,6 +89,7 @@ public class WorkflowApprovalSubjectHandlerTest {
|
||||
offlineImpactService,
|
||||
mock(WorkflowPluginBindingService.class),
|
||||
mock(WorkflowPluginSnapshotResolver.class),
|
||||
mock(AgentResourceReferenceService.class),
|
||||
new ObjectMapper(),
|
||||
List.of(scheduleReferenceProvider)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package tech.easyflow.ai.security;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.entity.Mcp;
|
||||
import tech.easyflow.ai.entity.Plugin;
|
||||
import tech.easyflow.ai.mcp.McpConnectionSnapshotFactory;
|
||||
import tech.easyflow.ai.plugin.PluginConnectionSnapshotFactory;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 连接资源发布快照的凭据边界测试。
|
||||
*/
|
||||
public class ConnectionSnapshotFactoryTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* MCP 快照应保留拓扑和服务端输入引用,同时拒绝复制明文凭据。
|
||||
*/
|
||||
@Test
|
||||
public void mcpSnapshotShouldKeepReferencesAndRejectPlaintextCredentials() {
|
||||
McpConnectionSnapshotFactory factory = new McpConnectionSnapshotFactory(objectMapper);
|
||||
Mcp mcp = mcp("""
|
||||
{"mcpServers":{"demo":{"url":"https://mcp.example.test/api",
|
||||
"headers":{"Authorization":"${input:mcp.token}"},
|
||||
"queryParams":{"tenant":"${input:mcp.tenant}"}}}}
|
||||
""");
|
||||
|
||||
Map<String, Object> snapshot = factory.snapshot(mcp);
|
||||
|
||||
Assert.assertEquals(mcp.getId(), snapshot.get("id"));
|
||||
Assert.assertTrue(String.valueOf(snapshot.get("configJson")).contains("${input:mcp.token}"));
|
||||
Assert.assertNotNull(snapshot.get("configHash"));
|
||||
assertBusinessFailure(() -> factory.snapshot(mcp("""
|
||||
{"mcpServers":{"demo":{"url":"https://mcp.example.test/api",
|
||||
"headers":{"Authorization":"Bearer plaintext-secret"}}}}
|
||||
""")), "必须使用");
|
||||
assertBusinessFailure(() -> factory.snapshot(mcp("""
|
||||
{"mcpServers":{"demo":{"url":"https://mcp.example.test/api",
|
||||
"extension":{"nestedApiKey":"plaintext-secret"}}}}
|
||||
""")), "敏感配置");
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin 快照应只接受服务端输入引用形式的鉴权值和私有请求头。
|
||||
*/
|
||||
@Test
|
||||
public void pluginSnapshotShouldKeepReferencesAndRejectPlaintextCredentials() {
|
||||
PluginConnectionSnapshotFactory factory = new PluginConnectionSnapshotFactory(objectMapper);
|
||||
Plugin plugin = plugin("${input:plugin.token}",
|
||||
"[{\"label\":\"Authorization\",\"value\":\"${input:plugin.header}\"}]");
|
||||
|
||||
Map<String, Object> snapshot = factory.snapshot(plugin);
|
||||
|
||||
Assert.assertEquals("${input:plugin.token}", snapshot.get("tokenValue"));
|
||||
Assert.assertFalse(snapshot.containsKey("tenantId"));
|
||||
assertBusinessFailure(() -> factory.snapshot(plugin(
|
||||
"plaintext-secret",
|
||||
"[{\"label\":\"Authorization\",\"value\":\"${input:plugin.header}\"}]")),
|
||||
"鉴权值");
|
||||
assertBusinessFailure(() -> factory.snapshot(plugin(
|
||||
"${input:plugin.token}",
|
||||
"[{\"label\":\"X-Secret\",\"value\":\"plaintext-secret\"}]")),
|
||||
"请求头凭据");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试 MCP。
|
||||
*
|
||||
* @param configJson MCP 配置
|
||||
* @return MCP
|
||||
*/
|
||||
private Mcp mcp(String configJson) {
|
||||
Mcp mcp = new Mcp();
|
||||
mcp.setId(BigInteger.ONE);
|
||||
mcp.setTitle("测试 MCP");
|
||||
mcp.setTransportType("SSE");
|
||||
mcp.setConfigJson(configJson);
|
||||
return mcp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试 Plugin。
|
||||
*
|
||||
* @param tokenValue 鉴权值
|
||||
* @param headers 请求头 JSON
|
||||
* @return Plugin
|
||||
*/
|
||||
private Plugin plugin(String tokenValue, String headers) {
|
||||
Plugin plugin = new Plugin();
|
||||
plugin.setId(BigInteger.TWO);
|
||||
plugin.setName("测试插件");
|
||||
plugin.setBaseUrl("https://plugin.example.test/api");
|
||||
plugin.setAuthType("apiKey");
|
||||
plugin.setPosition("headers");
|
||||
plugin.setTokenKey("Authorization");
|
||||
plugin.setTokenValue(tokenValue);
|
||||
plugin.setHeaders(headers);
|
||||
plugin.setTenantId(99L);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言业务校验失败且消息可定位。
|
||||
*
|
||||
* @param action 待执行动作
|
||||
* @param messageFragment 消息片段
|
||||
*/
|
||||
private void assertBusinessFailure(Runnable action, String messageFragment) {
|
||||
try {
|
||||
action.run();
|
||||
Assert.fail("Expected credential validation failure");
|
||||
} catch (BusinessException exception) {
|
||||
Assert.assertTrue(exception.getMessage(), exception.getMessage().contains(messageFragment));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ public class ResourceOfflineImpactServiceImplTest {
|
||||
BigInteger workflowId = BigInteger.valueOf(10);
|
||||
OfflineImpactBindingVo binding = binding(BigInteger.ONE, "测试智能体");
|
||||
when(referenceService.listAgentsByWorkflowId(workflowId)).thenReturn(List.of(binding));
|
||||
when(referenceService.listSkillsByWorkflowId(workflowId)).thenReturn(Collections.emptyList());
|
||||
when(pluginDependencyService.listPluginsByWorkflowId(workflowId))
|
||||
.thenReturn(Collections.emptyList());
|
||||
ResourceOfflineImpactServiceImpl service = new ResourceOfflineImpactServiceImpl(
|
||||
@@ -50,11 +51,37 @@ public class ResourceOfflineImpactServiceImplTest {
|
||||
service.unbindWorkflowFromAgents(workflowId);
|
||||
|
||||
Assert.assertTrue(result.isHasAgentBindings());
|
||||
Assert.assertFalse(result.isCanProceed());
|
||||
Assert.assertEquals(List.of(binding), result.getAgentBindings());
|
||||
Assert.assertTrue(result.getMessage().contains("智能体"));
|
||||
verify(referenceService).unbindWorkflow(workflowId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Skill 引用会直接阻止工作流下线并返回可处理摘要。
|
||||
*/
|
||||
@Test
|
||||
public void shouldBlockWorkflowOfflineWhenSkillReferencesIt() {
|
||||
WorkflowService workflowService = mock(WorkflowService.class);
|
||||
DocumentCollectionService documentCollectionService = mock(DocumentCollectionService.class);
|
||||
WorkflowPluginDependencyService pluginDependencyService = mock(WorkflowPluginDependencyService.class);
|
||||
AgentResourceReferenceService referenceService = mock(AgentResourceReferenceService.class);
|
||||
BigInteger workflowId = BigInteger.valueOf(10);
|
||||
OfflineImpactBindingVo skill = binding(BigInteger.valueOf(3), "合同审查 Skill");
|
||||
when(referenceService.listAgentsByWorkflowId(workflowId)).thenReturn(Collections.emptyList());
|
||||
when(referenceService.listSkillsByWorkflowId(workflowId)).thenReturn(List.of(skill));
|
||||
when(pluginDependencyService.listPluginsByWorkflowId(workflowId)).thenReturn(Collections.emptyList());
|
||||
ResourceOfflineImpactServiceImpl service = new ResourceOfflineImpactServiceImpl(
|
||||
workflowService, documentCollectionService, pluginDependencyService, referenceService);
|
||||
|
||||
OfflineImpactCheckVo result = service.checkWorkflowImpact(workflowId);
|
||||
|
||||
Assert.assertFalse(result.isCanProceed());
|
||||
Assert.assertTrue(result.isHasSkillBindings());
|
||||
Assert.assertEquals(List.of(skill), result.getSkillBindings());
|
||||
Assert.assertTrue(result.getMessage().contains("Skill"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证知识库影响结果使用 Agent 绑定字段并委托 Agent 解绑。
|
||||
*/
|
||||
|
||||
@@ -91,4 +91,73 @@ public class ChatAssistantAccumulatorTest {
|
||||
Assert.assertEquals("mcp_123_search", toolCalls.get(0).get("name"));
|
||||
Assert.assertEquals("知识库 MCP - search", toolCalls.get(0).get("toolDisplayName"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 状态应按稳定键原位更新、剔除内部字段并持久化为可回放终态。
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void shouldPersistWhitelistedSkillInvocationTerminalState() {
|
||||
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
|
||||
accumulator.appendSkillInvocationStatus(Map.of(
|
||||
"statusKey", "skill-invocation:round-1:skill-1",
|
||||
"status", "RUNNING",
|
||||
"skillId", "skill-1",
|
||||
"skillDisplayName", "合同审查",
|
||||
"internalSnapshot", "must-not-leak"));
|
||||
accumulator.appendSkillInvocationStatus(Map.of(
|
||||
"statusKey", "skill-invocation:round-1:skill-1",
|
||||
"status", "SUCCESS",
|
||||
"skillId", "skill-1",
|
||||
"skillDisplayName", "合同审查"));
|
||||
|
||||
List<Map<String, Object>> statuses = (List<Map<String, Object>>) accumulator
|
||||
.buildPayload("完成")
|
||||
.get("skillInvocationStatuses");
|
||||
|
||||
Assert.assertEquals(1, statuses.size());
|
||||
Assert.assertEquals("SUCCESS", statuses.get(0).get("status"));
|
||||
Assert.assertFalse(statuses.get(0).containsKey("internalSnapshot"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式运行异常时仍在执行的 Skill 应收口为可恢复失败状态。
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void shouldFinalizePendingSkillInvocationAfterRunFailure() {
|
||||
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
|
||||
accumulator.appendSkillInvocationStatus(Map.of(
|
||||
"statusKey", "skill-invocation:request-1:skill-1",
|
||||
"status", "RUNNING",
|
||||
"skillId", "skill-1"));
|
||||
|
||||
accumulator.finalizePendingSkillInvocations("FAILED", "本轮运行失败");
|
||||
List<Map<String, Object>> statuses = (List<Map<String, Object>>) accumulator
|
||||
.buildPayload(null)
|
||||
.get("skillInvocationStatuses");
|
||||
|
||||
Assert.assertEquals("FAILED", statuses.get(0).get("status"));
|
||||
Assert.assertEquals("本轮运行失败", statuses.get(0).get("message"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 正常流结束但缺失终态事件时应落为未完成,避免历史页长期显示运行中。
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void shouldConvertDanglingRunningSkillInvocationToIncomplete() {
|
||||
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
|
||||
accumulator.appendSkillInvocationStatus(Map.of(
|
||||
"statusKey", "skill-invocation:round-1:skill-1",
|
||||
"status", "RUNNING",
|
||||
"skillId", "skill-1"));
|
||||
|
||||
List<Map<String, Object>> statuses = (List<Map<String, Object>>) accumulator
|
||||
.buildPayload(null)
|
||||
.get("skillInvocationStatuses");
|
||||
|
||||
Assert.assertEquals("INCOMPLETE", statuses.get(0).get("status"));
|
||||
Assert.assertEquals("技能调用未完成", statuses.get(0).get("message"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user