package vip.mate.team.service; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import vip.mate.agent.AgentService; import vip.mate.channel.web.ChatStreamTracker; import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.model.TeamTaskStatus; import vip.mate.workspace.conversation.ConversationService; import java.util.List; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.startsWith; import static org.mockito.Mockito.*; /** * Pins the dispatch loop's arbitration rules: one task per member per sweep, * busy members skipped, the conditional assign as the only winner gate, the * circuit breaker short-circuit, and outcome settling (auto-complete vs * respecting a state the member already set). */ class TeamDispatchServiceTest { private static final Long TEAM_ID = 10L; private static final Long MEMBER_A = 2L; private static final Long MEMBER_B = 3L; private TeamService teamService; private TeamTaskService taskService; private AgentService agentService; private ConversationService conversationService; private ChatStreamTracker streamTracker; private TeamAnnounceService announceService; private TeamEventChannel eventChannel; private TeamDispatchService service; @BeforeEach void setUp() { teamService = mock(TeamService.class); taskService = mock(TeamTaskService.class); agentService = mock(AgentService.class); conversationService = mock(ConversationService.class); streamTracker = mock(ChatStreamTracker.class); announceService = mock(TeamAnnounceService.class); eventChannel = mock(TeamEventChannel.class); service = new TeamDispatchService(teamService, taskService, agentService, conversationService, streamTracker, announceService, eventChannel); } private TeamTaskEntity task(Long id, Long assignee) { TeamTaskEntity t = new TeamTaskEntity(); t.setId(id); t.setTeamId(TEAM_ID); t.setTaskNumber(id.intValue()); t.setSubject("task " + id); t.setStatus(TeamTaskStatus.PENDING); t.setAssigneeAgentId(assignee); t.setLeadConversationId("lead-conv"); return t; } // ==================== sweep arbitration ==================== @Test @DisplayName("one task per assignee per sweep — the second task for the same member waits") void onePerAssigneePerSweep() { TeamTaskEntity first = task(1L, MEMBER_A); TeamTaskEntity second = task(2L, MEMBER_A); TeamTaskEntity other = task(3L, MEMBER_B); // Second stub is empty so the async post-run re-sweep chain terminates. when(taskService.findDispatchable(TEAM_ID)) .thenReturn(List.of(first, second, other)) .thenReturn(List.of()); when(taskService.hasActiveTask(eq(TEAM_ID), any())).thenReturn(false); when(taskService.assignTask(any(), any())).thenReturn(true); when(taskService.tryAcquireDispatch(any())).thenReturn(true); when(taskService.getTask(any())).thenAnswer(inv -> task(inv.getArgument(0), MEMBER_A)); service.sweep(TEAM_ID); verify(taskService).assignTask(1L, MEMBER_A); verify(taskService, never()).assignTask(eq(2L), any()); verify(taskService).assignTask(3L, MEMBER_B); } @Test @DisplayName("a member already executing a task is skipped entirely") void busyMemberSkipped() { when(taskService.findDispatchable(TEAM_ID)).thenReturn(List.of(task(1L, MEMBER_A))); when(taskService.hasActiveTask(TEAM_ID, MEMBER_A)).thenReturn(true); service.sweep(TEAM_ID); verify(taskService, never()).assignTask(any(), any()); } @Test @DisplayName("losing the conditional assign means another sweep won — no dispatch") void assignRaceLostSkips() { when(taskService.findDispatchable(TEAM_ID)).thenReturn(List.of(task(1L, MEMBER_A))); when(taskService.hasActiveTask(TEAM_ID, MEMBER_A)).thenReturn(false); when(taskService.assignTask(1L, MEMBER_A)).thenReturn(false); service.sweep(TEAM_ID); verify(taskService, never()).tryAcquireDispatch(any()); } @Test @DisplayName("a tripped circuit breaker skips the run but announces the auto-fail to the lead") void breakerStopsDispatch() { when(taskService.findDispatchable(TEAM_ID)).thenReturn(List.of(task(1L, MEMBER_A))); when(taskService.hasActiveTask(TEAM_ID, MEMBER_A)).thenReturn(false); when(taskService.assignTask(1L, MEMBER_A)).thenReturn(true); when(taskService.tryAcquireDispatch(1L)).thenReturn(false); TeamTaskEntity failed = task(1L, MEMBER_A); failed.setStatus(TeamTaskStatus.FAILED); when(taskService.getTask(1L)).thenReturn(failed); service.sweep(TEAM_ID); // The work must not vanish silently: the lead hears about the auto-fail. verify(announceService).announceTaskSettled(failed); verifyNoInteractions(agentService); } // ==================== outcome settling ==================== @Test @DisplayName("an in_progress task is auto-completed with the member's final reply") void settleAutoCompletes() { TeamTaskEntity running = task(1L, MEMBER_A); running.setStatus(TeamTaskStatus.IN_PROGRESS); TeamTaskEntity done = task(1L, MEMBER_A); done.setStatus(TeamTaskStatus.COMPLETED); done.setResult("analysis finished"); when(taskService.getTask(1L)).thenReturn(running, done); when(taskService.completeTask(eq(1L), isNull(), anyString())).thenReturn(List.of()); service.settleOutcome(running, "analysis finished"); verify(taskService).completeTask(1L, null, "analysis finished"); verify(eventChannel).publishTaskEvent(any(), eq("team_task_completed"), any()); verify(announceService).announceTaskSettled(done); } @Test @DisplayName("a task the member already failed via blocker is not completed on top") void settleRespectsMemberFailure() { TeamTaskEntity failed = task(1L, MEMBER_A); failed.setStatus(TeamTaskStatus.FAILED); failed.setReason("blocked: missing docs"); when(taskService.getTask(1L)).thenReturn(failed); service.settleOutcome(failed, "irrelevant reply"); verify(taskService, never()).completeTask(any(), any(), anyString()); verify(eventChannel).publishTaskEvent(any(), eq("team_task_failed"), any()); } // ==================== run tracking & interrupt ==================== @Test @DisplayName("a member run is registered with the stream tracker and completed afterwards") void runTaskTracksChildConversation() { TeamTaskEntity assigned = task(1L, MEMBER_A); assigned.setStatus(TeamTaskStatus.IN_PROGRESS); TeamTaskEntity done = task(1L, MEMBER_A); done.setStatus(TeamTaskStatus.COMPLETED); when(taskService.getTask(1L)).thenReturn(assigned, done, done); when(taskService.completeTask(eq(1L), isNull(), anyString())).thenReturn(List.of()); when(agentService.chatWithUsage(eq(MEMBER_A), anyString(), anyString())) .thenReturn(AgentService.ChatResult.contentOnly("all done")); service.runTask(TEAM_ID, assigned); // The child run must be trackable so requestStop() can interrupt it. verify(streamTracker).register(startsWith("team-task-")); verify(streamTracker).incrementFlux(startsWith("team-task-")); verify(streamTracker).complete(startsWith("team-task-")); // Both sides of the run persist, so the task card's transcript view has content. verify(conversationService).saveMessage(startsWith("team-task-"), eq("user"), anyString()); verify(conversationService).saveMessage(startsWith("team-task-"), eq("assistant"), eq("all done")); } @Test @DisplayName("an interrupted run whose task was cancelled produces no failed event") void interruptedCancelledRunStaysSilent() { TeamTaskEntity assigned = task(1L, MEMBER_A); assigned.setStatus(TeamTaskStatus.IN_PROGRESS); when(agentService.chatWithUsage(eq(MEMBER_A), anyString(), anyString())) .thenThrow(new RuntimeException("run interrupted")); // The guarded transition refuses: the task is already terminal (cancelled). when(taskService.failTask(eq(1L), anyString())).thenReturn(false); service.runTask(TEAM_ID, assigned); verify(eventChannel, never()) .publishTaskEvent(any(), eq("team_task_failed"), any()); verify(announceService, never()).announceTaskSettled(any()); // Tracking still ends cleanly. verify(streamTracker).complete(startsWith("team-task-")); } @Test @DisplayName("a genuine member run error still fails the task and notifies the lead") void genuineRunErrorStillAnnounced() { TeamTaskEntity assigned = task(1L, MEMBER_A); assigned.setStatus(TeamTaskStatus.IN_PROGRESS); when(agentService.chatWithUsage(eq(MEMBER_A), anyString(), anyString())) .thenThrow(new RuntimeException("model unavailable")); when(taskService.failTask(eq(1L), anyString())).thenReturn(true); TeamTaskEntity failed = task(1L, MEMBER_A); failed.setStatus(TeamTaskStatus.FAILED); when(taskService.getTask(1L)).thenReturn(failed); service.runTask(TEAM_ID, assigned); verify(eventChannel).publishTaskEvent(any(), eq("team_task_failed"), any()); verify(announceService).announceTaskSettled(failed); } @Test @DisplayName("interruptRun stops the attached member conversation and tolerates idle tasks") void interruptRunStopsAttachedConversation() { TeamTaskEntity running = task(1L, MEMBER_A); running.setConversationId("team-task-abc"); when(streamTracker.requestStop("team-task-abc")).thenReturn(true); service.interruptRun(running); verify(streamTracker).requestStop("team-task-abc"); // Never-dispatched task and null task are silent no-ops. service.interruptRun(task(2L, MEMBER_A)); service.interruptRun(null); verifyNoMoreInteractions(streamTracker); } // ==================== prerequisite hand-off ==================== @Test @DisplayName("the dispatch envelope carries prerequisite results and deliverables") void envelopeCarriesPrerequisiteResults() { TeamTaskEntity dependent = task(3L, MEMBER_B); dependent.setBlockedBy("[\"1\",\"2\"]"); TeamTaskEntity done = task(1L, MEMBER_A); done.setStatus(TeamTaskStatus.COMPLETED); done.setResult("pricing collected: 3 competitors"); when(taskService.getTask(1L)).thenReturn(done); when(taskService.getTask(2L)).thenReturn(null); // vanished blocker is skipped when(taskService.listDeliverables(done)).thenReturn(List.of( new TeamTaskService.Deliverable("prices.xlsx", "/api/v1/files/generated/x", null))); StringBuilder sb = new StringBuilder(); service.appendPrerequisiteResults(sb, dependent); String section = sb.toString(); assertTrue(section.contains("[Prerequisite results]")); assertTrue(section.contains("pricing collected: 3 competitors")); assertTrue(section.contains("prices.xlsx → /api/v1/files/generated/x")); assertFalse(section.contains("#2"), "vanished blockers leave no trace"); // No blockers → no section at all. StringBuilder plain = new StringBuilder(); service.appendPrerequisiteResults(plain, task(4L, MEMBER_A)); assertEquals(0, plain.length()); } @Test @DisplayName("an oversized member reply is truncated before persisting") void settleTruncatesLongReply() { TeamTaskEntity running = task(1L, MEMBER_A); running.setStatus(TeamTaskStatus.IN_PROGRESS); when(taskService.getTask(1L)).thenReturn(running, running); when(taskService.completeTask(eq(1L), isNull(), anyString())).thenReturn(List.of()); service.settleOutcome(running, "x".repeat(TeamDispatchService.MAX_RESULT_CHARS + 500)); verify(taskService).completeTask(eq(1L), isNull(), argThat(r -> r.length() <= TeamDispatchService.MAX_RESULT_CHARS + 20 && r.endsWith("...(truncated)"))); } }