mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
- Add a database-backed supervisor with durable scheduling, fenced leases, cooldowns, bounded worker concurrency and expired-lease restart recovery. - Default new goals to persistent execution with zero meaning unlimited cumulative budget; preserve legacy goals and explicit positive limits. - Yield bounded graph segments to the supervisor instead of ending unfinished goals at graph-local continuation limits. Require persisted checklist evidence before accepting completion, including concurrent criterion edits. - Share conversation admission across interactive and background execution; preserve partial replies, usage and queued user input during interruption. - Persist Stop and missing-input pauses, respect approval boundaries, and commit resume and approval-denial transitions with correct transactions. - Retry identifiable transient failures with backoff; retain visible pauses for budget limits and errors that require review instead of replaying tools. - Expose owner-authorized execution status and reconnectable scheduling events; add H2, MySQL and Kingbase migrations, API types and bilingual documentation. Validation: 298 focused backend tests passed, including persistence, restart scheduling, approval races, cancellation, admission and existing runtime tests. Frontend type checking, bundled-doc parity and ID precision checks passed. V188 is registered and all three dialects have unique migration versions; the migration-map audit still reports 95 pre-existing missing registrations. Scope: single-backend native runtime. Recovery checks existing state before repeating effects; this does not promise exactly-once external tool execution.
235 lines
9.9 KiB
Java
235 lines
9.9 KiB
Java
package vip.mate.tool.builtin;
|
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.junit.jupiter.api.extension.ExtendWith;
|
|
import org.mockito.Mock;
|
|
import org.mockito.junit.jupiter.MockitoExtension;
|
|
import org.springframework.ai.chat.model.ToolContext;
|
|
import vip.mate.agent.context.ChatOrigin;
|
|
import vip.mate.exception.MateClawException;
|
|
import vip.mate.goal.config.GoalProperties;
|
|
import vip.mate.goal.model.GoalCreateRequest;
|
|
import vip.mate.goal.model.GoalEntity;
|
|
import vip.mate.goal.model.GoalEvaluationResult;
|
|
import vip.mate.goal.model.GoalStatus;
|
|
import vip.mate.goal.service.GoalService;
|
|
|
|
import java.util.Map;
|
|
|
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
import static org.mockito.ArgumentMatchers.anyString;
|
|
import static org.mockito.ArgumentMatchers.eq;
|
|
import static org.mockito.Mockito.never;
|
|
import static org.mockito.Mockito.verify;
|
|
import static org.mockito.Mockito.when;
|
|
|
|
/**
|
|
* Covers the four @Tool methods on {@link GoalManagementTool}, especially
|
|
* the disable-flag short-circuit + ChatOrigin requirement gates.
|
|
*/
|
|
@ExtendWith(MockitoExtension.class)
|
|
class GoalManagementToolTest {
|
|
|
|
@Mock private GoalService goalService;
|
|
@Mock private vip.mate.channel.web.ChatStreamTracker streamTracker;
|
|
|
|
private GoalProperties properties;
|
|
private GoalManagementTool tool;
|
|
|
|
@BeforeEach
|
|
void setUp() {
|
|
properties = new GoalProperties();
|
|
properties.setEnabled(true);
|
|
tool = new GoalManagementTool(goalService, properties, new ObjectMapper(), streamTracker);
|
|
}
|
|
|
|
private ToolContext ctxWith(String convId, Long agentId, String requester) {
|
|
ChatOrigin origin = ChatOrigin.web(convId, requester, 1L, "/tmp")
|
|
.withAgent(agentId);
|
|
return origin.toToolContext();
|
|
}
|
|
|
|
private GoalEntity goal(GoalStatus status) {
|
|
GoalEntity g = new GoalEntity();
|
|
g.setId(123L);
|
|
g.setConversationId("conv-1");
|
|
g.setAgentId(10L);
|
|
g.setWorkspaceId(1L);
|
|
g.setTitle("ship the blog");
|
|
g.setStatus(status);
|
|
g.setTurnBudget(20);
|
|
g.setTurnsUsed(3);
|
|
g.setLlmCallBudget(200);
|
|
g.setAgentLlmCallsUsed(12);
|
|
g.setEvalLlmCallsUsed(2);
|
|
g.setAutoFollowupEnabled(false);
|
|
return g;
|
|
}
|
|
|
|
// ==================== setGoal ====================
|
|
|
|
@Test
|
|
void setGoal_disabledFlag_returnsError() {
|
|
properties.setEnabled(false);
|
|
String result = tool.setGoal("title", null, null, null, null, null,
|
|
ctxWith("conv-1", 10L, "alice"));
|
|
assertTrue(result.contains("disabled"));
|
|
verify(goalService, never()).create(any(), anyString());
|
|
}
|
|
|
|
@Test
|
|
void setGoal_blankTitle_returnsError() {
|
|
String result = tool.setGoal(" ", null, null, null, null, null,
|
|
ctxWith("conv-1", 10L, "alice"));
|
|
assertTrue(result.contains("title is required"));
|
|
}
|
|
|
|
@Test
|
|
void setGoal_happyPath_returnsGoalId() {
|
|
when(goalService.create(any(GoalCreateRequest.class), eq("alice")))
|
|
.thenReturn(goal(GoalStatus.ACTIVE));
|
|
String result = tool.setGoal("ship the blog",
|
|
"deploy to fly.io",
|
|
"tests pass + deployed",
|
|
15, true, null,
|
|
ctxWith("conv-1", 10L, "alice"));
|
|
assertTrue(result.contains("\"goalId\":\"123\""));
|
|
assertTrue(result.contains("\"status\":\"active\""));
|
|
}
|
|
|
|
@Test
|
|
void setGoal_missingConversationContext_returnsError() {
|
|
String result = tool.setGoal("title", null, null, null, null, null, null);
|
|
assertTrue(result.contains("requires a bound conversation"));
|
|
}
|
|
|
|
// ==================== addGoalCriterion ====================
|
|
|
|
@Test
|
|
void addCriterion_noActiveGoal_returnsError() {
|
|
when(goalService.findActiveByConversation("conv-1")).thenReturn(null);
|
|
String result = tool.addGoalCriterion("test on Safari too",
|
|
ctxWith("conv-1", 10L, "alice"));
|
|
assertTrue(result.contains("No active goal"));
|
|
verify(goalService, never()).appendCriterion(any(), anyString(), anyString());
|
|
}
|
|
|
|
@Test
|
|
void addCriterion_blankInput_returnsError() {
|
|
String result = tool.addGoalCriterion(" ",
|
|
ctxWith("conv-1", 10L, "alice"));
|
|
assertTrue(result.contains("must not be empty"));
|
|
}
|
|
|
|
@Test
|
|
void addCriterion_happyPath_delegatesToService() {
|
|
when(goalService.findActiveByConversation("conv-1")).thenReturn(goal(GoalStatus.ACTIVE));
|
|
when(goalService.appendCriterion(eq(123L), eq("test on Safari"), eq("alice")))
|
|
.thenReturn(goal(GoalStatus.ACTIVE));
|
|
String result = tool.addGoalCriterion("test on Safari",
|
|
ctxWith("conv-1", 10L, "alice"));
|
|
assertTrue(result.contains("\"goalId\":\"123\""));
|
|
}
|
|
|
|
// ==================== completeGoal ====================
|
|
|
|
@Test
|
|
void completeGoal_noActiveGoal_returnsError() {
|
|
when(goalService.findActiveByConversation("conv-1")).thenReturn(null);
|
|
String result = tool.completeGoal(ctxWith("conv-1", 10L, "alice"));
|
|
assertTrue(result.contains("No active goal"));
|
|
verify(goalService, never()).markCompleted(any(), any(GoalEvaluationResult.class));
|
|
}
|
|
|
|
@Test
|
|
void completeGoal_happyPath_callsMarkCompleted() {
|
|
when(goalService.findActiveByConversation("conv-1")).thenReturn(goal(GoalStatus.ACTIVE));
|
|
GoalEntity completed = goal(GoalStatus.COMPLETED);
|
|
when(goalService.markCompleted(eq(123L), any(GoalEvaluationResult.class)))
|
|
.thenReturn(completed);
|
|
when(goalService.toResponse(any())).thenReturn(new vip.mate.goal.model.GoalResponse());
|
|
String result = tool.completeGoal(ctxWith("conv-1", 10L, "alice"));
|
|
assertTrue(result.contains("\"status\":\"completed\""));
|
|
}
|
|
|
|
// ==================== getGoalStatus ====================
|
|
|
|
@Test
|
|
void getGoalStatus_noActive_returnsActiveFalse() {
|
|
when(goalService.findActiveByConversation("conv-1")).thenReturn(null);
|
|
String result = tool.getGoalStatus(ctxWith("conv-1", 10L, "alice"));
|
|
assertTrue(result.contains("\"active\":false"));
|
|
}
|
|
|
|
@Test
|
|
void getGoalStatus_active_carriesProgressSummary() {
|
|
GoalEntity g = goal(GoalStatus.ACTIVE);
|
|
g.setProgressSummary("missing DNS");
|
|
g.setCompletionScore(0.62);
|
|
when(goalService.findActiveByConversation("conv-1")).thenReturn(g);
|
|
String result = tool.getGoalStatus(ctxWith("conv-1", 10L, "alice"));
|
|
assertTrue(result.contains("\"goalId\":\"123\""));
|
|
assertTrue(result.contains("\"completionScore\":0.62"));
|
|
assertTrue(result.contains("missing DNS"));
|
|
// total = agent(12) + eval(2)
|
|
assertTrue(result.contains("\"totalLlmCallsUsed\":14"));
|
|
}
|
|
@Test
|
|
void waitForGoalInputRequiresReasonAndBoundContext() {
|
|
ToolContext ctx = ctxWith("conv-1", 10L, "alice");
|
|
assertTrue(tool.waitForGoalInput(" ", ctx).contains("reason"));
|
|
assertTrue(tool.waitForGoalInput("Need hostname", null).contains("bound conversation"));
|
|
verify(goalService, never()).findActiveByConversation(anyString());
|
|
verify(goalService, never()).waitForInput(any(), anyString(), anyString());
|
|
}
|
|
|
|
@Test
|
|
void waitForGoalInputRequiresEnabledPersistentActiveGoal() {
|
|
ToolContext ctx = ctxWith("conv-1", 10L, "alice");
|
|
properties.setEnabled(false);
|
|
assertTrue(tool.waitForGoalInput("Need hostname", ctx).contains("disabled"));
|
|
verify(goalService, never()).findActiveByConversation(anyString());
|
|
properties.setEnabled(true);
|
|
GoalEntity goal = goal(GoalStatus.ACTIVE);
|
|
when(goalService.findActiveByConversation("conv-1")).thenReturn(goal);
|
|
assertTrue(tool.waitForGoalInput("Need hostname", ctx).contains("persistent"));
|
|
goal.setPersistentExecution(true);
|
|
goal.setStatus(GoalStatus.PAUSED);
|
|
assertTrue(tool.waitForGoalInput("Need hostname", ctx).contains("active goal"));
|
|
verify(goalService, never()).waitForInput(any(), anyString(), anyString());
|
|
}
|
|
|
|
@Test
|
|
void waitForGoalInputPausesBoundGoal_andBroadcastsUpdatedState() {
|
|
GoalEntity active = goal(GoalStatus.ACTIVE);
|
|
active.setPersistentExecution(true);
|
|
GoalEntity paused = goal(GoalStatus.PAUSED);
|
|
paused.setPersistentExecution(true);
|
|
paused.setProgressSummary("Waiting for input: Need the deployment hostname");
|
|
when(goalService.findActiveByConversation("conv-1")).thenReturn(active);
|
|
when(goalService.waitForInput(123L, "Need the deployment hostname", "alice")).thenReturn(paused);
|
|
when(goalService.toResponse(paused)).thenReturn(new vip.mate.goal.model.GoalResponse());
|
|
String result = tool.waitForGoalInput(" Need the deployment hostname ", ctxWith("conv-1", 10L, "alice"));
|
|
assertTrue(result.contains("\"status\":\"paused\""));
|
|
assertTrue(result.contains("Need the deployment hostname"));
|
|
verify(streamTracker).broadcastObject(eq("conv-1"), eq("goal_updated"), any());
|
|
verify(goalService, never()).markCompleted(any(), any());
|
|
}
|
|
|
|
@Test
|
|
void waitForGoalInputReturnsErrorIfStateChangedBeforePause() {
|
|
GoalEntity active = goal(GoalStatus.ACTIVE);
|
|
active.setPersistentExecution(true);
|
|
when(goalService.findActiveByConversation("conv-1")).thenReturn(active);
|
|
when(goalService.waitForInput(123L, "Need hostname", "alice"))
|
|
.thenThrow(new MateClawException("err.goal.bad_transition", 409, "Goal no longer active"));
|
|
assertTrue(tool.waitForGoalInput("Need hostname", ctxWith("conv-1", 10L, "alice")).contains("Goal no longer active"));
|
|
verify(streamTracker, never()).broadcastObject(anyString(), anyString(), any());
|
|
}
|
|
|
|
}
|