feat(team): team_tasks tool with role gating and team context injection into agent prompts

This commit is contained in:
matevip 2026-07-24 17:38:02 +08:00
parent 86e65beafe
commit 8fa10e3769
9 changed files with 823 additions and 1 deletions

View File

@ -60,6 +60,7 @@ import vip.mate.tool.guard.service.ToolGuardService;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.team.service.TeamContextBuilder;
import vip.mate.wiki.service.WikiContextService;
import java.lang.reflect.Field;
@ -101,6 +102,7 @@ public class AgentGraphBuilder {
"${mate.agent.markdown-normalize-enabled:true}")
private boolean markdownNormalizeEnabled;
private final ConversationService conversationService;
private final TeamContextBuilder teamContextBuilder;
private final ModelConfigService modelConfigService;
private final ModelProviderService modelProviderService;
private final ModelContextWindowResolver contextWindowResolver;
@ -1751,7 +1753,12 @@ public class AgentGraphBuilder {
Integer wikiBudgetTokens = memoryBudgetTokens == Integer.MAX_VALUE ? null : memoryBudgetTokens;
String wikiContext = wikiContextService.buildWikiContext(entity.getId(), wikiBudgetTokens);
return basePrompt + ABOUT_YOU_BLOCK + toolGuidance + searchGuidance + wikiContext;
// Team context (role-specific board playbook, or a negative notice for
// agents outside any team). Baked here so it shares the prompt-cache
// prefix; TeamChangedEvent evicts the cached agent on composition changes.
String teamContext = teamContextBuilder.buildTeamContext(entity.getId());
return basePrompt + ABOUT_YOU_BLOCK + toolGuidance + searchGuidance + wikiContext + teamContext;
}
/**

View File

@ -21,6 +21,7 @@ import vip.mate.memory.MemoryProperties;
import vip.mate.memory.lifecycle.MemoryLifecycleMediator;
import vip.mate.memory.lifecycle.TurnContext;
import vip.mate.memory.service.MemoryRecallTracker;
import vip.mate.team.event.TeamChangedEvent;
import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.repository.ConversationMapper;
@ -239,6 +240,18 @@ public class AgentService {
}
}
/**
* Invalidate cached agents whenever their team's composition or settings
* change. The team context block is baked into the system prompt at build
* time, so membership edits would otherwise stay invisible until restart.
*/
@EventListener
public void onTeamChanged(TeamChangedEvent event) {
if (event.agentIds() != null) {
event.agentIds().forEach(agentInstances::remove);
}
}
// ==================== 运行时入口 ====================
public String chat(Long agentId, String message, String conversationId) {

View File

@ -0,0 +1,110 @@
package vip.mate.team.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.AgentTeamMemberEntity;
import java.util.List;
import java.util.Optional;
/**
* Renders the team context block ("TEAM.md") appended to an agent's system
* prompt. The lead receives the full orchestration playbook; members receive
* execution-focused instructions; agents outside any team receive a one-line
* negative notice so the model never probes the team_tasks tool speculatively.
*
* The block is baked into the cached agent instance; {@code TeamChangedEvent}
* evicts affected agents so composition changes surface on the next turn.
*
* @author MateClaw Team
*/
@Component
@RequiredArgsConstructor
public class TeamContextBuilder {
static final String NO_TEAM_NOTICE = """
## Team
You are not part of any agent team. Do NOT call the team_tasks tool.
""";
private final TeamService teamService;
private final AgentMapper agentMapper;
/** Build the team context block for the given agent; never returns null. */
public String buildTeamContext(Long agentId) {
Optional<AgentTeamEntity> teamOpt = teamService.getTeamForAgent(agentId);
if (teamOpt.isEmpty()) {
return NO_TEAM_NOTICE;
}
AgentTeamEntity team = teamOpt.get();
List<AgentTeamMemberEntity> members = teamService.listMembers(team.getId());
boolean isLead = teamService.isLead(team, agentId);
StringBuilder sb = new StringBuilder(2048);
sb.append("\n\n## Team: ").append(team.getName()).append('\n');
if (team.getDescription() != null && !team.getDescription().isBlank()) {
sb.append(team.getDescription()).append('\n');
}
sb.append("Your role: ").append(isLead ? "LEAD — you orchestrate this team." : "MEMBER.")
.append('\n');
sb.append("""
### Members
This is the complete and authoritative list of your team. Do NOT use tools to verify it.
""");
for (AgentTeamMemberEntity member : members) {
AgentEntity agent = agentMapper.selectById(member.getAgentId());
String name = agent != null && agent.getName() != null ? agent.getName()
: String.valueOf(member.getAgentId());
sb.append("- **").append(name).append("** (agentId: ").append(member.getAgentId())
.append(", ").append(member.getRole()).append(')');
if (member.getAgentId().equals(agentId)) {
sb.append(" — you");
} else if (agent != null && agent.getDescription() != null
&& !agent.getDescription().isBlank()) {
sb.append(": ").append(agent.getDescription().strip());
}
sb.append('\n');
}
sb.append(isLead ? leadPlaybook() : memberPlaybook());
return sb.toString();
}
private static String leadPlaybook() {
return """
### Delegation workflow (mandatory)
- Delegate work by creating tasks on the team board: `team_tasks(action="create", subject=..., description=..., assigneeAgentId=...)`. Every delegation MUST go through the board never pretend a teammate did something without a task backing it.
- Check the board FIRST: call `team_tasks(action="list")` before creating tasks so you never create duplicates.
- Create ALL tasks for the request up front in one batch. Order dependent work with `blockedBy` (ids of prerequisite tasks). Then announce the assignments to the user and STOP do not keep reasoning while members work.
- Delegation is NOT completion. After creating tasks, never say the work is "done" or "finished"; say it has been assigned and results will follow.
- Never assign a task to yourself the lead orchestrates, members execute.
- Task sizing: one task = one specific action producing one output. Split a task if it needs two different skills; do not over-split mechanical steps.
- If a prerequisite task is already completed, pass its result inside the new task's description instead of blocking on it.
### When results arrive
Member results are delivered to you as system messages in this conversation. Review them, cross-check against the original request, then synthesize ONE coherent reply for the user. Do not forward raw member output unedited.
### Handling blockers
When a member reports a blocker the task auto-fails and you are notified with the reason. Resolve the missing input (provide context, adjust the description), then re-dispatch it with `team_tasks(action="retry", taskId=...)`, or cancel it with `action="cancel"` if it is no longer needed.
""";
}
private static String memberPlaybook() {
return """
### Working on assigned tasks
- When a task is dispatched to you, focus entirely on executing it. Your final reply becomes the task result and is reported back to the lead automatically.
- Report meaningful milestones with `team_tasks(action="progress", taskId=..., percent=..., step=...)`. The taskId is included in the dispatch message.
- Leave findings other teammates may need as comments: `team_tasks(action="comment", taskId=..., text=...)`.
- If you cannot proceed (missing input, unclear scope, failed dependency), report it with `team_tasks(action="comment", taskId=..., type="blocker", text="what you need")`. This fails the task and notifies the lead do NOT silently improvise around a blocker.
- You may inspect the board with `action="list"` or `action="get"` for context, but do not create or cancel tasks.
""";
}
}

View File

@ -0,0 +1,320 @@
package vip.mate.team.tool;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.TeamTaskCommentEntity;
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.TeamService;
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 java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Shared team task board exposed to the LLM. One multi-action tool (rather
* than one tool per action) keeps the schema compact and mirrors how the
* model already phrases board operations as an action verb plus fields.
*
* Role gating: only the lead creates/cancels/retries tasks; members complete,
* report progress, and comment; everyone reads. All errors return structured
* strings written for LLM self-correction, never exceptions.
*
* @author MateClaw Team
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class TeamTasksTool {
private final TeamService teamService;
private final TeamTaskService taskService;
private final TeamDispatchService dispatchService;
private final ConversationService conversationService;
private final AgentMapper agentMapper;
@Tool(description = "Operate your team's shared task board. Actions: "
+ "'list' all tasks; 'get' one task with comments (taskId); "
+ "'create' a task (lead only; subject, description, assigneeAgentId required, "
+ "optional blockedBy comma-separated prerequisite task ids, priority, higher first); "
+ "'complete' a task with its result summary (taskId, result); "
+ "'progress' to report execution progress (taskId, percent 0-100, step); "
+ "'comment' to leave a note, or type='blocker' when you are stuck and need the lead "
+ "(taskId, text); 'cancel' (lead only; taskId, text as reason); "
+ "'retry' a failed/stale task back to pending (lead only; taskId). "
+ "Only usable when you belong to an agent team.")
public String team_tasks(
@ToolParam(description = "One of: list, get, create, complete, progress, comment, cancel, retry")
String action,
@ToolParam(description = "Task id (string form is fine) — required by every action except list/create", required = false)
String taskId,
@ToolParam(description = "create: short task title", required = false)
String subject,
@ToolParam(description = "create: full task instructions; include every input the member needs — members do not see this conversation", required = false)
String description,
@ToolParam(description = "create: agentId of the member who should execute the task", required = false)
String assigneeAgentId,
@ToolParam(description = "create: comma-separated ids of tasks that must finish first", required = false)
String blockedBy,
@ToolParam(description = "create: priority, higher dispatches first (default 0)", required = false)
Integer priority,
@ToolParam(description = "complete: result summary reported back to the lead", required = false)
String result,
@ToolParam(description = "progress: completion percent 0-100", required = false)
Integer percent,
@ToolParam(description = "progress: one-line description of the current step", required = false)
String step,
@ToolParam(description = "comment/cancel: comment text or cancellation reason", required = false)
String text,
@ToolParam(description = "comment: 'note' (default) or 'blocker' to escalate to the lead", required = false)
String type,
@Nullable ToolContext ctx) {
String conversationId = ToolExecutionContext.conversationId(ctx);
if (conversationId == null || conversationId.isBlank()) {
return "Error: no conversation context bound to this call.";
}
ConversationEntity conversation = conversationService.findByConversationId(conversationId);
if (conversation == null || conversation.getAgentId() == null) {
return "Error: cannot resolve the calling agent for this conversation.";
}
Long agentId = conversation.getAgentId();
Optional<AgentTeamEntity> teamOpt = teamService.getTeamForAgent(agentId);
if (teamOpt.isEmpty()) {
return "Error: you are not part of any agent team; team_tasks is unavailable.";
}
AgentTeamEntity team = teamOpt.get();
boolean isLead = teamService.isLead(team, agentId);
try {
return switch (action == null ? "" : action) {
case "list" -> renderBoard(team);
case "get" -> renderDetail(team, parseId(taskId, "taskId"));
case "create" -> createTask(team, agentId, isLead, subject, description,
assigneeAgentId, blockedBy, priority, conversationId);
case "complete" -> completeTask(team, agentId, parseId(taskId, "taskId"), result);
case "progress" -> progress(team, agentId, parseId(taskId, "taskId"), percent, step);
case "comment" -> comment(team, agentId, parseId(taskId, "taskId"), type, text);
case "cancel" -> cancel(team, isLead, parseId(taskId, "taskId"), text);
case "retry" -> retry(team, isLead, parseId(taskId, "taskId"));
default -> "Error: unknown action '" + action
+ "'. Use one of: list, get, create, complete, progress, comment, cancel, retry.";
};
} catch (IllegalArgumentException | IllegalStateException e) {
return "Error: " + e.getMessage();
} catch (Exception e) {
log.warn("team_tasks {} failed for team={} agent={}: {}",
action, team.getId(), agentId, e.getMessage());
return "Error: team_tasks failed — " + e.getMessage();
}
}
// ==================== actions ====================
private String createTask(AgentTeamEntity team, Long agentId, boolean isLead,
String subject, String description, String assigneeAgentId,
String blockedBy, Integer priority, String conversationId) {
if (!isLead) {
return "Error: only the team lead can create tasks. Report blockers or ask the "
+ "lead via a comment on your current task instead.";
}
TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder()
.teamId(team.getId())
.subject(subject)
.description(description)
.assigneeAgentId(parseId(assigneeAgentId, "assigneeAgentId"))
.createdByAgentId(agentId)
.priority(priority)
.blockedBy(parseIdList(blockedBy))
.leadConversationId(conversationId)
.build());
if (TeamTaskStatus.PENDING.equals(task.getStatus())) {
dispatchService.requestDispatch(team.getId());
}
return "✓ Created task #" + task.getTaskNumber() + " (id: " + task.getId()
+ ") \"" + task.getSubject() + "\" assigned to " + agentName(task.getAssigneeAgentId())
+ ". Status: " + task.getStatus()
+ (TeamTaskStatus.BLOCKED.equals(task.getStatus())
? " (starts automatically once its prerequisites finish)." : ".")
+ " Members are dispatched automatically — do not wait in this turn.";
}
private String completeTask(AgentTeamEntity team, Long agentId, Long taskId, String result) {
requireTaskInTeam(team, taskId);
if (result == null || result.isBlank()) {
return "Error: result is required — summarize what was produced.";
}
List<Long> released = taskService.completeTask(taskId, agentId, result);
if (!released.isEmpty()) {
dispatchService.requestDispatch(team.getId());
}
TeamTaskEntity task = taskService.getTask(taskId);
StringBuilder sb = new StringBuilder("✓ Task #" + task.getTaskNumber() + " "
+ task.getStatus() + ".");
if (TeamTaskStatus.IN_REVIEW.equals(task.getStatus())) {
sb.append(" It awaits human approval before counting as done.");
}
if (!released.isEmpty()) {
sb.append(" Released ").append(released.size()).append(" dependent task(s).");
}
return sb.toString();
}
private String progress(AgentTeamEntity team, Long agentId, Long taskId,
Integer percent, String step) {
requireTaskInTeam(team, taskId);
if (percent != null && (percent < 0 || percent > 100)) {
return "Error: percent must be between 0 and 100.";
}
boolean ok = taskService.updateProgress(taskId, agentId, percent, step);
return ok ? "✓ Progress recorded."
: "Error: task is not in progress under your ownership; progress not recorded.";
}
private String comment(AgentTeamEntity team, Long agentId, Long taskId,
String type, String text) {
requireTaskInTeam(team, taskId);
if (text == null || text.isBlank()) {
return "Error: text is required for a comment.";
}
boolean escalated = taskService.addComment(taskId, TeamTaskService.AUTHOR_AGENT,
String.valueOf(agentId), type, text);
return escalated
? "✓ Blocker recorded. The task is now failed and the lead has been notified — stop working on it."
: "✓ Comment added.";
}
private String cancel(AgentTeamEntity team, boolean isLead, Long taskId, String reason) {
if (!isLead) {
return "Error: only the team lead can cancel tasks.";
}
requireTaskInTeam(team, taskId);
taskService.cancelTask(taskId, reason);
return "✓ Task cancelled.";
}
private String retry(AgentTeamEntity team, boolean isLead, Long taskId) {
if (!isLead) {
return "Error: only the team lead can retry tasks.";
}
requireTaskInTeam(team, taskId);
if (!taskService.retryTask(taskId)) {
return "Error: only failed or stale tasks can be retried.";
}
dispatchService.requestDispatch(team.getId());
return "✓ Task reset to pending; it will be re-dispatched.";
}
// ==================== rendering ====================
private String renderBoard(AgentTeamEntity team) {
List<TeamTaskEntity> tasks = taskService.listTasks(team.getId(), null);
if (tasks.isEmpty()) {
return "The task board is empty.";
}
StringBuilder sb = new StringBuilder("Task board for team \"")
.append(team.getName()).append("\" (").append(tasks.size()).append(" tasks):\n");
for (TeamTaskEntity task : tasks) {
sb.append("- #").append(task.getTaskNumber())
.append(" [").append(task.getStatus()).append("] ")
.append(task.getSubject())
.append(" (id: ").append(task.getId())
.append(", assignee: ").append(agentName(task.getAssigneeAgentId()));
if (task.getProgressPercent() != null
&& TeamTaskStatus.IN_PROGRESS.equals(task.getStatus())) {
sb.append(", ").append(task.getProgressPercent()).append('%');
}
sb.append(")\n");
}
return sb.toString();
}
private String renderDetail(AgentTeamEntity team, Long taskId) {
TeamTaskEntity task = requireTaskInTeam(team, taskId);
StringBuilder sb = new StringBuilder(512);
sb.append("Task #").append(task.getTaskNumber())
.append(" (id: ").append(task.getId()).append(")\n")
.append("Subject: ").append(task.getSubject()).append('\n')
.append("Status: ").append(task.getStatus()).append('\n')
.append("Assignee: ").append(agentName(task.getAssigneeAgentId())).append('\n');
if (task.getDescription() != null && !task.getDescription().isBlank()) {
sb.append("Description: ").append(task.getDescription()).append('\n');
}
if (task.getProgressStep() != null) {
sb.append("Progress: ").append(task.getProgressPercent() == null ? "?"
: task.getProgressPercent()).append("% — ").append(task.getProgressStep()).append('\n');
}
if (task.getResult() != null && !task.getResult().isBlank()) {
sb.append("Result: ").append(task.getResult()).append('\n');
}
if (task.getReason() != null && !task.getReason().isBlank()) {
sb.append("Reason: ").append(task.getReason()).append('\n');
}
List<TeamTaskCommentEntity> comments = taskService.listComments(taskId);
if (!comments.isEmpty()) {
sb.append("Comments:\n");
for (TeamTaskCommentEntity comment : comments) {
sb.append("- [").append(comment.getCommentType()).append("] ")
.append(comment.getAuthorType()).append(' ').append(comment.getAuthorId())
.append(": ").append(comment.getContent()).append('\n');
}
}
return sb.toString();
}
// ==================== helpers ====================
private TeamTaskEntity requireTaskInTeam(AgentTeamEntity team, Long taskId) {
TeamTaskEntity task = taskService.getTask(taskId);
if (task == null || !task.getTeamId().equals(team.getId())) {
throw new IllegalArgumentException("task " + taskId + " not found on this team's board");
}
return task;
}
private String agentName(Long agentId) {
if (agentId == null) {
return "-";
}
AgentEntity agent = agentMapper.selectById(agentId);
return agent != null && agent.getName() != null ? agent.getName() : String.valueOf(agentId);
}
private static Long parseId(String raw, String field) {
if (raw == null || raw.isBlank()) {
throw new IllegalArgumentException(field + " is required");
}
try {
return Long.valueOf(raw.trim());
} catch (NumberFormatException e) {
throw new IllegalArgumentException(field + " must be a numeric id, got: " + raw);
}
}
private static List<Long> parseIdList(String raw) {
if (raw == null || raw.isBlank()) {
return List.of();
}
List<Long> ids = new ArrayList<>();
for (String part : raw.split(",")) {
if (!part.isBlank()) {
ids.add(parseId(part, "blockedBy entry"));
}
}
return ids;
}
}

