package vip.mate.skill.reflection; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; import org.springframework.ai.chat.prompt.Prompt; import vip.mate.agent.AgentGraphBuilder; import vip.mate.llm.service.ModelConfigService; import vip.mate.skill.service.SkillService; import vip.mate.skill.model.SkillOrigin; import vip.mate.tool.builtin.SkillManageTool; import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.model.MessageEntity; import java.util.ArrayList; import java.util.List; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.mock; /** * Unit tests for the deterministic gating and action-routing logic of * {@link SkillReflectionService} — cadence, tool-call floor, cooldown, the * maxActionsPerRun cap, and the "never delete" rule. */ class SkillReflectionServiceTest { private ConversationService conversationService; private SkillService skillService; private SkillManageTool skillManageTool; private ModelConfigService modelConfigService; private AgentGraphBuilder agentGraphBuilder; private SkillReflectionProperties properties; private SkillReflectionService service; @BeforeEach void setUp() { conversationService = mock(ConversationService.class); skillService = mock(SkillService.class); skillManageTool = mock(SkillManageTool.class); modelConfigService = mock(ModelConfigService.class); agentGraphBuilder = mock(AgentGraphBuilder.class); properties = new SkillReflectionProperties(); service = new SkillReflectionService(conversationService, skillService, skillManageTool, modelConfigService, agentGraphBuilder, properties, new ObjectMapper()); when(skillService.listEnabledSkills()).thenReturn(List.of()); } private void stubLlm(String json) { ChatModel chatModel = (ChatModel) (Prompt p) -> new ChatResponse(List.of(new Generation(new AssistantMessage(json)))); when(agentGraphBuilder.buildRuntimeChatModel(any())).thenReturn(chatModel); when(modelConfigService.getDefaultModel()).thenReturn(null); } /** Build {@code turns} substantive user/assistant pairs. */ private List transcriptWithTurns(int turns) { List messages = new ArrayList<>(); for (int i = 0; i < turns; i++) { MessageEntity user = new MessageEntity(); user.setRole("user"); user.setContent("step " + i + ": how do I scaffold a spring boot module?"); messages.add(user); MessageEntity assistant = new MessageEntity(); assistant.setRole("assistant"); assistant.setContent("step " + i + ": run mvn archetype, then add the starter, then ..."); messages.add(assistant); } return messages; } @Test @DisplayName("disabled → no LLM call, no skill write") void disabledShortCircuits() { properties.setEnabled(false); service.maybeReflect(1L, "conv-1", 8); verify(conversationService, never()).listMessages(any()); verify(skillManageTool, never()).skillManageAs(eq(SkillOrigin.AGENT), any(), any(), any(), any(), any(), any(), any()); } @Test @DisplayName("cadence gate: messageCount not on interval → skip") void cadenceGateSkips() { properties.setReviewTurnInterval(8); service.maybeReflect(1L, "conv-1", 7); verify(conversationService, never()).listMessages(any()); } @Test @DisplayName("assistant-turn floor not met → no LLM call") void assistantTurnFloorSkips() { properties.setReviewTurnInterval(8); properties.setMinAssistantTurns(2); when(conversationService.listMessages("conv-1")).thenReturn(transcriptWithTurns(1)); service.maybeReflect(1L, "conv-1", 8); verify(agentGraphBuilder, never()).buildRuntimeChatModel(any()); verify(skillManageTool, never()).skillManageAs(eq(SkillOrigin.AGENT), any(), any(), any(), any(), any(), any(), any()); } @Test @DisplayName("happy path: a create action routes through the autonomous skill_manage entry point") void appliesCreateAction() { properties.setReviewTurnInterval(8); properties.setMinAssistantTurns(2); when(conversationService.listMessages("conv-1")).thenReturn(transcriptWithTurns(3)); stubLlm("[{\"action\":\"create\",\"name\":\"spring-scaffold\",\"reason\":\"reusable\"," + "\"content\":\"---\\nname: spring-scaffold\\n---\\n# X\"}]"); when(skillManageTool.skillManageAs(eq(SkillOrigin.AGENT), any(), any(), any(), any(), any(), any(), any())) .thenReturn("Skill 'spring-scaffold' created successfully (security scan: PASSED)."); service.maybeReflect(1L, "conv-1", 8); verify(skillManageTool, times(1)) .skillManageAs(eq(SkillOrigin.AGENT), eq("create"), eq("spring-scaffold"), any(), any(), any(), any(), any()); } @Test @DisplayName("delete actions are ignored — reflection only creates/improves") void ignoresDelete() { properties.setReviewTurnInterval(8); properties.setMinAssistantTurns(2); when(conversationService.listMessages("conv-1")).thenReturn(transcriptWithTurns(3)); stubLlm("[{\"action\":\"delete\",\"name\":\"old-skill\",\"reason\":\"stale\"}]"); service.maybeReflect(1L, "conv-1", 8); verify(skillManageTool, never()).skillManageAs(eq(SkillOrigin.AGENT), any(), any(), any(), any(), any(), any(), any()); } @Test @DisplayName("maxActionsPerRun caps how many actions are applied") void capsActions() { properties.setReviewTurnInterval(8); properties.setMinAssistantTurns(2); properties.setMaxActionsPerRun(2); when(conversationService.listMessages("conv-1")).thenReturn(transcriptWithTurns(3)); String body = "\"content\":\"---\\nname: s\\n---\\n# X\""; stubLlm("[{\"action\":\"create\",\"name\":\"s1\"," + body + "}," + "{\"action\":\"create\",\"name\":\"s2\"," + body + "}," + "{\"action\":\"create\",\"name\":\"s3\"," + body + "}]"); when(skillManageTool.skillManageAs(eq(SkillOrigin.AGENT), any(), any(), any(), any(), any(), any(), any())) .thenReturn("created successfully"); service.maybeReflect(1L, "conv-1", 8); verify(skillManageTool, times(2)).skillManageAs(eq(SkillOrigin.AGENT), any(), any(), any(), any(), any(), any(), any()); } @Test @DisplayName("cadence gate: a message count that steps over the interval still reviews") void cadenceGateSurvivesSkippedCounts() { // The published count is the conversation total and can jump by more // than one per event (batched persistence, tool messages, channel // replays). A review must still fire when the count steps straight // over an exact multiple of the interval. properties.setReviewTurnInterval(8); properties.setMinAssistantTurns(2); when(conversationService.listMessages("conv-1")).thenReturn(transcriptWithTurns(3)); stubLlm("[]"); service.maybeReflect(1L, "conv-1", 11); verify(conversationService, times(1)).listMessages("conv-1"); } @Test @DisplayName("cadence gate: an attempt blocked by the floor waits a full interval") void floorBlockedAttemptStillAdvancesTheMark() { properties.setReviewTurnInterval(8); properties.setMinAssistantTurns(5); properties.setCooldownMinutes(0); when(conversationService.listMessages("conv-1")).thenReturn(transcriptWithTurns(1)); service.maybeReflect(1L, "conv-1", 8); // Only three further messages — below the interval, so no re-check. service.maybeReflect(1L, "conv-1", 11); verify(conversationService, times(1)).listMessages("conv-1"); } @Test @DisplayName("cooldown blocks a second review for the same conversation") void cooldownBlocksSecondRun() { properties.setReviewTurnInterval(8); properties.setMinAssistantTurns(2); when(conversationService.listMessages("conv-1")).thenReturn(transcriptWithTurns(3)); stubLlm("[]"); service.maybeReflect(1L, "conv-1", 8); service.maybeReflect(1L, "conv-1", 16); // listMessages is only reached on the first (non-cooled-down) run. verify(conversationService, times(1)).listMessages("conv-1"); } }