feat: 完善多模型配置与运行切换

将模型连接统一持久化管理,并支持 Agent 运行中切换模型以及中断后选择模型继续。补充配置校验、事务与前后端交互测试。
This commit is contained in:
Zhu Junhao
2026-09-03 10:46:25 +08:00
parent e968a8ddc1
commit 988c0fe555
36 changed files with 1715 additions and 190 deletions

View File

@@ -1,14 +1,62 @@
package tech.easyflow.manuagent.agent;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.agentscope.core.agui.adapter.AguiAgentAdapter;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import tech.easyflow.manuagent.project.ProjectFileService;
import tech.easyflow.manuagent.project.ProjectService;
import tech.easyflow.manuagent.skill.SkillService;
/**
* 验证 Agent 事件持久化的精简规则。
*/
class AgentExecutionServiceTest {
/**
* Run 在创建时已经固化模型配置 ID执行和模型重连都必须沿用该 ID
* 不能在工厂内部重新读取可能已经变化的全局默认模型。
*/
@Test
void shouldCreateAgentWithModelBoundToRun() {
UUID projectId = UUID.randomUUID();
UUID runId = UUID.randomUUID();
UUID modelId = UUID.randomUUID();
AgentFactory factory = mock(AgentFactory.class);
AgentFactory.AgentHandle handle = mock(AgentFactory.AgentHandle.class);
AguiAgentAdapter adapter = mock(AguiAgentAdapter.class);
SkillService skillService = mock(SkillService.class);
when(handle.adapter()).thenReturn(adapter);
when(adapter.run(any())).thenReturn(Flux.empty());
when(skillService.enabledNames()).thenReturn(new String[] {"document"});
when(factory.create(eq(projectId), eq(modelId), any(String[].class))).thenReturn(handle);
AgentExecutionService service = new AgentExecutionService(
new ObjectMapper(),
factory,
mock(AgentEventService.class),
mock(ProjectFileService.class),
skillService);
ProjectService.ProjectView project = mock(ProjectService.ProjectView.class);
when(project.id()).thenReturn(projectId);
when(project.threadId()).thenReturn("project-" + projectId);
AgentRunService.RunView run = new AgentRunService.RunView(
runId, projectId, modelId, "INITIAL", "RUNNING", null, null, null, null);
service.execute(project, run, "执行测试", Mono.never(), () -> { }, () -> false);
verify(factory).create(eq(projectId), eq(modelId), any(String[].class));
}
/**
* 验证文档视觉结果保留图片路径元数据并丢弃 Base64 正文。
*/

View File

@@ -1,20 +1,87 @@
package tech.easyflow.manuagent.agent;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.agentscope.core.model.transport.HttpTransportException;
import io.agentscope.core.skill.AgentSkill;
import java.nio.file.Path;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import org.junit.jupiter.api.Test;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
import tech.easyflow.manuagent.artifact.ArtifactService;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
import tech.easyflow.manuagent.project.ProjectFileService;
import tech.easyflow.manuagent.project.ProjectService;
/**
* 验证 Agent Run 的模型重连边界。
*/
class AgentRunServiceTest {
/**
* 运行中模型配置被编辑后,用户应能选择同一模型 ID 创建新的恢复 Run
* 使新 Agent 客户端重新读取数据库中的最新地址、模型标识和密钥。
*/
@Test
void shouldRestartRunningTaskWhenSameModelConfigurationWasUpdated() {
UUID projectId = UUID.randomUUID();
UUID modelId = UUID.randomUUID();
UUID currentRunId = UUID.randomUUID();
UUID replacementRunId = UUID.randomUUID();
OffsetDateTime now = OffsetDateTime.now();
ProjectService.ProjectView project = new ProjectService.ProjectView(
projectId, "测试企业", "测试项目", "thread-1", "ADVANCED", "MATERIAL_CHECK", 0L, now, now);
AgentRunService.RunView current = new AgentRunService.RunView(
currentRunId, projectId, modelId, "INITIAL", "RUNNING", null, null, now, null);
AgentRunService.RunView replacement = new AgentRunService.RunView(
replacementRunId, projectId, modelId, "RESUME", "RUNNING", null, null, now, null);
AgentRunMapper runMapper = mock(AgentRunMapper.class);
AgentRunStore runStore = mock(AgentRunStore.class);
ProjectService projectService = mock(ProjectService.class);
UserService userService = mock(UserService.class);
when(projectService.require(projectId)).thenReturn(project);
when(userService.requireUserId("admin")).thenReturn(UUID.randomUUID());
when(runStore.latest(projectId)).thenReturn(current);
when(runStore.interruptedPhase(current, project)).thenReturn("MATERIAL_CHECK");
when(runMapper.interruptRunning(currentRunId)).thenReturn(1);
when(runStore.create(projectId, "RESUME", currentRunId, modelId)).thenReturn(replacement);
AgentRunService service = new AgentRunService(
runMapper,
new ObjectMapper(),
mock(AgentExecutionService.class),
mock(AgentOutputService.class),
runStore,
mock(AgentEventService.class),
projectService,
mock(ProjectFileService.class),
userService,
mock(ArtifactService.class),
mock(ExecutorService.class),
mock(TransactionTemplate.class));
// 服务会注册“提交后取消旧流并启动新流”的回调;测试只验证注册前的事务内状态转换。
TransactionSynchronizationManager.initSynchronization();
try {
assertThat(service.switchModel(projectId, modelId, () -> "admin")).isEqualTo(replacement);
verify(runMapper).interruptRunning(currentRunId);
verify(runStore).create(projectId, "RESUME", currentRunId, modelId);
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
/**
* 网络故障和服务端错误允许重连,参数错误保持原始失败。
*/

View File

@@ -14,6 +14,7 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import tech.easyflow.manuagent.entity.AgentRunEntity;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.entity.ModelConfigEntity;
import tech.easyflow.manuagent.mapper.AgentEventMapper;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
@@ -49,10 +50,11 @@ class AgentRunStoreQueryTest {
}
/**
* 默认模型缺失属于数据库配置异常,不应在 ORM 迁移中新增 409 业务错误
* 数据库允许在首次启动时没有模型,因此启动 Run 时应返回可操作的业务错误
* 不能把正常的“尚未配置”状态暴露为服务端技术异常。
*/
@Test
void shouldKeepMissingDefaultModelAsUnexpectedTechnicalFailure() {
void shouldReportMissingDefaultModelAsConfigurationConflict() {
AgentRunMapper runMapper = mock(AgentRunMapper.class);
when(runMapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(0L);
AgentRunStore store = new AgentRunStore(
@@ -62,8 +64,49 @@ class AgentRunStoreQueryTest {
new ObjectMapper());
assertThatThrownBy(() -> store.create(UUID.randomUUID(), "INITIAL", null))
.isInstanceOf(IllegalStateException.class)
.isNotInstanceOf(ApiException.class);
.isInstanceOfSatisfying(ApiException.class, exception -> {
assertThat(exception.status().value()).isEqualTo(409);
assertThat(exception.code()).isEqualTo("MODEL_NOT_CONFIGURED");
});
}
/**
* 用户恢复任务时可以显式选择替代模型;新 Run 必须保存该模型 ID
* 不能再次回退到随后可能变化的全局默认模型。
*/
@Test
void shouldCreateRunWithExplicitEnabledModel() {
UUID projectId = UUID.randomUUID();
UUID modelId = UUID.randomUUID();
AgentRunMapper runMapper = mock(AgentRunMapper.class);
ModelConfigMapper modelMapper = mock(ModelConfigMapper.class);
when(runMapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(0L);
ModelConfigEntity selectedModel = new ModelConfigEntity();
selectedModel.setId(modelId);
selectedModel.setEnabled(true);
when(modelMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(selectedModel);
AgentRunEntity storedRun = new AgentRunEntity();
storedRun.setId(UUID.randomUUID());
storedRun.setProjectId(projectId);
storedRun.setModelConfigId(modelId);
storedRun.setTriggerType("RESUME");
storedRun.setStatus("RUNNING");
when(runMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(storedRun);
AgentRunStore store = new AgentRunStore(
runMapper,
mock(AgentEventMapper.class),
modelMapper,
new ObjectMapper());
AgentRunService.RunView run = store.create(projectId, "RESUME", null, modelId);
ArgumentCaptor<AgentRunEntity> entityCaptor = ArgumentCaptor.forClass(AgentRunEntity.class);
verify(runMapper).insertSelective(entityCaptor.capture());
assertThat(entityCaptor.getValue().getModelConfigId()).isEqualTo(modelId);
assertThat(run.modelConfigId()).isEqualTo(modelId);
}
/**