View File

@ -0,0 +1,6 @@
-- V173: Register the team_tasks built-in tool (shared team task board).
-- (H2 dialect)
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000904, 'TeamTasksTool', '团队任务板', '团队共享任务板lead 用 create 建任务并指派成员(支持 blockedBy 依赖与优先级);成员用 progress 汇报进度、comment 留言type=blocker 时自动失败并升级给 lead、complete 提交结果list/get 查看看板。仅团队成员可用。', 'builtin', 'teamTasksTool', '📋', TRUE, TRUE, NOW(), NOW(), 0);

View File

@ -0,0 +1,6 @@
-- V173: Register the team_tasks built-in tool (shared team task board).
-- (KingbaseES / PostgreSQL dialect)
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000904, 'TeamTasksTool', '团队任务板', '团队共享任务板lead 用 create 建任务并指派成员(支持 blockedBy 依赖与优先级);成员用 progress 汇报进度、comment 留言type=blocker 时自动失败并升级给 lead、complete 提交结果list/get 查看看板。仅团队成员可用。', 'builtin', 'teamTasksTool', '📋', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;

View File

@ -0,0 +1,6 @@
-- V173: Register the team_tasks built-in tool (shared team task board).
-- (MySQL dialect)
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000904, 'TeamTasksTool', '团队任务板', '团队共享任务板lead 用 create 建任务并指派成员(支持 blockedBy 依赖与优先级);成员用 progress 汇报进度、comment 留言type=blocker 时自动失败并升级给 lead、complete 提交结果list/get 查看看板。仅团队成员可用。', 'builtin', 'teamTasksTool', '📋', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);

