package com.zy.ai.service; import com.alibaba.fastjson.JSONObject; import com.baomidou.mybatisplus.core.conditions.AbstractWrapper; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.zy.ai.domain.autotune.AutoTuneApplyRequest; import com.zy.ai.domain.autotune.AutoTuneApplyResult; import com.zy.ai.domain.autotune.AutoTuneChangeCommand; import com.zy.ai.domain.autotune.AutoTuneSnapshot; import com.zy.ai.domain.autotune.AutoTuneJobStatus; import com.zy.ai.domain.autotune.AutoTuneTriggerType; import com.zy.ai.entity.AiAutoTuneChange; import com.zy.ai.entity.AiAutoTuneJob; import com.zy.ai.entity.AiPromptTemplate; import com.zy.ai.entity.ChatCompletionRequest; import com.zy.ai.entity.ChatCompletionResponse; import com.zy.ai.enums.AiPromptScene; import com.zy.ai.mcp.service.SpringAiMcpToolManager; import com.zy.ai.mcp.tool.AutoTuneMcpTools; import com.zy.ai.service.impl.AutoTuneAgentServiceImpl; import com.zy.ai.service.impl.AutoTuneControlModeServiceImpl; import com.zy.ai.service.impl.AutoTuneCoordinatorServiceImpl; import com.zy.asrs.service.WrkMastService; import com.zy.common.utils.RedisUtil; import com.zy.core.enums.RedisKeyType; import com.zy.system.entity.OperateLog; import com.zy.system.service.ConfigService; import com.zy.system.service.OperateLogService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicLong; import java.util.function.LongSupplier; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class AutoTuneCoordinatorServiceImplTest { private AutoTuneMcpTools tools; @Mock private AutoTuneSnapshotService autoTuneSnapshotService; @Mock private AutoTuneApplyService autoTuneApplyService; @Mock private AiAutoTuneJobService aiAutoTuneJobService; @Mock private AiAutoTuneChangeService aiAutoTuneChangeService; @Mock private AiAutoTuneMcpCallService aiAutoTuneMcpCallService; @Mock private LlmChatService llmChatService; @Mock private SpringAiMcpToolManager mcpToolManager; @Mock private AiPromptTemplateService aiPromptTemplateService; @Mock private ConfigService configService; private AutoTuneControlModeService autoTuneControlModeService; @Mock private WrkMastService wrkMastService; @Mock private AutoTuneAgentService autoTuneAgentService; @Mock private RedisUtil redisUtil; @Mock private OperateLogService operateLogService; @BeforeEach void setUp() { autoTuneControlModeService = new AutoTuneControlModeServiceImpl(configService); tools = new AutoTuneMcpTools( autoTuneSnapshotService, autoTuneApplyService, aiAutoTuneJobService, aiAutoTuneChangeService, aiAutoTuneMcpCallService); lenient().when(configService.getConfigValue("aiAutoTuneEnabled", "N")).thenReturn("Y"); lenient().when(configService.getConfigValue("aiAutoTuneAnalysisOnly", "Y")).thenReturn("N"); } @Test void snapshotToolDelegatesToSnapshotService() { AutoTuneSnapshot snapshot = new AutoTuneSnapshot(); when(autoTuneSnapshotService.buildSnapshot()).thenReturn(snapshot); AutoTuneSnapshot result = tools.getAutoTuneSnapshot(); assertSame(snapshot, result); verify(autoTuneSnapshotService).buildSnapshot(); } @Test void recentJobsReturnsBoundedCompactSummariesWithChanges() { AiAutoTuneJob job = new AiAutoTuneJob(); job.setId(7L); job.setTriggerType("agent"); job.setStatus("success"); job.setSummary("applied"); job.setSuccessCount(1); job.setRejectCount(0); AiAutoTuneChange agentChange = new AiAutoTuneChange(); agentChange.setJobId(7L); agentChange.setTargetType("station"); agentChange.setTargetId("101"); agentChange.setTargetKey("outTaskLimit"); agentChange.setRequestedValue("3"); agentChange.setResultStatus("success"); AiAutoTuneChange applyChange = new AiAutoTuneChange(); applyChange.setJobId(70L); applyChange.setTargetType("sys_config"); applyChange.setTargetKey("conveyorStationTaskLimit"); applyChange.setRequestedValue("12"); applyChange.setResultStatus("success"); com.zy.ai.entity.AiAutoTuneMcpCall mcpCall = new com.zy.ai.entity.AiAutoTuneMcpCall(); mcpCall.setAgentJobId(7L); mcpCall.setCallSeq(1); mcpCall.setToolName("wcs_local_dispatch_apply_auto_tune_changes"); mcpCall.setStatus("success"); mcpCall.setApplyJobId(70L); when(aiAutoTuneJobService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(job)); when(aiAutoTuneMcpCallService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(mcpCall)); List auditChanges = new ArrayList<>(); auditChanges.add(agentChange); auditChanges.add(applyChange); when(aiAutoTuneChangeService.list(any(Wrapper.class))).thenReturn(auditChanges); List> result = tools.getRecentAutoTuneJobs(99); assertEquals(1, result.size()); assertEquals(7L, result.get(0).get("id")); assertFalse(result.get(0).containsKey("reasoningDigest")); assertEquals(1, result.get(0).get("mcpCallCount")); List changes = (List) result.get(0).get("changes"); assertEquals(2, changes.size()); assertEquals(7L, ((Map) changes.get(0)).get("jobId")); assertEquals(70L, ((Map) changes.get(1)).get("jobId")); ArgumentCaptor> wrapperCaptor = ArgumentCaptor.forClass(Wrapper.class); verify(aiAutoTuneJobService).list(wrapperCaptor.capture()); assertTrue(wrapperCaptor.getValue().getSqlSegment().contains("limit 20")); ArgumentCaptor> changeWrapperCaptor = ArgumentCaptor.forClass(Wrapper.class); verify(aiAutoTuneChangeService).list(changeWrapperCaptor.capture()); Wrapper changeWrapper = changeWrapperCaptor.getValue(); assertTrue(changeWrapper.getSqlSegment().contains("job_id IN")); List changeQueryParams = wrapperParamValues(changeWrapper); assertTrue(changeQueryParams.contains(7L)); assertTrue(changeQueryParams.contains(70L)); } @Test void recentJobsMarksRollbackMcpChangesAsRollback() { AiAutoTuneJob job = new AiAutoTuneJob(); job.setId(8L); job.setTriggerType("manual"); job.setStatus("success"); job.setSuccessCount(1); job.setRejectCount(0); AiAutoTuneChange rollbackChange = new AiAutoTuneChange(); rollbackChange.setJobId(80L); rollbackChange.setTargetType("sys_config"); rollbackChange.setTargetKey("conveyorStationTaskLimit"); rollbackChange.setResultStatus("success"); com.zy.ai.entity.AiAutoTuneMcpCall mcpCall = new com.zy.ai.entity.AiAutoTuneMcpCall(); mcpCall.setAgentJobId(8L); mcpCall.setCallSeq(1); mcpCall.setToolName("wcs_local_dispatch_revert_last_auto_tune_job"); mcpCall.setStatus("success"); mcpCall.setApplyJobId(80L); mcpCall.setSuccessCount(1); when(aiAutoTuneJobService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(job)); when(aiAutoTuneMcpCallService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(mcpCall)); when(aiAutoTuneChangeService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(rollbackChange)); List> result = tools.getRecentAutoTuneJobs(1); assertEquals("rollback", result.get(0).get("writeBehavior")); List changes = (List) result.get(0).get("changes"); assertEquals(1, changes.size()); assertEquals("rollback", ((Map) changes.get(0)).get("writeBehavior")); } @Test void applyToolDelegatesToApplyServiceWithDryRunAndChanges() { AutoTuneApplyResult expected = new AutoTuneApplyResult(); expected.setDryRun(true); expected.setSuccess(true); expected.setChanges(Collections.singletonList(applyResultChange("dry_run"))); when(autoTuneApplyService.apply(any(AutoTuneApplyRequest.class))).thenReturn(expected); AutoTuneChangeCommand command = new AutoTuneChangeCommand(); command.setTargetType("sys_config"); command.setTargetKey("conveyorStationTaskLimit"); command.setNewValue("12"); List changes = Collections.singletonList(command); AutoTuneApplyResult result = tools.applyAutoTuneChanges("reduce congestion", 10, "agent", true, null, changes); assertSame(expected, result); assertNotNull(result.getDryRunToken()); ArgumentCaptor captor = ArgumentCaptor.forClass(AutoTuneApplyRequest.class); verify(autoTuneApplyService).apply(captor.capture()); assertEquals("reduce congestion", captor.getValue().getReason()); assertEquals(10, captor.getValue().getAnalysisIntervalMinutes()); assertEquals("agent", captor.getValue().getTriggerType()); assertEquals(Boolean.TRUE, captor.getValue().getDryRun()); assertSame(changes, captor.getValue().getChanges()); } @Test void applyToolRejectsMissingDryRunBeforeServiceCall() { AutoTuneChangeCommand command = change("sys_config", null, "conveyorStationTaskLimit", "12"); IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> tools.applyAutoTuneChanges("missing dryRun", 10, "agent", null, null, Collections.singletonList(command))); assertTrue(exception.getMessage().contains("dryRun is required")); verify(autoTuneApplyService, never()).apply(any(AutoTuneApplyRequest.class)); } @Test void applyToolRejectsDirectRealApplyWithoutDryRunToken() { AutoTuneChangeCommand command = change("sys_config", null, "conveyorStationTaskLimit", "12"); IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> tools.applyAutoTuneChanges("direct apply", 10, "agent", false, null, Collections.singletonList(command))); assertTrue(exception.getMessage().contains("dryRunToken is required")); verify(autoTuneApplyService, never()).apply(any(AutoTuneApplyRequest.class)); } @Test void applyToolRejectsDuplicateDryRunTargetsBeforeServiceCall() { List changes = new ArrayList<>(); changes.add(change(" sys_config ", "ignored-first", " conveyorStationTaskLimit ", "12")); changes.add(change("SYS_CONFIG", "ignored-second", "conveyorStationTaskLimit", "13")); IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> tools.applyAutoTuneChanges("duplicate target", 10, "agent", true, null, changes)); assertTrue(exception.getMessage().contains("Duplicate auto-tune change target")); assertTrue(exception.getMessage().contains("targetType=sys_config")); assertTrue(exception.getMessage().contains("targetKey=conveyorStationTaskLimit")); verify(autoTuneApplyService, never()).apply(any(AutoTuneApplyRequest.class)); } @Test void applyToolRejectsRealApplyWithoutDryRunTokenInAnalysisOnlyMode() { AutoTuneChangeCommand command = change("sys_config", null, "conveyorStationTaskLimit", "12"); IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> tools.applyAutoTuneChanges("direct apply", 10, "agent", false, null, Collections.singletonList(command))); assertTrue(exception.getMessage().contains("dryRunToken is required")); verify(autoTuneApplyService, never()).apply(any(AutoTuneApplyRequest.class)); } @Test void applyToolAllowsRealApplyOnlyWithMatchingDryRunToken() { AutoTuneApplyResult dryRunResult = new AutoTuneApplyResult(); dryRunResult.setDryRun(true); dryRunResult.setSuccess(true); dryRunResult.setChanges(Collections.singletonList(applyResultChange("dry_run"))); AutoTuneApplyResult applyResult = new AutoTuneApplyResult(); applyResult.setDryRun(false); applyResult.setSuccess(true); when(autoTuneApplyService.apply(any(AutoTuneApplyRequest.class))).thenReturn(dryRunResult, applyResult); AutoTuneChangeCommand command = change(" sys_config ", "ignored", " conveyorStationTaskLimit ", " 12 "); List changes = Collections.singletonList(command); AutoTuneApplyResult preview = tools.applyAutoTuneChanges("preview", 10, "agent", true, null, changes); AutoTuneApplyResult applied = tools.applyAutoTuneChanges("apply", 10, "agent", false, preview.getDryRunToken(), changes); assertSame(applyResult, applied); ArgumentCaptor captor = ArgumentCaptor.forClass(AutoTuneApplyRequest.class); verify(autoTuneApplyService, times(2)).apply(captor.capture()); assertEquals(Boolean.TRUE, captor.getAllValues().get(0).getDryRun()); assertEquals(Boolean.FALSE, captor.getAllValues().get(1).getDryRun()); } @Test void applyToolRejectsMismatchedDryRunToken() { AutoTuneApplyResult dryRunResult = new AutoTuneApplyResult(); dryRunResult.setDryRun(true); dryRunResult.setSuccess(true); dryRunResult.setChanges(Collections.singletonList(applyResultChange("dry_run"))); when(autoTuneApplyService.apply(any(AutoTuneApplyRequest.class))).thenReturn(dryRunResult); AutoTuneApplyResult preview = tools.applyAutoTuneChanges("preview", 10, "agent", true, null, Collections.singletonList(change("sys_config", null, "conveyorStationTaskLimit", "12"))); IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> tools.applyAutoTuneChanges("apply", 10, "agent", false, preview.getDryRunToken(), Collections.singletonList(change("sys_config", null, "conveyorStationTaskLimit", "13")))); assertTrue(exception.getMessage().contains("does not match")); verify(autoTuneApplyService, times(1)).apply(any(AutoTuneApplyRequest.class)); } @Test void applyToolRejectsExpiredDryRunToken() { AtomicLong currentTimeMillis = new AtomicLong(1_000L); ReflectionTestUtils.invokeMethod(tools, "setCurrentTimeMillisSupplier", (LongSupplier) currentTimeMillis::get); AutoTuneApplyResult dryRunResult = new AutoTuneApplyResult(); dryRunResult.setDryRun(true); dryRunResult.setSuccess(true); dryRunResult.setChanges(Collections.singletonList(applyResultChange("dry_run"))); when(autoTuneApplyService.apply(any(AutoTuneApplyRequest.class))).thenReturn(dryRunResult); List changes = Collections.singletonList( change("sys_config", null, "conveyorStationTaskLimit", "12")); AutoTuneApplyResult preview = tools.applyAutoTuneChanges("preview", 10, "agent", true, null, changes); currentTimeMillis.addAndGet(10L * 60L * 1000L + 1L); IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> tools.applyAutoTuneChanges("apply", 10, "agent", false, preview.getDryRunToken(), changes)); assertTrue(exception.getMessage().contains("expired")); verify(autoTuneApplyService, times(1)).apply(any(AutoTuneApplyRequest.class)); } @Test void applyToolDoesNotIssueDryRunTokenWhenAllChangesAreNoChange() { AutoTuneApplyResult dryRunResult = new AutoTuneApplyResult(); dryRunResult.setDryRun(true); dryRunResult.setSuccess(true); dryRunResult.setSuccessCount(0); dryRunResult.setRejectCount(0); dryRunResult.setChanges(Collections.singletonList(applyResultChange("no_change"))); when(autoTuneApplyService.apply(any(AutoTuneApplyRequest.class))).thenReturn(dryRunResult); AutoTuneApplyResult preview = tools.applyAutoTuneChanges("preview", 10, "agent", true, null, Collections.singletonList(change("sys_config", null, "conveyorStationTaskLimit", "12"))); assertNull(preview.getDryRunToken()); verify(autoTuneApplyService, times(1)).apply(any(AutoTuneApplyRequest.class)); } @Test void rollbackToolDelegatesToApplyServiceRollback() { AutoTuneApplyResult expected = new AutoTuneApplyResult(); when(autoTuneApplyService.rollbackLastSuccessfulJob("bad result")).thenReturn(expected); AutoTuneApplyResult result = tools.revertLastAutoTuneJob("bad result"); assertSame(expected, result); verify(autoTuneApplyService).rollbackLastSuccessfulJob("bad result"); } @Test void rollbackToolBlocksRollbackInAnalysisOnlyMode() { AutoTuneApplyResult expected = new AutoTuneApplyResult(); expected.setSuccess(false); expected.setAnalysisOnly(true); expected.setNoApply(true); expected.setRejectCount(1); when(autoTuneApplyService.rollbackLastSuccessfulJob("bad result")).thenReturn(expected); AutoTuneApplyResult result = tools.revertLastAutoTuneJob("bad result"); assertSame(expected, result); verify(autoTuneApplyService).rollbackLastSuccessfulJob("bad result"); } @Test void coordinatorSkipsWhenDisabled() { when(configService.getConfigValue("aiAutoTuneEnabled", "N")).thenReturn("N"); AutoTuneCoordinatorService.AutoTuneCoordinatorResult result = coordinatorService().runAutoTuneIfEligible(); assertTrue(result.getSkipped()); assertEquals("disabled", result.getReason()); verify(wrkMastService, never()).count(any(Wrapper.class)); verify(autoTuneAgentService, never()).runAutoTune(anyString()); } @Test void coordinatorSkipsWhenNoActiveTasks() { when(configService.getConfigValue("aiAutoTuneEnabled", "N")).thenReturn("Y"); when(configService.getConfigValue("aiAutoTuneIntervalMinutes", "10")).thenReturn("10"); when(wrkMastService.count(any(Wrapper.class))).thenReturn(0L); AutoTuneCoordinatorService.AutoTuneCoordinatorResult result = coordinatorService().runAutoTuneIfEligible(); assertTrue(result.getSkipped()); assertEquals("no_active_tasks", result.getReason()); verify(autoTuneAgentService, never()).runAutoTune(anyString()); } @Test void coordinatorSkipsWhenIntervalNotReached() { AiAutoTuneJob recentJob = new AiAutoTuneJob(); recentJob.setId(11L); recentJob.setFinishTime(new Date()); when(configService.getConfigValue("aiAutoTuneEnabled", "N")).thenReturn("true"); when(configService.getConfigValue("aiAutoTuneIntervalMinutes", "10")).thenReturn("10"); when(wrkMastService.count(any(Wrapper.class))).thenReturn(1L); when(redisUtil.get(RedisKeyType.AI_AUTO_TUNE_LAST_TRIGGER_GUARD.key)).thenReturn(null); when(aiAutoTuneJobService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(recentJob)); AutoTuneCoordinatorService.AutoTuneCoordinatorResult result = coordinatorService().runAutoTuneIfEligible(); assertTrue(result.getSkipped()); assertEquals("interval_not_reached", result.getReason()); verify(autoTuneAgentService, never()).runAutoTune(anyString()); } @Test void coordinatorTriggersAgentWhenEligible() { AutoTuneAgentService.AutoTuneAgentResult agentResult = successfulAgentResult(); when(configService.getConfigValue("aiAutoTuneEnabled", "N")).thenReturn("1"); when(configService.getConfigValue("aiAutoTuneIntervalMinutes", "10")).thenReturn("10"); when(wrkMastService.count(any(Wrapper.class))).thenReturn(1L); when(redisUtil.get(RedisKeyType.AI_AUTO_TUNE_LAST_TRIGGER_GUARD.key)).thenReturn(null); when(aiAutoTuneJobService.list(any(Wrapper.class))).thenReturn(Collections.emptyList()); when(redisUtil.trySetStringIfAbsent(anyString(), anyString(), anyLong())).thenReturn(true); when(autoTuneAgentService.runAutoTune(AutoTuneTriggerType.AUTO.getCode())).thenReturn(agentResult); AutoTuneCoordinatorService.AutoTuneCoordinatorResult result = coordinatorService().runAutoTuneIfEligible(); assertFalse(result.getSkipped()); assertTrue(result.getTriggered()); assertSame(agentResult, result.getAgentResult()); verify(autoTuneAgentService).runAutoTune(AutoTuneTriggerType.AUTO.getCode()); verify(operateLogService).save(any()); verify(redisUtil).set(anyString(), any(), anyLong()); verify(redisUtil).compareAndDelete(anyString(), anyString()); } @Test void coordinatorKeepsAgentResultWhenOperateLogFails() { AutoTuneAgentService.AutoTuneAgentResult agentResult = successfulAgentResult(); when(configService.getConfigValue("aiAutoTuneEnabled", "N")).thenReturn("Y"); when(configService.getConfigValue("aiAutoTuneIntervalMinutes", "10")).thenReturn("10"); when(wrkMastService.count(any(Wrapper.class))).thenReturn(1L); when(redisUtil.get(RedisKeyType.AI_AUTO_TUNE_LAST_TRIGGER_GUARD.key)).thenReturn(null); when(aiAutoTuneJobService.list(any(Wrapper.class))).thenReturn(Collections.emptyList()); when(redisUtil.trySetStringIfAbsent(anyString(), anyString(), anyLong())).thenReturn(true); when(autoTuneAgentService.runAutoTune(AutoTuneTriggerType.AUTO.getCode())).thenReturn(agentResult); doThrow(new RuntimeException("log failed")).when(operateLogService).save(any()); AutoTuneCoordinatorService.AutoTuneCoordinatorResult result = coordinatorService().runAutoTuneIfEligible(); assertFalse(result.getSkipped()); assertTrue(result.getTriggered()); assertSame(agentResult, result.getAgentResult()); verify(redisUtil).compareAndDelete(anyString(), anyString()); } @Test void coordinatorRunsAgentAndReleasesLockWhenGuardWriteFails() { AutoTuneAgentService.AutoTuneAgentResult agentResult = successfulAgentResult(); when(configService.getConfigValue("aiAutoTuneEnabled", "N")).thenReturn("Y"); when(configService.getConfigValue("aiAutoTuneIntervalMinutes", "10")).thenReturn("10"); when(wrkMastService.count(any(Wrapper.class))).thenReturn(1L); when(redisUtil.get(RedisKeyType.AI_AUTO_TUNE_LAST_TRIGGER_GUARD.key)).thenReturn(null); when(aiAutoTuneJobService.list(any(Wrapper.class))).thenReturn(Collections.emptyList()); when(redisUtil.trySetStringIfAbsent(anyString(), anyString(), anyLong())).thenReturn(true); doThrow(new RuntimeException("guard failed")) .when(redisUtil) .set(eq(RedisKeyType.AI_AUTO_TUNE_LAST_TRIGGER_GUARD.key), any(), eq(600L)); when(autoTuneAgentService.runAutoTune(AutoTuneTriggerType.AUTO.getCode())).thenReturn(agentResult); AutoTuneCoordinatorService.AutoTuneCoordinatorResult result = coordinatorService().runAutoTuneIfEligible(); assertFalse(result.getSkipped()); assertTrue(result.getTriggered()); assertSame(agentResult, result.getAgentResult()); verify(autoTuneAgentService).runAutoTune(AutoTuneTriggerType.AUTO.getCode()); verify(redisUtil).compareAndDelete(anyString(), anyString()); } @Test void coordinatorSetsGuardWhenAgentReturnsFailure() { AutoTuneAgentService.AutoTuneAgentResult agentResult = failedAgentResult(); when(configService.getConfigValue("aiAutoTuneEnabled", "N")).thenReturn("Y"); when(configService.getConfigValue("aiAutoTuneIntervalMinutes", "10")).thenReturn("10"); when(wrkMastService.count(any(Wrapper.class))).thenReturn(1L); when(redisUtil.get(RedisKeyType.AI_AUTO_TUNE_LAST_TRIGGER_GUARD.key)).thenReturn(null); when(aiAutoTuneJobService.list(any(Wrapper.class))).thenReturn(Collections.emptyList()); when(redisUtil.trySetStringIfAbsent(anyString(), anyString(), anyLong())).thenReturn(true); when(autoTuneAgentService.runAutoTune(AutoTuneTriggerType.AUTO.getCode())).thenReturn(agentResult); AutoTuneCoordinatorService.AutoTuneCoordinatorResult result = coordinatorService().runAutoTuneIfEligible(); assertFalse(result.getSkipped()); assertSame(agentResult, result.getAgentResult()); verify(redisUtil).set(eq(RedisKeyType.AI_AUTO_TUNE_LAST_TRIGGER_GUARD.key), any(), eq(600L)); } @Test void coordinatorSetsGuardWhenAgentThrows() { when(configService.getConfigValue("aiAutoTuneEnabled", "N")).thenReturn("Y"); when(configService.getConfigValue("aiAutoTuneIntervalMinutes", "10")).thenReturn("10"); when(wrkMastService.count(any(Wrapper.class))).thenReturn(1L); when(redisUtil.get(RedisKeyType.AI_AUTO_TUNE_LAST_TRIGGER_GUARD.key)).thenReturn(null); when(aiAutoTuneJobService.list(any(Wrapper.class))).thenReturn(Collections.emptyList()); when(redisUtil.trySetStringIfAbsent(anyString(), anyString(), anyLong())).thenReturn(true); when(autoTuneAgentService.runAutoTune(AutoTuneTriggerType.AUTO.getCode())).thenThrow(new RuntimeException("agent failed")); AutoTuneCoordinatorService.AutoTuneCoordinatorResult result = coordinatorService().runAutoTuneIfEligible(); assertFalse(result.getSkipped()); assertFalse(result.getAgentResult().getSuccess()); verify(redisUtil).set(eq(RedisKeyType.AI_AUTO_TUNE_LAST_TRIGGER_GUARD.key), any(), eq(600L)); verify(redisUtil).compareAndDelete(anyString(), anyString()); } @Test void coordinatorSkipsWhenRunningLockIsNotAcquired() { when(configService.getConfigValue("aiAutoTuneEnabled", "N")).thenReturn("Y"); when(configService.getConfigValue("aiAutoTuneIntervalMinutes", "10")).thenReturn("10"); when(wrkMastService.count(any(Wrapper.class))).thenReturn(1L); when(redisUtil.get(RedisKeyType.AI_AUTO_TUNE_LAST_TRIGGER_GUARD.key)).thenReturn(null); when(aiAutoTuneJobService.list(any(Wrapper.class))).thenReturn(Collections.emptyList()); when(redisUtil.trySetStringIfAbsent(anyString(), anyString(), anyLong())).thenReturn(false); AutoTuneCoordinatorService.AutoTuneCoordinatorResult result = coordinatorService().runAutoTuneIfEligible(); assertTrue(result.getSkipped()); assertEquals("running_lock_not_acquired", result.getReason()); verify(autoTuneAgentService, never()).runAutoTune(anyString()); verify(redisUtil, never()).compareAndDelete(anyString(), anyString()); } @Test void manualTriggerRunsAgentWithRunningLockAndDoesNotWriteSchedulerGuard() { AutoTuneAgentService.AutoTuneAgentResult agentResult = successfulAgentResult(); agentResult.setTriggerType(AutoTuneTriggerType.MANUAL.getCode()); when(configService.getConfigValue("aiAutoTuneIntervalMinutes", "10")).thenReturn("10"); when(wrkMastService.count(any(Wrapper.class))).thenReturn(1L); when(redisUtil.trySetStringIfAbsent(anyString(), anyString(), anyLong())).thenReturn(true); when(autoTuneAgentService.runAutoTune(AutoTuneTriggerType.MANUAL.getCode())).thenReturn(agentResult); AutoTuneCoordinatorService.AutoTuneCoordinatorResult result = coordinatorService().runManualAutoTune(); assertFalse(result.getSkipped()); assertTrue(result.getTriggered()); assertSame(agentResult, result.getAgentResult()); verify(autoTuneAgentService).runAutoTune(AutoTuneTriggerType.MANUAL.getCode()); verify(redisUtil, never()).set(eq(RedisKeyType.AI_AUTO_TUNE_LAST_TRIGGER_GUARD.key), any(), anyLong()); verify(redisUtil).compareAndDelete(anyString(), anyString()); ArgumentCaptor operateLogCaptor = ArgumentCaptor.forClass(OperateLog.class); verify(operateLogService).save(operateLogCaptor.capture()); assertEquals("ai_auto_tune_manual_trigger", operateLogCaptor.getValue().getAction()); ArgumentCaptor jobCaptor = ArgumentCaptor.forClass(AiAutoTuneJob.class); verify(aiAutoTuneJobService).save(jobCaptor.capture()); AiAutoTuneJob auditJob = jobCaptor.getValue(); assertEquals(AutoTuneTriggerType.MANUAL.getCode(), auditJob.getTriggerType()); assertEquals(AutoTuneJobStatus.NO_CHANGE.getCode(), auditJob.getStatus()); assertNotNull(auditJob.getStartTime()); assertNotNull(auditJob.getFinishTime()); assertEquals(1, auditJob.getHasActiveTasks()); assertEquals(AiPromptScene.AUTO_TUNE_DISPATCH.getCode(), auditJob.getPromptSceneCode()); assertEquals("no changes needed", auditJob.getSummary()); assertEquals(10, auditJob.getIntervalBefore()); assertEquals(10, auditJob.getIntervalAfter()); assertEquals(0, auditJob.getSuccessCount()); assertEquals(0, auditJob.getRejectCount()); assertEquals(1, auditJob.getLlmCallCount()); assertEquals(10, auditJob.getPromptTokens()); assertEquals(5, auditJob.getCompletionTokens()); assertEquals(15, auditJob.getTotalTokens()); } @Test void manualTriggerAuditsSuccessOnlyWhenActualApplyHappened() { AutoTuneAgentService.AutoTuneAgentResult agentResult = actualAppliedAgentResult(); agentResult.setTriggerType(AutoTuneTriggerType.MANUAL.getCode()); when(configService.getConfigValue("aiAutoTuneIntervalMinutes", "10")).thenReturn("10"); when(wrkMastService.count(any(Wrapper.class))).thenReturn(1L); when(redisUtil.trySetStringIfAbsent(anyString(), anyString(), anyLong())).thenReturn(true); when(autoTuneAgentService.runAutoTune(AutoTuneTriggerType.MANUAL.getCode())).thenReturn(agentResult); AutoTuneCoordinatorService.AutoTuneCoordinatorResult result = coordinatorService().runManualAutoTune(); assertFalse(result.getSkipped()); ArgumentCaptor jobCaptor = ArgumentCaptor.forClass(AiAutoTuneJob.class); verify(aiAutoTuneJobService).save(jobCaptor.capture()); AiAutoTuneJob auditJob = jobCaptor.getValue(); assertEquals(AutoTuneJobStatus.SUCCESS.getCode(), auditJob.getStatus()); assertEquals(3, auditJob.getSuccessCount()); assertEquals(0, auditJob.getRejectCount()); } @Test void manualTriggerWritesMcpCallAuditUnderAgentJob() { AutoTuneAgentService.AutoTuneAgentResult agentResult = actualAppliedAgentResult(); agentResult.setTriggerType(AutoTuneTriggerType.MANUAL.getCode()); agentResult.setMcpCalls(Collections.singletonList(mcpCallResult())); when(configService.getConfigValue("aiAutoTuneIntervalMinutes", "10")).thenReturn("10"); when(wrkMastService.count(any(Wrapper.class))).thenReturn(1L); when(redisUtil.trySetStringIfAbsent(anyString(), anyString(), anyLong())).thenReturn(true); when(autoTuneAgentService.runAutoTune(AutoTuneTriggerType.MANUAL.getCode())).thenReturn(agentResult); when(aiAutoTuneJobService.save(any(AiAutoTuneJob.class))).thenAnswer(invocation -> { AiAutoTuneJob job = invocation.getArgument(0); job.setId(88L); return true; }); when(aiAutoTuneMcpCallService.saveBatch(any())).thenReturn(true); coordinatorService().runManualAutoTune(); ArgumentCaptor mcpCallsCaptor = ArgumentCaptor.forClass(List.class); verify(aiAutoTuneMcpCallService).saveBatch(mcpCallsCaptor.capture()); List savedCalls = mcpCallsCaptor.getValue(); assertEquals(1, savedCalls.size()); com.zy.ai.entity.AiAutoTuneMcpCall savedCall = (com.zy.ai.entity.AiAutoTuneMcpCall) savedCalls.get(0); assertEquals(88L, savedCall.getAgentJobId()); assertEquals("wcs_local_dispatch_apply_auto_tune_changes", savedCall.getToolName()); assertEquals(1, savedCall.getDryRun()); assertEquals(77L, savedCall.getApplyJobId()); } @Test void manualTriggerPreservesFullAuditSummaryAndErrorMessage() { String longSummary = "自动调参失败原因".repeat(120); AutoTuneAgentService.AutoTuneAgentResult agentResult = failedAgentResult(); agentResult.setTriggerType(AutoTuneTriggerType.MANUAL.getCode()); agentResult.setSummary(longSummary); when(configService.getConfigValue("aiAutoTuneIntervalMinutes", "10")).thenReturn("10"); when(wrkMastService.count(any(Wrapper.class))).thenReturn(1L); when(redisUtil.trySetStringIfAbsent(anyString(), anyString(), anyLong())).thenReturn(true); when(autoTuneAgentService.runAutoTune(AutoTuneTriggerType.MANUAL.getCode())).thenReturn(agentResult); coordinatorService().runManualAutoTune(); ArgumentCaptor jobCaptor = ArgumentCaptor.forClass(AiAutoTuneJob.class); verify(aiAutoTuneJobService).save(jobCaptor.capture()); AiAutoTuneJob auditJob = jobCaptor.getValue(); assertEquals(longSummary, auditJob.getSummary()); assertEquals(longSummary, auditJob.getErrorMessage()); } @Test void manualTriggerSkipsWhenRunningLockIsNotAcquired() { when(redisUtil.trySetStringIfAbsent(anyString(), anyString(), anyLong())).thenReturn(false); AutoTuneCoordinatorService.AutoTuneCoordinatorResult result = coordinatorService().runManualAutoTune(); assertTrue(result.getSkipped()); assertEquals("running_lock_not_acquired", result.getReason()); verify(autoTuneAgentService, never()).runAutoTune(anyString()); verify(redisUtil, never()).compareAndDelete(anyString(), anyString()); } @Test void agentExecutesSnapshotDryRunAndRealApplyToolSequence() { AutoTuneAgentServiceImpl service = new AutoTuneAgentServiceImpl( llmChatService, mcpToolManager, aiPromptTemplateService, autoTuneControlModeService); AiPromptTemplate promptTemplate = new AiPromptTemplate(); promptTemplate.setContent("system prompt"); when(aiPromptTemplateService.resolvePublished("wcs_auto_tune_dispatch")).thenReturn(promptTemplate); when(mcpToolManager.buildOpenAiTools()).thenReturn(allowedOpenAiTools()); when(mcpToolManager.callTool(any(), any(JSONObject.class))).thenAnswer(invocation -> { String toolName = invocation.getArgument(0); JSONObject arguments = invocation.getArgument(1); if ("wcs_local_dispatch_apply_auto_tune_changes".equals(toolName) && Boolean.TRUE.equals(arguments.getBoolean("dryRun"))) { LinkedHashMap dryRunOutput = new LinkedHashMap<>(); dryRunOutput.put("success", true); dryRunOutput.put("dryRun", true); dryRunOutput.put("dryRunToken", "token-123"); return dryRunOutput; } if ("wcs_local_dispatch_apply_auto_tune_changes".equals(toolName) && Boolean.FALSE.equals(arguments.getBoolean("dryRun"))) { LinkedHashMap applyOutput = new LinkedHashMap<>(); applyOutput.put("success", true); applyOutput.put("dryRun", false); applyOutput.put("successCount", 1); applyOutput.put("rejectCount", 0); return applyOutput; } return Collections.singletonMap("ok", true); }); when(llmChatService.chatCompletionOrThrow(any(), anyDouble(), anyInt(), any())) .thenReturn( response("read snapshot", toolCall("call_1", "wcs_local_dispatch_get_auto_tune_snapshot", "{}"), 10, 5), response("dry-run first", toolCall("call_2", "wcs_local_dispatch_apply_auto_tune_changes", "{\"dryRun\":true,\"changes\":[{\"targetType\":\"sys_config\",\"targetKey\":\"conveyorStationTaskLimit\",\"newValue\":\"12\"}]}"), 11, 6), response("apply after dry-run", toolCall("call_3", "wcs_local_dispatch_apply_auto_tune_changes", "{\"dryRun\":false,\"dryRunToken\":\"token-123\",\"changes\":[{\"targetType\":\"sys_config\",\"targetKey\":\"conveyorStationTaskLimit\",\"newValue\":\"12\"}]}"), 12, 7), response("已完成自动调参", null, 13, 8) ); AutoTuneAgentService.AutoTuneAgentResult result = service.runAutoTune("scheduler"); assertTrue(result.getSuccess()); assertEquals("scheduler", result.getTriggerType()); assertEquals(3, result.getToolCallCount()); assertEquals(4, result.getLlmCallCount()); assertEquals(46L, result.getPromptTokens()); assertEquals(26L, result.getCompletionTokens()); assertEquals(72L, result.getTotalTokens()); assertTrue(result.getSummary().contains("已完成自动调参")); assertTrue(result.getActualApplyCalled()); assertFalse(result.getRollbackCalled()); assertEquals(1, result.getSuccessCount()); assertEquals(0, result.getRejectCount()); ArgumentCaptor toolNameCaptor = ArgumentCaptor.forClass(String.class); ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(JSONObject.class); verify(mcpToolManager, times(3)).callTool(toolNameCaptor.capture(), argumentCaptor.capture()); assertEquals("wcs_local_dispatch_get_auto_tune_snapshot", toolNameCaptor.getAllValues().get(0)); assertEquals("wcs_local_dispatch_apply_auto_tune_changes", toolNameCaptor.getAllValues().get(1)); assertEquals(Boolean.TRUE, argumentCaptor.getAllValues().get(1).getBoolean("dryRun")); assertEquals(Boolean.FALSE, argumentCaptor.getAllValues().get(2).getBoolean("dryRun")); assertEquals("token-123", argumentCaptor.getAllValues().get(2).getString("dryRunToken")); ArgumentCaptor> toolsCaptor = ArgumentCaptor.forClass(List.class); verify(llmChatService, times(4)).chatCompletionOrThrow(any(), anyDouble(), anyInt(), toolsCaptor.capture()); List visibleToolNames = toolNames(toolsCaptor.getAllValues().get(0)); assertEquals(4, visibleToolNames.size()); assertTrue(visibleToolNames.contains("wcs_local_dispatch_get_auto_tune_snapshot")); assertTrue(visibleToolNames.contains("wcs_local_dispatch_get_recent_auto_tune_jobs")); assertTrue(visibleToolNames.contains("wcs_local_dispatch_apply_auto_tune_changes")); assertTrue(visibleToolNames.contains("wcs_local_dispatch_revert_last_auto_tune_job")); assertFalse(visibleToolNames.contains("wcs_local_device_get_crn_status")); } @Test void agentForcesAutoTriggerTypeOnApplyTools() { AutoTuneAgentServiceImpl service = agentService(); when(mcpToolManager.buildOpenAiTools()).thenReturn(allowedOpenAiTools()); when(mcpToolManager.callTool(any(), any(JSONObject.class))).thenReturn(Collections.singletonMap("ok", true)); when(llmChatService.chatCompletionOrThrow(any(), anyDouble(), anyInt(), any())) .thenReturn( response("snapshot", toolCall("call_1", "wcs_local_dispatch_get_auto_tune_snapshot", "{}"), 10, 5), response("dry run", toolCall("call_2", "wcs_local_dispatch_apply_auto_tune_changes", "{\"dryRun\":true,\"changes\":[]}"), 10, 5), response("done", null, 10, 5) ); AutoTuneAgentService.AutoTuneAgentResult result = service.runAutoTune("auto"); assertTrue(result.getSuccess()); ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(JSONObject.class); verify(mcpToolManager).callTool(eq("wcs_local_dispatch_apply_auto_tune_changes"), argumentCaptor.capture()); assertEquals("auto", argumentCaptor.getValue().getString("triggerType")); } @Test void agentCallsMcpForRealApplyInAnalysisOnlyMode() { when(configService.getConfigValue("aiAutoTuneAnalysisOnly", "Y")).thenReturn("Y"); AutoTuneAgentServiceImpl service = agentService(); when(mcpToolManager.buildOpenAiTools()).thenReturn(allowedOpenAiTools()); when(mcpToolManager.callTool(any(), any(JSONObject.class))).thenAnswer(invocation -> { String toolName = invocation.getArgument(0); if ("wcs_local_dispatch_apply_auto_tune_changes".equals(toolName)) { return rejectedAnalysisOnlyResult(false); } return Collections.singletonMap("ok", true); }); when(llmChatService.chatCompletionOrThrow(any(), anyDouble(), anyInt(), any())) .thenReturn( response("snapshot", toolCall("call_1", "wcs_local_dispatch_get_auto_tune_snapshot", "{}"), 10, 5), response("apply", toolCall("call_2", "wcs_local_dispatch_apply_auto_tune_changes", "{\"dryRun\":false,\"changes\":[{\"targetType\":\"sys_config\",\"targetKey\":\"conveyorStationTaskLimit\",\"newValue\":\"12\"}]}"), 10, 5), response("stop", null, 10, 5) ); AutoTuneAgentService.AutoTuneAgentResult result = service.runAutoTune("manual"); assertFalse(result.getSuccess()); assertTrue(result.getAnalysisOnly()); assertFalse(result.getAllowApply()); assertEquals("analysis_only", result.getExecutionMode()); assertFalse(result.getActualApplyCalled()); assertEquals(1, result.getRejectCount()); assertTrue(result.getSummary().contains("仅分析模式禁止实际应用/回滚")); verify(mcpToolManager).callTool(eq("wcs_local_dispatch_get_auto_tune_snapshot"), any(JSONObject.class)); verify(mcpToolManager).callTool(eq("wcs_local_dispatch_apply_auto_tune_changes"), any(JSONObject.class)); } @Test void agentCallsMcpForRollbackInAnalysisOnlyMode() { when(configService.getConfigValue("aiAutoTuneAnalysisOnly", "Y")).thenReturn("Y"); AutoTuneAgentServiceImpl service = agentService(); when(mcpToolManager.buildOpenAiTools()).thenReturn(allowedOpenAiTools()); when(mcpToolManager.callTool(any(), any(JSONObject.class))).thenAnswer(invocation -> { String toolName = invocation.getArgument(0); if ("wcs_local_dispatch_revert_last_auto_tune_job".equals(toolName)) { return rejectedAnalysisOnlyResult(false); } return Collections.singletonMap("ok", true); }); when(llmChatService.chatCompletionOrThrow(any(), anyDouble(), anyInt(), any())) .thenReturn( response("snapshot", toolCall("call_1", "wcs_local_dispatch_get_auto_tune_snapshot", "{}"), 10, 5), response("rollback", toolCall("call_2", "wcs_local_dispatch_revert_last_auto_tune_job", "{\"reason\":\"bad result\"}"), 10, 5), response("stop", null, 10, 5) ); AutoTuneAgentService.AutoTuneAgentResult result = service.runAutoTune("manual"); assertFalse(result.getSuccess()); assertTrue(result.getAnalysisOnly()); assertTrue(result.getRollbackCalled()); assertEquals(1, result.getRejectCount()); assertTrue(result.getSummary().contains("仅分析模式禁止实际应用/回滚")); verify(mcpToolManager).callTool(eq("wcs_local_dispatch_get_auto_tune_snapshot"), any(JSONObject.class)); verify(mcpToolManager).callTool(eq("wcs_local_dispatch_revert_last_auto_tune_job"), any(JSONObject.class)); } @Test void agentMarksSnapshotOnlyRunAsNoActualMutationEvenIfAssistantClaimsApplied() { AutoTuneAgentServiceImpl service = agentService(); when(mcpToolManager.buildOpenAiTools()).thenReturn(allowedOpenAiTools()); when(mcpToolManager.callTool(any(), any(JSONObject.class))).thenReturn(Collections.singletonMap("ok", true)); when(llmChatService.chatCompletionOrThrow(any(), anyDouble(), anyInt(), any())) .thenReturn( response("snapshot", toolCall("call_1", "wcs_local_dispatch_get_auto_tune_snapshot", "{}"), 10, 5), response("已执行 dry-run 并实际应用成功", null, 10, 5) ); AutoTuneAgentService.AutoTuneAgentResult result = service.runAutoTune("manual"); assertTrue(result.getSuccess()); assertFalse(result.getActualApplyCalled()); assertFalse(result.getRollbackCalled()); assertEquals(0, result.getSuccessCount()); assertTrue(result.getSummary().contains("自动调参 Agent 未调用实际应用或回滚工具,未修改运行参数。")); } @Test void agentDoesNotMarkNoChangeRealApplyAsActualApplyWhenSuccessCountIsPositive() { AutoTuneAgentServiceImpl service = agentService(); when(mcpToolManager.buildOpenAiTools()).thenReturn(allowedOpenAiTools()); when(mcpToolManager.callTool(any(), any(JSONObject.class))).thenAnswer(invocation -> { String toolName = invocation.getArgument(0); if ("wcs_local_dispatch_apply_auto_tune_changes".equals(toolName)) { LinkedHashMap noChange = new LinkedHashMap<>(); noChange.put("resultStatus", "no_change"); LinkedHashMap applyOutput = new LinkedHashMap<>(); applyOutput.put("success", true); applyOutput.put("dryRun", false); applyOutput.put("successCount", 1); applyOutput.put("rejectCount", 0); applyOutput.put("changes", Collections.singletonList(noChange)); return applyOutput; } return Collections.singletonMap("ok", true); }); when(llmChatService.chatCompletionOrThrow(any(), anyDouble(), anyInt(), any())) .thenReturn( response("snapshot", toolCall("call_1", "wcs_local_dispatch_get_auto_tune_snapshot", "{}"), 10, 5), response("apply no change", toolCall("call_2", "wcs_local_dispatch_apply_auto_tune_changes", "{\"dryRun\":false,\"dryRunToken\":\"token-123\",\"changes\":[{\"targetType\":\"sys_config\",\"targetKey\":\"conveyorStationTaskLimit\",\"newValue\":\"12\"}]}"), 10, 5), response("无变更", null, 10, 5) ); AutoTuneAgentService.AutoTuneAgentResult result = service.runAutoTune("manual"); assertTrue(result.getSuccess()); assertFalse(result.getActualApplyCalled()); assertEquals(1, result.getSuccessCount()); assertEquals(0, result.getRejectCount()); } @Test void agentMarksRejectedDryRunAsFailedAndFeedsBackGenericConstraints() { AutoTuneAgentServiceImpl service = agentService(); when(mcpToolManager.buildOpenAiTools()).thenReturn(allowedOpenAiTools()); when(mcpToolManager.callTool(any(), any(JSONObject.class))).thenAnswer(invocation -> { String toolName = invocation.getArgument(0); if ("wcs_local_dispatch_get_auto_tune_snapshot".equals(toolName)) { return Collections.singletonMap("ok", true); } LinkedHashMap rejectedChange = new LinkedHashMap<>(); rejectedChange.put("targetType", "sys_config"); rejectedChange.put("targetId", ""); rejectedChange.put("targetKey", "crnOutBatchRunningLimit"); rejectedChange.put("oldValue", "5"); rejectedChange.put("requestedValue", "9"); rejectedChange.put("resultStatus", "rejected"); rejectedChange.put("rejectReason", "crnOutBatchRunningLimit 单次调整步长不能超过 3"); LinkedHashMap dryRunOutput = new LinkedHashMap<>(); dryRunOutput.put("success", false); dryRunOutput.put("dryRun", true); dryRunOutput.put("rejectCount", 1); dryRunOutput.put("changes", Collections.singletonList(rejectedChange)); return dryRunOutput; }); when(llmChatService.chatCompletionOrThrow(any(), anyDouble(), anyInt(), any())) .thenReturn( response("snapshot", toolCall("call_1", "wcs_local_dispatch_get_auto_tune_snapshot", "{}"), 10, 5), response("dry run rejected", toolCall("call_2", "wcs_local_dispatch_apply_auto_tune_changes", "{\"dryRun\":true,\"changes\":[{\"targetType\":\"sys_config\",\"targetKey\":\"crnOutBatchRunningLimit\",\"newValue\":\"9\"}]}"), 10, 5), response("停止实际应用", null, 10, 5) ); AutoTuneAgentService.AutoTuneAgentResult result = service.runAutoTune("auto"); assertFalse(result.getSuccess()); assertTrue(result.getSummary().contains("被拒绝的 dry-run/apply")); assertEquals(0, result.getSuccessCount()); assertEquals(1, result.getRejectCount()); ArgumentCaptor> messagesCaptor = ArgumentCaptor.forClass(List.class); verify(llmChatService, times(3)).chatCompletionOrThrow(messagesCaptor.capture(), anyDouble(), anyInt(), any()); String userInstruction = messagesCaptor.getAllValues().stream() .flatMap(List::stream) .filter(message -> "user".equals(message.getRole())) .map(ChatCompletionRequest.Message::getContent) .filter(content -> content != null && content.contains("ruleSnapshot")) .findFirst() .orElse(""); assertTrue(userInstruction.contains("所有提交给")); assertTrue(userInstruction.contains("minValue")); assertTrue(userInstruction.contains("cooldownMinutes")); String rejectedToolMessage = messagesCaptor.getAllValues().stream() .flatMap(List::stream) .filter(message -> "tool".equals(message.getRole())) .map(ChatCompletionRequest.Message::getContent) .filter(content -> content != null && content.contains("rejectCount")) .findFirst() .orElse(""); assertTrue(rejectedToolMessage.contains("禁止继续实际应用")); assertTrue(rejectedToolMessage.contains("ruleSnapshot")); assertTrue(rejectedToolMessage.contains("maxStep")); assertFalse(rejectedToolMessage.contains("outBufferCapacity")); } @Test void agentKeepsOriginalLlmFailureReason() { AutoTuneAgentServiceImpl service = agentService(); when(mcpToolManager.buildOpenAiTools()).thenReturn(allowedOpenAiTools()); when(llmChatService.chatCompletionOrThrow(any(), anyDouble(), anyInt(), any())) .thenThrow(new IllegalStateException("OpenAI Responses 调用失败: HTTP 502")); AutoTuneAgentService.AutoTuneAgentResult result = service.runAutoTune("auto"); assertFalse(result.getSuccess()); assertTrue(result.getSummary().contains("OpenAI Responses 调用失败: HTTP 502")); assertFalse(result.getSummary().contains("LLM returned empty response")); } @Test void agentFailsAndDoesNotExecuteDisallowedToolCall() { AutoTuneAgentServiceImpl service = agentService(); when(mcpToolManager.buildOpenAiTools()).thenReturn(allowedOpenAiTools()); when(llmChatService.chatCompletionOrThrow(any(), anyDouble(), anyInt(), any())) .thenReturn(response("bad tool", toolCall("call_1", "wcs_local_device_get_crn_status", "{}"), 10, 5)); AutoTuneAgentService.AutoTuneAgentResult result = service.runAutoTune("scheduler"); assertFalse(result.getSuccess()); assertEquals(0, result.getToolCallCount()); assertTrue(result.getSummary().contains("Disallowed auto-tune MCP tool")); verify(mcpToolManager, never()).callTool(any(), any(JSONObject.class)); } @Test void agentFailsWhenAllowedToolThrows() { AutoTuneAgentServiceImpl service = agentService(); when(mcpToolManager.buildOpenAiTools()).thenReturn(allowedOpenAiTools()); when(mcpToolManager.callTool(any(), any(JSONObject.class))).thenThrow(new RuntimeException("boom")); when(llmChatService.chatCompletionOrThrow(any(), anyDouble(), anyInt(), any())) .thenReturn(response("snapshot", toolCall("call_1", "wcs_local_dispatch_get_auto_tune_snapshot", "{}"), 10, 5)); AutoTuneAgentService.AutoTuneAgentResult result = service.runAutoTune("scheduler"); assertFalse(result.getSuccess()); assertEquals(0, result.getToolCallCount()); assertTrue(result.getSummary().contains("Auto-tune MCP tool failed")); verify(mcpToolManager).callTool(any(), any(JSONObject.class)); } @Test void agentFailsWhenLlmReturnsNoToolCalls() { AutoTuneAgentServiceImpl service = agentService(); when(mcpToolManager.buildOpenAiTools()).thenReturn(allowedOpenAiTools()); when(llmChatService.chatCompletionOrThrow(any(), anyDouble(), anyInt(), any())) .thenReturn(response("no changes needed", null, 10, 5)); AutoTuneAgentService.AutoTuneAgentResult result = service.runAutoTune("scheduler"); assertFalse(result.getSuccess()); assertEquals(0, result.getToolCallCount()); assertTrue(result.getSummary().contains("未调用任何允许的 MCP 工具")); } @Test void agentFailsWhenMaxRoundsReached() { AutoTuneAgentServiceImpl service = agentService(); when(mcpToolManager.buildOpenAiTools()).thenReturn(allowedOpenAiTools()); when(mcpToolManager.callTool(any(), any(JSONObject.class))).thenReturn(Collections.singletonMap("ok", true)); when(llmChatService.chatCompletionOrThrow(any(), anyDouble(), anyInt(), any())) .thenReturn(response("keep going", toolCall("call_1", "wcs_local_dispatch_get_auto_tune_snapshot", "{}"), 10, 5)); AutoTuneAgentService.AutoTuneAgentResult result = service.runAutoTune("scheduler"); assertFalse(result.getSuccess()); assertEquals(10, result.getToolCallCount()); assertTrue(result.getMaxRoundsReached()); assertTrue(result.getSummary().contains("达到最大工具调用轮次")); } private AutoTuneAgentServiceImpl agentService() { AiPromptTemplate promptTemplate = new AiPromptTemplate(); promptTemplate.setContent("system prompt"); when(aiPromptTemplateService.resolvePublished("wcs_auto_tune_dispatch")).thenReturn(promptTemplate); return new AutoTuneAgentServiceImpl( llmChatService, mcpToolManager, aiPromptTemplateService, autoTuneControlModeService); } private AutoTuneCoordinatorServiceImpl coordinatorService() { return new AutoTuneCoordinatorServiceImpl( configService, autoTuneControlModeService, wrkMastService, aiAutoTuneJobService, aiAutoTuneMcpCallService, autoTuneAgentService, redisUtil, operateLogService); } private AutoTuneAgentService.AutoTuneAgentResult successfulAgentResult() { AutoTuneAgentService.AutoTuneAgentResult result = new AutoTuneAgentService.AutoTuneAgentResult(); result.setSuccess(true); result.setTriggerType(AutoTuneTriggerType.AUTO.getCode()); result.setSummary("no changes needed"); result.setToolCallCount(1); result.setLlmCallCount(1); result.setPromptTokens(10L); result.setCompletionTokens(5L); result.setTotalTokens(15L); result.setMaxRoundsReached(false); result.setActualApplyCalled(false); result.setRollbackCalled(false); result.setSuccessCount(0); result.setRejectCount(0); return result; } private AutoTuneAgentService.AutoTuneAgentResult actualAppliedAgentResult() { AutoTuneAgentService.AutoTuneAgentResult result = successfulAgentResult(); result.setSummary("applied"); result.setActualApplyCalled(true); result.setSuccessCount(3); result.setRejectCount(0); return result; } private AutoTuneAgentService.McpCallResult mcpCallResult() { AutoTuneAgentService.McpCallResult result = new AutoTuneAgentService.McpCallResult(); result.setCallSeq(1); result.setToolName("wcs_local_dispatch_apply_auto_tune_changes"); result.setStatus("success"); result.setDryRun(true); result.setApplyJobId(77L); result.setSuccessCount(1); result.setRejectCount(0); result.setDurationMs(12L); result.setRequestJson("{\"dryRun\":true}"); result.setResponseJson("{\"success\":true}"); return result; } private AutoTuneAgentService.AutoTuneAgentResult failedAgentResult() { AutoTuneAgentService.AutoTuneAgentResult result = successfulAgentResult(); result.setSuccess(false); result.setSummary("failed"); return result; } private AutoTuneApplyResult rejectedAnalysisOnlyResult(Boolean dryRun) { AutoTuneApplyResult result = new AutoTuneApplyResult(); result.setDryRun(dryRun); result.setSuccess(false); result.setAnalysisOnly(true); result.setNoApply(true); result.setSuccessCount(0); result.setRejectCount(1); result.setSummary("仅分析模式禁止实际应用/回滚,未修改运行参数"); result.setChanges(new ArrayList<>()); return result; } private AutoTuneChangeCommand change(String targetType, String targetId, String targetKey, String newValue) { AutoTuneChangeCommand command = new AutoTuneChangeCommand(); command.setTargetType(targetType); command.setTargetId(targetId); command.setTargetKey(targetKey); command.setNewValue(newValue); return command; } private AiAutoTuneChange applyResultChange(String resultStatus) { AiAutoTuneChange change = new AiAutoTuneChange(); change.setResultStatus(resultStatus); return change; } private List wrapperParamValues(Wrapper wrapper) { if (!(wrapper instanceof AbstractWrapper abstractWrapper)) { return Collections.emptyList(); } return new ArrayList<>(abstractWrapper.getParamNameValuePairs().values()); } private List allowedOpenAiTools() { List tools = new ArrayList<>(); tools.add(openAiTool("wcs_local_dispatch_get_auto_tune_snapshot")); tools.add(openAiTool("wcs_local_dispatch_get_recent_auto_tune_jobs")); tools.add(openAiTool("wcs_local_dispatch_apply_auto_tune_changes")); tools.add(openAiTool("wcs_local_dispatch_revert_last_auto_tune_job")); tools.add(openAiTool("wcs_local_device_get_crn_status")); return tools; } private Map openAiTool(String name) { LinkedHashMap function = new LinkedHashMap<>(); function.put("name", name); function.put("parameters", Collections.emptyMap()); LinkedHashMap tool = new LinkedHashMap<>(); tool.put("type", "function"); tool.put("function", function); return tool; } private List toolNames(List tools) { List names = new ArrayList<>(); for (Object tool : tools) { Map toolMap = (Map) tool; Map function = (Map) toolMap.get("function"); names.add(String.valueOf(function.get("name"))); } return names; } private ChatCompletionResponse response(String content, ChatCompletionRequest.ToolCall toolCall, int promptTokens, int completionTokens) { ChatCompletionRequest.Message message = new ChatCompletionRequest.Message(); message.setRole("assistant"); message.setContent(content); if (toolCall != null) { message.setTool_calls(Collections.singletonList(toolCall)); } ChatCompletionResponse.Choice choice = new ChatCompletionResponse.Choice(); choice.setIndex(0); choice.setMessage(message); ChatCompletionResponse.Usage usage = new ChatCompletionResponse.Usage(); usage.setPromptTokens(promptTokens); usage.setCompletionTokens(completionTokens); usage.setTotalTokens(promptTokens + completionTokens); ChatCompletionResponse response = new ChatCompletionResponse(); List choices = new ArrayList<>(); choices.add(choice); response.setChoices(choices); response.setUsage(usage); return response; } private ChatCompletionRequest.ToolCall toolCall(String id, String name, String arguments) { ChatCompletionRequest.Function function = new ChatCompletionRequest.Function(); function.setName(name); function.setArguments(arguments); ChatCompletionRequest.ToolCall toolCall = new ChatCompletionRequest.ToolCall(); toolCall.setId(id); toolCall.setType("function"); toolCall.setFunction(function); return toolCall; } }