mateclaw/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java
taobig 2fa2e60170 feat(goal): persist continuous execution across bounded turns
- 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.
2026-08-26 05:46:15 -04:00

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());
}
}