View File

@ -0,0 +1,113 @@
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.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.AgentTeamMemberEntity;
import vip.mate.team.model.TeamRole;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* Pins the role split of the team prompt block: lead gets the delegation
* playbook, members get execution instructions, non-team agents get the
* negative notice that keeps the model away from team_tasks.
*/
class TeamContextBuilderTest {
private static final Long TEAM_ID = 10L;
private static final Long LEAD_ID = 1L;
private static final Long MEMBER_ID = 2L;
private TeamService teamService;
private AgentMapper agentMapper;
private TeamContextBuilder builder;
private AgentTeamEntity team;
@BeforeEach
void setUp() {
teamService = mock(TeamService.class);
agentMapper = mock(AgentMapper.class);
builder = new TeamContextBuilder(teamService, agentMapper);
team = new AgentTeamEntity();
team.setId(TEAM_ID);
team.setName("内容组");
team.setDescription("负责内容生产");
team.setLeadAgentId(LEAD_ID);
AgentTeamMemberEntity lead = member(LEAD_ID, TeamRole.LEAD);
AgentTeamMemberEntity writer = member(MEMBER_ID, TeamRole.MEMBER);
when(teamService.listMembers(TEAM_ID)).thenReturn(List.of(lead, writer));
AgentEntity leadAgent = agent("主管", "orchestrates");
AgentEntity writerAgent = agent("写手", "writes articles");
when(agentMapper.selectById(LEAD_ID)).thenReturn(leadAgent);
when(agentMapper.selectById(MEMBER_ID)).thenReturn(writerAgent);
}
private static AgentTeamMemberEntity member(Long agentId, String role) {
AgentTeamMemberEntity m = new AgentTeamMemberEntity();
m.setTeamId(TEAM_ID);
m.setAgentId(agentId);
m.setRole(role);
return m;
}
private static AgentEntity agent(String name, String description) {
AgentEntity a = new AgentEntity();
a.setName(name);
a.setDescription(description);
return a;
}
@Test
@DisplayName("agents outside any team get the negative notice only")
void noTeamNegativeNotice() {
when(teamService.getTeamForAgent(99L)).thenReturn(Optional.empty());
String ctx = builder.buildTeamContext(99L);
assertTrue(ctx.contains("not part of any agent team"));
assertFalse(ctx.contains("Delegation workflow"));
}
@Test
@DisplayName("the lead gets the orchestration playbook and the member roster")
void leadGetsPlaybook() {
when(teamService.getTeamForAgent(LEAD_ID)).thenReturn(Optional.of(team));
when(teamService.isLead(team, LEAD_ID)).thenReturn(true);
String ctx = builder.buildTeamContext(LEAD_ID);
assertTrue(ctx.contains("## Team: 内容组"));
assertTrue(ctx.contains("LEAD — you orchestrate"));
assertTrue(ctx.contains("Delegation workflow (mandatory)"));
assertTrue(ctx.contains("Delegation is NOT completion"));
assertTrue(ctx.contains("写手"));
assertTrue(ctx.contains("agentId: " + MEMBER_ID));
// The lead must not receive member execution instructions.
assertFalse(ctx.contains("Working on assigned tasks"));
}
@Test
@DisplayName("a member gets execution instructions, not the delegation playbook")
void memberGetsExecutionRules() {
when(teamService.getTeamForAgent(MEMBER_ID)).thenReturn(Optional.of(team));
when(teamService.isLead(team, MEMBER_ID)).thenReturn(false);
String ctx = builder.buildTeamContext(MEMBER_ID);
assertTrue(ctx.contains("Your role: MEMBER."));
assertTrue(ctx.contains("Working on assigned tasks"));
assertTrue(ctx.contains("type=\"blocker\""));
assertFalse(ctx.contains("Delegation workflow"));
// Self is marked in the roster instead of repeating its description.
assertTrue(ctx.contains("— you"));
}
}

