mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 14:11:49 +08:00
676 lines
30 KiB
Java
676 lines
30 KiB
Java
package vip.mate.team.tool;
|
|
|
|
import org.junit.jupiter.api.AfterEach;
|
|
import vip.mate.decision.api.DecisionRecordingException;
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.DisplayName;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.mockito.ArgumentCaptor;
|
|
import org.mockito.InOrder;
|
|
import org.springframework.ai.chat.model.ToolContext;
|
|
import vip.mate.agent.context.ChatOrigin;
|
|
import vip.mate.agent.model.AgentEntity;
|
|
import vip.mate.agent.repository.AgentMapper;
|
|
import vip.mate.team.model.AgentTeamEntity;
|
|
import vip.mate.team.model.TeamRunCreateCommand;
|
|
import vip.mate.team.model.TeamRunEntity;
|
|
import vip.mate.team.model.TeamRunStatus;
|
|
import vip.mate.team.model.TeamTaskCreateCommand;
|
|
import vip.mate.team.model.TeamTaskEntity;
|
|
import vip.mate.team.model.TeamTaskStatus;
|
|
import vip.mate.team.service.TeamDispatchService;
|
|
import vip.mate.team.service.TeamEventChannel;
|
|
import vip.mate.team.service.TeamService;
|
|
import vip.mate.team.service.TeamRunService;
|
|
import vip.mate.team.service.TeamTaskService;
|
|
import vip.mate.tool.builtin.ToolExecutionContext;
|
|
import vip.mate.workspace.conversation.ConversationService;
|
|
import vip.mate.workspace.conversation.model.ConversationEntity;
|
|
import vip.mate.workspace.conversation.model.MessageEntity;
|
|
|
|
import java.util.List;
|
|
import java.util.Optional;
|
|
|
|
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.Mockito.*;
|
|
|
|
/**
|
|
* Pins the tool facade's contracts: caller resolution via the conversation,
|
|
* team membership gating, lead-only actions, LLM-friendly error strings, and
|
|
* the pass-through into TeamTaskService.
|
|
*/
|
|
class TeamTasksToolTest {
|
|
|
|
private static final String CONV = "conv-1";
|
|
private static final Long TEAM_ID = 10L;
|
|
private static final Long LEAD_ID = 1L;
|
|
private static final Long MEMBER_ID = 2L;
|
|
private static final Long WORKSPACE_ID = 30L;
|
|
private static final Long RUN_ID = 20L;
|
|
|
|
private TeamService teamService;
|
|
private TeamTaskService taskService;
|
|
private TeamRunService runService;
|
|
private TeamDispatchService dispatchService;
|
|
private TeamEventChannel eventChannel;
|
|
private ConversationService conversationService;
|
|
private AgentMapper agentMapper;
|
|
private TeamTasksTool tool;
|
|
private AgentTeamEntity team;
|
|
|
|
@BeforeEach
|
|
void setUp() {
|
|
teamService = mock(TeamService.class);
|
|
taskService = mock(TeamTaskService.class);
|
|
runService = mock(TeamRunService.class);
|
|
dispatchService = mock(TeamDispatchService.class);
|
|
eventChannel = mock(TeamEventChannel.class);
|
|
conversationService = mock(ConversationService.class);
|
|
agentMapper = mock(AgentMapper.class);
|
|
tool = new TeamTasksTool(teamService, taskService, runService, dispatchService,
|
|
eventChannel, conversationService, agentMapper);
|
|
|
|
team = new AgentTeamEntity();
|
|
team.setId(TEAM_ID);
|
|
team.setName("研发组");
|
|
team.setLeadAgentId(LEAD_ID);
|
|
team.setWorkspaceId(WORKSPACE_ID);
|
|
|
|
ToolExecutionContext.set(CONV, "admin");
|
|
}
|
|
|
|
@AfterEach
|
|
void tearDown() {
|
|
ToolExecutionContext.clear();
|
|
}
|
|
|
|
private void callerIs(Long agentId) {
|
|
ConversationEntity conv = new ConversationEntity();
|
|
conv.setConversationId(CONV);
|
|
conv.setAgentId(agentId);
|
|
conv.setWorkspaceId(WORKSPACE_ID);
|
|
when(conversationService.findByConversationId(CONV)).thenReturn(conv);
|
|
when(teamService.getTeamForAgent(agentId)).thenReturn(Optional.of(team));
|
|
when(teamService.isLead(team, agentId)).thenReturn(agentId.equals(LEAD_ID));
|
|
}
|
|
|
|
private TeamTaskEntity task(Long id, String status) {
|
|
TeamTaskEntity t = new TeamTaskEntity();
|
|
t.setId(id);
|
|
t.setTeamId(TEAM_ID);
|
|
t.setTaskNumber(3);
|
|
t.setSubject("collect data");
|
|
t.setStatus(status);
|
|
t.setAssigneeAgentId(MEMBER_ID);
|
|
return t;
|
|
}
|
|
|
|
private String invoke(String action, String taskId) {
|
|
return tool.team_tasks(action, taskId, null, null, null, null, null, null,
|
|
null, null, null, null, null, null, null, null, null, null, null);
|
|
}
|
|
|
|
@Test void methodCallbackEncodedProgressIsGroundedInActualSuccessfulWrite() throws Exception {
|
|
callerIs(MEMBER_ID);
|
|
when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS));
|
|
when(taskService.updateProgress(eq(5L), eq(MEMBER_ID), eq(100), anyString())).thenReturn(true);
|
|
var capture = new vip.mate.tool.mcp.runtime.McpToolResultCapture();
|
|
var ctx = capture.attach(new ToolContext(java.util.Map.of()));
|
|
String args = "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100,\"step\":\"done\"}";
|
|
var callback = org.springframework.ai.support.ToolCallbacks.from(tool)[0];
|
|
String result = callback.call(args, ctx);
|
|
assertFalse(capture.isError());
|
|
var ledger = vip.mate.agent.graph.state.ActionExecutionLedger.fromEvents(List.of(
|
|
vip.mate.agent.GraphEventPublisher.toolStart("p", "team_tasks", args),
|
|
vip.mate.agent.GraphEventPublisher.toolComplete("p", "team_tasks", result, !capture.isError())));
|
|
var facts = new com.fasterxml.jackson.databind.ObjectMapper().readTree(ledger.projectAnswerFacts(3000).text());
|
|
assertEquals(1, facts.path("successfulProgressUpdates").size());
|
|
assertEquals(100, facts.path("successfulProgressUpdates").get(0).path("percent").asInt());
|
|
verify(taskService).updateProgress(5L, MEMBER_ID, 100, "done");
|
|
}
|
|
|
|
@Test void rejectedNativeProgressCannotEstablishSuccessfulMilestone() throws Exception {
|
|
callerIs(MEMBER_ID);
|
|
when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS));
|
|
when(taskService.updateProgress(eq(5L), eq(MEMBER_ID), eq(100), anyString())).thenReturn(false);
|
|
var capture = new vip.mate.tool.mcp.runtime.McpToolResultCapture();
|
|
var ctx = capture.attach(new ToolContext(java.util.Map.of()));
|
|
String result = tool.team_tasks("progress", "5", null, null, null, null, null, null,
|
|
null, null, null, null, 100, "done", null, null, null, null, ctx);
|
|
assertTrue(capture.isError());
|
|
var ledger = vip.mate.agent.graph.state.ActionExecutionLedger.fromEvents(List.of(
|
|
vip.mate.agent.GraphEventPublisher.toolStart("p", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"),
|
|
vip.mate.agent.GraphEventPublisher.toolComplete("p", "team_tasks", result, !capture.isError())));
|
|
assertEquals(1, ledger.failedCallCount());
|
|
var facts = new com.fasterxml.jackson.databind.ObjectMapper().readTree(ledger.projectAnswerFacts(3000).text());
|
|
assertTrue(facts.path("successfulProgressUpdates").isEmpty());
|
|
verify(eventChannel, never()).publishTaskEvent(any(), eq("team_task_progress"), any());
|
|
}
|
|
|
|
private TeamRunEntity run(Long teamId, String conversationId, String status) {
|
|
TeamRunEntity run = new TeamRunEntity();
|
|
run.setId(RUN_ID);
|
|
run.setTeamId(teamId);
|
|
run.setWorkspaceId(WORKSPACE_ID);
|
|
run.setLeadConversationId(conversationId);
|
|
run.setStatus(status);
|
|
return run;
|
|
}
|
|
|
|
private TeamTaskEntity savedWorkerTask() {
|
|
TeamTaskEntity task = task(5L, TeamTaskStatus.COMPLETED);
|
|
task.setConversationId("team-task-worker");
|
|
task.setLeadConversationId(CONV);
|
|
task.setRunId(RUN_ID);
|
|
task.setResult("short board summary");
|
|
when(taskService.getTask(5L)).thenReturn(task);
|
|
when(runService.requireRun(RUN_ID, WORKSPACE_ID))
|
|
.thenReturn(run(TEAM_ID, CONV, TeamRunStatus.COMPLETED));
|
|
ConversationEntity worker = new ConversationEntity();
|
|
worker.setConversationId(task.getConversationId());
|
|
worker.setWorkspaceId(WORKSPACE_ID);
|
|
worker.setAgentId(MEMBER_ID);
|
|
worker.setConversationKind("team_worker");
|
|
worker.setParentConversationId(CONV);
|
|
when(conversationService.findByConversationId(task.getConversationId())).thenReturn(worker);
|
|
return task;
|
|
}
|
|
|
|
@Test
|
|
void outputReturnsCompleteSavedWorkerReplyInsteadOfBoardSummary() {
|
|
callerIs(LEAD_ID);
|
|
TeamTaskEntity task = savedWorkerTask();
|
|
MessageEntity message = new MessageEntity();
|
|
message.setRole("assistant");
|
|
message.setStatus("completed");
|
|
String full = "HEAD\n" + "analysis\n".repeat(2000) + "FINAL VERIFICATION";
|
|
message.setContent(full);
|
|
when(conversationService.listRecentMessages(task.getConversationId(), 1)).thenReturn(List.of(message));
|
|
|
|
String output = invoke("output", "5");
|
|
|
|
assertTrue(output.contains(full));
|
|
assertTrue(output.contains("taskId=5"));
|
|
assertFalse(output.contains("short board summary"));
|
|
}
|
|
|
|
@Test
|
|
void outputRejectsForeignTeamBeforeReadingAnyWorkerMessages() {
|
|
callerIs(LEAD_ID);
|
|
TeamTaskEntity foreign = task(5L, TeamTaskStatus.COMPLETED);
|
|
foreign.setTeamId(999L);
|
|
when(taskService.getTask(5L)).thenReturn(foreign);
|
|
|
|
assertTrue(invoke("output", "5").contains("not found on this team's board"));
|
|
verify(conversationService, never()).listRecentMessages(anyString(), anyInt());
|
|
}
|
|
|
|
@Test
|
|
void outputRejectsWorkerConversationOutsideTeamWorkspace() {
|
|
callerIs(LEAD_ID);
|
|
TeamTaskEntity task = savedWorkerTask();
|
|
conversationService.findByConversationId(task.getConversationId()).setWorkspaceId(999L);
|
|
|
|
assertTrue(invoke("output", "5").startsWith("Error:"));
|
|
verify(conversationService, never()).listRecentMessages(anyString(), anyInt());
|
|
}
|
|
|
|
@Test
|
|
void outputRejectsWorkerConversationWithDifferentParent() {
|
|
callerIs(LEAD_ID);
|
|
TeamTaskEntity task = savedWorkerTask();
|
|
conversationService.findByConversationId(task.getConversationId()).setParentConversationId("unrelated-lead");
|
|
|
|
assertTrue(invoke("output", "5").startsWith("Error:"));
|
|
verify(conversationService, never()).listRecentMessages(anyString(), anyInt());
|
|
}
|
|
|
|
@Test
|
|
void outputRejectsWorkerConversationWithDifferentAgent() {
|
|
callerIs(MEMBER_ID);
|
|
TeamTaskEntity task = savedWorkerTask();
|
|
conversationService.findByConversationId(task.getConversationId()).setAgentId(999L);
|
|
|
|
assertTrue(invoke("output", "5").startsWith("Error:"));
|
|
verify(conversationService, never()).listRecentMessages(anyString(), anyInt());
|
|
}
|
|
|
|
@Test
|
|
void outputWithoutSavedWorkerReplyClearlyIdentifiesFallbackSummary() {
|
|
callerIs(LEAD_ID);
|
|
TeamTaskEntity task = savedWorkerTask();
|
|
when(conversationService.listRecentMessages(task.getConversationId(), 1)).thenReturn(List.of());
|
|
|
|
String output = invoke("output", "5");
|
|
|
|
assertTrue(output.contains("Full worker output unavailable"));
|
|
assertTrue(output.contains("short board summary"));
|
|
}
|
|
|
|
@Test
|
|
void fullTaskRecordExposesDependenciesAndDeliverablesForOmittedContext() {
|
|
callerIs(MEMBER_ID);
|
|
TeamTaskEntity task = task(5L, TeamTaskStatus.COMPLETED);
|
|
task.setBlockedBy("[11,12,13,14,15,16,17,18,19]");
|
|
when(taskService.getTask(5L)).thenReturn(task);
|
|
when(taskService.listDeliverables(task)).thenReturn(List.of(
|
|
new TeamTaskService.Deliverable("report.docx", "/api/v1/files/generated/report", null)));
|
|
|
|
String output = invoke("get", "5");
|
|
|
|
assertTrue(output.contains("Dependencies (task ids): [11, 12, 13, 14, 15, 16, 17, 18, 19]"));
|
|
assertTrue(output.contains("report.docx → /api/v1/files/generated/report"));
|
|
}
|
|
|
|
@Test
|
|
void outputAcceptsTheSameBoundedLegacyParentFallbackAsTranscriptGovernance() {
|
|
callerIs(LEAD_ID);
|
|
TeamTaskEntity task = savedWorkerTask();
|
|
ConversationEntity worker = conversationService.findByConversationId(task.getConversationId());
|
|
worker.setConversationKind("primary");
|
|
worker.setParentConversationId(null);
|
|
MessageEntity reply = new MessageEntity();
|
|
reply.setRole("assistant");
|
|
reply.setContent("legacy saved worker result");
|
|
when(conversationService.listRecentMessages(task.getConversationId(), 1)).thenReturn(List.of(reply));
|
|
|
|
assertTrue(invoke("output", "5").endsWith("legacy saved worker result"));
|
|
}
|
|
|
|
@Test
|
|
void modernWorkerWithMissingParentCannotUseLegacyFallback() {
|
|
callerIs(LEAD_ID);
|
|
TeamTaskEntity task = savedWorkerTask();
|
|
conversationService.findByConversationId(task.getConversationId()).setParentConversationId(null);
|
|
|
|
assertTrue(invoke("output", "5").contains("linkage does not match"));
|
|
verify(conversationService, never()).listRecentMessages(anyString(), anyInt());
|
|
}
|
|
|
|
// ==================== context & membership gating ====================
|
|
|
|
@Test
|
|
@DisplayName("no conversation context yields a structured error")
|
|
void noContextError() {
|
|
ToolExecutionContext.clear();
|
|
assertTrue(invoke("list", null).startsWith("Error: no conversation context"));
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("an agent outside any team is refused")
|
|
void nonTeamAgentRefused() {
|
|
ConversationEntity conv = new ConversationEntity();
|
|
conv.setConversationId(CONV);
|
|
conv.setAgentId(99L);
|
|
conv.setWorkspaceId(WORKSPACE_ID);
|
|
when(conversationService.findByConversationId(CONV)).thenReturn(conv);
|
|
when(teamService.getTeamForAgent(99L)).thenReturn(Optional.empty());
|
|
|
|
assertTrue(invoke("list", null).contains("not part of any agent team"));
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("unknown action lists the valid ones")
|
|
void unknownAction() {
|
|
callerIs(LEAD_ID);
|
|
String output = invoke("destroy", null);
|
|
assertTrue(output.contains("unknown action"));
|
|
assertTrue(output.contains("start_run"));
|
|
assertTrue(output.contains("seal_run"));
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("a conversation without workspace context yields a structured error")
|
|
void missingWorkspaceError() {
|
|
ConversationEntity conv = new ConversationEntity();
|
|
conv.setConversationId(CONV);
|
|
conv.setAgentId(LEAD_ID);
|
|
when(conversationService.findByConversationId(CONV)).thenReturn(conv);
|
|
|
|
assertTrue(invoke("list", null).contains("workspaceId"));
|
|
}
|
|
|
|
// ==================== role gating ====================
|
|
|
|
@Test
|
|
@DisplayName("member create is refused — only the lead delegates")
|
|
void memberCannotCreate() {
|
|
callerIs(MEMBER_ID);
|
|
String out = tool.team_tasks("create", null, "subj", "desc",
|
|
null, null, null, String.valueOf(MEMBER_ID), null, null, null, null, null, null,
|
|
null, null, null, null, null);
|
|
assertTrue(out.contains("only the team lead can create"));
|
|
verify(taskService, never()).createTask(any());
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("member cancel and retry are refused")
|
|
void memberCannotCancelOrRetry() {
|
|
callerIs(MEMBER_ID);
|
|
when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.PENDING));
|
|
assertTrue(invoke("cancel", "5").contains("only the team lead"));
|
|
assertTrue(invoke("retry", "5").contains("only the team lead"));
|
|
verify(taskService, never()).cancelTask(any(), any());
|
|
verify(taskService, never()).retryTask(any());
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("members cannot start or seal runs")
|
|
void memberCannotStartOrSealRuns() {
|
|
callerIs(MEMBER_ID);
|
|
|
|
String start = tool.team_tasks("start_run", null, null, "Run", "Objective",
|
|
null, null, null, null, null, null, null, null, null, null, null, null, null, null);
|
|
String seal = tool.team_tasks("seal_run", null, String.valueOf(RUN_ID), null, null,
|
|
null, null, null, null, null, null, null, null, null, null, null, null, null, null);
|
|
|
|
assertTrue(start.contains("only the team lead"));
|
|
assertTrue(seal.contains("only the team lead"));
|
|
verifyNoInteractions(runService);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("start_run keeps the explicit origin id after later conversation activity")
|
|
void startRunUsesExplicitOriginMessage() {
|
|
callerIs(LEAD_ID);
|
|
when(runService.startRun(any())).thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING));
|
|
ToolContext originalTurn = ChatOrigin.web(CONV, "admin", WORKSPACE_ID, null)
|
|
.withOriginMessageId(99L)
|
|
.toToolContext();
|
|
|
|
String output = tool.team_tasks("start_run", null, null, "Research", "Find evidence",
|
|
null, null, null, null, null, null, null, null, null, null, null, null, null,
|
|
originalTurn);
|
|
|
|
assertEquals(String.valueOf(RUN_ID), output);
|
|
ArgumentCaptor<TeamRunCreateCommand> captor = ArgumentCaptor.forClass(TeamRunCreateCommand.class);
|
|
verify(runService).startRun(captor.capture());
|
|
TeamRunCreateCommand command = captor.getValue();
|
|
assertEquals(TEAM_ID, command.getTeamId());
|
|
assertEquals(WORKSPACE_ID, command.getWorkspaceId());
|
|
assertEquals(LEAD_ID, command.getLeadAgentId());
|
|
assertEquals(CONV, command.getLeadConversationId());
|
|
assertEquals(99L, command.getOriginMessageId());
|
|
assertEquals("Research", command.getTitle());
|
|
assertEquals("Find evidence", command.getObjective());
|
|
}
|
|
|
|
// ==================== create pass-through ====================
|
|
|
|
@Test
|
|
@DisplayName("lead create parses ids, wires the lead conversation and reports the assignee")
|
|
void leadCreatePassesThrough() {
|
|
callerIs(LEAD_ID);
|
|
when(runService.requireRun(RUN_ID, WORKSPACE_ID))
|
|
.thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING));
|
|
TeamTaskEntity created = task(50L, TeamTaskStatus.PENDING);
|
|
when(taskService.createTask(any())).thenReturn(created);
|
|
AgentEntity member = new AgentEntity();
|
|
member.setName("写手");
|
|
when(agentMapper.selectById(MEMBER_ID)).thenReturn(member);
|
|
|
|
String out = tool.team_tasks("create", null, String.valueOf(RUN_ID), null, null,
|
|
"collect data", "step details", String.valueOf(MEMBER_ID), "11,12", 5,
|
|
null, null, null, null, null, null, null, null, null);
|
|
|
|
assertTrue(out.startsWith("✓ Created task #3"));
|
|
assertTrue(out.contains("写手"));
|
|
ArgumentCaptor<TeamTaskCreateCommand> captor =
|
|
ArgumentCaptor.forClass(TeamTaskCreateCommand.class);
|
|
verify(taskService).createTask(captor.capture());
|
|
TeamTaskCreateCommand cmd = captor.getValue();
|
|
assertEquals(MEMBER_ID, cmd.getAssigneeAgentId());
|
|
assertEquals(List.of(11L, 12L), cmd.getBlockedBy());
|
|
assertEquals(LEAD_ID, cmd.getCreatedByAgentId());
|
|
assertEquals(CONV, cmd.getLeadConversationId());
|
|
assertEquals(RUN_ID, cmd.getRunId());
|
|
verify(dispatchService, never()).requestDispatch(any());
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("creating a blocked task does not trigger a dispatch sweep")
|
|
void blockedCreateDoesNotDispatch() {
|
|
callerIs(LEAD_ID);
|
|
when(runService.requireRun(RUN_ID, WORKSPACE_ID))
|
|
.thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING));
|
|
TeamTaskEntity blocked = task(51L, TeamTaskStatus.BLOCKED);
|
|
when(taskService.createTask(any())).thenReturn(blocked);
|
|
|
|
tool.team_tasks("create", null, String.valueOf(RUN_ID), null, null, "later step", null,
|
|
String.valueOf(MEMBER_ID), "50", null, null, null, null, null, null, null,
|
|
null, null, null);
|
|
|
|
verify(dispatchService, never()).requestDispatch(any());
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("lead create passes requireApproval through to the command")
|
|
void createPassesRequireApproval() {
|
|
callerIs(LEAD_ID);
|
|
when(runService.requireRun(RUN_ID, WORKSPACE_ID))
|
|
.thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING));
|
|
when(taskService.createTask(any())).thenReturn(task(52L, TeamTaskStatus.PENDING));
|
|
|
|
tool.team_tasks("create", null, String.valueOf(RUN_ID), null, null, "publish notes", null,
|
|
String.valueOf(MEMBER_ID), null, null, true, null, null, null, null, null,
|
|
null, null, null);
|
|
|
|
ArgumentCaptor<TeamTaskCreateCommand> captor =
|
|
ArgumentCaptor.forClass(TeamTaskCreateCommand.class);
|
|
verify(taskService).createTask(captor.capture());
|
|
assertTrue(captor.getValue().isRequireApproval());
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("file-producing create task records a required deliverable contract")
|
|
void createInfersRequiredDeliverableFromDescription() {
|
|
callerIs(LEAD_ID);
|
|
when(runService.requireRun(RUN_ID, WORKSPACE_ID))
|
|
.thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING));
|
|
when(taskService.createTask(any())).thenReturn(task(53L, TeamTaskStatus.PENDING));
|
|
|
|
tool.team_tasks("create", null, String.valueOf(RUN_ID), null, null,
|
|
"生成报告", "请生成最终 DOCX 文件", String.valueOf(MEMBER_ID), null, null,
|
|
null, null, null, null, null, null, null, null, null);
|
|
|
|
ArgumentCaptor<TeamTaskCreateCommand> captor =
|
|
ArgumentCaptor.forClass(TeamTaskCreateCommand.class);
|
|
verify(taskService).createTask(captor.capture());
|
|
assertTrue(captor.getValue().getMetadata().contains("\"deliverableRequired\":true"));
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("create requires an explicit run id")
|
|
void createRequiresRunId() {
|
|
callerIs(LEAD_ID);
|
|
|
|
String output = tool.team_tasks("create", null, null, null, null, "subject", "details",
|
|
String.valueOf(MEMBER_ID), null, null, null, null, null, null, null, null,
|
|
null, null, null);
|
|
|
|
assertTrue(output.contains("runId is required"));
|
|
verify(taskService, never()).createTask(any());
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("create rejects a run owned by another team")
|
|
void createRejectsForeignRun() {
|
|
callerIs(LEAD_ID);
|
|
when(runService.requireRun(RUN_ID, WORKSPACE_ID))
|
|
.thenReturn(run(999L, CONV, TeamRunStatus.PLANNING));
|
|
|
|
String output = tool.team_tasks("create", null, String.valueOf(RUN_ID), null, null,
|
|
"subject", "details", String.valueOf(MEMBER_ID), null, null, null, null,
|
|
null, null, null, null, null, null, null);
|
|
|
|
assertTrue(output.contains("runId"));
|
|
verify(taskService, never()).createTask(any());
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("create rejects a run owned by another lead conversation")
|
|
void createRejectsForeignConversationRun() {
|
|
callerIs(LEAD_ID);
|
|
when(runService.requireRun(RUN_ID, WORKSPACE_ID))
|
|
.thenReturn(run(TEAM_ID, "other-conversation", TeamRunStatus.PLANNING));
|
|
|
|
String output = tool.team_tasks("create", null, String.valueOf(RUN_ID), null, null,
|
|
"subject", "details", String.valueOf(MEMBER_ID), null, null, null, null,
|
|
null, null, null, null, null, null, null);
|
|
|
|
assertTrue(output.contains("lead conversation"));
|
|
verify(taskService, never()).createTask(any());
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("seal_run dispatches once only after the run is sealed")
|
|
void sealRunDispatchesAfterSeal() {
|
|
callerIs(LEAD_ID);
|
|
TeamRunEntity planning = run(TEAM_ID, CONV, TeamRunStatus.PLANNING);
|
|
TeamRunEntity running = run(TEAM_ID, CONV, TeamRunStatus.RUNNING);
|
|
when(runService.requireRun(RUN_ID, WORKSPACE_ID)).thenReturn(planning);
|
|
when(runService.sealRunWithResult(RUN_ID, WORKSPACE_ID))
|
|
.thenReturn(new TeamRunService.SealResult(running, true));
|
|
|
|
String output = tool.team_tasks("seal_run", null, String.valueOf(RUN_ID), null, null,
|
|
null, null, null, null, null, null, null, null, null, null, null, null, null, null);
|
|
|
|
assertTrue(output.contains("sealed"));
|
|
InOrder order = inOrder(runService, dispatchService);
|
|
order.verify(runService).sealRunWithResult(RUN_ID, WORKSPACE_ID);
|
|
order.verify(dispatchService).requestDispatch(TEAM_ID);
|
|
verify(dispatchService, times(1)).requestDispatch(TEAM_ID);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("repeated seal_run does not dispatch again")
|
|
void repeatedSealRunDoesNotDispatch() {
|
|
callerIs(LEAD_ID);
|
|
TeamRunEntity running = run(TEAM_ID, CONV, TeamRunStatus.RUNNING);
|
|
when(runService.requireRun(RUN_ID, WORKSPACE_ID)).thenReturn(running);
|
|
when(runService.sealRunWithResult(RUN_ID, WORKSPACE_ID))
|
|
.thenReturn(new TeamRunService.SealResult(running, false));
|
|
|
|
String output = tool.team_tasks("seal_run", null, String.valueOf(RUN_ID), null, null,
|
|
null, null, null, null, null, null, null, null, null, null, null, null, null, null);
|
|
|
|
assertTrue(output.contains("already sealed"));
|
|
verify(dispatchService, never()).requestDispatch(any());
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("seal_run does not dispatch when sealing fails")
|
|
void sealRunFailureDoesNotDispatch() {
|
|
callerIs(LEAD_ID);
|
|
when(runService.requireRun(RUN_ID, WORKSPACE_ID))
|
|
.thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING));
|
|
when(runService.sealRunWithResult(RUN_ID, WORKSPACE_ID))
|
|
.thenThrow(new IllegalStateException("cannot seal a team run without tasks"));
|
|
|
|
String output = tool.team_tasks("seal_run", null, String.valueOf(RUN_ID), null, null,
|
|
null, null, null, null, null, null, null, null, null, null, null, null, null, null);
|
|
|
|
assertTrue(output.startsWith("Error:"));
|
|
verify(dispatchService, never()).requestDispatch(any());
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("lead cancel interrupts the running member conversation")
|
|
void cancelInterruptsRun() {
|
|
callerIs(LEAD_ID);
|
|
TeamTaskEntity running = task(5L, TeamTaskStatus.IN_PROGRESS);
|
|
running.setConversationId("team-task-abc");
|
|
when(taskService.getTask(5L)).thenReturn(running);
|
|
when(taskService.cancelTask(eq(5L), any())).thenReturn(List.of(6L));
|
|
|
|
String out = invoke("cancel", "5");
|
|
|
|
assertTrue(out.startsWith("✓ Task cancelled"));
|
|
verify(dispatchService).interruptRun(running);
|
|
verify(dispatchService).requestDispatch(TEAM_ID);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("service validation errors surface as Error: strings, not exceptions")
|
|
void serviceErrorsBecomeStrings() {
|
|
callerIs(LEAD_ID);
|
|
when(taskService.createTask(any()))
|
|
.thenThrow(new IllegalArgumentException("assignee is required"));
|
|
when(runService.requireRun(RUN_ID, WORKSPACE_ID))
|
|
.thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING));
|
|
String out = tool.team_tasks("create", null, String.valueOf(RUN_ID), null, null,
|
|
"s", null, String.valueOf(MEMBER_ID), null, null, null, null, null, null,
|
|
null, null, null, null, null);
|
|
assertEquals("Error: assignee is required", out);
|
|
}
|
|
|
|
// ==================== member execution actions ====================
|
|
|
|
@Test
|
|
void completionAuditFailureEscapesToolErrorFormatting() {
|
|
callerIs(MEMBER_ID);
|
|
when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS));
|
|
when(taskService.completeTask(5L, MEMBER_ID, "done"))
|
|
.thenThrow(new IllegalStateException("wrapper", new DecisionRecordingException()));
|
|
assertThrows(DecisionRecordingException.class, () -> tool.team_tasks("complete", "5", null, null,
|
|
null, null, null, null, null, null, null, "done", null, null, null, null, null, null, null));
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("complete requires a result and reports released dependents")
|
|
void completeReportsRelease() {
|
|
callerIs(MEMBER_ID);
|
|
when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.COMPLETED));
|
|
when(taskService.completeTask(5L, MEMBER_ID, "done, see report"))
|
|
.thenReturn(List.of(6L));
|
|
|
|
assertTrue(invoke("complete", "5").startsWith("Error: result is required"));
|
|
|
|
String ok = tool.team_tasks("complete", "5", null, null, null, null, null, null, null,
|
|
null, null, "done, see report", null, null, null, null, null, null, null);
|
|
assertTrue(ok.contains("Released 1 dependent task(s)"));
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("a blocker comment tells the member to stop working")
|
|
void blockerCommentStops() {
|
|
callerIs(MEMBER_ID);
|
|
when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS));
|
|
when(taskService.addComment(eq(5L), eq(TeamTaskService.AUTHOR_AGENT),
|
|
anyString(), eq("blocker"), anyString())).thenReturn(true);
|
|
|
|
String out = tool.team_tasks("comment", "5", null, null, null, null, null, null, null,
|
|
null, null, null, null, null, "missing credentials", "blocker", null, null, null);
|
|
assertTrue(out.contains("stop working"));
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("attach registers a deliverable and reminds the member to keep the result a summary")
|
|
void attachRegistersDeliverable() {
|
|
callerIs(MEMBER_ID);
|
|
when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS));
|
|
|
|
String out = tool.team_tasks("attach", "5", null, null, null, null, null, null, null,
|
|
null, null, null, null, null, null, null,
|
|
"report.docx", "/api/v1/files/generated/abc", null);
|
|
|
|
assertTrue(out.startsWith("✓ Deliverable attached: report.docx"));
|
|
verify(taskService).addDeliverable(5L, MEMBER_ID, "report.docx",
|
|
"/api/v1/files/generated/abc");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("a task from another team is invisible")
|
|
void foreignTaskRejected() {
|
|
callerIs(MEMBER_ID);
|
|
TeamTaskEntity foreign = task(5L, TeamTaskStatus.PENDING);
|
|
foreign.setTeamId(999L);
|
|
when(taskService.getTask(5L)).thenReturn(foreign);
|
|
|
|
assertTrue(invoke("get", "5").contains("not found on this team's board"));
|
|
}
|
|
}
|