Junjie
3 天以前 63b01db83d9aad8a15276b4236a9a22e4aeef065
src/test/java/com/zy/ai/service/AutoTuneCoordinatorServiceImplTest.java
@@ -1,24 +1,29 @@
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;
@@ -41,6 +46,7 @@
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;
@@ -51,6 +57,7 @@
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;
@@ -70,6 +77,8 @@
    @Mock
    private AiAutoTuneChangeService aiAutoTuneChangeService;
    @Mock
    private AiAutoTuneMcpCallService aiAutoTuneMcpCallService;
    @Mock
    private LlmChatService llmChatService;
    @Mock
    private SpringAiMcpToolManager mcpToolManager;
@@ -77,6 +86,7 @@
    private AiPromptTemplateService aiPromptTemplateService;
    @Mock
    private ConfigService configService;
    private AutoTuneControlModeService autoTuneControlModeService;
    @Mock
    private WrkMastService wrkMastService;
    @Mock
@@ -88,11 +98,15 @@
    @BeforeEach
    void setUp() {
        autoTuneControlModeService = new AutoTuneControlModeServiceImpl(configService);
        tools = new AutoTuneMcpTools(
                autoTuneSnapshotService,
                autoTuneApplyService,
                aiAutoTuneJobService,
                aiAutoTuneChangeService);
                aiAutoTuneChangeService,
                aiAutoTuneMcpCallService);
        lenient().when(configService.getConfigValue("aiAutoTuneEnabled", "N")).thenReturn("Y");
        lenient().when(configService.getConfigValue("aiAutoTuneAnalysisOnly", "Y")).thenReturn("N");
    }
    @Test
@@ -115,27 +129,88 @@
        job.setSummary("applied");
        job.setSuccessCount(1);
        job.setRejectCount(0);
        AiAutoTuneChange change = new AiAutoTuneChange();
        change.setJobId(7L);
        change.setTargetType("sys_config");
        change.setTargetKey("conveyorStationTaskLimit");
        change.setRequestedValue("12");
        change.setResultStatus("success");
        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(aiAutoTuneChangeService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(change));
        when(aiAutoTuneMcpCallService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(mcpCall));
        List<AiAutoTuneChange> auditChanges = new ArrayList<>();
        auditChanges.add(agentChange);
        auditChanges.add(applyChange);
        when(aiAutoTuneChangeService.list(any(Wrapper.class))).thenReturn(auditChanges);
        List<Map<String, Object>> 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(1, changes.size());
        assertEquals(2, changes.size());
        assertEquals(7L, ((Map<?, ?>) changes.get(0)).get("jobId"));
        assertEquals(70L, ((Map<?, ?>) changes.get(1)).get("jobId"));
        ArgumentCaptor<Wrapper<AiAutoTuneJob>> wrapperCaptor = ArgumentCaptor.forClass(Wrapper.class);
        verify(aiAutoTuneJobService).list(wrapperCaptor.capture());
        assertTrue(wrapperCaptor.getValue().getSqlSegment().contains("limit 20"));
        ArgumentCaptor<Wrapper<AiAutoTuneChange>> changeWrapperCaptor = ArgumentCaptor.forClass(Wrapper.class);
        verify(aiAutoTuneChangeService).list(changeWrapperCaptor.capture());
        Wrapper<AiAutoTuneChange> changeWrapper = changeWrapperCaptor.getValue();
        assertTrue(changeWrapper.getSqlSegment().contains("job_id IN"));
        List<Object> 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<Map<String, Object>> 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
@@ -143,6 +218,7 @@
        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();
@@ -189,10 +265,38 @@
    }
    @Test
    void applyToolRejectsDuplicateDryRunTargetsBeforeServiceCall() {
        List<AutoTuneChangeCommand> 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);
@@ -216,6 +320,7 @@
        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,
@@ -236,6 +341,7 @@
        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<AutoTuneChangeCommand> changes = Collections.singletonList(
                change("sys_config", null, "conveyorStationTaskLimit", "12"));
@@ -250,8 +356,40 @@
    }
    @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");
@@ -346,6 +484,29 @@
    }
    @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");
@@ -399,11 +560,135 @@
    }
    @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<OperateLog> operateLogCaptor = ArgumentCaptor.forClass(OperateLog.class);
        verify(operateLogService).save(operateLogCaptor.capture());
        assertEquals("ai_auto_tune_manual_trigger", operateLogCaptor.getValue().getAction());
        ArgumentCaptor<AiAutoTuneJob> 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<AiAutoTuneJob> 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<List> 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<AiAutoTuneJob> 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);
                aiPromptTemplateService,
                autoTuneControlModeService);
        AiPromptTemplate promptTemplate = new AiPromptTemplate();
        promptTemplate.setContent("system prompt");
        when(aiPromptTemplateService.resolvePublished("wcs_auto_tune_dispatch")).thenReturn(promptTemplate);
@@ -419,9 +704,18 @@
                dryRunOutput.put("dryRunToken", "token-123");
                return dryRunOutput;
            }
            if ("wcs_local_dispatch_apply_auto_tune_changes".equals(toolName)
                    && Boolean.FALSE.equals(arguments.getBoolean("dryRun"))) {
                LinkedHashMap<String, Object> 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.chatCompletion(any(), anyDouble(), anyInt(), any()))
        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",