View File

@ -0,0 +1,241 @@
package vip.mate.team.tool;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.team.model.AgentTeamEntity;
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.TeamService;
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 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 TeamService teamService;
private TeamTaskService taskService;
private TeamDispatchService dispatchService;
private ConversationService conversationService;
private AgentMapper agentMapper;
private TeamTasksTool tool;
private AgentTeamEntity team;
@BeforeEach
void setUp() {
teamService = mock(TeamService.class);
taskService = mock(TeamTaskService.class);
dispatchService = mock(TeamDispatchService.class);
conversationService = mock(ConversationService.class);
agentMapper = mock(AgentMapper.class);
tool = new TeamTasksTool(teamService, taskService, dispatchService,
conversationService, agentMapper);
team = new AgentTeamEntity();
team.setId(TEAM_ID);
team.setName("研发组");
team.setLeadAgentId(LEAD_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);
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);
}
// ==================== 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);
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);
assertTrue(invoke("destroy", null).contains("unknown action"));
}
// ==================== 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",
String.valueOf(MEMBER_ID), 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());
}
// ==================== create pass-through ====================
@Test
@DisplayName("lead create parses ids, wires the lead conversation and reports the assignee")
void leadCreatePassesThrough() {
callerIs(LEAD_ID);
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, "collect data", "step details",
String.valueOf(MEMBER_ID), "11,12", 5, 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());
verify(dispatchService).requestDispatch(TEAM_ID);
}
@Test
@DisplayName("creating a blocked task does not trigger a dispatch sweep")
void blockedCreateDoesNotDispatch() {
callerIs(LEAD_ID);
TeamTaskEntity blocked = task(51L, TeamTaskStatus.BLOCKED);
when(taskService.createTask(any())).thenReturn(blocked);
tool.team_tasks("create", null, "later step", null,
String.valueOf(MEMBER_ID), "50", null, null, null, null, null, null, null);
verify(dispatchService, never()).requestDispatch(any());
}
@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"));
String out = tool.team_tasks("create", null, "s", null,
String.valueOf(MEMBER_ID), null, null, null, null, null, null, null, null);
assertEquals("Error: assignee is required", out);
}
// ==================== member execution actions ====================
@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,
"done, see report", 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, "missing credentials", "blocker", null);
assertTrue(out.contains("stop working"));
}
@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"));
}
}