mateclaw/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java

976 lines
47 KiB
Java

package vip.mate.goal.service;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DuplicateKeyException;
import vip.mate.audit.service.AuditEventService;
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.GoalEventEntity;
import vip.mate.goal.model.GoalStatus;
import vip.mate.goal.model.GoalUpdateRequest;
import vip.mate.goal.repository.GoalEventMapper;
import vip.mate.goal.repository.GoalMapper;
import java.time.LocalDateTime;
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.assertThrows;
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.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Unit tests for {@link GoalServiceImpl} — covers CRUD, state machine,
* evaluation bookkeeping, budget exhaustion, optimistic-lock retry, and
* the DB unique-index 409 mapping.
*/
@ExtendWith(MockitoExtension.class)
class GoalServiceTest {
@Mock private GoalMapper goalMapper;
@Mock private GoalEventMapper eventMapper;
@Mock private AuditEventService auditEventService;
private GoalServiceImpl service;
private GoalProperties properties;
@BeforeAll
static void initTableInfo() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new Configuration(), ""),
GoalEntity.class);
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new Configuration(), ""),
GoalEventEntity.class);
}
@BeforeEach
void setUp() {
properties = new GoalProperties();
service = new GoalServiceImpl(goalMapper, eventMapper, properties,
auditEventService, new ObjectMapper());
}
// ==================== Helpers ====================
private GoalCreateRequest validReq() {
GoalCreateRequest r = new GoalCreateRequest();
r.setConversationId("conv-1");
r.setAgentId(10L);
r.setWorkspaceId(1L);
r.setTitle("ship the blog");
r.setDescription("deploy and verify");
r.setExitCriteria("hello world accessible");
return r;
}
private GoalEntity persisted(Long id, GoalStatus status) {
GoalEntity g = new GoalEntity();
g.setId(id);
g.setConversationId("conv-1");
g.setAgentId(10L);
g.setWorkspaceId(1L);
g.setCreatedBy("alice");
g.setTitle("ship the blog");
g.setDescription("desc");
g.setStatus(status);
g.setTurnBudget(20);
g.setTurnsUsed(0);
g.setLlmCallBudget(200);
g.setAgentLlmCallsUsed(0);
g.setEvalLlmCallsUsed(0);
g.setAutoFollowupEnabled(false);
g.setFollowupCooldownSeconds(0);
g.setVersion(0);
g.setDeleted(0);
g.setCreateTime(LocalDateTime.now());
g.setUpdateTime(LocalDateTime.now());
return g;
}
// ==================== create ====================
@Test
void create_succeeds_whenNoActiveGoalExists() {
when(goalMapper.selectOne(any())).thenReturn(null);
when(goalMapper.insert(any(GoalEntity.class))).thenReturn(1);
GoalEntity created = service.create(validReq(), "alice");
assertNotNull(created);
assertEquals("alice", created.getCreatedBy());
assertEquals(GoalStatus.ACTIVE, created.getStatus());
assertTrue(created.getPersistentExecution());
assertEquals(0, created.getTurnBudget());
assertEquals(0, created.getLlmCallBudget());
verify(eventMapper, times(1)).insert(any(GoalEventEntity.class));
verify(auditEventService).record(eq("goal.created"), eq("goal"),
anyString(), anyString(), anyString(), any());
}
@Test
void create_returns409_whenActiveGoalAlreadyExists() {
when(goalMapper.selectOne(any())).thenReturn(persisted(99L, GoalStatus.ACTIVE));
MateClawException ex = assertThrows(MateClawException.class,
() -> service.create(validReq(), "alice"));
assertEquals(409, ex.getCode());
verify(goalMapper, never()).insert(any(GoalEntity.class));
}
@Test
void create_returns409_whenDbUniqueIndexHits() {
// Concurrent race: pre-check sees nothing, but the DB does.
when(goalMapper.selectOne(any())).thenReturn(null);
when(goalMapper.insert(any(GoalEntity.class)))
.thenThrow(new DuplicateKeyException("uk_agent_goal_active_conv"));
MateClawException ex = assertThrows(MateClawException.class,
() -> service.create(validReq(), "alice"));
assertEquals(409, ex.getCode());
}
@Test
void create_rejectsBlankTitle() {
GoalCreateRequest r = validReq();
r.setTitle("");
MateClawException ex = assertThrows(MateClawException.class,
() -> service.create(r, "alice"));
assertEquals(400, ex.getCode());
}
@Test
void create_rejectsNonPositiveBudget() {
GoalCreateRequest r = validReq();
r.setPersistentExecution(false);
r.setTurnBudget(0);
MateClawException ex = assertThrows(MateClawException.class,
() -> service.create(r, "alice"));
assertEquals(400, ex.getCode());
}
@Test
void create_persistenceDefaultCanBeDisabled_andExplicitOptInWins() {
properties.setDefaultPersistentExecution(false);
GoalEntity legacy = service.create(validReq(), "alice");
assertFalse(legacy.getPersistentExecution());
assertEquals(20, legacy.getTurnBudget());
assertEquals(200, legacy.getLlmCallBudget());
GoalCreateRequest req = validReq();
req.setPersistentExecution(true);
GoalEntity persistent = service.create(req, "alice");
assertTrue(persistent.getPersistentExecution());
assertEquals(0, persistent.getTurnBudget());
assertEquals(0, persistent.getLlmCallBudget());
assertTrue(service.toResponse(persistent).getPersistentExecution());
}
@Test
void create_explicitLegacyRetainsDefaults() {
GoalCreateRequest req = validReq();
req.setPersistentExecution(false);
GoalEntity goal = service.create(req, "alice");
assertFalse(goal.getPersistentExecution());
assertEquals(20, goal.getTurnBudget());
assertEquals(200, goal.getLlmCallBudget());
}
@Test
void create_persistentAcceptsZero_andHonorsPositiveBudgets() {
GoalCreateRequest req = validReq();
req.setTurnBudget(0);
req.setLlmCallBudget(7);
GoalEntity goal = service.create(req, "alice");
assertEquals(0, goal.getTurnBudget());
assertEquals(7, goal.getLlmCallBudget());
req.setTurnBudget(-1);
assertEquals(400, assertThrows(MateClawException.class,
() -> service.create(req, "alice")).getCode());
req.setTurnBudget(1);
req.setLlmCallBudget(-1);
assertEquals(400, assertThrows(MateClawException.class,
() -> service.create(req, "alice")).getCode());
}
@Test
void persistentZeroBudgetsAreUnlimited_butLegacyZeroIsExhausted() {
GoalEntity goal = persisted(1L, GoalStatus.ACTIVE);
goal.setPersistentExecution(true);
goal.setTurnBudget(0);
goal.setLlmCallBudget(0);
goal.setTurnsUsed(999);
goal.setAgentLlmCallsUsed(999);
assertFalse(service.isBudgetExhausted(goal));
goal.setPersistentExecution(false);
assertTrue(service.isBudgetExhausted(goal));
}
@Test
void persistentPositiveBudgetsRemainBinding() {
GoalEntity goal = persisted(1L, GoalStatus.ACTIVE);
goal.setPersistentExecution(true);
goal.setTurnBudget(0);
goal.setLlmCallBudget(10);
goal.setAgentLlmCallsUsed(9);
assertFalse(service.isBudgetExhausted(goal));
goal.setEvalLlmCallsUsed(1);
assertTrue(service.isBudgetExhausted(goal));
assertEquals("llm_call_budget", service.exhaustionReason(goal));
goal.setTurnBudget(3);
goal.setTurnsUsed(3);
assertEquals("turn_budget", service.exhaustionReason(goal));
}
@Test
void persistentBudgetExhaustionPauses_withResumableReason() {
GoalEntity goal = persisted(1L, GoalStatus.ACTIVE);
goal.setPersistentExecution(true);
goal.setTurnsUsed(20);
when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.PAUSED));
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
GoalEntity result = service.markExhausted(1L, "turn_budget");
assertEquals(GoalStatus.PAUSED, result.getStatus());
ArgumentCaptor<GoalEventEntity> event = ArgumentCaptor.forClass(GoalEventEntity.class);
verify(eventMapper).insert(event.capture());
assertEquals("paused", event.getValue().getEventType());
assertTrue(event.getValue().getDetailJson().contains("turn_budget"));
ArgumentCaptor<LambdaUpdateWrapper> update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(goalMapper).update(any(), update.capture());
assertTrue(update.getValue().getSqlSet().contains("progress_summary"));
assertTrue(update.getValue().getParamNameValuePairs().values().stream()
.anyMatch(value -> String.valueOf(value).contains("turn_budget")));
}
@Test
void resumePersistentRequiresBudgetHeadroom_andAllowsRaisedBudget() {
GoalEntity goal = persisted(1L, GoalStatus.PAUSED);
goal.setPersistentExecution(true);
goal.setTurnsUsed(20);
when(goalMapper.selectById(1L)).thenReturn(goal);
assertEquals(409, assertThrows(MateClawException.class,
() -> service.resume(1L, "alice")).getCode());
verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class));
goal.setTurnBudget(21);
when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.ACTIVE));
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
assertEquals(GoalStatus.ACTIVE, service.resume(1L, "alice").getStatus());
}
@Test
void updateUsesFreshMode_forZeroBudget_andDoesNotReplaceOmittedBudgets() {
GoalEntity goal = persisted(1L, GoalStatus.ACTIVE);
goal.setPersistentExecution(true);
when(goalMapper.selectById(1L)).thenReturn(goal);
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
GoalUpdateRequest req = new GoalUpdateRequest();
req.setTurnBudget(0);
service.update(1L, req, "alice");
ArgumentCaptor<LambdaUpdateWrapper> update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(goalMapper).update(any(), update.capture());
assertTrue(setsProperty(update.getValue(), "turnBudget"), update.getValue().getSqlSet());
assertFalse(setsProperty(update.getValue(), "llmCallBudget"));
assertFalse(setsProperty(update.getValue(), "persistentExecution"));
}
@Test
void updateModeValidatesCombinedState_beforeWriting() {
GoalEntity goal = persisted(1L, GoalStatus.ACTIVE);
goal.setPersistentExecution(true);
goal.setTurnBudget(0);
goal.setLlmCallBudget(0);
when(goalMapper.selectById(1L)).thenReturn(goal);
GoalUpdateRequest req = new GoalUpdateRequest();
req.setPersistentExecution(false);
assertEquals(400, assertThrows(MateClawException.class,
() -> service.update(1L, req, "alice")).getCode());
verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class));
req.setTurnBudget(10);
req.setLlmCallBudget(100);
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
service.update(1L, req, "alice");
ArgumentCaptor<LambdaUpdateWrapper> update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(goalMapper).update(any(), update.capture());
assertTrue(setsProperty(update.getValue(), "persistentExecution"), update.getValue().getSqlSet());
}
private GoalEvaluationResult completedEvaluation() {
return new GoalEvaluationResult(1.0, "", "completed", true, "fixture", 1, 0,
java.util.List.of(), null);
}
@Test
void automaticCompletionCannotForcePassNewCriteria() {
GoalEntity goal = verifiedPersistentGoal(GoalStatus.ACTIVE);
goal.setPersistentExecution(false);
goal.setCriteria("[{\"id\":\"C1\",\"text\":\"new requirement\",\"passed\":false,\"evidence\":\"\"}]");
when(goalMapper.selectById(1L)).thenReturn(goal);
lenient().when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
assertEquals(409, assertThrows(MateClawException.class,
() -> service.markEvaluatedCompleted(1L, completedEvaluation())).getCode());
verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class));
}
@Test
void automaticCompletionRechecksCurrentCriteriaAfterCasMiss() {
GoalEntity old = verifiedPersistentGoal(GoalStatus.ACTIVE);
old.setPersistentExecution(false);
GoalEntity fresh = verifiedPersistentGoal(GoalStatus.ACTIVE);
fresh.setPersistentExecution(false);
fresh.setVersion(1);
fresh.setCriteria("[{\"id\":\"C1\",\"text\":\"new requirement\",\"passed\":false,\"evidence\":\"\"}]");
when(goalMapper.selectById(1L)).thenReturn(old, fresh);
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0);
assertEquals(409, assertThrows(MateClawException.class,
() -> service.markEvaluatedCompleted(1L, completedEvaluation())).getCode());
verify(goalMapper, times(1)).update(any(), any(LambdaUpdateWrapper.class));
}
@Test
void automaticCompletionCannotOverridePauseOrAbandonment() {
for (GoalStatus status : java.util.List.of(GoalStatus.PAUSED, GoalStatus.ABANDONED)) {
GoalEntity goal = verifiedPersistentGoal(status);
goal.setPersistentExecution(false);
when(goalMapper.selectById(1L)).thenReturn(goal);
assertEquals(409, assertThrows(MateClawException.class,
() -> service.markEvaluatedCompleted(1L, completedEvaluation())).getCode());
}
verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class));
verify(eventMapper, never()).insert(any(GoalEventEntity.class));
}
@Test
void automaticCompletionPreservesCurrentChecklist() {
GoalEntity goal = verifiedPersistentGoal(GoalStatus.ACTIVE);
goal.setPersistentExecution(false);
when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.COMPLETED));
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
assertEquals(GoalStatus.COMPLETED, service.markEvaluatedCompleted(1L, completedEvaluation()).getStatus());
ArgumentCaptor<LambdaUpdateWrapper> update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(goalMapper).update(any(), update.capture());
assertFalse(update.getValue().getSqlSet().contains("criteria="));
}
@Test
void automaticCompletionRejectsMissingOrFallbackEvaluation() {
assertEquals(409, assertThrows(MateClawException.class,
() -> service.markEvaluatedCompleted(1L, null)).getCode());
assertEquals(409, assertThrows(MateClawException.class,
() -> service.markEvaluatedCompleted(1L, GoalEvaluationResult.fallback("unavailable"))).getCode());
verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class));
}
@Test
void explicitLegacyCompletionRetainsItsSeparateCompatibilityPath() {
GoalEntity goal = persisted(1L, GoalStatus.ACTIVE);
goal.setPersistentExecution(false);
goal.setCriteria("[{\"id\":\"C1\",\"text\":\"manual check\",\"passed\":false,\"evidence\":\"\"}]");
when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.COMPLETED));
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
assertEquals(GoalStatus.COMPLETED, service.markCompleted(1L, completedEvaluation()).getStatus());
ArgumentCaptor<LambdaUpdateWrapper> update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(goalMapper).update(any(), update.capture());
assertTrue(update.getValue().getSqlSet().contains("criteria="));
}
@Test
void persistentCompletionRequiresFreshPassedCriteriaWithEvidence() {
GoalEntity goal = persisted(1L, GoalStatus.ACTIVE);
goal.setPersistentExecution(true);
when(goalMapper.selectById(1L)).thenReturn(goal);
for (String criteria : new String[]{null, "[]",
"[{\"id\":\"C1\",\"text\":\"deploy\",\"passed\":false,\"evidence\":\"attempted\"}]",
"[{\"id\":\"C1\",\"text\":\"deploy\",\"passed\":true,\"evidence\":\" \"}]"}) {
goal.setCriteria(criteria);
MateClawException error = assertThrows(MateClawException.class,
() -> service.markCompleted(1L, null));
assertEquals(409, error.getCode());
}
verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class));
}
@Test
void persistentCompletionCannotOverridePause() {
GoalEntity goal = verifiedPersistentGoal(GoalStatus.PAUSED);
when(goalMapper.selectById(1L)).thenReturn(goal);
assertEquals(409, assertThrows(MateClawException.class,
() -> service.markCompleted(1L, null)).getCode());
verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class));
}
@Test
void persistentCompletionPreservesVerifiedChecklist() {
GoalEntity goal = verifiedPersistentGoal(GoalStatus.ACTIVE);
when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.COMPLETED));
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
assertEquals(GoalStatus.COMPLETED, service.markCompleted(1L, null).getStatus());
ArgumentCaptor<LambdaUpdateWrapper> update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(goalMapper).update(any(), update.capture());
assertFalse(update.getValue().getSqlSet().contains("criteria="));
}
@Test
void persistentCompletionRechecksEvidenceAfterCasMiss() {
GoalEntity old = verifiedPersistentGoal(GoalStatus.ACTIVE);
GoalEntity fresh = verifiedPersistentGoal(GoalStatus.ACTIVE);
fresh.setVersion(1);
fresh.setCriteria("[{\"id\":\"C1\",\"text\":\"new requirement\",\"passed\":false,\"evidence\":\"\"}]");
when(goalMapper.selectById(1L)).thenReturn(old, fresh);
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0);
assertEquals(409, assertThrows(MateClawException.class,
() -> service.markCompleted(1L, null)).getCode());
verify(goalMapper, times(1)).update(any(), any(LambdaUpdateWrapper.class));
}
@Test
void persistentResumePublishesSignalOnlyAfterSuccessfulTransition() {
var publisher = org.mockito.Mockito.mock(org.springframework.context.ApplicationEventPublisher.class);
service.setApplicationEventPublisher(publisher);
GoalEntity goal = verifiedPersistentGoal(GoalStatus.PAUSED);
goal.setTurnsUsed(20);
when(goalMapper.selectById(1L)).thenReturn(goal);
assertThrows(MateClawException.class, () -> service.resume(1L, "alice"));
verify(publisher, never()).publishEvent(any(Object.class));
goal.setTurnBudget(21);
when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.ACTIVE));
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
service.resume(1L, "alice");
verify(publisher).publishEvent(new GoalExecutionSignal.Resume(1L));
}
private boolean setsProperty(LambdaUpdateWrapper<?> update, String property) {
String column = TableInfoHelper.getTableInfo(GoalEntity.class).getFieldList().stream()
.filter(field -> property.equals(field.getProperty())).findFirst().orElseThrow().getColumn();
return update.getSqlSet().contains(column + "=");
}
private GoalEntity verifiedPersistentGoal(GoalStatus status) {
GoalEntity goal = persisted(1L, status);
goal.setPersistentExecution(true);
goal.setCriteria("[{\"id\":\"C1\",\"text\":\"deploy\",\"passed\":true,\"evidence\":\"HTTP 200 verified\"}]");
return goal;
}
// ==================== state transitions ====================
@Test
void pause_flipsActiveToPaused_andWritesEvent() {
GoalEntity g = persisted(1L, GoalStatus.ACTIVE);
when(goalMapper.selectById(1L)).thenReturn(g, statusFlipped(g, GoalStatus.PAUSED));
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
GoalEntity result = service.pause(1L, "alice");
assertEquals(GoalStatus.PAUSED, result.getStatus());
ArgumentCaptor<GoalEventEntity> evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class);
verify(eventMapper).insert(evCaptor.capture());
assertEquals("paused", evCaptor.getValue().getEventType());
}
@Test
void pause_failsWhenGoalIsTerminal() {
when(goalMapper.selectById(1L)).thenReturn(persisted(1L, GoalStatus.COMPLETED));
MateClawException ex = assertThrows(MateClawException.class,
() -> service.pause(1L, "alice"));
assertEquals(409, ex.getCode());
}
@Test
void resume_flipsPausedToActive() {
GoalEntity g = persisted(1L, GoalStatus.PAUSED);
when(goalMapper.selectById(1L)).thenReturn(g, statusFlipped(g, GoalStatus.ACTIVE));
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
assertEquals(GoalStatus.ACTIVE, service.resume(1L, "alice").getStatus());
}
@Test
void abandon_flipsAnyNonTerminalToAbandoned() {
GoalEntity g = persisted(1L, GoalStatus.ACTIVE);
when(goalMapper.selectById(1L)).thenReturn(g, statusFlipped(g, GoalStatus.ABANDONED));
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
assertEquals(GoalStatus.ABANDONED, service.abandon(1L, "alice").getStatus());
}
@Test
void markCompleted_isIdempotent_onTerminal() {
var memory = mock(vip.mate.memory.spi.MemoryManager.class);
service.setMemoryManager(memory);
GoalEntity g = persisted(1L, GoalStatus.COMPLETED);
when(goalMapper.selectById(1L)).thenReturn(g);
GoalEntity result = service.markCompleted(1L, null);
assertEquals(GoalStatus.COMPLETED, result.getStatus());
verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class));
verifyNoInteractions(eventMapper, auditEventService, memory);
}
@Test
void completionCasLoserDoesNotRepeatWinnerSideEffects() {
var memory = mock(vip.mate.memory.spi.MemoryManager.class);
service.setMemoryManager(memory);
GoalEntity active = persisted(1L, GoalStatus.ACTIVE);
GoalEntity completed = statusFlipped(active, GoalStatus.COMPLETED);
when(goalMapper.selectById(1L)).thenReturn(active, completed);
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0);
assertEquals(GoalStatus.COMPLETED, service.markCompleted(1L, null).getStatus());
verify(goalMapper, times(1)).update(any(), any(LambdaUpdateWrapper.class));
verifyNoInteractions(eventMapper, auditEventService, memory);
}
@Test
void markExhausted_carriesReasonInDetail() {
GoalEntity g = persisted(1L, GoalStatus.ACTIVE);
when(goalMapper.selectById(1L)).thenReturn(g, statusFlipped(g, GoalStatus.EXHAUSTED));
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
service.markExhausted(1L, "turn_budget");
ArgumentCaptor<GoalEventEntity> evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class);
verify(eventMapper).insert(evCaptor.capture());
assertEquals("exhausted", evCaptor.getValue().getEventType());
assertTrue(evCaptor.getValue().getDetailJson().contains("turn_budget"));
}
// ==================== evaluation bookkeeping ====================
@Test
void recordEvaluation_bumpsCountersAndWritesEvent() {
GoalEntity g = persisted(1L, GoalStatus.ACTIVE);
when(goalMapper.selectById(1L)).thenReturn(g);
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
GoalEvaluationResult r = new GoalEvaluationResult(
0.62, "DNS still missing", "continue", false,
"qwen-turbo", 1, 800L,
java.util.List.of(), null);
service.recordEvaluation(1L, r, 3, 1);
ArgumentCaptor<GoalEventEntity> evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class);
verify(eventMapper).insert(evCaptor.capture());
assertEquals("evaluated", evCaptor.getValue().getEventType());
String detail = evCaptor.getValue().getDetailJson();
assertTrue(detail.contains("agentLlmCallsDelta"));
assertTrue(detail.contains("evalLlmCallsDelta"));
assertTrue(detail.contains("qwen-turbo"));
}
@Test
void recordEvaluation_isNoop_onTerminalGoal() {
when(goalMapper.selectById(1L)).thenReturn(persisted(1L, GoalStatus.COMPLETED));
service.recordEvaluation(1L, null, 5, 1);
verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class));
verify(eventMapper, never()).insert(any(GoalEventEntity.class));
}
@Test
void isBudgetExhausted_detectsTurnBudgetHit() {
GoalEntity g = persisted(1L, GoalStatus.ACTIVE);
g.setTurnsUsed(20);
g.setTurnBudget(20);
assertTrue(service.isBudgetExhausted(g));
assertEquals("turn_budget", service.exhaustionReason(g));
}
@Test
void isBudgetExhausted_detectsLlmBudgetHit() {
GoalEntity g = persisted(1L, GoalStatus.ACTIVE);
g.setAgentLlmCallsUsed(180);
g.setEvalLlmCallsUsed(25);
g.setLlmCallBudget(200);
assertTrue(service.isBudgetExhausted(g));
assertEquals("llm_call_budget", service.exhaustionReason(g));
}
@Test
void isBudgetExhausted_returnsFalse_whenHeadroomRemains() {
GoalEntity g = persisted(1L, GoalStatus.ACTIVE);
g.setTurnsUsed(5);
g.setAgentLlmCallsUsed(30);
g.setEvalLlmCallsUsed(4);
assertFalse(service.isBudgetExhausted(g));
}
@Test
void appendCriterion_concatenatesWithMarker() {
GoalEntity g = persisted(1L, GoalStatus.ACTIVE);
g.setExitCriteria("DNS works");
when(goalMapper.selectById(1L)).thenReturn(g, g);
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
service.appendCriterion(1L, "tests pass", "alice");
ArgumentCaptor<GoalEventEntity> evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class);
verify(eventMapper).insert(evCaptor.capture());
assertEquals("criterion_added", evCaptor.getValue().getEventType());
assertTrue(evCaptor.getValue().getDetailJson().contains("tests pass"));
}
@Test
void appendCriterion_rejectsBlankInput() {
// Validation happens before selectById, so we do NOT stub the mapper.
MateClawException ex = assertThrows(MateClawException.class,
() -> service.appendCriterion(1L, " ", "alice"));
assertEquals(400, ex.getCode());
verify(goalMapper, never()).selectById(any());
}
private GoalEvaluationResult bootstrapEvaluation() {
return new GoalEvaluationResult(0.0, "checklist created", "continue", false, "fixture", 1, 0,
java.util.List.of(), java.util.List.of(
new vip.mate.goal.model.GoalCriterion("C1", "model draft", false, "")));
}
@Test
void bootstrapInitializesStillEmptyChecklist() {
GoalEntity empty = persisted(1L, GoalStatus.ACTIVE);
when(goalMapper.selectById(1L)).thenReturn(empty);
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
service.recordEvaluation(1L, bootstrapEvaluation(), 2, 1);
ArgumentCaptor<LambdaUpdateWrapper> writes = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(goalMapper).update(any(), writes.capture());
assertTrue(setsProperty(writes.getValue(), "criteria"));
assertTrue(writes.getValue().getParamNameValuePairs().values().stream()
.anyMatch(value -> String.valueOf(value).contains("model draft")));
}
@Test
void lateBootstrapPreservesChecklistEstablishedByUser() {
GoalEntity fresh = persisted(1L, GoalStatus.ACTIVE);
fresh.setCriteria("[{\"id\":\"C1\",\"text\":\"user requirement\",\"passed\":false,\"evidence\":\"\"}]");
when(goalMapper.selectById(1L)).thenReturn(fresh);
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
service.recordEvaluation(1L, bootstrapEvaluation(), 2, 1);
ArgumentCaptor<LambdaUpdateWrapper> writes = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(goalMapper).update(any(), writes.capture());
assertFalse(setsProperty(writes.getValue(), "criteria"), "late bootstrap must preserve current user criteria");
assertTrue(writes.getValue().getSqlSet().contains("eval_llm_calls_used = eval_llm_calls_used + 1"));
}
@Test
void bootstrapRechecksEmptyChecklistAfterCasConflict() {
GoalEntity empty = persisted(1L, GoalStatus.ACTIVE);
GoalEntity fresh = persisted(1L, GoalStatus.ACTIVE);
fresh.setVersion(1);
fresh.setCriteria("[{\"id\":\"C1\",\"text\":\"concurrent user requirement\",\"passed\":false,\"evidence\":\"\"}]");
when(goalMapper.selectById(1L)).thenReturn(empty, empty, fresh, fresh);
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0, 1);
service.recordEvaluation(1L, bootstrapEvaluation(), 2, 1);
ArgumentCaptor<LambdaUpdateWrapper> writes = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(goalMapper, times(2)).update(any(), writes.capture());
assertTrue(setsProperty(writes.getAllValues().get(0), "criteria"));
assertFalse(setsProperty(writes.getAllValues().get(1), "criteria"), "retry must not replace newly established criteria");
}
@Test
void verdictProgressRecomputesAfterCasConflict() {
GoalEntity original = persisted(1L, GoalStatus.ACTIVE);
original.setCriteria("[{\"id\":\"C1\",\"text\":\"report\",\"passed\":false,\"evidence\":\"\"}]");
GoalEntity fresh = persisted(1L, GoalStatus.ACTIVE);
fresh.setVersion(1);
fresh.setCriteria("[{\"id\":\"C1\",\"text\":\"report\",\"passed\":false,\"evidence\":\"\"},"
+ "{\"id\":\"C2\",\"text\":\"appendix\",\"passed\":false,\"evidence\":\"\"}]");
when(goalMapper.selectById(1L)).thenReturn(original, original, fresh, fresh);
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0, 1);
var result = new GoalEvaluationResult(1.0, "", "completed", true, "fixture", 1, 0,
java.util.List.of(new vip.mate.goal.model.GoalChecklistVerdict.CriterionVerdict(
"C1", true, "report written")), null);
service.recordEvaluation(1L, result, 2, 1);
ArgumentCaptor<LambdaUpdateWrapper> writes = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(goalMapper, times(2)).update(any(), writes.capture());
var first = writes.getAllValues().getFirst();
var retry = writes.getAllValues().getLast();
assertTrue(setsProperty(first, "completionScore"));
assertTrue(setsProperty(retry, "completionScore"));
assertTrue(first.getParamNameValuePairs().containsValue(1.0));
assertTrue(retry.getParamNameValuePairs().containsValue(0.5));
assertTrue(retry.getParamNameValuePairs().containsValue("Still missing: appendix"));
}
@Test
void staleEvaluationStopsProjectingAfterCasRevisionChange() {
GoalEntity original = persisted(1L, GoalStatus.ACTIVE);
original.setCriteria("[{\"id\":\"C1\",\"text\":\"report\",\"passed\":false,\"evidence\":\"\"}]");
GoalEntity fresh = persisted(1L, GoalStatus.ACTIVE);
fresh.setVersion(1); fresh.setEvaluationRevision(1L); fresh.setCriteria(original.getCriteria());
when(goalMapper.selectById(1L)).thenReturn(original, original, fresh, fresh);
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0, 1);
var result = new GoalEvaluationResult(1.0, "", "completed", true, "fixture", 1, 0,
java.util.List.of(new vip.mate.goal.model.GoalChecklistVerdict.CriterionVerdict("C1", true, "evidence")), null);
service.recordEvaluation(1L, result, 2, 1);
ArgumentCaptor<LambdaUpdateWrapper> writes = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(goalMapper, times(2)).update(any(), writes.capture());
assertTrue(setsProperty(writes.getAllValues().getFirst(), "criteria"));
assertFalse(setsProperty(writes.getAllValues().getLast(), "criteria"));
assertFalse(setsProperty(writes.getAllValues().getLast(), "completionScore"));
assertTrue(writes.getAllValues().getLast().getSqlSet().contains("eval_llm_calls_used = eval_llm_calls_used + 1"));
}
// ==================== criteria checklist ====================
@Test
void create_normalizesInitialCriteria_assignsIdsForcesUnpassed() {
when(goalMapper.selectOne(any())).thenReturn(null);
when(goalMapper.insert(any(GoalEntity.class))).thenReturn(1);
GoalCreateRequest r = validReq();
r.setCriteria(java.util.List.of(
new vip.mate.goal.model.GoalCriterion("ignored", "tests pass", true, "x"),
new vip.mate.goal.model.GoalCriterion("", " ", false, ""), // blank dropped
new vip.mate.goal.model.GoalCriterion("", "deployed", false, "")));
ArgumentCaptor<GoalEntity> captor = ArgumentCaptor.forClass(GoalEntity.class);
service.create(r, "alice");
verify(goalMapper).insert(captor.capture());
java.util.List<vip.mate.goal.model.GoalCriterion> parsed =
vip.mate.goal.model.GoalCriteriaCodec.parse(captor.getValue().getCriteria(), new ObjectMapper());
assertEquals(2, parsed.size());
assertEquals("C1", parsed.get(0).id());
assertEquals("tests pass", parsed.get(0).text());
assertFalse(parsed.get(0).passed()); // forced false even though caller said true
assertEquals("C2", parsed.get(1).id());
assertEquals("deployed", parsed.get(1).text());
}
@Test
void create_emptyCriteria_leavesColumnNull_forBootstrap() {
when(goalMapper.selectOne(any())).thenReturn(null);
when(goalMapper.insert(any(GoalEntity.class))).thenReturn(1);
ArgumentCaptor<GoalEntity> captor = ArgumentCaptor.forClass(GoalEntity.class);
service.create(validReq(), "alice");
verify(goalMapper).insert(captor.capture());
assertNull(captor.getValue().getCriteria());
}
@Test
void create_autoFollowup_threeState() {
when(goalMapper.selectOne(any())).thenReturn(null);
when(goalMapper.insert(any(GoalEntity.class))).thenReturn(1);
// null -> config default (true by default)
assertTrue(service.create(validReq(), "alice").getAutoFollowupEnabled());
// explicit false is honored
GoalCreateRequest off = validReq();
off.setAutoFollowupEnabled(false);
assertFalse(service.create(off, "alice").getAutoFollowupEnabled());
}
@Test
void toResponse_parsesCriteriaArray_nullBecomesEmpty() {
GoalEntity g = persisted(1L, GoalStatus.ACTIVE);
g.setCriteria("[{\"id\":\"C1\",\"text\":\"a\",\"passed\":true,\"evidence\":\"ok\"}]");
var resp = service.toResponse(g);
assertEquals(1, resp.getCriteria().size());
assertTrue(resp.getCriteria().get(0).passed());
GoalEntity bare = persisted(2L, GoalStatus.ACTIVE); // criteria == null
assertNotNull(service.toResponse(bare).getCriteria());
assertTrue(service.toResponse(bare).getCriteria().isEmpty());
}
// ==================== optimistic lock retry ====================
@Test
void update_failsAfterRetriesExhausted_whenVersionAlwaysStale() {
GoalEntity g = persisted(1L, GoalStatus.ACTIVE);
when(goalMapper.selectById(1L)).thenReturn(g);
// Always return 0 rows affected — simulates persistent version conflict.
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0);
GoalUpdateRequest upd = new GoalUpdateRequest();
upd.setTitle("new title");
MateClawException ex = assertThrows(MateClawException.class,
() -> service.update(1L, upd, "alice"));
assertEquals(409, ex.getCode());
verify(goalMapper, times(3)).update(any(), any(LambdaUpdateWrapper.class));
}
/**
* Regression: after the first CAS miss the retry loop must refetch the
* entity so the rebuilt wrapper carries the current version. Previously
* the wrapper was captured once with version=oldVersion, so once stale
* it could never succeed even when contention cleared.
*/
@Test
void update_succeedsOnSecondAttempt_afterRefetchPicksUpFreshVersion() {
GoalEntity v0 = persisted(1L, GoalStatus.ACTIVE);
v0.setVersion(0);
GoalEntity v1 = persisted(1L, GoalStatus.ACTIVE);
v1.setVersion(1);
GoalEntity v2 = persisted(1L, GoalStatus.ACTIVE);
v2.setVersion(2);
// First refetch returns v0 (stale — CAS will miss). Second refetch
// returns v1 (fresh — CAS will succeed). Third call (post-update
// selectById) returns the final v2 state for the return value.
when(goalMapper.selectById(1L)).thenReturn(v0, v1, v2);
// First update misses (rows=0), second update succeeds (rows=1).
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class)))
.thenReturn(0).thenReturn(1);
GoalUpdateRequest upd = new GoalUpdateRequest();
upd.setTitle("retry-survives");
GoalEntity out = service.update(1L, upd, "alice");
assertNotNull(out);
// Two update attempts (one miss + one hit) plus three selectById
// calls (two for the loop refetch, one for the post-update return).
verify(goalMapper, times(2)).update(any(), any(LambdaUpdateWrapper.class));
verify(goalMapper, times(3)).selectById(1L);
}
@Test
void findActiveByConversation_returnsNull_forBlankInput() {
assertNull(service.findActiveByConversation(""));
assertNull(service.findActiveByConversation(null));
verify(goalMapper, never()).selectOne(any());
}
@Test
void findLatestByConversation_returnsNull_forBlankInput() {
assertNull(service.findLatestByConversation(""));
assertNull(service.findLatestByConversation(null));
verify(goalMapper, never()).selectOne(any());
}
@Test
void getById_throws404_whenMissing() {
when(goalMapper.selectById(1L)).thenReturn(null);
MateClawException ex = assertThrows(MateClawException.class, () -> service.getById(1L));
assertEquals(404, ex.getCode());
}
/** Helper: mutate a copy of {@code g} with a new status, simulating
* what the post-update selectById would return. */
private GoalEntity statusFlipped(GoalEntity g, GoalStatus newStatus) {
GoalEntity copy = new GoalEntity();
copy.setId(g.getId());
copy.setConversationId(g.getConversationId());
copy.setAgentId(g.getAgentId());
copy.setWorkspaceId(g.getWorkspaceId());
copy.setCreatedBy(g.getCreatedBy());
copy.setTitle(g.getTitle());
copy.setStatus(newStatus);
copy.setPersistentExecution(g.getPersistentExecution());
copy.setTurnBudget(g.getTurnBudget());
copy.setTurnsUsed(g.getTurnsUsed());
copy.setLlmCallBudget(g.getLlmCallBudget());
copy.setAgentLlmCallsUsed(g.getAgentLlmCallsUsed());
copy.setEvalLlmCallsUsed(g.getEvalLlmCallsUsed());
copy.setVersion(g.getVersion() + 1);
copy.setDeleted(0);
copy.setCreateTime(g.getCreateTime());
copy.setUpdateTime(LocalDateTime.now());
return copy;
}
@Test
void waitForInputPausesActivePersistentGoal_andRecordsReason() {
GoalEntity goal = verifiedPersistentGoal(GoalStatus.ACTIVE);
when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.PAUSED));
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
GoalEntity paused = service.waitForInput(1L, " Need production hostname from the owner ", "alice");
assertEquals(GoalStatus.PAUSED, paused.getStatus());
ArgumentCaptor<LambdaUpdateWrapper> update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(goalMapper).update(any(), update.capture());
assertTrue(setsProperty(update.getValue(), "progressSummary"));
assertTrue(update.getValue().getParamNameValuePairs().containsValue(
"Waiting for input: Need production hostname from the owner"));
assertTrue(update.getValue().getParamNameValuePairs().containsValue(GoalStatus.PAUSED));
ArgumentCaptor<GoalEventEntity> event = ArgumentCaptor.forClass(GoalEventEntity.class);
verify(eventMapper).insert(event.capture());
assertEquals("paused", event.getValue().getEventType());
assertTrue(event.getValue().getDetailJson().contains("Need production hostname from the owner"));
verify(auditEventService).record(eq("goal.waiting_input"), eq("goal"), eq("1"),
anyString(), anyString(), any());
}
@Test
void waitForInputRejectsBlankReasonBeforeAccessingGoal() {
for (String reason : new String[]{null, "", " "}) {
assertEquals(400, assertThrows(MateClawException.class,
() -> service.waitForInput(1L, reason, "alice")).getCode());
}
verify(goalMapper, never()).selectById(any());
}
@Test
void waitForInputRechecksActiveStateAfterCasConflict() {
GoalEntity active = verifiedPersistentGoal(GoalStatus.ACTIVE);
GoalEntity paused = statusFlipped(active, GoalStatus.PAUSED);
when(goalMapper.selectById(1L)).thenReturn(active, paused);
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0);
assertEquals(409, assertThrows(MateClawException.class,
() -> service.waitForInput(1L, "Need deployment approval", "alice")).getCode());
verify(goalMapper, times(1)).update(any(), any(LambdaUpdateWrapper.class));
verify(eventMapper, never()).insert(any(GoalEventEntity.class));
}
@Test
void waitForInputRejectsLegacyAndTerminalGoals() {
GoalEntity goal = persisted(1L, GoalStatus.ACTIVE);
when(goalMapper.selectById(1L)).thenReturn(goal);
assertEquals(409, assertThrows(MateClawException.class,
() -> service.waitForInput(1L, "Need deployment approval", "alice")).getCode());
goal.setPersistentExecution(true);
goal.setStatus(GoalStatus.COMPLETED);
assertEquals(409, assertThrows(MateClawException.class,
() -> service.waitForInput(1L, "Need deployment approval", "alice")).getCode());
verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class));
}
@Test
void waitForInputBoundsPersistedReason() {
GoalEntity goal = verifiedPersistentGoal(GoalStatus.ACTIVE);
when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.PAUSED));
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
service.waitForInput(1L, "Missing permission: " + "x".repeat(10000), "alice");
ArgumentCaptor<LambdaUpdateWrapper> update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(goalMapper).update(any(), update.capture());
update.getValue().getSqlSet();
String summary = ((java.util.Map<?, ?>) update.getValue().getParamNameValuePairs()).values().stream()
.filter(value -> value instanceof String && ((String) value).startsWith("Waiting for input: "))
.map(String::valueOf).findFirst().orElseThrow();
assertTrue(summary.length() <= 2048);
}
@Test
void lateEvaluationAccountsUsageWithoutOverwritingPersistentPauseReason() {
GoalEntity active = verifiedPersistentGoal(GoalStatus.ACTIVE);
GoalEntity paused = statusFlipped(active, GoalStatus.PAUSED);
paused.setProgressSummary("Waiting for input: Need deployment approval");
when(goalMapper.selectById(1L)).thenReturn(active, paused, paused);
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
GoalEvaluationResult evaluation = new GoalEvaluationResult(0.4, "More work needed",
GoalEvaluationResult.DECISION_CONTINUE, false, "stub", 1, 0, java.util.List.of(), null);
service.recordEvaluation(1L, evaluation, 3, 1);
ArgumentCaptor<LambdaUpdateWrapper> update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(goalMapper).update(any(), update.capture());
assertFalse(setsProperty(update.getValue(), "progressSummary"));
assertTrue(update.getValue().getSqlSet().contains("agent_llm_calls_used = agent_llm_calls_used + 3"));
assertTrue(update.getValue().getSqlSet().contains("eval_llm_calls_used = eval_llm_calls_used + 1"));
}
}