@@ -441,6 +735,10 @@
        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<String> toolNameCaptor = ArgumentCaptor.forClass(String.class);
        ArgumentCaptor<JSONObject> argumentCaptor = ArgumentCaptor.forClass(JSONObject.class);
@@ -452,7 +750,7 @@
        assertEquals("token-123", argumentCaptor.getAllValues().get(2).getString("dryRunToken"));
        ArgumentCaptor<List<Object>> toolsCaptor = ArgumentCaptor.forClass(List.class);
        verify(llmChatService, times(4)).chatCompletion(any(), anyDouble(), anyInt(), toolsCaptor.capture());
        verify(llmChatService, times(4)).chatCompletionOrThrow(any(), anyDouble(), anyInt(), toolsCaptor.capture());
        List<String> visibleToolNames = toolNames(toolsCaptor.getAllValues().get(0));
        assertEquals(4, visibleToolNames.size());
        assertTrue(visibleToolNames.contains("wcs_local_dispatch_get_auto_tune_snapshot"));
@@ -467,7 +765,7 @@
        AutoTuneAgentServiceImpl service = agentService();
        when(mcpToolManager.buildOpenAiTools()).thenReturn(allowedOpenAiTools());
        when(mcpToolManager.callTool(any(), any(JSONObject.class))).thenReturn(Collections.singletonMap("ok", true));
        when(llmChatService.chatCompletion(any(), anyDouble(), anyInt(), any()))
        when(llmChatService.chatCompletionOrThrow(any(), anyDouble(), anyInt(), any()))
                .thenReturn(
                        response("snapshot", toolCall("call_1", "wcs_local_dispatch_get_auto_tune_snapshot",
                                "{}"), 10, 5),
@@ -485,10 +783,211 @@
    }
    @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<String, Object> noChange = new LinkedHashMap<>();
                noChange.put("resultStatus", "no_change");
                LinkedHashMap<String, Object> 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<String, Object> 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<String, Object> 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<List<ChatCompletionRequest.Message>> 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.chatCompletion(any(), anyDouble(), anyInt(), any()))
        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");
@@ -504,7 +1003,7 @@
        AutoTuneAgentServiceImpl service = agentService();
        when(mcpToolManager.buildOpenAiTools()).thenReturn(allowedOpenAiTools());
        when(mcpToolManager.callTool(any(), any(JSONObject.class))).thenThrow(new RuntimeException("boom"));
        when(llmChatService.chatCompletion(any(), anyDouble(), anyInt(), any()))
        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");
@@ -519,7 +1018,7 @@
    void agentFailsWhenLlmReturnsNoToolCalls() {
        AutoTuneAgentServiceImpl service = agentService();
        when(mcpToolManager.buildOpenAiTools()).thenReturn(allowedOpenAiTools());
        when(llmChatService.chatCompletion(any(), anyDouble(), anyInt(), any()))
        when(llmChatService.chatCompletionOrThrow(any(), anyDouble(), anyInt(), any()))
                .thenReturn(response("no changes needed", null, 10, 5));
        AutoTuneAgentService.AutoTuneAgentResult result = service.runAutoTune("scheduler");
@@ -534,7 +1033,7 @@
        AutoTuneAgentServiceImpl service = agentService();
        when(mcpToolManager.buildOpenAiTools()).thenReturn(allowedOpenAiTools());
        when(mcpToolManager.callTool(any(), any(JSONObject.class))).thenReturn(Collections.singletonMap("ok", true));
        when(llmChatService.chatCompletion(any(), anyDouble(), anyInt(), any()))
        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");
@@ -549,14 +1048,20 @@
        AiPromptTemplate promptTemplate = new AiPromptTemplate();
        promptTemplate.setContent("system prompt");
        when(aiPromptTemplateService.resolvePublished("wcs_auto_tune_dispatch")).thenReturn(promptTemplate);
        return new AutoTuneAgentServiceImpl(llmChatService, mcpToolManager, aiPromptTemplateService);
        return new AutoTuneAgentServiceImpl(
                llmChatService,
                mcpToolManager,
                aiPromptTemplateService,
                autoTuneControlModeService);
    }
    private AutoTuneCoordinatorServiceImpl coordinatorService() {
        return new AutoTuneCoordinatorServiceImpl(
                configService,
                autoTuneControlModeService,
                wrkMastService,
                aiAutoTuneJobService,
                aiAutoTuneMcpCallService,
                autoTuneAgentService,
                redisUtil,
                operateLogService);
@@ -573,6 +1078,34 @@
        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;
    }
@@ -583,6 +1116,19 @@
        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);
@@ -592,6 +1138,19 @@
        return command;
    }
    private AiAutoTuneChange applyResultChange(String resultStatus) {
        AiAutoTuneChange change = new AiAutoTuneChange();
        change.setResultStatus(resultStatus);
        return change;
    }
    private List<Object> wrapperParamValues(Wrapper<?> wrapper) {
        if (!(wrapper instanceof AbstractWrapper<?, ?, ?> abstractWrapper)) {
            return Collections.emptyList();
        }
        return new ArrayList<>(abstractWrapper.getParamNameValuePairs().values());
    }
    private List<Object> allowedOpenAiTools() {
        List<Object> tools = new ArrayList<>();
        tools.add(openAiTool("wcs_local_dispatch_get_auto_tune_snapshot"));