diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java index 8423ae04..3092c08e 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java @@ -82,7 +82,7 @@ public class ActionNode implements NodeAction { "extract_document_text", "extract_pdf_text", "extract_docx_text", "detect_file_type", "getCurrentDateTime", "getCurrentDate", "getCurrentTime", - "listSubagents" + "listSubagents", "getManagedGoalJsonSlots" ); private final ToolExecutionExecutor executor; diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonGraphIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonGraphIntegrationTest.java new file mode 100644 index 00000000..8839f6a7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonGraphIntegrationTest.java @@ -0,0 +1,163 @@ +package vip.mate.goal; + +import com.alibaba.cloud.ai.graph.CompiledGraph; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.ToolResponseMessage; +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 org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.util.ReflectionTestUtils; +import reactor.core.publisher.Flux; +import vip.mate.MateClawApplication; +import vip.mate.agent.AgentGraphBuilder; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.context.ChatOriginHolder; +import vip.mate.agent.context.ConversationWindowManager; +import vip.mate.agent.graph.StateGraphReActAgent; +import vip.mate.goal.model.*; +import vip.mate.goal.service.*; +import vip.mate.memory.spi.MemoryManager; +import vip.mate.tool.ToolRegistry; +import vip.mate.workspace.conversation.ConversationService; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Real compiled graph and callback executor; model choices are a deterministic offline fixture. */ +@SpringBootTest(classes = MateClawApplication.class, webEnvironment = SpringBootTest.WebEnvironment.NONE) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:json_graph_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", "spring.main.web-application-type=none", + "mateclaw.goal.enabled=true", "mateclaw.plugin.enabled=false", "mateclaw.skill.workspace.auto-init=false", + "mateclaw.skill.workspace.root=${java.io.tmpdir}/mateclaw-json-graph-skills-${random.uuid}" +}) +class GoalJsonGraphIntegrationTest { + @MockBean private MemoryManager memory; + @MockBean private GoalEvaluationService evaluator; + @MockBean private GoalContinuationSupervisor supervisor; + @Autowired private GoalService goals; + @Autowired private GoalJsonAcceptanceService requirements; + @Autowired private GoalJsonBindingService bindings; + @Autowired private GoalContinuationStore continuations; + @Autowired private GoalRunCoordinator coordinator; + @Autowired private JdbcTemplate jdbc; + @Autowired private ObjectMapper json; + @Autowired private AgentGraphBuilder builder; + @Autowired private ToolRegistry tools; + @Autowired private ConversationService conversations; + @Autowired private ConversationWindowManager window; + @Autowired private vip.mate.planning.service.PlanningService planning; + @Autowired private vip.mate.agent.progress.ProgressLedgerService progress; + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({"false,false", "false,true", "true,false", "true,true"}) + void realGraphsPreserveAuthenticatedOriginThroughReadPublishCheckAndComplete(boolean plan, boolean scheduled) throws Exception { + String username = "graph-" + UUID.randomUUID(); + long userId = IdWorker.getId(); + String conversation = UUID.randomUUID().toString(); + jdbc.update("INSERT INTO mate_user(id,username,password,enabled,role,create_time,update_time,deleted) VALUES (?,?,?,TRUE,'user',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0)", userId, username, "unused"); + jdbc.update("INSERT INTO mate_conversation(id,conversation_id,username,workspace_id,agent_id,create_time,update_time,deleted) VALUES (?,?,?,1,1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0)", IdWorker.getId(), conversation, username); + var request = new GoalCreateRequest(); request.setConversationId(conversation); request.setAgentId(1L); request.setWorkspaceId(1L); + request.setTitle("Managed graph fixture"); request.setDescription("Produce JSON"); request.setPersistentExecution(scheduled); request.setAutoFollowupEnabled(false); + GoalEntity goal = goals.create(request, username); + goals.appendCriterion(goal.getId(), "Produce the report", username); + goals.recordEvaluation(goal.getId(), new GoalEvaluationResult(1, "offline semantic fixture", "completed", true, + "fixture", 1, 0, List.of(new GoalChecklistVerdict.CriterionVerdict("C1", true, "fixture only")), null), 1, 1); + requirements.configure(goal.getId(), "r", new GoalJsonAcceptanceService.ConfigureRequest(0L, "report", List.of("summary")), username); + GoalRunCoordinator.ClaimedRun run = null; + ChatOrigin origin = ChatOrigin.web(conversation, username, 1L, null, null, userId).withAgent(1L); + if (scheduled) { + jdbc.update("UPDATE mate_agent_goal SET auto_followup_enabled=TRUE WHERE id=?", goal.getId()); + var now = java.time.LocalDateTime.now(); continuations.discover(now); + run = coordinator.claim(continuations.get(goal.getId()), goals.getById(goal.getId()), now); + assertNotNull(run); assertTrue(coordinator.markRunning(run, now)); + origin = ChatOrigin.web(conversation, username, 1L, null).withAgent(1L) + .withExecutionAttribution(new vip.mate.agent.context.ExecutionAttribution(goal.getId(), run.attempt().id(), null, null, run.attempt().leaseToken())); + } + when(evaluator.evaluate(any(), anyList(), anyString())).thenReturn(GoalEvaluationResult.fallback("offline_graph_fixture")); + var toolSet = tools.getEnabledToolSet().withAllowedToolsOnly(Set.of("getManagedGoalJsonSlots", "publishManagedGoalJson", "checkManagedGoalJson", "completeGoal")); + assertEquals(4, toolSet.callbacks().size()); + ChatModel model = mock(ChatModel.class); + AtomicInteger calls = new AtomicInteger(); + java.util.concurrent.atomic.AtomicReference revision = new java.util.concurrent.atomic.AtomicReference<>(); + org.mockito.stubbing.Answer script = invocation -> { + Prompt prompt = invocation.getArgument(0); + int step = calls.getAndIncrement(); + if (plan && step == 0) { + return new ChatResponse(List.of(new Generation(new AssistantMessage( + "{\"needs_planning\":true,\"steps\":[\"Produce, publish, check and complete the managed JSON report\"]}")))); + } + if (plan) step--; + List responses = prompt.getInstructions().stream() + .filter(ToolResponseMessage.class::isInstance).map(ToolResponseMessage.class::cast) + .flatMap(m -> m.getResponses().stream()).toList(); + JsonNode last = responses.isEmpty() ? null : json.readTree(responses.getLast().responseData()); + String name; String arguments = "{}"; + switch (step) { + case 0 -> name = "completeGoal"; + case 1 -> { assertTrue(last.path("error").asBoolean(), String.valueOf(last)); name = "getManagedGoalJsonSlots"; } + case 2 -> { + assertTrue(last.path("required").asBoolean(), String.valueOf(last)); + revision.set(last.path("requirements").get(0).path("revision").asText()); + name = "publishManagedGoalJson"; + arguments = json.writeValueAsString(Map.of("artifactSlot", "report", "expectedGeneration", "0", "jsonContent", "{\"summary\":false}")); + } + case 3 -> { + assertEquals(scheduled ? "goal-attempt" : "account-runtime", last.path("producerKind").asText(), String.valueOf(last)); + name = "checkManagedGoalJson"; + arguments = json.writeValueAsString(Map.of("criterionKey", "r", "expectedRequirementRevision", revision.get(), + "artifactId", last.path("artifactId").asText(), "expectedGeneration", last.path("generation").asText())); + } + case 4 -> { assertTrue(last.path("acceptanceEligible").asBoolean(), String.valueOf(last)); name = "completeGoal"; } + default -> { + if (last != null) assertEquals("completed", last.path("status").asText(), String.valueOf(last)); + assertEquals(GoalStatus.COMPLETED, goals.getById(goal.getId()).getStatus()); + return new ChatResponse(List.of(new Generation(new AssistantMessage("Managed JSON fixture completed.")))); + } + } + return new ChatResponse(List.of(new Generation(AssistantMessage.builder().content("") + .toolCalls(List.of(new AssistantMessage.ToolCall("json-" + step, "function", name, arguments))).build()))); + }; + when(model.call(any(Prompt.class))).thenAnswer(script); + when(model.stream(any(Prompt.class))).thenAnswer(invocation -> Flux.just(script.answer(invocation))); + CompiledGraph graph = ReflectionTestUtils.invokeMethod(builder, plan ? "buildPlanExecuteGraph" : "buildReActGraph", toolSet, model, 12, null); + assertNotNull(graph); + vip.mate.agent.BaseAgent agent = plan + ? new vip.mate.agent.graph.plan.StateGraphPlanExecuteAgent(mock(ChatClient.class), conversations, graph, planning, model, window, toolSet) + : new StateGraphReActAgent(mock(ChatClient.class), conversations, graph, model, window, toolSet); + ReflectionTestUtils.setField(agent, "agentId", "1"); + ReflectionTestUtils.setField(agent, "agentName", "JSON graph fixture"); + ReflectionTestUtils.setField(agent, "systemPrompt", "Follow the user's managed JSON requirements."); + ReflectionTestUtils.setField(agent, "goalService", goals); + ChatOriginHolder.set(origin); + try { assertNotNull(agent.chat("Produce and check the managed JSON report.", conversation)); } + finally { ChatOriginHolder.clear(); } + assertEquals(GoalStatus.COMPLETED, goals.getById(goal.getId()).getStatus()); + assertTrue(bindings.state(goal.getId(), username).getFirst().acceptanceEligible()); + if (scheduled) { + assertTrue(coordinator.settle(run, new SegmentOutcome.Complete("graph fixture"), java.time.LocalDateTime.now())); + assertEquals("completed", continuations.get(goal.getId()).state()); + } + assertFalse(progress.load(conversation).asMap().containsKey("auto_getManagedGoalJsonSlots"), + "Current acceptance reads must not become a permanent done step that discourages reloading"); + assertTrue(calls.get() >= 6 && calls.get() <= 10, "Bounded scripted model calls: " + calls.get()); + } +}